feat: Add Spotify-style backdrop, Cascade OCR, Smart Scan Flow & OCR Dashboard

- BottleGrid: Implement blurred backdrop effect for bottle cards
- Cascade OCR: TextDetector → RegEx → Fuzzy Match → window.ai pipeline
- Smart Scan: Native OCR for Android, Live Text fallback for iOS
- OCR Dashboard: Admin page at /admin/ocr-logs with stats and scan history
- Features: Add feature flags in src/config/features.ts
- SQL: Add ocr_logs table migration
- Services: Update analyze-bottle to use OpenRouter, add save-ocr-log
This commit is contained in:
2026-01-18 20:38:48 +01:00
parent 83e852e5fb
commit 9ba0825bcd
46 changed files with 3874 additions and 741 deletions

View File

@@ -1,6 +1,5 @@
'use server';
import { GoogleGenerativeAI, SchemaType, HarmCategory, HarmBlockThreshold } from '@google/generative-ai';
import { BottleMetadataSchema, BottleMetadata } from '@/types/whisky';
import { createClient } from '@/lib/supabase/server';
import { trackApiUsage } from '@/services/track-api-usage';
@@ -9,30 +8,6 @@ import { getAIProvider, getOpenRouterClient, OPENROUTER_VISION_MODEL, OPENROUTER
import { normalizeWhiskyData } from '@/lib/distillery-matcher';
import { formatWhiskyName } from '@/utils/formatWhiskyName';
import { createHash } from 'crypto';
import sharp from 'sharp';
// Schema for AI extraction
const visionSchema = {
description: "Whisky bottle label metadata extracted from image",
type: SchemaType.OBJECT as const,
properties: {
name: { type: SchemaType.STRING, description: "Full whisky name (constructed)", nullable: false },
distillery: { type: SchemaType.STRING, description: "Distillery name", nullable: true },
bottler: { type: SchemaType.STRING, description: "Independent bottler if applicable", nullable: true },
series: { type: SchemaType.STRING, description: "Whisky series or collection (e.g. Cadenhead's Natural Strength)", nullable: true },
category: { type: SchemaType.STRING, description: "Whisky category (Single Malt, Blended, Bourbon, etc.)", nullable: true },
abv: { type: SchemaType.NUMBER, description: "Alcohol by volume percentage", nullable: true },
age: { type: SchemaType.NUMBER, description: "Age statement in years", nullable: true },
vintage: { type: SchemaType.STRING, description: "Vintage/distillation year", nullable: true },
cask_type: { type: SchemaType.STRING, description: "Cask type (Sherry, Bourbon, Port, etc.)", nullable: true },
distilled_at: { type: SchemaType.STRING, description: "Distillation date", nullable: true },
bottled_at: { type: SchemaType.STRING, description: "Bottling date", nullable: true },
batch_info: { type: SchemaType.STRING, description: "Batch or cask number", nullable: true },
is_whisky: { type: SchemaType.BOOLEAN, description: "Whether this is a whisky product", nullable: false },
confidence: { type: SchemaType.NUMBER, description: "Confidence score 0-1", nullable: false },
},
required: ["name", "is_whisky", "confidence"],
};
const VISION_PROMPT = `ROLE: Senior Whisky Database Curator.
@@ -68,13 +43,11 @@ OUTPUT SCHEMA (Strict JSON):
"confidence": number
}`;
const GEMINI_MODEL = 'gemini-2.5-flash';
export interface ScannerResult {
success: boolean;
data?: BottleMetadata;
error?: string;
provider?: 'gemini' | 'openrouter';
provider?: 'openrouter';
perf?: {
imagePrep?: number;
apiCall: number;
@@ -183,86 +156,57 @@ export async function analyzeBottleLabel(imageBase64: string): Promise<ScannerRe
console.log(`[Scanner] Using provider: ${provider}`);
let aiResult: { data: any; apiTime: number; responseText: string };
if (provider === 'openrouter') {
const client = getOpenRouterClient();
const startApi = performance.now();
const maxRetries = 3;
let lastError: any = null;
let response: any = null;
const client = getOpenRouterClient();
const startApi = performance.now();
const maxRetries = 3;
let lastError: any = null;
let response: any = null;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
response = await client.chat.completions.create({
model: OPENROUTER_VISION_MODEL,
messages: [
{
role: 'user',
content: [
{ type: 'image_url', image_url: { url: `data:${mimeType};base64,${base64Data}` } },
{ type: 'text', text: VISION_PROMPT },
],
},
],
temperature: 0.1,
max_tokens: 1024,
// @ts-ignore
provider: OPENROUTER_PROVIDER_PREFERENCES,
});
break; // Success!
} catch (err: any) {
lastError = err;
if (err.status === 429 && attempt < maxRetries) {
const delay = Math.pow(2, attempt) * 1000;
console.warn(`[Scanner] Rate limited (429). Retrying in ${delay}ms...`);
await new Promise(resolve => setTimeout(resolve, delay));
continue;
}
throw err;
}
}
if (!response) throw lastError || new Error('OpenRouter response failed after retries');
const content = response.choices[0]?.message?.content || '{}';
let jsonStr = content;
const jsonMatch = content.match(/```(?:json)?\s*([\s\S]*?)```/) || content.match(/\{[\s\S]*\}/);
if (jsonMatch) {
jsonStr = jsonMatch[jsonMatch.length - 1].trim();
}
aiResult = {
data: JSON.parse(jsonStr),
apiTime: performance.now() - startApi,
responseText: content
};
} else {
const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY!);
const model = genAI.getGenerativeModel({
model: GEMINI_MODEL,
generationConfig: {
responseMimeType: "application/json",
responseSchema: visionSchema as any,
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
response = await client.chat.completions.create({
model: OPENROUTER_VISION_MODEL,
messages: [
{
role: 'user',
content: [
{ type: 'image_url', image_url: { url: `data:${mimeType};base64,${base64Data}` } },
{ type: 'text', text: VISION_PROMPT },
],
},
],
temperature: 0.1,
},
safetySettings: [
{ category: HarmCategory.HARM_CATEGORY_HARASSMENT, threshold: HarmBlockThreshold.BLOCK_NONE },
{ category: HarmCategory.HARM_CATEGORY_HATE_SPEECH, threshold: HarmBlockThreshold.BLOCK_NONE },
{ category: HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT, threshold: HarmBlockThreshold.BLOCK_NONE },
{ category: HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT, threshold: HarmBlockThreshold.BLOCK_NONE },
] as any,
});
const startApi = performance.now();
const result = await model.generateContent([
{ inlineData: { data: base64Data, mimeType } },
{ text: VISION_PROMPT },
]);
const responseText = result.response.text();
aiResult = {
data: JSON.parse(responseText),
apiTime: performance.now() - startApi,
responseText: responseText
};
max_tokens: 1024,
// @ts-ignore
provider: OPENROUTER_PROVIDER_PREFERENCES,
});
break; // Success!
} catch (err: any) {
lastError = err;
if (err.status === 429 && attempt < maxRetries) {
const delay = Math.pow(2, attempt) * 1000;
console.warn(`[Scanner] Rate limited (429). Retrying in ${delay}ms...`);
await new Promise(resolve => setTimeout(resolve, delay));
continue;
}
throw err;
}
}
if (!response) throw lastError || new Error('OpenRouter response failed after retries');
const content = response.choices[0]?.message?.content || '{}';
let jsonStr = content;
const jsonMatch = content.match(/```(?:json)?\s*([\s\S]*?)```/) || content.match(/\{[\s\S]*\}/);
if (jsonMatch) {
jsonStr = jsonMatch[jsonMatch.length - 1].trim();
}
aiResult = {
data: JSON.parse(jsonStr),
apiTime: performance.now() - startApi,
responseText: content
};
// 6. Name Composition & Normalization
// Use standardized helper to construct the perfect name
console.log(`[Uncleaned Data]: ${JSON.stringify(aiResult.data)}`);
@@ -301,7 +245,7 @@ export async function analyzeBottleLabel(imageBase64: string): Promise<ScannerRe
endpoint: `analyzeBottleLabel_${provider}`,
success: true,
provider,
model: provider === 'openrouter' ? OPENROUTER_VISION_MODEL : GEMINI_MODEL,
model: OPENROUTER_VISION_MODEL,
responseText: aiResult.responseText
});
await deductCredits(user.id, 'gemini_ai', `Scanner analysis (${provider})`);