fix(scanner): printing disambiguation + Layer-1 500 #39

Merged
varutasu merged 1 commit from fix/scanner-printing-disambiguation into main 2026-05-27 14:10:47 -04:00
3 changed files with 120 additions and 37 deletions
Showing only changes of commit 2d4600e2be - Show all commits

View file

@ -16,6 +16,9 @@ export default function CameraScanner({ onCardScanned, onError }) {
const [trackedCards, setTrackedCards] = useState([]); // Array of tracked card objects
const trackedCardsRef = useRef([]);
const nextCardIdRef = useRef(1);
const visionCooldownUntilRef = useRef(0);
const activeVerificationRef = useRef(0);
const lastErrorAtRef = useRef(0);
// Mana symbol settings
const [manaSymbolSettings, setManaSymbolSettings] = useState({ useSVG: false });
@ -278,7 +281,7 @@ export default function CameraScanner({ onCardScanned, onError }) {
const processIdentifyResponse = async (cardTracker, imageData, result) => {
if (!result.isCard) {
cardTracker.status = 'negative';
onError?.(result.reason || 'No trading card detected');
reportScannerError(result.reason || 'No trading card detected');
return;
}
@ -310,17 +313,29 @@ export default function CameraScanner({ onCardScanned, onError }) {
if (result.needsReview || result.needsUserInput) {
cardTracker.status = 'negative';
onError?.(result.message || 'Could not identify card — saved for review or retry.');
reportScannerError(result.message || 'Could not identify card — saved for review or retry.');
return;
}
cardTracker.status = 'negative';
onError?.('Could not identify card from scan.');
reportScannerError('Could not identify card from scan.');
};
const reportScannerError = (message) => {
const now = Date.now();
if (now - lastErrorAtRef.current < 4000) return;
lastErrorAtRef.current = now;
onError?.(message);
};
// Server-side card identification
const verifyCardShape = async (cardTracker) => {
if (!videoRef.current || !canvasRef.current || cardTracker.status !== 'detecting') return;
if (disambiguation) return;
if (activeVerificationRef.current >= 1) return;
activeVerificationRef.current += 1;
cardTracker.status = 'verifying';
try {
cardTracker.scanAttempts++;
@ -378,7 +393,12 @@ export default function CameraScanner({ onCardScanned, onError }) {
console.warn('Layer-1 OCR path failed, escalating to vision:', l1Error);
}
// Layer 2: vision via AI Gateway
// Layer 2: vision via AI Gateway (skip while rate-limited)
if (Date.now() < visionCooldownUntilRef.current) {
cardTracker.status = 'detecting';
return;
}
const response = await fetch('/api/scan/identify', {
method: 'POST',
headers: authHeaders,
@ -386,7 +406,8 @@ export default function CameraScanner({ onCardScanned, onError }) {
});
if (response.status === 429) {
onError?.('Too many scan attempts. Please wait a moment and try again.');
visionCooldownUntilRef.current = Date.now() + 60_000;
reportScannerError('Too many scan attempts. Please wait a moment and try again.');
cardTracker.status = 'negative';
return;
}
@ -402,7 +423,9 @@ export default function CameraScanner({ onCardScanned, onError }) {
} catch (error) {
console.error(`Error verifying card ${cardTracker.id}:`, error);
cardTracker.status = 'negative';
onError?.(error.message || 'Scan failed');
reportScannerError(error.message || 'Scan failed');
} finally {
activeVerificationRef.current = Math.max(0, activeVerificationRef.current - 1);
}
};
@ -423,13 +446,12 @@ export default function CameraScanner({ onCardScanned, onError }) {
trackingIntervalRef.current = setInterval(() => {
const cardsToVerify = trackedCardsRef.current.filter(card =>
card.status === 'detecting' &&
card.stableCount >= 4 && // Reduced back to 4 for better responsiveness
card.scanAttempts < 2 && // Allow 2 attempts again
Date.now() - card.firstSeen > 1500 // Reduced to 1.5 seconds
card.stableCount >= 6 &&
card.scanAttempts < 1 &&
Date.now() - card.firstSeen > 2500
);
// Verify up to 2 cards simultaneously to allow multi-card scanning
const cardsToProcess = cardsToVerify.slice(0, 2);
const cardsToProcess = cardsToVerify.slice(0, 1);
cardsToProcess.forEach(card => {
verifyCardShape(card);
});

View file

@ -104,30 +104,49 @@ export async function matchCardInCatalog({
SELECT * FROM cards
WHERE LOWER(name) = LOWER(${trimmedName})
AND (LOWER(set_name) = LOWER(${set}) OR LOWER(set_code) = LOWER(${setCode || set}))
LIMIT 1
LIMIT 5
`
: await sql`
SELECT * FROM cards
WHERE LOWER(name) = LOWER(${trimmedName})
AND LOWER(set_code) = LOWER(${setCode})
LIMIT 1
LIMIT 5
`;
if (setResult.rows.length > 0) {
if (setResult.rows.length === 1) {
existingCard = setResult.rows[0];
} else if (setResult.rows.length > 1) {
return {
type: 'disambiguation',
card: null,
matches: setResult.rows.map(mapCardRow),
needsUserSelection: true,
message: `Found ${setResult.rows.length} matches for "${trimmedName}" in that set. Select the correct printing.`,
};
}
}
if (!existingCard) {
const nameResult = await sql`
SELECT * FROM cards
WHERE LOWER(name) = LOWER(${trimmedName})
ORDER BY
CASE WHEN game = ${game || 'UNKNOWN'} THEN 1 ELSE 2 END,
created_at DESC
LIMIT 1
`;
if (nameResult.rows.length > 0) {
const nameResult = game
? await sql`
SELECT * FROM cards
WHERE LOWER(name) = LOWER(${trimmedName})
ORDER BY CASE WHEN game = ${game} THEN 0 ELSE 1 END, set_name, card_number
`
: await sql`
SELECT * FROM cards
WHERE LOWER(name) = LOWER(${trimmedName})
ORDER BY set_name, card_number
`;
if (nameResult.rows.length === 1) {
existingCard = nameResult.rows[0];
} else if (nameResult.rows.length > 1) {
return {
type: 'disambiguation',
card: null,
matches: nameResult.rows.slice(0, 8).map(mapCardRow),
needsUserSelection: true,
message: `Found ${nameResult.rows.length} printings of "${trimmedName}". Select the correct card.`,
};
}
}
@ -169,6 +188,26 @@ export async function matchCardInCatalog({
}
if (existingCard) {
const siblingsResult = await sql`
SELECT * FROM cards
WHERE LOWER(name) = LOWER(${trimmedName})
ORDER BY set_name, card_number
`;
if (siblingsResult.rows.length > 1) {
const ordered = [
existingCard,
...siblingsResult.rows.filter((row) => row.id !== existingCard.id),
];
return {
type: 'disambiguation',
card: null,
matches: ordered.slice(0, 8).map(mapCardRow),
needsUserSelection: true,
message: `Found ${siblingsResult.rows.length} printings of "${trimmedName}". Confirm the correct one.`,
};
}
return {
type: 'matched',
card: existingCard,

View file

@ -36,7 +36,6 @@ export function extractNameCandidate(ocrText) {
return ocrText.replace(/\s+/g, ' ').trim();
}
// Prefer the first substantial line (card titles are printed at the top).
const scored = lines.slice(0, 5).map((line, index) => ({
line,
score: line.length - index * 2,
@ -45,6 +44,26 @@ export function extractNameCandidate(ocrText) {
return scored[0].line;
}
async function querySimilarCards(query, game) {
if (game) {
return sql`
SELECT *, similarity(name, ${query}) AS sim
FROM cards
WHERE similarity(name, ${query}) > ${DISAMBIGUATION_THRESHOLD - 0.05}
ORDER BY sim DESC, CASE WHEN game = ${game} THEN 0 ELSE 1 END, LENGTH(name)
LIMIT 8
`;
}
return sql`
SELECT *, similarity(name, ${query}) AS sim
FROM cards
WHERE similarity(name, ${query}) > ${DISAMBIGUATION_THRESHOLD - 0.05}
ORDER BY sim DESC, LENGTH(name)
LIMIT 8
`;
}
/**
* Fuzzy match OCR text against cards.name using pg_trgm similarity.
*/
@ -60,19 +79,7 @@ export async function matchTextInCatalog({ ocrText, game = null, ocrConfidence =
};
}
const result = await sql`
SELECT
*,
similarity(name, ${query}) AS sim
FROM cards
WHERE similarity(name, ${query}) > ${DISAMBIGUATION_THRESHOLD - 0.05}
ORDER BY
sim DESC,
CASE WHEN ${game} IS NOT NULL AND game = ${game} THEN 0 ELSE 1 END,
LENGTH(name)
LIMIT 8
`;
const result = await querySimilarCards(query, game || null);
const candidates = result.rows.filter((row) => row.sim >= DISAMBIGUATION_THRESHOLD);
if (candidates.length === 0) {
@ -84,6 +91,21 @@ export async function matchTextInCatalog({ ocrText, game = null, ocrConfidence =
};
}
const normalizedQuery = query.toLowerCase();
const exactNameMatches = candidates.filter(
(row) => row.name?.toLowerCase() === normalizedQuery
);
// Same card name, multiple printings — always ask the user.
if (exactNameMatches.length > 1) {
return {
type: 'disambiguation',
matches: exactNameMatches.slice(0, 8).map(mapCardRow),
query,
message: `Found ${exactNameMatches.length} printings of "${query}". Select the correct one.`,
};
}
const top = candidates[0];
const runnerUp = candidates[1];
const clearWinner =