feat(scanner): server-side scan pipeline (convoy #2) #35
12 changed files with 1111 additions and 1314 deletions
27
.github/workflows/ci.yml
vendored
27
.github/workflows/ci.yml
vendored
|
|
@ -164,30 +164,25 @@ jobs:
|
||||||
done
|
done
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
# Grandfathered until server-side-scan-pipeline (#2) removes browser LLM clients.
|
AI_OCR_IMPORTS=$(grep -rEn 'from ['\''"].*ai-ocr|import.*ai-ocr' components/ 2>/dev/null || true)
|
||||||
GRANDFATHER=(
|
if [ -n "$AI_OCR_IMPORTS" ]; then
|
||||||
lib/ai-ocr.js
|
echo "::error::Browser code must not import lib/ai-ocr — use POST /api/scan/identify instead."
|
||||||
components/OCRSettings.js
|
echo "$AI_OCR_IMPORTS" | while IFS= read -r line; do
|
||||||
)
|
file=$(echo "$line" | cut -d: -f1)
|
||||||
|
lineno=$(echo "$line" | cut -d: -f2)
|
||||||
|
echo "::error file=${file},line=${lineno}::Remove ai-ocr import; call server-side scan API."
|
||||||
|
done
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
LLM_PATTERN='generativelanguage\.googleapis\.com|api\.openai\.com'
|
LLM_PATTERN='generativelanguage\.googleapis\.com|api\.openai\.com'
|
||||||
FOUND=()
|
FOUND=()
|
||||||
while IFS= read -r file; do
|
while IFS= read -r file; do
|
||||||
skip=false
|
|
||||||
for gf in "${GRANDFATHER[@]}"; do
|
|
||||||
if [ "$file" = "$gf" ]; then
|
|
||||||
skip=true
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
if [ "$skip" = true ]; then
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
if grep -qE "$LLM_PATTERN" "$file" 2>/dev/null; then
|
if grep -qE "$LLM_PATTERN" "$file" 2>/dev/null; then
|
||||||
FOUND+=("$file")
|
FOUND+=("$file")
|
||||||
fi
|
fi
|
||||||
done < <(find components lib pages -name '*.js' ! -path 'pages/api/*' 2>/dev/null || true)
|
done < <(find components lib pages -name '*.js' ! -path 'pages/api/*' 2>/dev/null || true)
|
||||||
if [ ${#FOUND[@]} -gt 0 ]; then
|
if [ ${#FOUND[@]} -gt 0 ]; then
|
||||||
echo "::error::Client-side LLM API URLs must not appear outside pages/api/ (except grandfathered files pending server-side-scan-pipeline)."
|
echo "::error::Client-side LLM API URLs must not appear outside pages/api/."
|
||||||
for path in "${FOUND[@]}"; do
|
for path in "${FOUND[@]}"; do
|
||||||
echo "::error file=${path}::Move LLM calls server-side or add to server-side-scan-pipeline removal list."
|
echo "::error file=${path}::Move LLM calls server-side or add to server-side-scan-pipeline removal list."
|
||||||
done
|
done
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,9 @@
|
||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { aiCardOCR, ollamaCardOCR, puterCardOCR, geminiCardOCR } from '../lib/ai-ocr';
|
|
||||||
|
|
||||||
export default function CameraScanner({ onCardScanned, onError }) {
|
export default function CameraScanner({ onCardScanned, onError }) {
|
||||||
const [isStreaming, setIsStreaming] = useState(false);
|
const [isStreaming, setIsStreaming] = useState(false);
|
||||||
const [isDetecting, setIsDetecting] = useState(false);
|
const [isDetecting, setIsDetecting] = useState(false);
|
||||||
const [scanningAnimation, setScanningAnimation] = useState(false);
|
const [disambiguation, setDisambiguation] = useState(null);
|
||||||
const [scanResult, setScanResult] = useState(null);
|
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
|
||||||
const [ocrSettings, setOcrSettings] = useState({
|
|
||||||
service: 'gemini',
|
|
||||||
openaiApiKey: '',
|
|
||||||
geminiApiKey: '',
|
|
||||||
ollamaUrl: 'http://localhost:11434'
|
|
||||||
});
|
|
||||||
|
|
||||||
const videoRef = useRef(null);
|
const videoRef = useRef(null);
|
||||||
const canvasRef = useRef(null);
|
const canvasRef = useRef(null);
|
||||||
|
|
@ -29,38 +20,6 @@ export default function CameraScanner({ onCardScanned, onError }) {
|
||||||
// Mana symbol settings
|
// Mana symbol settings
|
||||||
const [manaSymbolSettings, setManaSymbolSettings] = useState({ useSVG: false });
|
const [manaSymbolSettings, setManaSymbolSettings] = useState({ useSVG: false });
|
||||||
|
|
||||||
// Load OCR settings from localStorage only (never fetch server-side API keys)
|
|
||||||
useEffect(() => {
|
|
||||||
let settings = {
|
|
||||||
service: 'gemini',
|
|
||||||
openaiApiKey: '',
|
|
||||||
geminiApiKey: '',
|
|
||||||
ollamaUrl: 'http://localhost:11434'
|
|
||||||
};
|
|
||||||
|
|
||||||
const savedSettings = localStorage.getItem('ocrSettings');
|
|
||||||
if (savedSettings) {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(savedSettings);
|
|
||||||
settings = { ...settings, ...parsed };
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load OCR settings:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setOcrSettings(settings);
|
|
||||||
|
|
||||||
if (settings.openaiApiKey) {
|
|
||||||
aiCardOCR.setApiKey(settings.openaiApiKey);
|
|
||||||
}
|
|
||||||
if (settings.geminiApiKey) {
|
|
||||||
geminiCardOCR.setApiKey(settings.geminiApiKey);
|
|
||||||
}
|
|
||||||
if (settings.ollamaUrl) {
|
|
||||||
ollamaCardOCR.setBaseUrl(settings.ollamaUrl);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Configure canvas contexts for optimal performance
|
// Configure canvas contexts for optimal performance
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (canvasRef.current) {
|
if (canvasRef.current) {
|
||||||
|
|
@ -280,132 +239,11 @@ export default function CameraScanner({ onCardScanned, onError }) {
|
||||||
setTrackedCards(updatedCards);
|
setTrackedCards(updatedCards);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Quick card verification using AI
|
const emitScannedCard = (cardTracker, imageData, finalCard, ocrMeta = {}) => {
|
||||||
const verifyCardShape = async (cardTracker) => {
|
|
||||||
if (!videoRef.current || !canvasRef.current || cardTracker.status !== 'detecting') return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
console.log(`🔍 Verifying card ${cardTracker.id}...`);
|
|
||||||
cardTracker.scanAttempts++;
|
|
||||||
|
|
||||||
const video = videoRef.current;
|
|
||||||
const canvas = canvasRef.current;
|
|
||||||
const ctx = canvas.getContext('2d');
|
|
||||||
|
|
||||||
// Capture the specific region
|
|
||||||
const { x, y, width, height } = cardTracker.bounds;
|
|
||||||
const margin = 20; // Add some margin around the detected area
|
|
||||||
|
|
||||||
canvas.width = width + margin * 2;
|
|
||||||
canvas.height = height + margin * 2;
|
|
||||||
|
|
||||||
// Draw the card region with margin
|
|
||||||
ctx.drawImage(
|
|
||||||
video,
|
|
||||||
Math.max(0, x - margin), Math.max(0, y - margin),
|
|
||||||
width + margin * 2, height + margin * 2,
|
|
||||||
0, 0,
|
|
||||||
canvas.width, canvas.height
|
|
||||||
);
|
|
||||||
|
|
||||||
const imageData = canvas.toDataURL('image/jpeg', 0.8);
|
|
||||||
|
|
||||||
// Quick AI verification
|
|
||||||
let ocrResult;
|
|
||||||
console.log('🔍 Current OCR settings:', ocrSettings);
|
|
||||||
|
|
||||||
if (ocrSettings.service === 'puter') {
|
|
||||||
console.log('🎯 Using Puter.js (Free AI Vision)...');
|
|
||||||
ocrResult = await puterCardOCR.analyzeCard(imageData);
|
|
||||||
console.log('✅ Puter.js Vision result:', ocrResult);
|
|
||||||
} else if (ocrSettings.service === 'openai') {
|
|
||||||
console.log('🤖 Using OpenAI Vision API...');
|
|
||||||
ocrResult = await aiCardOCR.analyzeCard(imageData);
|
|
||||||
console.log('✅ OpenAI Vision result:', ocrResult);
|
|
||||||
} else if (ocrSettings.service === 'ollama') {
|
|
||||||
console.log('🦙 Using Ollama Vision...');
|
|
||||||
ocrResult = await ollamaCardOCR.analyzeCard(imageData);
|
|
||||||
console.log('✅ Ollama Vision result:', ocrResult);
|
|
||||||
} else if (ocrSettings.service === 'gemini') {
|
|
||||||
console.log('🤖 Using Gemini Vision API...');
|
|
||||||
ocrResult = await geminiCardOCR.analyzeCard(imageData);
|
|
||||||
console.log('✅ Gemini Vision result:', ocrResult);
|
|
||||||
} else {
|
|
||||||
throw new Error('No OCR service configured');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ocrResult.isCard && ocrResult.confidence > 60) {
|
|
||||||
// Confirmed as a card!
|
|
||||||
cardTracker.status = 'confirmed';
|
|
||||||
cardTracker.cardData = ocrResult;
|
|
||||||
console.log(`✅ Card ${cardTracker.id} confirmed: ${ocrResult.cardName}`);
|
|
||||||
|
|
||||||
// Process the card through database lookup
|
|
||||||
await processConfirmedCard(cardTracker, imageData);
|
|
||||||
|
|
||||||
} else {
|
|
||||||
// Not a card or low confidence
|
|
||||||
cardTracker.status = 'negative';
|
|
||||||
console.log(`❌ Card ${cardTracker.id} rejected: ${ocrResult.reason || 'Low confidence'}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Error verifying card ${cardTracker.id}:`, error);
|
|
||||||
cardTracker.status = 'negative';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Process confirmed card through database lookup
|
|
||||||
const processConfirmedCard = async (cardTracker, imageData) => {
|
|
||||||
try {
|
|
||||||
const ocrResult = cardTracker.cardData;
|
|
||||||
|
|
||||||
// Database lookup
|
|
||||||
console.log('🔍 Cross-referencing with database...');
|
|
||||||
const dbResponse = await fetch('/api/cards/find-or-create', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
name: ocrResult.cardName.trim(),
|
|
||||||
set: ocrResult.setName,
|
|
||||||
setCode: ocrResult.setCode,
|
|
||||||
cardNumber: ocrResult.cardNumber,
|
|
||||||
game: ocrResult.game,
|
|
||||||
cardType: ocrResult.cardType,
|
|
||||||
rarity: ocrResult.rarity,
|
|
||||||
hp: ocrResult.hp,
|
|
||||||
manaCost: ocrResult.manaCost,
|
|
||||||
ocrData: {
|
|
||||||
confidence: ocrResult.confidence,
|
|
||||||
rawText: ocrResult.rawText,
|
|
||||||
abilities: ocrResult.abilities,
|
|
||||||
flavorText: ocrResult.flavorText,
|
|
||||||
artist: ocrResult.artist
|
|
||||||
}
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!dbResponse.ok) {
|
|
||||||
throw new Error(`Database lookup failed: ${dbResponse.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const dbResult = await dbResponse.json();
|
|
||||||
|
|
||||||
// Handle successful card identification
|
|
||||||
if (dbResult.card) {
|
|
||||||
const finalCard = dbResult.card;
|
|
||||||
|
|
||||||
// Mark as scanned and send to parent
|
|
||||||
cardTracker.status = 'scanned';
|
cardTracker.status = 'scanned';
|
||||||
|
|
||||||
// Update browser tab title with card name
|
|
||||||
const originalTitle = document.title;
|
const originalTitle = document.title;
|
||||||
document.title = `📸 ${finalCard.name} - Card Scanner`;
|
document.title = `📸 ${finalCard.name} - Card Scanner`;
|
||||||
|
|
||||||
// Reset title after 3 seconds
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
document.title = originalTitle;
|
document.title = originalTitle;
|
||||||
}, 3000);
|
}, 3000);
|
||||||
|
|
@ -418,23 +256,121 @@ export default function CameraScanner({ onCardScanned, onError }) {
|
||||||
game: finalCard.game,
|
game: finalCard.game,
|
||||||
cardType: finalCard.card_type,
|
cardType: finalCard.card_type,
|
||||||
rarity: finalCard.rarity,
|
rarity: finalCard.rarity,
|
||||||
hp: finalCard.hp || ocrResult.hp,
|
hp: finalCard.hp || ocrMeta.hp,
|
||||||
manaCost: finalCard.mana_cost || ocrResult.manaCost,
|
manaCost: finalCard.mana_cost || ocrMeta.manaCost,
|
||||||
abilities: ocrResult.abilities,
|
abilities: ocrMeta.abilities || [],
|
||||||
ocrText: ocrResult.rawText,
|
ocrText: ocrMeta.rawText,
|
||||||
confidence: ocrResult.confidence,
|
confidence: ocrMeta.confidence,
|
||||||
capturedImage: imageData,
|
capturedImage: imageData,
|
||||||
image_url: finalCard.image_url,
|
image_url: finalCard.image_url,
|
||||||
databaseId: finalCard.id,
|
databaseId: finalCard.id,
|
||||||
isExisting: dbResult.isExisting
|
isExisting: true,
|
||||||
});
|
});
|
||||||
|
};
|
||||||
|
|
||||||
console.log(`🎉 Card ${cardTracker.id} successfully scanned: ${finalCard.name}`);
|
const handleDisambiguationPick = (candidate) => {
|
||||||
|
if (!disambiguation) return;
|
||||||
|
const { cardTracker, imageData, ocrMeta } = disambiguation;
|
||||||
|
emitScannedCard(cardTracker, imageData, candidate, ocrMeta);
|
||||||
|
setDisambiguation(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const processIdentifyResponse = async (cardTracker, imageData, result) => {
|
||||||
|
if (!result.isCard) {
|
||||||
|
cardTracker.status = 'negative';
|
||||||
|
onError?.(result.reason || 'No trading card detected');
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
const ocrMeta = {
|
||||||
console.error(`Error processing card ${cardTracker.id}:`, error);
|
confidence: result.ocr?.confidence ?? result.card?.ocr?.confidence,
|
||||||
|
rawText: result.ocr?.rawText ?? result.card?.ocr?.rawText,
|
||||||
|
abilities: result.card?.ocr?.abilities || [],
|
||||||
|
hp: result.card?.hp,
|
||||||
|
manaCost: result.card?.mana_cost,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (result.card) {
|
||||||
|
cardTracker.status = 'confirmed';
|
||||||
|
emitScannedCard(cardTracker, imageData, result.card, ocrMeta);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.needsUserSelection && result.matches?.length) {
|
||||||
|
cardTracker.status = 'confirmed';
|
||||||
|
setDisambiguation({
|
||||||
|
cardTracker,
|
||||||
|
imageData,
|
||||||
|
candidates: result.matches,
|
||||||
|
ocrMeta,
|
||||||
|
message: result.message,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.needsReview || result.needsUserInput) {
|
||||||
cardTracker.status = 'negative';
|
cardTracker.status = 'negative';
|
||||||
|
onError?.(result.message || 'Could not identify card — saved for review or retry.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
cardTracker.status = 'negative';
|
||||||
|
onError?.('Could not identify card from scan.');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Server-side card identification
|
||||||
|
const verifyCardShape = async (cardTracker) => {
|
||||||
|
if (!videoRef.current || !canvasRef.current || cardTracker.status !== 'detecting') return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
cardTracker.scanAttempts++;
|
||||||
|
|
||||||
|
const video = videoRef.current;
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
|
const { x, y, width, height } = cardTracker.bounds;
|
||||||
|
const margin = 20;
|
||||||
|
|
||||||
|
canvas.width = width + margin * 2;
|
||||||
|
canvas.height = height + margin * 2;
|
||||||
|
|
||||||
|
ctx.drawImage(
|
||||||
|
video,
|
||||||
|
Math.max(0, x - margin), Math.max(0, y - margin),
|
||||||
|
width + margin * 2, height + margin * 2,
|
||||||
|
0, 0,
|
||||||
|
canvas.width, canvas.height
|
||||||
|
);
|
||||||
|
|
||||||
|
const imageData = canvas.toDataURL('image/jpeg', 0.8);
|
||||||
|
|
||||||
|
const response = await fetch('/api/scan/identify', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${localStorage.getItem('auth_token')}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ imageData }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 429) {
|
||||||
|
onError?.('Too many scan attempts. Please wait a moment and try again.');
|
||||||
|
cardTracker.status = 'negative';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Scan identify failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
cardTracker.status = 'confirmed';
|
||||||
|
await processIdentifyResponse(cardTracker, imageData, result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error verifying card ${cardTracker.id}:`, error);
|
||||||
|
cardTracker.status = 'negative';
|
||||||
|
onError?.(error.message || 'Scan failed');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -777,7 +713,7 @@ export default function CameraScanner({ onCardScanned, onError }) {
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-blue-500">●</span>
|
<span className="text-blue-500">●</span>
|
||||||
<span>AI verification</span>
|
<span>Server identification</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-yellow-500">●</span>
|
<span className="text-yellow-500">●</span>
|
||||||
|
|
@ -790,6 +726,56 @@ export default function CameraScanner({ onCardScanned, onError }) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{disambiguation && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-60 p-4">
|
||||||
|
<div
|
||||||
|
className="max-w-lg w-full rounded-xl border p-6 max-h-[80vh] overflow-y-auto"
|
||||||
|
style={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="disambiguation-title"
|
||||||
|
>
|
||||||
|
<h3 id="disambiguation-title" className="text-lg font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
Which card is this?
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm mb-4" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
{disambiguation.message || 'Multiple matches found. Select the correct printing.'}
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{disambiguation.candidates.map((candidate) => (
|
||||||
|
<button
|
||||||
|
key={candidate.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleDisambiguationPick(candidate)}
|
||||||
|
className="w-full flex items-center gap-3 p-3 rounded-lg border text-left hover:opacity-90"
|
||||||
|
style={{ borderColor: 'var(--border)', backgroundColor: 'var(--bg-tertiary)' }}
|
||||||
|
>
|
||||||
|
{candidate.image_url ? (
|
||||||
|
<img src={candidate.image_url} alt="" className="w-12 h-16 object-cover rounded" />
|
||||||
|
) : (
|
||||||
|
<div className="w-12 h-16 rounded flex items-center justify-center text-xs" style={{ backgroundColor: 'var(--bg-secondary)' }}>🃏</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<div className="font-medium" style={{ color: 'var(--text-primary)' }}>{candidate.name}</div>
|
||||||
|
<div className="text-xs" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
{[candidate.set_name, candidate.set_code, candidate.card_number].filter(Boolean).join(' · ')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDisambiguation(null)}
|
||||||
|
className="mt-4 w-full py-2 rounded-lg border text-sm"
|
||||||
|
style={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1,443 +1,39 @@
|
||||||
import { useState, useEffect } from 'react';
|
export default function OCRSettings({ onClose }) {
|
||||||
import { aiCardOCR, ollamaCardOCR, geminiCardOCR } from '../lib/ai-ocr';
|
|
||||||
|
|
||||||
export default function OCRSettings({ isOpen, onClose }) {
|
|
||||||
const [settings, setSettings] = useState({
|
|
||||||
service: 'gemini', // Default to free Gemini
|
|
||||||
openaiApiKey: '',
|
|
||||||
geminiApiKey: '',
|
|
||||||
ollamaUrl: 'http://localhost:11434'
|
|
||||||
});
|
|
||||||
const [isTesting, setIsTesting] = useState(false);
|
|
||||||
const [testResult, setTestResult] = useState(null);
|
|
||||||
|
|
||||||
// Load settings from localStorage on mount (never fetch server-side API keys)
|
|
||||||
useEffect(() => {
|
|
||||||
const savedSettings = localStorage.getItem('ocrSettings');
|
|
||||||
let currentSettings = {
|
|
||||||
service: 'gemini',
|
|
||||||
openaiApiKey: '',
|
|
||||||
geminiApiKey: '',
|
|
||||||
ollamaUrl: 'http://localhost:11434'
|
|
||||||
};
|
|
||||||
|
|
||||||
if (savedSettings) {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(savedSettings);
|
|
||||||
currentSettings = { ...currentSettings, ...parsed };
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading OCR settings:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setSettings(currentSettings);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const saveSettings = () => {
|
|
||||||
try {
|
|
||||||
localStorage.setItem('ocrSettings', JSON.stringify(settings));
|
|
||||||
|
|
||||||
// Configure AI services
|
|
||||||
if (settings.openaiApiKey) {
|
|
||||||
aiCardOCR.setApiKey(settings.openaiApiKey);
|
|
||||||
}
|
|
||||||
if (settings.geminiApiKey) {
|
|
||||||
geminiCardOCR.setApiKey(settings.geminiApiKey);
|
|
||||||
}
|
|
||||||
if (settings.ollamaUrl) {
|
|
||||||
ollamaCardOCR.setBaseUrl(settings.ollamaUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
setTestResult({ type: 'success', message: 'Settings saved successfully!' });
|
|
||||||
setTimeout(() => setTestResult(null), 3000);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error saving OCR settings:', error);
|
|
||||||
setTestResult({ type: 'error', message: 'Failed to save settings' });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const testConnection = async () => {
|
|
||||||
setIsTesting(true);
|
|
||||||
setTestResult(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (settings.service === 'puter') {
|
|
||||||
// Test Puter.js connection
|
|
||||||
try {
|
|
||||||
// Try to load Puter.js if not already loaded
|
|
||||||
if (typeof window !== 'undefined' && !window.puter) {
|
|
||||||
const script = document.createElement('script');
|
|
||||||
script.src = 'https://js.puter.com/v2/';
|
|
||||||
await new Promise((resolve, reject) => {
|
|
||||||
script.onload = resolve;
|
|
||||||
script.onerror = reject;
|
|
||||||
document.head.appendChild(script);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (window.puter && window.puter.ai) {
|
|
||||||
setTestResult({ type: 'success', message: 'Puter.js loaded successfully! Ready for free AI vision.' });
|
|
||||||
} else {
|
|
||||||
setTestResult({ type: 'error', message: 'Failed to load Puter.js AI capabilities' });
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
setTestResult({ type: 'error', message: 'Could not connect to Puter.js service' });
|
|
||||||
}
|
|
||||||
} else if (settings.service === 'openai') {
|
|
||||||
if (!settings.openaiApiKey) {
|
|
||||||
setTestResult({ type: 'error', message: 'Please enter your OpenAI API key' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test with a simple request
|
|
||||||
const response = await fetch('https://api.openai.com/v1/models', {
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${settings.openaiApiKey}`,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
setTestResult({ type: 'success', message: 'OpenAI API connection successful!' });
|
|
||||||
} else {
|
|
||||||
const error = await response.json().catch(() => ({}));
|
|
||||||
setTestResult({
|
|
||||||
type: 'error',
|
|
||||||
message: `OpenAI API error: ${error.error?.message || response.statusText}`
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else if (settings.service === 'gemini') {
|
|
||||||
if (!settings.geminiApiKey) {
|
|
||||||
setTestResult({ type: 'error', message: 'Please enter your Gemini API key' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test with a simple request
|
|
||||||
const response = await fetch('https://generativelanguage.googleapis.com/v1beta/models', {
|
|
||||||
headers: {
|
|
||||||
'x-goog-api-key': settings.geminiApiKey,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
const hasVisionModel = data.models?.some(model =>
|
|
||||||
model.name.includes('gemini') && model.supportedGenerationMethods?.includes('generateContent')
|
|
||||||
);
|
|
||||||
|
|
||||||
if (hasVisionModel) {
|
|
||||||
setTestResult({ type: 'success', message: 'Gemini API connection successful with vision support!' });
|
|
||||||
} else {
|
|
||||||
setTestResult({
|
|
||||||
type: 'warning',
|
|
||||||
message: 'Gemini connected but vision capabilities unclear.'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const error = await response.json().catch(() => ({}));
|
|
||||||
setTestResult({
|
|
||||||
type: 'error',
|
|
||||||
message: `Gemini API error: ${error.error?.message || response.statusText}`
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else if (settings.service === 'ollama') {
|
|
||||||
// Test Ollama connection
|
|
||||||
const response = await fetch(`${settings.ollamaUrl}/api/tags`);
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
const hasVisionModel = data.models?.some(model =>
|
|
||||||
model.name.includes('llava') || model.name.includes('vision')
|
|
||||||
);
|
|
||||||
|
|
||||||
if (hasVisionModel) {
|
|
||||||
setTestResult({ type: 'success', message: 'Ollama connection successful with vision models!' });
|
|
||||||
} else {
|
|
||||||
setTestResult({
|
|
||||||
type: 'warning',
|
|
||||||
message: 'Ollama connected but no vision models found. Install llava:latest for card scanning.'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setTestResult({ type: 'error', message: 'Could not connect to Ollama server' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Connection test error:', error);
|
|
||||||
setTestResult({
|
|
||||||
type: 'error',
|
|
||||||
message: `Connection failed: ${error.message}`
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setIsTesting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleInputChange = (field, value) => {
|
|
||||||
setSettings(prev => ({
|
|
||||||
...prev,
|
|
||||||
[field]: value
|
|
||||||
}));
|
|
||||||
setTestResult(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!isOpen) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||||
<div className="rounded-xl border max-w-md w-full max-h-[90vh] overflow-y-auto" style={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}>
|
<div
|
||||||
{/* Header */}
|
className="rounded-xl border max-w-md w-full p-6"
|
||||||
<div className="flex items-center justify-between p-6 border-b" style={{ borderColor: 'var(--border)' }}>
|
style={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h2 className="text-xl font-semibold" style={{ color: 'var(--text-primary)' }}>
|
<h2 className="text-xl font-semibold" style={{ color: 'var(--text-primary)' }}>
|
||||||
🤖 OCR Settings
|
Card Scanner
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="p-2 rounded-lg hover:opacity-70 transition-opacity"
|
className="p-2 rounded-lg hover:opacity-70"
|
||||||
style={{ color: 'var(--text-secondary)' }}
|
style={{ color: 'var(--text-secondary)' }}
|
||||||
|
aria-label="Close"
|
||||||
>
|
>
|
||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-sm mb-4" style={{ color: 'var(--text-secondary)' }}>
|
||||||
{/* Content */}
|
Card identification runs on Deck Hearth's servers using Gemini Vision. No API keys
|
||||||
<div className="p-6 space-y-6">
|
are required in your browser.
|
||||||
{/* Service Selection */}
|
</p>
|
||||||
<div>
|
<ul className="text-sm space-y-2 mb-6" style={{ color: 'var(--text-secondary)' }}>
|
||||||
<label className="block text-sm font-medium mb-3" style={{ color: 'var(--text-primary)' }}>
|
<li>Hold the card steady in the camera frame for best results.</li>
|
||||||
OCR Service
|
<li>If multiple matches are found, you will be asked to pick the correct printing.</li>
|
||||||
</label>
|
<li>Unknown cards are saved for admin review instead of being added to the global catalog.</li>
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="flex items-center">
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name="service"
|
|
||||||
value="puter"
|
|
||||||
checked={settings.service === 'puter'}
|
|
||||||
onChange={(e) => handleInputChange('service', e.target.value)}
|
|
||||||
className="mr-3"
|
|
||||||
style={{ accentColor: 'var(--accent-ember)' }}
|
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
<div className="font-medium" style={{ color: 'var(--text-primary)' }}>Puter.js</div>
|
|
||||||
<div className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
Free GPT-4o vision - requires signing in to puter.com first
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="flex items-center">
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name="service"
|
|
||||||
value="openai"
|
|
||||||
checked={settings.service === 'openai'}
|
|
||||||
onChange={(e) => handleInputChange('service', e.target.value)}
|
|
||||||
className="mr-3"
|
|
||||||
style={{ accentColor: 'var(--accent-ember)' }}
|
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
<div className="font-medium" style={{ color: 'var(--text-primary)' }}>OpenAI Vision API</div>
|
|
||||||
<div className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
High accuracy, requires API key (~$0.01 per scan)
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="flex items-center">
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name="service"
|
|
||||||
value="gemini"
|
|
||||||
checked={settings.service === 'gemini'}
|
|
||||||
onChange={(e) => handleInputChange('service', e.target.value)}
|
|
||||||
className="mr-3"
|
|
||||||
style={{ accentColor: 'var(--accent-ember)' }}
|
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
<div className="font-medium" style={{ color: 'var(--text-primary)' }}>Google Gemini (Recommended)</div>
|
|
||||||
<div className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
Free tier available, high accuracy, requires API key
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="flex items-center">
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name="service"
|
|
||||||
value="ollama"
|
|
||||||
checked={settings.service === 'ollama'}
|
|
||||||
onChange={(e) => handleInputChange('service', e.target.value)}
|
|
||||||
className="mr-3"
|
|
||||||
style={{ accentColor: 'var(--accent-ember)' }}
|
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
<div className="font-medium" style={{ color: 'var(--text-primary)' }}>Ollama Local</div>
|
|
||||||
<div className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
Private & free, requires local Ollama + LLaVA model
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* OpenAI Settings */}
|
|
||||||
{settings.service === 'openai' && (
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
|
|
||||||
OpenAI API Key
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
placeholder="sk-..."
|
|
||||||
value={settings.openaiApiKey}
|
|
||||||
onChange={(e) => handleInputChange('openaiApiKey', e.target.value)}
|
|
||||||
className="w-full px-3 py-2 rounded-lg border"
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'var(--bg-primary)',
|
|
||||||
borderColor: 'var(--border)',
|
|
||||||
color: 'var(--text-primary)'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className="mt-2 text-sm" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
Get your API key from{' '}
|
|
||||||
<a
|
|
||||||
href="https://platform.openai.com/api-keys"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="underline hover:opacity-70"
|
|
||||||
style={{ color: 'var(--accent-ember)' }}
|
|
||||||
>
|
|
||||||
OpenAI Platform
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Gemini Settings */}
|
|
||||||
{settings.service === 'gemini' && (
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
|
|
||||||
Gemini API Key
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
placeholder="AIza..."
|
|
||||||
value={settings.geminiApiKey}
|
|
||||||
onChange={(e) => handleInputChange('geminiApiKey', e.target.value)}
|
|
||||||
className="w-full px-3 py-2 rounded-lg border"
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'var(--bg-primary)',
|
|
||||||
borderColor: 'var(--border)',
|
|
||||||
color: 'var(--text-primary)'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className="mt-2 text-sm" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
Get your free API key from{' '}
|
|
||||||
<a
|
|
||||||
href="https://ai.google.dev/gemini-api/docs/api-key"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="underline hover:opacity-70"
|
|
||||||
style={{ color: 'var(--accent-ember)' }}
|
|
||||||
>
|
|
||||||
Google AI Studio
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Ollama Settings */}
|
|
||||||
{settings.service === 'ollama' && (
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
|
|
||||||
Ollama Server URL
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="url"
|
|
||||||
placeholder="http://localhost:11434"
|
|
||||||
value={settings.ollamaUrl}
|
|
||||||
onChange={(e) => handleInputChange('ollamaUrl', e.target.value)}
|
|
||||||
className="w-full px-3 py-2 rounded-lg border"
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'var(--bg-primary)',
|
|
||||||
borderColor: 'var(--border)',
|
|
||||||
color: 'var(--text-primary)'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className="mt-2 text-sm space-y-1" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
<div>Install Ollama and run: <code className="px-1 py-0.5 rounded text-xs" style={{ backgroundColor: 'var(--bg-tertiary)' }}>ollama pull llava:latest</code></div>
|
|
||||||
<div>
|
|
||||||
Setup guide:{' '}
|
|
||||||
<a
|
|
||||||
href="https://ollama.ai"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="underline hover:opacity-70"
|
|
||||||
style={{ color: 'var(--accent-ember)' }}
|
|
||||||
>
|
|
||||||
ollama.ai
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Test Result */}
|
|
||||||
{testResult && (
|
|
||||||
<div className={`p-3 rounded-lg border ${
|
|
||||||
testResult.type === 'success' ? 'border-green-500 bg-green-50 dark:bg-green-900/20' :
|
|
||||||
testResult.type === 'warning' ? 'border-yellow-500 bg-yellow-50 dark:bg-yellow-900/20' :
|
|
||||||
'border-red-500 bg-red-50 dark:bg-red-900/20'
|
|
||||||
}`}>
|
|
||||||
<div className={`text-sm ${
|
|
||||||
testResult.type === 'success' ? 'text-green-700 dark:text-green-300' :
|
|
||||||
testResult.type === 'warning' ? 'text-yellow-700 dark:text-yellow-300' :
|
|
||||||
'text-red-700 dark:text-red-300'
|
|
||||||
}`}>
|
|
||||||
{testResult.message}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<button
|
|
||||||
onClick={testConnection}
|
|
||||||
disabled={isTesting}
|
|
||||||
className="flex-1 px-4 py-2 rounded-lg border font-medium transition-all duration-200 hover:opacity-80 disabled:opacity-50"
|
|
||||||
style={{
|
|
||||||
borderColor: 'var(--border)',
|
|
||||||
color: 'var(--text-primary)'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isTesting ? 'Testing...' : 'Test Connection'}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={saveSettings}
|
|
||||||
className="flex-1 px-4 py-2 rounded-lg font-medium transition-all duration-200 hover:opacity-90"
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'var(--accent-ember)',
|
|
||||||
color: 'white'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Save Settings
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Usage Tips */}
|
|
||||||
<div className="rounded-lg p-4" style={{ backgroundColor: 'var(--bg-tertiary)' }}>
|
|
||||||
<div className="font-medium mb-2" style={{ color: 'var(--text-primary)' }}>💡 Tips</div>
|
|
||||||
<ul className="text-sm space-y-1" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
<li>• Puter.js offers free GPT-4o vision with no setup required</li>
|
|
||||||
<li>• OpenAI Vision API offers highest accuracy for card recognition</li>
|
|
||||||
<li>• Ollama is free and private but requires local setup</li>
|
|
||||||
<li>• Test your connection before scanning cards</li>
|
|
||||||
<li>• Settings are saved locally in your browser</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
<button
|
||||||
</div>
|
onClick={onClose}
|
||||||
|
className="w-full px-4 py-2 rounded-lg font-medium"
|
||||||
|
style={{ backgroundColor: 'var(--accent-ember)', color: 'white' }}
|
||||||
|
>
|
||||||
|
Got it
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@
|
||||||
| **Ownership** | `user_cards`, `user_favorites` | What a user owns / has favorited |
|
| **Ownership** | `user_cards`, `user_favorites` | What a user owns / has favorited |
|
||||||
| **Collections** | `collections`, `collection_cards`, `collection_permissions`, `collection_activity` | Curated card lists with sharing |
|
| **Collections** | `collections`, `collection_cards`, `collection_permissions`, `collection_activity` | Curated card lists with sharing |
|
||||||
| **Decks** | `decks`, `deck_cards` | Playable deck definitions |
|
| **Decks** | `decks`, `deck_cards` | Playable deck definitions |
|
||||||
|
| **Scanning** | `card_submissions`, `scan_attempts` | Unknown-card review queue + scan telemetry |
|
||||||
| **Invitations** | `invitations` (referenced; verify) | Pending share requests |
|
| **Invitations** | `invitations` (referenced; verify) | Pending share requests |
|
||||||
|
|
||||||
## Tables
|
## Tables
|
||||||
|
|
@ -166,6 +167,44 @@
|
||||||
| `quantity` | `INTEGER` default `1` | |
|
| `quantity` | `INTEGER` default `1` | |
|
||||||
| | | **UNIQUE(deck_id, card_id)** |
|
| | | **UNIQUE(deck_id, card_id)** |
|
||||||
|
|
||||||
|
### card_submissions
|
||||||
|
|
||||||
|
Added by `migrations/1748365200000_add-scan-tables.js` (server-side scan pipeline). Unknown high-confidence scans queue here for admin review instead of polluting `cards`.
|
||||||
|
|
||||||
|
| Column | Type | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `id` | `SERIAL PK` | |
|
||||||
|
| `user_id` | FK → `users` cascade | Submitter |
|
||||||
|
| `ocr_text` | `TEXT` | Raw OCR / vision text |
|
||||||
|
| `ocr_confidence` | `INTEGER` | 0–100 from scan layer |
|
||||||
|
| `scan_image_url` | `TEXT` | Optional blob URL of capture |
|
||||||
|
| `candidate_card_ids` | `JSONB` default `'[]'` | Near-miss catalog IDs |
|
||||||
|
| `ocr_payload` | `JSONB` | Structured fields for admin promote |
|
||||||
|
| `status` | `VARCHAR(32)` default `'pending'` | `'pending' \| 'approved' \| 'rejected'` |
|
||||||
|
| `reviewed_by` | FK → `users` SET NULL | Admin reviewer |
|
||||||
|
| `promoted_card_id` | FK → `cards` SET NULL | Set on approve |
|
||||||
|
| `created_at`, `updated_at` | `TIMESTAMP` default now | |
|
||||||
|
|
||||||
|
Index: `idx_card_submissions_status (status, created_at DESC)`.
|
||||||
|
|
||||||
|
### scan_attempts
|
||||||
|
|
||||||
|
Per-scan telemetry for the identify pipeline (layer 2 = Gemini today).
|
||||||
|
|
||||||
|
| Column | Type | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `id` | `SERIAL PK` | |
|
||||||
|
| `user_id` | FK → `users` SET NULL | |
|
||||||
|
| `ocr_text` | `TEXT` | |
|
||||||
|
| `ocr_confidence` | `INTEGER` | |
|
||||||
|
| `layer` | `INTEGER` default `2` | OCR layer (1 = Tesseract future) |
|
||||||
|
| `matched_card_id` | FK → `cards` SET NULL | |
|
||||||
|
| `result_kind` | `VARCHAR(32)` | e.g. `'matched'`, `'disambiguation'`, `'submitted'`, `'not_a_card'` |
|
||||||
|
| `latency_ms` | `INTEGER` | End-to-end identify latency |
|
||||||
|
| `created_at` | `TIMESTAMP` default now | |
|
||||||
|
|
||||||
|
Index: `idx_scan_attempts_user_created (user_id, created_at DESC)`.
|
||||||
|
|
||||||
### user_settings (split from users.*; verify which is canonical)
|
### user_settings (split from users.*; verify which is canonical)
|
||||||
|
|
||||||
Defined in `add-user-profile-fields.js`. Mirrors several `users.*` columns — there's redundancy that needs to be reconciled.
|
Defined in `add-user-profile-fields.js`. Mirrors several `users.*` columns — there's redundancy that needs to be reconciled.
|
||||||
|
|
|
||||||
527
lib/ai-ocr.js
527
lib/ai-ocr.js
|
|
@ -1,527 +0,0 @@
|
||||||
// AI OCR Service for Trading Card Recognition
|
|
||||||
|
|
||||||
export class AICardOCR {
|
|
||||||
constructor() {
|
|
||||||
this.apiKey = null;
|
|
||||||
this.baseUrl = 'https://api.openai.com/v1';
|
|
||||||
}
|
|
||||||
|
|
||||||
setApiKey(apiKey) {
|
|
||||||
this.apiKey = apiKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
async analyzeCard(imageDataUrl) {
|
|
||||||
if (!this.apiKey) {
|
|
||||||
throw new Error('OpenAI API key not configured');
|
|
||||||
}
|
|
||||||
|
|
||||||
const prompt = `You are a specialized trading card recognition system. Analyze this image and determine if it contains a trading card (Magic: The Gathering, Pokemon, Yu-Gi-Oh, Lorcana, etc.).
|
|
||||||
|
|
||||||
IMPORTANT: Only respond with card data if you can clearly identify a TRADING CARD in the image. Ignore:
|
|
||||||
- Random objects, books, papers
|
|
||||||
- Screenshots of websites or apps
|
|
||||||
- Blurry or unclear images
|
|
||||||
- Non-card gaming items
|
|
||||||
|
|
||||||
If you detect a trading card, extract the following information in JSON format:
|
|
||||||
{
|
|
||||||
"isCard": true,
|
|
||||||
"cardName": "exact card name as printed",
|
|
||||||
"setName": "set name if visible",
|
|
||||||
"setCode": "set code/symbol if visible",
|
|
||||||
"cardNumber": "collector number if visible",
|
|
||||||
"game": "MTG, Pokemon, YuGiOh, Lorcana, etc.",
|
|
||||||
"cardType": "creature, instant, trainer, etc.",
|
|
||||||
"rarity": "common, uncommon, rare, mythic, etc.",
|
|
||||||
"manaCost": "mana cost if visible",
|
|
||||||
"hp": "HP or power if visible",
|
|
||||||
"abilities": ["list of abilities/attacks if visible"],
|
|
||||||
"flavorText": "flavor text if clearly readable",
|
|
||||||
"artist": "artist name if visible",
|
|
||||||
"confidence": 85,
|
|
||||||
"rawText": "all visible text on the card"
|
|
||||||
}
|
|
||||||
|
|
||||||
If NO trading card is detected, respond with:
|
|
||||||
{
|
|
||||||
"isCard": false,
|
|
||||||
"confidence": 0,
|
|
||||||
"reason": "No trading card detected in image"
|
|
||||||
}
|
|
||||||
|
|
||||||
Focus on accuracy over speed. Only extract data you can clearly read.`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${this.baseUrl}/chat/completions`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${this.apiKey}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
model: 'gpt-4o-mini',
|
|
||||||
messages: [
|
|
||||||
{
|
|
||||||
role: 'user',
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: 'text',
|
|
||||||
text: prompt
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'image_url',
|
|
||||||
image_url: {
|
|
||||||
url: imageDataUrl,
|
|
||||||
detail: 'high'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
max_tokens: 1000,
|
|
||||||
temperature: 0.1
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`OpenAI API error: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
const content = data.choices[0]?.message?.content;
|
|
||||||
|
|
||||||
if (!content) {
|
|
||||||
throw new Error('No response from OpenAI');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse JSON response
|
|
||||||
let result;
|
|
||||||
try {
|
|
||||||
// Clean up the response - remove markdown code blocks if present
|
|
||||||
const cleanContent = content.replace(/```json\n?/g, '').replace(/```\n?/g, '').trim();
|
|
||||||
result = JSON.parse(cleanContent);
|
|
||||||
} catch (parseError) {
|
|
||||||
console.error('Failed to parse OpenAI JSON response:', content);
|
|
||||||
// Fallback: try to extract card name from raw text
|
|
||||||
const cardNameMatch = content.match(/card.*?name.*?[:"]\s*([^"'\n]+)/i);
|
|
||||||
result = {
|
|
||||||
isCard: !!cardNameMatch,
|
|
||||||
cardName: cardNameMatch ? cardNameMatch[1].trim() : null,
|
|
||||||
confidence: 30,
|
|
||||||
rawText: content,
|
|
||||||
reason: 'Failed to parse structured response'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure we have the required structure
|
|
||||||
return {
|
|
||||||
isCard: result.isCard || false,
|
|
||||||
cardName: result.cardName || null,
|
|
||||||
setName: result.setName || null,
|
|
||||||
setCode: result.setCode || null,
|
|
||||||
cardNumber: result.cardNumber || null,
|
|
||||||
game: result.game || null,
|
|
||||||
cardType: result.cardType || null,
|
|
||||||
rarity: result.rarity || null,
|
|
||||||
manaCost: result.manaCost || null,
|
|
||||||
hp: result.hp || null,
|
|
||||||
abilities: result.abilities || [],
|
|
||||||
flavorText: result.flavorText || null,
|
|
||||||
artist: result.artist || null,
|
|
||||||
confidence: result.confidence || 0,
|
|
||||||
rawText: result.rawText || content,
|
|
||||||
reason: result.reason || null
|
|
||||||
};
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('OpenAI Vision API error:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class OllamaVisionOCR {
|
|
||||||
constructor() {
|
|
||||||
this.baseUrl = 'http://localhost:11434';
|
|
||||||
}
|
|
||||||
|
|
||||||
setBaseUrl(url) {
|
|
||||||
this.baseUrl = url;
|
|
||||||
}
|
|
||||||
|
|
||||||
async analyzeCard(imageDataUrl) {
|
|
||||||
const prompt = `You are a specialized trading card recognition system. Analyze this image and determine if it contains a trading card (Magic: The Gathering, Pokemon, Yu-Gi-Oh, Lorcana, etc.).
|
|
||||||
|
|
||||||
IMPORTANT: Only respond with card data if you can clearly identify a TRADING CARD in the image. Ignore random objects, books, papers, screenshots, or blurry images.
|
|
||||||
|
|
||||||
If you detect a trading card, extract this information in JSON format:
|
|
||||||
{
|
|
||||||
"isCard": true,
|
|
||||||
"cardName": "exact card name as printed",
|
|
||||||
"setName": "set name if visible",
|
|
||||||
"setCode": "set code if visible",
|
|
||||||
"cardNumber": "collector number if visible",
|
|
||||||
"game": "MTG, Pokemon, YuGiOh, Lorcana, etc.",
|
|
||||||
"cardType": "creature, instant, trainer, etc.",
|
|
||||||
"rarity": "common, uncommon, rare, mythic, etc.",
|
|
||||||
"confidence": 85,
|
|
||||||
"rawText": "all visible text"
|
|
||||||
}
|
|
||||||
|
|
||||||
If NO trading card detected, respond: {"isCard": false, "confidence": 0, "reason": "No trading card detected"}`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Convert data URL to base64
|
|
||||||
const base64Data = imageDataUrl.split(',')[1];
|
|
||||||
|
|
||||||
const response = await fetch(`${this.baseUrl}/api/generate`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
model: 'llava:latest',
|
|
||||||
prompt: prompt,
|
|
||||||
images: [base64Data],
|
|
||||||
stream: false,
|
|
||||||
options: {
|
|
||||||
temperature: 0.1,
|
|
||||||
top_p: 0.9
|
|
||||||
}
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Ollama API error: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
const content = data.response;
|
|
||||||
|
|
||||||
if (!content) {
|
|
||||||
throw new Error('No response from Ollama');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse JSON response
|
|
||||||
let result;
|
|
||||||
try {
|
|
||||||
const cleanContent = content.replace(/```json\n?/g, '').replace(/```\n?/g, '').trim();
|
|
||||||
result = JSON.parse(cleanContent);
|
|
||||||
} catch (parseError) {
|
|
||||||
console.error('Failed to parse Ollama JSON response:', content);
|
|
||||||
result = {
|
|
||||||
isCard: false,
|
|
||||||
confidence: 0,
|
|
||||||
rawText: content,
|
|
||||||
reason: 'Failed to parse response'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
isCard: result.isCard || false,
|
|
||||||
cardName: result.cardName || null,
|
|
||||||
setName: result.setName || null,
|
|
||||||
setCode: result.setCode || null,
|
|
||||||
cardNumber: result.cardNumber || null,
|
|
||||||
game: result.game || null,
|
|
||||||
cardType: result.cardType || null,
|
|
||||||
rarity: result.rarity || null,
|
|
||||||
manaCost: result.manaCost || null,
|
|
||||||
hp: result.hp || null,
|
|
||||||
abilities: result.abilities || [],
|
|
||||||
flavorText: result.flavorText || null,
|
|
||||||
artist: result.artist || null,
|
|
||||||
confidence: result.confidence || 0,
|
|
||||||
rawText: result.rawText || content,
|
|
||||||
reason: result.reason || null
|
|
||||||
};
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Ollama Vision API error:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class PuterVisionOCR {
|
|
||||||
constructor() {
|
|
||||||
this.puterLoaded = false;
|
|
||||||
this.authFailed = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
async loadPuterJS() {
|
|
||||||
if (this.puterLoaded || typeof window === 'undefined') return;
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const script = document.createElement('script');
|
|
||||||
script.src = 'https://js.puter.com/v2/';
|
|
||||||
script.onload = () => {
|
|
||||||
this.puterLoaded = true;
|
|
||||||
resolve();
|
|
||||||
};
|
|
||||||
script.onerror = reject;
|
|
||||||
document.head.appendChild(script);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async analyzeCard(imageDataUrl) {
|
|
||||||
// If we've already failed auth, don't try again
|
|
||||||
if (this.authFailed) {
|
|
||||||
throw new Error('Puter.js authentication failed. Please use OpenAI or Ollama instead.');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.loadPuterJS();
|
|
||||||
|
|
||||||
if (!window.puter) {
|
|
||||||
throw new Error('Puter.js not loaded');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if user is authenticated with Puter
|
|
||||||
try {
|
|
||||||
await window.puter.auth.getUser();
|
|
||||||
} catch (authError) {
|
|
||||||
console.warn('Puter.js authentication required. Please sign in to Puter.com first.');
|
|
||||||
this.authFailed = true;
|
|
||||||
throw new Error('Puter.js requires authentication. Please use OpenAI or Ollama instead, or sign in to Puter.com first.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const prompt = `You are a specialized trading card recognition system. Analyze this image and determine if it contains a trading card (Magic: The Gathering, Pokemon, Yu-Gi-Oh, Lorcana, etc.).
|
|
||||||
|
|
||||||
CRITICAL: Only respond with card data if you can clearly identify a TRADING CARD in the image. Ignore:
|
|
||||||
- Random objects, books, papers, phone screens
|
|
||||||
- Screenshots of websites or digital interfaces
|
|
||||||
- Blurry, unclear, or dark images
|
|
||||||
- Non-card gaming items or accessories
|
|
||||||
|
|
||||||
If you detect a trading card, extract information in this JSON format:
|
|
||||||
{
|
|
||||||
"isCard": true,
|
|
||||||
"cardName": "exact card name as printed on the card",
|
|
||||||
"setName": "set name if visible",
|
|
||||||
"setCode": "set code/symbol if visible",
|
|
||||||
"cardNumber": "collector number if visible",
|
|
||||||
"game": "MTG, Pokemon, YuGiOh, Lorcana, etc.",
|
|
||||||
"cardType": "creature, instant, sorcery, trainer, etc.",
|
|
||||||
"rarity": "common, uncommon, rare, mythic, etc.",
|
|
||||||
"manaCost": "mana cost if visible",
|
|
||||||
"hp": "HP or power if visible",
|
|
||||||
"abilities": ["list of abilities or attacks if clearly readable"],
|
|
||||||
"confidence": 85,
|
|
||||||
"rawText": "all text visible on the card"
|
|
||||||
}
|
|
||||||
|
|
||||||
If NO trading card is clearly visible, respond with:
|
|
||||||
{
|
|
||||||
"isCard": false,
|
|
||||||
"confidence": 0,
|
|
||||||
"reason": "No trading card detected in image"
|
|
||||||
}
|
|
||||||
|
|
||||||
Be conservative - only extract data you can clearly read. Quality over quantity.`;
|
|
||||||
|
|
||||||
const response = await window.puter.ai.chat(prompt, imageDataUrl, {
|
|
||||||
model: "gpt-4o"
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response) {
|
|
||||||
throw new Error('No response from Puter.js');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse JSON response
|
|
||||||
let result;
|
|
||||||
try {
|
|
||||||
// Clean up the response - remove markdown code blocks if present
|
|
||||||
const cleanContent = response.replace(/```json\n?/g, '').replace(/```\n?/g, '').trim();
|
|
||||||
result = JSON.parse(cleanContent);
|
|
||||||
} catch (parseError) {
|
|
||||||
console.error('Failed to parse Puter JSON response:', response);
|
|
||||||
// Try to extract card name from raw response
|
|
||||||
const cardNameMatch = response.match(/card.*?name.*?[:"]\s*([^"'\n,}]+)/i);
|
|
||||||
result = {
|
|
||||||
isCard: !!cardNameMatch,
|
|
||||||
cardName: cardNameMatch ? cardNameMatch[1].trim() : null,
|
|
||||||
confidence: 30,
|
|
||||||
rawText: response,
|
|
||||||
reason: 'Failed to parse structured response'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure we have the required structure
|
|
||||||
return {
|
|
||||||
isCard: result.isCard || false,
|
|
||||||
cardName: result.cardName || null,
|
|
||||||
setName: result.setName || null,
|
|
||||||
setCode: result.setCode || null,
|
|
||||||
cardNumber: result.cardNumber || null,
|
|
||||||
game: result.game || null,
|
|
||||||
cardType: result.cardType || null,
|
|
||||||
rarity: result.rarity || null,
|
|
||||||
manaCost: result.manaCost || null,
|
|
||||||
hp: result.hp || null,
|
|
||||||
abilities: result.abilities || [],
|
|
||||||
flavorText: result.flavorText || null,
|
|
||||||
artist: result.artist || null,
|
|
||||||
confidence: result.confidence || 0,
|
|
||||||
rawText: result.rawText || response,
|
|
||||||
reason: result.reason || null
|
|
||||||
};
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Puter.js Vision API error:', error);
|
|
||||||
|
|
||||||
// Mark auth as failed if it's an auth-related error
|
|
||||||
if (error.message.includes('authentication') || error.message.includes('auth') || error.message.includes('401')) {
|
|
||||||
this.authFailed = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Gemini Vision OCR using Google's Gemini API
|
|
||||||
export class GeminiVisionOCR {
|
|
||||||
constructor() {
|
|
||||||
this.apiKey = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
setApiKey(apiKey) {
|
|
||||||
this.apiKey = apiKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
async analyzeCard(imageDataUrl) {
|
|
||||||
if (!this.apiKey) {
|
|
||||||
throw new Error('Gemini API key not configured');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert data URL to base64
|
|
||||||
const base64Data = imageDataUrl.split(',')[1];
|
|
||||||
if (!base64Data) {
|
|
||||||
throw new Error('Invalid image data format');
|
|
||||||
}
|
|
||||||
|
|
||||||
const prompt = `You are a specialized trading card recognition system. Analyze this image and determine if it contains a trading card (Magic: The Gathering, Pokemon, Yu-Gi-Oh, Lorcana, etc.).
|
|
||||||
|
|
||||||
CRITICAL: Only respond with card data if you can clearly identify a TRADING CARD in the image. Ignore:
|
|
||||||
- Random objects, books, papers, phone screens
|
|
||||||
- Screenshots of websites or digital interfaces
|
|
||||||
- Blurry, unclear, or dark images
|
|
||||||
- Non-card gaming items or accessories
|
|
||||||
|
|
||||||
If you detect a trading card, extract information in this JSON format:
|
|
||||||
{
|
|
||||||
"isCard": true,
|
|
||||||
"cardName": "exact card name as printed on the card",
|
|
||||||
"setName": "set name if visible",
|
|
||||||
"setCode": "set code/symbol if visible",
|
|
||||||
"cardNumber": "collector number if visible",
|
|
||||||
"game": "MTG, Pokemon, YuGiOh, Lorcana, etc.",
|
|
||||||
"cardType": "creature, instant, sorcery, trainer, etc.",
|
|
||||||
"rarity": "common, uncommon, rare, mythic, etc.",
|
|
||||||
"manaCost": "mana cost if visible",
|
|
||||||
"hp": "HP or power if visible",
|
|
||||||
"abilities": ["list of abilities or attacks if clearly readable"],
|
|
||||||
"confidence": 85,
|
|
||||||
"rawText": "all text visible on the card"
|
|
||||||
}
|
|
||||||
|
|
||||||
If NO trading card is clearly visible, respond with:
|
|
||||||
{
|
|
||||||
"isCard": false,
|
|
||||||
"confidence": 0,
|
|
||||||
"reason": "No trading card detected in image"
|
|
||||||
}
|
|
||||||
|
|
||||||
Be conservative - only extract data you can clearly read. Quality over quantity.`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'x-goog-api-key': this.apiKey
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
contents: [{
|
|
||||||
parts: [
|
|
||||||
{ text: prompt },
|
|
||||||
{
|
|
||||||
inline_data: {
|
|
||||||
mime_type: 'image/jpeg',
|
|
||||||
data: base64Data
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}],
|
|
||||||
generationConfig: {
|
|
||||||
thinkingConfig: {
|
|
||||||
thinkingBudget: 0 // Disable thinking for faster response
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json().catch(() => ({}));
|
|
||||||
throw new Error(`Gemini API error: ${response.status} - ${errorData.error?.message || 'Unknown error'}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
const content = data.candidates?.[0]?.content?.parts?.[0]?.text;
|
|
||||||
|
|
||||||
if (!content) {
|
|
||||||
throw new Error('No response from Gemini API');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse JSON response
|
|
||||||
let result;
|
|
||||||
try {
|
|
||||||
// Clean up the response - remove markdown code blocks if present
|
|
||||||
const cleanContent = content.replace(/```json\n?/g, '').replace(/```\n?/g, '').trim();
|
|
||||||
result = JSON.parse(cleanContent);
|
|
||||||
} catch (parseError) {
|
|
||||||
console.error('Failed to parse Gemini JSON response:', content);
|
|
||||||
// Try to extract card name from raw response
|
|
||||||
const cardNameMatch = content.match(/card.*?name.*?[:"]\s*([^"'\n,}]+)/i);
|
|
||||||
result = {
|
|
||||||
isCard: !!cardNameMatch,
|
|
||||||
cardName: cardNameMatch ? cardNameMatch[1].trim() : null,
|
|
||||||
confidence: 30,
|
|
||||||
rawText: content,
|
|
||||||
reason: 'Failed to parse structured response'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure we have the required structure
|
|
||||||
return {
|
|
||||||
isCard: result.isCard || false,
|
|
||||||
cardName: result.cardName || null,
|
|
||||||
setName: result.setName || null,
|
|
||||||
setCode: result.setCode || null,
|
|
||||||
cardNumber: result.cardNumber || null,
|
|
||||||
game: result.game || null,
|
|
||||||
cardType: result.cardType || null,
|
|
||||||
rarity: result.rarity || null,
|
|
||||||
manaCost: result.manaCost || null,
|
|
||||||
hp: result.hp || null,
|
|
||||||
abilities: result.abilities || [],
|
|
||||||
flavorText: result.flavorText || null,
|
|
||||||
artist: result.artist || null,
|
|
||||||
confidence: result.confidence || 0,
|
|
||||||
rawText: result.rawText || content,
|
|
||||||
reason: result.reason || null
|
|
||||||
};
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Gemini Vision API error:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Export singleton instances
|
|
||||||
export const aiCardOCR = new AICardOCR();
|
|
||||||
export const ollamaCardOCR = new OllamaVisionOCR();
|
|
||||||
export const puterCardOCR = new PuterVisionOCR();
|
|
||||||
export const geminiCardOCR = new GeminiVisionOCR();
|
|
||||||
229
lib/card-catalog-match.js
Normal file
229
lib/card-catalog-match.js
Normal file
|
|
@ -0,0 +1,229 @@
|
||||||
|
import { sql } from '@vercel/postgres';
|
||||||
|
|
||||||
|
function mapCardRow(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,
|
||||||
|
rarity: card.rarity,
|
||||||
|
image_url: card.image_url,
|
||||||
|
card_type: card.card_type,
|
||||||
|
mana_cost: card.mana_cost,
|
||||||
|
hp: card.power,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOcrPayload(fields) {
|
||||||
|
return {
|
||||||
|
name: fields.name?.trim() || null,
|
||||||
|
set: fields.set || null,
|
||||||
|
setCode: fields.setCode || null,
|
||||||
|
cardNumber: fields.cardNumber || null,
|
||||||
|
game: fields.game || null,
|
||||||
|
cardType: fields.cardType || null,
|
||||||
|
rarity: fields.rarity || null,
|
||||||
|
hp: fields.hp || null,
|
||||||
|
manaCost: fields.manaCost || null,
|
||||||
|
rawText: fields.ocrData?.rawText || null,
|
||||||
|
abilities: fields.ocrData?.abilities || [],
|
||||||
|
flavorText: fields.ocrData?.flavorText || null,
|
||||||
|
artist: fields.ocrData?.artist || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createCardSubmission(userId, fields, candidateIds = []) {
|
||||||
|
const ocrPayload = buildOcrPayload(fields);
|
||||||
|
const result = await sql`
|
||||||
|
INSERT INTO card_submissions (
|
||||||
|
user_id, ocr_text, ocr_confidence, scan_image_url,
|
||||||
|
candidate_card_ids, ocr_payload, status
|
||||||
|
) VALUES (
|
||||||
|
${userId},
|
||||||
|
${fields.ocrData?.rawText || fields.name || null},
|
||||||
|
${fields.ocrData?.confidence ?? null},
|
||||||
|
${fields.scanImageUrl || null},
|
||||||
|
${JSON.stringify(candidateIds)},
|
||||||
|
${JSON.stringify(ocrPayload)},
|
||||||
|
'pending'
|
||||||
|
)
|
||||||
|
RETURNING id
|
||||||
|
`;
|
||||||
|
return result.rows[0].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Match OCR fields against the global cards catalog.
|
||||||
|
* Never INSERTs into cards — unknowns become card_submissions.
|
||||||
|
*/
|
||||||
|
export async function matchCardInCatalog({
|
||||||
|
userId,
|
||||||
|
name,
|
||||||
|
set,
|
||||||
|
setCode,
|
||||||
|
cardNumber,
|
||||||
|
game,
|
||||||
|
cardType,
|
||||||
|
rarity,
|
||||||
|
hp,
|
||||||
|
manaCost,
|
||||||
|
ocrData,
|
||||||
|
scanImageUrl = null,
|
||||||
|
}) {
|
||||||
|
if (!name || typeof name !== 'string' || !name.trim()) {
|
||||||
|
return {
|
||||||
|
type: 'needs_input',
|
||||||
|
card: null,
|
||||||
|
matches: [],
|
||||||
|
needsUserInput: true,
|
||||||
|
message: 'Card name is required',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmedName = name.trim();
|
||||||
|
let existingCard = null;
|
||||||
|
|
||||||
|
if ((set || setCode) && cardNumber) {
|
||||||
|
const exactResult = await sql`
|
||||||
|
SELECT * FROM cards
|
||||||
|
WHERE LOWER(name) = LOWER(${trimmedName})
|
||||||
|
AND (LOWER(set_name) = LOWER(${set || setCode}) OR LOWER(set_code) = LOWER(${setCode || set}))
|
||||||
|
AND LOWER(card_number) = LOWER(${cardNumber})
|
||||||
|
LIMIT 1
|
||||||
|
`;
|
||||||
|
if (exactResult.rows.length > 0) {
|
||||||
|
existingCard = exactResult.rows[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existingCard && (set || setCode)) {
|
||||||
|
const setResult = set
|
||||||
|
? await sql`
|
||||||
|
SELECT * FROM cards
|
||||||
|
WHERE LOWER(name) = LOWER(${trimmedName})
|
||||||
|
AND (LOWER(set_name) = LOWER(${set}) OR LOWER(set_code) = LOWER(${setCode || set}))
|
||||||
|
LIMIT 1
|
||||||
|
`
|
||||||
|
: await sql`
|
||||||
|
SELECT * FROM cards
|
||||||
|
WHERE LOWER(name) = LOWER(${trimmedName})
|
||||||
|
AND LOWER(set_code) = LOWER(${setCode})
|
||||||
|
LIMIT 1
|
||||||
|
`;
|
||||||
|
if (setResult.rows.length > 0) {
|
||||||
|
existingCard = setResult.rows[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
existingCard = nameResult.rows[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existingCard) {
|
||||||
|
const fuzzyResult = await sql`
|
||||||
|
SELECT * FROM cards
|
||||||
|
WHERE LOWER(name) ILIKE LOWER(${`%${trimmedName}%`})
|
||||||
|
ORDER BY
|
||||||
|
CASE
|
||||||
|
WHEN LOWER(name) = LOWER(${trimmedName}) THEN 1
|
||||||
|
WHEN LOWER(name) LIKE LOWER(${trimmedName + '%'}) THEN 2
|
||||||
|
WHEN LOWER(name) LIKE LOWER(${'%' + trimmedName + '%'}) THEN 3
|
||||||
|
ELSE 4
|
||||||
|
END,
|
||||||
|
CASE WHEN game = ${game || 'UNKNOWN'} THEN 1 ELSE 2 END,
|
||||||
|
LENGTH(name)
|
||||||
|
LIMIT 5
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (fuzzyResult.rows.length > 0) {
|
||||||
|
const exactFuzzyMatch = fuzzyResult.rows.find(
|
||||||
|
(row) => row.name.toLowerCase() === trimmedName.toLowerCase()
|
||||||
|
);
|
||||||
|
|
||||||
|
if (exactFuzzyMatch && ocrData?.confidence >= 80) {
|
||||||
|
existingCard = exactFuzzyMatch;
|
||||||
|
} else if (ocrData?.confidence < 80 && fuzzyResult.rows.length > 1) {
|
||||||
|
return {
|
||||||
|
type: 'disambiguation',
|
||||||
|
card: null,
|
||||||
|
matches: fuzzyResult.rows.map(mapCardRow),
|
||||||
|
needsUserSelection: true,
|
||||||
|
message: `Found ${fuzzyResult.rows.length} possible matches for "${trimmedName}". Please select the correct card.`,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
existingCard = fuzzyResult.rows[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingCard) {
|
||||||
|
return {
|
||||||
|
type: 'matched',
|
||||||
|
card: existingCard,
|
||||||
|
isExisting: true,
|
||||||
|
message: `Found existing card: "${existingCard.name}"`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const confidenceThreshold = 75;
|
||||||
|
if (!ocrData || ocrData.confidence < confidenceThreshold) {
|
||||||
|
return {
|
||||||
|
type: 'needs_input',
|
||||||
|
card: null,
|
||||||
|
matches: [],
|
||||||
|
needsUserInput: true,
|
||||||
|
message: `Could not find card "${trimmedName}" in database and confidence is low (${ocrData?.confidence || 0}%). Please verify the card name and try again.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const submissionId = await createCardSubmission(
|
||||||
|
userId,
|
||||||
|
{ name: trimmedName, set, setCode, cardNumber, game, cardType, rarity, hp, manaCost, ocrData, scanImageUrl },
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'submitted',
|
||||||
|
card: null,
|
||||||
|
submissionId,
|
||||||
|
needsReview: true,
|
||||||
|
message: `Card "${trimmedName}" was not found in the catalog. Your scan was saved for admin review (submission #${submissionId}).`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logScanAttempt({
|
||||||
|
userId,
|
||||||
|
ocrText,
|
||||||
|
ocrConfidence,
|
||||||
|
layer = 2,
|
||||||
|
matchedCardId = null,
|
||||||
|
resultKind,
|
||||||
|
latencyMs,
|
||||||
|
}) {
|
||||||
|
await sql`
|
||||||
|
INSERT INTO scan_attempts (
|
||||||
|
user_id, ocr_text, ocr_confidence, layer,
|
||||||
|
matched_card_id, result_kind, latency_ms
|
||||||
|
) VALUES (
|
||||||
|
${userId},
|
||||||
|
${ocrText || null},
|
||||||
|
${ocrConfidence ?? null},
|
||||||
|
${layer},
|
||||||
|
${matchedCardId},
|
||||||
|
${resultKind},
|
||||||
|
${latencyMs ?? null}
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
}
|
||||||
129
lib/scan-gemini.js
Normal file
129
lib/scan-gemini.js
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
const GEMINI_MODEL =
|
||||||
|
'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent';
|
||||||
|
|
||||||
|
const CARD_PROMPT = `You are a specialized trading card recognition system. Analyze this image and determine if it contains a trading card (Magic: The Gathering, Pokemon, Yu-Gi-Oh, Lorcana, etc.).
|
||||||
|
|
||||||
|
CRITICAL: Only respond with card data if you can clearly identify a TRADING CARD in the image. Ignore random objects, books, papers, phone screens, screenshots, blurry images, and non-card gaming items.
|
||||||
|
|
||||||
|
If you detect a trading card, extract information in this JSON format:
|
||||||
|
{
|
||||||
|
"isCard": true,
|
||||||
|
"cardName": "exact card name as printed on the card",
|
||||||
|
"setName": "set name if visible",
|
||||||
|
"setCode": "set code/symbol if visible",
|
||||||
|
"cardNumber": "collector number if visible",
|
||||||
|
"game": "mtg, pokemon, or lorcana (lowercase)",
|
||||||
|
"cardType": "creature, instant, sorcery, trainer, etc.",
|
||||||
|
"rarity": "common, uncommon, rare, mythic, etc.",
|
||||||
|
"manaCost": "mana cost if visible",
|
||||||
|
"hp": "HP or power if visible",
|
||||||
|
"abilities": ["list of abilities or attacks if clearly readable"],
|
||||||
|
"confidence": 85,
|
||||||
|
"rawText": "all text visible on the card"
|
||||||
|
}
|
||||||
|
|
||||||
|
If NO trading card is clearly visible, respond with:
|
||||||
|
{
|
||||||
|
"isCard": false,
|
||||||
|
"confidence": 0,
|
||||||
|
"reason": "No trading card detected in image"
|
||||||
|
}
|
||||||
|
|
||||||
|
Be conservative — only extract data you can clearly read. Quality over quantity.`;
|
||||||
|
|
||||||
|
function parseGeminiJson(content) {
|
||||||
|
const cleanContent = content.replace(/```json\n?/g, '').replace(/```\n?/g, '').trim();
|
||||||
|
try {
|
||||||
|
return JSON.parse(cleanContent);
|
||||||
|
} catch {
|
||||||
|
const cardNameMatch = content.match(/card.*?name.*?[:"]\s*([^"'\n,}]+)/i);
|
||||||
|
return {
|
||||||
|
isCard: !!cardNameMatch,
|
||||||
|
cardName: cardNameMatch ? cardNameMatch[1].trim() : null,
|
||||||
|
confidence: 30,
|
||||||
|
rawText: content,
|
||||||
|
reason: 'Failed to parse structured response',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeGame(game) {
|
||||||
|
if (!game) return null;
|
||||||
|
const value = String(game).trim().toLowerCase();
|
||||||
|
if (value === 'mtg' || value.includes('magic')) return 'mtg';
|
||||||
|
if (value.includes('pokemon') || value.includes('pokémon')) return 'pokemon';
|
||||||
|
if (value.includes('lorcana')) return 'lorcana';
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server-side Gemini Vision analysis. Requires GEMINI_AI_API_KEY in env.
|
||||||
|
* @param {string} imageDataUrl - data:image/jpeg;base64,... capture from scanner
|
||||||
|
*/
|
||||||
|
export async function analyzeCardImage(imageDataUrl) {
|
||||||
|
const apiKey = process.env.GEMINI_AI_API_KEY;
|
||||||
|
if (!apiKey) {
|
||||||
|
throw new Error('GEMINI_AI_API_KEY is not configured on the server');
|
||||||
|
}
|
||||||
|
|
||||||
|
const base64Data = imageDataUrl.split(',')[1];
|
||||||
|
if (!base64Data) {
|
||||||
|
throw new Error('Invalid image data format');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(GEMINI_MODEL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-goog-api-key': apiKey,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
contents: [{
|
||||||
|
parts: [
|
||||||
|
{ text: CARD_PROMPT },
|
||||||
|
{
|
||||||
|
inline_data: {
|
||||||
|
mime_type: 'image/jpeg',
|
||||||
|
data: base64Data,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
generationConfig: {
|
||||||
|
temperature: 0.1,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(
|
||||||
|
`Gemini API error: ${response.status} - ${errorData.error?.message || 'Unknown error'}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const content = data.candidates?.[0]?.content?.parts?.[0]?.text;
|
||||||
|
if (!content) {
|
||||||
|
throw new Error('No response from Gemini API');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = parseGeminiJson(content);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isCard: result.isCard || false,
|
||||||
|
cardName: result.cardName || null,
|
||||||
|
setName: result.setName || null,
|
||||||
|
setCode: result.setCode || null,
|
||||||
|
cardNumber: result.cardNumber || null,
|
||||||
|
game: normalizeGame(result.game),
|
||||||
|
cardType: result.cardType || null,
|
||||||
|
rarity: result.rarity || null,
|
||||||
|
manaCost: result.manaCost || null,
|
||||||
|
hp: result.hp || null,
|
||||||
|
abilities: result.abilities || [],
|
||||||
|
confidence: result.confidence || 0,
|
||||||
|
rawText: result.rawText || content,
|
||||||
|
reason: result.reason || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
52
migrations/1748365200000_add-scan-tables.js
Normal file
52
migrations/1748365200000_add-scan-tables.js
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
/**
|
||||||
|
* card_submissions + scan_attempts for server-side scan pipeline.
|
||||||
|
*
|
||||||
|
* @type {import('node-pg-migrate').ColumnDefinitions | undefined}
|
||||||
|
*/
|
||||||
|
export const shorthands = undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {import('node-pg-migrate').MigrationBuilder} pgm
|
||||||
|
*/
|
||||||
|
export const up = (pgm) => {
|
||||||
|
pgm.sql(`
|
||||||
|
CREATE TABLE IF NOT EXISTS card_submissions (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
ocr_text TEXT,
|
||||||
|
ocr_confidence INTEGER,
|
||||||
|
scan_image_url TEXT,
|
||||||
|
candidate_card_ids JSONB DEFAULT '[]',
|
||||||
|
ocr_payload JSONB,
|
||||||
|
status VARCHAR(32) NOT NULL DEFAULT 'pending',
|
||||||
|
reviewed_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
promoted_card_id INTEGER REFERENCES cards(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS scan_attempts (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
ocr_text TEXT,
|
||||||
|
ocr_confidence INTEGER,
|
||||||
|
layer INTEGER NOT NULL DEFAULT 2,
|
||||||
|
matched_card_id INTEGER REFERENCES cards(id) ON DELETE SET NULL,
|
||||||
|
result_kind VARCHAR(32) NOT NULL,
|
||||||
|
latency_ms INTEGER,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_card_submissions_status
|
||||||
|
ON card_submissions (status, created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_scan_attempts_user_created
|
||||||
|
ON scan_attempts (user_id, created_at DESC);
|
||||||
|
`);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {import('node-pg-migrate').MigrationBuilder} pgm
|
||||||
|
*/
|
||||||
|
export const down = (pgm) => {
|
||||||
|
throw new Error('Down migration not supported for add-scan-tables');
|
||||||
|
};
|
||||||
145
pages/admin/card-submissions.js
Normal file
145
pages/admin/card-submissions.js
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import Layout from '../../components/Layout';
|
||||||
|
import AdminProtected from '../../components/AdminProtected';
|
||||||
|
|
||||||
|
function CardSubmissionsAdmin() {
|
||||||
|
const [submissions, setSubmissions] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [processingId, setProcessingId] = useState(null);
|
||||||
|
|
||||||
|
const loadSubmissions = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/admin/card-submissions?status=pending', {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${localStorage.getItem('auth_token')}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to load submissions');
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
setSubmissions(data.submissions || []);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadSubmissions();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const reviewSubmission = async (submissionId, action) => {
|
||||||
|
setProcessingId(submissionId);
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/admin/card-submissions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${localStorage.getItem('auth_token')}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ submissionId, action }),
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || 'Review failed');
|
||||||
|
}
|
||||||
|
await loadSubmissions();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setProcessingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto px-6 py-8">
|
||||||
|
<h1 className="text-2xl font-bold mb-2" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
Card Scan Submissions
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm mb-6" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
Review cards identified by the scanner that are not yet in the global catalog.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 p-3 rounded-lg border border-red-500 text-sm text-red-600">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p style={{ color: 'var(--text-secondary)' }}>Loading…</p>
|
||||||
|
) : submissions.length === 0 ? (
|
||||||
|
<p style={{ color: 'var(--text-secondary)' }}>No pending submissions.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-4">
|
||||||
|
{submissions.map((sub) => {
|
||||||
|
const payload = sub.ocr_payload || {};
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={sub.id}
|
||||||
|
className="p-4 rounded-xl border"
|
||||||
|
style={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between gap-4 mb-2">
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
{payload.name || sub.ocr_text || 'Unknown card'}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
{payload.game} · confidence {sub.ocr_confidence ?? '—'}% · by {sub.submitter_email}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
#{sub.id}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{sub.ocr_text && (
|
||||||
|
<p className="text-xs mb-3 line-clamp-2" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
{sub.ocr_text}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={processingId === sub.id}
|
||||||
|
onClick={() => reviewSubmission(sub.id, 'approve')}
|
||||||
|
className="px-3 py-1.5 rounded-lg text-sm font-medium disabled:opacity-50"
|
||||||
|
style={{ backgroundColor: 'var(--accent-gold)', color: 'white' }}
|
||||||
|
>
|
||||||
|
Approve
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={processingId === sub.id}
|
||||||
|
onClick={() => reviewSubmission(sub.id, 'reject')}
|
||||||
|
className="px-3 py-1.5 rounded-lg text-sm border disabled:opacity-50"
|
||||||
|
style={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
||||||
|
>
|
||||||
|
Reject
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CardSubmissionsPage() {
|
||||||
|
return (
|
||||||
|
<AdminProtected>
|
||||||
|
{(user) => (
|
||||||
|
<Layout user={user}>
|
||||||
|
<CardSubmissionsAdmin />
|
||||||
|
</Layout>
|
||||||
|
)}
|
||||||
|
</AdminProtected>
|
||||||
|
);
|
||||||
|
}
|
||||||
104
pages/api/admin/card-submissions.js
Normal file
104
pages/api/admin/card-submissions.js
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
import { sql } from '@vercel/postgres';
|
||||||
|
import { getUserFromRequest } from '../../../lib/permission-middleware';
|
||||||
|
|
||||||
|
export default async function handler(req, res) {
|
||||||
|
try {
|
||||||
|
const user = await getUserFromRequest(req);
|
||||||
|
if (!user) {
|
||||||
|
return res.status(401).json({ error: 'Authentication required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.role !== 'admin') {
|
||||||
|
return res.status(403).json({ error: 'Admin access required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === 'GET') {
|
||||||
|
const { status = 'pending' } = req.query;
|
||||||
|
const result = await sql`
|
||||||
|
SELECT
|
||||||
|
cs.*,
|
||||||
|
u.email AS submitter_email
|
||||||
|
FROM card_submissions cs
|
||||||
|
JOIN users u ON cs.user_id = u.id
|
||||||
|
WHERE cs.status = ${status}
|
||||||
|
ORDER BY cs.created_at DESC
|
||||||
|
LIMIT 100
|
||||||
|
`;
|
||||||
|
return res.status(200).json({ submissions: result.rows });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === 'POST') {
|
||||||
|
const { submissionId, action } = req.body;
|
||||||
|
|
||||||
|
if (!submissionId || !['approve', 'reject'].includes(action)) {
|
||||||
|
return res.status(400).json({ error: 'submissionId and action (approve|reject) are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const submissionResult = await sql`
|
||||||
|
SELECT * FROM card_submissions WHERE id = ${submissionId} LIMIT 1
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (submissionResult.rows.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'Submission not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const submission = submissionResult.rows[0];
|
||||||
|
|
||||||
|
if (submission.status !== 'pending') {
|
||||||
|
return res.status(400).json({ error: 'Submission has already been reviewed' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'reject') {
|
||||||
|
await sql`
|
||||||
|
UPDATE card_submissions
|
||||||
|
SET status = 'rejected', reviewed_by = ${user.userId}, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ${submissionId}
|
||||||
|
`;
|
||||||
|
return res.status(200).json({ message: 'Submission rejected' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = submission.ocr_payload || {};
|
||||||
|
const newCardResult = await sql`
|
||||||
|
INSERT INTO cards (
|
||||||
|
name, set_name, set_code, card_number, rarity, game,
|
||||||
|
mana_cost, card_type, oracle_text, power, verified
|
||||||
|
) VALUES (
|
||||||
|
${payload.name || 'Unknown'},
|
||||||
|
${payload.set || null},
|
||||||
|
${payload.setCode || null},
|
||||||
|
${payload.cardNumber || null},
|
||||||
|
${payload.rarity || null},
|
||||||
|
${payload.game || 'UNKNOWN'},
|
||||||
|
${payload.manaCost || null},
|
||||||
|
${payload.cardType || null},
|
||||||
|
${payload.rawText || submission.ocr_text || null},
|
||||||
|
${payload.hp || null},
|
||||||
|
${true}
|
||||||
|
)
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const newCard = newCardResult.rows[0];
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
UPDATE card_submissions
|
||||||
|
SET
|
||||||
|
status = 'approved',
|
||||||
|
reviewed_by = ${user.userId},
|
||||||
|
promoted_card_id = ${newCard.id},
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ${submissionId}
|
||||||
|
`;
|
||||||
|
|
||||||
|
return res.status(201).json({
|
||||||
|
message: 'Submission approved and card promoted to catalog',
|
||||||
|
card: newCard,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(405).json({ error: 'Method not allowed' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[admin/card-submissions]', error);
|
||||||
|
return res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { sql } from '@vercel/postgres';
|
|
||||||
import { getUserFromRequest } from '../../../lib/permission-middleware';
|
import { getUserFromRequest } from '../../../lib/permission-middleware';
|
||||||
|
import { matchCardInCatalog } from '../../../lib/card-catalog-match.js';
|
||||||
|
|
||||||
export default async function handler(req, res) {
|
export default async function handler(req, res) {
|
||||||
if (req.method !== 'POST') {
|
if (req.method !== 'POST') {
|
||||||
|
|
@ -22,189 +22,56 @@ export default async function handler(req, res) {
|
||||||
rarity,
|
rarity,
|
||||||
hp,
|
hp,
|
||||||
manaCost,
|
manaCost,
|
||||||
ocrData
|
ocrData,
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
if (!name) {
|
const matchResult = await matchCardInCatalog({
|
||||||
return res.status(400).json({ error: 'Card name is required' });
|
userId: user.userId,
|
||||||
|
name,
|
||||||
|
set,
|
||||||
|
setCode,
|
||||||
|
cardNumber,
|
||||||
|
game,
|
||||||
|
cardType,
|
||||||
|
rarity,
|
||||||
|
hp,
|
||||||
|
manaCost,
|
||||||
|
ocrData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (matchResult.type === 'matched') {
|
||||||
|
return res.status(200).json({
|
||||||
|
card: matchResult.card,
|
||||||
|
isExisting: matchResult.isExisting,
|
||||||
|
message: matchResult.message,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`🔍 Looking for card: "${name}" | Set: "${set || setCode}" | Number: "${cardNumber}" | Game: "${game}"`);
|
if (matchResult.type === 'disambiguation') {
|
||||||
|
|
||||||
// First, try exact match by name, set, and card number (most specific)
|
|
||||||
let existingCard = null;
|
|
||||||
|
|
||||||
if ((set || setCode) && cardNumber) {
|
|
||||||
console.log('🎯 Trying exact match with card number...');
|
|
||||||
const exactQuery = sql`
|
|
||||||
SELECT * FROM cards
|
|
||||||
WHERE LOWER(name) = LOWER(${name})
|
|
||||||
AND (LOWER(set_name) = LOWER(${set || setCode}) OR LOWER(set_code) = LOWER(${setCode || set}))
|
|
||||||
AND LOWER(card_number) = LOWER(${cardNumber})
|
|
||||||
LIMIT 1
|
|
||||||
`;
|
|
||||||
|
|
||||||
const exactResult = await exactQuery;
|
|
||||||
if (exactResult.rows.length > 0) {
|
|
||||||
existingCard = exactResult.rows[0];
|
|
||||||
console.log('✅ Found exact match with card number:', existingCard.name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Second, try exact match by name and set (without card number)
|
|
||||||
if (!existingCard && (set || setCode)) {
|
|
||||||
console.log('🎯 Trying exact match by name and set...');
|
|
||||||
const setQuery = set ?
|
|
||||||
sql`SELECT * FROM cards WHERE LOWER(name) = LOWER(${name}) AND (LOWER(set_name) = LOWER(${set}) OR LOWER(set_code) = LOWER(${setCode || set})) LIMIT 1` :
|
|
||||||
sql`SELECT * FROM cards WHERE LOWER(name) = LOWER(${name}) AND LOWER(set_code) = LOWER(${setCode}) LIMIT 1`;
|
|
||||||
|
|
||||||
const setResult = await setQuery;
|
|
||||||
if (setResult.rows.length > 0) {
|
|
||||||
existingCard = setResult.rows[0];
|
|
||||||
console.log('✅ Found exact match by name and set:', existingCard.name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Third, try exact name match (any set)
|
|
||||||
if (!existingCard) {
|
|
||||||
console.log('🎯 Trying exact name match (any set)...');
|
|
||||||
const nameQuery = sql`
|
|
||||||
SELECT * FROM cards
|
|
||||||
WHERE LOWER(name) = LOWER(${name})
|
|
||||||
ORDER BY
|
|
||||||
CASE WHEN game = ${game || 'UNKNOWN'} THEN 1 ELSE 2 END,
|
|
||||||
created_at DESC
|
|
||||||
LIMIT 1
|
|
||||||
`;
|
|
||||||
|
|
||||||
const nameResult = await nameQuery;
|
|
||||||
if (nameResult.rows.length > 0) {
|
|
||||||
existingCard = nameResult.rows[0];
|
|
||||||
console.log('✅ Found exact name match:', existingCard.name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fourth, try fuzzy name matching with game preference
|
|
||||||
if (!existingCard) {
|
|
||||||
console.log('🎯 Trying fuzzy name matching...');
|
|
||||||
const fuzzyResult = await sql`
|
|
||||||
SELECT * FROM cards
|
|
||||||
WHERE LOWER(name) ILIKE LOWER(${`%${name}%`})
|
|
||||||
ORDER BY
|
|
||||||
CASE
|
|
||||||
WHEN LOWER(name) = LOWER(${name}) THEN 1
|
|
||||||
WHEN LOWER(name) LIKE LOWER(${name + '%'}) THEN 2
|
|
||||||
WHEN LOWER(name) LIKE LOWER(${'%' + name + '%'}) THEN 3
|
|
||||||
ELSE 4
|
|
||||||
END,
|
|
||||||
CASE WHEN game = ${game || 'UNKNOWN'} THEN 1 ELSE 2 END,
|
|
||||||
LENGTH(name)
|
|
||||||
LIMIT 5
|
|
||||||
`;
|
|
||||||
|
|
||||||
if (fuzzyResult.rows.length > 0) {
|
|
||||||
console.log(`🔍 Found ${fuzzyResult.rows.length} fuzzy matches`);
|
|
||||||
|
|
||||||
// If we have high confidence and an exact match, use it
|
|
||||||
const exactFuzzyMatch = fuzzyResult.rows.find(row =>
|
|
||||||
row.name.toLowerCase() === name.toLowerCase()
|
|
||||||
);
|
|
||||||
|
|
||||||
if (exactFuzzyMatch && ocrData?.confidence >= 80) {
|
|
||||||
existingCard = exactFuzzyMatch;
|
|
||||||
console.log('✅ Using high-confidence fuzzy exact match:', existingCard.name);
|
|
||||||
} else if (ocrData?.confidence < 80 && fuzzyResult.rows.length > 1) {
|
|
||||||
// Low confidence with multiple matches - let user choose
|
|
||||||
console.log('⚠️ Multiple matches with low confidence - requiring user selection');
|
|
||||||
return res.status(200).json({
|
return res.status(200).json({
|
||||||
card: null,
|
card: null,
|
||||||
matches: fuzzyResult.rows.map(card => ({
|
matches: matchResult.matches,
|
||||||
id: card.id,
|
|
||||||
name: card.name,
|
|
||||||
set_name: card.set_name,
|
|
||||||
set_code: card.set_code,
|
|
||||||
card_number: card.card_number,
|
|
||||||
game: card.game,
|
|
||||||
rarity: card.rarity,
|
|
||||||
image_url: card.image_url
|
|
||||||
})),
|
|
||||||
needsUserSelection: true,
|
needsUserSelection: true,
|
||||||
message: `Found ${fuzzyResult.rows.length} possible matches for "${name}". Please select the correct card.`
|
message: matchResult.message,
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
// Use the best match
|
|
||||||
existingCard = fuzzyResult.rows[0];
|
|
||||||
console.log('✅ Using best fuzzy match:', existingCard.name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we found an existing card, return it
|
if (matchResult.type === 'submitted') {
|
||||||
if (existingCard) {
|
|
||||||
console.log('🎉 Returning existing card:', existingCard.name);
|
|
||||||
return res.status(200).json({
|
return res.status(200).json({
|
||||||
card: existingCard,
|
card: null,
|
||||||
isExisting: true,
|
matches: [],
|
||||||
message: `Found existing card: "${existingCard.name}"`
|
submissionId: matchResult.submissionId,
|
||||||
|
needsReview: true,
|
||||||
|
message: matchResult.message,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// If no existing card found, decide whether to create a new one
|
|
||||||
const confidenceThreshold = 75; // Increased threshold for better accuracy
|
|
||||||
|
|
||||||
if (!ocrData || ocrData.confidence < confidenceThreshold) {
|
|
||||||
console.log(`❌ No match found and confidence too low (${ocrData?.confidence || 0}% < ${confidenceThreshold}%)`);
|
|
||||||
return res.status(200).json({
|
return res.status(200).json({
|
||||||
card: null,
|
card: null,
|
||||||
matches: [],
|
matches: [],
|
||||||
needsUserInput: true,
|
needsUserInput: true,
|
||||||
message: `Could not find card "${name}" in database and confidence is low (${ocrData?.confidence || 0}%). Please verify the card name and try again.`
|
message: matchResult.message,
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
// Create new card entry with enhanced data
|
|
||||||
console.log('🆕 Creating new card from OCR data...');
|
|
||||||
const newCardResult = await sql`
|
|
||||||
INSERT INTO cards (
|
|
||||||
name, set_name, set_code, card_number, rarity, game,
|
|
||||||
mana_cost, cmc, card_type, colors, oracle_text,
|
|
||||||
power, toughness, image_url, stock_image_url,
|
|
||||||
current_price, market_price, scryfall_id, verified
|
|
||||||
) VALUES (
|
|
||||||
${name.trim()},
|
|
||||||
${set || null},
|
|
||||||
${setCode || null},
|
|
||||||
${cardNumber || null},
|
|
||||||
${rarity || null},
|
|
||||||
${game || 'UNKNOWN'},
|
|
||||||
${manaCost || null},
|
|
||||||
${null}, -- cmc (calculated from mana cost)
|
|
||||||
${cardType || null},
|
|
||||||
${null}, -- colors (unknown from OCR)
|
|
||||||
${ocrData?.rawText || null}, -- Store OCR text in oracle_text temporarily
|
|
||||||
${hp || null}, -- power (HP for Pokemon)
|
|
||||||
${null}, -- toughness
|
|
||||||
${null}, -- image_url (to be fetched later)
|
|
||||||
${null}, -- stock_image_url
|
|
||||||
${null}, -- current_price
|
|
||||||
${null}, -- market_price
|
|
||||||
${null}, -- scryfall_id (to be populated later)
|
|
||||||
${false} -- not verified since it's from OCR
|
|
||||||
)
|
|
||||||
RETURNING *
|
|
||||||
`;
|
|
||||||
|
|
||||||
const newCard = newCardResult.rows[0];
|
|
||||||
|
|
||||||
// Log the OCR creation for potential review
|
|
||||||
console.log(`✅ Created new card from OCR: ${name} (${game}) - Confidence: ${ocrData?.confidence}%`);
|
|
||||||
|
|
||||||
return res.status(201).json({
|
|
||||||
card: newCard,
|
|
||||||
isExisting: false,
|
|
||||||
message: `Created new card "${name}" from scan data. This card may need verification.`
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error in find-or-create card:', error);
|
console.error('Error in find-or-create card:', error);
|
||||||
return res.status(500).json({ error: 'Internal server error' });
|
return res.status(500).json({ error: 'Internal server error' });
|
||||||
|
|
|
||||||
182
pages/api/scan/identify.js
Normal file
182
pages/api/scan/identify.js
Normal file
|
|
@ -0,0 +1,182 @@
|
||||||
|
import { getUserFromRequest } from '../../../lib/permission-middleware';
|
||||||
|
import { checkScanRateLimit } from '../../../lib/rate-limit.js';
|
||||||
|
import { analyzeCardImage } from '../../../lib/scan-gemini.js';
|
||||||
|
import { matchCardInCatalog, logScanAttempt } from '../../../lib/card-catalog-match.js';
|
||||||
|
|
||||||
|
function formatCardResponse(card, ocrResult) {
|
||||||
|
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: {
|
||||||
|
confidence: ocrResult.confidence,
|
||||||
|
rawText: ocrResult.rawText,
|
||||||
|
abilities: ocrResult.abilities,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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 { allowed, reset } = await checkScanRateLimit(req, user.userId);
|
||||||
|
if (!allowed) {
|
||||||
|
res.setHeader('Retry-After', Math.ceil((reset - Date.now()) / 1000));
|
||||||
|
return res.status(429).json({ error: 'Too many attempts. Try again later.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { imageData, game: preferredGame } = 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' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ocrResult = await analyzeCardImage(imageData);
|
||||||
|
const latencyMs = Date.now() - startedAt;
|
||||||
|
|
||||||
|
if (!ocrResult.isCard || ocrResult.confidence <= 60) {
|
||||||
|
await logScanAttempt({
|
||||||
|
userId: user.userId,
|
||||||
|
ocrText: ocrResult.rawText,
|
||||||
|
ocrConfidence: ocrResult.confidence,
|
||||||
|
layer: 2,
|
||||||
|
resultKind: 'not_a_card',
|
||||||
|
latencyMs,
|
||||||
|
});
|
||||||
|
return res.status(200).json({
|
||||||
|
isCard: false,
|
||||||
|
confidence: ocrResult.confidence,
|
||||||
|
reason: ocrResult.reason || 'No trading card detected',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchResult = await matchCardInCatalog({
|
||||||
|
userId: user.userId,
|
||||||
|
name: ocrResult.cardName,
|
||||||
|
set: ocrResult.setName,
|
||||||
|
setCode: ocrResult.setCode,
|
||||||
|
cardNumber: ocrResult.cardNumber,
|
||||||
|
game: preferredGame || ocrResult.game,
|
||||||
|
cardType: ocrResult.cardType,
|
||||||
|
rarity: ocrResult.rarity,
|
||||||
|
hp: ocrResult.hp,
|
||||||
|
manaCost: ocrResult.manaCost,
|
||||||
|
ocrData: {
|
||||||
|
confidence: ocrResult.confidence,
|
||||||
|
rawText: ocrResult.rawText,
|
||||||
|
abilities: ocrResult.abilities,
|
||||||
|
},
|
||||||
|
scanImageUrl: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (matchResult.type === 'matched') {
|
||||||
|
await logScanAttempt({
|
||||||
|
userId: user.userId,
|
||||||
|
ocrText: ocrResult.rawText,
|
||||||
|
ocrConfidence: ocrResult.confidence,
|
||||||
|
layer: 2,
|
||||||
|
matchedCardId: matchResult.card.id,
|
||||||
|
resultKind: 'matched',
|
||||||
|
latencyMs,
|
||||||
|
});
|
||||||
|
return res.status(200).json({
|
||||||
|
isCard: true,
|
||||||
|
card: formatCardResponse(matchResult.card, ocrResult),
|
||||||
|
isExisting: matchResult.isExisting,
|
||||||
|
message: matchResult.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchResult.type === 'disambiguation') {
|
||||||
|
await logScanAttempt({
|
||||||
|
userId: user.userId,
|
||||||
|
ocrText: ocrResult.rawText,
|
||||||
|
ocrConfidence: ocrResult.confidence,
|
||||||
|
layer: 2,
|
||||||
|
resultKind: 'disambiguation',
|
||||||
|
latencyMs,
|
||||||
|
});
|
||||||
|
return res.status(200).json({
|
||||||
|
isCard: true,
|
||||||
|
card: null,
|
||||||
|
matches: matchResult.matches,
|
||||||
|
needsUserSelection: true,
|
||||||
|
ocr: {
|
||||||
|
confidence: ocrResult.confidence,
|
||||||
|
rawText: ocrResult.rawText,
|
||||||
|
cardName: ocrResult.cardName,
|
||||||
|
},
|
||||||
|
message: matchResult.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchResult.type === 'submitted') {
|
||||||
|
await logScanAttempt({
|
||||||
|
userId: user.userId,
|
||||||
|
ocrText: ocrResult.rawText,
|
||||||
|
ocrConfidence: ocrResult.confidence,
|
||||||
|
layer: 2,
|
||||||
|
resultKind: 'submitted',
|
||||||
|
latencyMs,
|
||||||
|
});
|
||||||
|
return res.status(200).json({
|
||||||
|
isCard: true,
|
||||||
|
card: null,
|
||||||
|
submissionId: matchResult.submissionId,
|
||||||
|
needsReview: true,
|
||||||
|
ocr: {
|
||||||
|
confidence: ocrResult.confidence,
|
||||||
|
rawText: ocrResult.rawText,
|
||||||
|
cardName: ocrResult.cardName,
|
||||||
|
},
|
||||||
|
message: matchResult.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await logScanAttempt({
|
||||||
|
userId: user.userId,
|
||||||
|
ocrText: ocrResult.rawText,
|
||||||
|
ocrConfidence: ocrResult.confidence,
|
||||||
|
layer: 2,
|
||||||
|
resultKind: 'needs_input',
|
||||||
|
latencyMs,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
isCard: true,
|
||||||
|
card: null,
|
||||||
|
needsUserInput: true,
|
||||||
|
ocr: {
|
||||||
|
confidence: ocrResult.confidence,
|
||||||
|
rawText: ocrResult.rawText,
|
||||||
|
cardName: ocrResult.cardName,
|
||||||
|
},
|
||||||
|
message: matchResult.message,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[POST /api/scan/identify]', error);
|
||||||
|
return res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue