import { sql } from './sql.js'; import { formatEmbeddingForPg } from './card-embed.js'; export const MATCH_THRESHOLD = 0.82; export const DISAMBIGUATION_THRESHOLD = 0.58; 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, }; } /** * Pure ranking logic for visual kNN candidates (unit-tested without DB). */ export function resolveVisualCandidates(candidates) { if (!candidates?.length) { return { type: 'escalate', reason: `No catalog match above ${DISAMBIGUATION_THRESHOLD} visual similarity`, }; } const top = candidates[0]; const runnerUp = candidates[1]; const clearWinner = top.sim >= MATCH_THRESHOLD && (!runnerUp || top.sim - runnerUp.sim >= 0.06); if (clearWinner) { return { type: 'matched', card: top, similarity: top.sim, message: `Matched "${top.name}" via visual search (${Math.round(top.sim * 100)}% similar)`, }; } return { type: 'disambiguation', matches: candidates.slice(0, 5).map(mapCardRow), similarity: top.sim, message: `Found ${candidates.length} visually similar printings. Select the correct one.`, }; } async function querySimilarCards(vectorLiteral, game) { if (game) { return sql` SELECT id, name, set_name, set_code, card_number, game, rarity, image_url, card_type, mana_cost, power, 1 - (embedding <=> ${vectorLiteral}::vector) AS sim FROM cards WHERE embedding IS NOT NULL AND game = ${game} AND 1 - (embedding <=> ${vectorLiteral}::vector) > ${DISAMBIGUATION_THRESHOLD - 0.05} ORDER BY embedding <=> ${vectorLiteral}::vector LIMIT 8 `; } return sql` SELECT id, name, set_name, set_code, card_number, game, rarity, image_url, card_type, mana_cost, power, 1 - (embedding <=> ${vectorLiteral}::vector) AS sim FROM cards WHERE embedding IS NOT NULL AND 1 - (embedding <=> ${vectorLiteral}::vector) > ${DISAMBIGUATION_THRESHOLD - 0.05} ORDER BY embedding <=> ${vectorLiteral}::vector LIMIT 8 `; } /** * True when at least one catalog row has a visual embedding index entry. */ export async function catalogHasEmbeddings() { const indexed = await sql` SELECT EXISTS( SELECT 1 FROM cards WHERE embedding IS NOT NULL LIMIT 1 ) AS has_embeddings `; return Boolean(indexed.rows[0]?.has_embeddings); } /** * kNN visual match against precomputed catalog embeddings (Layer 0). */ export async function matchVisualInCatalog({ embedding, game = null }) { if (!embedding?.length) { return { type: 'escalate', reason: 'Missing query embedding', }; } if (!(await catalogHasEmbeddings())) { return { type: 'escalate', reason: 'Catalog visual index is empty — run npm run backfill-embeddings', }; } const vectorLiteral = formatEmbeddingForPg(embedding); const result = await querySimilarCards(vectorLiteral, game || null); const candidates = result.rows.filter((row) => row.sim >= DISAMBIGUATION_THRESHOLD); return resolveVisualCandidates(candidates); }