feat(scanner): AI Gateway + Layer-1 OCR (convoy #3) #38
11 changed files with 523 additions and 45 deletions
10
.github/workflows/ci.yml
vendored
10
.github/workflows/ci.yml
vendored
|
|
@ -59,6 +59,7 @@ jobs:
|
||||||
contains(github.event.pull_request.changed_files, 'scripts/add-') ||
|
contains(github.event.pull_request.changed_files, 'scripts/add-') ||
|
||||||
contains(github.event.pull_request.changed_files, 'scripts/fix-') ||
|
contains(github.event.pull_request.changed_files, 'scripts/fix-') ||
|
||||||
contains(github.event.pull_request.changed_files, 'scripts/setup-neon-db.js') ||
|
contains(github.event.pull_request.changed_files, 'scripts/setup-neon-db.js') ||
|
||||||
|
contains(github.event.pull_request.changed_files, 'migrations/') ||
|
||||||
contains(github.event.pull_request.changed_files, 'docs/SCHEMA_MAP.md')
|
contains(github.event.pull_request.changed_files, 'docs/SCHEMA_MAP.md')
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
@ -71,6 +72,9 @@ jobs:
|
||||||
if git diff --name-only HEAD~1 | grep -qE '^scripts/(add-|fix-|setup-neon-db\.js)'; then
|
if git diff --name-only HEAD~1 | grep -qE '^scripts/(add-|fix-|setup-neon-db\.js)'; then
|
||||||
MIGRATION_CHANGED=true
|
MIGRATION_CHANGED=true
|
||||||
fi
|
fi
|
||||||
|
if git diff --name-only HEAD~1 | grep -qE '^migrations/'; then
|
||||||
|
MIGRATION_CHANGED=true
|
||||||
|
fi
|
||||||
if git diff --name-only HEAD~1 | grep -q '^docs/SCHEMA_MAP\.md$'; then
|
if git diff --name-only HEAD~1 | grep -q '^docs/SCHEMA_MAP\.md$'; then
|
||||||
MAP_CHANGED=true
|
MAP_CHANGED=true
|
||||||
fi
|
fi
|
||||||
|
|
@ -174,11 +178,11 @@ jobs:
|
||||||
done
|
done
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
# lib/ may hold server-only helpers (e.g. scan-gemini.js) imported only from pages/api/.
|
# lib/ may hold server-only helpers imported only from pages/api/.
|
||||||
SERVER_ONLY=(
|
SERVER_ONLY=(
|
||||||
lib/scan-gemini.js
|
lib/scan-vision.js
|
||||||
)
|
)
|
||||||
LLM_PATTERN='generativelanguage\.googleapis\.com|api\.openai\.com'
|
LLM_PATTERN='generativelanguage\.googleapis\.com|api\.openai\.com|ai-gateway\.vercel\.sh'
|
||||||
FOUND=()
|
FOUND=()
|
||||||
while IFS= read -r file; do
|
while IFS= read -r file; do
|
||||||
skip=false
|
skip=false
|
||||||
|
|
|
||||||
|
|
@ -345,12 +345,43 @@ export default function CameraScanner({ onCardScanned, onError }) {
|
||||||
|
|
||||||
const imageData = canvas.toDataURL('image/jpeg', 0.8);
|
const imageData = canvas.toDataURL('image/jpeg', 0.8);
|
||||||
|
|
||||||
const response = await fetch('/api/scan/identify', {
|
const authHeaders = {
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Authorization: `Bearer ${localStorage.getItem('auth_token')}`,
|
Authorization: `Bearer ${localStorage.getItem('auth_token')}`,
|
||||||
},
|
};
|
||||||
|
|
||||||
|
// Layer 1: local OCR + pg_trgm catalog match (no vision LLM)
|
||||||
|
try {
|
||||||
|
const { recognizeCardNameStrip } = await import('../lib/ocr-worker.js');
|
||||||
|
const ocr = await recognizeCardNameStrip(imageData);
|
||||||
|
|
||||||
|
if (ocr.text.length >= 3) {
|
||||||
|
const l1Response = await fetch('/api/cards/identify-by-text', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: authHeaders,
|
||||||
|
body: JSON.stringify({
|
||||||
|
ocrText: ocr.text,
|
||||||
|
ocrConfidence: ocr.confidence,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (l1Response.ok) {
|
||||||
|
const l1Result = await l1Response.json();
|
||||||
|
if (!l1Result.escalate) {
|
||||||
|
cardTracker.status = 'confirmed';
|
||||||
|
await processIdentifyResponse(cardTracker, imageData, l1Result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (l1Error) {
|
||||||
|
console.warn('Layer-1 OCR path failed, escalating to vision:', l1Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Layer 2: vision via AI Gateway
|
||||||
|
const response = await fetch('/api/scan/identify', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: authHeaders,
|
||||||
body: JSON.stringify({ imageData }),
|
body: JSON.stringify({ imageData }),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,14 @@
|
||||||
| `favorited` | `BOOLEAN` default `false` | **Unused; favorites live in `user_favorites`** |
|
| `favorited` | `BOOLEAN` default `false` | **Unused; favorites live in `user_favorites`** |
|
||||||
| `created_at`, `updated_at` | `TIMESTAMP` default now | |
|
| `created_at`, `updated_at` | `TIMESTAMP` default now | |
|
||||||
|
|
||||||
|
**Indexes (post `1779853647565_add-pg-trgm-card-name-index`):**
|
||||||
|
|
||||||
|
- `idx_cards_name_trgm` — GIN on `name` using `gin_trgm_ops` for Layer-1 OCR fuzzy match (`similarity()` / `pg_trgm`).
|
||||||
|
|
||||||
|
**Extensions used by scan pipeline:**
|
||||||
|
|
||||||
|
- `pg_trgm` — enabled by `1779853647565_add-pg-trgm-card-name-index.js` for trigram similarity on `cards.name`.
|
||||||
|
|
||||||
### user_cards
|
### user_cards
|
||||||
|
|
||||||
| Column | Type | Notes |
|
| Column | Type | Notes |
|
||||||
|
|
|
||||||
109
lib/card-text-match.js
Normal file
109
lib/card-text-match.js
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
import { sql } from '@vercel/postgres';
|
||||||
|
|
||||||
|
const MATCH_THRESHOLD = 0.85;
|
||||||
|
const DISAMBIGUATION_THRESHOLD = 0.6;
|
||||||
|
const MIN_QUERY_LENGTH = 3;
|
||||||
|
|
||||||
|
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,
|
||||||
|
similarity: card.sim,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick the most likely card name line from raw OCR output (name strip is top of card).
|
||||||
|
*/
|
||||||
|
export function extractNameCandidate(ocrText) {
|
||||||
|
if (!ocrText || typeof ocrText !== 'string') return '';
|
||||||
|
|
||||||
|
const lines = ocrText
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => line.replace(/\s+/g, ' ').trim())
|
||||||
|
.filter((line) => line.length >= MIN_QUERY_LENGTH);
|
||||||
|
|
||||||
|
if (lines.length === 0) {
|
||||||
|
return ocrText.replace(/\s+/g, ' ').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefer the first substantial line (card titles are printed at the top).
|
||||||
|
const scored = lines.slice(0, 5).map((line, index) => ({
|
||||||
|
line,
|
||||||
|
score: line.length - index * 2,
|
||||||
|
}));
|
||||||
|
scored.sort((a, b) => b.score - a.score);
|
||||||
|
return scored[0].line;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fuzzy match OCR text against cards.name using pg_trgm similarity.
|
||||||
|
*/
|
||||||
|
export async function matchTextInCatalog({ ocrText, game = null, ocrConfidence = null }) {
|
||||||
|
const query = extractNameCandidate(ocrText);
|
||||||
|
|
||||||
|
if (!query || query.length < MIN_QUERY_LENGTH) {
|
||||||
|
return {
|
||||||
|
type: 'escalate',
|
||||||
|
reason: 'OCR text too short for catalog match',
|
||||||
|
query,
|
||||||
|
ocrConfidence,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await sql`
|
||||||
|
SELECT
|
||||||
|
*,
|
||||||
|
similarity(name, ${query}) AS sim
|
||||||
|
FROM cards
|
||||||
|
WHERE similarity(name, ${query}) > ${DISAMBIGUATION_THRESHOLD - 0.05}
|
||||||
|
ORDER BY
|
||||||
|
sim DESC,
|
||||||
|
CASE WHEN ${game} IS NOT NULL AND game = ${game} THEN 0 ELSE 1 END,
|
||||||
|
LENGTH(name)
|
||||||
|
LIMIT 8
|
||||||
|
`;
|
||||||
|
|
||||||
|
const candidates = result.rows.filter((row) => row.sim >= DISAMBIGUATION_THRESHOLD);
|
||||||
|
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
return {
|
||||||
|
type: 'escalate',
|
||||||
|
reason: `No catalog match above ${DISAMBIGUATION_THRESHOLD} similarity for "${query}"`,
|
||||||
|
query,
|
||||||
|
ocrConfidence,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const top = candidates[0];
|
||||||
|
const runnerUp = candidates[1];
|
||||||
|
const clearWinner =
|
||||||
|
top.sim >= MATCH_THRESHOLD &&
|
||||||
|
(!runnerUp || top.sim - runnerUp.sim >= 0.08);
|
||||||
|
|
||||||
|
if (clearWinner) {
|
||||||
|
return {
|
||||||
|
type: 'matched',
|
||||||
|
card: top,
|
||||||
|
query,
|
||||||
|
similarity: top.sim,
|
||||||
|
message: `Matched "${top.name}" via text search (${Math.round(top.sim * 100)}% similar)`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'disambiguation',
|
||||||
|
matches: candidates.slice(0, 5).map(mapCardRow),
|
||||||
|
query,
|
||||||
|
message: `Found ${candidates.length} possible matches for "${query}". Select the correct card.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
74
lib/ocr-worker.js
Normal file
74
lib/ocr-worker.js
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
/**
|
||||||
|
* Browser-side Tesseract OCR for Layer-1 card name strip recognition.
|
||||||
|
* Uses tesseract.js workers (off main thread). Language data loads from CDN on first use.
|
||||||
|
*/
|
||||||
|
|
||||||
|
let workerPromise = null;
|
||||||
|
|
||||||
|
async function getWorker() {
|
||||||
|
if (!workerPromise) {
|
||||||
|
workerPromise = (async () => {
|
||||||
|
const { createWorker } = await import('tesseract.js');
|
||||||
|
const worker = await createWorker('eng', 1, {
|
||||||
|
logger: () => {},
|
||||||
|
});
|
||||||
|
await worker.setParameters({
|
||||||
|
tessedit_pageseg_mode: '6',
|
||||||
|
});
|
||||||
|
return worker;
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
return workerPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cropNameStrip(imageDataUrl) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
const stripHeight = Math.max(1, Math.floor(img.height * 0.35));
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = img.width;
|
||||||
|
canvas.height = stripHeight;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
ctx.drawImage(img, 0, 0, img.width, stripHeight, 0, 0, img.width, stripHeight);
|
||||||
|
resolve(canvas.toDataURL('image/jpeg', 0.92));
|
||||||
|
};
|
||||||
|
img.onerror = () => reject(new Error('Failed to load image for OCR'));
|
||||||
|
img.src = imageDataUrl;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OCR the top name strip of a card crop.
|
||||||
|
* @param {string} imageDataUrl - full card crop data URL
|
||||||
|
* @returns {Promise<{ text: string, confidence: number }>}
|
||||||
|
*/
|
||||||
|
export async function recognizeCardNameStrip(imageDataUrl) {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return { text: '', confidence: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stripUrl = await cropNameStrip(imageDataUrl);
|
||||||
|
const worker = await getWorker();
|
||||||
|
const { data } = await worker.recognize(stripUrl);
|
||||||
|
return {
|
||||||
|
text: (data.text || '').trim(),
|
||||||
|
confidence: Math.round(data.confidence || 0),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[ocr-worker] recognizeCardNameStrip failed:', error);
|
||||||
|
return { text: '', confidence: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function terminateOcrWorker() {
|
||||||
|
if (!workerPromise) return;
|
||||||
|
try {
|
||||||
|
const worker = await workerPromise;
|
||||||
|
await worker.terminate();
|
||||||
|
} catch {
|
||||||
|
// ignore teardown errors
|
||||||
|
}
|
||||||
|
workerPromise = null;
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
const DEFAULT_VISION_MODEL = process.env.GEMINI_VISION_MODEL || 'gemini-2.5-flash';
|
const GATEWAY_URL = 'https://ai-gateway.vercel.sh/v1/chat/completions';
|
||||||
const GEMINI_MODEL =
|
const DEFAULT_VISION_MODEL =
|
||||||
`https://generativelanguage.googleapis.com/v1beta/models/${DEFAULT_VISION_MODEL}:generateContent`;
|
process.env.SCAN_VISION_MODEL || 'google/gemini-2.5-flash-lite';
|
||||||
|
|
||||||
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.).
|
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.).
|
||||||
|
|
||||||
|
|
@ -32,7 +32,7 @@ If NO trading card is clearly visible, respond with:
|
||||||
|
|
||||||
Be conservative — only extract data you can clearly read. Quality over quantity.`;
|
Be conservative — only extract data you can clearly read. Quality over quantity.`;
|
||||||
|
|
||||||
function parseGeminiJson(content) {
|
function parseVisionJson(content) {
|
||||||
const cleanContent = content.replace(/```json\n?/g, '').replace(/```\n?/g, '').trim();
|
const cleanContent = content.replace(/```json\n?/g, '').replace(/```\n?/g, '').trim();
|
||||||
try {
|
try {
|
||||||
return JSON.parse(cleanContent);
|
return JSON.parse(cleanContent);
|
||||||
|
|
@ -58,58 +58,51 @@ function normalizeGame(game) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Server-side Gemini Vision analysis. Requires GEMINI_AI_API_KEY in env.
|
* Server-side vision analysis via Vercel AI Gateway. Requires AI_GATEWAY_API_KEY.
|
||||||
* @param {string} imageDataUrl - data:image/jpeg;base64,... capture from scanner
|
* @param {string} imageDataUrl - data:image/jpeg;base64,... capture from scanner
|
||||||
*/
|
*/
|
||||||
export async function analyzeCardImage(imageDataUrl) {
|
export async function analyzeCardImage(imageDataUrl) {
|
||||||
const apiKey = process.env.GEMINI_AI_API_KEY;
|
const apiKey = process.env.AI_GATEWAY_API_KEY;
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
throw new Error('GEMINI_AI_API_KEY is not configured on the server');
|
throw new Error('AI_GATEWAY_API_KEY is not configured on the server');
|
||||||
}
|
}
|
||||||
|
|
||||||
const base64Data = imageDataUrl.split(',')[1];
|
if (!imageDataUrl?.includes(',')) {
|
||||||
if (!base64Data) {
|
|
||||||
throw new Error('Invalid image data format');
|
throw new Error('Invalid image data format');
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(GEMINI_MODEL, {
|
const response = await fetch(GATEWAY_URL, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'x-goog-api-key': apiKey,
|
Authorization: `Bearer ${apiKey}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
contents: [{
|
model: DEFAULT_VISION_MODEL,
|
||||||
parts: [
|
temperature: 0.1,
|
||||||
{ text: CARD_PROMPT },
|
messages: [{
|
||||||
{
|
role: 'user',
|
||||||
inline_data: {
|
content: [
|
||||||
mime_type: 'image/jpeg',
|
{ type: 'text', text: CARD_PROMPT },
|
||||||
data: base64Data,
|
{ type: 'image_url', image_url: { url: imageDataUrl } },
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
}],
|
}],
|
||||||
generationConfig: {
|
|
||||||
temperature: 0.1,
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorData = await response.json().catch(() => ({}));
|
const errorData = await response.json().catch(() => ({}));
|
||||||
throw new Error(
|
const message = errorData.error?.message || errorData.message || 'Unknown error';
|
||||||
`Gemini API error: ${response.status} - ${errorData.error?.message || 'Unknown error'}`
|
throw new Error(`Vision API error: ${response.status} - ${message}`);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
const content = data.candidates?.[0]?.content?.parts?.[0]?.text;
|
const content = data.choices?.[0]?.message?.content;
|
||||||
if (!content) {
|
if (!content) {
|
||||||
throw new Error('No response from Gemini API');
|
throw new Error('No response from vision model');
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = parseGeminiJson(content);
|
const result = parseVisionJson(content);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isCard: result.isCard || false,
|
isCard: result.isCard || false,
|
||||||
25
migrations/1779853647565_add-pg-trgm-card-name-index.js
Normal file
25
migrations/1779853647565_add-pg-trgm-card-name-index.js
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
/**
|
||||||
|
* Enable pg_trgm + GIN index on cards.name for Layer-1 OCR text matching.
|
||||||
|
*
|
||||||
|
* @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 EXTENSION IF NOT EXISTS pg_trgm;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_cards_name_trgm
|
||||||
|
ON cards USING gin (name gin_trgm_ops);
|
||||||
|
`);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {import('node-pg-migrate').MigrationBuilder} pgm
|
||||||
|
*/
|
||||||
|
export const down = (pgm) => {
|
||||||
|
throw new Error('Down migration not supported for add-pg-trgm-card-name-index');
|
||||||
|
};
|
||||||
117
package-lock.json
generated
117
package-lock.json
generated
|
|
@ -20,7 +20,8 @@
|
||||||
"node-fetch": "^3.3.2",
|
"node-fetch": "^3.3.2",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"resend": "^4.7.0"
|
"resend": "^4.7.0",
|
||||||
|
"tesseract.js": "^6.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.60.0",
|
"@playwright/test": "^1.60.0",
|
||||||
|
|
@ -3834,6 +3835,12 @@
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/bmp-js": {
|
||||||
|
"version": "0.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/bmp-js/-/bmp-js-0.1.0.tgz",
|
||||||
|
"integrity": "sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "1.1.12",
|
"version": "1.1.12",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||||
|
|
@ -5970,6 +5977,12 @@
|
||||||
"entities": "^4.4.0"
|
"entities": "^4.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/idb-keyval": {
|
||||||
|
"version": "6.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.4.tgz",
|
||||||
|
"integrity": "sha512-D/NzHWUmYJGXi++z67aMSrnisb9A3621CyRK5G89JyTlN13C8xf0g04DLxUKMufPem3e3L2JAXR6Z00OWy183Q==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/ignore": {
|
"node_modules/ignore": {
|
||||||
"version": "5.3.2",
|
"version": "5.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||||
|
|
@ -6436,6 +6449,12 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-url": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/is-weakmap": {
|
"node_modules/is-weakmap": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz",
|
||||||
|
|
@ -7482,6 +7501,15 @@
|
||||||
"integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==",
|
"integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/opencollective-postinstall": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"opencollective-postinstall": "index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/optionator": {
|
"node_modules/optionator": {
|
||||||
"version": "0.9.4",
|
"version": "0.9.4",
|
||||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||||
|
|
@ -8269,6 +8297,12 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/regenerator-runtime": {
|
||||||
|
"version": "0.13.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
|
||||||
|
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/regexp.prototype.flags": {
|
"node_modules/regexp.prototype.flags": {
|
||||||
"version": "1.5.4",
|
"version": "1.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
|
||||||
|
|
@ -9247,6 +9281,72 @@
|
||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tesseract.js": {
|
||||||
|
"version": "6.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/tesseract.js/-/tesseract.js-6.0.1.tgz",
|
||||||
|
"integrity": "sha512-/sPvMvrCtgxnNRCjbTYbr7BRu0yfWDsMZQ2a/T5aN/L1t8wUQN6tTWv6p6FwzpoEBA0jrN2UD2SX4QQFRdoDbA==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"bmp-js": "^0.1.0",
|
||||||
|
"idb-keyval": "^6.2.0",
|
||||||
|
"is-url": "^1.2.4",
|
||||||
|
"node-fetch": "^2.6.9",
|
||||||
|
"opencollective-postinstall": "^2.0.3",
|
||||||
|
"regenerator-runtime": "^0.13.3",
|
||||||
|
"tesseract.js-core": "^6.0.0",
|
||||||
|
"wasm-feature-detect": "^1.2.11",
|
||||||
|
"zlibjs": "^0.3.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tesseract.js-core": {
|
||||||
|
"version": "6.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/tesseract.js-core/-/tesseract.js-core-6.1.2.tgz",
|
||||||
|
"integrity": "sha512-pv4GjmramjdObhDyR1q85Td8X60Puu/lGQn7Kw2id05LLgHhAcWgnz6xSdMCSxBMWjQDmMyDXPTC2aqADdpiow==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
|
"node_modules/tesseract.js/node_modules/node-fetch": {
|
||||||
|
"version": "2.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||||
|
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"whatwg-url": "^5.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "4.x || >=6.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"encoding": "^0.1.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"encoding": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tesseract.js/node_modules/tr46": {
|
||||||
|
"version": "0.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||||
|
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/tesseract.js/node_modules/webidl-conversions": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/tesseract.js/node_modules/whatwg-url": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tr46": "~0.0.3",
|
||||||
|
"webidl-conversions": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/thenify": {
|
"node_modules/thenify": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
|
||||||
|
|
@ -9919,6 +10019,12 @@
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/wasm-feature-detect": {
|
||||||
|
"version": "1.8.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/wasm-feature-detect/-/wasm-feature-detect-1.8.0.tgz",
|
||||||
|
"integrity": "sha512-zksaLKM2fVlnB5jQQDqKXXwYHLQUVH9es+5TOOHwGOVJOCeRBCiPjwSg+3tN2AdTCzjgli4jijCH290kXb/zWQ==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/web-streams-polyfill": {
|
"node_modules/web-streams-polyfill": {
|
||||||
"version": "3.3.3",
|
"version": "3.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
|
||||||
|
|
@ -10337,6 +10443,15 @@
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/zlibjs": {
|
||||||
|
"version": "0.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/zlibjs/-/zlibjs-0.3.1.tgz",
|
||||||
|
"integrity": "sha512-+J9RrgTKOmlxFSDHo0pI1xM6BLVUv+o0ZT9ANtCxGkjIVCCUdx9alUF8Gm+dGLKbkkkidWIHFDZHDMpfITt4+w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/zod": {
|
"node_modules/zod": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,8 @@
|
||||||
"node-fetch": "^3.3.2",
|
"node-fetch": "^3.3.2",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"resend": "^4.7.0"
|
"resend": "^4.7.0",
|
||||||
|
"tesseract.js": "^6.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.60.0",
|
"@playwright/test": "^1.60.0",
|
||||||
|
|
|
||||||
118
pages/api/cards/identify-by-text.js
Normal file
118
pages/api/cards/identify-by-text.js
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
import { getUserFromRequest } from '../../../lib/permission-middleware';
|
||||||
|
import { matchTextInCatalog } from '../../../lib/card-text-match.js';
|
||||||
|
import { logScanAttempt } from '../../../lib/card-catalog-match.js';
|
||||||
|
|
||||||
|
function formatCardResponse(card, ocrMeta) {
|
||||||
|
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: ocrMeta,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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 { ocrText, ocrConfidence, game } = req.body || {};
|
||||||
|
|
||||||
|
if (!ocrText || typeof ocrText !== 'string') {
|
||||||
|
return res.status(400).json({ error: 'ocrText is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchResult = await matchTextInCatalog({ ocrText, game, ocrConfidence });
|
||||||
|
const latencyMs = Date.now() - startedAt;
|
||||||
|
|
||||||
|
const ocrMeta = {
|
||||||
|
confidence: ocrConfidence ?? null,
|
||||||
|
rawText: ocrText,
|
||||||
|
query: matchResult.query || ocrText,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (matchResult.type === 'escalate') {
|
||||||
|
await logScanAttempt({
|
||||||
|
userId: user.userId,
|
||||||
|
ocrText,
|
||||||
|
ocrConfidence,
|
||||||
|
layer: 1,
|
||||||
|
resultKind: 'escalate',
|
||||||
|
latencyMs,
|
||||||
|
});
|
||||||
|
return res.status(200).json({
|
||||||
|
layer: 1,
|
||||||
|
escalate: true,
|
||||||
|
ocr: ocrMeta,
|
||||||
|
reason: matchResult.reason,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchResult.type === 'matched') {
|
||||||
|
await logScanAttempt({
|
||||||
|
userId: user.userId,
|
||||||
|
ocrText,
|
||||||
|
ocrConfidence,
|
||||||
|
layer: 1,
|
||||||
|
matchedCardId: matchResult.card.id,
|
||||||
|
resultKind: 'matched',
|
||||||
|
latencyMs,
|
||||||
|
});
|
||||||
|
return res.status(200).json({
|
||||||
|
layer: 1,
|
||||||
|
escalate: false,
|
||||||
|
isCard: true,
|
||||||
|
card: formatCardResponse(matchResult.card, ocrMeta),
|
||||||
|
isExisting: true,
|
||||||
|
message: matchResult.message,
|
||||||
|
ocr: ocrMeta,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await logScanAttempt({
|
||||||
|
userId: user.userId,
|
||||||
|
ocrText,
|
||||||
|
ocrConfidence,
|
||||||
|
layer: 1,
|
||||||
|
resultKind: 'disambiguation',
|
||||||
|
latencyMs,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
layer: 1,
|
||||||
|
escalate: false,
|
||||||
|
isCard: true,
|
||||||
|
card: null,
|
||||||
|
matches: matchResult.matches,
|
||||||
|
needsUserSelection: true,
|
||||||
|
message: matchResult.message,
|
||||||
|
ocr: ocrMeta,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[POST /api/cards/identify-by-text]', error);
|
||||||
|
|
||||||
|
if (String(error.message).includes('pg_trgm') || String(error.message).includes('similarity')) {
|
||||||
|
return res.status(503).json({
|
||||||
|
error: 'Text matching unavailable — run npm run migrate up (pg_trgm extension).',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,34 +1,34 @@
|
||||||
import { getUserFromRequest } from '../../../lib/permission-middleware';
|
import { getUserFromRequest } from '../../../lib/permission-middleware';
|
||||||
import { checkScanRateLimit } from '../../../lib/rate-limit.js';
|
import { checkScanRateLimit } from '../../../lib/rate-limit.js';
|
||||||
import { analyzeCardImage } from '../../../lib/scan-gemini.js';
|
import { analyzeCardImage } from '../../../lib/scan-vision.js';
|
||||||
import { matchCardInCatalog, logScanAttempt } from '../../../lib/card-catalog-match.js';
|
import { matchCardInCatalog, logScanAttempt } from '../../../lib/card-catalog-match.js';
|
||||||
|
|
||||||
function scanErrorResponse(error) {
|
function scanErrorResponse(error) {
|
||||||
const msg = error?.message || '';
|
const msg = error?.message || '';
|
||||||
|
|
||||||
if (msg.includes('GEMINI_AI_API_KEY is not configured')) {
|
if (msg.includes('AI_GATEWAY_API_KEY is not configured')) {
|
||||||
return {
|
return {
|
||||||
status: 503,
|
status: 503,
|
||||||
body: {
|
body: {
|
||||||
error: 'Card scanning is not configured on this server (missing GEMINI_AI_API_KEY).',
|
error: 'Card scanning is not configured on this server (missing AI_GATEWAY_API_KEY).',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (msg.includes('Gemini API error: 429')) {
|
if (msg.includes('Vision API error: 429')) {
|
||||||
return {
|
return {
|
||||||
status: 502,
|
status: 502,
|
||||||
body: {
|
body: {
|
||||||
error: 'Vision service quota exceeded. Check Gemini API billing or try again later.',
|
error: 'Vision service quota exceeded. Check AI Gateway billing or try again later.',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (msg.includes('Gemini API error: 403') || msg.includes('PERMISSION_DENIED')) {
|
if (msg.includes('Vision API error: 403') || msg.includes('PERMISSION_DENIED')) {
|
||||||
return {
|
return {
|
||||||
status: 502,
|
status: 502,
|
||||||
body: {
|
body: {
|
||||||
error: 'Vision service access denied. Regenerate the Gemini API key in Google AI Studio.',
|
error: 'Vision service access denied. Check AI Gateway model access and API key.',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue