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:
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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user