* docs(convoy): seed scanner identify upgrade epic and sub-convoys Baseline scan_attempts telemetry and three-phase plan for faster, more accurate card identification without touching scanner chrome. Co-authored-by: Cursor <cursoragent@cursor.com> * feat(scanner): tighten Layer-1 identify hot path (Phase 1) Cut verify hold-still gates, OCR collector numbers on Layer 1, request structured Gemini JSON, and skip automatic L2 refine when L1 opens the printing picker. Includes convoy UX/architecture briefs and unit tests. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
12 KiB
| name | classification | success_metric | skip | status | created | depends_on | umbrella | model_policy | |||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| tighten-scan-identify-hot-path | feature | 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. |
|
in-progress | 2026-08-14 |
|
scanner-identify-upgrade |
|
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_VERIFY2500 → ~800;MIN_STABLE_COUNT_FOR_VERIFY6 → 3. Keep tests intest/lib/scanner-card-detection.test.jsin lockstep. - Name-strip OCR mode: Tesseract PSM 7 (single line), JPEG quality
0.92 on the crop used for OCR (
lib/ocr-worker.js,captureCardRegionFromVideoquality 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 acardsrow, auto-match even if many printings share the name. - Structured Gemini output in
lib/scan-vision.js:response_formatJSON 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 allowSCAN_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.jswhen 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.layeris 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
role-ux-reviewer— hold-still feel; when the picker still appears; no new screens.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.role-implementer— per brief.- Audit:
role-reviewer+role-security-auditor. Skip design-system- a11y unless a brief unexpectedly touches
components/.
- a11y unless a brief unexpectedly touches
Todos
- UX: confirm 800ms / 3-frame gate does not cause double-scans
- Architect: briefs + match-rule when number is present
- Brief A — detection constants + tests
- Brief B — OCR strips (name PSM 7 + collector number) +
card-text-match.js/identify-by-text - Brief C —
scan-vision.jsJSON schema + optional model id - Brief D — skip automatic disambiguation Gemini refine
- SCHEMA_MAP layer-1 wording
- Re-query
scan_attemptsafter 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.ScanDisambiguationDialogpattern 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 (
verificationPausedRefalready wired inuse-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
cardNumberecho inocrmeta - Rate limit: none (unchanged)
POST /api/scan/identify (modified behavior only)
- On vision JSON parse failure: 200 with
needsUserInput: trueinstead of silent regex fallback at confidence 30
Schema diff
No migration. scan_attempts.layer semantics documented only.
Test plan
- Update
scanner-card-detection.test.jsthresholds (800ms, stable 3). - New
card-text-match.test.jsforextractCollectorNumberCandidateand collector-aware disambiguation resolution (pure functions exported for test). - Extend
scanner-card-identify.test.jsforfromLayer1disambiguation 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
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).