* 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>
119 lines
3.1 KiB
JavaScript
119 lines
3.1 KiB
JavaScript
import { getUserFromRequest } from '../../../lib/permission-middleware';
|
|
import { matchTextInCatalog } from '../../../lib/card-text-match.js';
|
|
import { logScanAttempt } from '../../../lib/card-catalog-match.js';
|
|
|
|
function formatCardResponse(card, ocrMeta) {
|
|
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: ocrMeta,
|
|
};
|
|
}
|
|
|
|
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 { ocrText, ocrConfidence, cardNumber, game } = req.body || {};
|
|
|
|
if (!ocrText || typeof ocrText !== 'string') {
|
|
return res.status(400).json({ error: 'ocrText is required' });
|
|
}
|
|
|
|
const matchResult = await matchTextInCatalog({ ocrText, cardNumber, game, ocrConfidence });
|
|
const latencyMs = Date.now() - startedAt;
|
|
|
|
const ocrMeta = {
|
|
confidence: ocrConfidence ?? null,
|
|
rawText: ocrText,
|
|
query: matchResult.query || ocrText,
|
|
cardNumber: matchResult.cardNumber ?? cardNumber ?? null,
|
|
};
|
|
|
|
if (matchResult.type === 'escalate') {
|
|
await logScanAttempt({
|
|
userId: user.userId,
|
|
ocrText,
|
|
ocrConfidence,
|
|
layer: 1,
|
|
resultKind: 'escalate',
|
|
latencyMs,
|
|
});
|
|
return res.status(200).json({
|
|
layer: 1,
|
|
escalate: true,
|
|
ocr: ocrMeta,
|
|
reason: matchResult.reason,
|
|
});
|
|
}
|
|
|
|
if (matchResult.type === 'matched') {
|
|
await logScanAttempt({
|
|
userId: user.userId,
|
|
ocrText,
|
|
ocrConfidence,
|
|
layer: 1,
|
|
matchedCardId: matchResult.card.id,
|
|
resultKind: 'matched',
|
|
latencyMs,
|
|
});
|
|
return res.status(200).json({
|
|
layer: 1,
|
|
escalate: false,
|
|
isCard: true,
|
|
card: formatCardResponse(matchResult.card, ocrMeta),
|
|
isExisting: true,
|
|
message: matchResult.message,
|
|
ocr: ocrMeta,
|
|
});
|
|
}
|
|
|
|
await logScanAttempt({
|
|
userId: user.userId,
|
|
ocrText,
|
|
ocrConfidence,
|
|
layer: 1,
|
|
resultKind: 'disambiguation',
|
|
latencyMs,
|
|
});
|
|
|
|
return res.status(200).json({
|
|
layer: 1,
|
|
escalate: false,
|
|
isCard: true,
|
|
card: null,
|
|
matches: matchResult.matches,
|
|
needsUserSelection: true,
|
|
message: matchResult.message,
|
|
ocr: ocrMeta,
|
|
});
|
|
} catch (error) {
|
|
console.error('[POST /api/cards/identify-by-text]', error);
|
|
|
|
if (String(error.message).includes('pg_trgm') || String(error.message).includes('similarity')) {
|
|
return res.status(503).json({
|
|
error: 'Text matching unavailable — run npm run migrate up (pg_trgm extension).',
|
|
});
|
|
}
|
|
|
|
return res.status(500).json({ error: 'Internal server error' });
|
|
}
|
|
}
|