deckhearth/lib/card-visual-match.js

119 lines
3.3 KiB
JavaScript
Raw Normal View History

import { sql } from '@vercel/postgres';
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);
}