S9 (a1): slip reader — zero-API OCR

tesseract.js (self-hosted WASM, Apache-2.0) + pure per-book layout
parsers (DK/FD/MGM/Caesars) with per-field confidence and needs_review
honesty — the reader never guesses. POST /api/slips/parse (auth, free
1/day paid 10/day, 4MB cap) + Next proxy. Gated /slip page: upload or
paste, manual-correct UI, per-leg grades through the normal engine
(refusals render honestly), add-all to Parlay Lab, share card. Vision
model upgrade logged post-revenue. 2574 -> 2608 tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kev
2026-07-11 19:32:17 -04:00
parent aaafc3e0f2
commit 4e49ee0990
18 changed files with 1914 additions and 1 deletions
+110
View File
@@ -0,0 +1,110 @@
# A1 Session 9 — Slip Reader (zero-API OCR)
## What
Upload a sportsbook bet-slip screenshot. VYNDR reads it (Tesseract OCR,
self-hosted, free), extracts the legs, lets the user correct anything the
OCR was unsure about, grades each leg through the normal engine (refusals
apply), and hands the legs to the Parlay Lab. "VYNDR read my slip."
## Zero out-of-pocket
- OCR = `tesseract.js` (npm, Apache-2.0). Bundles the WASM core — no
external OCR service, no per-call cost. English traineddata (~11MB) is
fetched once on first recognize and cached on disk (`.tesseract-cache/`).
- No other new dependencies. Image decode for the acceptance harness uses
the existing `sharp` dependency.
## Data semantics
Extracted values are USER-SLIP values — the line/odds the user's book
printed on their slip, labeled as such in the UI. They are never written
into any market cache. Grading goes through the normal engine
(`POST /api/scan` per leg); insufficient-data refusals render honestly.
NEVER guess: a field the parser cannot read above threshold is returned
`null` and the leg carries `needs_review: true`. Absent beats wrong.
## Endpoints
### POST /api/slips/parse (Express) — auth required (`requireAuth`)
Body (JSON):
- `image`: base64 or data-URL image (≤ 4MB decoded), OR
- `text`: raw OCR/slip text (skips OCR — used by tests + paste-text path)
- `book` (optional hint): `draftkings | fanduel | betmgm | caesars`
Response 200:
```json
{
"book": "draftkings",
"legs": [
{
"player": "Aaron Judge", "player_key": "aaron judge",
"stat": "total_bases", "stat_label": "Total Bases",
"line": 1.5, "side": "over", "odds": -115,
"confidence": { "player": 0.9, "stat": 0.95, "line": 0.95, "side": 0.95, "odds": 0.9 },
"needs_review": false
}
],
"needs_review": false,
"source": "user_slip"
}
```
Errors: 400 (no image/text, image too large, undecodable), 401 (no auth),
429 (daily limit), 503 (OCR engine unavailable).
Rate limit: Redis counter `slips:{userId}:{YYYY-MM-DD}` (UTC day, 48h TTL),
free = 1/day, paid (analyst/desk) = 10/day. In-memory fallback when Redis
is degraded. 429 carries `used`/`limit`.
### Next proxy
`web/src/app/api/slips/parse/route.ts``${BACKEND_URL}/api/slips/parse`,
forwards the Authorization header (S25 rule).
## Parser design (`src/services/slipReader.js`)
- PURE text parsers per book layout: `parseDraftKings`, `parseFanDuel`,
`parseBetMGM`, `parseCaesars` + `detectBook(text)` + `parseSlipText(text,
bookHint)`. Unit-tested on realistic OCR-text fixtures. No I/O.
- Stat names normalize through `STAT_ALIASES` → the existing stat
vocabulary (`VALID_STAT_TYPES` in src/routes/scan.js / STAT_LABELS in
web/src/lib/gradeAdapter.js). Unknown stat → `stat: null`,
`needs_review: true`.
- Player names normalize through `src/utils/playerName` (`normalizeName`
display + `nameKey`).
- Side grammar per book: `Over 1.5` / `Under 1.5`, FanDuel `To Record 2+
Total Bases` (2+ → line 1.5, side over), `Alt` prefixes stripped.
- Per-field confidence in [0,1]; `CONFIDENCE_THRESHOLD = 0.6`. A field
below threshold is nulled and flags the leg `needs_review`.
- `recognizeImage(buffer)` lazy-requires tesseract.js (parsers stay
loadable in tests without WASM).
## Frontend `/slip` (gated route)
- Added to `GATED_ROUTES` in web/src/lib/routes.js.
- Upload or paste a screenshot → POST /api/slips/parse → editable leg rows
(each field pre-filled; needs_review fields amber). "User-slip values"
label on the extracted numbers.
- Confirm → each leg graded via existing `POST /api/scan` (per leg);
refusals render as "NO GRADE — insufficient data".
- "Add all to Parlay Lab" → `useParlay().addLeg` per graded leg.
- Shareable result card ("VYNDR read my slip") — DOM card in existing
VYNDR card styling + copy-to-clipboard share text. No new OG plumbing.
## Acceptance criteria
1. DK/FD/MGM/Caesars OCR-text fixtures parse to correct legs (player,
stat, line, side, odds) with confidences; garbage text → no legs, never
fabricated ones.
2. A leg with an unreadable field returns that field null +
`needs_review: true`.
3. Route: 401 unauthenticated; free tier blocked at 2nd parse in a day
(429); paid tier allows 10.
4. End-to-end: DK fixture → parse → normalized legs → the exact
`POST /api/scan` request shape (`{sport, player, stat, line,
direction}`). If a synthetic PNG can be OCR'd in this environment
(SVG→PNG via sharp → tesseract.js), run the image path too; otherwise
the parser acceptance stands, honestly labeled.
5. Full jest suite green; `web` build exit 0.
## Test plan
- `tests/unit/slipReader.test.js` — per-book fixtures, alias
normalization, threshold/needs_review, never-guess, detectBook.
- `tests/integration/slipsRoute.test.js` — auth 401, text-path parse,
free 1/day + paid 10/day limits, 4MB cap, bad payloads.
## Post-revenue upgrade
Vision-model slip reading (Claude/GPT-4V-class) replaces layout parsers —
logged in specs/vyndr-roadmap.md as a post-revenue item.