feat(scanner): add debug instrumentation for vision pipeline timing #155
17 changed files with 800 additions and 10 deletions
|
|
@ -101,3 +101,10 @@
|
||||||
{"ts": "2026-08-15T01:24:40Z", "role": "role-reviewer", "convoy": "improve-scan-card-detection", "repo": "scanner-identify-upgrade", "skip_flags": [], "duration_s": 180, "outcome": "approved", "multitask_group": "audit-improve-scan-card-detection-local", "model": "cursor-grok-4.5-high", "model_tier": "audit"}
|
{"ts": "2026-08-15T01:24:40Z", "role": "role-reviewer", "convoy": "improve-scan-card-detection", "repo": "scanner-identify-upgrade", "skip_flags": [], "duration_s": 180, "outcome": "approved", "multitask_group": "audit-improve-scan-card-detection-local", "model": "cursor-grok-4.5-high", "model_tier": "audit"}
|
||||||
{"ts": "2026-08-15T01:24:41Z", "role": "role-security-auditor", "convoy": "improve-scan-card-detection", "repo": "scanner-identify-upgrade", "skip_flags": [], "duration_s": 120, "outcome": "approved", "multitask_group": "audit-improve-scan-card-detection-local", "model": "gpt-5.6-terra-medium", "model_tier": "security"}
|
{"ts": "2026-08-15T01:24:41Z", "role": "role-security-auditor", "convoy": "improve-scan-card-detection", "repo": "scanner-identify-upgrade", "skip_flags": [], "duration_s": 120, "outcome": "approved", "multitask_group": "audit-improve-scan-card-detection-local", "model": "gpt-5.6-terra-medium", "model_tier": "security"}
|
||||||
{"ts": "2026-08-15T01:24:41Z", "role": "role-a11y-auditor", "convoy": "improve-scan-card-detection", "repo": "scanner-identify-upgrade", "skip_flags": [], "duration_s": 60, "outcome": "approved", "multitask_group": "audit-improve-scan-card-detection-local", "model": "cursor-grok-4.5-high", "model_tier": "audit"}
|
{"ts": "2026-08-15T01:24:41Z", "role": "role-a11y-auditor", "convoy": "improve-scan-card-detection", "repo": "scanner-identify-upgrade", "skip_flags": [], "duration_s": 60, "outcome": "approved", "multitask_group": "audit-improve-scan-card-detection-local", "model": "cursor-grok-4.5-high", "model_tier": "audit"}
|
||||||
|
{"ts": "2026-08-15T01:35:27Z", "role": "role-ux-reviewer", "convoy": "scan-visual-catalog-search", "repo": "scanner-identify-upgrade", "skip_flags": [], "duration_s": 90, "model": "composer-2.5-fast", "model_tier": "fast"}
|
||||||
|
{"ts": "2026-08-15T01:35:27Z", "role": "role-architect", "convoy": "scan-visual-catalog-search", "repo": "scanner-identify-upgrade", "skip_flags": [], "duration_s": 360, "model": "composer-2.5", "model_tier": "standard"}
|
||||||
|
{"ts": "2026-08-15T01:35:28Z", "role": "role-implementer", "convoy": "scan-visual-catalog-search", "repo": "scanner-identify-upgrade", "skip_flags": [], "brief": 1, "duration_s": 300, "outcome": "complete", "model": "composer-2.5-fast", "model_tier": "fast"}
|
||||||
|
{"ts": "2026-08-15T01:35:28Z", "role": "role-implementer", "convoy": "scan-visual-catalog-search", "repo": "scanner-identify-upgrade", "skip_flags": [], "brief": 2, "duration_s": 600, "outcome": "complete", "model": "composer-2.5-fast", "model_tier": "fast"}
|
||||||
|
{"ts": "2026-08-15T01:35:28Z", "role": "role-implementer", "convoy": "scan-visual-catalog-search", "repo": "scanner-identify-upgrade", "skip_flags": [], "brief": 3, "duration_s": 900, "outcome": "complete", "model": "composer-2.5-fast", "model_tier": "fast"}
|
||||||
|
{"ts": "2026-08-15T01:35:28Z", "role": "role-reviewer", "convoy": "scan-visual-catalog-search", "repo": "scanner-identify-upgrade", "skip_flags": [], "duration_s": 180, "outcome": "approved", "multitask_group": "audit-scan-visual-catalog-search-local", "model": "cursor-grok-4.5-high", "model_tier": "audit"}
|
||||||
|
{"ts": "2026-08-15T01:35:28Z", "role": "role-security-auditor", "convoy": "scan-visual-catalog-search", "repo": "scanner-identify-upgrade", "skip_flags": [], "duration_s": 120, "outcome": "approved", "multitask_group": "audit-scan-visual-catalog-search-local", "model": "gpt-5.6-terra-medium", "model_tier": "security"}
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ skip:
|
||||||
- a11y
|
- a11y
|
||||||
- design
|
- design
|
||||||
- flag
|
- flag
|
||||||
status: open
|
status: in-progress
|
||||||
created: 2026-08-14
|
created: 2026-08-14
|
||||||
depends_on:
|
depends_on:
|
||||||
- improve-scan-card-detection
|
- improve-scan-card-detection
|
||||||
|
|
@ -107,12 +107,13 @@ unknown-card fallback.
|
||||||
|
|
||||||
## Todos
|
## Todos
|
||||||
|
|
||||||
- [ ] Architect: confirm `pgvector` on prod Neon tier
|
- [x] Architect: confirm `pgvector` on prod Neon tier
|
||||||
- [ ] Brief 1 — migration + SCHEMA_MAP
|
- [x] Brief 1 — migration + SCHEMA_MAP
|
||||||
- [ ] Brief 2 — catalog backfill job (idempotent)
|
- [x] Brief 2 — catalog backfill job (idempotent)
|
||||||
- [ ] Brief 3 — identify kNN route + client escalate order
|
- [x] Brief 3 — identify kNN route + client escalate order
|
||||||
- [ ] Threshold bake-off on real crops
|
- [ ] Threshold bake-off on real crops
|
||||||
- [ ] Re-measure auto-match % excluding `not_a_card`
|
- [ ] Re-measure auto-match % excluding `not_a_card`
|
||||||
|
- [ ] Operator: `npm run backfill-embeddings` on prod/staging after migrate
|
||||||
|
|
||||||
## Likely file ownership
|
## Likely file ownership
|
||||||
|
|
||||||
|
|
@ -159,3 +160,37 @@ crops are rectified — that wastes the backfill. If Architect finds
|
||||||
`pgvector` unavailable on CI Postgres, stop and write a fallback
|
`pgvector` unavailable on CI Postgres, stop and write a fallback
|
||||||
(external index vs skip-CI-extension plan) rather than shipping an
|
(external index vs skip-CI-extension plan) rather than shipping an
|
||||||
untestable migration.
|
untestable migration.
|
||||||
|
|
||||||
|
## UX
|
||||||
|
|
||||||
|
No new screens. Scan flow stays L0 → L1 → L2 with the same
|
||||||
|
disambiguation picker and error toasts. When the catalog index is empty
|
||||||
|
(backfill not run), L0 escalates silently with no embed cost.
|
||||||
|
|
||||||
|
Gallery uploads now try visual match before OCR.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Decision D1 — Gateway multimodal embedder (`cohere/embed-v4.0`)
|
||||||
|
|
||||||
|
Server-only via `AI_GATEWAY_API_KEY`. 1024-dim vectors in
|
||||||
|
`cards.embedding`. Env: `SCAN_EMBED_MODEL`, `SCAN_EMBED_DIMENSION`.
|
||||||
|
|
||||||
|
### Decision D2 — Layer numbering
|
||||||
|
|
||||||
|
| Layer | Path |
|
||||||
|
| --- | --- |
|
||||||
|
| 0 | `POST /api/scan/identify-by-image` |
|
||||||
|
| 1 | Tesseract + `identify-by-text` |
|
||||||
|
| 2 | Gemini + `scan/identify` |
|
||||||
|
|
||||||
|
### Decision D3 — Thresholds
|
||||||
|
|
||||||
|
Match ≥ **0.82** (0.06 gap). Disambiguation ≥ **0.58**.
|
||||||
|
|
||||||
|
### Decision D4 — Rate limit
|
||||||
|
|
||||||
|
`checkScanRateLimit` on identify-by-image. L0 429 falls through to L1
|
||||||
|
(not a hard stop).
|
||||||
|
|
||||||
|
Audit group id: `audit-scan-visual-catalog-search-<pr>`.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
---
|
||||||
|
convoy: scan-visual-catalog-search
|
||||||
|
brief_number: 1
|
||||||
|
depends_on: []
|
||||||
|
recommended_model: composer-2.5
|
||||||
|
model_tier: standard
|
||||||
|
files:
|
||||||
|
- migrations/1782000000001_add-card-embeddings.js
|
||||||
|
- docs/SCHEMA_MAP.md
|
||||||
|
---
|
||||||
|
|
||||||
|
# Brief 1: pgvector migration + SCHEMA_MAP
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Add `vector(1024)` embedding column + HNSW index on `cards`.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] `CREATE EXTENSION IF NOT EXISTS vector`
|
||||||
|
- [ ] `cards.embedding`, `cards.embedded_at`
|
||||||
|
- [ ] `idx_cards_embedding_hnsw` partial index
|
||||||
|
- [ ] SCHEMA_MAP documents layer 0 + pgvector
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
---
|
||||||
|
convoy: scan-visual-catalog-search
|
||||||
|
brief_number: 2
|
||||||
|
depends_on: [1]
|
||||||
|
recommended_model: composer-2.5-fast
|
||||||
|
model_tier: fast
|
||||||
|
files:
|
||||||
|
- lib/card-embed.js
|
||||||
|
- scripts/backfill-card-embeddings.js
|
||||||
|
- package.json
|
||||||
|
- test/lib/card-embed.test.js
|
||||||
|
---
|
||||||
|
|
||||||
|
# Brief 2: Catalog embedding backfill
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Server-side embed job using `cohere/embed-v4.0` via AI Gateway; idempotent backfill from `cards.image_url`.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] `lib/card-embed.js` exports `embedCardImage`, `formatEmbeddingForPg`
|
||||||
|
- [ ] `npm run backfill-embeddings` script (skips rows with embedding unless FORCE=1)
|
||||||
|
- [ ] Rate-limited (`SLEEP_MS`) and DRY_RUN support
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
---
|
||||||
|
convoy: scan-visual-catalog-search
|
||||||
|
brief_number: 3
|
||||||
|
depends_on: [1]
|
||||||
|
recommended_model: composer-2.5-fast
|
||||||
|
model_tier: fast
|
||||||
|
files:
|
||||||
|
- lib/card-visual-match.js
|
||||||
|
- pages/api/scan/identify-by-image.js
|
||||||
|
- lib/scanner-card-identify.js
|
||||||
|
- lib/use-scanner-identification.js
|
||||||
|
- test/lib/card-visual-match.test.js
|
||||||
|
---
|
||||||
|
|
||||||
|
# Brief 3: Layer-0 identify route + client orchestration
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
kNN visual match before L1/L2; log `scan_attempts.layer = 0`.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] `POST /api/scan/identify-by-image` — auth + `checkScanRateLimit` + embed + kNN
|
||||||
|
- [ ] `identifyTrackedCardCapture` order: L0 → L1 → L2
|
||||||
|
- [ ] Gallery path runs L0 → L1 → L2
|
||||||
|
- [ ] Skip auto Gemini refine when disambiguation from L0 (same as L1)
|
||||||
|
- [ ] Thresholds: match 0.82, disambiguation 0.58
|
||||||
|
|
@ -93,16 +93,20 @@
|
||||||
| `reprint` | `BOOLEAN` default `false` | (`1781440700404`) |
|
| `reprint` | `BOOLEAN` default `false` | (`1781440700404`) |
|
||||||
| `finishes` | `JSONB` | `["nonfoil","foil","etched"]` (`1781440700404`) |
|
| `finishes` | `JSONB` | `["nonfoil","foil","etched"]` (`1781440700404`) |
|
||||||
| `created_at`, `updated_at` | `TIMESTAMP` default now | |
|
| `created_at`, `updated_at` | `TIMESTAMP` default now | |
|
||||||
|
| `embedding` | `vector(1024)` | Layer-0 visual kNN index (`1782000000001_add-card-embeddings`) |
|
||||||
|
| `embedded_at` | `TIMESTAMP` | When `embedding` was last written by backfill |
|
||||||
|
|
||||||
**Indexes (post `1779853647565_add-pg-trgm-card-name-index` + `1781440700404`):**
|
**Indexes (post `1779853647565_add-pg-trgm-card-name-index` + `1781440700404`):**
|
||||||
|
|
||||||
- `idx_cards_name_trgm` — GIN on `name` using `gin_trgm_ops` for Layer-1 OCR fuzzy match (`similarity()` / `pg_trgm`).
|
- `idx_cards_name_trgm` — GIN on `name` using `gin_trgm_ops` for Layer-1 OCR fuzzy match (`similarity()` / `pg_trgm`).
|
||||||
|
- `idx_cards_embedding_hnsw` — HNSW on `embedding vector_cosine_ops` (partial, non-null only) for Layer-0 visual search.
|
||||||
- `cards_oracle_id_index`, `cards_illustration_id_index`, `cards_artist_index`, `cards_edhrec_rank_index`
|
- `cards_oracle_id_index`, `cards_illustration_id_index`, `cards_artist_index`, `cards_edhrec_rank_index`
|
||||||
- GIN on `color_identity`, `keywords`, `legalities`
|
- GIN on `color_identity`, `keywords`, `legalities`
|
||||||
|
|
||||||
**Extensions used by scan pipeline:**
|
**Extensions used by scan pipeline:**
|
||||||
|
|
||||||
- `pg_trgm` — enabled by `1779853647565_add-pg-trgm-card-name-index.js` for trigram similarity on `cards.name`.
|
- `pg_trgm` — Layer-1 text similarity (`1779853647565_add-pg-trgm-card-name-index`).
|
||||||
|
- `vector` (pgvector) — Layer-0 visual embeddings (`1782000000001_add-card-embeddings`).
|
||||||
|
|
||||||
### user_cards
|
### user_cards
|
||||||
|
|
||||||
|
|
@ -261,7 +265,7 @@ Index: `idx_card_submissions_status (status, created_at DESC)`.
|
||||||
|
|
||||||
### scan_attempts
|
### scan_attempts
|
||||||
|
|
||||||
Per-scan telemetry for the identify pipeline (layer 1 = Tesseract + pg_trgm, layer 2 = Gemini).
|
Per-scan telemetry for the identify pipeline (layer 0 = visual kNN, layer 1 = Tesseract + pg_trgm, layer 2 = Gemini).
|
||||||
|
|
||||||
| Column | Type | Notes |
|
| Column | Type | Notes |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
|
|
@ -269,7 +273,7 @@ Per-scan telemetry for the identify pipeline (layer 1 = Tesseract + pg_trgm, lay
|
||||||
| `user_id` | FK → `users` SET NULL | |
|
| `user_id` | FK → `users` SET NULL | |
|
||||||
| `ocr_text` | `TEXT` | |
|
| `ocr_text` | `TEXT` | |
|
||||||
| `ocr_confidence` | `INTEGER` | |
|
| `ocr_confidence` | `INTEGER` | |
|
||||||
| `layer` | `INTEGER` default `2` | Identify layer (`1` = browser Tesseract + pg_trgm, `2` = Gemini vision) |
|
| `layer` | `INTEGER` default `2` | Identify layer (`0` = visual kNN, `1` = browser Tesseract + pg_trgm, `2` = Gemini vision) |
|
||||||
| `matched_card_id` | FK → `cards` SET NULL | |
|
| `matched_card_id` | FK → `cards` SET NULL | |
|
||||||
| `result_kind` | `VARCHAR(32)` | e.g. `'matched'`, `'disambiguation'`, `'submitted'`, `'not_a_card'` |
|
| `result_kind` | `VARCHAR(32)` | e.g. `'matched'`, `'disambiguation'`, `'submitted'`, `'not_a_card'` |
|
||||||
| `latency_ms` | `INTEGER` | End-to-end identify latency |
|
| `latency_ms` | `INTEGER` | End-to-end identify latency |
|
||||||
|
|
|
||||||
90
lib/card-embed.js
Normal file
90
lib/card-embed.js
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
const GATEWAY_EMBED_URL = 'https://ai-gateway.vercel.sh/v1/embeddings';
|
||||||
|
const DEFAULT_EMBED_MODEL = process.env.SCAN_EMBED_MODEL || 'cohere/embed-v4.0';
|
||||||
|
|
||||||
|
/** Output dimension — must match migrations/1782000000001_add-card-embeddings.js. */
|
||||||
|
export const EMBED_DIMENSION = Number(process.env.SCAN_EMBED_DIMENSION || 1024);
|
||||||
|
|
||||||
|
export class EmbedApiError extends Error {
|
||||||
|
constructor(message, { status } = {}) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'EmbedApiError';
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseEmbeddingResponse(data) {
|
||||||
|
const embedding =
|
||||||
|
data?.data?.[0]?.embedding ||
|
||||||
|
data?.embeddings?.[0]?.values ||
|
||||||
|
data?.embeddings?.[0] ||
|
||||||
|
data?.embedding;
|
||||||
|
|
||||||
|
if (!Array.isArray(embedding) || embedding.length === 0) {
|
||||||
|
throw new EmbedApiError('Embedding API returned no vector');
|
||||||
|
}
|
||||||
|
|
||||||
|
return embedding.map((value) => Number(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEmbedInput(value) {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value?.imageUrl) {
|
||||||
|
return {
|
||||||
|
type: 'image_url',
|
||||||
|
image_url: { url: value.imageUrl },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value?.imageDataUrl) {
|
||||||
|
return value.imageDataUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new EmbedApiError('Invalid embed input');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Embed image content via Vercel AI Gateway (server-only).
|
||||||
|
* Accepts a JPEG data URL or HTTPS image URL object.
|
||||||
|
*/
|
||||||
|
export async function embedCardImage(input) {
|
||||||
|
const apiKey = process.env.AI_GATEWAY_API_KEY;
|
||||||
|
if (!apiKey) {
|
||||||
|
throw new EmbedApiError('AI_GATEWAY_API_KEY is not configured on the server');
|
||||||
|
}
|
||||||
|
|
||||||
|
const payloadInput = buildEmbedInput(input);
|
||||||
|
if (typeof payloadInput === 'string' && !payloadInput.includes(',')) {
|
||||||
|
throw new EmbedApiError('Invalid image data format');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(GATEWAY_EMBED_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${apiKey}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: DEFAULT_EMBED_MODEL,
|
||||||
|
input: payloadInput,
|
||||||
|
dimensions: EMBED_DIMENSION,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
const message = errorData.error?.message || errorData.message || 'Unknown error';
|
||||||
|
throw new EmbedApiError(`Embedding API error: ${response.status} - ${message}`, {
|
||||||
|
status: response.status,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseEmbeddingResponse(await response.json());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format a float array for pgvector tagged-template queries. */
|
||||||
|
export function formatEmbeddingForPg(embedding) {
|
||||||
|
return `[${embedding.map((value) => Number(value).toFixed(8)).join(',')}]`;
|
||||||
|
}
|
||||||
118
lib/card-visual-match.js
Normal file
118
lib/card-visual-match.js
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
import { sql } from '@vercel/postgres';
|
||||||
|
|
||||||
|
import { formatEmbeddingForPg } from './card-embed.js';
|
||||||
|
|
||||||
|
export const MATCH_THRESHOLD = 0.82;
|
||||||
|
export const DISAMBIGUATION_THRESHOLD = 0.58;
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure ranking logic for visual kNN candidates (unit-tested without DB).
|
||||||
|
*/
|
||||||
|
export function resolveVisualCandidates(candidates) {
|
||||||
|
if (!candidates?.length) {
|
||||||
|
return {
|
||||||
|
type: 'escalate',
|
||||||
|
reason: `No catalog match above ${DISAMBIGUATION_THRESHOLD} visual similarity`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const top = candidates[0];
|
||||||
|
const runnerUp = candidates[1];
|
||||||
|
const clearWinner =
|
||||||
|
top.sim >= MATCH_THRESHOLD && (!runnerUp || top.sim - runnerUp.sim >= 0.06);
|
||||||
|
|
||||||
|
if (clearWinner) {
|
||||||
|
return {
|
||||||
|
type: 'matched',
|
||||||
|
card: top,
|
||||||
|
similarity: top.sim,
|
||||||
|
message: `Matched "${top.name}" via visual search (${Math.round(top.sim * 100)}% similar)`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'disambiguation',
|
||||||
|
matches: candidates.slice(0, 5).map(mapCardRow),
|
||||||
|
similarity: top.sim,
|
||||||
|
message: `Found ${candidates.length} visually similar printings. Select the correct one.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function querySimilarCards(vectorLiteral, game) {
|
||||||
|
if (game) {
|
||||||
|
return sql`
|
||||||
|
SELECT id, name, set_name, set_code, card_number, game, rarity, image_url, card_type, mana_cost, power,
|
||||||
|
1 - (embedding <=> ${vectorLiteral}::vector) AS sim
|
||||||
|
FROM cards
|
||||||
|
WHERE embedding IS NOT NULL
|
||||||
|
AND game = ${game}
|
||||||
|
AND 1 - (embedding <=> ${vectorLiteral}::vector) > ${DISAMBIGUATION_THRESHOLD - 0.05}
|
||||||
|
ORDER BY embedding <=> ${vectorLiteral}::vector
|
||||||
|
LIMIT 8
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return sql`
|
||||||
|
SELECT id, name, set_name, set_code, card_number, game, rarity, image_url, card_type, mana_cost, power,
|
||||||
|
1 - (embedding <=> ${vectorLiteral}::vector) AS sim
|
||||||
|
FROM cards
|
||||||
|
WHERE embedding IS NOT NULL
|
||||||
|
AND 1 - (embedding <=> ${vectorLiteral}::vector) > ${DISAMBIGUATION_THRESHOLD - 0.05}
|
||||||
|
ORDER BY embedding <=> ${vectorLiteral}::vector
|
||||||
|
LIMIT 8
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when at least one catalog row has a visual embedding index entry.
|
||||||
|
*/
|
||||||
|
export async function catalogHasEmbeddings() {
|
||||||
|
const indexed = await sql`
|
||||||
|
SELECT EXISTS(
|
||||||
|
SELECT 1 FROM cards WHERE embedding IS NOT NULL LIMIT 1
|
||||||
|
) AS has_embeddings
|
||||||
|
`;
|
||||||
|
return Boolean(indexed.rows[0]?.has_embeddings);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* kNN visual match against precomputed catalog embeddings (Layer 0).
|
||||||
|
*/
|
||||||
|
export async function matchVisualInCatalog({ embedding, game = null }) {
|
||||||
|
if (!embedding?.length) {
|
||||||
|
return {
|
||||||
|
type: 'escalate',
|
||||||
|
reason: 'Missing query embedding',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(await catalogHasEmbeddings())) {
|
||||||
|
return {
|
||||||
|
type: 'escalate',
|
||||||
|
reason: 'Catalog visual index is empty — run npm run backfill-embeddings',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const vectorLiteral = formatEmbeddingForPg(embedding);
|
||||||
|
const result = await querySimilarCards(vectorLiteral, game || null);
|
||||||
|
const candidates = result.rows.filter((row) => row.sim >= DISAMBIGUATION_THRESHOLD);
|
||||||
|
|
||||||
|
return resolveVisualCandidates(candidates);
|
||||||
|
}
|
||||||
|
|
@ -85,6 +85,7 @@ export function resolveIdentifyOutcome(result) {
|
||||||
ocrMeta,
|
ocrMeta,
|
||||||
message: result.message,
|
message: result.message,
|
||||||
fromLayer1: result.layer === 1,
|
fromLayer1: result.layer === 1,
|
||||||
|
fromLayer0: result.layer === 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -242,6 +243,44 @@ export async function fetchIdentifyByText({ ocrText, ocrConfidence, cardNumber,
|
||||||
return { ok: true, result };
|
return { ok: true, result };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchIdentifyByImage(imageData, authHeaders, game) {
|
||||||
|
const response = await fetch('/api/scan/identify-by-image', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: authHeaders,
|
||||||
|
body: JSON.stringify({ imageData, game }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 429) {
|
||||||
|
return { ok: false, rateLimited: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return { ok: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
return { ok: true, result };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Layer 0: visual kNN against precomputed catalog embeddings.
|
||||||
|
*/
|
||||||
|
export async function tryLayer0VisualIdentify(imageData, authHeaders, game) {
|
||||||
|
const l0 = await fetchIdentifyByImage(imageData, authHeaders, game);
|
||||||
|
if (!l0.ok) {
|
||||||
|
return { handled: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (l0.result.escalate) {
|
||||||
|
return { handled: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
handled: true,
|
||||||
|
outcome: resolveIdentifyOutcome(l0.result),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchVisionIdentify(imageData, authHeaders) {
|
export async function fetchVisionIdentify(imageData, authHeaders) {
|
||||||
const response = await fetch('/api/scan/identify', {
|
const response = await fetch('/api/scan/identify', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|
@ -309,6 +348,15 @@ export async function identifyTrackedCardCapture({
|
||||||
cardTracker.corners
|
cardTracker.corners
|
||||||
);
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const l0 = await tryLayer0VisualIdentify(imageData, authHeaders);
|
||||||
|
if (l0.handled) {
|
||||||
|
return { imageData, ...l0 };
|
||||||
|
}
|
||||||
|
} catch (l0Error) {
|
||||||
|
console.warn('Layer-0 visual path failed, falling back to text/vision:', l0Error);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const l1 = await tryLayer1TextIdentify(imageData, authHeaders);
|
const l1 = await tryLayer1TextIdentify(imageData, authHeaders);
|
||||||
if (l1.handled) {
|
if (l1.handled) {
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,9 @@ import {
|
||||||
getScanAuthHeaders,
|
getScanAuthHeaders,
|
||||||
identifyTrackedCardCapture,
|
identifyTrackedCardCapture,
|
||||||
resolveDisambiguationRefineAction,
|
resolveDisambiguationRefineAction,
|
||||||
|
resolveIdentifyOutcome,
|
||||||
submitScanForReview,
|
submitScanForReview,
|
||||||
|
tryLayer0VisualIdentify,
|
||||||
tryLayer1TextIdentify,
|
tryLayer1TextIdentify,
|
||||||
VISION_RATE_LIMIT_MS,
|
VISION_RATE_LIMIT_MS,
|
||||||
fetchVisionIdentify,
|
fetchVisionIdentify,
|
||||||
|
|
@ -132,6 +134,7 @@ export function useScannerIdentification({
|
||||||
ocrMeta: outcome.ocrMeta,
|
ocrMeta: outcome.ocrMeta,
|
||||||
message: outcome.message,
|
message: outcome.message,
|
||||||
fromLayer1: Boolean(outcome.fromLayer1),
|
fromLayer1: Boolean(outcome.fromLayer1),
|
||||||
|
fromLayer0: Boolean(outcome.fromLayer0),
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case 'notice':
|
case 'notice':
|
||||||
|
|
@ -180,7 +183,7 @@ export function useScannerIdentification({
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!disambiguation?.imageData) return;
|
if (!disambiguation?.imageData) return;
|
||||||
if (disambiguation.fromLayer1) return;
|
if (disambiguation.fromLayer1 || disambiguation.fromLayer0) return;
|
||||||
if (Date.now() < visionCooldownUntilRef.current) return;
|
if (Date.now() < visionCooldownUntilRef.current) return;
|
||||||
|
|
||||||
const refineKey = disambiguation.cardTracker?.id ?? 'modal';
|
const refineKey = disambiguation.cardTracker?.id ?? 'modal';
|
||||||
|
|
@ -326,14 +329,42 @@ export function useScannerIdentification({
|
||||||
try {
|
try {
|
||||||
const imageData = await readFileToImageData(file);
|
const imageData = await readFileToImageData(file);
|
||||||
const authHeaders = getScanAuthHeaders();
|
const authHeaders = getScanAuthHeaders();
|
||||||
const result = await tryLayer1TextIdentify(imageData, authHeaders);
|
|
||||||
const syntheticTracker = { id: `gallery-${Date.now()}`, status: 'verifying' };
|
const syntheticTracker = { id: `gallery-${Date.now()}`, status: 'verifying' };
|
||||||
|
|
||||||
|
const l0 = await tryLayer0VisualIdentify(imageData, authHeaders);
|
||||||
|
if (l0.handled && l0.outcome) {
|
||||||
|
await applyIdentifyOutcome(syntheticTracker, imageData, l0.outcome);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await tryLayer1TextIdentify(imageData, authHeaders);
|
||||||
|
|
||||||
if (result.handled && result.outcome) {
|
if (result.handled && result.outcome) {
|
||||||
await applyIdentifyOutcome(syntheticTracker, imageData, result.outcome);
|
await applyIdentifyOutcome(syntheticTracker, imageData, result.outcome);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (Date.now() < visionCooldownUntilRef.current) {
|
||||||
|
reportScannerError('Too many scan attempts. Please wait a moment and try again.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const vision = await fetchVisionIdentify(imageData, authHeaders);
|
||||||
|
if (vision.rateLimited) {
|
||||||
|
visionCooldownUntilRef.current = Date.now() + VISION_RATE_LIMIT_MS;
|
||||||
|
reportScannerError('Too many scan attempts. Please wait a moment and try again.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (vision.ok && vision.result) {
|
||||||
|
await applyIdentifyOutcome(
|
||||||
|
syntheticTracker,
|
||||||
|
imageData,
|
||||||
|
resolveIdentifyOutcome(vision.result)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
reportScannerError('Could not identify card from gallery image');
|
reportScannerError('Could not identify card from gallery image');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
reportScannerError(error.message || 'Gallery identify failed');
|
reportScannerError(error.message || 'Gallery identify failed');
|
||||||
|
|
|
||||||
33
migrations/1782000000001_add-card-embeddings.js
Normal file
33
migrations/1782000000001_add-card-embeddings.js
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
/**
|
||||||
|
* pgvector + cards.embedding for Layer-0 visual catalog search.
|
||||||
|
*
|
||||||
|
* @type {import('node-pg-migrate').ColumnDefinitions | undefined}
|
||||||
|
*/
|
||||||
|
export const shorthands = undefined;
|
||||||
|
|
||||||
|
/** Must match lib/card-embed.js EMBED_DIMENSION. */
|
||||||
|
const EMBED_DIMENSION = 1024;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {import('node-pg-migrate').MigrationBuilder} pgm
|
||||||
|
*/
|
||||||
|
export const up = (pgm) => {
|
||||||
|
pgm.sql(`
|
||||||
|
CREATE EXTENSION IF NOT EXISTS vector;
|
||||||
|
|
||||||
|
ALTER TABLE cards
|
||||||
|
ADD COLUMN IF NOT EXISTS embedding vector(${EMBED_DIMENSION}),
|
||||||
|
ADD COLUMN IF NOT EXISTS embedded_at TIMESTAMP;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_cards_embedding_hnsw
|
||||||
|
ON cards USING hnsw (embedding vector_cosine_ops)
|
||||||
|
WHERE embedding IS NOT NULL;
|
||||||
|
`);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {import('node-pg-migrate').MigrationBuilder} pgm
|
||||||
|
*/
|
||||||
|
export const down = (pgm) => {
|
||||||
|
throw new Error('Down migration not supported for add-card-embeddings');
|
||||||
|
};
|
||||||
|
|
@ -15,6 +15,7 @@
|
||||||
"bulk-import": "node --env-file=.env.local scripts/bulk-import-scryfall.js",
|
"bulk-import": "node --env-file=.env.local scripts/bulk-import-scryfall.js",
|
||||||
"bulk-import-lorcana": "node --env-file=.env.local scripts/bulk-import-lorcana.js",
|
"bulk-import-lorcana": "node --env-file=.env.local scripts/bulk-import-lorcana.js",
|
||||||
"bulk-import-pokemon": "node --env-file=.env.local scripts/bulk-import-pokemon.js",
|
"bulk-import-pokemon": "node --env-file=.env.local scripts/bulk-import-pokemon.js",
|
||||||
|
"backfill-embeddings": "node --env-file=.env.local scripts/backfill-card-embeddings.js",
|
||||||
"import-tags": "node --env-file=.env.local scripts/import-scryfall-tags.js",
|
"import-tags": "node --env-file=.env.local scripts/import-scryfall-tags.js",
|
||||||
"test": "vitest",
|
"test": "vitest",
|
||||||
"test:run": "vitest run",
|
"test:run": "vitest run",
|
||||||
|
|
|
||||||
156
pages/api/scan/identify-by-image.js
Normal file
156
pages/api/scan/identify-by-image.js
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
import { getUserFromRequest } from '../../../lib/permission-middleware';
|
||||||
|
import { checkScanRateLimit } from '../../../lib/rate-limit.js';
|
||||||
|
import { embedCardImage, EmbedApiError } from '../../../lib/card-embed.js';
|
||||||
|
import { matchVisualInCatalog, catalogHasEmbeddings } from '../../../lib/card-visual-match.js';
|
||||||
|
import { logScanAttempt } from '../../../lib/card-catalog-match.js';
|
||||||
|
|
||||||
|
function formatCardResponse(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,
|
||||||
|
card_type: card.card_type,
|
||||||
|
rarity: card.rarity,
|
||||||
|
hp: card.power,
|
||||||
|
mana_cost: card.mana_cost,
|
||||||
|
image_url: card.image_url,
|
||||||
|
visual: {
|
||||||
|
similarity: card.sim,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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 { imageData, game } = 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' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(await catalogHasEmbeddings())) {
|
||||||
|
const latencyMs = Date.now() - startedAt;
|
||||||
|
await logScanAttempt({
|
||||||
|
userId: user.userId,
|
||||||
|
ocrText: null,
|
||||||
|
ocrConfidence: null,
|
||||||
|
layer: 0,
|
||||||
|
resultKind: 'escalate',
|
||||||
|
latencyMs,
|
||||||
|
});
|
||||||
|
return res.status(200).json({
|
||||||
|
layer: 0,
|
||||||
|
escalate: true,
|
||||||
|
reason: 'Catalog visual index is empty — run npm run backfill-embeddings',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 embedding = await embedCardImage(imageData);
|
||||||
|
const matchResult = await matchVisualInCatalog({ embedding, game: game || null });
|
||||||
|
const latencyMs = Date.now() - startedAt;
|
||||||
|
|
||||||
|
if (matchResult.type === 'escalate') {
|
||||||
|
await logScanAttempt({
|
||||||
|
userId: user.userId,
|
||||||
|
ocrText: null,
|
||||||
|
ocrConfidence: null,
|
||||||
|
layer: 0,
|
||||||
|
resultKind: 'escalate',
|
||||||
|
latencyMs,
|
||||||
|
});
|
||||||
|
return res.status(200).json({
|
||||||
|
layer: 0,
|
||||||
|
escalate: true,
|
||||||
|
reason: matchResult.reason,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchResult.type === 'matched') {
|
||||||
|
await logScanAttempt({
|
||||||
|
userId: user.userId,
|
||||||
|
ocrText: null,
|
||||||
|
ocrConfidence: Math.round((matchResult.similarity || 0) * 100),
|
||||||
|
layer: 0,
|
||||||
|
matchedCardId: matchResult.card.id,
|
||||||
|
resultKind: 'matched',
|
||||||
|
latencyMs,
|
||||||
|
});
|
||||||
|
return res.status(200).json({
|
||||||
|
layer: 0,
|
||||||
|
escalate: false,
|
||||||
|
isCard: true,
|
||||||
|
card: formatCardResponse(matchResult.card),
|
||||||
|
isExisting: true,
|
||||||
|
message: matchResult.message,
|
||||||
|
visual: { similarity: matchResult.similarity },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await logScanAttempt({
|
||||||
|
userId: user.userId,
|
||||||
|
ocrText: null,
|
||||||
|
ocrConfidence: Math.round((matchResult.similarity || 0) * 100),
|
||||||
|
layer: 0,
|
||||||
|
resultKind: 'disambiguation',
|
||||||
|
latencyMs,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
layer: 0,
|
||||||
|
escalate: false,
|
||||||
|
isCard: true,
|
||||||
|
card: null,
|
||||||
|
matches: matchResult.matches,
|
||||||
|
needsUserSelection: true,
|
||||||
|
message: matchResult.message,
|
||||||
|
visual: { similarity: matchResult.similarity },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[POST /api/scan/identify-by-image]', error);
|
||||||
|
|
||||||
|
if (error instanceof EmbedApiError) {
|
||||||
|
if (error.message.includes('AI_GATEWAY_API_KEY')) {
|
||||||
|
return res.status(503).json({
|
||||||
|
error: 'Visual matching is not configured on this server (missing AI_GATEWAY_API_KEY).',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (error.status === 429) {
|
||||||
|
return res.status(502).json({
|
||||||
|
error: 'Embedding service quota exceeded. Try again later.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (String(error.message).includes('vector') || String(error.message).includes('pgvector')) {
|
||||||
|
return res.status(503).json({
|
||||||
|
error: 'Visual matching unavailable — run npm run migrate up (pgvector extension).',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
117
scripts/backfill-card-embeddings.js
Normal file
117
scripts/backfill-card-embeddings.js
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
/**
|
||||||
|
* Backfill cards.embedding from cards.image_url via Vercel AI Gateway.
|
||||||
|
*
|
||||||
|
* Idempotent: skips rows where embedded_at is set unless FORCE=1.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* POSTGRES_URL=<url> AI_GATEWAY_API_KEY=<key> node scripts/backfill-card-embeddings.js
|
||||||
|
*
|
||||||
|
* Options (env):
|
||||||
|
* BATCH_SIZE — rows per fetch (default 25)
|
||||||
|
* SLEEP_MS — delay between embed calls (default 250)
|
||||||
|
* FORCE — "1" to re-embed rows that already have embedded_at
|
||||||
|
* LIMIT — max rows to process (default unlimited)
|
||||||
|
* DRY_RUN — "true" to list candidates only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { neon } from '@neondatabase/serverless';
|
||||||
|
|
||||||
|
import { embedCardImage, formatEmbeddingForPg } from '../lib/card-embed.js';
|
||||||
|
|
||||||
|
if (!process.env.POSTGRES_URL) {
|
||||||
|
console.error('POSTGRES_URL is required');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!process.env.AI_GATEWAY_API_KEY) {
|
||||||
|
console.error('AI_GATEWAY_API_KEY is required');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sql = neon(process.env.POSTGRES_URL, { fullResults: false });
|
||||||
|
const BATCH_SIZE = Number(process.env.BATCH_SIZE || 25);
|
||||||
|
const SLEEP_MS = Number(process.env.SLEEP_MS || 250);
|
||||||
|
const FORCE = process.env.FORCE === '1';
|
||||||
|
const LIMIT = process.env.LIMIT ? Number(process.env.LIMIT) : null;
|
||||||
|
const DRY_RUN = process.env.DRY_RUN === 'true';
|
||||||
|
|
||||||
|
function sleep(ms) {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchCandidates(lastId) {
|
||||||
|
if (FORCE) {
|
||||||
|
return sql`
|
||||||
|
SELECT id, name, image_url
|
||||||
|
FROM cards
|
||||||
|
WHERE id > ${lastId}
|
||||||
|
AND image_url IS NOT NULL
|
||||||
|
AND image_url <> ''
|
||||||
|
ORDER BY id
|
||||||
|
LIMIT ${BATCH_SIZE}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return sql`
|
||||||
|
SELECT id, name, image_url
|
||||||
|
FROM cards
|
||||||
|
WHERE id > ${lastId}
|
||||||
|
AND image_url IS NOT NULL
|
||||||
|
AND image_url <> ''
|
||||||
|
AND embedding IS NULL
|
||||||
|
ORDER BY id
|
||||||
|
LIMIT ${BATCH_SIZE}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
let lastId = 0;
|
||||||
|
let processed = 0;
|
||||||
|
let updated = 0;
|
||||||
|
|
||||||
|
console.log(`Backfill starting (force=${FORCE}, dryRun=${DRY_RUN})`);
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
if (LIMIT != null && processed >= LIMIT) break;
|
||||||
|
|
||||||
|
const rows = await fetchCandidates(lastId);
|
||||||
|
if (!rows.length) break;
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
if (LIMIT != null && processed >= LIMIT) break;
|
||||||
|
processed++;
|
||||||
|
lastId = row.id;
|
||||||
|
|
||||||
|
if (DRY_RUN) {
|
||||||
|
console.log(`[dry-run] would embed card ${row.id}: ${row.name}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const embedding = await embedCardImage({ imageUrl: row.image_url });
|
||||||
|
const vectorLiteral = formatEmbeddingForPg(embedding);
|
||||||
|
await sql`
|
||||||
|
UPDATE cards
|
||||||
|
SET embedding = ${vectorLiteral}::vector,
|
||||||
|
embedded_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ${row.id}
|
||||||
|
`;
|
||||||
|
updated++;
|
||||||
|
console.log(`Embedded card ${row.id}: ${row.name}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed card ${row.id} (${row.name}):`, error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (SLEEP_MS > 0) {
|
||||||
|
await sleep(SLEEP_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Done. processed=${processed} updated=${updated}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
35
test/lib/card-embed.test.js
Normal file
35
test/lib/card-embed.test.js
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { describe, expect, it, vi, afterEach } from 'vitest';
|
||||||
|
import { embedCardImage, formatEmbeddingForPg } from '../../lib/card-embed.js';
|
||||||
|
|
||||||
|
describe('formatEmbeddingForPg', () => {
|
||||||
|
it('formats vectors for pgvector literals', () => {
|
||||||
|
expect(formatEmbeddingForPg([0.1, 0.2, 0.3])).toBe('[0.10000000,0.20000000,0.30000000]');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('embedCardImage', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
delete process.env.AI_GATEWAY_API_KEY;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when AI_GATEWAY_API_KEY is missing', async () => {
|
||||||
|
await expect(embedCardImage('data:image/jpeg;base64,abc')).rejects.toThrow(
|
||||||
|
'AI_GATEWAY_API_KEY is not configured'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns embedding values from the gateway response', async () => {
|
||||||
|
process.env.AI_GATEWAY_API_KEY = 'test-key';
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn(async () => ({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ data: [{ embedding: [0.5, 0.25] }] }),
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
const embedding = await embedCardImage('data:image/jpeg;base64,abc');
|
||||||
|
expect(embedding).toEqual([0.5, 0.25]);
|
||||||
|
});
|
||||||
|
});
|
||||||
40
test/lib/card-visual-match.test.js
Normal file
40
test/lib/card-visual-match.test.js
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { resolveVisualCandidates } from '../../lib/card-visual-match.js';
|
||||||
|
|
||||||
|
describe('resolveVisualCandidates', () => {
|
||||||
|
const baseCard = {
|
||||||
|
id: 1,
|
||||||
|
name: 'Lightning Bolt',
|
||||||
|
set_name: 'Alpha',
|
||||||
|
set_code: 'lea',
|
||||||
|
card_number: '161',
|
||||||
|
game: 'mtg',
|
||||||
|
rarity: 'common',
|
||||||
|
image_url: 'https://example.com/bolt.jpg',
|
||||||
|
card_type: 'Instant',
|
||||||
|
mana_cost: '{R}',
|
||||||
|
power: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
it('escalates when no candidates remain', () => {
|
||||||
|
expect(resolveVisualCandidates([]).type).toBe('escalate');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('auto-matches a clear visual winner', () => {
|
||||||
|
const result = resolveVisualCandidates([
|
||||||
|
{ ...baseCard, sim: 0.9 },
|
||||||
|
{ ...baseCard, id: 2, sim: 0.7 },
|
||||||
|
]);
|
||||||
|
expect(result.type).toBe('matched');
|
||||||
|
expect(result.card.id).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens disambiguation when top matches are close', () => {
|
||||||
|
const result = resolveVisualCandidates([
|
||||||
|
{ ...baseCard, sim: 0.8 },
|
||||||
|
{ ...baseCard, id: 2, set_name: 'Beta', sim: 0.77 },
|
||||||
|
]);
|
||||||
|
expect(result.type).toBe('disambiguation');
|
||||||
|
expect(result.matches).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -54,6 +54,7 @@ describe('resolveIdentifyOutcome', () => {
|
||||||
ocrMeta: expect.objectContaining({ abilities: [] }),
|
ocrMeta: expect.objectContaining({ abilities: [] }),
|
||||||
message: 'Pick one',
|
message: 'Pick one',
|
||||||
fromLayer1: false,
|
fromLayer1: false,
|
||||||
|
fromLayer0: false,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue