* 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>
209 lines
6.1 KiB
JavaScript
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,
|
|
};
|
|
}
|