feat: restore scan quality, implement standardized naming, and add cask_type integration
This commit is contained in:
330
src/app/actions/scanner.ts
Normal file
330
src/app/actions/scanner.ts
Normal file
@@ -0,0 +1,330 @@
|
||||
'use server';
|
||||
|
||||
import { GoogleGenerativeAI, SchemaType, HarmCategory, HarmBlockThreshold } from '@google/generative-ai';
|
||||
import { BottleMetadataSchema, BottleMetadata } from '@/types/whisky';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { trackApiUsage } from '@/services/track-api-usage';
|
||||
import { checkCreditBalance, deductCredits } from '@/services/credit-service';
|
||||
import { getAIProvider, getOpenRouterClient, OPENROUTER_VISION_MODEL, OPENROUTER_PROVIDER_PREFERENCES } from '@/lib/openrouter';
|
||||
import { normalizeWhiskyData } from '@/lib/distillery-matcher';
|
||||
import { formatWhiskyName } from '@/utils/formatWhiskyName';
|
||||
import { createHash } from 'crypto';
|
||||
import sharp from 'sharp';
|
||||
|
||||
// Schema for AI extraction
|
||||
const visionSchema = {
|
||||
description: "Whisky bottle label metadata extracted from image",
|
||||
type: SchemaType.OBJECT as const,
|
||||
properties: {
|
||||
name: { type: SchemaType.STRING, description: "Full whisky name (constructed)", nullable: false },
|
||||
distillery: { type: SchemaType.STRING, description: "Distillery name", nullable: true },
|
||||
bottler: { type: SchemaType.STRING, description: "Independent bottler if applicable", nullable: true },
|
||||
series: { type: SchemaType.STRING, description: "Whisky series or collection (e.g. Cadenhead's Natural Strength)", nullable: true },
|
||||
category: { type: SchemaType.STRING, description: "Whisky category (Single Malt, Blended, Bourbon, etc.)", 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/distillation year", nullable: true },
|
||||
cask_type: { type: SchemaType.STRING, description: "Cask type (Sherry, Bourbon, Port, etc.)", 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 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"],
|
||||
};
|
||||
|
||||
const VISION_PROMPT = `ROLE: Senior Whisky Database Curator.
|
||||
|
||||
OBJECTIVE: Extract metadata from the bottle image.
|
||||
CRITICAL: You must CONSTRUCT the product name from its parts, not just read lines.
|
||||
|
||||
INPUT IMAGE ANALYSIS (Mental Steps):
|
||||
1. FIND DISTILLERY: Look for the most prominent brand name (e.g. "Annandale").
|
||||
2. FIND AGE: Scan the center for "Aged X Years" or large numbers. (In this image: Look for a large "10" in the center).
|
||||
3. FIND VINTAGE: Look for small script/cursive text like "Distilled 2011".
|
||||
4. FIND CASK TYPE: Look for specific maturation terms like 'Sherry', 'Bourbon', 'Port', 'Oloroso', 'PX', 'Madeira', 'Rum', 'Hogshead', 'Butt', 'Barrel', or 'Finish'. Extract ONLY this phrase into this field (e.g., 'Oloroso Cask Matured') cask_type. Do not leave null if these words are visible.
|
||||
5. FIND SERIES: Look at the top logo (e.g. "Cadenhead's Natural Strength").
|
||||
|
||||
COMPOSITION RULES (How to fill the 'name' field):
|
||||
- DO NOT just write "Single Malt".
|
||||
- Format: "[Age/Vintage] [Series] [Cask Info]"
|
||||
- Example: "10 Year Old Cadenhead's Natural Strength Oloroso Matured"
|
||||
|
||||
OUTPUT SCHEMA (Strict JSON):
|
||||
{
|
||||
"name": "string (The constructed full name based on rules above)",
|
||||
"distillery": "string",
|
||||
"bottler": "string",
|
||||
"series": "string (e.g. Natural Strength)",
|
||||
"abv": number,
|
||||
"age": numberOrNull,
|
||||
"vintage": "stringOrNull",
|
||||
"cask_type": "stringOrNull",
|
||||
"distilled_at": "stringOrNull",
|
||||
"bottled_at": "stringOrNull",
|
||||
"batch_info": "stringOrNull",
|
||||
"is_whisky": true,
|
||||
"confidence": number
|
||||
}`;
|
||||
|
||||
const GEMINI_MODEL = 'gemini-2.5-flash';
|
||||
|
||||
export interface ScannerResult {
|
||||
success: boolean;
|
||||
data?: BottleMetadata;
|
||||
error?: string;
|
||||
provider?: 'gemini' | 'openrouter';
|
||||
perf?: {
|
||||
imagePrep?: number;
|
||||
apiCall: number;
|
||||
total: number;
|
||||
cacheHit?: boolean;
|
||||
apiDuration?: number;
|
||||
parsing?: number;
|
||||
parseDuration?: number;
|
||||
uploadSize?: number;
|
||||
cacheCheck?: number;
|
||||
encoding?: number;
|
||||
modelInit?: number;
|
||||
validation?: number;
|
||||
dbOps?: number;
|
||||
};
|
||||
raw?: any;
|
||||
search_string?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compatibility wrapper for older call sites that use an object/FormData input.
|
||||
*/
|
||||
export async function scanLabel(input: any): Promise<ScannerResult> {
|
||||
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');
|
||||
if (file && file instanceof File) {
|
||||
// Handle file input (e.g. from FormData)
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const base64 = Buffer.from(arrayBuffer).toString('base64');
|
||||
return analyzeBottleLabel(`data:${file.type};base64,${base64}`);
|
||||
}
|
||||
|
||||
if (typeof input === 'string') {
|
||||
return analyzeBottleLabel(input);
|
||||
}
|
||||
|
||||
return { success: false, error: 'Ungültiges Eingabeformat.' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified action for analyzing a whisky bottle label.
|
||||
* Replaces redundant gemini-vision.ts, scan-label.ts, and analyze-bottle.ts.
|
||||
*/
|
||||
export async function analyzeBottleLabel(imageBase64: string): Promise<ScannerResult> {
|
||||
const startTotal = performance.now();
|
||||
const provider = getAIProvider();
|
||||
|
||||
if (!imageBase64 || imageBase64.length < 100) {
|
||||
return { success: false, error: 'Ungültige Bilddaten.' };
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Auth & Credit Check
|
||||
const supabase = await createClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) return { success: false, error: 'Nicht autorisiert.' };
|
||||
|
||||
const creditCheck = await checkCreditBalance(user.id, 'gemini_ai');
|
||||
if (!creditCheck.allowed) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Nicht genügend Credits. Benötigt: ${creditCheck.cost}, Verfügbar: ${creditCheck.balance}.`
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Extract base64 and hash for caching
|
||||
let base64Data = imageBase64;
|
||||
let mimeType = 'image/webp';
|
||||
if (imageBase64.startsWith('data:')) {
|
||||
const matches = imageBase64.match(/^data:([^;]+);base64,(.+)$/);
|
||||
if (matches) {
|
||||
mimeType = matches[1];
|
||||
base64Data = matches[2];
|
||||
}
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(base64Data, 'base64');
|
||||
const imageHash = createHash('sha256').update(buffer).digest('hex');
|
||||
|
||||
// 3. Cache Check
|
||||
const { data: cachedResult } = await supabase
|
||||
.from('vision_cache')
|
||||
.select('result')
|
||||
.eq('hash', imageHash)
|
||||
.maybeSingle();
|
||||
|
||||
if (cachedResult) {
|
||||
console.log(`[Scanner] Cache HIT for hash: ${imageHash.slice(0, 8)}...`);
|
||||
return {
|
||||
success: true,
|
||||
data: cachedResult.result as BottleMetadata,
|
||||
perf: {
|
||||
apiCall: 0,
|
||||
total: performance.now() - startTotal,
|
||||
cacheHit: true
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// 4. AI Analysis with retry logic for OpenRouter
|
||||
console.log(`[Scanner] Using provider: ${provider}`);
|
||||
let aiResult: { data: any; apiTime: number; responseText: string };
|
||||
|
||||
if (provider === 'openrouter') {
|
||||
const client = getOpenRouterClient();
|
||||
const startApi = performance.now();
|
||||
const maxRetries = 3;
|
||||
let lastError: any = null;
|
||||
let response: any = null;
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
response = await client.chat.completions.create({
|
||||
model: OPENROUTER_VISION_MODEL,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'image_url', image_url: { url: `data:${mimeType};base64,${base64Data}` } },
|
||||
{ type: 'text', text: VISION_PROMPT },
|
||||
],
|
||||
},
|
||||
],
|
||||
temperature: 0.1,
|
||||
max_tokens: 1024,
|
||||
// @ts-ignore
|
||||
provider: OPENROUTER_PROVIDER_PREFERENCES,
|
||||
});
|
||||
break; // Success!
|
||||
} catch (err: any) {
|
||||
lastError = err;
|
||||
if (err.status === 429 && attempt < maxRetries) {
|
||||
const delay = Math.pow(2, attempt) * 1000;
|
||||
console.warn(`[Scanner] Rate limited (429). Retrying in ${delay}ms...`);
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
if (!response) throw lastError || new Error('OpenRouter response failed after retries');
|
||||
|
||||
const content = response.choices[0]?.message?.content || '{}';
|
||||
let jsonStr = content;
|
||||
const jsonMatch = content.match(/```(?:json)?\s*([\s\S]*?)```/) || content.match(/\{[\s\S]*\}/);
|
||||
if (jsonMatch) {
|
||||
jsonStr = jsonMatch[jsonMatch.length - 1].trim();
|
||||
}
|
||||
aiResult = {
|
||||
data: JSON.parse(jsonStr),
|
||||
apiTime: performance.now() - startApi,
|
||||
responseText: content
|
||||
};
|
||||
} else {
|
||||
const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY!);
|
||||
const model = genAI.getGenerativeModel({
|
||||
model: GEMINI_MODEL,
|
||||
generationConfig: {
|
||||
responseMimeType: "application/json",
|
||||
responseSchema: visionSchema 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 startApi = performance.now();
|
||||
const result = await model.generateContent([
|
||||
{ inlineData: { data: base64Data, mimeType } },
|
||||
{ text: VISION_PROMPT },
|
||||
]);
|
||||
const responseText = result.response.text();
|
||||
aiResult = {
|
||||
data: JSON.parse(responseText),
|
||||
apiTime: performance.now() - startApi,
|
||||
responseText: responseText
|
||||
};
|
||||
}
|
||||
|
||||
// 6. Name Composition & Normalization
|
||||
// Use standardized helper to construct the perfect name
|
||||
console.log(`[Uncleaned Data]: ${JSON.stringify(aiResult.data)}`);
|
||||
|
||||
const d = aiResult.data;
|
||||
const constructedName = formatWhiskyName({
|
||||
distillery: d.distillery || '',
|
||||
bottler: d.bottler,
|
||||
series: d.series,
|
||||
age: d.age,
|
||||
vintage: d.vintage,
|
||||
cask_type: d.cask_type
|
||||
}) || d.name;
|
||||
|
||||
// Validation & Normalization
|
||||
const validatedData = BottleMetadataSchema.parse({
|
||||
...d,
|
||||
name: constructedName
|
||||
});
|
||||
|
||||
const normalized = normalizeWhiskyData(
|
||||
validatedData.name || '',
|
||||
validatedData.distillery || ''
|
||||
);
|
||||
|
||||
const finalData = {
|
||||
...validatedData,
|
||||
name: normalized.name || validatedData.name,
|
||||
distillery: normalized.distillery || validatedData.distillery,
|
||||
};
|
||||
|
||||
// 7. Success Tracking & Caching
|
||||
await trackApiUsage({
|
||||
userId: user.id,
|
||||
apiType: 'gemini_ai',
|
||||
endpoint: `analyzeBottleLabel_${provider}`,
|
||||
success: true,
|
||||
provider,
|
||||
model: provider === 'openrouter' ? OPENROUTER_VISION_MODEL : GEMINI_MODEL,
|
||||
responseText: aiResult.responseText
|
||||
});
|
||||
await deductCredits(user.id, 'gemini_ai', `Scanner analysis (${provider})`);
|
||||
|
||||
await supabase.from('vision_cache').insert({ hash: imageHash, result: finalData });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: finalData,
|
||||
provider,
|
||||
perf: {
|
||||
apiCall: aiResult.apiTime,
|
||||
apiDuration: aiResult.apiTime, // Compatibility
|
||||
total: performance.now() - startTotal,
|
||||
cacheHit: false
|
||||
}
|
||||
};
|
||||
|
||||
} catch (error: any) {
|
||||
console.error(`[Scanner] Analysis failed:`, error);
|
||||
return {
|
||||
success: false,
|
||||
error: error.message || 'Vision analysis failed.',
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user