deckhearth/pages/api/cards/identify-by-text.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

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