* 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>
244 lines
6.7 KiB
JavaScript
244 lines
6.7 KiB
JavaScript
import { getUserFromRequest } from '../../../lib/permission-middleware';
|
|
import { checkScanRateLimit } from '../../../lib/rate-limit.js';
|
|
import { analyzeCardImage, VisionParseError } from '../../../lib/scan-vision.js';
|
|
import { matchCardInCatalog, logScanAttempt } from '../../../lib/card-catalog-match.js';
|
|
|
|
function scanErrorResponse(error) {
|
|
const msg = error?.message || '';
|
|
|
|
if (msg.includes('AI_GATEWAY_API_KEY is not configured')) {
|
|
return {
|
|
status: 503,
|
|
body: {
|
|
error: 'Card scanning is not configured on this server (missing AI_GATEWAY_API_KEY).',
|
|
},
|
|
};
|
|
}
|
|
|
|
if (msg.includes('Vision API error: 429')) {
|
|
return {
|
|
status: 502,
|
|
body: {
|
|
error: 'Vision service quota exceeded. Check AI Gateway billing or try again later.',
|
|
},
|
|
};
|
|
}
|
|
|
|
if (msg.includes('Vision API error: 403') || msg.includes('PERMISSION_DENIED')) {
|
|
return {
|
|
status: 502,
|
|
body: {
|
|
error: 'Vision service access denied. Check AI Gateway model access and API key.',
|
|
},
|
|
};
|
|
}
|
|
|
|
if (msg.includes('scan_attempts') || msg.includes('card_submissions')) {
|
|
return {
|
|
status: 503,
|
|
body: {
|
|
error: 'Scan database tables are missing. Run npm run migrate up on the deployment database.',
|
|
},
|
|
};
|
|
}
|
|
|
|
if (error instanceof VisionParseError || msg.includes('Failed to parse structured vision')) {
|
|
return {
|
|
status: 200,
|
|
body: {
|
|
isCard: true,
|
|
card: null,
|
|
needsUserInput: true,
|
|
message: 'Could not read card details from scan — try again or pick from matches.',
|
|
},
|
|
};
|
|
}
|
|
|
|
return {
|
|
status: 500,
|
|
body: { error: 'Internal server error' },
|
|
};
|
|
}
|
|
|
|
function formatCardResponse(card, ocrResult) {
|
|
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,
|
|
card_type: card.card_type,
|
|
rarity: card.rarity,
|
|
hp: card.power,
|
|
mana_cost: card.mana_cost,
|
|
image_url: card.image_url,
|
|
ocr: {
|
|
confidence: ocrResult.confidence,
|
|
rawText: ocrResult.rawText,
|
|
abilities: ocrResult.abilities,
|
|
},
|
|
};
|
|
}
|
|
|
|
function buildOcrPayload(ocrResult) {
|
|
return {
|
|
confidence: ocrResult.confidence,
|
|
rawText: ocrResult.rawText,
|
|
cardName: ocrResult.cardName,
|
|
setName: ocrResult.setName,
|
|
setCode: ocrResult.setCode,
|
|
cardNumber: ocrResult.cardNumber,
|
|
abilities: ocrResult.abilities,
|
|
};
|
|
}
|
|
|
|
export default async function handler(req, res) {
|
|
if (req.method !== 'POST') {
|
|
return res.status(405).json({ error: 'Method not allowed' });
|
|
}
|
|
|
|
const startedAt = Date.now();
|
|
|
|
try {
|
|
const user = await getUserFromRequest(req);
|
|
if (!user) {
|
|
return res.status(401).json({ error: 'Authentication required' });
|
|
}
|
|
|
|
const { allowed, reset } = await checkScanRateLimit(req, user.userId);
|
|
if (!allowed) {
|
|
res.setHeader('Retry-After', Math.ceil((reset - Date.now()) / 1000));
|
|
return res.status(429).json({ error: 'Too many attempts. Try again later.' });
|
|
}
|
|
|
|
const { imageData, game: preferredGame } = req.body || {};
|
|
|
|
if (!imageData || typeof imageData !== 'string') {
|
|
return res.status(400).json({ error: 'imageData is required' });
|
|
}
|
|
|
|
if (imageData.length > 6_000_000) {
|
|
return res.status(400).json({ error: 'Image payload too large' });
|
|
}
|
|
|
|
const ocrResult = await analyzeCardImage(imageData);
|
|
const latencyMs = Date.now() - startedAt;
|
|
const hasPartialCard =
|
|
ocrResult.cardName &&
|
|
typeof ocrResult.cardName === 'string' &&
|
|
ocrResult.cardName.trim().length > 0;
|
|
|
|
if ((!ocrResult.isCard || ocrResult.confidence <= 60) && !hasPartialCard) {
|
|
await logScanAttempt({
|
|
userId: user.userId,
|
|
ocrText: ocrResult.rawText,
|
|
ocrConfidence: ocrResult.confidence,
|
|
layer: 2,
|
|
resultKind: 'not_a_card',
|
|
latencyMs,
|
|
});
|
|
return res.status(200).json({
|
|
isCard: false,
|
|
confidence: ocrResult.confidence,
|
|
reason: ocrResult.reason || 'No trading card detected',
|
|
});
|
|
}
|
|
|
|
const matchResult = await matchCardInCatalog({
|
|
userId: user.userId,
|
|
name: ocrResult.cardName,
|
|
set: ocrResult.setName,
|
|
setCode: ocrResult.setCode,
|
|
cardNumber: ocrResult.cardNumber,
|
|
game: preferredGame || ocrResult.game,
|
|
cardType: ocrResult.cardType,
|
|
rarity: ocrResult.rarity,
|
|
hp: ocrResult.hp,
|
|
manaCost: ocrResult.manaCost,
|
|
ocrData: {
|
|
confidence: ocrResult.confidence,
|
|
rawText: ocrResult.rawText,
|
|
abilities: ocrResult.abilities,
|
|
},
|
|
scanImageUrl: null,
|
|
});
|
|
|
|
if (matchResult.type === 'matched') {
|
|
await logScanAttempt({
|
|
userId: user.userId,
|
|
ocrText: ocrResult.rawText,
|
|
ocrConfidence: ocrResult.confidence,
|
|
layer: 2,
|
|
matchedCardId: matchResult.card.id,
|
|
resultKind: 'matched',
|
|
latencyMs,
|
|
});
|
|
return res.status(200).json({
|
|
isCard: true,
|
|
card: formatCardResponse(matchResult.card, ocrResult),
|
|
isExisting: matchResult.isExisting,
|
|
message: matchResult.message,
|
|
});
|
|
}
|
|
|
|
if (matchResult.type === 'disambiguation') {
|
|
await logScanAttempt({
|
|
userId: user.userId,
|
|
ocrText: ocrResult.rawText,
|
|
ocrConfidence: ocrResult.confidence,
|
|
layer: 2,
|
|
resultKind: 'disambiguation',
|
|
latencyMs,
|
|
});
|
|
return res.status(200).json({
|
|
isCard: true,
|
|
card: null,
|
|
matches: matchResult.matches,
|
|
needsUserSelection: true,
|
|
ocr: buildOcrPayload(ocrResult),
|
|
message: matchResult.message,
|
|
});
|
|
}
|
|
|
|
if (matchResult.type === 'submitted') {
|
|
await logScanAttempt({
|
|
userId: user.userId,
|
|
ocrText: ocrResult.rawText,
|
|
ocrConfidence: ocrResult.confidence,
|
|
layer: 2,
|
|
resultKind: 'submitted',
|
|
latencyMs,
|
|
});
|
|
return res.status(200).json({
|
|
isCard: true,
|
|
card: null,
|
|
submissionId: matchResult.submissionId,
|
|
needsReview: true,
|
|
ocr: buildOcrPayload(ocrResult),
|
|
message: matchResult.message,
|
|
});
|
|
}
|
|
|
|
await logScanAttempt({
|
|
userId: user.userId,
|
|
ocrText: ocrResult.rawText,
|
|
ocrConfidence: ocrResult.confidence,
|
|
layer: 2,
|
|
resultKind: 'needs_input',
|
|
latencyMs,
|
|
});
|
|
|
|
return res.status(200).json({
|
|
isCard: true,
|
|
card: null,
|
|
needsUserInput: true,
|
|
ocr: buildOcrPayload(ocrResult),
|
|
message: matchResult.message,
|
|
});
|
|
} catch (error) {
|
|
console.error('[POST /api/scan/identify]', error);
|
|
const { status, body } = scanErrorResponse(error);
|
|
return res.status(status).json(body);
|
|
}
|
|
}
|