deckhearth/lib/card-text-match.js
Randall Stillwell f94dc58c99 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 <cursoragent@cursor.com>
2026-05-27 12:58:16 -05:00

109 lines
2.8 KiB
JavaScript

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.`,
};
}