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>
5.0 KiB
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
sharpdependency.
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), ORtext: raw OCR/slip text (skips OCR — used by tests + paste-text path)book(optional hint):draftkings | fanduel | betmgm | caesars
Response 200:
{
"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_TYPESin 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(normalizeNamedisplay +nameKey). - Side grammar per book:
Over 1.5/Under 1.5, FanDuelTo Record 2+ Total Bases(2+ → line 1.5, side over),Altprefixes stripped. - Per-field confidence in [0,1];
CONFIDENCE_THRESHOLD = 0.6. A field below threshold is nulled and flags the legneeds_review. recognizeImage(buffer)lazy-requires tesseract.js (parsers stay loadable in tests without WASM).
Frontend /slip (gated route)
- Added to
GATED_ROUTESin 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().addLegper 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
- 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.
- A leg with an unreadable field returns that field null +
needs_review: true. - Route: 401 unauthenticated; free tier blocked at 2nd parse in a day (429); paid tier allows 10.
- End-to-end: DK fixture → parse → normalized legs → the exact
POST /api/scanrequest 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. - Full jest suite green;
webbuild 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.