From f94dc58c99f9723555bcfbc55efa8a01eccb4a11 Mon Sep 17 00:00:00 2001 From: Randall Stillwell Date: Wed, 27 May 2026 12:58:16 -0500 Subject: [PATCH] feat(scanner): AI Gateway vision + Layer-1 Tesseract/pg_trgm OCR Route Layer-2 identification through Vercel AI Gateway (AI_GATEWAY_API_KEY, default google/gemini-2.5-flash-lite). Add Layer-1 browser Tesseract name-strip OCR with pg_trgm fuzzy catalog match via /api/cards/identify-by-text before escalating to vision. Co-authored-by: Cursor --- .github/workflows/ci.yml | 10 +- components/CameraScanner.js | 39 +++++- docs/SCHEMA_MAP.md | 8 ++ lib/card-text-match.js | 109 ++++++++++++++++ lib/ocr-worker.js | 74 +++++++++++ lib/{scan-gemini.js => scan-vision.js} | 51 ++++---- ...79853647565_add-pg-trgm-card-name-index.js | 25 ++++ package-lock.json | 117 ++++++++++++++++- package.json | 3 +- pages/api/cards/identify-by-text.js | 118 ++++++++++++++++++ pages/api/scan/identify.js | 14 +-- 11 files changed, 523 insertions(+), 45 deletions(-) create mode 100644 lib/card-text-match.js create mode 100644 lib/ocr-worker.js rename lib/{scan-gemini.js => scan-vision.js} (73%) create mode 100644 migrations/1779853647565_add-pg-trgm-card-name-index.js create mode 100644 pages/api/cards/identify-by-text.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 50d36c3..6e18782 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,6 +59,7 @@ jobs: contains(github.event.pull_request.changed_files, 'scripts/add-') || contains(github.event.pull_request.changed_files, 'scripts/fix-') || contains(github.event.pull_request.changed_files, 'scripts/setup-neon-db.js') || + contains(github.event.pull_request.changed_files, 'migrations/') || contains(github.event.pull_request.changed_files, 'docs/SCHEMA_MAP.md') steps: - uses: actions/checkout@v4 @@ -71,6 +72,9 @@ jobs: if git diff --name-only HEAD~1 | grep -qE '^scripts/(add-|fix-|setup-neon-db\.js)'; then MIGRATION_CHANGED=true fi + if git diff --name-only HEAD~1 | grep -qE '^migrations/'; then + MIGRATION_CHANGED=true + fi if git diff --name-only HEAD~1 | grep -q '^docs/SCHEMA_MAP\.md$'; then MAP_CHANGED=true fi @@ -174,11 +178,11 @@ jobs: done exit 1 fi - # lib/ may hold server-only helpers (e.g. scan-gemini.js) imported only from pages/api/. + # lib/ may hold server-only helpers imported only from pages/api/. SERVER_ONLY=( - lib/scan-gemini.js + lib/scan-vision.js ) - LLM_PATTERN='generativelanguage\.googleapis\.com|api\.openai\.com' + LLM_PATTERN='generativelanguage\.googleapis\.com|api\.openai\.com|ai-gateway\.vercel\.sh' FOUND=() while IFS= read -r file; do skip=false diff --git a/components/CameraScanner.js b/components/CameraScanner.js index f0faa1b..5f8e2fd 100644 --- a/components/CameraScanner.js +++ b/components/CameraScanner.js @@ -345,12 +345,43 @@ export default function CameraScanner({ onCardScanned, onError }) { const imageData = canvas.toDataURL('image/jpeg', 0.8); + const authHeaders = { + 'Content-Type': 'application/json', + Authorization: `Bearer ${localStorage.getItem('auth_token')}`, + }; + + // Layer 1: local OCR + pg_trgm catalog match (no vision LLM) + try { + const { recognizeCardNameStrip } = await import('../lib/ocr-worker.js'); + const ocr = await recognizeCardNameStrip(imageData); + + if (ocr.text.length >= 3) { + const l1Response = await fetch('/api/cards/identify-by-text', { + method: 'POST', + headers: authHeaders, + body: JSON.stringify({ + ocrText: ocr.text, + ocrConfidence: ocr.confidence, + }), + }); + + if (l1Response.ok) { + const l1Result = await l1Response.json(); + if (!l1Result.escalate) { + cardTracker.status = 'confirmed'; + await processIdentifyResponse(cardTracker, imageData, l1Result); + return; + } + } + } + } catch (l1Error) { + console.warn('Layer-1 OCR path failed, escalating to vision:', l1Error); + } + + // Layer 2: vision via AI Gateway const response = await fetch('/api/scan/identify', { method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${localStorage.getItem('auth_token')}`, - }, + headers: authHeaders, body: JSON.stringify({ imageData }), }); diff --git a/docs/SCHEMA_MAP.md b/docs/SCHEMA_MAP.md index 2a0d1ab..c709251 100644 --- a/docs/SCHEMA_MAP.md +++ b/docs/SCHEMA_MAP.md @@ -79,6 +79,14 @@ | `favorited` | `BOOLEAN` default `false` | **Unused; favorites live in `user_favorites`** | | `created_at`, `updated_at` | `TIMESTAMP` default now | | +**Indexes (post `1779853647565_add-pg-trgm-card-name-index`):** + +- `idx_cards_name_trgm` — GIN on `name` using `gin_trgm_ops` for Layer-1 OCR fuzzy match (`similarity()` / `pg_trgm`). + +**Extensions used by scan pipeline:** + +- `pg_trgm` — enabled by `1779853647565_add-pg-trgm-card-name-index.js` for trigram similarity on `cards.name`. + ### user_cards | Column | Type | Notes | diff --git a/lib/card-text-match.js b/lib/card-text-match.js new file mode 100644 index 0000000..87b7388 --- /dev/null +++ b/lib/card-text-match.js @@ -0,0 +1,109 @@ +import { sql } from '@vercel/postgres'; + +const MATCH_THRESHOLD = 0.85; +const DISAMBIGUATION_THRESHOLD = 0.6; +const MIN_QUERY_LENGTH = 3; + +function mapCardRow(card) { + return { + id: card.id, + name: card.name, + set_name: card.set_name, + set_code: card.set_code, + card_number: card.card_number, + game: card.game, + rarity: card.rarity, + image_url: card.image_url, + card_type: card.card_type, + mana_cost: card.mana_cost, + hp: card.power, + similarity: card.sim, + }; +} + +/** + * Pick the most likely card name line from raw OCR output (name strip is top of card). + */ +export function extractNameCandidate(ocrText) { + if (!ocrText || typeof ocrText !== 'string') return ''; + + const lines = ocrText + .split(/\r?\n/) + .map((line) => line.replace(/\s+/g, ' ').trim()) + .filter((line) => line.length >= MIN_QUERY_LENGTH); + + if (lines.length === 0) { + return ocrText.replace(/\s+/g, ' ').trim(); + } + + // Prefer the first substantial line (card titles are printed at the top). + const scored = lines.slice(0, 5).map((line, index) => ({ + line, + score: line.length - index * 2, + })); + scored.sort((a, b) => b.score - a.score); + return scored[0].line; +} + +/** + * Fuzzy match OCR text against cards.name using pg_trgm similarity. + */ +export async function matchTextInCatalog({ ocrText, game = null, ocrConfidence = null }) { + const query = extractNameCandidate(ocrText); + + if (!query || query.length < MIN_QUERY_LENGTH) { + return { + type: 'escalate', + reason: 'OCR text too short for catalog match', + query, + ocrConfidence, + }; + } + + const result = await sql` + SELECT + *, + similarity(name, ${query}) AS sim + FROM cards + WHERE similarity(name, ${query}) > ${DISAMBIGUATION_THRESHOLD - 0.05} + ORDER BY + sim DESC, + CASE WHEN ${game} IS NOT NULL AND game = ${game} THEN 0 ELSE 1 END, + LENGTH(name) + LIMIT 8 + `; + + const candidates = result.rows.filter((row) => row.sim >= DISAMBIGUATION_THRESHOLD); + + if (candidates.length === 0) { + return { + type: 'escalate', + reason: `No catalog match above ${DISAMBIGUATION_THRESHOLD} similarity for "${query}"`, + query, + ocrConfidence, + }; + } + + const top = candidates[0]; + const runnerUp = candidates[1]; + const clearWinner = + top.sim >= MATCH_THRESHOLD && + (!runnerUp || top.sim - runnerUp.sim >= 0.08); + + if (clearWinner) { + return { + type: 'matched', + card: top, + query, + similarity: top.sim, + message: `Matched "${top.name}" via text search (${Math.round(top.sim * 100)}% similar)`, + }; + } + + return { + type: 'disambiguation', + matches: candidates.slice(0, 5).map(mapCardRow), + query, + message: `Found ${candidates.length} possible matches for "${query}". Select the correct card.`, + }; +} diff --git a/lib/ocr-worker.js b/lib/ocr-worker.js new file mode 100644 index 0000000..6a98058 --- /dev/null +++ b/lib/ocr-worker.js @@ -0,0 +1,74 @@ +/** + * Browser-side Tesseract OCR for Layer-1 card name strip recognition. + * Uses tesseract.js workers (off main thread). Language data loads from CDN on first use. + */ + +let workerPromise = null; + +async function getWorker() { + if (!workerPromise) { + workerPromise = (async () => { + const { createWorker } = await import('tesseract.js'); + const worker = await createWorker('eng', 1, { + logger: () => {}, + }); + await worker.setParameters({ + tessedit_pageseg_mode: '6', + }); + return worker; + })(); + } + return workerPromise; +} + +function cropNameStrip(imageDataUrl) { + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => { + const stripHeight = Math.max(1, Math.floor(img.height * 0.35)); + const canvas = document.createElement('canvas'); + canvas.width = img.width; + canvas.height = stripHeight; + const ctx = canvas.getContext('2d'); + ctx.drawImage(img, 0, 0, img.width, stripHeight, 0, 0, img.width, stripHeight); + resolve(canvas.toDataURL('image/jpeg', 0.92)); + }; + img.onerror = () => reject(new Error('Failed to load image for OCR')); + img.src = imageDataUrl; + }); +} + +/** + * OCR the top name strip of a card crop. + * @param {string} imageDataUrl - full card crop data URL + * @returns {Promise<{ text: string, confidence: number }>} + */ +export async function recognizeCardNameStrip(imageDataUrl) { + if (typeof window === 'undefined') { + return { text: '', confidence: 0 }; + } + + try { + const stripUrl = await cropNameStrip(imageDataUrl); + const worker = await getWorker(); + const { data } = await worker.recognize(stripUrl); + return { + text: (data.text || '').trim(), + confidence: Math.round(data.confidence || 0), + }; + } catch (error) { + console.warn('[ocr-worker] recognizeCardNameStrip failed:', error); + return { text: '', confidence: 0 }; + } +} + +export async function terminateOcrWorker() { + if (!workerPromise) return; + try { + const worker = await workerPromise; + await worker.terminate(); + } catch { + // ignore teardown errors + } + workerPromise = null; +} diff --git a/lib/scan-gemini.js b/lib/scan-vision.js similarity index 73% rename from lib/scan-gemini.js rename to lib/scan-vision.js index a95f4b6..78fd32c 100644 --- a/lib/scan-gemini.js +++ b/lib/scan-vision.js @@ -1,6 +1,6 @@ -const DEFAULT_VISION_MODEL = process.env.GEMINI_VISION_MODEL || 'gemini-2.5-flash'; -const GEMINI_MODEL = - `https://generativelanguage.googleapis.com/v1beta/models/${DEFAULT_VISION_MODEL}:generateContent`; +const GATEWAY_URL = 'https://ai-gateway.vercel.sh/v1/chat/completions'; +const DEFAULT_VISION_MODEL = + process.env.SCAN_VISION_MODEL || 'google/gemini-2.5-flash-lite'; const CARD_PROMPT = `You are a specialized trading card recognition system. Analyze this image and determine if it contains a trading card (Magic: The Gathering, Pokemon, Yu-Gi-Oh, Lorcana, etc.). @@ -32,7 +32,7 @@ If NO trading card is clearly visible, respond with: Be conservative — only extract data you can clearly read. Quality over quantity.`; -function parseGeminiJson(content) { +function parseVisionJson(content) { const cleanContent = content.replace(/```json\n?/g, '').replace(/```\n?/g, '').trim(); try { return JSON.parse(cleanContent); @@ -58,58 +58,51 @@ function normalizeGame(game) { } /** - * Server-side Gemini Vision analysis. Requires GEMINI_AI_API_KEY in env. + * Server-side vision analysis via Vercel AI Gateway. Requires AI_GATEWAY_API_KEY. * @param {string} imageDataUrl - data:image/jpeg;base64,... capture from scanner */ export async function analyzeCardImage(imageDataUrl) { - const apiKey = process.env.GEMINI_AI_API_KEY; + const apiKey = process.env.AI_GATEWAY_API_KEY; if (!apiKey) { - throw new Error('GEMINI_AI_API_KEY is not configured on the server'); + throw new Error('AI_GATEWAY_API_KEY is not configured on the server'); } - const base64Data = imageDataUrl.split(',')[1]; - if (!base64Data) { + if (!imageDataUrl?.includes(',')) { throw new Error('Invalid image data format'); } - const response = await fetch(GEMINI_MODEL, { + const response = await fetch(GATEWAY_URL, { method: 'POST', headers: { 'Content-Type': 'application/json', - 'x-goog-api-key': apiKey, + Authorization: `Bearer ${apiKey}`, }, body: JSON.stringify({ - contents: [{ - parts: [ - { text: CARD_PROMPT }, - { - inline_data: { - mime_type: 'image/jpeg', - data: base64Data, - }, - }, + model: DEFAULT_VISION_MODEL, + temperature: 0.1, + messages: [{ + role: 'user', + content: [ + { type: 'text', text: CARD_PROMPT }, + { type: 'image_url', image_url: { url: imageDataUrl } }, ], }], - generationConfig: { - temperature: 0.1, - }, }), }); if (!response.ok) { const errorData = await response.json().catch(() => ({})); - throw new Error( - `Gemini API error: ${response.status} - ${errorData.error?.message || 'Unknown error'}` - ); + const message = errorData.error?.message || errorData.message || 'Unknown error'; + throw new Error(`Vision API error: ${response.status} - ${message}`); } const data = await response.json(); - const content = data.candidates?.[0]?.content?.parts?.[0]?.text; + const content = data.choices?.[0]?.message?.content; if (!content) { - throw new Error('No response from Gemini API'); + throw new Error('No response from vision model'); } - const result = parseGeminiJson(content); + const result = parseVisionJson(content); return { isCard: result.isCard || false, diff --git a/migrations/1779853647565_add-pg-trgm-card-name-index.js b/migrations/1779853647565_add-pg-trgm-card-name-index.js new file mode 100644 index 0000000..bcd5a1d --- /dev/null +++ b/migrations/1779853647565_add-pg-trgm-card-name-index.js @@ -0,0 +1,25 @@ +/** + * Enable pg_trgm + GIN index on cards.name for Layer-1 OCR text matching. + * + * @type {import('node-pg-migrate').ColumnDefinitions | undefined} + */ +export const shorthands = undefined; + +/** + * @param {import('node-pg-migrate').MigrationBuilder} pgm + */ +export const up = (pgm) => { + pgm.sql(` + CREATE EXTENSION IF NOT EXISTS pg_trgm; + + CREATE INDEX IF NOT EXISTS idx_cards_name_trgm + ON cards USING gin (name gin_trgm_ops); + `); +}; + +/** + * @param {import('node-pg-migrate').MigrationBuilder} pgm + */ +export const down = (pgm) => { + throw new Error('Down migration not supported for add-pg-trgm-card-name-index'); +}; diff --git a/package-lock.json b/package-lock.json index 34e376f..40b99e6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,8 @@ "node-fetch": "^3.3.2", "react": "^18.3.1", "react-dom": "^18.3.1", - "resend": "^4.7.0" + "resend": "^4.7.0", + "tesseract.js": "^6.0.1" }, "devDependencies": { "@playwright/test": "^1.60.0", @@ -3834,6 +3835,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -5970,6 +5977,12 @@ "entities": "^4.4.0" } }, + "node_modules/idb-keyval": { + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.4.tgz", + "integrity": "sha512-D/NzHWUmYJGXi++z67aMSrnisb9A3621CyRK5G89JyTlN13C8xf0g04DLxUKMufPem3e3L2JAXR6Z00OWy183Q==", + "license": "Apache-2.0" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -6436,6 +6449,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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/is-weakmap": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", @@ -7482,6 +7501,15 @@ "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==", "license": "MIT" }, + "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/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -8269,6 +8297,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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/regexp.prototype.flags": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", @@ -9247,6 +9281,72 @@ "node": ">= 6" } }, + "node_modules/tesseract.js": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tesseract.js/-/tesseract.js-6.0.1.tgz", + "integrity": "sha512-/sPvMvrCtgxnNRCjbTYbr7BRu0yfWDsMZQ2a/T5aN/L1t8wUQN6tTWv6p6FwzpoEBA0jrN2UD2SX4QQFRdoDbA==", + "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": "^6.0.0", + "wasm-feature-detect": "^1.2.11", + "zlibjs": "^0.3.1" + } + }, + "node_modules/tesseract.js-core": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/tesseract.js-core/-/tesseract.js-core-6.1.2.tgz", + "integrity": "sha512-pv4GjmramjdObhDyR1q85Td8X60Puu/lGQn7Kw2id05LLgHhAcWgnz6xSdMCSxBMWjQDmMyDXPTC2aqADdpiow==", + "license": "Apache-2.0" + }, + "node_modules/tesseract.js/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/tesseract.js/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/tesseract.js/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/tesseract.js/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/thenify": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", @@ -9919,6 +10019,12 @@ "node": ">=18" } }, + "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-streams-polyfill": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", @@ -10337,6 +10443,15 @@ "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": "*" + } + }, "node_modules/zod": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", diff --git a/package.json b/package.json index b9e0cce..2282d33 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,8 @@ "node-fetch": "^3.3.2", "react": "^18.3.1", "react-dom": "^18.3.1", - "resend": "^4.7.0" + "resend": "^4.7.0", + "tesseract.js": "^6.0.1" }, "devDependencies": { "@playwright/test": "^1.60.0", diff --git a/pages/api/cards/identify-by-text.js b/pages/api/cards/identify-by-text.js new file mode 100644 index 0000000..1dc5cd3 --- /dev/null +++ b/pages/api/cards/identify-by-text.js @@ -0,0 +1,118 @@ +import { getUserFromRequest } from '../../../lib/permission-middleware'; +import { matchTextInCatalog } from '../../../lib/card-text-match.js'; +import { logScanAttempt } from '../../../lib/card-catalog-match.js'; + +function formatCardResponse(card, ocrMeta) { + return { + id: card.id, + name: card.name, + set_name: card.set_name, + set_code: card.set_code, + card_number: card.card_number, + game: card.game, + card_type: card.card_type, + rarity: card.rarity, + hp: card.power, + mana_cost: card.mana_cost, + image_url: card.image_url, + ocr: ocrMeta, + }; +} + +export default async function handler(req, res) { + if (req.method !== 'POST') { + return res.status(405).json({ error: 'Method not allowed' }); + } + + const startedAt = Date.now(); + + try { + const user = await getUserFromRequest(req); + if (!user) { + return res.status(401).json({ error: 'Authentication required' }); + } + + const { ocrText, ocrConfidence, game } = req.body || {}; + + if (!ocrText || typeof ocrText !== 'string') { + return res.status(400).json({ error: 'ocrText is required' }); + } + + const matchResult = await matchTextInCatalog({ ocrText, game, ocrConfidence }); + const latencyMs = Date.now() - startedAt; + + const ocrMeta = { + confidence: ocrConfidence ?? null, + rawText: ocrText, + query: matchResult.query || ocrText, + }; + + if (matchResult.type === 'escalate') { + await logScanAttempt({ + userId: user.userId, + ocrText, + ocrConfidence, + layer: 1, + resultKind: 'escalate', + latencyMs, + }); + return res.status(200).json({ + layer: 1, + escalate: true, + ocr: ocrMeta, + reason: matchResult.reason, + }); + } + + if (matchResult.type === 'matched') { + await logScanAttempt({ + userId: user.userId, + ocrText, + ocrConfidence, + layer: 1, + matchedCardId: matchResult.card.id, + resultKind: 'matched', + latencyMs, + }); + return res.status(200).json({ + layer: 1, + escalate: false, + isCard: true, + card: formatCardResponse(matchResult.card, ocrMeta), + isExisting: true, + message: matchResult.message, + ocr: ocrMeta, + }); + } + + await logScanAttempt({ + userId: user.userId, + ocrText, + ocrConfidence, + layer: 1, + resultKind: 'disambiguation', + latencyMs, + }); + + return res.status(200).json({ + layer: 1, + escalate: false, + isCard: true, + card: null, + matches: matchResult.matches, + needsUserSelection: true, + message: matchResult.message, + ocr: ocrMeta, + }); + } catch (error) { + console.error('[POST /api/cards/identify-by-text]', error); + + if (String(error.message).includes('pg_trgm') || String(error.message).includes('similarity')) { + return res.status(503).json({ + error: 'Text matching unavailable — run npm run migrate up (pg_trgm extension).', + }); + } + + return res.status(500).json({ error: 'Internal server error' }); + } +} diff --git a/pages/api/scan/identify.js b/pages/api/scan/identify.js index ff24e3b..cdaef3b 100644 --- a/pages/api/scan/identify.js +++ b/pages/api/scan/identify.js @@ -1,34 +1,34 @@ import { getUserFromRequest } from '../../../lib/permission-middleware'; import { checkScanRateLimit } from '../../../lib/rate-limit.js'; -import { analyzeCardImage } from '../../../lib/scan-gemini.js'; +import { analyzeCardImage } from '../../../lib/scan-vision.js'; import { matchCardInCatalog, logScanAttempt } from '../../../lib/card-catalog-match.js'; function scanErrorResponse(error) { const msg = error?.message || ''; - if (msg.includes('GEMINI_AI_API_KEY is not configured')) { + if (msg.includes('AI_GATEWAY_API_KEY is not configured')) { return { status: 503, body: { - error: 'Card scanning is not configured on this server (missing GEMINI_AI_API_KEY).', + error: 'Card scanning is not configured on this server (missing AI_GATEWAY_API_KEY).', }, }; } - if (msg.includes('Gemini API error: 429')) { + if (msg.includes('Vision API error: 429')) { return { status: 502, body: { - error: 'Vision service quota exceeded. Check Gemini API billing or try again later.', + error: 'Vision service quota exceeded. Check AI Gateway billing or try again later.', }, }; } - if (msg.includes('Gemini API error: 403') || msg.includes('PERMISSION_DENIED')) { + if (msg.includes('Vision API error: 403') || msg.includes('PERMISSION_DENIED')) { return { status: 502, body: { - error: 'Vision service access denied. Regenerate the Gemini API key in Google AI Studio.', + error: 'Vision service access denied. Check AI Gateway model access and API key.', }, }; } -- 2.45.2