150 lines
4.2 KiB
JavaScript
150 lines
4.2 KiB
JavaScript
|
|
import { getUserFromRequest } from '../../../lib/permission-middleware';
|
||
|
|
import { embedCardImage, EmbedApiError } from '../../../lib/card-embed.js';
|
||
|
|
import { matchVisualInCatalog, catalogHasEmbeddings } from '../../../lib/card-visual-match.js';
|
||
|
|
import { logScanAttempt } from '../../../lib/card-catalog-match.js';
|
||
|
|
|
||
|
|
function formatCardResponse(card) {
|
||
|
|
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,
|
||
|
|
visual: {
|
||
|
|
similarity: card.sim,
|
||
|
|
},
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
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 { imageData, game } = 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' });
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!(await catalogHasEmbeddings())) {
|
||
|
|
const latencyMs = Date.now() - startedAt;
|
||
|
|
await logScanAttempt({
|
||
|
|
userId: user.userId,
|
||
|
|
ocrText: null,
|
||
|
|
ocrConfidence: null,
|
||
|
|
layer: 0,
|
||
|
|
resultKind: 'escalate',
|
||
|
|
latencyMs,
|
||
|
|
});
|
||
|
|
return res.status(200).json({
|
||
|
|
layer: 0,
|
||
|
|
escalate: true,
|
||
|
|
reason: 'Catalog visual index is empty — run npm run backfill-embeddings',
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
const embedding = await embedCardImage(imageData);
|
||
|
|
const matchResult = await matchVisualInCatalog({ embedding, game: game || null });
|
||
|
|
const latencyMs = Date.now() - startedAt;
|
||
|
|
|
||
|
|
if (matchResult.type === 'escalate') {
|
||
|
|
await logScanAttempt({
|
||
|
|
userId: user.userId,
|
||
|
|
ocrText: null,
|
||
|
|
ocrConfidence: null,
|
||
|
|
layer: 0,
|
||
|
|
resultKind: 'escalate',
|
||
|
|
latencyMs,
|
||
|
|
});
|
||
|
|
return res.status(200).json({
|
||
|
|
layer: 0,
|
||
|
|
escalate: true,
|
||
|
|
reason: matchResult.reason,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
if (matchResult.type === 'matched') {
|
||
|
|
await logScanAttempt({
|
||
|
|
userId: user.userId,
|
||
|
|
ocrText: null,
|
||
|
|
ocrConfidence: Math.round((matchResult.similarity || 0) * 100),
|
||
|
|
layer: 0,
|
||
|
|
matchedCardId: matchResult.card.id,
|
||
|
|
resultKind: 'matched',
|
||
|
|
latencyMs,
|
||
|
|
});
|
||
|
|
return res.status(200).json({
|
||
|
|
layer: 0,
|
||
|
|
escalate: false,
|
||
|
|
isCard: true,
|
||
|
|
card: formatCardResponse(matchResult.card),
|
||
|
|
isExisting: true,
|
||
|
|
message: matchResult.message,
|
||
|
|
visual: { similarity: matchResult.similarity },
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
await logScanAttempt({
|
||
|
|
userId: user.userId,
|
||
|
|
ocrText: null,
|
||
|
|
ocrConfidence: Math.round((matchResult.similarity || 0) * 100),
|
||
|
|
layer: 0,
|
||
|
|
resultKind: 'disambiguation',
|
||
|
|
latencyMs,
|
||
|
|
});
|
||
|
|
|
||
|
|
return res.status(200).json({
|
||
|
|
layer: 0,
|
||
|
|
escalate: false,
|
||
|
|
isCard: true,
|
||
|
|
card: null,
|
||
|
|
matches: matchResult.matches,
|
||
|
|
needsUserSelection: true,
|
||
|
|
message: matchResult.message,
|
||
|
|
visual: { similarity: matchResult.similarity },
|
||
|
|
});
|
||
|
|
} catch (error) {
|
||
|
|
console.error('[POST /api/scan/identify-by-image]', error);
|
||
|
|
|
||
|
|
if (error instanceof EmbedApiError) {
|
||
|
|
if (error.message.includes('AI_GATEWAY_API_KEY')) {
|
||
|
|
return res.status(503).json({
|
||
|
|
error: 'Visual matching is not configured on this server (missing AI_GATEWAY_API_KEY).',
|
||
|
|
});
|
||
|
|
}
|
||
|
|
if (error.status === 429) {
|
||
|
|
return res.status(502).json({
|
||
|
|
error: 'Embedding service quota exceeded. Try again later.',
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (String(error.message).includes('vector') || String(error.message).includes('pgvector')) {
|
||
|
|
return res.status(503).json({
|
||
|
|
error: 'Visual matching unavailable — run npm run migrate up (pgvector extension).',
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
return res.status(500).json({ error: 'Internal server error' });
|
||
|
|
}
|
||
|
|
}
|