feat: improve AI resilience, add background enrichment loading states, and fix duplicate identifier in TagSelector
This commit is contained in:
117
src/app/actions/enrich-data.ts
Normal file
117
src/app/actions/enrich-data.ts
Normal 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.',
|
||||
};
|
||||
}
|
||||
}
|
||||
207
src/app/actions/scan-label.ts
Normal file
207
src/app/actions/scan-label.ts
Normal 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.',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user