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