Files
Dramlog-Prod/src/app/actions/scanner.ts
robin 9ba0825bcd 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
2026-01-18 20:38:48 +01:00

275 lines
9.7 KiB
TypeScript

'use server';
import { BottleMetadataSchema, BottleMetadata } from '@/types/whisky';
import { createClient } from '@/lib/supabase/server';
import { trackApiUsage } from '@/services/track-api-usage';
import { checkCreditBalance, deductCredits } from '@/services/credit-service';
import { getAIProvider, getOpenRouterClient, OPENROUTER_VISION_MODEL, OPENROUTER_PROVIDER_PREFERENCES } from '@/lib/openrouter';
import { normalizeWhiskyData } from '@/lib/distillery-matcher';
import { formatWhiskyName } from '@/utils/formatWhiskyName';
import { createHash } from 'crypto';
const VISION_PROMPT = `ROLE: Senior Whisky Database Curator.
OBJECTIVE: Extract metadata from the bottle image.
CRITICAL: You must CONSTRUCT the product name from its parts, not just read lines.
INPUT IMAGE ANALYSIS (Mental Steps):
1. FIND DISTILLERY: Look for the most prominent brand name (e.g. "Annandale").
2. FIND AGE: Scan the center for "Aged X Years" or large numbers. (In this image: Look for a large "10" in the center).
3. FIND VINTAGE: Look for small script/cursive text like "Distilled 2011".
4. FIND CASK TYPE: Look for specific maturation terms like 'Sherry', 'Bourbon', 'Port', 'Oloroso', 'PX', 'Madeira', 'Rum', 'Hogshead', 'Butt', 'Barrel', or 'Finish'. Extract ONLY this phrase into this field (e.g., 'Oloroso Cask Matured') cask_type. Do not leave null if these words are visible.
5. FIND SERIES: Look at the top logo (e.g. "Cadenhead's Natural Strength").
COMPOSITION RULES (How to fill the 'name' field):
- DO NOT just write "Single Malt".
- Format: "[Age/Vintage] [Series] [Cask Info]"
- Example: "10 Year Old Cadenhead's Natural Strength Oloroso Matured"
OUTPUT SCHEMA (Strict JSON):
{
"name": "string (The constructed full name based on rules above)",
"distillery": "string",
"bottler": "string",
"series": "string (e.g. Natural Strength)",
"abv": number,
"age": numberOrNull,
"vintage": "stringOrNull",
"cask_type": "stringOrNull",
"distilled_at": "stringOrNull",
"bottled_at": "stringOrNull",
"batch_info": "stringOrNull",
"is_whisky": true,
"confidence": number
}`;
export interface ScannerResult {
success: boolean;
data?: BottleMetadata;
error?: string;
provider?: 'openrouter';
perf?: {
imagePrep?: number;
apiCall: number;
total: number;
cacheHit?: boolean;
apiDuration?: number;
parsing?: number;
parseDuration?: number;
uploadSize?: number;
cacheCheck?: number;
encoding?: number;
modelInit?: number;
validation?: number;
dbOps?: number;
};
raw?: any;
search_string?: string;
}
/**
* Compatibility wrapper for older call sites that use an object/FormData input.
*/
export async function scanLabel(input: any): Promise<ScannerResult> {
const getValue = (obj: any, key: string): any => {
if (obj && typeof obj.get === 'function') return obj.get(key);
if (obj && typeof obj[key] !== 'undefined') return obj[key];
return null;
};
const file = getValue(input, 'file');
if (file && file instanceof File) {
// Handle file input (e.g. from FormData)
const arrayBuffer = await file.arrayBuffer();
const base64 = Buffer.from(arrayBuffer).toString('base64');
return analyzeBottleLabel(`data:${file.type};base64,${base64}`);
}
if (typeof input === 'string') {
return analyzeBottleLabel(input);
}
return { success: false, error: 'Ungültiges Eingabeformat.' };
}
/**
* Unified action for analyzing a whisky bottle label.
* Replaces redundant gemini-vision.ts, scan-label.ts, and analyze-bottle.ts.
*/
export async function analyzeBottleLabel(imageBase64: string): Promise<ScannerResult> {
const startTotal = performance.now();
const provider = getAIProvider();
if (!imageBase64 || imageBase64.length < 100) {
return { success: false, error: 'Ungültige Bilddaten.' };
}
try {
// 1. Auth & Credit Check
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return { success: false, error: 'Nicht autorisiert.' };
const creditCheck = await checkCreditBalance(user.id, 'gemini_ai');
if (!creditCheck.allowed) {
return {
success: false,
error: `Nicht genügend Credits. Benötigt: ${creditCheck.cost}, Verfügbar: ${creditCheck.balance}.`
};
}
// 2. Extract base64 and hash for caching
let base64Data = imageBase64;
let mimeType = 'image/webp';
if (imageBase64.startsWith('data:')) {
const matches = imageBase64.match(/^data:([^;]+);base64,(.+)$/);
if (matches) {
mimeType = matches[1];
base64Data = matches[2];
}
}
const buffer = Buffer.from(base64Data, 'base64');
const imageHash = createHash('sha256').update(buffer).digest('hex');
// 3. Cache Check
const { data: cachedResult } = await supabase
.from('vision_cache')
.select('result')
.eq('hash', imageHash)
.maybeSingle();
if (cachedResult) {
console.log(`[Scanner] Cache HIT for hash: ${imageHash.slice(0, 8)}...`);
return {
success: true,
data: cachedResult.result as BottleMetadata,
perf: {
apiCall: 0,
total: performance.now() - startTotal,
cacheHit: true
}
};
}
// 4. AI Analysis with retry logic for OpenRouter
console.log(`[Scanner] Using provider: ${provider}`);
let aiResult: { data: any; apiTime: number; responseText: string };
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
};
// 6. Name Composition & Normalization
// Use standardized helper to construct the perfect name
console.log(`[Uncleaned Data]: ${JSON.stringify(aiResult.data)}`);
const d = aiResult.data;
const constructedName = formatWhiskyName({
distillery: d.distillery || '',
bottler: d.bottler,
series: d.series,
age: d.age,
vintage: d.vintage,
cask_type: d.cask_type
}) || d.name;
// Validation & Normalization
const validatedData = BottleMetadataSchema.parse({
...d,
name: constructedName
});
const normalized = normalizeWhiskyData(
validatedData.name || '',
validatedData.distillery || ''
);
const finalData = {
...validatedData,
name: normalized.name || validatedData.name,
distillery: normalized.distillery || validatedData.distillery,
};
// 7. Success Tracking & Caching
await trackApiUsage({
userId: user.id,
apiType: 'gemini_ai',
endpoint: `analyzeBottleLabel_${provider}`,
success: true,
provider,
model: OPENROUTER_VISION_MODEL,
responseText: aiResult.responseText
});
await deductCredits(user.id, 'gemini_ai', `Scanner analysis (${provider})`);
await supabase.from('vision_cache').insert({ hash: imageHash, result: finalData });
return {
success: true,
data: finalData,
provider,
perf: {
apiCall: aiResult.apiTime,
apiDuration: aiResult.apiTime, // Compatibility
total: performance.now() - startTotal,
cacheHit: false
}
};
} catch (error: any) {
console.error(`[Scanner] Analysis failed:`, error);
return {
success: false,
error: error.message || 'Vision analysis failed.',
};
}
}