feat: improve AI resilience, add background enrichment loading states, and fix duplicate identifier in TagSelector

This commit is contained in:
2025-12-23 11:38:16 +01:00
parent 1d98bb9947
commit c134c78a2c
37 changed files with 1906 additions and 786 deletions

View File

@@ -0,0 +1,207 @@
'use server';
import { GoogleGenerativeAI, SchemaType, HarmCategory, HarmBlockThreshold } from '@google/generative-ai';
import { BottleMetadataSchema, AnalysisResponse } from '@/types/whisky';
import { createClient } from '@/lib/supabase/server';
import { createHash } from 'crypto';
import { trackApiUsage } from '@/services/track-api-usage';
import { checkCreditBalance, deductCredits } from '@/services/credit-service';
// Native Schema Definition for Gemini API
const metadataSchema = {
description: "Technical metadata extracted from whisky label",
type: SchemaType.OBJECT as const,
properties: {
name: { type: SchemaType.STRING, description: "Full whisky name including vintage/age", nullable: false },
distillery: { type: SchemaType.STRING, description: "Distillery name", nullable: true },
bottler: { type: SchemaType.STRING, description: "Independent bottler if applicable", nullable: true },
category: { type: SchemaType.STRING, description: "Whisky category (e.g., Single Malt, Blended)", 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 year", 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 information", nullable: true },
bottleCode: { type: SchemaType.STRING, description: "Bottle code or serial 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"],
};
export async function scanLabel(input: any): Promise<AnalysisResponse> {
if (!process.env.GEMINI_API_KEY) {
return { success: false, error: 'GEMINI_API_KEY is not configured.' };
}
let supabase;
try {
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') as File;
if (!file) {
return { success: false, error: 'Kein Bild empfangen.' };
}
supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return { success: false, error: 'Nicht autorisiert.' };
}
const userId = user.id;
const creditCheck = await checkCreditBalance(userId, 'gemini_ai');
if (!creditCheck.allowed) {
return {
success: false,
error: `Nicht genügend Credits. Benötigt: ${creditCheck.cost}, Verfügbar: ${creditCheck.balance}.`
};
}
const perfTotal = performance.now();
// Step 1: Image Preparation
const startImagePrep = performance.now();
const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const imageHash = createHash('sha256').update(buffer).digest('hex');
const endImagePrep = performance.now();
// Step 2: Cache Check
const startCacheCheck = performance.now();
const { data: cachedResult } = await supabase
.from('vision_cache')
.select('result')
.eq('hash', imageHash)
.maybeSingle();
const endCacheCheck = performance.now();
if (cachedResult) {
return {
success: true,
data: cachedResult.result as any,
perf: {
imagePrep: endImagePrep - startImagePrep,
cacheCheck: endCacheCheck - startCacheCheck,
apiCall: 0,
parsing: 0,
validation: 0,
dbOps: 0,
uploadSize: buffer.length,
total: performance.now() - perfTotal,
cacheHit: true
}
};
}
// Step 3: Base64 Encoding
const startEncoding = performance.now();
const base64Data = buffer.toString('base64');
const mimeType = file.type || 'image/webp';
const uploadSize = buffer.length;
const endEncoding = performance.now();
// Step 4: Model Initialization & Step 5: API Call
const startAiTotal = performance.now();
let jsonData;
let validatedData;
try {
const startModelInit = performance.now();
const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY);
const model = genAI.getGenerativeModel({
model: 'gemini-2.5-flash',
generationConfig: {
responseMimeType: "application/json",
responseSchema: metadataSchema 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 endModelInit = performance.now();
const instruction = "Extract whisky label metadata.";
const startApi = performance.now();
const result = await model.generateContent([
{ inlineData: { data: base64Data, mimeType: mimeType } },
{ text: instruction },
]);
const endApi = performance.now();
const startParse = performance.now();
jsonData = JSON.parse(result.response.text());
const endParse = performance.now();
const startValidation = performance.now();
validatedData = BottleMetadataSchema.parse(jsonData);
const endValidation = performance.now();
// Cache record
await supabase.from('vision_cache').insert({ hash: imageHash, result: validatedData });
await trackApiUsage({
userId: userId,
apiType: 'gemini_ai',
endpoint: 'scanLabel',
success: true
});
await deductCredits(userId, 'gemini_ai', 'Bottle OCR scan');
const totalTime = performance.now() - perfTotal;
return {
success: true,
data: validatedData,
perf: {
imagePrep: endImagePrep - startImagePrep,
cacheCheck: endCacheCheck - startCacheCheck,
encoding: endEncoding - startEncoding,
modelInit: endModelInit - startModelInit,
apiCall: endApi - startApi,
parsing: endParse - startParse,
validation: endValidation - startValidation,
total: totalTime,
cacheHit: false
},
raw: jsonData
} as any;
} catch (aiError: any) {
console.warn('[ScanLabel] AI Analysis failed, providing fallback path:', aiError.message);
// Track failure
await trackApiUsage({
userId: userId,
apiType: 'gemini_ai',
endpoint: 'scanLabel',
success: false,
errorMessage: aiError.message
});
// Return a specific structure that ScanAndTasteFlow can use to fallback to placeholder
return {
success: false,
isAiError: true,
error: aiError.message,
imageHash: imageHash // Useful for local tracking
} as any;
}
} catch (error) {
console.error('Scan Label Global Error:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Fehler bei der Label-Analyse.',
};
}
}