From 4e49ee09907e3ea14583909fdaea593e243f7d39 Mon Sep 17 00:00:00 2001 From: Kev Date: Sat, 11 Jul 2026 19:32:17 -0400 Subject: [PATCH] =?UTF-8?q?S9=20(a1):=20slip=20reader=20=E2=80=94=20zero-A?= =?UTF-8?q?PI=20OCR?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- BUILD-STATE.md | 18 + CLAUDE.md | 34 ++ package-lock.json | 115 ++++++ package.json | 3 +- specs/a1-s9-slip-reader.md | 110 ++++++ specs/vyndr-roadmap.md | 17 + src/app.js | 3 + src/routes/slips.js | 147 ++++++++ src/services/slipReader.js | 462 +++++++++++++++++++++++ tests/fixtures/slips/betmgm.txt | 10 + tests/fixtures/slips/caesars.txt | 10 + tests/fixtures/slips/draftkings.txt | 20 + tests/fixtures/slips/fanduel.txt | 10 + tests/integration/slipsRoute.test.js | 179 +++++++++ tests/unit/slipReader.test.js | 220 +++++++++++ web/src/app/api/slips/parse/route.ts | 31 ++ web/src/app/slip/page.tsx | 525 +++++++++++++++++++++++++++ web/src/lib/routes.js | 1 + 18 files changed, 1914 insertions(+), 1 deletion(-) create mode 100644 specs/a1-s9-slip-reader.md create mode 100644 src/routes/slips.js create mode 100644 src/services/slipReader.js create mode 100644 tests/fixtures/slips/betmgm.txt create mode 100644 tests/fixtures/slips/caesars.txt create mode 100644 tests/fixtures/slips/draftkings.txt create mode 100644 tests/fixtures/slips/fanduel.txt create mode 100644 tests/integration/slipsRoute.test.js create mode 100644 tests/unit/slipReader.test.js create mode 100644 web/src/app/api/slips/parse/route.ts create mode 100644 web/src/app/slip/page.tsx diff --git a/BUILD-STATE.md b/BUILD-STATE.md index 2c9aff3..e853f94 100755 --- a/BUILD-STATE.md +++ b/BUILD-STATE.md @@ -3,6 +3,24 @@ ## Last Updated 2026-07-11 +## Session S9 (a1 board, 2026-07-11) — Slip Reader: zero-API OCR ✅ +Spec: `specs/a1-s9-slip-reader.md`. +34 tests (24 unit + 10 integration). +- **slipReader service** — tesseract.js (self-hosted WASM, Apache-2.0, zero + API spend) + PURE per-book layout parsers (DraftKings, FanDuel, BetMGM, + Caesars) on OCR-text fixtures. Per-field confidence; below 0.6 → field + null + `needs_review` — the reader never guesses. Stats normalize to the + scan-route vocabulary; names through `playerName`. +- **POST /api/slips/parse** — auth, free 1/day paid 10/day (`slips:{user}: + {day}` Redis + memory mirror), 4MB image cap, text paste path. Next proxy + added. Mounted in app.js. +- **/slip page** (gated) — upload/paste screenshot → manual-correct UI + (amber = uncertain) → per-leg grade via existing /api/scan (honest + refusals) → add-all to Parlay Lab → "VYNDR read my slip" share card. +- **Acceptance: FULL image path** — synthetic DK slip PNG (sharp SVG→PNG) + → real tesseract.js OCR → parser → 3/3 complete legs. Untested on a real + phone screenshot yet — grab one post-deploy. +- Vision-model upgrade logged post-revenue in specs/vyndr-roadmap.md. + ## Session S3 (a1 board, 2026-07-11) — Affiliate + Partnership Plumbing ✅ Zero out-of-pocket; everything config-flip-ready but DISABLED/organic. 2398 → **2439 tests** (209 suites), web build exit 0. diff --git a/CLAUDE.md b/CLAUDE.md index f0e4371..f691c82 100755 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -880,6 +880,40 @@ phased plan in the Session-57 conversation / BUILD-STATE Next section). - Box-side ops (Uptime Kuma, Coolify deploy-failure webhook, ntfy phone setup) = `docs/OPS-RUNBOOK.md`. +## Slip Reader (Session 9, A1 board — non-obvious) +- **`src/services/slipReader.js`** — PURE per-book OCR-text layout parsers + (DK/FD/MGM/Caesars) + `detectBook` + `parseSlipText(text, bookHint)`. The + NEVER-GUESS contract: every field carries a confidence; below + `CONFIDENCE_THRESHOLD` (0.6) the field is NULL + the leg `needs_review:true`. + Values are USER-SLIP values (`source:'user_slip'`) — never written to any + market cache. Stat labels normalize via `STAT_ALIASES` → the scan-route + vocabulary (a unit test cross-checks every alias against VALID_STAT_TYPES); + players via `playerName` normalizeName/nameKey. +- **Caesars head split is alias-anchored:** "Pete Alonso Home Runs Over 0.5" + is regex-ambiguous (greedy name eats "Home" → stat "runs" = WRONG-stat + fabrication). `splitPlayerStat` matches the LONGEST known stat alias the + head ENDS with; no alias → stat null AND player low-confidence (boundary + unknowable). BetMGM legs carry "@ -115" — don't pre-filter its lines with + the game-row `@` heuristic; the Over/Under grammar is the filter. +- **OCR = tesseract.js v7 (Apache-2.0), lazy-required** in `recognizeImage` + so the parsers load without WASM. ~46MB on disk (core WASM); eng + traineddata (~11MB) downloads ONCE at first recognize and caches at + `TESSERACT_CACHE_PATH` (default `.tesseract-cache/`) — first prod OCR call + needs outbound network, then it's fully offline. +- **`POST /api/slips/parse`** (requireAuth): base64/data-URL image ≤4MB + decoded OR raw `text` (paste path, also what hermetic route tests use). + Daily quota `slips:{user}:{YYYY-MM-DD}` — free 1/day, paid 10/day; Redis + counter with in-memory mirror (max of both enforces). Validation runs + BEFORE the quota is burned. Injectable via `router.__internals.setDeps`. + Next proxy: `web/src/app/api/slips/parse/route.ts` (S25 rule). +- **`/slip` page** (added to GATED_ROUTES): upload/paste → editable leg rows + (nulled fields amber; editing clears the flag) → each leg graded via the + EXISTING `POST /api/scan` (refusals render "NO GRADE — insufficient data") + → `useParlay().addLeg` per graded leg → share card is copy-text (no OG + plumbing). Sport selector is MLB/NBA/WNBA (ParlayLeg's sport union). +- **Vision-model upgrade is POST-REVENUE** (specs/vyndr-roadmap.md) — the + route contract is the stable interface; only the extraction engine swaps. + ## Active Skills - vyndr-voice (all user-facing output) - prop-analysis (grading methodology) diff --git a/package-lock.json b/package-lock.json index 86c3c7a..a6f69e1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "postgres": "^3.4.8", "sharp": "^0.34.5", "stripe": "^20.4.1", + "tesseract.js": "^7.0.0", "web-push": "^3.6.7" }, "devDependencies": { @@ -2540,6 +2541,12 @@ "node": ">=6.0.0" } }, + "node_modules/bmp-js": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/bmp-js/-/bmp-js-0.1.0.tgz", + "integrity": "sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==", + "license": "MIT" + }, "node_modules/bn.js": { "version": "4.12.3", "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", @@ -4043,6 +4050,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/idb-keyval": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.3.0.tgz", + "integrity": "sha512-um+2dgAWmYsu615EXpWVwSmapJhON0G43t3Ka/EVaohzPQXSMqKEqeDK/oIW3Ow+BXaF2PvSc+oBTFp793A5Ow==", + "license": "Apache-2.0" + }, "node_modules/import-in-the-middle": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-3.0.2.tgz", @@ -4185,6 +4198,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-url": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz", + "integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==", + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -5230,6 +5249,26 @@ "node": ">= 0.6" } }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -5337,6 +5376,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/opencollective-postinstall": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz", + "integrity": "sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==", + "license": "MIT", + "bin": { + "opencollective-postinstall": "index.js" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -5730,6 +5778,12 @@ "node": ">=4" } }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT" + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -6365,6 +6419,30 @@ "url": "https://opencollective.com/synckit" } }, + "node_modules/tesseract.js": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/tesseract.js/-/tesseract.js-7.0.0.tgz", + "integrity": "sha512-exPBkd+z+wM1BuMkx/Bjv43OeLBxhL5kKWsz/9JY+DXcXdiBjiAch0V49QR3oAJqCaL5qURE0vx9Eo+G5YE7mA==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "bmp-js": "^0.1.0", + "idb-keyval": "^6.2.0", + "is-url": "^1.2.4", + "node-fetch": "^2.6.9", + "opencollective-postinstall": "^2.0.3", + "regenerator-runtime": "^0.13.3", + "tesseract.js-core": "^7.0.0", + "wasm-feature-detect": "^1.8.0", + "zlibjs": "^0.3.1" + } + }, + "node_modules/tesseract.js-core": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/tesseract.js-core/-/tesseract.js-core-7.0.0.tgz", + "integrity": "sha512-WnNH518NzmbSq9zgTPeoF8c+xmilS8rFIl1YKbk/ptuuc7p6cLNELNuPAzcmsYw450ca6bLa8j3t0VAtq435Vw==", + "license": "Apache-2.0" + }, "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", @@ -6442,6 +6520,12 @@ "node": ">=0.6" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -6609,6 +6693,12 @@ "makeerror": "1.0.12" } }, + "node_modules/wasm-feature-detect": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/wasm-feature-detect/-/wasm-feature-detect-1.8.0.tgz", + "integrity": "sha512-zksaLKM2fVlnB5jQQDqKXXwYHLQUVH9es+5TOOHwGOVJOCeRBCiPjwSg+3tN2AdTCzjgli4jijCH290kXb/zWQ==", + "license": "Apache-2.0" + }, "node_modules/web-push": { "version": "3.6.7", "resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz", @@ -6628,6 +6718,12 @@ "node": ">= 16" } }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, "node_modules/whatwg-encoding": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", @@ -6662,6 +6758,16 @@ "node": ">=18" } }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -6917,6 +7023,15 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zlibjs": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/zlibjs/-/zlibjs-0.3.1.tgz", + "integrity": "sha512-+J9RrgTKOmlxFSDHo0pI1xM6BLVUv+o0ZT9ANtCxGkjIVCCUdx9alUF8Gm+dGLKbkkkidWIHFDZHDMpfITt4+w==", + "license": "MIT", + "engines": { + "node": "*" + } } } } diff --git a/package.json b/package.json index 79c0471..136745f 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "postgres": "^3.4.8", "sharp": "^0.34.5", "stripe": "^20.4.1", + "tesseract.js": "^7.0.0", "web-push": "^3.6.7" }, "devDependencies": { @@ -52,4 +53,4 @@ "/.claude/" ] } -} \ No newline at end of file +} diff --git a/specs/a1-s9-slip-reader.md b/specs/a1-s9-slip-reader.md new file mode 100644 index 0000000..6cc8360 --- /dev/null +++ b/specs/a1-s9-slip-reader.md @@ -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. diff --git a/specs/vyndr-roadmap.md b/specs/vyndr-roadmap.md index b399434..3a00af8 100644 --- a/specs/vyndr-roadmap.md +++ b/specs/vyndr-roadmap.md @@ -131,6 +131,23 @@ this is an aggregate + UI change only. --- +## Slip Reader — vision-model upgrade (POST-REVENUE) + +Session 9 (A1 board) shipped the zero-API Slip Reader: tesseract.js OCR +(self-hosted WASM, free) + rigid per-book layout parsers (DK/FD/MGM/Caesars) +with per-field confidence and needs_review honesty. That architecture is +deliberately conservative — it reads clean screenshots of the four big books +and refuses everything else. + +The upgrade, when revenue funds it (zero-out-of-pocket rule): a vision model +(Claude-class multimodal) replaces the OCR+layout-parser pair — one call reads +ANY book, any theme, any crop, and returns structured legs with real +confidence. Costs per-call money, so it is gated on paid tiers paying for +themselves. The route contract (`POST /api/slips/parse` → `{ legs, +needs_review, source: 'user_slip' }`) is the stable interface; only the +extraction engine behind it swaps. Keep the never-guess rule: model output +below confidence threshold still nulls the field. + ## Operating invariants (do not regress) - Three stat_type whitelists stay in sync: `routes/analyze.js`, `routes/scan.js`, `python/utils/validation.py`. diff --git a/src/app.js b/src/app.js index 2c9b68c..9044361 100644 --- a/src/app.js +++ b/src/app.js @@ -187,6 +187,9 @@ app.use('/api/content', contentRoutes); // Session S7 (a1) — THE VYNDR REPORT: public double-opt-in subscribe // (forwards to the self-hosted Listmonk; graceful no-op without env). app.use('/api/newsletter', require('./routes/newsletter')); +// A1 S9 — Slip Reader: OCR a bet-slip screenshot into legs (auth + +// per-tier daily quota inside the router). Values are user-slip values. +app.use('/api/slips', require('./routes/slips')); // Session 18 — internal ops endpoints (admin dashboard triggers, // shared-key auth via `VYNDR_INTERNAL_KEY`). Never reachable from // the public surface; the Next.js admin route proxies through with diff --git a/src/routes/slips.js b/src/routes/slips.js new file mode 100644 index 0000000..e5c855b --- /dev/null +++ b/src/routes/slips.js @@ -0,0 +1,147 @@ +'use strict'; + +/** + * A1 Session 9 — POST /api/slips/parse (Slip Reader). + * + * Auth required. Accepts a base64/data-URL slip screenshot (≤4MB decoded) + * OR raw slip `text` (paste path + hermetic tests — skips OCR entirely). + * OCR is tesseract.js (self-hosted WASM, zero API spend); parsing is + * `slipReader.parseSlipText` — extracted values are USER-SLIP values, + * never written into any market cache. + * + * Rate limit: `slips:{user}:{YYYY-MM-DD}` (UTC day) — free 1/day, + * paid (analyst/desk) 10/day. Redis counter with an in-memory mirror so + * a degraded Redis still enforces the cap within this process. + */ + +const express = require('express'); +const { requireAuth } = require('../middleware/auth'); +const { cacheGet, cacheSet } = require('../utils/redis'); +const slipReader = require('../services/slipReader'); + +const router = express.Router(); + +const MAX_IMAGE_BYTES = 4 * 1024 * 1024; // 4MB decoded +const MAX_TEXT_CHARS = 20_000; +const COUNTER_TTL_S = 48 * 3600; // outlives the UTC day it counts +const MEM_MAX = 20_000; + +const mem = new Map(); // dayKey → count (in-memory mirror / fallback) + +function dailyLimit(tier) { + return tier === 'free' ? 1 : 10; +} + +function dayKey(userId, now = new Date()) { + return `slips:${userId}:${now.toISOString().slice(0, 10)}`; +} + +/** + * Consume one parse slot for the user's UTC day. Uses max(redis, memory) + * as the used count so neither store resetting under-counts alone. + */ +async function takeSlot(user, deps = {}) { + const get = deps.cacheGet || cacheGet; + const set = deps.cacheSet || cacheSet; + const limit = dailyLimit(user.tier || 'free'); + const key = dayKey(user.id, deps.now); + + let redisUsed = 0; + try { + const v = await get(key); + redisUsed = Number(v) || 0; + } catch { /* degraded redis → memory mirror carries it */ } + + const used = Math.max(redisUsed, mem.get(key) || 0); + if (used >= limit) return { ok: false, used, limit }; + + if (mem.size > MEM_MAX) { + const oldest = mem.keys().next().value; + if (oldest !== undefined) mem.delete(oldest); + } + mem.set(key, used + 1); + try { await set(key, used + 1, COUNTER_TTL_S); } catch { /* best effort */ } + return { ok: true, used: used + 1, limit }; +} + +/** Decode a base64 / data-URL image field into a Buffer, or null. */ +function decodeImage(image) { + if (typeof image !== 'string' || !image) return null; + const b64 = image.replace(/^data:image\/[a-z+.-]+;base64,/i, '').replace(/\s/g, ''); + if (!/^[A-Za-z0-9+/=]+$/.test(b64)) return null; + try { + const buf = Buffer.from(b64, 'base64'); + return buf.length > 0 ? buf : null; + } catch { + return null; + } +} + +router.post('/parse', requireAuth, async (req, res) => { + const { image, text, book } = req.body || {}; + + // ── Validate input BEFORE burning a daily slot ── + let buffer = null; + let rawText = null; + if (typeof text === 'string' && text.trim()) { + if (text.length > MAX_TEXT_CHARS) { + return res.status(400).json({ error: 'Slip text too long.' }); + } + rawText = text; + } else if (image != null) { + buffer = decodeImage(image); + if (!buffer) { + return res.status(400).json({ error: 'Could not decode the image. Send base64 or a data URL.' }); + } + if (buffer.length > MAX_IMAGE_BYTES) { + return res.status(400).json({ error: 'Image too large. 4MB max.' }); + } + } else { + return res.status(400).json({ error: 'Send an image (base64) or slip text.' }); + } + + // ── Daily quota ── + const slot = await takeSlot(req.user, router.__deps || {}); + if (!slot.ok) { + res.set('X-Slips-Used', String(slot.used)); + res.set('X-Slips-Limit', String(slot.limit)); + return res.status(429).json({ + error: slot.limit === 1 + ? 'One slip read per day on the free tier. Upgrade for ten.' + : 'Daily slip-read limit reached.', + used: slot.used, + limit: slot.limit, + tier: req.user.tier || 'free', + }); + } + + // ── OCR (image path only) ── + if (rawText == null) { + try { + const recognize = (router.__deps && router.__deps.recognizeImage) || slipReader.recognizeImage; + rawText = await recognize(buffer); + } catch (err) { + console.error('[slips] OCR failed:', err.message); + return res.status(503).json({ error: 'The reader could not process this image. Try again or paste the slip text.' }); + } + } + + // ── Parse. Values are user-slip values; unreadable fields stay null. ── + const result = slipReader.parseSlipText(rawText, book); + res.set('X-Slips-Used', String(slot.used)); + res.set('X-Slips-Limit', String(slot.limit)); + return res.json(result); +}); + +// Test hooks — injectable deps + counter reset (same pattern as scanLimit). +router.__internals = { + mem, + dayKey, + takeSlot, + dailyLimit, + decodeImage, + resetForTests: () => mem.clear(), + setDeps: (deps) => { router.__deps = deps; }, +}; + +module.exports = router; diff --git a/src/services/slipReader.js b/src/services/slipReader.js new file mode 100644 index 0000000..9728b0b --- /dev/null +++ b/src/services/slipReader.js @@ -0,0 +1,462 @@ +'use strict'; + +/** + * A1 Session 9 — Slip Reader (zero-API OCR). + * + * Parses sportsbook bet-slip OCR text into legs the grading engine can + * consume. Layout parsers are PURE (text in → legs out, no I/O) so they + * unit-test on raw OCR-text fixtures; the tesseract.js image path is a + * thin wrapper (`recognizeImage`) that lazy-requires the WASM engine. + * + * DATA SEMANTICS: everything extracted here is a USER-SLIP value — the + * line/odds the user's book printed on their slip. It is never written + * into any market cache and is labeled `source: 'user_slip'` upstream. + * + * NEVER GUESS: a field the parser cannot read above CONFIDENCE_THRESHOLD + * is returned null and the leg carries `needs_review: true`. Absent + * beats wrong (North Star operating rule). + */ + +const { normalizeName, nameKey } = require('../utils/playerName'); + +const CONFIDENCE_THRESHOLD = 0.6; +const MAX_LEGS = 12; // mirrors the scan route's parlay cap + +/* ── Stat vocabulary ───────────────────────────────────────────── + Slip market labels → the canonical stat_type vocabulary shared by + src/routes/scan.js VALID_STAT_TYPES + web gradeAdapter STAT_LABELS. + Keys are lowercased, space-collapsed slip labels. */ +const STAT_ALIASES = { + // MLB batters + 'total bases': 'total_bases', 'tb': 'total_bases', + 'hits': 'hits', 'hit': 'hits', + 'home runs': 'home_runs', 'home run': 'home_runs', 'hr': 'home_runs', + 'any time home run': 'home_runs', 'anytime home run': 'home_runs', + 'to hit a home run': 'home_runs', + 'rbi': 'rbi', 'rbis': 'rbi', 'runs batted in': 'rbi', + 'runs': 'runs', 'runs scored': 'runs', 'run scored': 'runs', + 'stolen bases': 'stolen_bases', 'stolen base': 'stolen_bases', + 'doubles': 'doubles', + 'walks': 'walks', 'batter walks': 'walks', + // MLB pitchers + 'strikeouts': 'strikeouts', 'strikeouts thrown': 'strikeouts', + 'pitcher strikeouts': 'strikeouts', 'ks': 'strikeouts', 'k s': 'strikeouts', + 'earned runs': 'earned_runs', 'earned runs allowed': 'earned_runs', + 'hits allowed': 'hits_allowed', + 'innings pitched': 'innings_pitched', + 'outs': 'outs', 'outs recorded': 'outs', + // NBA / WNBA + 'points': 'points', 'pts': 'points', + 'rebounds': 'rebounds', 'reb': 'rebounds', 'rebs': 'rebounds', + 'assists': 'assists', 'ast': 'assists', 'asts': 'assists', + 'threes': 'threes', '3 pointers made': 'threes', 'three pointers made': 'threes', + '3 pt made': 'threes', 'threes made': 'threes', 'made threes': 'threes', '3pm': 'threes', + 'blocks': 'blocks', 'blk': 'blocks', + 'steals': 'steals', 'stl': 'steals', + 'turnovers': 'turnovers', 'to': 'turnovers', + 'pts + reb + ast': 'pra', 'points + rebounds + assists': 'pra', + 'pts rebs asts': 'pra', 'pts reb ast': 'pra', 'pra': 'pra', + // Soccer + 'goals': 'goals', 'goal': 'goals', 'any time goalscorer': 'goals', + 'anytime goalscorer': 'goals', + 'shots': 'shots', 'shots on target': 'shots_on_target', + 'shots on goal': 'shots_on_target', + 'tackles': 'tackles', 'cards': 'cards', 'corners': 'corners', + 'saves': 'saves', 'goalkeeper saves': 'saves', + 'goals conceded': 'goals_conceded', 'passes': 'passes', + 'clean sheet': 'clean_sheet', +}; + +// Display labels (mirror web/src/lib/gradeAdapter.js STAT_LABELS style). +function statLabel(stat) { + if (!stat) return null; + const special = { pra: 'P+R+A', rbi: 'RBI', threes: '3-Pointers' }; + if (special[stat]) return special[stat]; + return stat.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); +} + +/** Normalize a slip market label to the canonical stat_type, or null. */ +function normalizeStat(rawLabel) { + if (!rawLabel) return null; + let s = String(rawLabel).toLowerCase() + .replace(/[|]/g, '') + .replace(/\balt\b/g, '') // "Alt Total Bases" → "Total Bases" + .replace(/\bo\/u\b/g, '') // "Total Bases O/U" + .replace(/[()]/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + if (STAT_ALIASES[s]) return STAT_ALIASES[s]; + const underscored = s.replace(/ /g, '_'); + if (Object.values(STAT_ALIASES).includes(underscored)) return underscored; + return null; +} + +/* ── Shared token grammar ──────────────────────────────────────── */ + +// "Aaron Judge", "Ronald Acuña Jr.", "A.J. Ewing" — 2–3 capitalized tokens. +const NAME_PART = "[A-Z][A-Za-z'’.\\u00C0-\\u017F-]+"; +const NAME_RE = new RegExp( + `(${NAME_PART}(?:\\s${NAME_PART}){1,2}(?:\\s(?:Jr|Sr|II|III|IV)\\.?)?)`, +); + +// Odds token: "+320", "-115", OCR unicode minus "−115" / dash "–115". +const ODDS_RE = /(?:^|[\s(@])([+\-−–]\s?\d{2,4})(?:[\s).]|$)/; + +function parseOdds(token) { + if (!token) return null; + const n = parseInt(String(token).replace(/[−–]/, '-').replace(/\s/g, ''), 10); + if (!Number.isFinite(n)) return null; + if (Math.abs(n) < 100 || Math.abs(n) > 9999) return null; // not american odds + return n; +} + +function parseLine(token) { + const n = Number(token); + return Number.isFinite(n) && n >= 0 && n <= 500 ? n : null; +} + +/** Confidence for an extracted player-name candidate. */ +function nameConfidence(raw) { + if (!raw) return 0; + const parts = raw.trim().split(/\s+/); + if (parts.length < 2) return 0.3; // single token — too weak + const capitalized = parts.every((p) => /^[A-Z]/.test(p)); + return capitalized ? 0.9 : 0.5; +} + +/** Build a finalized leg: normalize, threshold-null, flag needs_review. */ +function buildLeg(fields) { + const conf = { + player: fields.player != null ? (fields.playerConfidence ?? nameConfidence(fields.player)) : 0, + stat: 0, + line: fields.line != null ? (fields.lineConfidence ?? 0.95) : 0, + side: fields.side != null ? (fields.sideConfidence ?? 0.95) : 0, + odds: fields.odds != null ? (fields.oddsConfidence ?? 0.9) : 0, + }; + + const stat = normalizeStat(fields.statLabel); + if (stat) conf.stat = 0.95; + + const playerOk = conf.player >= CONFIDENCE_THRESHOLD; + const norm = playerOk ? normalizeName(fields.player) : null; + + const leg = { + player: playerOk ? norm.display : null, + player_key: playerOk ? nameKey(fields.player) : null, + stat: conf.stat >= CONFIDENCE_THRESHOLD ? stat : null, + stat_label: conf.stat >= CONFIDENCE_THRESHOLD ? statLabel(stat) : null, + raw_stat_label: fields.statLabel || null, + line: conf.line >= CONFIDENCE_THRESHOLD ? fields.line : null, + side: conf.side >= CONFIDENCE_THRESHOLD ? fields.side : null, + odds: conf.odds >= CONFIDENCE_THRESHOLD ? fields.odds : null, + confidence: { + player: round2(conf.player), stat: round2(conf.stat), line: round2(conf.line), + side: round2(conf.side), odds: round2(conf.odds), + }, + needs_review: false, + }; + leg.needs_review = leg.player == null || leg.stat == null + || leg.line == null || leg.side == null || leg.odds == null; + return leg; +} + +function round2(n) { return Math.round(n * 100) / 100; } + +function cleanLines(text) { + return String(text || '') + .split(/\r?\n/) + .map((l) => l.replace(/\s+/g, ' ').trim()) + .filter(Boolean); +} + +// Slip chrome that is never a leg (wager rows, headers, game rows). +const NOISE_RE = /^(wager|to win|total payout|bet id|placed|cash out|share|profit boost|same game parlay|sgp|parlay|\d+ leg|odds|stake|returns|potential|today|tomorrow|final|\$)/i; +const GAME_ROW_RE = /(@| at | vs\.? | v )/i; + +/* ── DraftKings ────────────────────────────────────────────────── + Layout: " Over 1.5" / market label on the NEXT line / + odds on the line after ("-115"). */ +function parseDraftKings(text) { + const lines = cleanLines(text); + const legs = []; + const headRe = new RegExp(`^${NAME_RE.source}\\s+(Over|Under)\\s+(\\d+(?:\\.\\d+)?)$`, 'i'); + + for (let i = 0; i < lines.length && legs.length < MAX_LEGS; i += 1) { + const m = lines[i].match(headRe); + if (!m) continue; + const [, player, sideRaw, lineRaw] = m; + + // Market label = next non-noise, non-game line. + let statLabelRaw = null; + let odds = null; + for (let j = i + 1; j <= i + 3 && j < lines.length; j += 1) { + const l = lines[j]; + if (NOISE_RE.test(l)) break; + const oddsMatch = l.match(/^([+\-−–]\s?\d{2,4})$/); + if (oddsMatch) { odds = parseOdds(oddsMatch[1]); break; } + if (!statLabelRaw && !GAME_ROW_RE.test(l) && !headRe.test(l)) statLabelRaw = l; + } + + legs.push(buildLeg({ + player, + statLabel: statLabelRaw, + line: parseLine(lineRaw), + side: sideRaw.toLowerCase(), + odds, + })); + } + return legs; +} + +/* ── FanDuel ───────────────────────────────────────────────────── + Layouts: " To Record 2+ Total Bases" (2+ → line 1.5 over), + " Any Time Home Run", " Over 7.5 Strikeouts". + SGP legs often carry no per-leg odds → odds null + needs_review. */ +function parseFanDuel(text) { + const lines = cleanLines(text); + const legs = []; + const recordRe = new RegExp(`^${NAME_RE.source}\\s+To Record\\s+(\\d+)\\+\\s+(.+)$`, 'i'); + const anytimeRe = new RegExp(`^${NAME_RE.source}\\s+((?:Any\\s?Time|Anytime) (?:Home Run|Goalscorer))$`, 'i'); + const ouRe = new RegExp(`^${NAME_RE.source}\\s+(Over|Under)\\s+(\\d+(?:\\.\\d+)?)\\s+(.+)$`, 'i'); + + for (let i = 0; i < lines.length && legs.length < MAX_LEGS; i += 1) { + const line = lines[i]; + if (NOISE_RE.test(line) || GAME_ROW_RE.test(line)) continue; + + // Trailing odds on the same line, else a bare odds token on the next. + let body = line; + let odds = null; + const trailing = body.match(/\s([+\-−–]\s?\d{2,4})$/); + if (trailing) { odds = parseOdds(trailing[1]); body = body.slice(0, trailing.index).trim(); } + if (odds == null && lines[i + 1] && /^([+\-−–]\s?\d{2,4})$/.test(lines[i + 1])) { + odds = parseOdds(lines[i + 1]); + } + + let m = body.match(recordRe); + if (m) { + const threshold = parseInt(m[2], 10); + legs.push(buildLeg({ + player: m[1], statLabel: m[3], + line: Number.isFinite(threshold) ? threshold - 0.5 : null, + lineConfidence: 0.9, + side: 'over', sideConfidence: 0.85, + odds, + })); + continue; + } + m = body.match(anytimeRe); + if (m) { + legs.push(buildLeg({ + player: m[1], statLabel: m[2], + line: 0.5, lineConfidence: 0.9, + side: 'over', sideConfidence: 0.85, + odds, + })); + continue; + } + m = body.match(ouRe); + if (m) { + legs.push(buildLeg({ + player: m[1], statLabel: m[4], + line: parseLine(m[3]), + side: m[2].toLowerCase(), + odds, + })); + } + } + return legs; +} + +/* ── BetMGM ────────────────────────────────────────────────────── + Layout: " Over 1.5 Total Bases @ -115" (inline), or the + odds trailing without "@". */ +function parseBetMGM(text) { + const lines = cleanLines(text); + const legs = []; + const re = new RegExp( + `^${NAME_RE.source}\\s+(Over|Under)\\s+(\\d+(?:\\.\\d+)?)\\s+(.+?)(?:\\s*@\\s*([+\\-−–]\\s?\\d{2,4}))?$`, 'i', + ); + + for (let i = 0; i < lines.length && legs.length < MAX_LEGS; i += 1) { + const line = lines[i]; + if (NOISE_RE.test(line)) continue; + // NOTE: no game-row pre-skip here — MGM leg lines carry "@ -115", + // which the game-row heuristic would eat. The grammar (Over/Under) + // is the filter; game rows never match it. + const m = line.match(re); + if (!m) continue; + let [, player, sideRaw, lineRaw, statRaw, oddsRaw] = m; + + // Odds sometimes trail the stat label without "@". + if (!oddsRaw) { + const trailing = statRaw.match(/\s([+\-−–]\s?\d{2,4})$/); + if (trailing) { oddsRaw = trailing[1]; statRaw = statRaw.slice(0, trailing.index).trim(); } + } + + legs.push(buildLeg({ + player, + statLabel: statRaw, + line: parseLine(lineRaw), + side: sideRaw.toLowerCase(), + odds: parseOdds(oddsRaw), + })); + } + return legs; +} + +/* ── Caesars ───────────────────────────────────────────────────── + Layout: " Total Bases Over 1.5 (-115)" — stat between the + name and the side, odds parenthesized or bare at the end. + + The player/stat boundary inside the head segment is ambiguous to a + regex ("Pete Alonso Home Runs" — is "Home" a name token?), so we + split on the LONGEST known stat alias the head ends with. No alias + match → stat null + needs_review; the split NEVER invents a stat. */ + +// Alias keys, longest (most words, then chars) first, for suffix scans. +const ALIAS_KEYS_BY_LEN = Object.keys(STAT_ALIASES) + .sort((a, b) => b.split(' ').length - a.split(' ').length || b.length - a.length); + +function splitPlayerStat(head) { + const low = String(head || '').toLowerCase().replace(/\s+/g, ' ').trim(); + for (const alias of ALIAS_KEYS_BY_LEN) { + if (low.length > alias.length && low.endsWith(' ' + alias)) { + const player = head.slice(0, head.length - alias.length) + .trim() + .replace(/\s+alt$/i, ''); // "Judge Alt Total Bases" → player "Judge" + return { player, statLabel: head.slice(head.length - alias.length).trim() }; + } + } + // No known stat suffix — the player/stat boundary is unknown too, so + // the name candidate is low-confidence (below threshold → nulled). + const nameMatch = String(head || '').match(new RegExp(`^${NAME_RE.source}`)); + return { player: nameMatch ? nameMatch[1] : null, statLabel: null, uncertain: true }; +} + +function parseCaesars(text) { + const lines = cleanLines(text); + const legs = []; + const re = /^(.+?)\s+(Over|Under)\s+(\d+(?:\.\d+)?)\s*(?:\(?([+\-−–]\s?\d{2,4})\)?)?$/i; + + for (let i = 0; i < lines.length && legs.length < MAX_LEGS; i += 1) { + const line = lines[i]; + if (NOISE_RE.test(line)) continue; + const m = line.match(re); + if (!m) continue; + const [, head, sideRaw, lineRaw, oddsRaw] = m; + const { player, statLabel, uncertain } = splitPlayerStat(head); + legs.push(buildLeg({ + player, + statLabel, + ...(uncertain ? { playerConfidence: 0.5 } : {}), + line: parseLine(lineRaw), + side: sideRaw.toLowerCase(), + odds: parseOdds(oddsRaw), + })); + } + return legs; +} + +/* ── Book detection + dispatch ─────────────────────────────────── */ + +const BOOK_MARKERS = [ + ['draftkings', /draft\s?kings|dksb/i], + ['fanduel', /fan\s?duel/i], + ['betmgm', /bet\s?mgm/i], + ['caesars', /caesars|czr/i], +]; + +const PARSERS = { + draftkings: parseDraftKings, + fanduel: parseFanDuel, + betmgm: parseBetMGM, + caesars: parseCaesars, +}; + +function detectBook(text) { + const t = String(text || ''); + for (const [book, re] of BOOK_MARKERS) { + if (re.test(t)) return book; + } + return null; +} + +/** + * Parse OCR slip text. Uses the hinted/detected book's layout parser; + * unknown book → try every layout, keep the best result (most legs, + * tie-broken by fewest needs_review). Layout SELECTION is heuristic; + * field VALUES never are — unparseable fields stay null. + */ +function parseSlipText(text, bookHint) { + const hinted = bookHint && PARSERS[String(bookHint).toLowerCase()] + ? String(bookHint).toLowerCase() : null; + const detected = hinted || detectBook(text); + + if (detected) { + const legs = PARSERS[detected](text); + return finalize(detected, legs); + } + + let best = { book: null, legs: [] }; + for (const [book, parser] of Object.entries(PARSERS)) { + const legs = parser(text); + const better = legs.length > best.legs.length + || (legs.length === best.legs.length && legs.length > 0 + && countReview(legs) < countReview(best.legs)); + if (better) best = { book, legs }; + } + return finalize(best.legs.length ? best.book : null, best.legs); +} + +function countReview(legs) { return legs.filter((l) => l.needs_review).length; } + +function finalize(book, legs) { + return { + book, + legs, + needs_review: legs.length === 0 || legs.some((l) => l.needs_review), + source: 'user_slip', + }; +} + +/* ── OCR (tesseract.js — self-hosted WASM, zero API) ───────────── */ + +/** + * Run OCR on an image buffer. Lazy-requires tesseract.js so the pure + * parsers stay loadable without the WASM engine (tests, cold paths). + * English traineddata (~11MB) downloads once and caches on disk. + */ +async function recognizeImage(buffer, opts = {}) { + const { createWorker } = opts.tesseract || require('tesseract.js'); + const worker = await createWorker('eng', 1, { + cachePath: opts.cachePath || process.env.TESSERACT_CACHE_PATH || '.tesseract-cache', + // Silence per-job progress logging. + logger: () => {}, + }); + try { + const { data } = await worker.recognize(buffer); + return data.text || ''; + } finally { + await worker.terminate(); + } +} + +module.exports = { + parseSlipText, + detectBook, + recognizeImage, + CONFIDENCE_THRESHOLD, + __internals: { + parseDraftKings, + parseFanDuel, + parseBetMGM, + parseCaesars, + normalizeStat, + splitPlayerStat, + statLabel, + parseOdds, + buildLeg, + STAT_ALIASES, + NOISE_RE, + }, +}; diff --git a/tests/fixtures/slips/betmgm.txt b/tests/fixtures/slips/betmgm.txt new file mode 100644 index 0000000..843e3b5 --- /dev/null +++ b/tests/fixtures/slips/betmgm.txt @@ -0,0 +1,10 @@ +BetMGM +PARLAY +Aaron Judge Over 1.5 Total Bases @ -115 +NY Yankees at BOS Red Sox +Shohei Ohtani Under 7.5 Strikeouts @ +105 +LA Dodgers at SD Padres +Mookie Betts Over 1.5 Hits +140 +LA Dodgers at SD Padres +Stake: $10.00 +Potential returns: $68.20 diff --git a/tests/fixtures/slips/caesars.txt b/tests/fixtures/slips/caesars.txt new file mode 100644 index 0000000..cbf5e87 --- /dev/null +++ b/tests/fixtures/slips/caesars.txt @@ -0,0 +1,10 @@ +Caesars Sportsbook +Bet ID 8823941 +Aaron Judge Total Bases Over 1.5 (-115) +NY Yankees vs BOS Red Sox +Pete Alonso Home Runs Over 0.5 (+340) +NY Mets vs ATL Braves +Freddie Freeman Hits Under 1.5 (-105) +LA Dodgers vs SD Padres +Wager $10.00 +To Win $61.50 diff --git a/tests/fixtures/slips/draftkings.txt b/tests/fixtures/slips/draftkings.txt new file mode 100644 index 0000000..5221191 --- /dev/null +++ b/tests/fixtures/slips/draftkings.txt @@ -0,0 +1,20 @@ +DraftKings Sportsbook +3 Leg Parlay ++575 +Aaron Judge Over 1.5 +Total Bases +-115 +NY Yankees @ BOS Red Sox +Today 7:05PM +Shohei Ohtani Over 7.5 +Strikeouts Thrown +-130 +LA Dodgers @ SD Padres +Today 9:40PM +Juan Soto Over 0.5 +Home Runs ++320 +NY Mets @ ATL Braves +Today 7:20PM +Wager: $10.00 +To Win: $57.50 diff --git a/tests/fixtures/slips/fanduel.txt b/tests/fixtures/slips/fanduel.txt new file mode 100644 index 0000000..a3293f9 --- /dev/null +++ b/tests/fixtures/slips/fanduel.txt @@ -0,0 +1,10 @@ +FanDuel Sportsbook +SAME GAME PARLAY ++650 +Aaron Judge To Record 2+ Total Bases +Giancarlo Stanton Any Time Home Run +Gerrit Cole Over 6.5 Strikeouts +NY Yankees @ BOS Red Sox +7:05 PM ET +$10 bet to win $65.00 +Total payout $75.00 diff --git a/tests/integration/slipsRoute.test.js b/tests/integration/slipsRoute.test.js new file mode 100644 index 0000000..79470f2 --- /dev/null +++ b/tests/integration/slipsRoute.test.js @@ -0,0 +1,179 @@ +// A1 S9 — POST /api/slips/parse route contract: auth, tier-aware daily +// quota (free 1/day, paid 10/day), payload validation, text-path parse. +// Auth + redis are mocked (hermetic — no supabase, no ioredis client). + +const express = require('express'); +const request = require('supertest'); +const fs = require('fs'); +const path = require('path'); + +// requireAuth stub: Authorization present → user from header knobs; else 401. +jest.mock('../../src/middleware/auth', () => ({ + requireAuth: (req, res, next) => { + if (!req.headers.authorization) return res.status(401).json({ error: 'Authentication required' }); + req.user = { + id: req.headers['x-test-user'] || 'u1', + tier: req.headers['x-test-tier'] || 'free', + }; + return next(); + }, +})); + +// Redis stub — in-memory kv so no ioredis client is ever created (single-suite +// runs hang at exit otherwise; see Session 8 note). +const mockKv = new Map(); +jest.mock('../../src/utils/redis', () => ({ + cacheGet: jest.fn(async (k) => (mockKv.has(k) ? mockKv.get(k) : null)), + cacheSet: jest.fn(async (k, v) => { mockKv.set(k, v); }), + cacheDel: jest.fn(async (k) => { mockKv.delete(k); }), + getRedisClient: jest.fn(() => null), + isDegraded: jest.fn(() => false), +})); + +const slipsRouter = require('../../src/routes/slips'); + +const dkFixture = fs.readFileSync( + path.join(__dirname, '..', 'fixtures', 'slips', 'draftkings.txt'), 'utf8', +); + +function mountApp() { + const app = express(); + app.use(express.json({ limit: '10mb' })); + app.use('/api/slips', slipsRouter); + return app; +} + +describe('POST /api/slips/parse', () => { + let app; + + beforeEach(() => { + mockKv.clear(); + slipsRouter.__internals.resetForTests(); + slipsRouter.__internals.setDeps({}); + app = mountApp(); + }); + + it('401s without auth', async () => { + const res = await request(app).post('/api/slips/parse').send({ text: dkFixture }); + expect(res.status).toBe(401); + }); + + it('400s with neither image nor text', async () => { + const res = await request(app) + .post('/api/slips/parse') + .set('Authorization', 'Bearer t') + .send({}); + expect(res.status).toBe(400); + }); + + it('400s an undecodable image without burning the daily slot', async () => { + const bad = await request(app) + .post('/api/slips/parse') + .set('Authorization', 'Bearer t') + .send({ image: '!!!not-base64!!!' }); + expect(bad.status).toBe(400); + + // The slot was not consumed — a valid parse still works. + const ok = await request(app) + .post('/api/slips/parse') + .set('Authorization', 'Bearer t') + .send({ text: dkFixture }); + expect(ok.status).toBe(200); + }); + + it('400s an image over 4MB', async () => { + // ~4.5MB of zeros, base64-encoded (~6MB payload — under the 10mb body cap). + const big = Buffer.alloc(4.5 * 1024 * 1024).toString('base64'); + const res = await request(app) + .post('/api/slips/parse') + .set('Authorization', 'Bearer t') + .send({ image: big }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/4MB/); + }); + + it('parses slip text into legs (user_slip semantics)', async () => { + const res = await request(app) + .post('/api/slips/parse') + .set('Authorization', 'Bearer t') + .send({ text: dkFixture }); + expect(res.status).toBe(200); + expect(res.body.book).toBe('draftkings'); + expect(res.body.source).toBe('user_slip'); + expect(res.body.legs).toHaveLength(3); + expect(res.body.legs[0]).toMatchObject({ + player: 'Aaron Judge', stat: 'total_bases', line: 1.5, side: 'over', odds: -115, + }); + }); + + it('free tier: 1 parse/day, second 429s with quota headers', async () => { + const first = await request(app) + .post('/api/slips/parse') + .set('Authorization', 'Bearer t') + .send({ text: dkFixture }); + expect(first.status).toBe(200); + expect(first.headers['x-slips-limit']).toBe('1'); + + const second = await request(app) + .post('/api/slips/parse') + .set('Authorization', 'Bearer t') + .send({ text: dkFixture }); + expect(second.status).toBe(429); + expect(second.body.limit).toBe(1); + expect(second.body.tier).toBe('free'); + }); + + it('paid tier: 10 parses/day, 11th 429s', async () => { + for (let i = 0; i < 10; i += 1) { + const res = await request(app) + .post('/api/slips/parse') + .set('Authorization', 'Bearer t') + .set('x-test-tier', 'analyst') + .send({ text: dkFixture }); + expect(res.status).toBe(200); + } + const eleventh = await request(app) + .post('/api/slips/parse') + .set('Authorization', 'Bearer t') + .set('x-test-tier', 'analyst') + .send({ text: dkFixture }); + expect(eleventh.status).toBe(429); + expect(eleventh.body.limit).toBe(10); + }); + + it('quota is per-user (a second user still parses)', async () => { + await request(app).post('/api/slips/parse') + .set('Authorization', 'Bearer t').send({ text: dkFixture }); + const other = await request(app) + .post('/api/slips/parse') + .set('Authorization', 'Bearer t') + .set('x-test-user', 'u2') + .send({ text: dkFixture }); + expect(other.status).toBe(200); + }); + + it('image path runs the injected OCR then parses', async () => { + slipsRouter.__internals.setDeps({ + recognizeImage: jest.fn(async () => dkFixture), + }); + const png = Buffer.from('fake-png-bytes').toString('base64'); + const res = await request(app) + .post('/api/slips/parse') + .set('Authorization', 'Bearer t') + .send({ image: `data:image/png;base64,${png}` }); + expect(res.status).toBe(200); + expect(res.body.legs).toHaveLength(3); + }); + + it('503s when OCR blows up (honest failure, no empty fabrication)', async () => { + slipsRouter.__internals.setDeps({ + recognizeImage: jest.fn(async () => { throw new Error('wasm sad'); }), + }); + const png = Buffer.from('fake-png-bytes').toString('base64'); + const res = await request(app) + .post('/api/slips/parse') + .set('Authorization', 'Bearer t') + .send({ image: png }); + expect(res.status).toBe(503); + }); +}); diff --git a/tests/unit/slipReader.test.js b/tests/unit/slipReader.test.js new file mode 100644 index 0000000..dbf53ca --- /dev/null +++ b/tests/unit/slipReader.test.js @@ -0,0 +1,220 @@ +// A1 S9 — Slip Reader parser suite. PURE parsers on realistic OCR-text +// fixtures per book. The contract under test: correct extraction on the +// rigid slip grammars, per-field confidence, and the NEVER-GUESS rule +// (unreadable → null + needs_review, never a fabricated value). + +const fs = require('fs'); +const path = require('path'); +const slipReader = require('../../src/services/slipReader'); +const { parseSlipText, detectBook, CONFIDENCE_THRESHOLD } = slipReader; +const { normalizeStat, splitPlayerStat, parseOdds, buildLeg } = slipReader.__internals; + +const fixture = (book) => + fs.readFileSync(path.join(__dirname, '..', 'fixtures', 'slips', `${book}.txt`), 'utf8'); + +describe('slipReader — book detection', () => { + it.each([ + ['draftkings'], ['fanduel'], ['betmgm'], ['caesars'], + ])('detects %s from its fixture', (book) => { + expect(detectBook(fixture(book))).toBe(book); + }); + + it('returns null for unbranded text', () => { + expect(detectBook('Aaron Judge Over 1.5 Total Bases')).toBeNull(); + }); +}); + +describe('slipReader — DraftKings layout', () => { + const result = parseSlipText(fixture('draftkings')); + + it('extracts all three legs completely', () => { + expect(result.book).toBe('draftkings'); + expect(result.legs).toHaveLength(3); + expect(result.needs_review).toBe(false); + expect(result.source).toBe('user_slip'); + }); + + it('extracts player/stat/line/side/odds on leg 1', () => { + const leg = result.legs[0]; + expect(leg.player).toBe('Aaron Judge'); + expect(leg.player_key).toBe('aaron judge'); + expect(leg.stat).toBe('total_bases'); + expect(leg.line).toBe(1.5); + expect(leg.side).toBe('over'); + expect(leg.odds).toBe(-115); + expect(leg.needs_review).toBe(false); + }); + + it('normalizes book market labels through the stat vocabulary', () => { + // "Strikeouts Thrown" (DK pitcher label) → strikeouts + expect(result.legs[1].stat).toBe('strikeouts'); + expect(result.legs[1].line).toBe(7.5); + // plus-odds leg + expect(result.legs[2].stat).toBe('home_runs'); + expect(result.legs[2].odds).toBe(320); + }); + + it('carries per-field confidences at or above threshold on clean legs', () => { + for (const leg of result.legs) { + for (const field of ['player', 'stat', 'line', 'side', 'odds']) { + expect(leg.confidence[field]).toBeGreaterThanOrEqual(CONFIDENCE_THRESHOLD); + } + } + }); +}); + +describe('slipReader — FanDuel layout', () => { + const result = parseSlipText(fixture('fanduel')); + + it('parses To Record N+ / Any Time / Over grammars', () => { + expect(result.book).toBe('fanduel'); + expect(result.legs).toHaveLength(3); + // "To Record 2+ Total Bases" → line 1.5 over + expect(result.legs[0]).toMatchObject({ player: 'Aaron Judge', stat: 'total_bases', line: 1.5, side: 'over' }); + // "Any Time Home Run" → home_runs 0.5 over + expect(result.legs[1]).toMatchObject({ player: 'Giancarlo Stanton', stat: 'home_runs', line: 0.5, side: 'over' }); + // plain Over grammar + expect(result.legs[2]).toMatchObject({ player: 'Gerrit Cole', stat: 'strikeouts', line: 6.5, side: 'over' }); + }); + + it('SGP legs without per-leg odds → odds null + needs_review (never guessed)', () => { + for (const leg of result.legs) { + expect(leg.odds).toBeNull(); + expect(leg.confidence.odds).toBeLessThan(CONFIDENCE_THRESHOLD); + expect(leg.needs_review).toBe(true); + } + expect(result.needs_review).toBe(true); + }); +}); + +describe('slipReader — BetMGM layout', () => { + const result = parseSlipText(fixture('betmgm')); + + it('parses inline "@ odds" and trailing-odds legs', () => { + expect(result.book).toBe('betmgm'); + expect(result.legs).toHaveLength(3); + expect(result.legs[0]).toMatchObject({ player: 'Aaron Judge', stat: 'total_bases', line: 1.5, side: 'over', odds: -115 }); + expect(result.legs[1]).toMatchObject({ player: 'Shohei Ohtani', stat: 'strikeouts', line: 7.5, side: 'under', odds: 105 }); + expect(result.legs[2]).toMatchObject({ player: 'Mookie Betts', stat: 'hits', line: 1.5, side: 'over', odds: 140 }); + expect(result.needs_review).toBe(false); + }); +}); + +describe('slipReader — Caesars layout', () => { + const result = parseSlipText(fixture('caesars')); + + it('splits the ambiguous player/stat head on known stat aliases', () => { + expect(result.book).toBe('caesars'); + expect(result.legs).toHaveLength(3); + // "Pete Alonso Home Runs" must NOT become player "Pete Alonso Home" + stat "runs" + expect(result.legs[1]).toMatchObject({ player: 'Pete Alonso', stat: 'home_runs', line: 0.5, side: 'over', odds: 340 }); + expect(result.legs[0]).toMatchObject({ player: 'Aaron Judge', stat: 'total_bases', odds: -115 }); + expect(result.legs[2]).toMatchObject({ player: 'Freddie Freeman', stat: 'hits', side: 'under', odds: -105 }); + }); + + it('unknown stat head → stat AND player null (boundary unknowable), needs_review', () => { + const r = parseSlipText('Caesars\nAaron Judge Fantasy Score Over 32.5 (-115)'); + expect(r.legs).toHaveLength(1); + expect(r.legs[0].stat).toBeNull(); + expect(r.legs[0].player).toBeNull(); // boundary uncertain → below threshold → nulled + expect(r.legs[0].needs_review).toBe(true); + // The readable fields still come through — nothing over-nulled. + expect(r.legs[0].line).toBe(32.5); + expect(r.legs[0].odds).toBe(-115); + }); +}); + +describe('slipReader — never guess', () => { + it('garbage text → zero legs, needs_review envelope, no fabrication', () => { + const r = parseSlipText('completely unrelated text\nnothing to see 12345\nlorem ipsum'); + expect(r.legs).toEqual([]); + expect(r.needs_review).toBe(true); + expect(r.book).toBeNull(); + }); + + it('empty/nullish input is safe', () => { + expect(parseSlipText('').legs).toEqual([]); + expect(parseSlipText(null).legs).toEqual([]); + expect(parseSlipText(undefined).legs).toEqual([]); + }); + + it('a below-threshold field is nulled, not passed through', () => { + const leg = buildLeg({ + player: 'judge', // lowercase single token → low confidence + statLabel: 'Total Bases', + line: 1.5, + side: 'over', + odds: -115, + }); + expect(leg.player).toBeNull(); + expect(leg.player_key).toBeNull(); + expect(leg.needs_review).toBe(true); + expect(leg.stat).toBe('total_bases'); // readable fields survive + }); + + it('book hint routes to the hinted layout parser', () => { + const dk = fixture('draftkings'); + const hinted = parseSlipText(dk, 'draftkings'); + expect(hinted.book).toBe('draftkings'); + expect(hinted.legs).toHaveLength(3); + }); + + it('unknown book → best layout wins without inventing values', () => { + const unbranded = fixture('draftkings').replace(/DraftKings Sportsbook\n/, ''); + const r = parseSlipText(unbranded); + expect(r.legs.length).toBeGreaterThanOrEqual(3); + for (const leg of r.legs) { + expect(leg.player).not.toBeNull(); + expect(leg.stat).not.toBeNull(); + } + }); +}); + +describe('slipReader — vocabulary + helpers', () => { + it('normalizeStat maps slip labels to canonical stat_types', () => { + expect(normalizeStat('Total Bases')).toBe('total_bases'); + expect(normalizeStat('Strikeouts Thrown')).toBe('strikeouts'); + expect(normalizeStat('Alt Total Bases')).toBe('total_bases'); + expect(normalizeStat('Total Bases O/U')).toBe('total_bases'); + expect(normalizeStat('Pts + Reb + Ast')).toBe('pra'); + expect(normalizeStat('3 Pointers Made')).toBe('threes'); + expect(normalizeStat('Runs Batted In')).toBe('rbi'); + expect(normalizeStat('Fantasy Score')).toBeNull(); // unknown → null, never a guess + }); + + it('canonical stat_types match the scan-route vocabulary exactly', () => { + // Mirror of VALID_STAT_TYPES in src/routes/scan.js — a drifted alias + // here would 400 at the grade gate. + const VALID = new Set([ + 'points', 'rebounds', 'assists', 'threes', 'blocks', 'steals', 'pra', 'turnovers', + 'goals', 'shots_on_target', 'shots', 'tackles', 'cards', 'corners', 'saves', + 'goals_conceded', 'passes', 'clean_sheet', + 'strikeouts', 'hits', 'home_runs', 'rbi', 'total_bases', 'walks', 'runs', + 'earned_runs', 'innings_pitched', 'hits_allowed', 'stolen_bases', 'doubles', 'outs', + ]); + for (const stat of Object.values(slipReader.__internals.STAT_ALIASES)) { + expect(VALID.has(stat)).toBe(true); + } + }); + + it('splitPlayerStat prefers the longest alias suffix', () => { + expect(splitPlayerStat('Pete Alonso Home Runs')).toMatchObject({ player: 'Pete Alonso', statLabel: 'Home Runs' }); + expect(splitPlayerStat('Aaron Judge Total Bases')).toMatchObject({ player: 'Aaron Judge', statLabel: 'Total Bases' }); + expect(splitPlayerStat('Aaron Judge Alt Total Bases')).toMatchObject({ player: 'Aaron Judge', statLabel: 'Total Bases' }); + }); + + it('parseOdds normalizes OCR minus glyphs and rejects non-odds', () => { + expect(parseOdds('-115')).toBe(-115); + expect(parseOdds('−115')).toBe(-115); // unicode minus + expect(parseOdds('+320')).toBe(320); + expect(parseOdds('-15')).toBeNull(); // |odds| < 100 is a line, not odds + expect(parseOdds('banana')).toBeNull(); + expect(parseOdds(null)).toBeNull(); + }); + + it('player names normalize through playerName (display + key)', () => { + const r = parseSlipText('DraftKings\nA.J. Ewing Over 1.5\nTotal Bases\n-115'); + expect(r.legs[0].player).toBe('AJ Ewing'); + expect(r.legs[0].player_key).toBe('aj ewing'); + }); +}); diff --git a/web/src/app/api/slips/parse/route.ts b/web/src/app/api/slips/parse/route.ts new file mode 100644 index 0000000..4555312 --- /dev/null +++ b/web/src/app/api/slips/parse/route.ts @@ -0,0 +1,31 @@ +import { NextRequest, NextResponse } from 'next/server'; + +export const dynamic = 'force-dynamic'; + +const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000'; + +/** + * A1 S9 — Slip Reader proxy. Forwards POST /api/slips/parse (base64 image + * or slip text) to Express with the Authorization header (S25 rule: the + * browser never reaches Express directly). Body can be ~5MB of base64 — + * stream it through as text. + */ +export async function POST(req: NextRequest) { + const body = await req.text(); + try { + const upstream = await fetch(`${BACKEND_URL}/api/slips/parse`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(req.headers.get('authorization') + ? { Authorization: req.headers.get('authorization')! } + : {}), + }, + body, + }); + const data = await upstream.json().catch(() => ({})); + return NextResponse.json(data, { status: upstream.status }); + } catch { + return NextResponse.json({ error: 'The reader is offline. Try again shortly.' }, { status: 502 }); + } +} diff --git a/web/src/app/slip/page.tsx b/web/src/app/slip/page.tsx new file mode 100644 index 0000000..62ef22f --- /dev/null +++ b/web/src/app/slip/page.tsx @@ -0,0 +1,525 @@ +'use client'; + +/** + * A1 S9 — Slip Reader. Upload/paste a bet-slip screenshot; tesseract OCR + * (self-hosted, backend) extracts the legs; every field is pre-filled AND + * editable (needs_review fields amber); confirm grades each leg through + * the normal engine via POST /api/scan — refusals render honestly; graded + * legs hand off to the Parlay Lab. + * + * DATA SEMANTICS: extracted numbers are USER-SLIP values (what the user's + * book printed), labeled as such. VYNDR never invents a line or odds — an + * unreadable field arrives null and stays empty until the user fills it. + */ + +import { useCallback, useRef, useState } from 'react'; +import SectionHead from '@/components/vyndr/SectionHead'; +import VBtn from '@/components/vyndr/VBtn'; +import GradeBadge from '@/components/vyndr/GradeBadge'; +import { useParlay } from '@/contexts/ParlayContext'; + +type Sport = 'MLB' | 'NBA' | 'WNBA'; + +interface ParsedLeg { + player: string | null; + stat: string | null; + line: number | null; + side: 'over' | 'under' | null; + odds: number | null; + confidence?: Record; + needs_review: boolean; +} + +interface EditLeg { + player: string; + stat: string; + line: string; + side: 'over' | 'under'; + odds: string; + needs_review: boolean; + flagged: { player: boolean; stat: boolean; line: boolean; side: boolean; odds: boolean }; +} + +interface LegGrade { + status: 'pending' | 'graded' | 'refused' | 'error'; + grade?: string; + confidence?: number; + message?: string; +} + +const STAT_OPTIONS: Record = { + MLB: ['hits', 'total_bases', 'home_runs', 'rbi', 'runs', 'stolen_bases', 'doubles', 'walks', 'strikeouts', 'hits_allowed', 'earned_runs', 'innings_pitched'], + NBA: ['points', 'rebounds', 'assists', 'threes', 'blocks', 'steals', 'pra', 'turnovers'], + WNBA: ['points', 'rebounds', 'assists', 'threes', 'blocks', 'steals', 'pra', 'turnovers'], +}; + +const label = (s: string) => s.replace(/_/g, ' ').toUpperCase(); + +function toEditLeg(l: ParsedLeg): EditLeg { + return { + player: l.player ?? '', + stat: l.stat ?? '', + line: l.line != null ? String(l.line) : '', + side: l.side === 'under' ? 'under' : 'over', + odds: l.odds != null ? (l.odds > 0 ? `+${l.odds}` : String(l.odds)) : '', + needs_review: l.needs_review, + flagged: { + player: l.player == null, + stat: l.stat == null, + line: l.line == null, + side: l.side == null, + odds: l.odds == null, + }, + }; +} + +const inputStyle = (flag: boolean): React.CSSProperties => ({ + width: '100%', + padding: '8px 10px', + background: 'var(--bg-2)', + border: `1px solid ${flag ? 'var(--amber)' : 'var(--border)'}`, + borderRadius: 6, + color: flag ? 'var(--amber)' : 'var(--text-0)', + fontSize: 13, +}); + +export default function SlipPage() { + const { addLeg, open } = useParlay(); + const fileRef = useRef(null); + + const [mode, setMode] = useState<'image' | 'text'>('image'); + const [sport, setSport] = useState('MLB'); + const [preview, setPreview] = useState(null); + const [slipText, setSlipText] = useState(''); + const [parsing, setParsing] = useState(false); + const [error, setError] = useState(''); + const [book, setBook] = useState(null); + const [legs, setLegs] = useState(null); + const [grades, setGrades] = useState([]); + const [grading, setGrading] = useState(false); + const [graded, setGraded] = useState(false); + const [copied, setCopied] = useState(false); + const [added, setAdded] = useState(false); + + const parse = useCallback(async (payload: { image?: string; text?: string }) => { + setParsing(true); + setError(''); + setLegs(null); + setGrades([]); + setGraded(false); + setAdded(false); + try { + const token = typeof window !== 'undefined' ? localStorage.getItem('sb-token') : null; + const res = await fetch('/api/slips/parse', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: JSON.stringify(payload), + }); + const data = await res.json(); + if (!res.ok) { + setError(data.error || 'The reader could not process that slip.'); + return; + } + setBook(data.book ?? null); + const parsed: ParsedLeg[] = Array.isArray(data.legs) ? data.legs : []; + if (parsed.length === 0) { + setError('No legs found on that slip. Try a sharper screenshot or paste the slip text.'); + return; + } + setLegs(parsed.map(toEditLeg)); + } catch { + setError('The reader is offline. Try again shortly.'); + } finally { + setParsing(false); + } + }, []); + + const onFile = useCallback((file: File | null) => { + if (!file) return; + if (file.size > 4 * 1024 * 1024) { + setError('Image too large. 4MB max.'); + return; + } + const reader = new FileReader(); + reader.onload = () => { + const url = String(reader.result || ''); + setPreview(url); + void parse({ image: url }); + }; + reader.readAsDataURL(file); + }, [parse]); + + const onPaste = useCallback((e: React.ClipboardEvent) => { + const item = Array.from(e.clipboardData?.items || []).find((i) => i.type.startsWith('image/')); + if (item) { + const f = item.getAsFile(); + if (f) onFile(f); + } + }, [onFile]); + + const setField = (i: number, field: keyof EditLeg['flagged'], value: string) => { + setLegs((prev) => { + if (!prev) return prev; + const next = [...prev]; + const leg = { ...next[i], [field]: value } as EditLeg; + // A user-corrected field is no longer flagged. + leg.flagged = { ...leg.flagged, [field]: value.trim() === '' }; + next[i] = leg; + return next; + }); + setGraded(false); + }; + + const removeLeg = (i: number) => { + setLegs((prev) => (prev ? prev.filter((_, idx) => idx !== i) : prev)); + setGraded(false); + }; + + const legReady = (l: EditLeg) => + l.player.trim() !== '' && l.stat !== '' && l.line.trim() !== '' && Number.isFinite(Number(l.line)); + + const gradeAll = async () => { + if (!legs || grading) return; + setGrading(true); + setGrades(legs.map(() => ({ status: 'pending' }))); + const token = typeof window !== 'undefined' ? localStorage.getItem('sb-token') : null; + const results: LegGrade[] = []; + for (const leg of legs) { + if (!legReady(leg)) { + results.push({ status: 'error', message: 'Fill player, stat and line first.' }); + continue; + } + try { + const res = await fetch('/api/scan', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: JSON.stringify({ + sport, + player: leg.player.trim(), + stat: leg.stat, + line: Number(leg.line), + direction: leg.side, + book: 'draftkings', + }), + }); + const data = await res.json(); + if (!res.ok) { + results.push({ status: 'error', message: data.error || 'The engine hit a wall on this leg.' }); + } else if (data.insufficient_data || !data.grade) { + // Honest refusal — the engine has no model reference for this prop. + results.push({ status: 'refused', message: 'NO GRADE — insufficient data. The engine does not guess.' }); + } else { + results.push({ status: 'graded', grade: data.grade, confidence: data.confidence }); + } + } catch { + results.push({ status: 'error', message: 'The engine hit a wall on this leg.' }); + } + setGrades([...results, ...legs.slice(results.length).map(() => ({ status: 'pending' as const }))]); + } + setGrades(results); + setGrading(false); + setGraded(true); + }; + + const gradedLegs = legs + ? legs.map((l, i) => ({ leg: l, grade: grades[i] })).filter((x) => x.grade?.status === 'graded') + : []; + + const addAllToParlay = () => { + for (const { leg, grade } of gradedLegs) { + addLeg({ + sport, + player: leg.player.trim(), + stat: leg.stat, + line: Number(leg.line), + direction: leg.side, + grade: grade!.grade || '', + confidence: grade!.confidence ?? 0, + }); + } + setAdded(true); + open(); + }; + + const shareText = () => { + const rows = (legs || []).map((l, i) => { + const g = grades[i]; + const mark = g?.status === 'graded' ? g.grade : 'NO GRADE'; + return `${l.player} ${l.stat ? label(l.stat) : '?'} ${l.side === 'under' ? 'u' : 'o'}${l.line} · ${mark}`; + }); + return ['VYNDR read my slip.', ...rows, 'vyndr.app/slip'].join('\n'); + }; + + const copyShare = () => { + if (typeof navigator !== 'undefined' && navigator.clipboard) { + void navigator.clipboard.writeText(shareText()); + setCopied(true); + setTimeout(() => setCopied(false), 1800); + } + }; + + return ( +
+ ▚ SLIP READER +

READ MY SLIP

+

+ Drop a bet-slip screenshot. The reader extracts your legs, you correct anything it was unsure + about, and the engine grades each one. Free tier reads one slip a day. +

+ + {/* Input mode */} +
+ {(['image', 'text'] as const).map((m) => ( + + ))} +
+ +
+
+ + {mode === 'image' ? ( +
fileRef.current?.click()} + style={{ + border: '1px dashed var(--border)', borderRadius: 8, padding: 28, textAlign: 'center', + cursor: 'pointer', background: 'var(--bg-1)', marginBottom: 16, + }} + > + onFile(e.target.files?.[0] ?? null)} + /> + {preview ? ( + // eslint-disable-next-line @next/next/no-img-element + Slip preview + ) : ( +
+ CLICK TO UPLOAD · OR PASTE A SCREENSHOT (CTRL+V) +
+ )} +
+ ) : ( +
+