Use the same vision model as the deleted browser client, surface Gemini quota/denial/migration failures as 502/503 with readable text, and stop scan_attempts telemetry from blocking identification. Co-authored-by: Cursor <cursoragent@cursor.com>
234 lines
6.2 KiB
JavaScript
234 lines
6.2 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,
|
|
}) {
|
|
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);
|
|
}
|
|
}
|