deckhearth/lib/scan-vision.js
varutasu a251dacbd3
fix(scanner): catalog gap review path + not-listed disambiguation (#40)
When vision reads a set+number missing from the catalog, route to
card_submissions rather than sibling disambiguation. Adds a not-listed
modal action, background vision refine, foil-friendly prompt, and
submit-for-review API. Queues catalog-sync-vercel-cron convoy for later.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-27 13:42:51 -05:00

125 lines
4.4 KiB
JavaScript

const GATEWAY_URL = 'https://ai-gateway.vercel.sh/v1/chat/completions';
const DEFAULT_VISION_MODEL =
process.env.SCAN_VISION_MODEL || 'google/gemini-2.5-flash-lite';
const CARD_PROMPT = `You are a specialized trading card recognition system. Analyze this image and determine if it contains a trading card (Magic: The Gathering, Pokemon, Yu-Gi-Oh, Lorcana, etc.).
HOLOGRAPHIC / FOIL CARDS: Many cards have reflective foil surfaces with glare or rainbow streaks. Do NOT reject these as "not a card" — read through glare when possible and extract any visible name, set, and collector number.
CRITICAL: Only respond with card data if you can clearly identify a TRADING CARD in the image. Ignore random objects, books, papers, phone screens, screenshots, and non-card gaming items. Blurry images with no readable card frame should be rejected.
If you detect a trading card, extract information in this JSON format:
{
"isCard": true,
"cardName": "exact card name as printed on the card",
"setName": "set name if visible",
"setCode": "set code/symbol if visible",
"cardNumber": "collector number if visible",
"game": "mtg, pokemon, or lorcana (lowercase)",
"cardType": "creature, instant, sorcery, trainer, etc.",
"rarity": "common, uncommon, rare, mythic, etc.",
"manaCost": "mana cost if visible",
"hp": "HP or power if visible",
"abilities": ["list of abilities or attacks if clearly readable"],
"confidence": 85,
"rawText": "all text visible on the card"
}
If NO trading card is clearly visible, respond with:
{
"isCard": false,
"confidence": 0,
"reason": "No trading card detected in image"
}
Be conservative — only extract data you can clearly read. Quality over quantity.`;
function parseVisionJson(content) {
const cleanContent = content.replace(/```json\n?/g, '').replace(/```\n?/g, '').trim();
try {
return JSON.parse(cleanContent);
} catch {
const cardNameMatch = content.match(/card.*?name.*?[:"]\s*([^"'\n,}]+)/i);
return {
isCard: !!cardNameMatch,
cardName: cardNameMatch ? cardNameMatch[1].trim() : null,
confidence: 30,
rawText: content,
reason: 'Failed to parse structured response',
};
}
}
function normalizeGame(game) {
if (!game) return null;
const value = String(game).trim().toLowerCase();
if (value === 'mtg' || value.includes('magic')) return 'mtg';
if (value.includes('pokemon') || value.includes('pokémon')) return 'pokemon';
if (value.includes('lorcana')) return 'lorcana';
return value;
}
/**
* Server-side vision analysis via Vercel AI Gateway. Requires AI_GATEWAY_API_KEY.
* @param {string} imageDataUrl - data:image/jpeg;base64,... capture from scanner
*/
export async function analyzeCardImage(imageDataUrl) {
const apiKey = process.env.AI_GATEWAY_API_KEY;
if (!apiKey) {
throw new Error('AI_GATEWAY_API_KEY is not configured on the server');
}
if (!imageDataUrl?.includes(',')) {
throw new Error('Invalid image data format');
}
const response = await fetch(GATEWAY_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: DEFAULT_VISION_MODEL,
temperature: 0.1,
messages: [{
role: 'user',
content: [
{ type: 'text', text: CARD_PROMPT },
{ type: 'image_url', image_url: { url: imageDataUrl } },
],
}],
}),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
const message = errorData.error?.message || errorData.message || 'Unknown error';
throw new Error(`Vision API error: ${response.status} - ${message}`);
}
const data = await response.json();
const content = data.choices?.[0]?.message?.content;
if (!content) {
throw new Error('No response from vision model');
}
const result = parseVisionJson(content);
return {
isCard: result.isCard || false,
cardName: result.cardName || null,
setName: result.setName || null,
setCode: result.setCode || null,
cardNumber: result.cardNumber || null,
game: normalizeGame(result.game),
cardType: result.cardType || null,
rarity: result.rarity || null,
manaCost: result.manaCost || null,
hp: result.hp || null,
abilities: result.abilities || [],
confidence: result.confidence || 0,
rawText: result.rawText || content,
reason: result.reason || null,
};
}