import { sql } from './sql.js'; import { findPrintingByCollectorNumber } from './card-number-utils.js'; 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(); } 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; } async function querySimilarCards(query, game) { if (game) { return sql` SELECT *, similarity(name, ${query}) AS sim FROM cards WHERE similarity(name, ${query}) > ${DISAMBIGUATION_THRESHOLD - 0.05} ORDER BY sim DESC, CASE WHEN game = ${game} THEN 0 ELSE 1 END, LENGTH(name) LIMIT 8 `; } return sql` SELECT *, similarity(name, ${query}) AS sim FROM cards WHERE similarity(name, ${query}) > ${DISAMBIGUATION_THRESHOLD - 0.05} ORDER BY sim DESC, LENGTH(name) LIMIT 8 `; } function resolveExactNameMatches(exactNameMatches, cardNumber, query) { if (exactNameMatches.length === 1) { return { type: 'matched', card: exactNameMatches[0], query, similarity: exactNameMatches[0].sim, message: `Matched "${exactNameMatches[0].name}" via text search`, }; } if (exactNameMatches.length > 1 && cardNumber) { const uniqueByNumber = findPrintingByCollectorNumber(exactNameMatches, cardNumber); if (uniqueByNumber) { return { type: 'matched', card: uniqueByNumber, query, similarity: uniqueByNumber.sim, message: `Matched "${uniqueByNumber.name}" (${uniqueByNumber.card_number}) via collector number`, }; } } if (exactNameMatches.length > 1) { return { type: 'disambiguation', matches: exactNameMatches.slice(0, 8).map(mapCardRow), query, message: `Found ${exactNameMatches.length} printings of "${query}". Select the correct one.`, }; } return null; } /** * Fuzzy match OCR text against cards.name using pg_trgm similarity. */ export async function matchTextInCatalog({ ocrText, cardNumber = null, game = null, ocrConfidence = null, }) { const query = extractNameCandidate(ocrText); const trimmedNumber = cardNumber && String(cardNumber).trim() ? String(cardNumber).trim() : null; if (!query || query.length < MIN_QUERY_LENGTH) { return { type: 'escalate', reason: 'OCR text too short for catalog match', query, ocrConfidence, cardNumber: trimmedNumber, }; } const result = await querySimilarCards(query, game || null); 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, cardNumber: trimmedNumber, }; } const normalizedQuery = query.toLowerCase(); const exactNameMatches = candidates.filter( (row) => row.name?.toLowerCase() === normalizedQuery ); const exactResolution = resolveExactNameMatches(exactNameMatches, trimmedNumber, query); if (exactResolution) { return exactResolution; } 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.`, }; }