deckhearth/lib/card-catalog-match.js
Randall Stillwell b0f9a9ec71 feat(scanner): move card identification server-side (convoy #2)
Replace browser Gemini/OCR with POST /api/scan/identify, add card_submissions
review queue, remove user-writable cards INSERT, and surface disambiguation
when catalog matching is ambiguous.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-27 08:44:02 -05:00

229 lines
6 KiB
JavaScript

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,
};
}
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];
}
}
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 1
`
: await sql`
SELECT * FROM cards
WHERE LOWER(name) = LOWER(${trimmedName})
AND LOWER(set_code) = LOWER(${setCode})
LIMIT 1
`;
if (setResult.rows.length > 0) {
existingCard = setResult.rows[0];
}
}
if (!existingCard) {
const nameResult = await sql`
SELECT * FROM cards
WHERE LOWER(name) = LOWER(${trimmedName})
ORDER BY
CASE WHEN game = ${game || 'UNKNOWN'} THEN 1 ELSE 2 END,
created_at DESC
LIMIT 1
`;
if (nameResult.rows.length > 0) {
existingCard = nameResult.rows[0];
}
}
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) {
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.`,
};
}
const submissionId = await createCardSubmission(
userId,
{ name: trimmedName, set, setCode, cardNumber, game, cardType, rarity, hp, manaCost, ocrData, scanImageUrl },
[]
);
return {
type: 'submitted',
card: null,
submissionId,
needsReview: true,
message: `Card "${trimmedName}" was not found in the catalog. Your scan was saved for admin review (submission #${submissionId}).`,
};
}
export async function logScanAttempt({
userId,
ocrText,
ocrConfidence,
layer = 2,
matchedCardId = null,
resultKind,
latencyMs,
}) {
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}
)
`;
}