feat(scanner): server-side scan pipeline (convoy #2) #35
12 changed files with 1116 additions and 1305 deletions
23
.github/workflows/ci.yml
vendored
23
.github/workflows/ci.yml
vendored
|
|
@ -164,17 +164,26 @@ jobs:
|
|||
done
|
||||
exit 1
|
||||
fi
|
||||
# Grandfathered until server-side-scan-pipeline (#2) removes browser LLM clients.
|
||||
GRANDFATHER=(
|
||||
lib/ai-ocr.js
|
||||
components/OCRSettings.js
|
||||
AI_OCR_IMPORTS=$(grep -rEn 'from ['\''"].*ai-ocr|import.*ai-ocr' components/ 2>/dev/null || true)
|
||||
if [ -n "$AI_OCR_IMPORTS" ]; then
|
||||
echo "::error::Browser code must not import lib/ai-ocr — use POST /api/scan/identify instead."
|
||||
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
|
||||
# lib/ may hold server-only helpers (e.g. scan-gemini.js) imported only from pages/api/.
|
||||
SERVER_ONLY=(
|
||||
lib/scan-gemini.js
|
||||
)
|
||||
LLM_PATTERN='generativelanguage\.googleapis\.com|api\.openai\.com'
|
||||
FOUND=()
|
||||
while IFS= read -r file; do
|
||||
skip=false
|
||||
for gf in "${GRANDFATHER[@]}"; do
|
||||
if [ "$file" = "$gf" ]; then
|
||||
for so in "${SERVER_ONLY[@]}"; do
|
||||
if [ "$file" = "$so" ]; then
|
||||
skip=true
|
||||
break
|
||||
fi
|
||||
|
|
@ -187,7 +196,7 @@ jobs:
|
|||
fi
|
||||
done < <(find components lib pages -name '*.js' ! -path 'pages/api/*' 2>/dev/null || true)
|
||||
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
|
||||
echo "::error file=${path}::Move LLM calls server-side or add to server-side-scan-pipeline removal list."
|
||||
done
|
||||
|
|
|
|||
|
|
@ -1,18 +1,9 @@
|
|||
import { useState, useEffect, useRef } from 'react';
|
||||
import { aiCardOCR, ollamaCardOCR, puterCardOCR, geminiCardOCR } from '../lib/ai-ocr';
|
||||
|
||||
export default function CameraScanner({ onCardScanned, onError }) {
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [isDetecting, setIsDetecting] = useState(false);
|
||||
const [scanningAnimation, setScanningAnimation] = useState(false);
|
||||
const [scanResult, setScanResult] = useState(null);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [ocrSettings, setOcrSettings] = useState({
|
||||
service: 'gemini',
|
||||
openaiApiKey: '',
|
||||
geminiApiKey: '',
|
||||
ollamaUrl: 'http://localhost:11434'
|
||||
});
|
||||
const [disambiguation, setDisambiguation] = useState(null);
|
||||
|
||||
const videoRef = useRef(null);
|
||||
const canvasRef = useRef(null);
|
||||
|
|
@ -29,38 +20,6 @@ export default function CameraScanner({ onCardScanned, onError }) {
|
|||
// Mana symbol settings
|
||||
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
|
||||
useEffect(() => {
|
||||
if (canvasRef.current) {
|
||||
|
|
@ -280,26 +239,102 @@ export default function CameraScanner({ onCardScanned, onError }) {
|
|||
setTrackedCards(updatedCards);
|
||||
};
|
||||
|
||||
// Quick card verification using AI
|
||||
const emitScannedCard = (cardTracker, imageData, finalCard, ocrMeta = {}) => {
|
||||
cardTracker.status = 'scanned';
|
||||
|
||||
const originalTitle = document.title;
|
||||
document.title = `📸 ${finalCard.name} - Card Scanner`;
|
||||
setTimeout(() => {
|
||||
document.title = originalTitle;
|
||||
}, 3000);
|
||||
|
||||
onCardScanned({
|
||||
name: finalCard.name,
|
||||
set: finalCard.set_name,
|
||||
setCode: finalCard.set_code,
|
||||
cardNumber: finalCard.card_number,
|
||||
game: finalCard.game,
|
||||
cardType: finalCard.card_type,
|
||||
rarity: finalCard.rarity,
|
||||
hp: finalCard.hp || ocrMeta.hp,
|
||||
manaCost: finalCard.mana_cost || ocrMeta.manaCost,
|
||||
abilities: ocrMeta.abilities || [],
|
||||
ocrText: ocrMeta.rawText,
|
||||
confidence: ocrMeta.confidence,
|
||||
capturedImage: imageData,
|
||||
image_url: finalCard.image_url,
|
||||
databaseId: finalCard.id,
|
||||
isExisting: true,
|
||||
});
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const ocrMeta = {
|
||||
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';
|
||||
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 {
|
||||
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
|
||||
const margin = 20;
|
||||
|
||||
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),
|
||||
|
|
@ -310,131 +345,32 @@ export default function CameraScanner({ onCardScanned, onError }) {
|
|||
|
||||
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', {
|
||||
const response = await fetch('/api/scan/identify', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
|
||||
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
|
||||
}
|
||||
})
|
||||
body: JSON.stringify({ imageData }),
|
||||
});
|
||||
|
||||
if (!dbResponse.ok) {
|
||||
throw new Error(`Database lookup failed: ${dbResponse.status}`);
|
||||
if (response.status === 429) {
|
||||
onError?.('Too many scan attempts. Please wait a moment and try again.');
|
||||
cardTracker.status = 'negative';
|
||||
return;
|
||||
}
|
||||
|
||||
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';
|
||||
|
||||
// Update browser tab title with card name
|
||||
const originalTitle = document.title;
|
||||
document.title = `📸 ${finalCard.name} - Card Scanner`;
|
||||
|
||||
// Reset title after 3 seconds
|
||||
setTimeout(() => {
|
||||
document.title = originalTitle;
|
||||
}, 3000);
|
||||
|
||||
onCardScanned({
|
||||
name: finalCard.name,
|
||||
set: finalCard.set_name,
|
||||
setCode: finalCard.set_code,
|
||||
cardNumber: finalCard.card_number,
|
||||
game: finalCard.game,
|
||||
cardType: finalCard.card_type,
|
||||
rarity: finalCard.rarity,
|
||||
hp: finalCard.hp || ocrResult.hp,
|
||||
manaCost: finalCard.mana_cost || ocrResult.manaCost,
|
||||
abilities: ocrResult.abilities,
|
||||
ocrText: ocrResult.rawText,
|
||||
confidence: ocrResult.confidence,
|
||||
capturedImage: imageData,
|
||||
image_url: finalCard.image_url,
|
||||
databaseId: finalCard.id,
|
||||
isExisting: dbResult.isExisting
|
||||
});
|
||||
|
||||
console.log(`🎉 Card ${cardTracker.id} successfully scanned: ${finalCard.name}`);
|
||||
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 processing card ${cardTracker.id}:`, 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 className="flex items-center gap-2">
|
||||
<span className="text-blue-500">●</span>
|
||||
<span>AI verification</span>
|
||||
<span>Server identification</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-yellow-500">●</span>
|
||||
|
|
@ -790,6 +726,56 @@ export default function CameraScanner({ onCardScanned, onError }) {
|
|||
</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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,443 +1,39 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
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;
|
||||
|
||||
export default function OCRSettings({ onClose }) {
|
||||
return (
|
||||
<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)' }}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b" style={{ borderColor: 'var(--border)' }}>
|
||||
<div
|
||||
className="rounded-xl border max-w-md w-full p-6"
|
||||
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)' }}>
|
||||
🤖 OCR Settings
|
||||
Card Scanner
|
||||
</h2>
|
||||
<button
|
||||
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)' }}
|
||||
aria-label="Close"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Service Selection */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-3" style={{ color: 'var(--text-primary)' }}>
|
||||
OCR Service
|
||||
</label>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm mb-4" style={{ color: 'var(--text-secondary)' }}>
|
||||
Card identification runs on Deck Hearth's servers using Gemini Vision. No API keys
|
||||
are required in your browser.
|
||||
</p>
|
||||
<ul className="text-sm space-y-2 mb-6" style={{ color: 'var(--text-secondary)' }}>
|
||||
<li>Hold the card steady in the camera frame for best results.</li>
|
||||
<li>If multiple matches are found, you will be asked to pick the correct printing.</li>
|
||||
<li>Unknown cards are saved for admin review instead of being added to the global catalog.</li>
|
||||
</ul>
|
||||
<button
|
||||
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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@
|
|||
| **Ownership** | `user_cards`, `user_favorites` | What a user owns / has favorited |
|
||||
| **Collections** | `collections`, `collection_cards`, `collection_permissions`, `collection_activity` | Curated card lists with sharing |
|
||||
| **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 |
|
||||
|
||||
## Tables
|
||||
|
|
@ -166,6 +167,44 @@
|
|||
| `quantity` | `INTEGER` default `1` | |
|
||||
| | | **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)
|
||||
|
||||
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 { matchCardInCatalog } from '../../../lib/card-catalog-match.js';
|
||||
|
||||
export default async function handler(req, res) {
|
||||
if (req.method !== 'POST') {
|
||||
|
|
@ -22,189 +22,56 @@ export default async function handler(req, res) {
|
|||
rarity,
|
||||
hp,
|
||||
manaCost,
|
||||
ocrData
|
||||
ocrData,
|
||||
} = req.body;
|
||||
|
||||
if (!name) {
|
||||
return res.status(400).json({ error: 'Card name is required' });
|
||||
}
|
||||
const matchResult = await matchCardInCatalog({
|
||||
userId: user.userId,
|
||||
name,
|
||||
set,
|
||||
setCode,
|
||||
cardNumber,
|
||||
game,
|
||||
cardType,
|
||||
rarity,
|
||||
hp,
|
||||
manaCost,
|
||||
ocrData,
|
||||
});
|
||||
|
||||
console.log(`🔍 Looking for card: "${name}" | Set: "${set || setCode}" | Number: "${cardNumber}" | Game: "${game}"`);
|
||||
|
||||
// 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({
|
||||
card: null,
|
||||
matches: fuzzyResult.rows.map(card => ({
|
||||
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,
|
||||
message: `Found ${fuzzyResult.rows.length} possible matches for "${name}". Please select the correct card.`
|
||||
});
|
||||
} 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 (existingCard) {
|
||||
console.log('🎉 Returning existing card:', existingCard.name);
|
||||
if (matchResult.type === 'matched') {
|
||||
return res.status(200).json({
|
||||
card: existingCard,
|
||||
isExisting: true,
|
||||
message: `Found existing card: "${existingCard.name}"`
|
||||
card: matchResult.card,
|
||||
isExisting: matchResult.isExisting,
|
||||
message: matchResult.message,
|
||||
});
|
||||
}
|
||||
|
||||
// If no existing card found, decide whether to create a new one
|
||||
const confidenceThreshold = 75; // Increased threshold for better accuracy
|
||||
if (matchResult.type === 'disambiguation') {
|
||||
return res.status(200).json({
|
||||
card: null,
|
||||
matches: matchResult.matches,
|
||||
needsUserSelection: true,
|
||||
message: matchResult.message,
|
||||
});
|
||||
}
|
||||
|
||||
if (!ocrData || ocrData.confidence < confidenceThreshold) {
|
||||
console.log(`❌ No match found and confidence too low (${ocrData?.confidence || 0}% < ${confidenceThreshold}%)`);
|
||||
if (matchResult.type === 'submitted') {
|
||||
return res.status(200).json({
|
||||
card: null,
|
||||
matches: [],
|
||||
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.`
|
||||
submissionId: matchResult.submissionId,
|
||||
needsReview: true,
|
||||
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.`
|
||||
return res.status(200).json({
|
||||
card: null,
|
||||
matches: [],
|
||||
needsUserInput: true,
|
||||
message: matchResult.message,
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error in find-or-create card:', 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