deckhearth/.convoys/tighten-scan-identify-hot-path.md

329 lines
12 KiB
Markdown
Raw Normal View History

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