diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e6ec433..c8834a5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -164,30 +164,25 @@ 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 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 - skip=true - break - fi - done - if [ "$skip" = true ]; then - continue - fi if grep -qE "$LLM_PATTERN" "$file" 2>/dev/null; then FOUND+=("$file") 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 diff --git a/components/CameraScanner.js b/components/CameraScanner.js index 350e422..8a21d50 100644 --- a/components/CameraScanner.js +++ b/components/CameraScanner.js @@ -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), @@ -307,134 +342,35 @@ export default function CameraScanner({ onCardScanned, onError }) { 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', { + 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')}` + 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 }) {
● - AI verification + Server identification
● @@ -790,6 +726,56 @@ export default function CameraScanner({ onCardScanned, onError }) {
)} + + {disambiguation && ( +
+
+

+ Which card is this? +

+

+ {disambiguation.message || 'Multiple matches found. Select the correct printing.'} +

+
+ {disambiguation.candidates.map((candidate) => ( + + ))} +
+ +
+
+ )} ); -} \ No newline at end of file +} \ No newline at end of file diff --git a/components/OCRSettings.js b/components/OCRSettings.js index e6e7931..ebde6a5 100644 --- a/components/OCRSettings.js +++ b/components/OCRSettings.js @@ -1,444 +1,40 @@ -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 (
-
- {/* Header */} -
+
+

- πŸ€– OCR Settings + Card Scanner

- - {/* Content */} -
- {/* Service Selection */} -
- -
- - - - - - - -
-
- - {/* OpenAI Settings */} - {settings.service === 'openai' && ( -
- - 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)' - }} - /> -
- Get your API key from{' '} - - OpenAI Platform - -
-
- )} - - {/* Gemini Settings */} - {settings.service === 'gemini' && ( -
- - 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)' - }} - /> -
- Get your free API key from{' '} - - Google AI Studio - -
-
- )} - - {/* Ollama Settings */} - {settings.service === 'ollama' && ( -
- - 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)' - }} - /> -
-
Install Ollama and run: ollama pull llava:latest
-
- Setup guide:{' '} - - ollama.ai - -
-
-
- )} - - {/* Test Result */} - {testResult && ( -
-
- {testResult.message} -
-
- )} - - {/* Actions */} -
- - - -
- - {/* Usage Tips */} -
-
πŸ’‘ Tips
-
    -
  • β€’ Puter.js offers free GPT-4o vision with no setup required
  • -
  • β€’ OpenAI Vision API offers highest accuracy for card recognition
  • -
  • β€’ Ollama is free and private but requires local setup
  • -
  • β€’ Test your connection before scanning cards
  • -
  • β€’ Settings are saved locally in your browser
  • -
-
-
+

+ Card identification runs on Deck Hearth's servers using Gemini Vision. No API keys + are required in your browser. +

+
    +
  • Hold the card steady in the camera frame for best results.
  • +
  • If multiple matches are found, you will be asked to pick the correct printing.
  • +
  • Unknown cards are saved for admin review instead of being added to the global catalog.
  • +
+
); -} \ No newline at end of file +} diff --git a/docs/SCHEMA_MAP.md b/docs/SCHEMA_MAP.md index b432281..2a0d1ab 100644 --- a/docs/SCHEMA_MAP.md +++ b/docs/SCHEMA_MAP.md @@ -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. diff --git a/lib/ai-ocr.js b/lib/ai-ocr.js deleted file mode 100644 index def1071..0000000 --- a/lib/ai-ocr.js +++ /dev/null @@ -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(); \ No newline at end of file diff --git a/lib/card-catalog-match.js b/lib/card-catalog-match.js new file mode 100644 index 0000000..0447b34 --- /dev/null +++ b/lib/card-catalog-match.js @@ -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} + ) + `; +} diff --git a/lib/scan-gemini.js b/lib/scan-gemini.js new file mode 100644 index 0000000..70ddafe --- /dev/null +++ b/lib/scan-gemini.js @@ -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, + }; +} diff --git a/migrations/1748365200000_add-scan-tables.js b/migrations/1748365200000_add-scan-tables.js new file mode 100644 index 0000000..310ff72 --- /dev/null +++ b/migrations/1748365200000_add-scan-tables.js @@ -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'); +}; diff --git a/pages/admin/card-submissions.js b/pages/admin/card-submissions.js new file mode 100644 index 0000000..ed988d4 --- /dev/null +++ b/pages/admin/card-submissions.js @@ -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 ( +
+

+ Card Scan Submissions +

+

+ Review cards identified by the scanner that are not yet in the global catalog. +

+ + {error && ( +
+ {error} +
+ )} + + {loading ? ( +

Loading…

+ ) : submissions.length === 0 ? ( +

No pending submissions.

+ ) : ( +
    + {submissions.map((sub) => { + const payload = sub.ocr_payload || {}; + return ( +
  • +
    +
    +
    + {payload.name || sub.ocr_text || 'Unknown card'} +
    +
    + {payload.game} Β· confidence {sub.ocr_confidence ?? 'β€”'}% Β· by {sub.submitter_email} +
    +
    +
    + #{sub.id} +
    +
    + {sub.ocr_text && ( +

    + {sub.ocr_text} +

    + )} +
    + + +
    +
  • + ); + })} +
+ )} +
+ ); +} + +export default function CardSubmissionsPage() { + return ( + + {(user) => ( + + + + )} + + ); +} diff --git a/pages/api/admin/card-submissions.js b/pages/api/admin/card-submissions.js new file mode 100644 index 0000000..1cd2b86 --- /dev/null +++ b/pages/api/admin/card-submissions.js @@ -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' }); + } +} diff --git a/pages/api/cards/find-or-create.js b/pages/api/cards/find-or-create.js index 0b27687..17efbc4 100644 --- a/pages/api/cards/find-or-create.js +++ b/pages/api/cards/find-or-create.js @@ -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,191 +22,58 @@ 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 (!ocrData || ocrData.confidence < confidenceThreshold) { - console.log(`❌ No match found and confidence too low (${ocrData?.confidence || 0}% < ${confidenceThreshold}%)`); + if (matchResult.type === 'disambiguation') { + return res.status(200).json({ + card: null, + matches: matchResult.matches, + needsUserSelection: true, + message: matchResult.message, + }); + } + + 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' }); } -} \ No newline at end of file +} diff --git a/pages/api/scan/identify.js b/pages/api/scan/identify.js new file mode 100644 index 0000000..391c261 --- /dev/null +++ b/pages/api/scan/identify.js @@ -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' }); + } +}