deckhearth/lib/scan-vision.js
varutasu d798e284c3
feat(scanner): AI Gateway vision + Layer-1 Tesseract/pg_trgm OCR (#38)
Route Layer-2 identification through Vercel AI Gateway (AI_GATEWAY_API_KEY,
default google/gemini-2.5-flash-lite). Add Layer-1 browser Tesseract name-strip
OCR with pg_trgm fuzzy catalog match via /api/cards/identify-by-text before
escalating to vision.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-27 12:59:59 -05:00

123 lines
4.1 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.).
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, blurry images, and non-card gaming items.
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,
};
}