deckhearth/lib/card-catalog-match.js

351 lines
9.3 KiB
JavaScript
Raw Normal View History

import { sql } from '@vercel/postgres';
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,
};
}
function buildOcrPayload(fields) {
return {
name: fields.name?.trim() || null,
set: fields.set || null,
setCode: fields.setCode || null,
cardNumber: fields.cardNumber || null,
game: fields.game || null,
cardType: fields.cardType || null,
rarity: fields.rarity || null,
hp: fields.hp || null,
manaCost: fields.manaCost || null,
rawText: fields.ocrData?.rawText || null,
abilities: fields.ocrData?.abilities || [],
flavorText: fields.ocrData?.flavorText || null,
artist: fields.ocrData?.artist || null,
};
}
export async function submitScanForReview(userId, fields, candidateIds = []) {
const submissionId = await createCardSubmission(userId, fields, candidateIds);
const label = [fields.name, fields.set || fields.setCode, fields.cardNumber]
.filter(Boolean)
.join(' · ');
return {
type: 'submitted',
card: null,
submissionId,
needsReview: true,
message: label
? `"${label}" is not in our catalog yet. Your scan was saved for admin review (submission #${submissionId}).`
: `Your scan was saved for admin review (submission #${submissionId}).`,
};
}
async function createCardSubmission(userId, fields, candidateIds = []) {
const ocrPayload = buildOcrPayload(fields);
const result = await sql`
INSERT INTO card_submissions (
user_id, ocr_text, ocr_confidence, scan_image_url,
candidate_card_ids, ocr_payload, status
) VALUES (
${userId},
${fields.ocrData?.rawText || fields.name || null},
${fields.ocrData?.confidence ?? null},
${fields.scanImageUrl || null},
${JSON.stringify(candidateIds)},
${JSON.stringify(ocrPayload)},
'pending'
)
RETURNING id
`;
return result.rows[0].id;
}
/**
* Match OCR fields against the global cards catalog.
* Never INSERTs into cards unknowns become card_submissions.
*/
export async function matchCardInCatalog({
userId,
name,
set,
setCode,
cardNumber,
game,
cardType,
rarity,
hp,
manaCost,
ocrData,
scanImageUrl = null,
}) {
if (!name || typeof name !== 'string' || !name.trim()) {
return {
type: 'needs_input',
card: null,
matches: [],
needsUserInput: true,
message: 'Card name is required',
};
}
const trimmedName = name.trim();
let existingCard = null;
if ((set || setCode) && cardNumber) {
const exactResult = await sql`
SELECT * FROM cards
WHERE LOWER(name) = LOWER(${trimmedName})
AND (LOWER(set_name) = LOWER(${set || setCode}) OR LOWER(set_code) = LOWER(${setCode || set}))
AND LOWER(card_number) = LOWER(${cardNumber})
LIMIT 1
`;
if (exactResult.rows.length > 0) {
existingCard = exactResult.rows[0];
} else {
return submitScanForReview(
userId,
{
name: trimmedName,
set,
setCode,
cardNumber,
game,
cardType,
rarity,
hp,
manaCost,
ocrData,
scanImageUrl,
},
[]
);
}
}
if (!existingCard && (set || setCode)) {
const setResult = set
? await sql`
SELECT * FROM cards
WHERE LOWER(name) = LOWER(${trimmedName})
AND (LOWER(set_name) = LOWER(${set}) OR LOWER(set_code) = LOWER(${setCode || set}))
LIMIT 5
`
: await sql`
SELECT * FROM cards
WHERE LOWER(name) = LOWER(${trimmedName})
AND LOWER(set_code) = LOWER(${setCode})
LIMIT 5
`;
if (setResult.rows.length === 1) {
existingCard = setResult.rows[0];
} else if (setResult.rows.length > 1) {
return {
type: 'disambiguation',
card: null,
matches: setResult.rows.map(mapCardRow),
needsUserSelection: true,
message: `Found ${setResult.rows.length} matches for "${trimmedName}" in that set. Select the correct printing.`,
};
}
}
if (!existingCard) {
const nameResult = game
? await sql`
SELECT * FROM cards
WHERE LOWER(name) = LOWER(${trimmedName})
ORDER BY CASE WHEN game = ${game} THEN 0 ELSE 1 END, set_name, card_number
`
: await sql`
SELECT * FROM cards
WHERE LOWER(name) = LOWER(${trimmedName})
ORDER BY set_name, card_number
`;
if (nameResult.rows.length === 1) {
existingCard = nameResult.rows[0];
} else if (nameResult.rows.length > 1) {
return {
type: 'disambiguation',
card: null,
matches: nameResult.rows.slice(0, 8).map(mapCardRow),
needsUserSelection: true,
message: `Found ${nameResult.rows.length} printings of "${trimmedName}". Select the correct card.`,
};
}
}
if (!existingCard) {
const fuzzyResult = await sql`
SELECT * FROM cards
WHERE LOWER(name) ILIKE LOWER(${`%${trimmedName}%`})
ORDER BY
CASE
WHEN LOWER(name) = LOWER(${trimmedName}) THEN 1
WHEN LOWER(name) LIKE LOWER(${trimmedName + '%'}) THEN 2
WHEN LOWER(name) LIKE LOWER(${'%' + trimmedName + '%'}) THEN 3
ELSE 4
END,
CASE WHEN game = ${game || 'UNKNOWN'} THEN 1 ELSE 2 END,
LENGTH(name)
LIMIT 5
`;
if (fuzzyResult.rows.length > 0) {
const exactFuzzyMatch = fuzzyResult.rows.find(
(row) => row.name.toLowerCase() === trimmedName.toLowerCase()
);
if (exactFuzzyMatch && ocrData?.confidence >= 80) {
existingCard = exactFuzzyMatch;
} else if (ocrData?.confidence < 80 && fuzzyResult.rows.length > 1) {
return {
type: 'disambiguation',
card: null,
matches: fuzzyResult.rows.map(mapCardRow),
needsUserSelection: true,
message: `Found ${fuzzyResult.rows.length} possible matches for "${trimmedName}". Please select the correct card.`,
};
} else {
existingCard = fuzzyResult.rows[0];
}
}
}
if (existingCard) {
const hasSpecificPrinting = Boolean((set || setCode) && cardNumber);
if (hasSpecificPrinting) {
const printingMatch = await sql`
SELECT * FROM cards
WHERE id = ${existingCard.id}
AND (
LOWER(card_number) = LOWER(${cardNumber})
AND (
LOWER(set_name) = LOWER(${set || setCode})
OR LOWER(set_code) = LOWER(${setCode || set})
)
)
LIMIT 1
`;
if (printingMatch.rows.length === 0) {
const siblingIds = await sql`
SELECT id FROM cards WHERE LOWER(name) = LOWER(${trimmedName})
`;
return submitScanForReview(
userId,
{
name: trimmedName,
set,
setCode,
cardNumber,
game,
cardType,
rarity,
hp,
manaCost,
ocrData,
scanImageUrl,
},
siblingIds.rows.map((row) => row.id)
);
}
} else {
const siblingsResult = await sql`
SELECT * FROM cards
WHERE LOWER(name) = LOWER(${trimmedName})
ORDER BY set_name, card_number
`;
if (siblingsResult.rows.length > 1) {
const ordered = [
existingCard,
...siblingsResult.rows.filter((row) => row.id !== existingCard.id),
];
return {
type: 'disambiguation',
card: null,
matches: ordered.slice(0, 8).map(mapCardRow),
needsUserSelection: true,
message: `Found ${siblingsResult.rows.length} printings of "${trimmedName}". Confirm the correct one.`,
};
}
}
return {
type: 'matched',
card: existingCard,
isExisting: true,
message: `Found existing card: "${existingCard.name}"`,
};
}
const confidenceThreshold = 75;
if (!ocrData || ocrData.confidence < confidenceThreshold) {
return {
type: 'needs_input',
card: null,
matches: [],
needsUserInput: true,
message: `Could not find card "${trimmedName}" in database and confidence is low (${ocrData?.confidence || 0}%). Please verify the card name and try again.`,
};
}
return submitScanForReview(
userId,
{
name: trimmedName,
set,
setCode,
cardNumber,
game,
cardType,
rarity,
hp,
manaCost,
ocrData,
scanImageUrl,
},
[]
);
}
export async function logScanAttempt({
userId,
ocrText,
ocrConfidence,
layer = 2,
matchedCardId = null,
resultKind,
latencyMs,
}) {
try {
await sql`
INSERT INTO scan_attempts (
user_id, ocr_text, ocr_confidence, layer,
matched_card_id, result_kind, latency_ms
) VALUES (
${userId},
${ocrText || null},
${ocrConfidence ?? null},
${layer},
${matchedCardId},
${resultKind},
${latencyMs ?? null}
)
`;
} catch (error) {
// Telemetry must not block identification (e.g. migration not yet applied).
console.error('[logScanAttempt]', error.message);
}
}