Merge S9 (a1): slip reader — zero-API OCR

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kev
2026-07-11 19:39:56 -04:00
18 changed files with 1914 additions and 1 deletions
+147
View File
@@ -0,0 +1,147 @@
'use strict';
/**
* A1 Session 9 — POST /api/slips/parse (Slip Reader).
*
* Auth required. Accepts a base64/data-URL slip screenshot (≤4MB decoded)
* OR raw slip `text` (paste path + hermetic tests — skips OCR entirely).
* OCR is tesseract.js (self-hosted WASM, zero API spend); parsing is
* `slipReader.parseSlipText` — extracted values are USER-SLIP values,
* never written into any market cache.
*
* Rate limit: `slips:{user}:{YYYY-MM-DD}` (UTC day) — free 1/day,
* paid (analyst/desk) 10/day. Redis counter with an in-memory mirror so
* a degraded Redis still enforces the cap within this process.
*/
const express = require('express');
const { requireAuth } = require('../middleware/auth');
const { cacheGet, cacheSet } = require('../utils/redis');
const slipReader = require('../services/slipReader');
const router = express.Router();
const MAX_IMAGE_BYTES = 4 * 1024 * 1024; // 4MB decoded
const MAX_TEXT_CHARS = 20_000;
const COUNTER_TTL_S = 48 * 3600; // outlives the UTC day it counts
const MEM_MAX = 20_000;
const mem = new Map(); // dayKey → count (in-memory mirror / fallback)
function dailyLimit(tier) {
return tier === 'free' ? 1 : 10;
}
function dayKey(userId, now = new Date()) {
return `slips:${userId}:${now.toISOString().slice(0, 10)}`;
}
/**
* Consume one parse slot for the user's UTC day. Uses max(redis, memory)
* as the used count so neither store resetting under-counts alone.
*/
async function takeSlot(user, deps = {}) {
const get = deps.cacheGet || cacheGet;
const set = deps.cacheSet || cacheSet;
const limit = dailyLimit(user.tier || 'free');
const key = dayKey(user.id, deps.now);
let redisUsed = 0;
try {
const v = await get(key);
redisUsed = Number(v) || 0;
} catch { /* degraded redis → memory mirror carries it */ }
const used = Math.max(redisUsed, mem.get(key) || 0);
if (used >= limit) return { ok: false, used, limit };
if (mem.size > MEM_MAX) {
const oldest = mem.keys().next().value;
if (oldest !== undefined) mem.delete(oldest);
}
mem.set(key, used + 1);
try { await set(key, used + 1, COUNTER_TTL_S); } catch { /* best effort */ }
return { ok: true, used: used + 1, limit };
}
/** Decode a base64 / data-URL image field into a Buffer, or null. */
function decodeImage(image) {
if (typeof image !== 'string' || !image) return null;
const b64 = image.replace(/^data:image\/[a-z+.-]+;base64,/i, '').replace(/\s/g, '');
if (!/^[A-Za-z0-9+/=]+$/.test(b64)) return null;
try {
const buf = Buffer.from(b64, 'base64');
return buf.length > 0 ? buf : null;
} catch {
return null;
}
}
router.post('/parse', requireAuth, async (req, res) => {
const { image, text, book } = req.body || {};
// ── Validate input BEFORE burning a daily slot ──
let buffer = null;
let rawText = null;
if (typeof text === 'string' && text.trim()) {
if (text.length > MAX_TEXT_CHARS) {
return res.status(400).json({ error: 'Slip text too long.' });
}
rawText = text;
} else if (image != null) {
buffer = decodeImage(image);
if (!buffer) {
return res.status(400).json({ error: 'Could not decode the image. Send base64 or a data URL.' });
}
if (buffer.length > MAX_IMAGE_BYTES) {
return res.status(400).json({ error: 'Image too large. 4MB max.' });
}
} else {
return res.status(400).json({ error: 'Send an image (base64) or slip text.' });
}
// ── Daily quota ──
const slot = await takeSlot(req.user, router.__deps || {});
if (!slot.ok) {
res.set('X-Slips-Used', String(slot.used));
res.set('X-Slips-Limit', String(slot.limit));
return res.status(429).json({
error: slot.limit === 1
? 'One slip read per day on the free tier. Upgrade for ten.'
: 'Daily slip-read limit reached.',
used: slot.used,
limit: slot.limit,
tier: req.user.tier || 'free',
});
}
// ── OCR (image path only) ──
if (rawText == null) {
try {
const recognize = (router.__deps && router.__deps.recognizeImage) || slipReader.recognizeImage;
rawText = await recognize(buffer);
} catch (err) {
console.error('[slips] OCR failed:', err.message);
return res.status(503).json({ error: 'The reader could not process this image. Try again or paste the slip text.' });
}
}
// ── Parse. Values are user-slip values; unreadable fields stay null. ──
const result = slipReader.parseSlipText(rawText, book);
res.set('X-Slips-Used', String(slot.used));
res.set('X-Slips-Limit', String(slot.limit));
return res.json(result);
});
// Test hooks — injectable deps + counter reset (same pattern as scanLimit).
router.__internals = {
mem,
dayKey,
takeSlot,
dailyLimit,
decodeImage,
resetForTests: () => mem.clear(),
setDeps: (deps) => { router.__deps = deps; },
};
module.exports = router;