2026-08-15 10:32:13 -04:00
|
|
|
import { sql } from './sql.js';
|
2026-08-14 22:46:39 -04:00
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
}
|