refactor: Consolidate all AI calls to use OpenRouter provider switch
- bulk-scan.ts: Now uses OpenRouter for batch analysis - scan-label.ts: Now uses OpenRouter with Gemini fallback - analyze-bottle.ts: Now uses OpenRouter with Gemini fallback - All AI calls now respect AI_PROVIDER env variable - Uses Nebius/FP8 provider preferences consistently - Unified logging: [FunctionName] Using provider: openrouter/gemini
This commit is contained in:
@@ -6,6 +6,7 @@ import { createClient } from '@/lib/supabase/server';
|
|||||||
import { createHash } from 'crypto';
|
import { createHash } from 'crypto';
|
||||||
import { trackApiUsage } from '@/services/track-api-usage';
|
import { trackApiUsage } from '@/services/track-api-usage';
|
||||||
import { checkCreditBalance, deductCredits } from '@/services/credit-service';
|
import { checkCreditBalance, deductCredits } from '@/services/credit-service';
|
||||||
|
import { getAIProvider, getOpenRouterClient, OPENROUTER_PROVIDER_PREFERENCES } from '@/lib/openrouter';
|
||||||
import sharp from 'sharp';
|
import sharp from 'sharp';
|
||||||
|
|
||||||
// Native Schema Definition for Gemini API
|
// Native Schema Definition for Gemini API
|
||||||
@@ -30,10 +31,30 @@ const metadataSchema = {
|
|||||||
required: ["name", "is_whisky", "confidence"],
|
required: ["name", "is_whisky", "confidence"],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const SCAN_PROMPT = `Extract whisky label metadata. Return JSON with:
|
||||||
|
- name: Full product name
|
||||||
|
- distillery: Distillery name
|
||||||
|
- bottler: Independent bottler if applicable
|
||||||
|
- category: e.g. "Single Malt", "Bourbon"
|
||||||
|
- abv: Alcohol percentage
|
||||||
|
- age: Age statement in years
|
||||||
|
- vintage: Vintage year
|
||||||
|
- distilled_at: Distillation date
|
||||||
|
- bottled_at: Bottling date
|
||||||
|
- batch_info: Batch or cask info
|
||||||
|
- is_whisky: boolean
|
||||||
|
- confidence: 0-1`;
|
||||||
|
|
||||||
export async function scanLabel(input: any): Promise<AnalysisResponse> {
|
export async function scanLabel(input: any): Promise<AnalysisResponse> {
|
||||||
if (!process.env.GEMINI_API_KEY) {
|
const provider = getAIProvider();
|
||||||
|
|
||||||
|
// Check API key based on provider
|
||||||
|
if (provider === 'gemini' && !process.env.GEMINI_API_KEY) {
|
||||||
return { success: false, error: 'GEMINI_API_KEY is not configured.' };
|
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.' };
|
||||||
|
}
|
||||||
|
|
||||||
let supabase;
|
let supabase;
|
||||||
try {
|
try {
|
||||||
@@ -101,7 +122,7 @@ export async function scanLabel(input: any): Promise<AnalysisResponse> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 3: AI-Specific Image Optimization (Grayscale, Normalize, Resize)
|
// Step 3: AI-Specific Image Optimization
|
||||||
const startOptimization = performance.now();
|
const startOptimization = performance.now();
|
||||||
const optimizedBuffer = await sharp(buffer)
|
const optimizedBuffer = await sharp(buffer)
|
||||||
.resize(1024, 1024, { fit: 'inside', withoutEnlargement: true })
|
.resize(1024, 1024, { fit: 'inside', withoutEnlargement: true })
|
||||||
@@ -117,14 +138,79 @@ export async function scanLabel(input: any): Promise<AnalysisResponse> {
|
|||||||
const uploadSize = optimizedBuffer.length;
|
const uploadSize = optimizedBuffer.length;
|
||||||
const endEncoding = performance.now();
|
const endEncoding = performance.now();
|
||||||
|
|
||||||
// Step 5: Model Initialization & Step 6: API Call
|
// Step 5: AI Analysis
|
||||||
const startAiTotal = performance.now();
|
const startAiTotal = performance.now();
|
||||||
let jsonData;
|
let jsonData;
|
||||||
let validatedData;
|
let validatedData;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
console.log(`[ScanLabel] Using provider: ${provider}`);
|
||||||
|
|
||||||
|
if (provider === 'openrouter') {
|
||||||
|
// OpenRouter path
|
||||||
|
const client = getOpenRouterClient();
|
||||||
|
const startApi = performance.now();
|
||||||
|
|
||||||
|
const response = await client.chat.completions.create({
|
||||||
|
model: 'google/gemma-3-27b-it',
|
||||||
|
messages: [{
|
||||||
|
role: 'user',
|
||||||
|
content: [
|
||||||
|
{ type: 'image_url', image_url: { url: `data:${mimeType};base64,${base64Data}` } },
|
||||||
|
{ type: 'text', text: SCAN_PROMPT + '\n\nRespond ONLY with valid JSON.' },
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
temperature: 0.1,
|
||||||
|
max_tokens: 1024,
|
||||||
|
// @ts-ignore
|
||||||
|
provider: OPENROUTER_PROVIDER_PREFERENCES,
|
||||||
|
});
|
||||||
|
|
||||||
|
const endApi = performance.now();
|
||||||
|
const content = response.choices[0]?.message?.content || '{}';
|
||||||
|
|
||||||
|
const startParse = performance.now();
|
||||||
|
let jsonStr = content;
|
||||||
|
const jsonMatch = content.match(/```(?:json)?\s*([\s\S]*?)```/);
|
||||||
|
if (jsonMatch) jsonStr = jsonMatch[1].trim();
|
||||||
|
jsonData = JSON.parse(jsonStr);
|
||||||
|
const endParse = performance.now();
|
||||||
|
|
||||||
|
const startValidation = performance.now();
|
||||||
|
validatedData = BottleMetadataSchema.parse(jsonData);
|
||||||
|
const endValidation = performance.now();
|
||||||
|
|
||||||
|
await supabase.from('vision_cache').insert({ hash: imageHash, result: validatedData });
|
||||||
|
|
||||||
|
await trackApiUsage({
|
||||||
|
userId: userId,
|
||||||
|
apiType: 'gemini_ai',
|
||||||
|
endpoint: 'scanLabel_openrouter',
|
||||||
|
success: true
|
||||||
|
});
|
||||||
|
await deductCredits(userId, 'gemini_ai', 'Bottle OCR scan (OpenRouter)');
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: validatedData,
|
||||||
|
perf: {
|
||||||
|
imagePrep: endImagePrep - startImagePrep,
|
||||||
|
optimization: endOptimization - startOptimization,
|
||||||
|
cacheCheck: endCacheCheck - startCacheCheck,
|
||||||
|
encoding: endEncoding - startEncoding,
|
||||||
|
apiCall: endApi - startApi,
|
||||||
|
parsing: endParse - startParse,
|
||||||
|
validation: endValidation - startValidation,
|
||||||
|
total: performance.now() - perfTotal,
|
||||||
|
cacheHit: false
|
||||||
|
},
|
||||||
|
raw: jsonData
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// Gemini path
|
||||||
const startModelInit = performance.now();
|
const startModelInit = performance.now();
|
||||||
const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY);
|
const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY!);
|
||||||
const model = genAI.getGenerativeModel({
|
const model = genAI.getGenerativeModel({
|
||||||
model: 'gemini-2.5-flash',
|
model: 'gemini-2.5-flash',
|
||||||
generationConfig: {
|
generationConfig: {
|
||||||
@@ -141,11 +227,10 @@ export async function scanLabel(input: any): Promise<AnalysisResponse> {
|
|||||||
});
|
});
|
||||||
const endModelInit = performance.now();
|
const endModelInit = performance.now();
|
||||||
|
|
||||||
const instruction = "Extract whisky label metadata.";
|
|
||||||
const startApi = performance.now();
|
const startApi = performance.now();
|
||||||
const result = await model.generateContent([
|
const result = await model.generateContent([
|
||||||
{ inlineData: { data: base64Data, mimeType: mimeType } },
|
{ inlineData: { data: base64Data, mimeType: mimeType } },
|
||||||
{ text: instruction },
|
{ text: 'Extract whisky label metadata.' },
|
||||||
]);
|
]);
|
||||||
const endApi = performance.now();
|
const endApi = performance.now();
|
||||||
|
|
||||||
@@ -157,18 +242,16 @@ export async function scanLabel(input: any): Promise<AnalysisResponse> {
|
|||||||
validatedData = BottleMetadataSchema.parse(jsonData);
|
validatedData = BottleMetadataSchema.parse(jsonData);
|
||||||
const endValidation = performance.now();
|
const endValidation = performance.now();
|
||||||
|
|
||||||
// Cache record
|
|
||||||
await supabase.from('vision_cache').insert({ hash: imageHash, result: validatedData });
|
await supabase.from('vision_cache').insert({ hash: imageHash, result: validatedData });
|
||||||
|
|
||||||
await trackApiUsage({
|
await trackApiUsage({
|
||||||
userId: userId,
|
userId: userId,
|
||||||
apiType: 'gemini_ai',
|
apiType: 'gemini_ai',
|
||||||
endpoint: 'scanLabel',
|
endpoint: 'scanLabel_gemini',
|
||||||
success: true
|
success: true
|
||||||
});
|
});
|
||||||
await deductCredits(userId, 'gemini_ai', 'Bottle OCR scan');
|
await deductCredits(userId, 'gemini_ai', 'Bottle OCR scan (Gemini)');
|
||||||
|
|
||||||
const totalTime = performance.now() - perfTotal;
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: validatedData,
|
data: validatedData,
|
||||||
@@ -181,30 +264,29 @@ export async function scanLabel(input: any): Promise<AnalysisResponse> {
|
|||||||
apiCall: endApi - startApi,
|
apiCall: endApi - startApi,
|
||||||
parsing: endParse - startParse,
|
parsing: endParse - startParse,
|
||||||
validation: endValidation - startValidation,
|
validation: endValidation - startValidation,
|
||||||
total: totalTime,
|
total: performance.now() - perfTotal,
|
||||||
cacheHit: false
|
cacheHit: false
|
||||||
},
|
},
|
||||||
raw: jsonData
|
raw: jsonData
|
||||||
} as any;
|
} as any;
|
||||||
|
}
|
||||||
|
|
||||||
} catch (aiError: any) {
|
} catch (aiError: any) {
|
||||||
console.warn('[ScanLabel] AI Analysis failed, providing fallback path:', aiError.message);
|
console.warn(`[ScanLabel] ${provider} failed:`, aiError.message);
|
||||||
|
|
||||||
// Track failure
|
|
||||||
await trackApiUsage({
|
await trackApiUsage({
|
||||||
userId: userId,
|
userId: userId,
|
||||||
apiType: 'gemini_ai',
|
apiType: 'gemini_ai',
|
||||||
endpoint: 'scanLabel',
|
endpoint: `scanLabel_${provider}`,
|
||||||
success: false,
|
success: false,
|
||||||
errorMessage: aiError.message
|
errorMessage: aiError.message
|
||||||
});
|
});
|
||||||
|
|
||||||
// Return a specific structure that ScanAndTasteFlow can use to fallback to placeholder
|
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
isAiError: true,
|
isAiError: true,
|
||||||
error: aiError.message,
|
error: aiError.message,
|
||||||
imageHash: imageHash // Useful for local tracking
|
imageHash: imageHash
|
||||||
} as any;
|
} as any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,29 +1,33 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import { geminiModel } from '@/lib/gemini';
|
import { GoogleGenerativeAI, HarmCategory, HarmBlockThreshold } from '@google/generative-ai';
|
||||||
import { getSystemPrompt } from '@/lib/ai-prompts';
|
import { getSystemPrompt } from '@/lib/ai-prompts';
|
||||||
import { BottleMetadataSchema, AnalysisResponse } from '@/types/whisky';
|
import { BottleMetadataSchema, AnalysisResponse } from '@/types/whisky';
|
||||||
import { createClient } from '@/lib/supabase/server';
|
import { createClient } from '@/lib/supabase/server';
|
||||||
import { createHash } from 'crypto';
|
import { createHash } from 'crypto';
|
||||||
import { trackApiUsage } from './track-api-usage';
|
import { trackApiUsage } from './track-api-usage';
|
||||||
import { checkCreditBalance, deductCredits } from './credit-service';
|
import { checkCreditBalance, deductCredits } from './credit-service';
|
||||||
|
import { getAIProvider, getOpenRouterClient, OPENROUTER_PROVIDER_PREFERENCES } from '@/lib/openrouter';
|
||||||
|
|
||||||
// WICHTIG: Wir akzeptieren jetzt FormData statt Strings
|
|
||||||
export async function analyzeBottle(input: any): Promise<AnalysisResponse> {
|
export async function analyzeBottle(input: any): Promise<AnalysisResponse> {
|
||||||
if (!process.env.GEMINI_API_KEY) {
|
const provider = getAIProvider();
|
||||||
|
|
||||||
|
// Check API key based on provider
|
||||||
|
if (provider === 'gemini' && !process.env.GEMINI_API_KEY) {
|
||||||
return { success: false, error: 'GEMINI_API_KEY is not configured.' };
|
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.' };
|
||||||
|
}
|
||||||
|
|
||||||
let supabase;
|
let supabase;
|
||||||
try {
|
try {
|
||||||
// Helper to get value from either FormData or POJO
|
|
||||||
const getValue = (obj: any, key: string): any => {
|
const getValue = (obj: any, key: string): any => {
|
||||||
if (obj && typeof obj.get === 'function') return obj.get(key);
|
if (obj && typeof obj.get === 'function') return obj.get(key);
|
||||||
if (obj && typeof obj[key] !== 'undefined') return obj[key];
|
if (obj && typeof obj[key] !== 'undefined') return obj[key];
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 1. Daten extrahieren (leichtgewichtig für den N100)
|
|
||||||
const file = getValue(input, 'file') as File;
|
const file = getValue(input, 'file') as File;
|
||||||
const tagsString = getValue(input, 'tags') as string;
|
const tagsString = getValue(input, 'tags') as string;
|
||||||
const locale = getValue(input, 'locale') || 'de';
|
const locale = getValue(input, 'locale') || 'de';
|
||||||
@@ -32,10 +36,8 @@ export async function analyzeBottle(input: any): Promise<AnalysisResponse> {
|
|||||||
return { success: false, error: 'Kein Bild empfangen.' };
|
return { success: false, error: 'Kein Bild empfangen.' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tags müssen manuell geparst werden, da FormData alles flach macht
|
|
||||||
const tags = tagsString ? (typeof tagsString === 'string' ? JSON.parse(tagsString) : tagsString) : [];
|
const tags = tagsString ? (typeof tagsString === 'string' ? JSON.parse(tagsString) : tagsString) : [];
|
||||||
|
|
||||||
// 2. Auth & Credits (bleibt gleich)
|
|
||||||
supabase = await createClient();
|
supabase = await createClient();
|
||||||
const { data: { user } } = await supabase.auth.getUser();
|
const { data: { user } } = await supabase.auth.getUser();
|
||||||
|
|
||||||
@@ -53,13 +55,10 @@ export async function analyzeBottle(input: any): Promise<AnalysisResponse> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Datei in Buffer umwandeln (Schneller als String-Manipulation)
|
|
||||||
// Der N100 mag ArrayBuffer lieber als riesige Base64 Strings im JSON
|
|
||||||
const arrayBuffer = await file.arrayBuffer();
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
const buffer = Buffer.from(arrayBuffer);
|
const buffer = Buffer.from(arrayBuffer);
|
||||||
|
|
||||||
// 4. Hash für Cache erstellen (direkt vom Buffer -> sehr schnell)
|
|
||||||
const imageHash = createHash('sha256').update(buffer).digest('hex');
|
const imageHash = createHash('sha256').update(buffer).digest('hex');
|
||||||
|
|
||||||
// Cache Check
|
// Cache Check
|
||||||
const { data: cachedResult } = await supabase
|
const { data: cachedResult } = await supabase
|
||||||
.from('vision_cache')
|
.from('vision_cache')
|
||||||
@@ -79,40 +78,44 @@ export async function analyzeBottle(input: any): Promise<AnalysisResponse> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Für Gemini vorbereiten
|
|
||||||
const base64Data = buffer.toString('base64');
|
const base64Data = buffer.toString('base64');
|
||||||
const mimeType = file.type || 'image/webp';
|
const mimeType = file.type || 'image/webp';
|
||||||
const uploadSize = buffer.length;
|
const uploadSize = buffer.length;
|
||||||
|
|
||||||
const instruction = getSystemPrompt(tags.length > 0 ? tags.join(', ') : 'No tags available', locale);
|
const instruction = getSystemPrompt(tags.length > 0 ? tags.join(', ') : 'No tags available', locale);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// API Call
|
console.log(`[AnalyzeBottle] Using provider: ${provider}`);
|
||||||
|
|
||||||
|
if (provider === 'openrouter') {
|
||||||
|
// OpenRouter path
|
||||||
|
const client = getOpenRouterClient();
|
||||||
const startApi = performance.now();
|
const startApi = performance.now();
|
||||||
const result = await geminiModel.generateContent([
|
|
||||||
{
|
const response = await client.chat.completions.create({
|
||||||
inlineData: {
|
model: 'google/gemma-3-27b-it',
|
||||||
data: base64Data,
|
messages: [{
|
||||||
mimeType: mimeType,
|
role: 'user',
|
||||||
},
|
content: [
|
||||||
},
|
{ type: 'image_url', image_url: { url: `data:${mimeType};base64,${base64Data}` } },
|
||||||
{ text: instruction },
|
{ type: 'text', text: instruction + '\n\nRespond ONLY with valid JSON.' },
|
||||||
]);
|
],
|
||||||
|
}],
|
||||||
|
temperature: 0.1,
|
||||||
|
max_tokens: 1024,
|
||||||
|
// @ts-ignore
|
||||||
|
provider: OPENROUTER_PROVIDER_PREFERENCES,
|
||||||
|
});
|
||||||
|
|
||||||
const endApi = performance.now();
|
const endApi = performance.now();
|
||||||
|
const content = response.choices[0]?.message?.content || '{}';
|
||||||
|
|
||||||
const startParse = performance.now();
|
const startParse = performance.now();
|
||||||
const responseText = result.response.text();
|
let jsonStr = content;
|
||||||
|
const jsonMatch = content.match(/```(?:json)?\s*([\s\S]*?)```/);
|
||||||
let jsonData;
|
if (jsonMatch) jsonStr = jsonMatch[1].trim();
|
||||||
try {
|
let jsonData = JSON.parse(jsonStr);
|
||||||
jsonData = JSON.parse(responseText);
|
|
||||||
} catch (e) {
|
|
||||||
const cleanedText = responseText.replace(/```json/g, '').replace(/```/g, '');
|
|
||||||
jsonData = JSON.parse(cleanedText);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(jsonData)) jsonData = jsonData[0];
|
if (Array.isArray(jsonData)) jsonData = jsonData[0];
|
||||||
console.log('[Gemini AI] JSON Response:', jsonData);
|
const endParse = performance.now();
|
||||||
|
|
||||||
const searchString = jsonData.search_string;
|
const searchString = jsonData.search_string;
|
||||||
delete jsonData.search_string;
|
delete jsonData.search_string;
|
||||||
@@ -120,19 +123,15 @@ export async function analyzeBottle(input: any): Promise<AnalysisResponse> {
|
|||||||
if (!jsonData) throw new Error('Keine Daten in der KI-Antwort gefunden.');
|
if (!jsonData) throw new Error('Keine Daten in der KI-Antwort gefunden.');
|
||||||
|
|
||||||
const validatedData = BottleMetadataSchema.parse(jsonData);
|
const validatedData = BottleMetadataSchema.parse(jsonData);
|
||||||
const endParse = performance.now();
|
|
||||||
|
|
||||||
// 6. Tracking & Credits
|
|
||||||
await trackApiUsage({
|
await trackApiUsage({
|
||||||
userId: userId,
|
userId: userId,
|
||||||
apiType: 'gemini_ai',
|
apiType: 'gemini_ai',
|
||||||
endpoint: 'generateContent',
|
endpoint: 'analyzeBottle_openrouter',
|
||||||
success: true
|
success: true
|
||||||
});
|
});
|
||||||
|
await deductCredits(userId, 'gemini_ai', 'Bottle analysis (OpenRouter)');
|
||||||
|
|
||||||
await deductCredits(userId, 'gemini_ai', 'Bottle analysis');
|
|
||||||
|
|
||||||
// Cache speichern
|
|
||||||
await supabase
|
await supabase
|
||||||
.from('vision_cache')
|
.from('vision_cache')
|
||||||
.insert({ hash: imageHash, result: validatedData });
|
.insert({ hash: imageHash, result: validatedData });
|
||||||
@@ -149,13 +148,82 @@ export async function analyzeBottle(input: any): Promise<AnalysisResponse> {
|
|||||||
raw: jsonData
|
raw: jsonData
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
} catch (aiError: any) {
|
} else {
|
||||||
console.warn('[AnalyzeBottle] AI Analysis failed, providing fallback path:', aiError.message);
|
// Gemini path
|
||||||
|
const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY!);
|
||||||
|
const model = genAI.getGenerativeModel({
|
||||||
|
model: 'gemini-2.5-flash',
|
||||||
|
generationConfig: {
|
||||||
|
responseMimeType: "application/json",
|
||||||
|
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: mimeType } },
|
||||||
|
{ text: instruction },
|
||||||
|
]);
|
||||||
|
const endApi = performance.now();
|
||||||
|
|
||||||
|
const startParse = performance.now();
|
||||||
|
const responseText = result.response.text();
|
||||||
|
let jsonData;
|
||||||
|
try {
|
||||||
|
jsonData = JSON.parse(responseText);
|
||||||
|
} catch (e) {
|
||||||
|
const cleanedText = responseText.replace(/```json/g, '').replace(/```/g, '');
|
||||||
|
jsonData = JSON.parse(cleanedText);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(jsonData)) jsonData = jsonData[0];
|
||||||
|
const endParse = performance.now();
|
||||||
|
|
||||||
|
const searchString = jsonData.search_string;
|
||||||
|
delete jsonData.search_string;
|
||||||
|
|
||||||
|
if (!jsonData) throw new Error('Keine Daten in der KI-Antwort gefunden.');
|
||||||
|
|
||||||
|
const validatedData = BottleMetadataSchema.parse(jsonData);
|
||||||
|
|
||||||
await trackApiUsage({
|
await trackApiUsage({
|
||||||
userId: userId,
|
userId: userId,
|
||||||
apiType: 'gemini_ai',
|
apiType: 'gemini_ai',
|
||||||
endpoint: 'generateContent',
|
endpoint: 'analyzeBottle_gemini',
|
||||||
|
success: true
|
||||||
|
});
|
||||||
|
await deductCredits(userId, 'gemini_ai', 'Bottle analysis (Gemini)');
|
||||||
|
|
||||||
|
await supabase
|
||||||
|
.from('vision_cache')
|
||||||
|
.insert({ hash: imageHash, result: validatedData });
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: validatedData,
|
||||||
|
search_string: searchString,
|
||||||
|
perf: {
|
||||||
|
apiDuration: endApi - startApi,
|
||||||
|
parseDuration: endParse - startParse,
|
||||||
|
uploadSize: uploadSize
|
||||||
|
},
|
||||||
|
raw: jsonData
|
||||||
|
} as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (aiError: any) {
|
||||||
|
console.warn(`[AnalyzeBottle] ${provider} failed:`, aiError.message);
|
||||||
|
|
||||||
|
await trackApiUsage({
|
||||||
|
userId: userId,
|
||||||
|
apiType: 'gemini_ai',
|
||||||
|
endpoint: `analyzeBottle_${provider}`,
|
||||||
success: false,
|
success: false,
|
||||||
errorMessage: aiError.message
|
errorMessage: aiError.message
|
||||||
});
|
});
|
||||||
@@ -169,7 +237,7 @@ export async function analyzeBottle(input: any): Promise<AnalysisResponse> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Gemini Analysis Global Error:', error);
|
console.error('Analyze Bottle Global Error:', error);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: error instanceof Error ? error.message : 'An unknown error occurred during analysis.',
|
error: error instanceof Error ? error.message : 'An unknown error occurred during analysis.',
|
||||||
|
|||||||
@@ -234,8 +234,8 @@ async function markBottleError(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Call Gemini to analyze bottle image
|
* Analyze bottle image using configured AI provider
|
||||||
* Uses existing Gemini integration
|
* Uses OpenRouter by default, falls back to Gemini
|
||||||
*/
|
*/
|
||||||
async function analyzeBottleImage(imageUrl: string): Promise<{
|
async function analyzeBottleImage(imageUrl: string): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
@@ -250,6 +250,9 @@ async function analyzeBottleImage(imageUrl: string): Promise<{
|
|||||||
};
|
};
|
||||||
error?: string;
|
error?: string;
|
||||||
}> {
|
}> {
|
||||||
|
const { getAIProvider, getOpenRouterClient, OPENROUTER_PROVIDER_PREFERENCES } = await import('@/lib/openrouter');
|
||||||
|
const provider = getAIProvider();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Fetch image and convert to base64
|
// Fetch image and convert to base64
|
||||||
const response = await fetch(imageUrl);
|
const response = await fetch(imageUrl);
|
||||||
@@ -262,22 +265,7 @@ async function analyzeBottleImage(imageUrl: string): Promise<{
|
|||||||
const base64 = Buffer.from(buffer).toString('base64');
|
const base64 = Buffer.from(buffer).toString('base64');
|
||||||
const mimeType = blob.type || 'image/webp';
|
const mimeType = blob.type || 'image/webp';
|
||||||
|
|
||||||
// Call Gemini
|
const prompt = `Analyze this whisky bottle image. Extract:
|
||||||
const apiKey = process.env.GOOGLE_GENERATIVE_AI_API_KEY;
|
|
||||||
if (!apiKey) {
|
|
||||||
return { success: false, error: 'API Key nicht konfiguriert' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const geminiResponse = await fetch(
|
|
||||||
`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${apiKey}`,
|
|
||||||
{
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
contents: [{
|
|
||||||
parts: [
|
|
||||||
{
|
|
||||||
text: `Analyze this whisky bottle image. Extract:
|
|
||||||
- name: Full product name
|
- name: Full product name
|
||||||
- distillery: Distillery name
|
- distillery: Distillery name
|
||||||
- category: e.g. "Single Malt", "Bourbon", "Blend"
|
- category: e.g. "Single Malt", "Bourbon", "Blend"
|
||||||
@@ -286,20 +274,53 @@ async function analyzeBottleImage(imageUrl: string): Promise<{
|
|||||||
- is_whisky: boolean, false if not a whisky
|
- is_whisky: boolean, false if not a whisky
|
||||||
- confidence: 0-100 how confident you are
|
- confidence: 0-100 how confident you are
|
||||||
|
|
||||||
Respond ONLY with valid JSON, no markdown.`
|
Respond ONLY with valid JSON, no markdown.`;
|
||||||
},
|
|
||||||
|
if (provider === 'openrouter') {
|
||||||
|
// OpenRouter path
|
||||||
|
const client = getOpenRouterClient();
|
||||||
|
const openRouterResponse = await client.chat.completions.create({
|
||||||
|
model: 'google/gemma-3-27b-it',
|
||||||
|
messages: [{
|
||||||
|
role: 'user',
|
||||||
|
content: [
|
||||||
|
{ type: 'image_url', image_url: { url: `data:${mimeType};base64,${base64}` } },
|
||||||
|
{ type: 'text', text: prompt },
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
temperature: 0.1,
|
||||||
|
max_tokens: 500,
|
||||||
|
// @ts-ignore
|
||||||
|
provider: OPENROUTER_PROVIDER_PREFERENCES,
|
||||||
|
});
|
||||||
|
|
||||||
|
const content = openRouterResponse.choices[0]?.message?.content || '{}';
|
||||||
|
let jsonStr = content;
|
||||||
|
const jsonMatch = content.match(/```(?:json)?\s*([\s\S]*?)```/);
|
||||||
|
if (jsonMatch) jsonStr = jsonMatch[1].trim();
|
||||||
|
const parsed = JSON.parse(jsonStr);
|
||||||
|
return { success: true, data: parsed };
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// Gemini path
|
||||||
|
const apiKey = process.env.GEMINI_API_KEY;
|
||||||
|
if (!apiKey) {
|
||||||
|
return { success: false, error: 'GEMINI_API_KEY nicht konfiguriert' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const geminiResponse = await fetch(
|
||||||
|
`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${apiKey}`,
|
||||||
{
|
{
|
||||||
inline_data: {
|
method: 'POST',
|
||||||
mime_type: mimeType,
|
headers: { 'Content-Type': 'application/json' },
|
||||||
data: base64
|
body: JSON.stringify({
|
||||||
}
|
contents: [{
|
||||||
}
|
parts: [
|
||||||
|
{ text: prompt },
|
||||||
|
{ inline_data: { mime_type: mimeType, data: base64 } }
|
||||||
]
|
]
|
||||||
}],
|
}],
|
||||||
generationConfig: {
|
generationConfig: { temperature: 0.1, maxOutputTokens: 500 }
|
||||||
temperature: 0.1,
|
|
||||||
maxOutputTokens: 500,
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -315,7 +336,6 @@ Respond ONLY with valid JSON, no markdown.`
|
|||||||
return { success: false, error: 'Keine Antwort von Gemini' };
|
return { success: false, error: 'Keine Antwort von Gemini' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse JSON response
|
|
||||||
const jsonMatch = textContent.match(/\{[\s\S]*\}/);
|
const jsonMatch = textContent.match(/\{[\s\S]*\}/);
|
||||||
if (!jsonMatch) {
|
if (!jsonMatch) {
|
||||||
return { success: false, error: 'Ungültige Gemini-Antwort' };
|
return { success: false, error: 'Ungültige Gemini-Antwort' };
|
||||||
@@ -323,9 +343,10 @@ Respond ONLY with valid JSON, no markdown.`
|
|||||||
|
|
||||||
const parsed = JSON.parse(jsonMatch[0]);
|
const parsed = JSON.parse(jsonMatch[0]);
|
||||||
return { success: true, data: parsed };
|
return { success: true, data: parsed };
|
||||||
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Gemini analysis error:', error);
|
console.error(`[BulkScan] ${provider} analysis error:`, error);
|
||||||
return { success: false, error: 'Analysefehler' };
|
return { success: false, error: 'Analysefehler' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user