deckhearth/pages/api/scan/identify.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

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);
}
}