'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 { 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 { 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.', }; } }