* 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>
328 lines
12 KiB
Markdown
328 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).
|