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