2026-05-27 13:59:59 -04:00
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-27 14:10:46 -04:00
|
|
|
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
|
|
|
|
|
`;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-27 13:59:59 -04:00
|
|
|
/**
|
|
|
|
|
* 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,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-27 14:10:46 -04:00
|
|
|
const result = await querySimilarCards(query, game || null);
|
2026-05-27 13:59:59 -04:00
|
|
|
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,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-27 14:10:46 -04:00
|
|
|
const normalizedQuery = query.toLowerCase();
|
|
|
|
|
const exactNameMatches = candidates.filter(
|
|
|
|
|
(row) => row.name?.toLowerCase() === normalizedQuery
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Same card name, multiple printings — always ask the user.
|
|
|
|
|
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.`,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-27 13:59:59 -04:00
|
|
|
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.`,
|
|
|
|
|
};
|
|
|
|
|
}
|