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
+18
View File
@@ -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.
+34
View File
@@ -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)
+115
View File
@@ -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": "*"
}
}
}
}
+1
View File
@@ -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": {
+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.
+17
View File
@@ -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`.
+3
View File
@@ -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
+147
View File
@@ -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;
+462
View File
@@ -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" — 23 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: "<Player> 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: "<Player> To Record 2+ Total Bases" (2+ → line 1.5 over),
"<Player> Any Time Home Run", "<Player> 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: "<Player> 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: "<Player> 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,
},
};
+10
View File
@@ -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
+10
View File
@@ -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
+20
View File
@@ -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
+10
View File
@@ -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
+179
View File
@@ -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);
});
});
+220
View File
@@ -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');
});
});
+31
View File
@@ -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 });
}
}
+525
View File
@@ -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<string, number>;
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<Sport, string[]> = {
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<HTMLInputElement>(null);
const [mode, setMode] = useState<'image' | 'text'>('image');
const [sport, setSport] = useState<Sport>('MLB');
const [preview, setPreview] = useState<string | null>(null);
const [slipText, setSlipText] = useState('');
const [parsing, setParsing] = useState(false);
const [error, setError] = useState('');
const [book, setBook] = useState<string | null>(null);
const [legs, setLegs] = useState<EditLeg[] | null>(null);
const [grades, setGrades] = useState<LegGrade[]>([]);
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 (
<section style={{ maxWidth: 720, margin: '0 auto', padding: '28px 16px 96px' }} onPaste={onPaste}>
<SectionHead accent="var(--g-a)"> SLIP READER</SectionHead>
<h1 className="mono" style={{ fontSize: 28, fontWeight: 800, letterSpacing: '-0.02em', margin: '8px 0 8px' }}>READ MY SLIP</h1>
<p style={{ fontSize: 14, color: 'var(--text-1)', lineHeight: 1.6, marginBottom: 20 }}>
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.
</p>
{/* Input mode */}
<div style={{ display: 'flex', gap: 8, marginBottom: 14 }}>
{(['image', 'text'] as const).map((m) => (
<button
key={m}
className="mono"
onClick={() => setMode(m)}
style={{
padding: '7px 14px', borderRadius: 6, fontSize: 12, fontWeight: 700, cursor: 'pointer',
background: mode === m ? 'var(--bg-2)' : 'transparent',
border: `1px solid ${mode === m ? 'var(--g-a)' : 'var(--border)'}`,
color: mode === m ? 'var(--g-a)' : 'var(--text-1)',
}}
>
{m === 'image' ? 'SCREENSHOT' : 'PASTE TEXT'}
</button>
))}
<div style={{ marginLeft: 'auto' }}>
<select
className="mono"
value={sport}
onChange={(e) => setSport(e.target.value as Sport)}
style={{ ...inputStyle(false), width: 'auto', fontSize: 12 }}
aria-label="Sport"
>
<option value="MLB">MLB</option>
<option value="NBA">NBA</option>
<option value="WNBA">WNBA</option>
</select>
</div>
</div>
{mode === 'image' ? (
<div
onClick={() => fileRef.current?.click()}
style={{
border: '1px dashed var(--border)', borderRadius: 8, padding: 28, textAlign: 'center',
cursor: 'pointer', background: 'var(--bg-1)', marginBottom: 16,
}}
>
<input
ref={fileRef}
type="file"
accept="image/*"
style={{ display: 'none' }}
onChange={(e) => onFile(e.target.files?.[0] ?? null)}
/>
{preview ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={preview} alt="Slip preview" style={{ maxWidth: '100%', maxHeight: 260, borderRadius: 6 }} />
) : (
<div className="mono" style={{ fontSize: 13, color: 'var(--text-1)' }}>
CLICK TO UPLOAD · OR PASTE A SCREENSHOT (CTRL+V)
</div>
)}
</div>
) : (
<div style={{ marginBottom: 16 }}>
<textarea
className="mono"
value={slipText}
onChange={(e) => setSlipText(e.target.value)}
placeholder={'Aaron Judge Over 1.5\nTotal Bases\n-115'}
rows={7}
style={{ ...inputStyle(false), resize: 'vertical', fontSize: 13 }}
/>
<div style={{ marginTop: 10 }}>
<VBtn variant="primary" disabled={parsing || !slipText.trim()} onClick={() => void parse({ text: slipText })}>
{parsing ? 'READING…' : 'READ SLIP TEXT'}
</VBtn>
</div>
</div>
)}
{parsing && mode === 'image' && (
<div className="mono" style={{ fontSize: 13, color: 'var(--g-a)', marginBottom: 16 }}>READING THE SLIP</div>
)}
{error && (
<div className="mono" style={{ fontSize: 13, color: 'var(--miss, #ff6b6b)', marginBottom: 16 }}>{error}</div>
)}
{/* Parsed legs — manual-correct UI */}
{legs && (
<div style={{ marginBottom: 20 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 6 }}>
<span className="label">
EXTRACTED LEGS{book ? ` · ${book.toUpperCase()}` : ''}
</span>
<span className="mono" style={{ fontSize: 11, color: 'var(--text-1)' }}>
USER-SLIP VALUES your book&apos;s numbers, not ours
</span>
</div>
{legs.some((l) => l.needs_review) && (
<div className="mono" style={{ fontSize: 12, color: 'var(--amber)', marginBottom: 10 }}>
Amber fields were unreadable or uncertain. The reader does not guess check them before grading.
</div>
)}
{legs.map((leg, i) => {
const g = grades[i];
return (
<div
key={i}
style={{
border: `1px solid ${leg.needs_review && !legReady(leg) ? 'var(--amber)' : 'var(--border)'}`,
borderRadius: 8, padding: 12, marginBottom: 10, background: 'var(--bg-1)',
}}
>
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1.6fr 0.9fr 1fr 1fr auto', gap: 8, alignItems: 'center' }}>
<input
className="mono"
value={leg.player}
placeholder="Player"
onChange={(e) => setField(i, 'player', e.target.value)}
style={inputStyle(leg.flagged.player)}
aria-label={`Leg ${i + 1} player`}
/>
<select
className="mono"
value={leg.stat}
onChange={(e) => setField(i, 'stat', e.target.value)}
style={inputStyle(leg.flagged.stat)}
aria-label={`Leg ${i + 1} stat`}
>
<option value="">STAT</option>
{STAT_OPTIONS[sport].map((s) => (
<option key={s} value={s}>{label(s)}</option>
))}
</select>
<input
className="mono"
value={leg.line}
placeholder="Line"
inputMode="decimal"
onChange={(e) => setField(i, 'line', e.target.value)}
style={inputStyle(leg.flagged.line)}
aria-label={`Leg ${i + 1} line`}
/>
<select
className="mono"
value={leg.side}
onChange={(e) => setField(i, 'side', e.target.value)}
style={inputStyle(leg.flagged.side)}
aria-label={`Leg ${i + 1} side`}
>
<option value="over">OVER</option>
<option value="under">UNDER</option>
</select>
<input
className="mono"
value={leg.odds}
placeholder="Odds"
onChange={(e) => setField(i, 'odds', e.target.value)}
style={inputStyle(leg.flagged.odds)}
aria-label={`Leg ${i + 1} odds`}
/>
<button
className="mono"
onClick={() => removeLeg(i)}
style={{ background: 'transparent', border: 'none', color: 'var(--text-1)', cursor: 'pointer', fontSize: 15 }}
aria-label={`Remove leg ${i + 1}`}
>
</button>
</div>
{g && g.status !== 'pending' && (
<div style={{ marginTop: 10, display: 'flex', alignItems: 'center', gap: 10 }}>
{g.status === 'graded' ? (
<>
<GradeBadge grade={g.grade || ''} />
{g.confidence != null && (
<span className="mono" style={{ fontSize: 12, color: 'var(--text-1)' }}>
{Math.round(g.confidence)}% confidence
</span>
)}
</>
) : (
<span className="mono" style={{ fontSize: 12, color: g.status === 'refused' ? 'var(--amber)' : 'var(--miss, #ff6b6b)' }}>
{g.message}
</span>
)}
</div>
)}
{g && g.status === 'pending' && grading && (
<div className="mono" style={{ marginTop: 10, fontSize: 12, color: 'var(--text-1)' }}>GRADING</div>
)}
</div>
);
})}
<div style={{ display: 'flex', gap: 10, marginTop: 14, flexWrap: 'wrap' }}>
<VBtn variant="primary" disabled={grading || legs.length === 0 || !legs.every(legReady)} onClick={() => void gradeAll()}>
{grading ? 'GRADING…' : 'CONFIRM + GRADE'}
</VBtn>
{graded && gradedLegs.length >= 1 && !added && (
<VBtn variant="outline" onClick={addAllToParlay}>
+ ADD {gradedLegs.length} TO PARLAY LAB
</VBtn>
)}
{added && (
<span className="mono" style={{ fontSize: 12, color: 'var(--g-a)', alignSelf: 'center' }}>
In the Lab
</span>
)}
</div>
{!legs.every(legReady) && (
<div className="mono" style={{ fontSize: 11, color: 'var(--text-1)', marginTop: 8 }}>
Every leg needs a player, stat and line before the engine will grade it.
</div>
)}
</div>
)}
{/* Share card — VYNDR read my slip */}
{graded && legs && (
<div
style={{
border: '1px solid var(--border)', borderRadius: 10, padding: 18,
background: 'var(--bg-1)', marginTop: 8,
}}
>
<div className="label" style={{ marginBottom: 10 }}>VYNDR READ MY SLIP</div>
{legs.map((l, i) => {
const g = grades[i];
return (
<div key={i} className="mono" style={{ display: 'flex', justifyContent: 'space-between', fontSize: 13, padding: '6px 0', borderBottom: i < legs.length - 1 ? '1px solid var(--border)' : 'none' }}>
<span>
{l.player || '—'} · {l.stat ? label(l.stat) : '?'} {l.side === 'under' ? 'u' : 'o'}{l.line || '?'}
{l.odds ? ` · ${l.odds}` : ''}
</span>
<span style={{ fontWeight: 800, color: g?.status === 'graded' ? 'var(--g-a)' : 'var(--amber)' }}>
{g?.status === 'graded' ? g.grade : 'NO GRADE'}
</span>
</div>
);
})}
<div style={{ display: 'flex', gap: 10, marginTop: 14 }}>
<VBtn variant={copied ? 'primary' : 'outline'} onClick={copyShare}>
{copied ? '✓ Copied' : 'Copy share text'}
</VBtn>
</div>
<div className="mono" style={{ fontSize: 10, color: 'var(--text-1)', marginTop: 10 }}>
Slip values are your book&apos;s numbers. Grades are model output. The Ledger keeps the receipts.
</div>
</div>
)}
</section>
);
}
+1
View File
@@ -24,6 +24,7 @@ const GATED_ROUTES = [
'/settings',
'/notifications',
'/invite',
'/slip', // A1 S9 — slip reader (personal surface: reads the user's own bet slip)
];
/* Open — reachable without auth (marketing + the free funnel + auth itself). */