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