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:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
Generated
+115
@@ -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": "*"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -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/"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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: "<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,
|
||||
},
|
||||
};
|
||||
Vendored
+10
@@ -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
|
||||
Vendored
+10
@@ -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
|
||||
Vendored
+20
@@ -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
|
||||
Vendored
+10
@@ -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
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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'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's numbers. Grades are model output. The Ledger keeps the receipts.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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). */
|
||||
|
||||
Reference in New Issue
Block a user