152 lines
5.4 KiB
TypeScript
152 lines
5.4 KiB
TypeScript
'use server';
|
|
|
|
import { Mistral } from '@mistralai/mistralai';
|
|
import { BottleMetadataSchema, AnalysisResponse, BottleMetadata } from '@/types/whisky';
|
|
import { createClient } from '@/lib/supabase/server';
|
|
import { createHash } from 'crypto';
|
|
import { trackApiUsage } from './track-api-usage';
|
|
import { checkCreditBalance, deductCredits } from './credit-service';
|
|
|
|
export async function analyzeBottlePixtral(base64Image: string, tags?: string[], locale: string = 'de'): Promise<AnalysisResponse & { search_string?: string }> {
|
|
if (!process.env.MISTRAL_API_KEY) {
|
|
return { success: false, error: 'MISTRAL_API_KEY is not configured.' };
|
|
}
|
|
|
|
let supabase;
|
|
try {
|
|
supabase = await createClient();
|
|
const { data: { session } } = await supabase.auth.getSession();
|
|
if (!session || !session.user) {
|
|
return { success: false, error: 'Nicht autorisiert oder Session abgelaufen.' };
|
|
}
|
|
|
|
const userId = session.user.id;
|
|
|
|
const creditCheck = await checkCreditBalance(userId, 'gemini_ai');
|
|
if (!creditCheck.allowed) {
|
|
return {
|
|
success: false,
|
|
error: `Nicht genügend Credits. Du benötigst ${creditCheck.cost} Credits, hast aber nur ${creditCheck.balance}.`
|
|
};
|
|
}
|
|
|
|
const base64Data = base64Image.split(',')[1] || base64Image;
|
|
const imageHash = createHash('sha256').update(base64Data).digest('hex');
|
|
|
|
const { data: cachedResult } = await supabase
|
|
.from('vision_cache')
|
|
.select('result')
|
|
.eq('hash', imageHash)
|
|
.maybeSingle();
|
|
|
|
if (cachedResult) {
|
|
return {
|
|
success: true,
|
|
data: cachedResult.result as any,
|
|
};
|
|
}
|
|
|
|
const client = new Mistral({ apiKey: process.env.MISTRAL_API_KEY });
|
|
const dataUrl = `data:image/jpeg;base64,${base64Data}`;
|
|
|
|
const prompt = `Du bist ein Whisky-Experte und OCR-Spezialist.
|
|
Analysiere dieses Etikett präzise.
|
|
Sprache für Beschreibungen: ${locale === 'en' ? 'Englisch' : 'Deutsch'}.
|
|
Verfügbare Tags zur Einordnung: ${tags ? tags.join(', ') : 'Keine Tags verfügbar'}.
|
|
|
|
Antworte AUSSCHLIESSLICH mit gültigem JSON (kein Markdown, kein Text davor/danach):
|
|
{
|
|
"distillery": "Name der Brennerei (z.B. Lagavulin)",
|
|
"name": "Exakter Name/Edition (z.B. 16 Year Old)",
|
|
"vintage": "Jahrgang als Zahl oder null",
|
|
"age": "Alter als Zahl oder null (z.B. 16)",
|
|
"abv": "Alkoholgehalt als Zahl ohne % (z.B. 43)",
|
|
"category": "Kategorie (z.B. Single Malt Scotch Whisky)",
|
|
"search_string": "site:whiskybase.com [Brennerei] [Name] [Alter]"
|
|
}`;
|
|
|
|
const chatResponse = await client.chat.complete({
|
|
model: 'pixtral-large-latest',
|
|
messages: [
|
|
{
|
|
role: 'user',
|
|
content: [
|
|
{ type: 'text', text: prompt },
|
|
{ type: 'image_url', imageUrl: dataUrl }
|
|
]
|
|
}
|
|
],
|
|
responseFormat: { type: 'json_object' },
|
|
temperature: 0.1
|
|
});
|
|
|
|
const rawContent = chatResponse.choices?.[0].message.content;
|
|
if (!rawContent) throw new Error("Keine Antwort von Pixtral");
|
|
|
|
const jsonData = JSON.parse(rawContent as string);
|
|
|
|
// 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);
|
|
|
|
// Track usage
|
|
await trackApiUsage({
|
|
userId: userId,
|
|
apiType: 'gemini_ai', // Keep as generic 'gemini_ai' for now or update schema later
|
|
endpoint: 'mistral/pixtral-large',
|
|
success: true
|
|
});
|
|
|
|
// Deduct credits
|
|
await deductCredits(userId, 'gemini_ai', 'Pixtral AI analysis');
|
|
|
|
// Store in Cache
|
|
await supabase
|
|
.from('vision_cache')
|
|
.insert({ hash: imageHash, result: validatedData });
|
|
|
|
return {
|
|
success: true,
|
|
data: validatedData,
|
|
search_string: searchString
|
|
};
|
|
|
|
} catch (error) {
|
|
console.error('Pixtral Analysis Error:', error);
|
|
|
|
// Track failed API call
|
|
try {
|
|
if (supabase) {
|
|
const { data: { session } } = await supabase.auth.getSession();
|
|
if (session?.user) {
|
|
await trackApiUsage({
|
|
userId: session.user.id,
|
|
apiType: 'gemini_ai',
|
|
endpoint: 'mistral/pixtral-large',
|
|
success: false,
|
|
errorMessage: error instanceof Error ? error.message : 'Unknown error'
|
|
});
|
|
}
|
|
}
|
|
} catch (trackError) {
|
|
console.error('Failed to track error:', trackError);
|
|
}
|
|
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : 'Pixtral AI analysis failed.',
|
|
};
|
|
}
|
|
}
|