diff --git a/src/app/actions/gemini-vision.ts b/src/app/actions/gemini-vision.ts index f8199d1..dbdd6a8 100644 --- a/src/app/actions/gemini-vision.ts +++ b/src/app/actions/gemini-vision.ts @@ -5,6 +5,7 @@ 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 } from '@/lib/openrouter'; // Schema for Gemini Vision extraction const visionSchema = { @@ -32,24 +33,144 @@ export interface GeminiVisionResult { success: boolean; data?: BottleMetadata; error?: string; + provider?: 'gemini' | 'openrouter'; perf?: { apiCall: number; total: number; }; } +const VISION_PROMPT = `Analyze this whisky bottle label image and extract all visible metadata. +Look carefully for: +- Brand/Distillery name +- Bottle name or expression +- Age statement (e.g., "12 Years Old") +- ABV/Alcohol percentage +- Vintage year (if shown) +- Cask type (e.g., Sherry, Bourbon cask) +- Bottler name (if independent bottling) +- Category (Single Malt, Blended Malt, Bourbon, etc.) + +Be precise and only include information you can clearly read from the label. +If you cannot read something clearly, leave it null. + +Respond ONLY with valid JSON in this format: +{ + "name": "Full whisky name", + "distillery": "Distillery name or null", + "bottler": "Bottler name or null", + "category": "Whisky category or null", + "abv": 46.0, + "age": 12, + "vintage": "2010 or null", + "cask_type": "Cask type or null", + "distilled_at": "Date or null", + "bottled_at": "Date or null", + "batch_info": "Batch info or null", + "is_whisky": true, + "confidence": 0.85 +}`; + /** - * Analyze a whisky bottle label image using Gemini Vision + * Analyze whisky label with OpenRouter (Gemma 3 27B) + */ +async function analyzeWithOpenRouter(base64Data: string, mimeType: string): Promise<{ data: any; apiTime: number }> { + const client = getOpenRouterClient(); + const startApi = performance.now(); + + const 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, + }); + + const endApi = performance.now(); + const content = response.choices[0]?.message?.content || '{}'; + + // Extract JSON from response (may have markdown code blocks) + let jsonStr = content; + const jsonMatch = content.match(/```(?:json)?\s*([\s\S]*?)```/); + if (jsonMatch) { + jsonStr = jsonMatch[1].trim(); + } + + return { + data: JSON.parse(jsonStr), + apiTime: endApi - startApi, + }; +} + +/** + * Analyze whisky label with Gemini + */ +async function analyzeWithGemini(base64Data: string, mimeType: string): Promise<{ data: any; apiTime: number }> { + const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY!); + const model = genAI.getGenerativeModel({ + model: 'gemini-2.5-flash', + generationConfig: { + responseMimeType: "application/json", + responseSchema: visionSchema as any, + 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 endApi = performance.now(); + + return { + data: JSON.parse(result.response.text()), + apiTime: endApi - startApi, + }; +} + +/** + * Analyze a whisky bottle label image using configured AI provider + * + * Provider is controlled by AI_PROVIDER env variable: + * - "openrouter" (default) - Uses OpenRouter with Gemma 3 27B + * - "gemini" - Uses Google Gemini 2.5 Flash * * @param imageBase64 - Base64 encoded image (with data URL prefix) * @returns GeminiVisionResult with extracted metadata */ export async function analyzeLabelWithGemini(imageBase64: string): Promise { const startTotal = performance.now(); + const provider = getAIProvider(); - if (!process.env.GEMINI_API_KEY) { + // Check API key based on provider + if (provider === 'gemini' && !process.env.GEMINI_API_KEY) { return { success: false, error: 'GEMINI_API_KEY is not configured.' }; } + if (provider === 'openrouter' && !process.env.OPENROUTER_API_KEY) { + return { success: false, error: 'OPENROUTER_API_KEY is not configured.' }; + } if (!imageBase64 || imageBase64.length < 100) { return { success: false, error: 'Invalid image data provided.' }; @@ -64,7 +185,7 @@ export async function analyzeLabelWithGemini(imageBase64: string): Promise