Replace @vercel/postgres, Blob, and Upstash with lib/sql.js, MinIO object storage, and CT 102 Redis rate limits. Add Dockerfile for Dokploy deploy, homelab runbooks, Neon data-copy helper, and point CI smoke/visual at the homelab URL instead of Vercel previews. Co-authored-by: Cursor <cursoragent@cursor.com>
172 lines
4.5 KiB
JavaScript
172 lines
4.5 KiB
JavaScript
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.`,
|
|
};
|
|
}
|