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,117 @@
'use server';
import { GoogleGenerativeAI, SchemaType, HarmCategory, HarmBlockThreshold } from '@google/generative-ai';
import { createClient } from '@/lib/supabase/server';
import { trackApiUsage } from '@/services/track-api-usage';
import { deductCredits } from '@/services/credit-service';
import { getAllSystemTags } from '@/services/tags';
// Native Schema Definition for Enrichment Data
const enrichmentSchema = {
description: "Sensory profile and search metadata for whisky",
type: SchemaType.OBJECT as const,
properties: {
suggested_tags: {
type: SchemaType.ARRAY,
description: "Array of suggested aroma/taste tags from the available system tags",
items: { type: SchemaType.STRING },
nullable: true
},
suggested_custom_tags: {
type: SchemaType.ARRAY,
description: "Array of custom dominant notes not in the system tags",
items: { type: SchemaType.STRING },
nullable: true
},
search_string: {
type: SchemaType.STRING,
description: "Optimized search query for Whiskybase discovery",
nullable: true
}
},
required: [],
};
export async function enrichData(name: string, distillery: string, availableTags?: string, language: string = 'de') {
if (!process.env.GEMINI_API_KEY) {
return { success: false, error: 'GEMINI_API_KEY is not configured.' };
}
let supabase;
try {
let tagsToUse = availableTags;
if (!tagsToUse) {
const systemTags = await getAllSystemTags();
tagsToUse = systemTags.map(t => t.name).join(', ');
}
supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return { success: false, error: 'Nicht autorisiert.' };
}
const userId = user.id;
// Initialize Gemini with native schema validation
const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY);
const model = genAI.getGenerativeModel({
model: 'gemini-2.5-flash',
generationConfig: {
responseMimeType: "application/json",
responseSchema: enrichmentSchema as any,
temperature: 0.3,
},
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 instruction = `Identify the sensory profile for the following whisky.
Whisky: ${name} (${distillery})
Language: ${language}
Available system tags (pick relevant ones): ${tagsToUse}
Instructions:
1. Select the most appropriate sensory tags from the "Available system tags" list.
2. If there are dominant notes that are NOT in the system list, add them to "suggested_custom_tags".
3. Provide a clean "search_string" for Whiskybase (e.g., "Distillery Name Age").`;
const startApi = performance.now();
const result = await model.generateContent(instruction);
const endApi = performance.now();
const responseText = result.response.text();
console.log('[EnrichData] Raw Response:', responseText);
const jsonData = JSON.parse(responseText);
// Track usage
await trackApiUsage({
userId: userId,
apiType: 'gemini_ai',
endpoint: 'enrichData',
success: true
});
await deductCredits(userId, 'gemini_ai', 'Data enrichment');
return {
success: true,
data: jsonData,
perf: {
apiDuration: endApi - startApi
}
};
} catch (error) {
console.error('Enrich Data Error:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Fehler bei der Daten-Anreicherung.',
};
}
}

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

View File

@@ -9,12 +9,12 @@ export async function POST(req: Request) {
const supabase = await createClient();
// Check session
const { data: { session } } = await supabase.auth.getSession();
if (!session) {
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 });
}
const userId = session.user.id;
const userId = user.id;
const formData = await req.formData();
const file = formData.get('file') as File;

View File

@@ -44,7 +44,7 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="de">
<html lang="de" suppressHydrationWarning={true}>
<body className={`${inter.variable} font-sans`}>
<I18nProvider>
<SessionProvider>

View File

@@ -120,7 +120,6 @@ export default function Home() {
.order('created_at', { ascending: false });
if (error) {
console.error('Supabase fetch error:', error);
throw error;
}
@@ -143,8 +142,20 @@ export default function Home() {
setBottles(processedBottles);
} catch (err: any) {
console.error('Detailed fetch error:', err);
setFetchError(err.message || JSON.stringify(err));
// Silently skip if offline
const isNetworkError = !navigator.onLine ||
err.message?.includes('Failed to fetch') ||
err.message?.includes('NetworkError') ||
err.message?.includes('ERR_INTERNET_DISCONNECTED') ||
(err && Object.keys(err).length === 0); // Empty error object from Supabase when offline
if (isNetworkError) {
console.log('[fetchCollection] Skipping due to offline mode');
setFetchError(null);
} else {
console.error('Detailed fetch error:', err);
setFetchError(err.message || JSON.stringify(err));
}
} finally {
setIsLoading(false);
}
@@ -271,6 +282,7 @@ export default function Home() {
isOpen={isFlowOpen}
onClose={() => setIsFlowOpen(false)}
imageFile={capturedFile}
onBottleSaved={() => fetchCollection()}
/>
</main>
);