From 8dc6dd6e26d5180bd452a81e315983ac4bc89a93 Mon Sep 17 00:00:00 2001 From: varutasu <104105839+varutasu@users.noreply.github.com> Date: Wed, 27 May 2026 13:10:46 -0500 Subject: [PATCH] fix(scanner): Layer-1 SQL, printing picker, and rate-limit storm (#39) Fix identify-by-text 500 (Neon could not infer null game param type). When a card name has multiple catalog printings, show disambiguation instead of auto-picking the first match. Throttle concurrent vision calls and suppress repeated 429/error toasts during detection. Co-authored-by: Cursor --- components/CameraScanner.js | 44 +++++++++++++++++++------- lib/card-catalog-match.js | 63 ++++++++++++++++++++++++++++++------- lib/card-text-match.js | 50 ++++++++++++++++++++--------- 3 files changed, 120 insertions(+), 37 deletions(-) diff --git a/components/CameraScanner.js b/components/CameraScanner.js index 5f8e2fd..caf62b0 100644 --- a/components/CameraScanner.js +++ b/components/CameraScanner.js @@ -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); }); diff --git a/lib/card-catalog-match.js b/lib/card-catalog-match.js index d8a74f3..ab9b900 100644 --- a/lib/card-catalog-match.js +++ b/lib/card-catalog-match.js @@ -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, diff --git a/lib/card-text-match.js b/lib/card-text-match.js index 87b7388..ea2fa7f 100644 --- a/lib/card-text-match.js +++ b/lib/card-text-match.js @@ -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 =