329 lines
12 KiB
Markdown
329 lines
12 KiB
Markdown
|
|
---
|
|||
|
|
name: tighten-scan-identify-hot-path
|
|||
|
|
classification: feature
|
|||
|
|
success_metric: |
|
|||
|
|
After ship, a new scan_attempts window shows L1 escalate ≤40% of L1
|
|||
|
|
(was 76.7%), L1 matched ≥25% of L1 (was 5.8%), and first identify
|
|||
|
|
attempt starts in ≤1.2s of tracking (was 3.5s). L2 p50 stays ≤1.8s.
|
|||
|
|
skip:
|
|||
|
|
- ia
|
|||
|
|
- ui-design
|
|||
|
|
- visual
|
|||
|
|
- flag
|
|||
|
|
status: in-progress
|
|||
|
|
created: 2026-08-14
|
|||
|
|
depends_on:
|
|||
|
|
- add-real-ocr-layer
|
|||
|
|
umbrella: scanner-identify-upgrade
|
|||
|
|
model_policy:
|
|||
|
|
default_session: auto
|
|||
|
|
roles:
|
|||
|
|
role-conductor: composer-2.5-fast
|
|||
|
|
role-architect: composer-2.5
|
|||
|
|
role-ia-architect: composer-2.5-fast
|
|||
|
|
role-ux-reviewer: composer-2.5-fast
|
|||
|
|
role-ui-designer: composer-2.5-fast
|
|||
|
|
role-implementer: composer-2.5-fast
|
|||
|
|
role-reviewer: cursor-grok-4.5-high
|
|||
|
|
role-security-auditor: gpt-5.6-terra-medium
|
|||
|
|
role-design-system-auditor: cursor-grok-4.5-high
|
|||
|
|
role-a11y-auditor: cursor-grok-4.5-high
|
|||
|
|
role-doc-writer: auto
|
|||
|
|
escalate_to: claude-sonnet-5-thinking-medium
|
|||
|
|
escalate_to_premium: claude-4.6-opus-high-thinking
|
|||
|
|
never_premium:
|
|||
|
|
- role-reviewer
|
|||
|
|
- role-security-auditor
|
|||
|
|
- role-design-system-auditor
|
|||
|
|
- role-a11y-auditor
|
|||
|
|
- role-ui-designer
|
|||
|
|
- role-doc-writer
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
# Convoy: tighten-scan-identify-hot-path
|
|||
|
|
|
|||
|
|
Phase 1 of `scanner-identify-upgrade`. Make the **existing** two-layer
|
|||
|
|
pipeline faster and more unique-match capable. No new vendors, no
|
|||
|
|
detection rewrite, no embeddings.
|
|||
|
|
|
|||
|
|
## Why
|
|||
|
|
|
|||
|
|
Live telemetry (see umbrella) says Layer-1 is fast (p50 158ms) and
|
|||
|
|
almost useless as an identifier (5.8% auto-match, 76.7% escalate). The
|
|||
|
|
user still waits 3.5s of hold-still before that cheap path runs. L2
|
|||
|
|
Gemini is 1.7s p50 and spends a second token on every disambiguation
|
|||
|
|
refine. Collector number — the unique printing key L2 already uses —
|
|||
|
|
is never read on L1.
|
|||
|
|
|
|||
|
|
## Scope
|
|||
|
|
|
|||
|
|
### In scope
|
|||
|
|
|
|||
|
|
- **Hold-still constants** in `lib/scanner-card-detection.js`:
|
|||
|
|
`MIN_FIRST_SEEN_MS_FOR_VERIFY` 2500 → ~800;
|
|||
|
|
`MIN_STABLE_COUNT_FOR_VERIFY` 6 → 3. Keep tests in
|
|||
|
|
`test/lib/scanner-card-detection.test.js` in lockstep.
|
|||
|
|
- **Name-strip OCR mode:** Tesseract PSM 7 (single line), JPEG quality
|
|||
|
|
0.92 on the crop used for OCR (`lib/ocr-worker.js`,
|
|||
|
|
`captureCardRegionFromVideo` quality if shared).
|
|||
|
|
- **Collector-number strip:** second crop of the bottom ~18% of the
|
|||
|
|
card; pass number (+ optional set hint) into `matchTextInCatalog`.
|
|||
|
|
When name + number uniquely match a `cards` row, auto-match even if
|
|||
|
|
many printings share the name.
|
|||
|
|
- **Structured Gemini output** in `lib/scan-vision.js`:
|
|||
|
|
`response_format` JSON schema (or Gateway equivalent). Delete the
|
|||
|
|
confidence-30 regex fallback as the success path; keep a hard parse
|
|||
|
|
error → `needs_input`.
|
|||
|
|
- **Model A/B:** default stays `google/gemini-2.5-flash-lite`. Document
|
|||
|
|
and allow `SCAN_VISION_MODEL=google/gemini-3.1-flash-lite` (GA on
|
|||
|
|
Vercel AI Gateway, ~2.5× input price). Do not flip prod default in
|
|||
|
|
this convoy unless Architect + a short identify bake-off say so.
|
|||
|
|
- **Stop automatic L2 refine** in `use-scanner-identification.js` when
|
|||
|
|
L1 already returned a printing list. User picks; Gemini refine is
|
|||
|
|
opt-in or only when L1 had no set/number hint.
|
|||
|
|
- **`docs/SCHEMA_MAP.md`:** `scan_attempts.layer` is 1 = Tesseract
|
|||
|
|
(shipped), not "Tesseract future".
|
|||
|
|
|
|||
|
|
### Out of scope
|
|||
|
|
|
|||
|
|
- Detector / OpenCV / YOLO / perspective warp →
|
|||
|
|
`improve-scan-card-detection`.
|
|||
|
|
- pgvector / CLIP / catalog embeddings →
|
|||
|
|
`scan-visual-catalog-search`.
|
|||
|
|
- Changing `checkScanRateLimit` (5/min). Revisit after re-measure.
|
|||
|
|
- Scanner chrome, cart, checkout (`scanner-mobile-checkout`).
|
|||
|
|
- Replacing `tesseract.js`.
|
|||
|
|
|
|||
|
|
## Roles invoked
|
|||
|
|
|
|||
|
|
1. `role-ux-reviewer` — hold-still feel; when the picker still appears;
|
|||
|
|
no new screens.
|
|||
|
|
2. `role-architect` — ratify constant values, L1 match rules when a
|
|||
|
|
collector number is present, JSON schema shape, whether the model
|
|||
|
|
default flips. Write briefs + `slice_dependencies`.
|
|||
|
|
3. `role-implementer` — per brief.
|
|||
|
|
4. Audit: `role-reviewer` + `role-security-auditor`. Skip design-system
|
|||
|
|
+ a11y unless a brief unexpectedly touches `components/`.
|
|||
|
|
|
|||
|
|
## Todos
|
|||
|
|
|
|||
|
|
- [x] UX: confirm 800ms / 3-frame gate does not cause double-scans
|
|||
|
|
- [x] Architect: briefs + match-rule when number is present
|
|||
|
|
- [x] Brief A — detection constants + tests
|
|||
|
|
- [x] Brief B — OCR strips (name PSM 7 + collector number) +
|
|||
|
|
`card-text-match.js` / `identify-by-text`
|
|||
|
|
- [x] Brief C — `scan-vision.js` JSON schema + optional model id
|
|||
|
|
- [x] Brief D — skip automatic disambiguation Gemini refine
|
|||
|
|
- [x] SCHEMA_MAP layer-1 wording
|
|||
|
|
- [ ] Re-query `scan_attempts` after preview traffic
|
|||
|
|
|
|||
|
|
## Likely file ownership (Architect will lock)
|
|||
|
|
|
|||
|
|
| Area | Files |
|
|||
|
|
| --- | --- |
|
|||
|
|
| Gates | `lib/scanner-card-detection.js`, `test/lib/scanner-card-detection.test.js` |
|
|||
|
|
| OCR | `lib/ocr-worker.js` |
|
|||
|
|
| L1 match | `lib/card-text-match.js`, `pages/api/cards/identify-by-text.js` |
|
|||
|
|
| L2 vision | `lib/scan-vision.js`, `pages/api/scan/identify.js` |
|
|||
|
|
| Refine | `lib/use-scanner-identification.js`, `lib/scanner-card-identify.js` |
|
|||
|
|
| Docs | `docs/SCHEMA_MAP.md` |
|
|||
|
|
|
|||
|
|
Briefs A and C are file-disjoint and can run in parallel. B depends on
|
|||
|
|
nothing if it does not retouch detection constants. D depends on B
|
|||
|
|
(needs L1 payload shape).
|
|||
|
|
|
|||
|
|
## Multitask dispatch
|
|||
|
|
|
|||
|
|
Planning: UX → Architect (serial).
|
|||
|
|
|
|||
|
|
After Architect, if `slice_dependencies` marks A+C `depends_on: []`:
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
/multitask role-implementer briefs A, C
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Then B, then D.
|
|||
|
|
|
|||
|
|
Audit group id: `audit-tighten-scan-identify-hot-path-<pr>`.
|
|||
|
|
|
|||
|
|
## CI impact
|
|||
|
|
|
|||
|
|
| Workflow / job | Behavior |
|
|||
|
|
| --- | --- |
|
|||
|
|
| `ci.yml` test | Fires — detection + identify unit tests change |
|
|||
|
|
| `visual-diff.yml` | Should **not** fire if `components/` / `pages/` / `styles/` untouched |
|
|||
|
|
| `preview-smoke.yml` | Fires |
|
|||
|
|
| `forbidden-client-side-llm-keys` | Must stay green — no client Gemini URLs |
|
|||
|
|
|
|||
|
|
## Operator action
|
|||
|
|
|
|||
|
|
Optional: set `SCAN_VISION_MODEL=google/gemini-3.1-flash-lite` on a
|
|||
|
|
preview to bake off against 2.5 Flash Lite. Leave prod on 2.5 until
|
|||
|
|
the bake-off.
|
|||
|
|
|
|||
|
|
## Conductor notes
|
|||
|
|
|
|||
|
|
Do not treat Tesseract replacement as in-scope if L1 escalate stays
|
|||
|
|
high after collector-number matching — that is a crop/detection
|
|||
|
|
problem (#2) or a visual-search problem (#3), not an OCR-engine
|
|||
|
|
problem. The six historical L1 matches already show Tesseract can
|
|||
|
|
read a name when the crop is clean; it cannot pick a printing.
|
|||
|
|
|
|||
|
|
## UX
|
|||
|
|
|
|||
|
|
No new routes or screens. Changes are timing + fewer automatic Gemini
|
|||
|
|
calls during disambiguation.
|
|||
|
|
|
|||
|
|
### Existing components to reuse
|
|||
|
|
|
|||
|
|
- `ScannerDisambiguation` (`components/scanner/ScannerDisambiguation.js`) —
|
|||
|
|
printing picker when L1/L2 cannot unique-match.
|
|||
|
|
- `ScannerToast` / status copy around tracked card (`components/scanner/ScannerCamera.js`)
|
|||
|
|
— continue showing detecting → verifying → found without new chrome.
|
|||
|
|
- `ScanDisambiguationDialog` pattern if still referenced — do not fork a
|
|||
|
|
second picker.
|
|||
|
|
|
|||
|
|
### Design direction alignment
|
|||
|
|
|
|||
|
|
Liquid Glass tokens unchanged. No new overlays. Faster identify should
|
|||
|
|
feel like the camera "wakes up" sooner (H1 visibility of system status).
|
|||
|
|
|
|||
|
|
### Patterns to follow
|
|||
|
|
|
|||
|
|
- Disambiguation stays a bottom sheet / modal pause (`verificationPausedRef`
|
|||
|
|
already wired in `use-scanner-identification.js`).
|
|||
|
|
- Error toasts debounced via `reportScannerError` (4s gap) — keep that
|
|||
|
|
when tightening gates so double-failures do not spam.
|
|||
|
|
|
|||
|
|
### A11y constraints
|
|||
|
|
|
|||
|
|
- No new interactive controls in this convoy.
|
|||
|
|
- Existing disambiguation list must remain keyboard-selectable (hand off
|
|||
|
|
unchanged to a11y auditor if Brief D touches the sheet).
|
|||
|
|
|
|||
|
|
### Interaction patterns
|
|||
|
|
|
|||
|
|
| Pattern | Requirement | Heuristic |
|
|||
|
|
| --- | --- | --- |
|
|||
|
|
| Hold-still gate | **required** — 800ms + 3 stable frames; user sees brackets sooner | H1 feedback |
|
|||
|
|
| Auto-match | **required** — collector number should skip picker for multi-printing names | H6 recognition vs recall |
|
|||
|
|
| Disambiguation refine | **required** — stop silent second Gemini call when L1 already opened picker; user tap only | H3 user control |
|
|||
|
|
| Rate-limit toast | **required** — unchanged copy when L2 throttled | H9 error recovery |
|
|||
|
|
| Loading during verify | **nice-to-have** — optional subtle "Reading…" on card status if trivial | H1 |
|
|||
|
|
|
|||
|
|
### Anti-patterns
|
|||
|
|
|
|||
|
|
- Adding a setup screen or settings toggle for gate timing (H4 consistency).
|
|||
|
|
- Auto-picking a printing without number evidence when multiple exact-name
|
|||
|
|
rows exist (H6).
|
|||
|
|
- Blocking the camera on L1 OCR worker load (H1 — worker stays async).
|
|||
|
|
|
|||
|
|
### Mobile / responsive
|
|||
|
|
|
|||
|
|
Gate timing applies equally on phone and desktop; no viewport-specific
|
|||
|
|
branches in this convoy.
|
|||
|
|
|
|||
|
|
## Architecture
|
|||
|
|
|
|||
|
|
### File plan
|
|||
|
|
|
|||
|
|
| File | Action | Purpose |
|
|||
|
|
| --- | --- | --- |
|
|||
|
|
| `lib/scanner-card-detection.js` | modified | 800ms / 3-frame verify gates |
|
|||
|
|
| `test/lib/scanner-card-detection.test.js` | modified | Lock new constants |
|
|||
|
|
| `lib/ocr-worker.js` | modified | PSM 7 name strip + bottom collector strip |
|
|||
|
|
| `lib/card-text-match.js` | modified | Name + collector number unique match |
|
|||
|
|
| `pages/api/cards/identify-by-text.js` | modified | Accept `cardNumber` body field |
|
|||
|
|
| `test/lib/card-text-match.test.js` | new | Unit tests for number path (mock-free helpers) |
|
|||
|
|
| `lib/scanner-card-identify.js` | modified | Pass number to L1; JPEG 0.92; `fromLayer1` flag |
|
|||
|
|
| `lib/scan-vision.js` | modified | JSON schema response; stricter parse |
|
|||
|
|
| `pages/api/scan/identify.js` | modified | Handle vision parse failures → needs_input |
|
|||
|
|
| `lib/use-scanner-identification.js` | modified | Skip auto Gemini refine when `fromLayer1` |
|
|||
|
|
| `docs/SCHEMA_MAP.md` | modified | Layer 1 = Tesseract (shipped) |
|
|||
|
|
|
|||
|
|
### API surface
|
|||
|
|
|
|||
|
|
**POST `/api/cards/identify-by-text`** (modified)
|
|||
|
|
|
|||
|
|
- Auth: `getUserFromRequest` → 401
|
|||
|
|
- Body: `{ ocrText, ocrConfidence?, cardNumber?, game? }`
|
|||
|
|
- Response: unchanged envelope + optional `cardNumber` echo in `ocr` meta
|
|||
|
|
- Rate limit: none (unchanged)
|
|||
|
|
|
|||
|
|
**POST `/api/scan/identify`** (modified behavior only)
|
|||
|
|
|
|||
|
|
- On vision JSON parse failure: 200 with `needsUserInput: true` instead of
|
|||
|
|
silent regex fallback at confidence 30
|
|||
|
|
|
|||
|
|
### Schema diff
|
|||
|
|
|
|||
|
|
No migration. `scan_attempts.layer` semantics documented only.
|
|||
|
|
|
|||
|
|
### Test plan
|
|||
|
|
|
|||
|
|
- Update `scanner-card-detection.test.js` thresholds (800ms, stable 3).
|
|||
|
|
- New `card-text-match.test.js` for `extractCollectorNumberCandidate` and
|
|||
|
|
collector-aware disambiguation resolution (pure functions exported for test).
|
|||
|
|
- Extend `scanner-card-identify.test.js` for `fromLayer1` disambiguation
|
|||
|
|
payload if added to pure helpers.
|
|||
|
|
- Run `npm run test:run` + `npm run lint`.
|
|||
|
|
|
|||
|
|
### Risks
|
|||
|
|
|
|||
|
|
| Risk | Mitigation |
|
|||
|
|
| --- | --- |
|
|||
|
|
| Faster gate double-scans same card | `scanAttempts < 1` + tracker negative state unchanged |
|
|||
|
|
| OCR number strip reads set symbol garbage | Normalize with `extractCollectorNumberCandidate`; fall back to name-only |
|
|||
|
|
| Gateway rejects `response_format` | Catch 400, log once, fall back to prose prompt (Architect: implementer may feature-detect) |
|
|||
|
|
| L1 skip-refine hides catalog-gap auto-submit | Refine still runs for L2 disambiguation only |
|
|||
|
|
|
|||
|
|
### Decomposition
|
|||
|
|
|
|||
|
|
| Brief # | Title | Files | Depends on | Size |
|
|||
|
|
| --- | --- | --- | --- | --- |
|
|||
|
|
| 1 | Faster verify gates | detection + test | — | S |
|
|||
|
|
| 2 | Collector number L1 | ocr-worker, card-text-match, identify-by-text, scanner-card-identify, test | — | M |
|
|||
|
|
| 3 | Structured vision JSON | scan-vision, identify API | — | S |
|
|||
|
|
| 4 | Skip L1 disambiguation refine | use-scanner-identification, SCHEMA_MAP | 2 | S |
|
|||
|
|
|
|||
|
|
### Slice dependencies
|
|||
|
|
|
|||
|
|
```yaml
|
|||
|
|
slice_dependencies:
|
|||
|
|
- brief: 1
|
|||
|
|
depends_on: []
|
|||
|
|
files:
|
|||
|
|
- lib/scanner-card-detection.js
|
|||
|
|
- test/lib/scanner-card-detection.test.js
|
|||
|
|
- brief: 2
|
|||
|
|
depends_on: []
|
|||
|
|
files:
|
|||
|
|
- lib/ocr-worker.js
|
|||
|
|
- lib/card-text-match.js
|
|||
|
|
- pages/api/cards/identify-by-text.js
|
|||
|
|
- lib/scanner-card-identify.js
|
|||
|
|
- test/lib/card-text-match.test.js
|
|||
|
|
- brief: 3
|
|||
|
|
depends_on: []
|
|||
|
|
files:
|
|||
|
|
- lib/scan-vision.js
|
|||
|
|
- pages/api/scan/identify.js
|
|||
|
|
- brief: 4
|
|||
|
|
depends_on: [2]
|
|||
|
|
files:
|
|||
|
|
- lib/use-scanner-identification.js
|
|||
|
|
- docs/SCHEMA_MAP.md
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### Decisions (post-UX)
|
|||
|
|
|
|||
|
|
| # | Decision |
|
|||
|
|
| --- | --- |
|
|||
|
|
| D1 | Gate values: `MIN_FIRST_SEEN_MS_FOR_VERIFY = 800`, `MIN_STABLE_COUNT_FOR_VERIFY = 3`. |
|
|||
|
|
| D2 | Collector strip: bottom 18% of crop; PSM 7 for both strips. |
|
|||
|
|
| D3 | When exact-name multiple printings AND parsed collector number matches exactly one row → auto-match. |
|
|||
|
|
| D4 | `SCAN_VISION_MODEL` default stays `google/gemini-2.5-flash-lite`; structured JSON either way. |
|
|||
|
|
| D5 | Auto Gemini refine skipped when disambiguation opened from L1 (`result.layer === 1`). |
|
|||
|
|
|
|||
|
|
Human gate 1: **approved** (operator requested full pipeline run 2026-08-14).
|