Files
vyndr/specs/a1-s9-slip-reader.md
builtbykev 4e49ee0990 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>
2026-07-11 19:32:17 -04:00

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 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:

{
  "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.