deckhearth/lib/scan-vision.js
varutasu 0b4f419f49
Scanner identify upgrade — Phase 1 hot path (#156)
* docs(convoy): seed scanner identify upgrade epic and sub-convoys

Baseline scan_attempts telemetry and three-phase plan for faster, more
accurate card identification without touching scanner chrome.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(scanner): tighten Layer-1 identify hot path (Phase 1)

Cut verify hold-still gates, OCR collector numbers on Layer 1, request
structured Gemini JSON, and skip automatic L2 refine when L1 opens the
printing picker. Includes convoy UX/architecture briefs and unit tests.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-14 20:21:20 -05:00

209 lines
6.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.).
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.
Respond with JSON only (no markdown fences) using this shape:
{
"isCard": true,
"cardName": "exact card name",
"setName": "set name or null",
"setCode": "set code or null",
"cardNumber": "collector number or null",
"game": "mtg, pokemon, or lorcana",
"cardType": "creature, instant, etc.",
"rarity": "common, uncommon, rare, mythic, etc.",
"manaCost": "mana cost or null",
"hp": "HP or power or null",
"abilities": ["ability strings"],
"confidence": 85,
"rawText": "all visible text",
"reason": null
}
If NO trading card is clearly visible, respond with:
{
"isCard": false,
"cardName": null,
"setName": null,
"setCode": null,
"cardNumber": null,
"game": null,
"cardType": null,
"rarity": null,
"manaCost": null,
"hp": null,
"abilities": [],
"confidence": 0,
"rawText": null,
"reason": "No trading card detected in image"
}
Be conservative — only extract data you can clearly read. Quality over quantity.`;
const CARD_VISION_SCHEMA = {
name: 'card_scan_result',
strict: true,
schema: {
type: 'object',
additionalProperties: false,
properties: {
isCard: { type: 'boolean' },
cardName: { type: ['string', 'null'] },
setName: { type: ['string', 'null'] },
setCode: { type: ['string', 'null'] },
cardNumber: { type: ['string', 'null'] },
game: { type: ['string', 'null'] },
cardType: { type: ['string', 'null'] },
rarity: { type: ['string', 'null'] },
manaCost: { type: ['string', 'null'] },
hp: { type: ['string', 'null'] },
abilities: {
type: 'array',
items: { type: 'string' },
},
confidence: { type: 'number' },
rawText: { type: ['string', 'null'] },
reason: { type: ['string', 'null'] },
},
required: [
'isCard',
'cardName',
'setName',
'setCode',
'cardNumber',
'game',
'cardType',
'rarity',
'manaCost',
'hp',
'abilities',
'confidence',
'rawText',
'reason',
],
},
};
export class VisionParseError extends Error {
constructor(message) {
super(message);
this.name = 'VisionParseError';
}
}
function parseVisionJson(content) {
if (!content || typeof content !== 'string') {
throw new VisionParseError('Empty vision model response');
}
const cleanContent = content.replace(/```json\n?/g, '').replace(/```\n?/g, '').trim();
try {
return JSON.parse(cleanContent);
} catch {
throw new VisionParseError('Failed to parse structured vision 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;
}
function buildGatewayBody(imageDataUrl, { structured = true } = {}) {
const body = {
model: DEFAULT_VISION_MODEL,
temperature: 0.1,
messages: [{
role: 'user',
content: [
{ type: 'text', text: CARD_PROMPT },
{ type: 'image_url', image_url: { url: imageDataUrl } },
],
}],
};
if (structured) {
body.response_format = {
type: 'json_schema',
json_schema: CARD_VISION_SCHEMA,
};
}
return body;
}
/**
* 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');
}
let response = await fetch(GATEWAY_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify(buildGatewayBody(imageDataUrl)),
});
if (!response.ok && response.status === 400) {
response = await fetch(GATEWAY_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify(buildGatewayBody(imageDataUrl, { structured: false })),
});
}
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 VisionParseError('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,
};
}