deckhearth/.convoys/tighten-scan-identify-hot-path.md
varutasu 0b4f419f49
Scanner identify upgrade — Phase 1 hot path (#156)
* 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>
2026-08-14 20:21:20 -05:00

328 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
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).