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 <cursoragent@cursor.com>
This commit is contained in:
parent
d798e284c3
commit
8dc6dd6e26
3 changed files with 120 additions and 37 deletions
|
|
@ -16,6 +16,9 @@ export default function CameraScanner({ onCardScanned, onError }) {
|
||||||
const [trackedCards, setTrackedCards] = useState([]); // Array of tracked card objects
|
const [trackedCards, setTrackedCards] = useState([]); // Array of tracked card objects
|
||||||
const trackedCardsRef = useRef([]);
|
const trackedCardsRef = useRef([]);
|
||||||
const nextCardIdRef = useRef(1);
|
const nextCardIdRef = useRef(1);
|
||||||
|
const visionCooldownUntilRef = useRef(0);
|
||||||
|
const activeVerificationRef = useRef(0);
|
||||||
|
const lastErrorAtRef = useRef(0);
|
||||||
|
|
||||||
// Mana symbol settings
|
// Mana symbol settings
|
||||||
const [manaSymbolSettings, setManaSymbolSettings] = useState({ useSVG: false });
|
const [manaSymbolSettings, setManaSymbolSettings] = useState({ useSVG: false });
|
||||||
|
|
@ -278,7 +281,7 @@ export default function CameraScanner({ onCardScanned, onError }) {
|
||||||
const processIdentifyResponse = async (cardTracker, imageData, result) => {
|
const processIdentifyResponse = async (cardTracker, imageData, result) => {
|
||||||
if (!result.isCard) {
|
if (!result.isCard) {
|
||||||
cardTracker.status = 'negative';
|
cardTracker.status = 'negative';
|
||||||
onError?.(result.reason || 'No trading card detected');
|
reportScannerError(result.reason || 'No trading card detected');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -310,17 +313,29 @@ export default function CameraScanner({ onCardScanned, onError }) {
|
||||||
|
|
||||||
if (result.needsReview || result.needsUserInput) {
|
if (result.needsReview || result.needsUserInput) {
|
||||||
cardTracker.status = 'negative';
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
cardTracker.status = 'negative';
|
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
|
// Server-side card identification
|
||||||
const verifyCardShape = async (cardTracker) => {
|
const verifyCardShape = async (cardTracker) => {
|
||||||
if (!videoRef.current || !canvasRef.current || cardTracker.status !== 'detecting') return;
|
if (!videoRef.current || !canvasRef.current || cardTracker.status !== 'detecting') return;
|
||||||
|
if (disambiguation) return;
|
||||||
|
if (activeVerificationRef.current >= 1) return;
|
||||||
|
|
||||||
|
activeVerificationRef.current += 1;
|
||||||
|
cardTracker.status = 'verifying';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
cardTracker.scanAttempts++;
|
cardTracker.scanAttempts++;
|
||||||
|
|
@ -378,7 +393,12 @@ export default function CameraScanner({ onCardScanned, onError }) {
|
||||||
console.warn('Layer-1 OCR path failed, escalating to vision:', l1Error);
|
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', {
|
const response = await fetch('/api/scan/identify', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: authHeaders,
|
headers: authHeaders,
|
||||||
|
|
@ -386,7 +406,8 @@ export default function CameraScanner({ onCardScanned, onError }) {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.status === 429) {
|
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';
|
cardTracker.status = 'negative';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -402,7 +423,9 @@ export default function CameraScanner({ onCardScanned, onError }) {
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error verifying card ${cardTracker.id}:`, error);
|
console.error(`Error verifying card ${cardTracker.id}:`, error);
|
||||||
cardTracker.status = 'negative';
|
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(() => {
|
trackingIntervalRef.current = setInterval(() => {
|
||||||
const cardsToVerify = trackedCardsRef.current.filter(card =>
|
const cardsToVerify = trackedCardsRef.current.filter(card =>
|
||||||
card.status === 'detecting' &&
|
card.status === 'detecting' &&
|
||||||
card.stableCount >= 4 && // Reduced back to 4 for better responsiveness
|
card.stableCount >= 6 &&
|
||||||
card.scanAttempts < 2 && // Allow 2 attempts again
|
card.scanAttempts < 1 &&
|
||||||
Date.now() - card.firstSeen > 1500 // Reduced to 1.5 seconds
|
Date.now() - card.firstSeen > 2500
|
||||||
);
|
);
|
||||||
|
|
||||||
// Verify up to 2 cards simultaneously to allow multi-card scanning
|
const cardsToProcess = cardsToVerify.slice(0, 1);
|
||||||
const cardsToProcess = cardsToVerify.slice(0, 2);
|
|
||||||
cardsToProcess.forEach(card => {
|
cardsToProcess.forEach(card => {
|
||||||
verifyCardShape(card);
|
verifyCardShape(card);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -104,30 +104,49 @@ export async function matchCardInCatalog({
|
||||||
SELECT * FROM cards
|
SELECT * FROM cards
|
||||||
WHERE LOWER(name) = LOWER(${trimmedName})
|
WHERE LOWER(name) = LOWER(${trimmedName})
|
||||||
AND (LOWER(set_name) = LOWER(${set}) OR LOWER(set_code) = LOWER(${setCode || set}))
|
AND (LOWER(set_name) = LOWER(${set}) OR LOWER(set_code) = LOWER(${setCode || set}))
|
||||||
LIMIT 1
|
LIMIT 5
|
||||||
`
|
`
|
||||||
: await sql`
|
: await sql`
|
||||||
SELECT * FROM cards
|
SELECT * FROM cards
|
||||||
WHERE LOWER(name) = LOWER(${trimmedName})
|
WHERE LOWER(name) = LOWER(${trimmedName})
|
||||||
AND LOWER(set_code) = LOWER(${setCode})
|
AND LOWER(set_code) = LOWER(${setCode})
|
||||||
LIMIT 1
|
LIMIT 5
|
||||||
`;
|
`;
|
||||||
if (setResult.rows.length > 0) {
|
if (setResult.rows.length === 1) {
|
||||||
existingCard = setResult.rows[0];
|
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) {
|
if (!existingCard) {
|
||||||
const nameResult = await sql`
|
const nameResult = game
|
||||||
|
? await sql`
|
||||||
SELECT * FROM cards
|
SELECT * FROM cards
|
||||||
WHERE LOWER(name) = LOWER(${trimmedName})
|
WHERE LOWER(name) = LOWER(${trimmedName})
|
||||||
ORDER BY
|
ORDER BY CASE WHEN game = ${game} THEN 0 ELSE 1 END, set_name, card_number
|
||||||
CASE WHEN game = ${game || 'UNKNOWN'} THEN 1 ELSE 2 END,
|
`
|
||||||
created_at DESC
|
: await sql`
|
||||||
LIMIT 1
|
SELECT * FROM cards
|
||||||
|
WHERE LOWER(name) = LOWER(${trimmedName})
|
||||||
|
ORDER BY set_name, card_number
|
||||||
`;
|
`;
|
||||||
if (nameResult.rows.length > 0) {
|
if (nameResult.rows.length === 1) {
|
||||||
existingCard = nameResult.rows[0];
|
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) {
|
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 {
|
return {
|
||||||
type: 'matched',
|
type: 'matched',
|
||||||
card: existingCard,
|
card: existingCard,
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,6 @@ export function extractNameCandidate(ocrText) {
|
||||||
return ocrText.replace(/\s+/g, ' ').trim();
|
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) => ({
|
const scored = lines.slice(0, 5).map((line, index) => ({
|
||||||
line,
|
line,
|
||||||
score: line.length - index * 2,
|
score: line.length - index * 2,
|
||||||
|
|
@ -45,6 +44,26 @@ export function extractNameCandidate(ocrText) {
|
||||||
return scored[0].line;
|
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.
|
* 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`
|
const result = await querySimilarCards(query, game || null);
|
||||||
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 candidates = result.rows.filter((row) => row.sim >= DISAMBIGUATION_THRESHOLD);
|
const candidates = result.rows.filter((row) => row.sim >= DISAMBIGUATION_THRESHOLD);
|
||||||
|
|
||||||
if (candidates.length === 0) {
|
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 top = candidates[0];
|
||||||
const runnerUp = candidates[1];
|
const runnerUp = candidates[1];
|
||||||
const clearWinner =
|
const clearWinner =
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue