deckhearth/lib/card-text-match.js

173 lines
4.5 KiB
JavaScript
Raw Permalink Normal View History

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.`,
};
}