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

@@ -36,12 +36,12 @@ export async function analyzeBottleMistral(input: any): Promise<AnalysisResponse
// 2. Auth & Credits
supabase = await createClient();
const { data: { session } } = await supabase.auth.getSession();
if (!session || !session.user) {
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return { success: false, error: 'Nicht autorisiert oder Session abgelaufen.' };
}
const userId = session.user.id;
const userId = user.id;
const creditCheck = await checkCreditBalance(userId, 'gemini_ai');
if (!creditCheck.allowed) {
return {
@@ -85,98 +85,98 @@ export async function analyzeBottleMistral(input: any): Promise<AnalysisResponse
const prompt = getSystemPrompt(tags.length > 0 ? tags.join(', ') : 'Keine Tags verfügbar', locale);
const startApi = performance.now();
const chatResponse = await client.chat.complete({
model: 'mistral-large-latest',
messages: [
{
role: 'user',
content: [
{ type: 'text', text: prompt },
{ type: 'image_url', imageUrl: dataUrl }
]
}
],
responseFormat: { type: 'json_object' },
temperature: 0.1
});
const endApi = performance.now();
const startParse = performance.now();
const rawContent = chatResponse.choices?.[0].message.content;
if (!rawContent) throw new Error("Keine Antwort von Mistral");
let jsonData;
try {
jsonData = JSON.parse(rawContent as string);
} catch (e) {
const cleanedText = (rawContent as string).replace(/```json/g, '').replace(/```/g, '');
jsonData = JSON.parse(cleanedText);
const startApi = performance.now();
const chatResponse = await client.chat.complete({
model: 'mistral-large-latest',
messages: [
{
role: 'user',
content: [
{ type: 'text', text: prompt },
{ type: 'image_url', imageUrl: dataUrl }
]
}
],
responseFormat: { type: 'json_object' },
temperature: 0.1
});
const endApi = performance.now();
const startParse = performance.now();
const rawContent = chatResponse.choices?.[0].message.content;
if (!rawContent) throw new Error("Keine Antwort von Mistral");
let jsonData;
try {
jsonData = JSON.parse(rawContent as string);
} catch (e) {
const cleanedText = (rawContent as string).replace(/```json/g, '').replace(/```/g, '');
jsonData = JSON.parse(cleanedText);
}
if (Array.isArray(jsonData)) jsonData = jsonData[0];
console.log('[Mistral AI] JSON Response:', jsonData);
const searchString = jsonData.search_string;
delete jsonData.search_string;
if (typeof jsonData.abv === 'string') {
jsonData.abv = parseFloat(jsonData.abv.replace('%', '').trim());
}
if (jsonData.age) jsonData.age = parseInt(jsonData.age);
if (jsonData.vintage) jsonData.vintage = parseInt(jsonData.vintage);
const validatedData = BottleMetadataSchema.parse(jsonData);
const endParse = performance.now();
await trackApiUsage({
userId: userId,
apiType: 'gemini_ai',
endpoint: 'mistral/mistral-large',
success: true
});
await deductCredits(userId, 'gemini_ai', 'Mistral AI analysis');
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
};
} catch (aiError: any) {
console.warn('[MistralAnalysis] AI Analysis failed, providing fallback path:', aiError.message);
await trackApiUsage({
userId: userId,
apiType: 'gemini_ai',
endpoint: 'mistral/mistral-large',
success: false,
errorMessage: aiError.message
});
return {
success: false,
isAiError: true,
error: aiError.message,
imageHash: imageHash
} as any;
}
if (Array.isArray(jsonData)) jsonData = jsonData[0];
console.log('[Mistral AI] JSON Response:', jsonData);
// Extract search_string before validation
const searchString = jsonData.search_string;
delete jsonData.search_string;
// Ensure abv is a number if it came as a string
if (typeof jsonData.abv === 'string') {
jsonData.abv = parseFloat(jsonData.abv.replace('%', '').trim());
}
// Ensure age/vintage are numbers
if (jsonData.age) jsonData.age = parseInt(jsonData.age);
if (jsonData.vintage) jsonData.vintage = parseInt(jsonData.vintage);
const validatedData = BottleMetadataSchema.parse(jsonData);
const endParse = performance.now();
// Track usage
await trackApiUsage({
userId: userId,
apiType: 'gemini_ai',
endpoint: 'mistral/mistral-large',
success: true
});
// Deduct credits
await deductCredits(userId, 'gemini_ai', 'Mistral AI analysis');
// Store in Cache
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
};
} catch (error) {
console.error('Mistral Analysis Error:', error);
if (supabase) {
const { data: { session } } = await supabase.auth.getSession();
if (session?.user) {
await trackApiUsage({
userId: session.user.id,
apiType: 'gemini_ai',
endpoint: 'mistral/mistral-large',
success: false,
errorMessage: error instanceof Error ? error.message : 'Unknown error'
});
}
}
console.error('Mistral Analysis Global Error:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Mistral AI analysis failed.',

View File

@@ -37,13 +37,13 @@ export async function analyzeBottle(input: any): Promise<AnalysisResponse> {
// 2. Auth & Credits (bleibt gleich)
supabase = await createClient();
const { data: { session } } = await supabase.auth.getSession();
const { data: { user } } = await supabase.auth.getUser();
if (!session || !session.user) {
if (!user) {
return { success: false, error: 'Nicht autorisiert oder Session abgelaufen.' };
}
const userId = session.user.id;
const userId = user.id;
const creditCheck = await checkCreditBalance(userId, 'gemini_ai');
if (!creditCheck.allowed) {
@@ -80,96 +80,96 @@ export async function analyzeBottle(input: any): Promise<AnalysisResponse> {
}
// 5. Für Gemini vorbereiten
// Wir müssen es hier zwar zu Base64 machen, aber Node.js (C++) macht das
// extrem effizient. Das Problem vorher war der JSON Parser von Next.js.
const base64Data = buffer.toString('base64');
const mimeType = file.type || 'image/webp'; // Fallback
const mimeType = file.type || 'image/webp';
const uploadSize = buffer.length;
const instruction = getSystemPrompt(tags.length > 0 ? tags.join(', ') : 'No tags available', locale);
// API Call
const startApi = performance.now();
const result = await geminiModel.generateContent([
{
inlineData: {
data: base64Data,
mimeType: mimeType,
},
},
{ text: instruction },
]);
const endApi = performance.now();
const startParse = performance.now();
const responseText = result.response.text();
// JSON Parsing der ANTWORT (das ist klein, das schafft der N100 locker)
let jsonData;
try {
jsonData = JSON.parse(responseText);
} catch (e) {
// Fallback falls Gemini Markdown ```json Blöcke schickt
const cleanedText = responseText.replace(/```json/g, '').replace(/```/g, '');
jsonData = JSON.parse(cleanedText);
// API Call
const startApi = performance.now();
const result = await geminiModel.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];
console.log('[Gemini AI] JSON Response:', jsonData);
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);
const endParse = performance.now();
// 6. Tracking & Credits
await trackApiUsage({
userId: userId,
apiType: 'gemini_ai',
endpoint: 'generateContent',
success: true
});
await deductCredits(userId, 'gemini_ai', 'Bottle analysis');
// Cache speichern
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] AI Analysis failed, providing fallback path:', aiError.message);
await trackApiUsage({
userId: userId,
apiType: 'gemini_ai',
endpoint: 'generateContent',
success: false,
errorMessage: aiError.message
});
return {
success: false,
isAiError: true,
error: aiError.message,
imageHash: imageHash
} as any;
}
if (Array.isArray(jsonData)) jsonData = jsonData[0];
console.log('[Gemini AI] JSON Response:', jsonData);
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);
const endParse = performance.now();
// 6. Tracking & Credits (bleibt gleich)
await trackApiUsage({
userId: userId,
apiType: 'gemini_ai',
endpoint: 'generateContent',
success: true
});
await deductCredits(userId, 'gemini_ai', 'Bottle analysis');
// Cache speichern
const { error: storeError } = await supabase
.from('vision_cache')
.insert({ hash: imageHash, result: validatedData });
if (storeError) console.warn(`[AI Cache] Storage failed: ${storeError.message}`);
return {
success: true,
data: validatedData,
search_string: searchString,
perf: {
apiDuration: endApi - startApi,
parseDuration: endParse - startParse,
uploadSize: uploadSize
},
raw: jsonData
} as any;
} catch (error) {
console.error('Gemini Analysis Error:', error);
// Error Tracking Logic (bleibt gleich)
if (supabase) {
const { data: { session } } = await supabase.auth.getSession();
if (session?.user) {
await trackApiUsage({
userId: session.user.id,
apiType: 'gemini_ai',
endpoint: 'generateContent',
success: false,
errorMessage: error instanceof Error ? error.message : 'Unknown error'
});
}
}
console.error('Gemini Analysis Global Error:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'An unknown error occurred during analysis.',

View File

@@ -8,12 +8,12 @@ export async function addBuddy(rawData: BuddyData) {
try {
const { name } = BuddySchema.parse(rawData);
const { data: { session } } = await supabase.auth.getSession();
if (!session) throw new Error('Nicht autorisiert');
const { data: { user } } = await supabase.auth.getUser();
if (!user) throw new Error('Nicht autorisiert');
const { data, error } = await supabase
.from('buddies')
.insert([{ name, user_id: session.user.id }])
.insert([{ name, user_id: user.id }])
.select()
.single();
@@ -32,14 +32,14 @@ export async function deleteBuddy(id: string) {
const supabase = await createClient();
try {
const { data: { session } } = await supabase.auth.getSession();
if (!session) throw new Error('Nicht autorisiert');
const { data: { user } } = await supabase.auth.getUser();
if (!user) throw new Error('Nicht autorisiert');
const { error } = await supabase
.from('buddies')
.delete()
.eq('id', id)
.eq('user_id', session.user.id);
.eq('user_id', user.id);
if (error) throw error;
return { success: true };

View File

@@ -7,8 +7,8 @@ export async function deleteBottle(bottleId: string) {
const supabase = await createClient();
try {
const { data: { session } } = await supabase.auth.getSession();
if (!session) {
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
throw new Error('Nicht autorisiert.');
}
@@ -23,7 +23,7 @@ export async function deleteBottle(bottleId: string) {
throw new Error('Flasche nicht gefunden.');
}
if (bottle.user_id !== session.user.id) {
if (bottle.user_id !== user.id) {
throw new Error('Keine Berechtigung.');
}

View File

@@ -7,8 +7,8 @@ export async function deleteSession(sessionId: string) {
const supabase = await createClient();
try {
const { data: { session } } = await supabase.auth.getSession();
if (!session) {
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
throw new Error('Nicht autorisiert.');
}
@@ -16,7 +16,7 @@ export async function deleteSession(sessionId: string) {
.from('tasting_sessions')
.delete()
.eq('id', sessionId)
.eq('user_id', session.user.id);
.eq('user_id', user.id);
if (deleteError) throw deleteError;

View File

@@ -7,8 +7,8 @@ export async function deleteTasting(tastingId: string, bottleId: string) {
const supabase = await createClient();
try {
const { data: { session } } = await supabase.auth.getSession();
if (!session) {
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
throw new Error('Nicht autorisiert.');
}
@@ -16,7 +16,7 @@ export async function deleteTasting(tastingId: string, bottleId: string) {
.from('tastings')
.delete()
.eq('id', tastingId)
.eq('user_id', session.user.id);
.eq('user_id', user.id);
if (deleteError) throw deleteError;

View File

@@ -7,10 +7,10 @@ export async function findMatchingBottle(metadata: BottleMetadata) {
const supabase = await createClient();
try {
const { data: { session } } = await supabase.auth.getSession();
if (!session) return null;
const { data: { user } } = await supabase.auth.getUser();
if (!user) return null;
const userId = session.user.id;
const userId = user.id;
// 1. Try matching by Whiskybase ID (most reliable)
if (metadata.whiskybaseId) {

View File

@@ -14,12 +14,12 @@ export async function saveBottle(
try {
const metadata = BottleMetadataSchema.parse(rawMetadata);
const { data: { session } } = await supabase.auth.getSession();
if (!session) {
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
throw new Error('Nicht autorisiert oder Session abgelaufen.');
}
const userId = session.user.id;
const userId = user.id;
let finalImageUrl = preUploadedUrl;
// 1. Upload Image to Storage if not already uploaded
@@ -50,6 +50,26 @@ export async function saveBottle(
throw new Error('Kein Bild zum Speichern vorhanden.');
}
// 1.5 Deduplication Check
// If a bottle with the same name/distillery was created by the same user in the last 5 minutes,
// we treat it as a duplicate (likely from a race condition or double sync).
const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000).toISOString();
const { data: existingBottle } = await supabase
.from('bottles')
.select('*')
.eq('user_id', userId)
.eq('name', metadata.name)
.eq('distillery', metadata.distillery)
.gte('created_at', fiveMinutesAgo)
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle();
if (existingBottle) {
console.log('[saveBottle] Potential duplicate detected, returning existing bottle:', existingBottle.id);
return { success: true, data: existingBottle };
}
// 2. Save Metadata to Database
const { data: bottleData, error: dbError } = await supabase
.from('bottles')
@@ -64,7 +84,7 @@ export async function saveBottle(
image_url: finalImageUrl,
status: 'sealed',
is_whisky: metadata.is_whisky ?? true,
confidence: metadata.confidence ?? 100,
confidence: metadata.confidence ? Math.round(metadata.confidence * 100) : 100,
distilled_at: metadata.distilled_at,
bottled_at: metadata.bottled_at,
batch_info: metadata.batch_info,

View File

@@ -11,8 +11,8 @@ export async function saveTasting(rawData: TastingNoteData) {
try {
const data = TastingNoteSchema.parse(rawData);
const { data: { session } } = await supabase.auth.getSession();
if (!session) throw new Error('Nicht autorisiert');
const { data: { user } } = await supabase.auth.getUser();
if (!user) throw new Error('Nicht autorisiert');
// Validate Session Age (12 hour limit)
if (data.session_id) {
@@ -26,7 +26,7 @@ export async function saveTasting(rawData: TastingNoteData) {
.from('tastings')
.insert({
bottle_id: data.bottle_id,
user_id: session.user.id,
user_id: user.id,
session_id: data.session_id,
rating: data.rating,
nose_notes: data.nose_notes,
@@ -46,7 +46,7 @@ export async function saveTasting(rawData: TastingNoteData) {
const buddies = data.buddy_ids.map(buddyId => ({
tasting_id: tasting.id,
buddy_id: buddyId,
user_id: session.user.id
user_id: user.id
}));
const { error: tagError } = await supabase
.from('tasting_buddies')
@@ -64,7 +64,7 @@ export async function saveTasting(rawData: TastingNoteData) {
const aromaTags = data.tag_ids.map(tagId => ({
tasting_id: tasting.id,
tag_id: tagId,
user_id: session.user.id
user_id: user.id
}));
const { error: aromaTagError } = await supabase
.from('tasting_tags')

View File

@@ -74,8 +74,8 @@ export async function createCustomTag(rawName: string, rawCategory: TagCategory)
try {
const { name, category } = TagSchema.parse({ name: rawName, category: rawCategory });
const { data: { session } } = await supabase.auth.getSession();
if (!session) throw new Error('Nicht autorisiert');
const { data: { user } } = await supabase.auth.getUser();
if (!user) throw new Error('Nicht autorisiert');
const { data, error } = await supabase
.from('tags')
@@ -83,7 +83,7 @@ export async function createCustomTag(rawName: string, rawCategory: TagCategory)
name,
category,
is_system_default: false,
created_by: session.user.id
created_by: user.id
})
.select()
.single();

View File

@@ -7,8 +7,8 @@ export async function updateBottleStatus(bottleId: string, status: 'sealed' | 'o
const supabase = await createClient();
try {
const { data: { session } } = await supabase.auth.getSession();
if (!session) {
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
throw new Error('Nicht autorisiert');
}
@@ -20,7 +20,7 @@ export async function updateBottleStatus(bottleId: string, status: 'sealed' | 'o
finished_at: status === 'empty' ? new Date().toISOString() : null
})
.eq('id', bottleId)
.eq('user_id', session.user.id);
.eq('user_id', user.id);
if (error) {
throw error;

View File

@@ -10,8 +10,8 @@ export async function updateBottle(bottleId: string, rawData: UpdateBottleData)
try {
const data = UpdateBottleSchema.parse(rawData);
const { data: { session } } = await supabase.auth.getSession();
if (!session) throw new Error('Nicht autorisiert');
const { data: { user } } = await supabase.auth.getUser();
if (!user) throw new Error('Nicht autorisiert');
const { error } = await supabase
.from('bottles')
@@ -29,7 +29,7 @@ export async function updateBottle(bottleId: string, rawData: UpdateBottleData)
updated_at: new Date().toISOString(),
})
.eq('id', bottleId)
.eq('user_id', session.user.id);
.eq('user_id', user.id);
if (error) throw error;