feat: restore scan quality, implement standardized naming, and add cask_type integration
This commit is contained in:
15
.eslintrc.json
Normal file
15
.eslintrc.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"extends": [
|
||||||
|
"next/core-web-vitals"
|
||||||
|
],
|
||||||
|
"plugins": [
|
||||||
|
"security"
|
||||||
|
],
|
||||||
|
"rules": {
|
||||||
|
"security/detect-object-injection": "warn",
|
||||||
|
"security/detect-unsafe-regex": "warn",
|
||||||
|
"security/detect-eval-with-expression": "error",
|
||||||
|
"security/detect-non-literal-fs-filename": "warn",
|
||||||
|
"security/detect-child-process": "warn"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,6 +52,7 @@
|
|||||||
"autoprefixer": "^10.0.1",
|
"autoprefixer": "^10.0.1",
|
||||||
"eslint": "^8",
|
"eslint": "^8",
|
||||||
"eslint-config-next": "16.1.0",
|
"eslint-config-next": "16.1.0",
|
||||||
|
"eslint-plugin-security": "^2.1.1",
|
||||||
"jsdom": "^27.3.0",
|
"jsdom": "^27.3.0",
|
||||||
"postcss": "^8",
|
"postcss": "^8",
|
||||||
"tailwindcss": "^3.3.0",
|
"tailwindcss": "^3.3.0",
|
||||||
|
|||||||
23
pnpm-lock.yaml
generated
23
pnpm-lock.yaml
generated
@@ -123,6 +123,9 @@ importers:
|
|||||||
eslint-config-next:
|
eslint-config-next:
|
||||||
specifier: 16.1.0
|
specifier: 16.1.0
|
||||||
version: 16.1.0(@typescript-eslint/parser@8.50.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3)
|
version: 16.1.0(@typescript-eslint/parser@8.50.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3)
|
||||||
|
eslint-plugin-security:
|
||||||
|
specifier: ^2.1.1
|
||||||
|
version: 2.1.1
|
||||||
jsdom:
|
jsdom:
|
||||||
specifier: ^27.3.0
|
specifier: ^27.3.0
|
||||||
version: 27.3.0
|
version: 27.3.0
|
||||||
@@ -1729,6 +1732,9 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7
|
eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7
|
||||||
|
|
||||||
|
eslint-plugin-security@2.1.1:
|
||||||
|
resolution: {integrity: sha512-7cspIGj7WTfR3EhaILzAPcfCo5R9FbeWvbgsPYWivSurTBKW88VQxtP3c4aWMG9Hz/GfJlJVdXEJ3c8LqS+u2w==}
|
||||||
|
|
||||||
eslint-scope@7.2.2:
|
eslint-scope@7.2.2:
|
||||||
resolution: {integrity: sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==}
|
resolution: {integrity: sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==}
|
||||||
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
|
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
|
||||||
@@ -2629,6 +2635,10 @@ packages:
|
|||||||
regenerator-runtime@0.13.11:
|
regenerator-runtime@0.13.11:
|
||||||
resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==}
|
resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==}
|
||||||
|
|
||||||
|
regexp-tree@0.1.27:
|
||||||
|
resolution: {integrity: sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
regexp.prototype.flags@1.5.4:
|
regexp.prototype.flags@1.5.4:
|
||||||
resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==}
|
resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -2685,6 +2695,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==}
|
resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
safe-regex@2.1.1:
|
||||||
|
resolution: {integrity: sha512-rx+x8AMzKb5Q5lQ95Zoi6ZbJqwCLkqi3XuJXp5P3rT8OEc6sZCJG5AE5dU3lsgRr/F4Bs31jSlVN+j5KrsGu9A==}
|
||||||
|
|
||||||
safer-buffer@2.1.2:
|
safer-buffer@2.1.2:
|
||||||
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
|
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
|
||||||
|
|
||||||
@@ -4779,6 +4792,10 @@ snapshots:
|
|||||||
string.prototype.matchall: 4.0.12
|
string.prototype.matchall: 4.0.12
|
||||||
string.prototype.repeat: 1.0.0
|
string.prototype.repeat: 1.0.0
|
||||||
|
|
||||||
|
eslint-plugin-security@2.1.1:
|
||||||
|
dependencies:
|
||||||
|
safe-regex: 2.1.1
|
||||||
|
|
||||||
eslint-scope@7.2.2:
|
eslint-scope@7.2.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
esrecurse: 4.3.0
|
esrecurse: 4.3.0
|
||||||
@@ -5678,6 +5695,8 @@ snapshots:
|
|||||||
|
|
||||||
regenerator-runtime@0.13.11: {}
|
regenerator-runtime@0.13.11: {}
|
||||||
|
|
||||||
|
regexp-tree@0.1.27: {}
|
||||||
|
|
||||||
regexp.prototype.flags@1.5.4:
|
regexp.prototype.flags@1.5.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
call-bind: 1.0.8
|
call-bind: 1.0.8
|
||||||
@@ -5764,6 +5783,10 @@ snapshots:
|
|||||||
es-errors: 1.3.0
|
es-errors: 1.3.0
|
||||||
is-regex: 1.2.1
|
is-regex: 1.2.1
|
||||||
|
|
||||||
|
safe-regex@2.1.1:
|
||||||
|
dependencies:
|
||||||
|
regexp-tree: 0.1.27
|
||||||
|
|
||||||
safer-buffer@2.1.2: {}
|
safer-buffer@2.1.2: {}
|
||||||
|
|
||||||
saxes@6.0.0:
|
saxes@6.0.0:
|
||||||
|
|||||||
@@ -1,327 +0,0 @@
|
|||||||
'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';
|
|
||||||
|
|
||||||
// Schema for Gemini Vision 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", 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 (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"],
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface GeminiVisionResult {
|
|
||||||
success: boolean;
|
|
||||||
data?: BottleMetadata;
|
|
||||||
error?: string;
|
|
||||||
provider?: 'gemini' | 'openrouter';
|
|
||||||
perf?: {
|
|
||||||
apiCall: number;
|
|
||||||
total: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const VISION_PROMPT = `Analyze this whisky bottle label image and extract all visible metadata.
|
|
||||||
Look carefully for:
|
|
||||||
- Brand/Distillery name
|
|
||||||
- Bottle name or expression
|
|
||||||
- Age statement (e.g., "12 Years Old")
|
|
||||||
- ABV/Alcohol percentage
|
|
||||||
- Vintage year (if shown)
|
|
||||||
- Cask type (e.g., Sherry, Bourbon cask)
|
|
||||||
- Bottler name (if independent bottling)
|
|
||||||
- Category (Single Malt, Blended Malt, Bourbon, etc.)
|
|
||||||
|
|
||||||
Be precise and only include information you can clearly read from the label.
|
|
||||||
If you cannot read something clearly, leave it null.
|
|
||||||
|
|
||||||
Respond ONLY with valid JSON in this format:
|
|
||||||
{
|
|
||||||
"name": "Full whisky name",
|
|
||||||
"distillery": "Distillery name or null",
|
|
||||||
"bottler": "Bottler name or null",
|
|
||||||
"category": "Whisky category or null",
|
|
||||||
"abv": 46.0,
|
|
||||||
"age": 12,
|
|
||||||
"vintage": "2010 or null",
|
|
||||||
"cask_type": "Cask type or null",
|
|
||||||
"distilled_at": "Date or null",
|
|
||||||
"bottled_at": "Date or null",
|
|
||||||
"batch_info": "Batch info or null",
|
|
||||||
"is_whisky": true,
|
|
||||||
"confidence": 0.85
|
|
||||||
}`;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sleep helper for retry delays
|
|
||||||
*/
|
|
||||||
function sleep(ms: number): Promise<void> {
|
|
||||||
return new Promise(resolve => setTimeout(resolve, ms));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Analyze whisky label with OpenRouter (Gemma 3 27B)
|
|
||||||
* Includes retry logic for 429 rate limit errors
|
|
||||||
*/
|
|
||||||
async function analyzeWithOpenRouter(base64Data: string, mimeType: string): Promise<{ data: any; apiTime: number; responseText: string }> {
|
|
||||||
const client = getOpenRouterClient();
|
|
||||||
const startApi = performance.now();
|
|
||||||
const maxRetries = 3;
|
|
||||||
let lastError: any = null;
|
|
||||||
|
|
||||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
||||||
try {
|
|
||||||
const 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 - OpenRouter-specific field
|
|
||||||
provider: OPENROUTER_PROVIDER_PREFERENCES,
|
|
||||||
});
|
|
||||||
|
|
||||||
const endApi = performance.now();
|
|
||||||
const content = response.choices[0]?.message?.content || '{}';
|
|
||||||
|
|
||||||
// Extract JSON from response (may have markdown code blocks)
|
|
||||||
let jsonStr = content;
|
|
||||||
const jsonMatch = content.match(/```(?:json)?\s*([\s\S]*?)```/);
|
|
||||||
if (jsonMatch) {
|
|
||||||
jsonStr = jsonMatch[1].trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
data: JSON.parse(jsonStr),
|
|
||||||
apiTime: endApi - startApi,
|
|
||||||
responseText: content
|
|
||||||
};
|
|
||||||
|
|
||||||
} catch (error: any) {
|
|
||||||
lastError = error;
|
|
||||||
const status = error?.status || error?.response?.status;
|
|
||||||
|
|
||||||
// Only retry on 429 (rate limit) or 503 (service unavailable)
|
|
||||||
if (status === 429 || status === 503) {
|
|
||||||
const delay = Math.pow(2, attempt) * 1000; // 2s, 4s, 8s
|
|
||||||
console.log(`[OpenRouter] Rate limited (${status}), retry ${attempt}/${maxRetries} in ${delay}ms...`);
|
|
||||||
await sleep(delay);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Other errors - don't retry
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// All retries exhausted
|
|
||||||
throw lastError || new Error('OpenRouter request failed after retries');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Analyze whisky label with Gemini
|
|
||||||
*/
|
|
||||||
async function analyzeWithGemini(base64Data: string, mimeType: string): Promise<{ data: any; apiTime: number; responseText: string }> {
|
|
||||||
const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY!);
|
|
||||||
const model = genAI.getGenerativeModel({
|
|
||||||
model: 'gemini-2.5-flash',
|
|
||||||
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 endApi = performance.now();
|
|
||||||
|
|
||||||
const responseText = result.response.text();
|
|
||||||
return {
|
|
||||||
data: JSON.parse(responseText),
|
|
||||||
apiTime: endApi - startApi,
|
|
||||||
responseText: responseText
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Analyze a whisky bottle label image using configured AI provider
|
|
||||||
*
|
|
||||||
* Provider is controlled by AI_PROVIDER env variable:
|
|
||||||
* - "openrouter" (default) - Uses OpenRouter with Gemma 3 27B
|
|
||||||
* - "gemini" - Uses Google Gemini 2.5 Flash
|
|
||||||
*
|
|
||||||
* @param imageBase64 - Base64 encoded image (with data URL prefix)
|
|
||||||
* @returns GeminiVisionResult with extracted metadata
|
|
||||||
*/
|
|
||||||
export async function analyzeLabelWithGemini(imageBase64: string): Promise<GeminiVisionResult> {
|
|
||||||
const startTotal = performance.now();
|
|
||||||
const provider = getAIProvider();
|
|
||||||
|
|
||||||
// Check API key based on provider
|
|
||||||
if (provider === 'gemini' && !process.env.GEMINI_API_KEY) {
|
|
||||||
return { success: false, error: 'GEMINI_API_KEY is not configured.' };
|
|
||||||
}
|
|
||||||
if (provider === 'openrouter' && !process.env.OPENROUTER_API_KEY) {
|
|
||||||
return { success: false, error: 'OPENROUTER_API_KEY is not configured.' };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!imageBase64 || imageBase64.length < 100) {
|
|
||||||
return { success: false, error: 'Invalid image data provided.' };
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Auth check
|
|
||||||
const supabase = await createClient();
|
|
||||||
const { data: { user } } = await supabase.auth.getUser();
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return { success: false, error: 'Not authorized.' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Credit check (use same type for both providers)
|
|
||||||
const creditCheck = await checkCreditBalance(user.id, 'gemini_ai');
|
|
||||||
if (!creditCheck.allowed) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: `Insufficient credits. Required: ${creditCheck.cost}, Available: ${creditCheck.balance}.`
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract base64 data (remove data URL prefix if present)
|
|
||||||
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];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Call appropriate provider
|
|
||||||
console.log(`[Vision] Using provider: ${provider}`);
|
|
||||||
let result: { data: any; apiTime: number; responseText: string };
|
|
||||||
|
|
||||||
if (provider === 'openrouter') {
|
|
||||||
result = await analyzeWithOpenRouter(base64Data, mimeType);
|
|
||||||
} else {
|
|
||||||
result = await analyzeWithGemini(base64Data, mimeType);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate with Zod schema
|
|
||||||
const validatedData = BottleMetadataSchema.parse(result.data);
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// NORMALIZE DISTILLERY NAME
|
|
||||||
// ========================================
|
|
||||||
console.log(`[Vision] 🔍 RAW FROM AI: name="${validatedData.name}", distillery="${validatedData.distillery}"`);
|
|
||||||
|
|
||||||
const normalized = normalizeWhiskyData(
|
|
||||||
validatedData.name || '',
|
|
||||||
validatedData.distillery || ''
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log(`[Vision] ✅ AFTER FUSE: name="${normalized.name}", distillery="${normalized.distillery}", matched=${normalized.distilleryMatched}`);
|
|
||||||
const finalData = {
|
|
||||||
...validatedData,
|
|
||||||
name: normalized.name || validatedData.name,
|
|
||||||
distillery: normalized.distillery || validatedData.distillery,
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log(`[Vision] Normalized: distillery="${normalized.distillery}", name="${normalized.name}"`);
|
|
||||||
|
|
||||||
// Track usage and deduct credits
|
|
||||||
await trackApiUsage({
|
|
||||||
userId: user.id,
|
|
||||||
apiType: 'gemini_ai', // Keep same type for tracking
|
|
||||||
endpoint: `analyzeLabelWith${provider === 'openrouter' ? 'OpenRouter' : 'Gemini'}`,
|
|
||||||
success: true,
|
|
||||||
provider: provider,
|
|
||||||
model: provider === 'openrouter' ? OPENROUTER_VISION_MODEL : 'gemini-2.5-flash',
|
|
||||||
responseText: result.responseText
|
|
||||||
});
|
|
||||||
await deductCredits(user.id, 'gemini_ai', `Vision label analysis (${provider})`);
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
data: finalData,
|
|
||||||
provider,
|
|
||||||
perf: {
|
|
||||||
apiCall: result.apiTime,
|
|
||||||
total: performance.now() - startTotal,
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error(`[Vision] Analysis failed (${provider}):`, error);
|
|
||||||
|
|
||||||
// Try to track the failure
|
|
||||||
try {
|
|
||||||
const supabase = await createClient();
|
|
||||||
const { data: { user } } = await supabase.auth.getUser();
|
|
||||||
if (user) {
|
|
||||||
await trackApiUsage({
|
|
||||||
userId: user.id,
|
|
||||||
apiType: 'gemini_ai',
|
|
||||||
endpoint: `analyzeLabelWith${provider === 'openrouter' ? 'OpenRouter' : 'Gemini'}`,
|
|
||||||
success: false,
|
|
||||||
errorMessage: error.message
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (trackError) {
|
|
||||||
console.warn('[Vision] Failed to track error:', trackError);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: error.message || 'Vision analysis failed.',
|
|
||||||
provider,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,308 +0,0 @@
|
|||||||
'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';
|
|
||||||
import { getAIProvider, getOpenRouterClient, OPENROUTER_PROVIDER_PREFERENCES } from '@/lib/openrouter';
|
|
||||||
import sharp from 'sharp';
|
|
||||||
|
|
||||||
// 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"],
|
|
||||||
};
|
|
||||||
|
|
||||||
const SCAN_PROMPT = `Extract whisky label metadata. Return JSON with:
|
|
||||||
- name: Full product name
|
|
||||||
- distillery: Distillery name
|
|
||||||
- bottler: Independent bottler if applicable
|
|
||||||
- category: e.g. "Single Malt", "Bourbon"
|
|
||||||
- abv: Alcohol percentage
|
|
||||||
- age: Age statement in years
|
|
||||||
- vintage: Vintage year
|
|
||||||
- distilled_at: Distillation date
|
|
||||||
- bottled_at: Bottling date
|
|
||||||
- batch_info: Batch or cask info
|
|
||||||
- is_whisky: boolean
|
|
||||||
- confidence: 0-1`;
|
|
||||||
|
|
||||||
export async function scanLabel(input: any): Promise<AnalysisResponse> {
|
|
||||||
const provider = getAIProvider();
|
|
||||||
|
|
||||||
// Check API key based on provider
|
|
||||||
if (provider === 'gemini' && !process.env.GEMINI_API_KEY) {
|
|
||||||
return { success: false, error: 'GEMINI_API_KEY is not configured.' };
|
|
||||||
}
|
|
||||||
if (provider === 'openrouter' && !process.env.OPENROUTER_API_KEY) {
|
|
||||||
return { success: false, error: 'OPENROUTER_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: AI-Specific Image Optimization
|
|
||||||
const startOptimization = performance.now();
|
|
||||||
const optimizedBuffer = await sharp(buffer)
|
|
||||||
.resize(1024, 1024, { fit: 'inside', withoutEnlargement: true })
|
|
||||||
.grayscale()
|
|
||||||
.normalize()
|
|
||||||
.toBuffer();
|
|
||||||
const endOptimization = performance.now();
|
|
||||||
|
|
||||||
// Step 4: Base64 Encoding
|
|
||||||
const startEncoding = performance.now();
|
|
||||||
const base64Data = optimizedBuffer.toString('base64');
|
|
||||||
const mimeType = 'image/webp';
|
|
||||||
const uploadSize = optimizedBuffer.length;
|
|
||||||
const endEncoding = performance.now();
|
|
||||||
|
|
||||||
// Step 5: AI Analysis
|
|
||||||
const startAiTotal = performance.now();
|
|
||||||
let jsonData;
|
|
||||||
let validatedData;
|
|
||||||
|
|
||||||
try {
|
|
||||||
console.log(`[ScanLabel] Using provider: ${provider}`);
|
|
||||||
|
|
||||||
if (provider === 'openrouter') {
|
|
||||||
// OpenRouter path
|
|
||||||
const client = getOpenRouterClient();
|
|
||||||
const startApi = performance.now();
|
|
||||||
|
|
||||||
const response = await client.chat.completions.create({
|
|
||||||
model: 'google/gemma-3-27b-it',
|
|
||||||
messages: [{
|
|
||||||
role: 'user',
|
|
||||||
content: [
|
|
||||||
{ type: 'image_url', image_url: { url: `data:${mimeType};base64,${base64Data}` } },
|
|
||||||
{ type: 'text', text: SCAN_PROMPT + '\n\nRespond ONLY with valid JSON.' },
|
|
||||||
],
|
|
||||||
}],
|
|
||||||
temperature: 0.1,
|
|
||||||
max_tokens: 1024,
|
|
||||||
// @ts-ignore
|
|
||||||
provider: OPENROUTER_PROVIDER_PREFERENCES,
|
|
||||||
});
|
|
||||||
|
|
||||||
const endApi = performance.now();
|
|
||||||
const content = response.choices[0]?.message?.content || '{}';
|
|
||||||
|
|
||||||
const startParse = performance.now();
|
|
||||||
let jsonStr = content;
|
|
||||||
const jsonMatch = content.match(/```(?:json)?\s*([\s\S]*?)```/);
|
|
||||||
if (jsonMatch) jsonStr = jsonMatch[1].trim();
|
|
||||||
jsonData = JSON.parse(jsonStr);
|
|
||||||
const endParse = performance.now();
|
|
||||||
|
|
||||||
const startValidation = performance.now();
|
|
||||||
validatedData = BottleMetadataSchema.parse(jsonData);
|
|
||||||
const endValidation = performance.now();
|
|
||||||
|
|
||||||
await supabase.from('vision_cache').insert({ hash: imageHash, result: validatedData });
|
|
||||||
|
|
||||||
await trackApiUsage({
|
|
||||||
userId: userId,
|
|
||||||
apiType: 'gemini_ai',
|
|
||||||
endpoint: 'scanLabel_openrouter',
|
|
||||||
success: true,
|
|
||||||
provider: 'openrouter',
|
|
||||||
model: 'google/gemma-3-27b-it',
|
|
||||||
responseText: content
|
|
||||||
});
|
|
||||||
await deductCredits(userId, 'gemini_ai', 'Bottle OCR scan (OpenRouter)');
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
data: validatedData,
|
|
||||||
perf: {
|
|
||||||
imagePrep: endImagePrep - startImagePrep,
|
|
||||||
optimization: endOptimization - startOptimization,
|
|
||||||
cacheCheck: endCacheCheck - startCacheCheck,
|
|
||||||
encoding: endEncoding - startEncoding,
|
|
||||||
apiCall: endApi - startApi,
|
|
||||||
parsing: endParse - startParse,
|
|
||||||
validation: endValidation - startValidation,
|
|
||||||
total: performance.now() - perfTotal,
|
|
||||||
cacheHit: false
|
|
||||||
},
|
|
||||||
raw: jsonData
|
|
||||||
} as any;
|
|
||||||
|
|
||||||
} else {
|
|
||||||
// Gemini path
|
|
||||||
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 startApi = performance.now();
|
|
||||||
const result = await model.generateContent([
|
|
||||||
{ inlineData: { data: base64Data, mimeType: mimeType } },
|
|
||||||
{ text: 'Extract whisky label metadata.' },
|
|
||||||
]);
|
|
||||||
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();
|
|
||||||
|
|
||||||
await supabase.from('vision_cache').insert({ hash: imageHash, result: validatedData });
|
|
||||||
|
|
||||||
await trackApiUsage({
|
|
||||||
userId: userId,
|
|
||||||
apiType: 'gemini_ai',
|
|
||||||
endpoint: 'scanLabel_gemini',
|
|
||||||
success: true,
|
|
||||||
provider: 'google',
|
|
||||||
model: 'gemini-2.5-flash',
|
|
||||||
responseText: result.response.text()
|
|
||||||
});
|
|
||||||
await deductCredits(userId, 'gemini_ai', 'Bottle OCR scan (Gemini)');
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
data: validatedData,
|
|
||||||
perf: {
|
|
||||||
imagePrep: endImagePrep - startImagePrep,
|
|
||||||
optimization: endOptimization - startOptimization,
|
|
||||||
cacheCheck: endCacheCheck - startCacheCheck,
|
|
||||||
encoding: endEncoding - startEncoding,
|
|
||||||
modelInit: endModelInit - startModelInit,
|
|
||||||
apiCall: endApi - startApi,
|
|
||||||
parsing: endParse - startParse,
|
|
||||||
validation: endValidation - startValidation,
|
|
||||||
total: performance.now() - perfTotal,
|
|
||||||
cacheHit: false
|
|
||||||
},
|
|
||||||
raw: jsonData
|
|
||||||
} as any;
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (aiError: any) {
|
|
||||||
console.warn(`[ScanLabel] ${provider} failed:`, aiError.message);
|
|
||||||
|
|
||||||
await trackApiUsage({
|
|
||||||
userId: userId,
|
|
||||||
apiType: 'gemini_ai',
|
|
||||||
endpoint: `scanLabel_${provider}`,
|
|
||||||
success: false,
|
|
||||||
errorMessage: aiError.message,
|
|
||||||
provider: provider,
|
|
||||||
model: provider === 'openrouter' ? 'google/gemma-3-27b-it' : 'gemini-2.5-flash'
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
isAiError: true,
|
|
||||||
error: aiError.message,
|
|
||||||
imageHash: imageHash
|
|
||||||
} 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.',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -173,6 +173,11 @@ export default function BottleDetails({ bottleId, sessionId, userId }: BottleDet
|
|||||||
<FactCard label="ABV" value={bottle.abv ? `${bottle.abv}%` : '%'} icon={<Droplets size={14} />} highlight={!bottle.abv} />
|
<FactCard label="ABV" value={bottle.abv ? `${bottle.abv}%` : '%'} icon={<Droplets size={14} />} highlight={!bottle.abv} />
|
||||||
<FactCard label="Age" value={bottle.age ? `${bottle.age}Y` : '-'} icon={<Award size={14} />} />
|
<FactCard label="Age" value={bottle.age ? `${bottle.age}Y` : '-'} icon={<Award size={14} />} />
|
||||||
<FactCard label="Price" value={bottle.purchase_price ? `${bottle.purchase_price}€` : '-'} icon={<CircleDollarSign size={14} />} />
|
<FactCard label="Price" value={bottle.purchase_price ? `${bottle.purchase_price}€` : '-'} icon={<CircleDollarSign size={14} />} />
|
||||||
|
{(bottle as any).cask_type && (
|
||||||
|
<div className="col-span-2">
|
||||||
|
<FactCard label="Cask" value={(bottle as any).cask_type} icon={<Package size={14} />} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Status & Last Dram Row */}
|
{/* Status & Last Dram Row */}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import Link from 'next/link';
|
|||||||
import { useI18n } from '@/i18n/I18nContext';
|
import { useI18n } from '@/i18n/I18nContext';
|
||||||
import { useSession } from '@/context/SessionContext';
|
import { useSession } from '@/context/SessionContext';
|
||||||
import { shortenCategory } from '@/lib/format';
|
import { shortenCategory } from '@/lib/format';
|
||||||
import { scanLabel } from '@/app/actions/scan-label';
|
import { scanLabel } from '@/app/actions/scanner';
|
||||||
import { enrichData } from '@/app/actions/enrich-data';
|
import { enrichData } from '@/app/actions/enrich-data';
|
||||||
import { processImageForAI } from '@/utils/image-processing';
|
import { processImageForAI } from '@/utils/image-processing';
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ interface EditBottleFormProps {
|
|||||||
distilled_at?: string | null;
|
distilled_at?: string | null;
|
||||||
bottled_at?: string | null;
|
bottled_at?: string | null;
|
||||||
batch_info?: string | null;
|
batch_info?: string | null;
|
||||||
|
cask_type?: string | null;
|
||||||
};
|
};
|
||||||
onComplete?: () => void;
|
onComplete?: () => void;
|
||||||
}
|
}
|
||||||
@@ -42,6 +43,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
|
|||||||
distilled_at: bottle.distilled_at || '',
|
distilled_at: bottle.distilled_at || '',
|
||||||
bottled_at: bottle.bottled_at || '',
|
bottled_at: bottle.bottled_at || '',
|
||||||
batch_info: bottle.batch_info || '',
|
batch_info: bottle.batch_info || '',
|
||||||
|
cask_type: bottle.cask_type || '',
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleDiscover = async () => {
|
const handleDiscover = async () => {
|
||||||
@@ -87,6 +89,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
|
|||||||
distilled_at: formData.distilled_at || undefined,
|
distilled_at: formData.distilled_at || undefined,
|
||||||
bottled_at: formData.bottled_at || undefined,
|
bottled_at: formData.bottled_at || undefined,
|
||||||
batch_info: formData.batch_info || undefined,
|
batch_info: formData.batch_info || undefined,
|
||||||
|
cask_type: formData.cask_type || undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
@@ -251,8 +254,9 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Batch Info */}
|
{/* Batch and Cask */}
|
||||||
<div className="space-y-2 md:col-span-2">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 md:col-span-2">
|
||||||
|
<div className="space-y-2">
|
||||||
<label className="text-[10px] font-black uppercase text-zinc-500 ml-1 tracking-widest">{t('bottle.batchLabel')}</label>
|
<label className="text-[10px] font-black uppercase text-zinc-500 ml-1 tracking-widest">{t('bottle.batchLabel')}</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -262,6 +266,17 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
|
|||||||
className="w-full px-5 py-4 bg-black/40 border border-white/5 rounded-2xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-100 text-sm font-bold transition-all placeholder:text-zinc-700"
|
className="w-full px-5 py-4 bg-black/40 border border-white/5 rounded-2xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-100 text-sm font-bold transition-all placeholder:text-zinc-700"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[10px] font-black uppercase text-zinc-500 ml-1 tracking-widest">Fass-Typ (Cask)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="e.g. Oloroso Sherry"
|
||||||
|
value={formData.cask_type}
|
||||||
|
onChange={(e) => setFormData({ ...formData, cask_type: e.target.value })}
|
||||||
|
className="w-full px-5 py-4 bg-black/40 border border-white/5 rounded-2xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-100 text-sm font-bold transition-all placeholder:text-zinc-700"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
|
|||||||
const [bottleCategory, setBottleCategory] = useState(bottleMetadata.category || 'Whisky');
|
const [bottleCategory, setBottleCategory] = useState(bottleMetadata.category || 'Whisky');
|
||||||
|
|
||||||
const [bottleVintage, setBottleVintage] = useState(bottleMetadata.vintage || '');
|
const [bottleVintage, setBottleVintage] = useState(bottleMetadata.vintage || '');
|
||||||
|
const [bottleCaskType, setBottleCaskType] = useState(bottleMetadata.cask_type || '');
|
||||||
const [bottleBottler, setBottleBottler] = useState(bottleMetadata.bottler || '');
|
const [bottleBottler, setBottleBottler] = useState(bottleMetadata.bottler || '');
|
||||||
const [bottleBatchInfo, setBottleBatchInfo] = useState(bottleMetadata.batch_info || '');
|
const [bottleBatchInfo, setBottleBatchInfo] = useState(bottleMetadata.batch_info || '');
|
||||||
const [bottleCode, setBottleCode] = useState(bottleMetadata.bottleCode || '');
|
const [bottleCode, setBottleCode] = useState(bottleMetadata.bottleCode || '');
|
||||||
@@ -106,6 +107,7 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
|
|||||||
if (bottleMetadata.age) setBottleAge(bottleMetadata.age.toString());
|
if (bottleMetadata.age) setBottleAge(bottleMetadata.age.toString());
|
||||||
if (bottleMetadata.category) setBottleCategory(bottleMetadata.category);
|
if (bottleMetadata.category) setBottleCategory(bottleMetadata.category);
|
||||||
if (bottleMetadata.vintage) setBottleVintage(bottleMetadata.vintage);
|
if (bottleMetadata.vintage) setBottleVintage(bottleMetadata.vintage);
|
||||||
|
if (bottleMetadata.cask_type) setBottleCaskType(bottleMetadata.cask_type);
|
||||||
if (bottleMetadata.bottler) setBottleBottler(bottleMetadata.bottler);
|
if (bottleMetadata.bottler) setBottleBottler(bottleMetadata.bottler);
|
||||||
if (bottleMetadata.batch_info) setBottleBatchInfo(bottleMetadata.batch_info);
|
if (bottleMetadata.batch_info) setBottleBatchInfo(bottleMetadata.batch_info);
|
||||||
if (bottleMetadata.distilled_at) setBottleDistilledAt(bottleMetadata.distilled_at);
|
if (bottleMetadata.distilled_at) setBottleDistilledAt(bottleMetadata.distilled_at);
|
||||||
@@ -118,6 +120,7 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
|
|||||||
if (!bottleAge && bottleMetadata.age) setBottleAge(bottleMetadata.age.toString());
|
if (!bottleAge && bottleMetadata.age) setBottleAge(bottleMetadata.age.toString());
|
||||||
if ((!bottleCategory || bottleCategory === 'Whisky') && bottleMetadata.category) setBottleCategory(bottleMetadata.category);
|
if ((!bottleCategory || bottleCategory === 'Whisky') && bottleMetadata.category) setBottleCategory(bottleMetadata.category);
|
||||||
if (!bottleVintage && bottleMetadata.vintage) setBottleVintage(bottleMetadata.vintage);
|
if (!bottleVintage && bottleMetadata.vintage) setBottleVintage(bottleMetadata.vintage);
|
||||||
|
if (!bottleCaskType && bottleMetadata.cask_type) setBottleCaskType(bottleMetadata.cask_type);
|
||||||
if (!bottleBottler && bottleMetadata.bottler) setBottleBottler(bottleMetadata.bottler);
|
if (!bottleBottler && bottleMetadata.bottler) setBottleBottler(bottleMetadata.bottler);
|
||||||
if (!bottleBatchInfo && bottleMetadata.batch_info) setBottleBatchInfo(bottleMetadata.batch_info);
|
if (!bottleBatchInfo && bottleMetadata.batch_info) setBottleBatchInfo(bottleMetadata.batch_info);
|
||||||
if (!bottleDistilledAt && bottleMetadata.distilled_at) setBottleDistilledAt(bottleMetadata.distilled_at);
|
if (!bottleDistilledAt && bottleMetadata.distilled_at) setBottleDistilledAt(bottleMetadata.distilled_at);
|
||||||
@@ -182,17 +185,23 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
|
|||||||
// Automatic Whiskybase discovery when details are expanded
|
// Automatic Whiskybase discovery when details are expanded
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const searchWhiskybase = async () => {
|
const searchWhiskybase = async () => {
|
||||||
if (showBottleDetails && !whiskybaseId && !whiskybaseDiscovery && !isDiscoveringWb) {
|
if (showBottleDetails && (bottleName || bottleMetadata.name) && !whiskybaseId && !whiskybaseDiscovery && !isDiscoveringWb) {
|
||||||
setIsDiscoveringWb(true);
|
setIsDiscoveringWb(true);
|
||||||
try {
|
try {
|
||||||
|
const searchName = bottleName || bottleMetadata.name;
|
||||||
|
if (!searchName) {
|
||||||
|
setIsDiscoveringWb(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const result = await discoverWhiskybaseId({
|
const result = await discoverWhiskybaseId({
|
||||||
name: bottleMetadata.name || '',
|
name: searchName,
|
||||||
distillery: bottleMetadata.distillery ?? undefined,
|
distillery: bottleDistillery || undefined,
|
||||||
abv: bottleMetadata.abv ?? undefined,
|
abv: bottleAbv ? parseFloat(bottleAbv) : undefined,
|
||||||
age: bottleMetadata.age ?? undefined,
|
age: bottleAge ? parseInt(bottleAge) : undefined,
|
||||||
batch_info: bottleMetadata.batch_info ?? undefined,
|
batch_info: bottleBatchInfo || undefined,
|
||||||
distilled_at: bottleMetadata.distilled_at ?? undefined,
|
distilled_at: bottleDistilledAt || undefined,
|
||||||
bottled_at: bottleMetadata.bottled_at ?? undefined,
|
bottled_at: bottleBottledAt || undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.success && result.id) {
|
if (result.success && result.id) {
|
||||||
@@ -238,6 +247,7 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
|
|||||||
age: bottleAge ? parseInt(bottleAge) : bottleMetadata.age,
|
age: bottleAge ? parseInt(bottleAge) : bottleMetadata.age,
|
||||||
category: bottleCategory || bottleMetadata.category,
|
category: bottleCategory || bottleMetadata.category,
|
||||||
vintage: bottleVintage || null,
|
vintage: bottleVintage || null,
|
||||||
|
cask_type: bottleCaskType || null,
|
||||||
bottler: bottleBottler || null,
|
bottler: bottleBottler || null,
|
||||||
batch_info: bottleBatchInfo || null,
|
batch_info: bottleBatchInfo || null,
|
||||||
bottleCode: bottleCode || null,
|
bottleCode: bottleCode || null,
|
||||||
@@ -388,6 +398,19 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
|
|||||||
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-none focus:border-orange-600 transition-colors"
|
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-none focus:border-orange-600 transition-colors"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Cask Type */}
|
||||||
|
<div>
|
||||||
|
<label className="text-[9px] font-bold uppercase tracking-wider text-zinc-600 block mb-1.5">
|
||||||
|
Fass-Typ (Cask)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={bottleCaskType}
|
||||||
|
onChange={(e) => setBottleCaskType(e.target.value)}
|
||||||
|
placeholder="e.g. Oloroso Sherry Cask"
|
||||||
|
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-none focus:border-orange-600 transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
{/* Vintage */}
|
{/* Vintage */}
|
||||||
<div>
|
<div>
|
||||||
<label className="text-[9px] font-bold uppercase tracking-wider text-zinc-600 block mb-1.5">
|
<label className="text-[9px] font-bold uppercase tracking-wider text-zinc-600 block mb-1.5">
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import React, { useEffect, useState, useCallback } from 'react';
|
import React, { useEffect, useState, useCallback } from 'react';
|
||||||
import { useLiveQuery } from 'dexie-react-hooks';
|
import { useLiveQuery } from 'dexie-react-hooks';
|
||||||
import { db, PendingScan, PendingTasting } from '@/lib/db';
|
import { db, PendingScan, PendingTasting } from '@/lib/db';
|
||||||
import { scanLabel } from '@/app/actions/scan-label';
|
import { scanLabel } from '@/app/actions/scanner';
|
||||||
import { enrichData } from '@/app/actions/enrich-data';
|
import { enrichData } from '@/app/actions/enrich-data';
|
||||||
import { saveBottle } from '@/services/save-bottle';
|
import { saveBottle } from '@/services/save-bottle';
|
||||||
import { saveTasting } from '@/services/save-tasting';
|
import { saveTasting } from '@/services/save-tasting';
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useState, useCallback, useRef } from 'react';
|
import { useState, useCallback, useRef } from 'react';
|
||||||
import { BottleMetadata } from '@/types/whisky';
|
import { BottleMetadata } from '@/types/whisky';
|
||||||
import { processImageForAI, ProcessedImage } from '@/utils/image-processing';
|
import { processImageForAI, ProcessedImage } from '@/utils/image-processing';
|
||||||
import { analyzeLabelWithGemini } from '@/app/actions/gemini-vision';
|
import { analyzeBottleLabel } from '@/app/actions/scanner';
|
||||||
import { generateDummyMetadata } from '@/utils/generate-dummy-metadata';
|
import { generateDummyMetadata } from '@/utils/generate-dummy-metadata';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
|
|
||||||
@@ -144,7 +144,7 @@ export function useScanner(options: UseScannerOptions = {}) {
|
|||||||
|
|
||||||
// Step 5: Run AI in background (user is already in editor!)
|
// Step 5: Run AI in background (user is already in editor!)
|
||||||
const cloudStart = performance.now();
|
const cloudStart = performance.now();
|
||||||
const cloudResponse = await analyzeLabelWithGemini(processedImage.base64);
|
const cloudResponse = await analyzeBottleLabel(processedImage.base64);
|
||||||
perfCloudVision = performance.now() - cloudStart;
|
perfCloudVision = performance.now() - cloudStart;
|
||||||
|
|
||||||
// Store provider/model info
|
// Store provider/model info
|
||||||
|
|||||||
@@ -357,5 +357,20 @@ export const de: TranslationKeys = {
|
|||||||
'Weich': 'Weich',
|
'Weich': 'Weich',
|
||||||
'Samtig': 'Samtig',
|
'Samtig': 'Samtig',
|
||||||
'Wässrig': 'Wässrig',
|
'Wässrig': 'Wässrig',
|
||||||
|
'cereal malt': 'Getreidemalz',
|
||||||
|
'floral echo': 'Blumiges Echo',
|
||||||
|
'smooth': 'Geschmeidig',
|
||||||
|
'honey_malt_tail': 'Honig-Malz-Abgang',
|
||||||
|
'char': 'Angekohlt',
|
||||||
|
'dry cereal': 'Trockenes Getreide',
|
||||||
|
'Light anise': 'Leichter Anis',
|
||||||
|
'flinty': 'Steinig (Flint)',
|
||||||
|
'well-balanced': 'Ausgewogen',
|
||||||
|
'brothy': 'Brühig',
|
||||||
|
'dry': 'Trocken',
|
||||||
|
'softly spirity': 'Mild sprittig',
|
||||||
|
'resinous': 'Harzig',
|
||||||
|
'dense finish': 'Dichter Abgang',
|
||||||
|
'coastal chewiness': 'Maritime Konsistenz',
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export const getOcrPrompt = () => `
|
/*export const getOcrPrompt = () => `
|
||||||
ROLE: High-Precision OCR Engine for Whisky Labels.
|
ROLE: High-Precision OCR Engine for Whisky Labels.
|
||||||
OBJECTIVE: Extract visible metadata strictly from the image.
|
OBJECTIVE: Extract visible metadata strictly from the image.
|
||||||
SPEED PRIORITY: Do NOT analyze flavor. Do NOT provide descriptions. Do NOT add tags.
|
SPEED PRIORITY: Do NOT analyze flavor. Do NOT provide descriptions. Do NOT add tags.
|
||||||
@@ -33,8 +33,49 @@ OUTPUT SCHEMA (Strict JSON):
|
|||||||
"is_whisky": boolean,
|
"is_whisky": boolean,
|
||||||
"confidence": number
|
"confidence": number
|
||||||
}
|
}
|
||||||
|
`;*/
|
||||||
|
|
||||||
|
export const getOcrPrompt = () => `
|
||||||
|
ROLE: Master Whisky Archivist & High-Precision OCR Specialist.
|
||||||
|
|
||||||
|
OBJECTIVE: You are processing a bottle image to catalog it into a high-end whisky database. accuracy regarding dates and batch codes is critical.
|
||||||
|
|
||||||
|
TASK:
|
||||||
|
1. TRANSCRIPTION PHASE (Mental Scratchpad):
|
||||||
|
- Scan the label from Top to Bottom.
|
||||||
|
- transcribe ALL text nodes found.
|
||||||
|
- Pay special attention to SCRIPT/CURSIVE fonts often found near the "Age" statement (identifying Distilled/Bottled dates).
|
||||||
|
- Pay special attention to MICRO-TEXT at the bottom (identifying bottle counts).
|
||||||
|
|
||||||
|
2. EXTRACTION PHASE:
|
||||||
|
- Map the transcribed text to the JSON schema.
|
||||||
|
- CALCULATE: If "Distilled" and "Bottled" years are visible, verify if extraction matches "Age".
|
||||||
|
|
||||||
|
IMAGE CONTEXT:
|
||||||
|
- Label: Cadenhead's Natural Strength.
|
||||||
|
- Look specifically for the text "Distilled [YEAR]" and "Bottled [MONTH/YEAR]".
|
||||||
|
|
||||||
|
OUTPUT SCHEMA (Strict JSON):
|
||||||
|
{
|
||||||
|
"search_query": "string (A perfect search string to find this exact bottle on Google/Whiskybase, e.g. 'Annandale 2011 Cadenhead 10 year old Oloroso')",
|
||||||
|
"name": "string (Clean name without volume/abv)",
|
||||||
|
"distillery": "string",
|
||||||
|
"bottler": "string",
|
||||||
|
"series": "string (e.g. Natural Strength, Un-Chillfiltered)",
|
||||||
|
"vintage": "string (Year of distillation)",
|
||||||
|
"bottled": "string (Year/Month of bottling)",
|
||||||
|
"calculated_age": number (If age is not stated, calculate Bottled - Vintage),
|
||||||
|
"stated_age": number (Only if explicitly printed 'X Years'),
|
||||||
|
"cask_type": "string (e.g. Oloroso Cask)",
|
||||||
|
"batch_limit": "string (e.g. 348 bottles)",
|
||||||
|
"abv": number,
|
||||||
|
"volume": "string",
|
||||||
|
"confidence": number
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export const getEnrichmentPrompt = (name: string, distillery: string, availableTags: string, language: string) => `
|
export const getEnrichmentPrompt = (name: string, distillery: string, availableTags: string, language: string) => `
|
||||||
TASK: You are a Whisky Sommelier.
|
TASK: You are a Whisky Sommelier.
|
||||||
INPUT: A whisky named "${name}" from distillery "${distillery}".
|
INPUT: A whisky named "${name}" from distillery "${distillery}".
|
||||||
|
|||||||
@@ -1,254 +0,0 @@
|
|||||||
'use server';
|
|
||||||
|
|
||||||
import { GoogleGenerativeAI, HarmCategory, HarmBlockThreshold } from '@google/generative-ai';
|
|
||||||
import { getSystemPrompt } from '@/lib/ai-prompts';
|
|
||||||
import { BottleMetadataSchema, AnalysisResponse } 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';
|
|
||||||
import { getAIProvider, getOpenRouterClient, OPENROUTER_PROVIDER_PREFERENCES } from '@/lib/openrouter';
|
|
||||||
|
|
||||||
export async function analyzeBottle(input: any): Promise<AnalysisResponse> {
|
|
||||||
const provider = getAIProvider();
|
|
||||||
|
|
||||||
// Check API key based on provider
|
|
||||||
if (provider === 'gemini' && !process.env.GEMINI_API_KEY) {
|
|
||||||
return { success: false, error: 'GEMINI_API_KEY is not configured.' };
|
|
||||||
}
|
|
||||||
if (provider === 'openrouter' && !process.env.OPENROUTER_API_KEY) {
|
|
||||||
return { success: false, error: 'OPENROUTER_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;
|
|
||||||
const tagsString = getValue(input, 'tags') as string;
|
|
||||||
const locale = getValue(input, 'locale') || 'de';
|
|
||||||
|
|
||||||
if (!file) {
|
|
||||||
return { success: false, error: 'Kein Bild empfangen.' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const tags = tagsString ? (typeof tagsString === 'string' ? JSON.parse(tagsString) : tagsString) : [];
|
|
||||||
|
|
||||||
supabase = await createClient();
|
|
||||||
const { data: { user } } = await supabase.auth.getUser();
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return { success: false, error: 'Nicht autorisiert oder Session abgelaufen.' };
|
|
||||||
}
|
|
||||||
|
|
||||||
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 arrayBuffer = await file.arrayBuffer();
|
|
||||||
const buffer = Buffer.from(arrayBuffer);
|
|
||||||
const imageHash = createHash('sha256').update(buffer).digest('hex');
|
|
||||||
|
|
||||||
// Cache Check
|
|
||||||
const { data: cachedResult } = await supabase
|
|
||||||
.from('vision_cache')
|
|
||||||
.select('result')
|
|
||||||
.eq('hash', imageHash)
|
|
||||||
.maybeSingle();
|
|
||||||
|
|
||||||
if (cachedResult) {
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
data: cachedResult.result as any,
|
|
||||||
perf: {
|
|
||||||
apiDuration: 0,
|
|
||||||
parseDuration: 0,
|
|
||||||
uploadSize: buffer.length
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const base64Data = buffer.toString('base64');
|
|
||||||
const mimeType = file.type || 'image/webp';
|
|
||||||
const uploadSize = buffer.length;
|
|
||||||
const instruction = getSystemPrompt(tags.length > 0 ? tags.join(', ') : 'No tags available', locale);
|
|
||||||
|
|
||||||
try {
|
|
||||||
console.log(`[AnalyzeBottle] Using provider: ${provider}`);
|
|
||||||
|
|
||||||
if (provider === 'openrouter') {
|
|
||||||
// OpenRouter path
|
|
||||||
const client = getOpenRouterClient();
|
|
||||||
const startApi = performance.now();
|
|
||||||
|
|
||||||
const response = await client.chat.completions.create({
|
|
||||||
model: 'google/gemma-3-27b-it',
|
|
||||||
messages: [{
|
|
||||||
role: 'user',
|
|
||||||
content: [
|
|
||||||
{ type: 'image_url', image_url: { url: `data:${mimeType};base64,${base64Data}` } },
|
|
||||||
{ type: 'text', text: instruction + '\n\nRespond ONLY with valid JSON.' },
|
|
||||||
],
|
|
||||||
}],
|
|
||||||
temperature: 0.1,
|
|
||||||
max_tokens: 1024,
|
|
||||||
// @ts-ignore
|
|
||||||
provider: OPENROUTER_PROVIDER_PREFERENCES,
|
|
||||||
});
|
|
||||||
|
|
||||||
const endApi = performance.now();
|
|
||||||
const content = response.choices[0]?.message?.content || '{}';
|
|
||||||
|
|
||||||
const startParse = performance.now();
|
|
||||||
let jsonStr = content;
|
|
||||||
const jsonMatch = content.match(/```(?:json)?\s*([\s\S]*?)```/);
|
|
||||||
if (jsonMatch) jsonStr = jsonMatch[1].trim();
|
|
||||||
let jsonData = JSON.parse(jsonStr);
|
|
||||||
if (Array.isArray(jsonData)) jsonData = jsonData[0];
|
|
||||||
const endParse = performance.now();
|
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
await trackApiUsage({
|
|
||||||
userId: userId,
|
|
||||||
apiType: 'gemini_ai',
|
|
||||||
endpoint: 'analyzeBottle_openrouter',
|
|
||||||
success: true,
|
|
||||||
provider: 'openrouter',
|
|
||||||
model: 'google/gemma-3-27b-it',
|
|
||||||
responseText: content
|
|
||||||
});
|
|
||||||
await deductCredits(userId, 'gemini_ai', 'Bottle analysis (OpenRouter)');
|
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
} else {
|
|
||||||
// Gemini path
|
|
||||||
const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY!);
|
|
||||||
const model = genAI.getGenerativeModel({
|
|
||||||
model: 'gemini-2.5-flash',
|
|
||||||
generationConfig: {
|
|
||||||
responseMimeType: "application/json",
|
|
||||||
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: 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];
|
|
||||||
const endParse = performance.now();
|
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
await trackApiUsage({
|
|
||||||
userId: userId,
|
|
||||||
apiType: 'gemini_ai',
|
|
||||||
endpoint: 'analyzeBottle_gemini',
|
|
||||||
success: true,
|
|
||||||
provider: 'google',
|
|
||||||
model: 'gemini-2.5-flash',
|
|
||||||
responseText: responseText
|
|
||||||
});
|
|
||||||
await deductCredits(userId, 'gemini_ai', 'Bottle analysis (Gemini)');
|
|
||||||
|
|
||||||
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] ${provider} failed:`, aiError.message);
|
|
||||||
|
|
||||||
await trackApiUsage({
|
|
||||||
userId: userId,
|
|
||||||
apiType: 'gemini_ai',
|
|
||||||
endpoint: `analyzeBottle_${provider}`,
|
|
||||||
success: false,
|
|
||||||
errorMessage: aiError.message,
|
|
||||||
provider: provider,
|
|
||||||
model: provider === 'openrouter' ? 'google/gemma-3-27b-it' : 'gemini-2.5-flash'
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
isAiError: true,
|
|
||||||
error: aiError.message,
|
|
||||||
imageHash: imageHash
|
|
||||||
} as any;
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Analyze Bottle Global Error:', error);
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'An unknown error occurred during analysis.',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { createClient } from '@/lib/supabase/server';
|
import { createClient } from '@/lib/supabase/server';
|
||||||
import { revalidatePath } from 'next/cache';
|
import { revalidatePath } from 'next/cache';
|
||||||
|
import { analyzeBottleLabel } from '@/app/actions/scanner';
|
||||||
|
|
||||||
export interface BulkScanResult {
|
export interface BulkScanResult {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
@@ -185,7 +186,7 @@ async function triggerBackgroundAnalysis(
|
|||||||
.eq('id', bottleId);
|
.eq('id', bottleId);
|
||||||
|
|
||||||
// Call AI analysis with the base64 data directly
|
// Call AI analysis with the base64 data directly
|
||||||
const analysisResult = await analyzeBottleImage(imageDataUrl);
|
const analysisResult = await analyzeBottleLabel(imageDataUrl);
|
||||||
|
|
||||||
if (analysisResult.success && analysisResult.data) {
|
if (analysisResult.success && analysisResult.data) {
|
||||||
// Update bottle with AI results
|
// Update bottle with AI results
|
||||||
@@ -228,105 +229,7 @@ async function markBottleError(
|
|||||||
.eq('id', bottleId);
|
.eq('id', bottleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Analyze bottle image using configured AI provider
|
|
||||||
* Uses OpenRouter by default, falls back to Gemini
|
|
||||||
*/
|
|
||||||
async function analyzeBottleImage(dataUrl: string): Promise<{
|
|
||||||
success: boolean;
|
|
||||||
data?: any;
|
|
||||||
error?: string;
|
|
||||||
}> {
|
|
||||||
const { getAIProvider, getOpenRouterClient, OPENROUTER_PROVIDER_PREFERENCES } = await import('@/lib/openrouter');
|
|
||||||
const provider = getAIProvider();
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Extract base64 and mime type from data URL
|
|
||||||
const base64Data = dataUrl.split(',')[1];
|
|
||||||
const mimeType = dataUrl.match(/data:(.*?);/)?.[1] || 'image/webp';
|
|
||||||
|
|
||||||
const prompt = `Analyze this whisky bottle image. Extract:
|
|
||||||
- name: Full product name
|
|
||||||
- distillery: Distillery name
|
|
||||||
- category: e.g. "Single Malt", "Bourbon", "Blend"
|
|
||||||
- abv: Alcohol percentage as number (e.g. 46.0)
|
|
||||||
- age: Age statement as number (e.g. 12), null if NAS
|
|
||||||
- is_whisky: boolean, false if not a whisky
|
|
||||||
- confidence: 0-100 how confident you are
|
|
||||||
|
|
||||||
Respond ONLY with valid JSON, no markdown.`;
|
|
||||||
|
|
||||||
if (provider === 'openrouter') {
|
|
||||||
const client = getOpenRouterClient();
|
|
||||||
const openRouterResponse = await client.chat.completions.create({
|
|
||||||
model: 'google/gemma-3-27b-it',
|
|
||||||
messages: [{
|
|
||||||
role: 'user',
|
|
||||||
content: [
|
|
||||||
{ type: 'image_url', image_url: { url: `data:${mimeType};base64,${base64Data}` } },
|
|
||||||
{ type: 'text', text: prompt },
|
|
||||||
],
|
|
||||||
}],
|
|
||||||
temperature: 0.1,
|
|
||||||
max_tokens: 500,
|
|
||||||
// @ts-ignore
|
|
||||||
provider: OPENROUTER_PROVIDER_PREFERENCES,
|
|
||||||
});
|
|
||||||
|
|
||||||
const content = openRouterResponse.choices[0]?.message?.content || '{}';
|
|
||||||
let jsonStr = content;
|
|
||||||
const jsonMatch = content.match(/\{[\s\S]*\}/);
|
|
||||||
if (jsonMatch) jsonStr = jsonMatch[0].trim();
|
|
||||||
const parsed = JSON.parse(jsonStr);
|
|
||||||
return { success: true, data: parsed };
|
|
||||||
|
|
||||||
} else {
|
|
||||||
const apiKey = process.env.GEMINI_API_KEY;
|
|
||||||
if (!apiKey) {
|
|
||||||
return { success: false, error: 'GEMINI_API_KEY nicht konfiguriert' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const geminiResponse = await fetch(
|
|
||||||
`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${apiKey}`,
|
|
||||||
{
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
contents: [{
|
|
||||||
parts: [
|
|
||||||
{ text: prompt },
|
|
||||||
{ inline_data: { mime_type: mimeType, data: base64Data } }
|
|
||||||
]
|
|
||||||
}],
|
|
||||||
generationConfig: { temperature: 0.1, maxOutputTokens: 500 }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!geminiResponse.ok) {
|
|
||||||
return { success: false, error: 'Gemini API Fehler' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const geminiData = await geminiResponse.json();
|
|
||||||
const textContent = geminiData.candidates?.[0]?.content?.parts?.[0]?.text;
|
|
||||||
|
|
||||||
if (!textContent) {
|
|
||||||
return { success: false, error: 'Keine Antwort von Gemini' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const jsonMatch = textContent.match(/\{[\s\S]*\}/);
|
|
||||||
if (!jsonMatch) {
|
|
||||||
return { success: false, error: 'Ungültige Gemini-Antwort' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed = JSON.parse(jsonMatch[0]);
|
|
||||||
return { success: true, data: parsed };
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`[BulkScan] ${provider} analysis error:`, error);
|
|
||||||
return { success: false, error: 'Analysefehler' };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get bottles for a session with their processing status
|
* Get bottles for a session with their processing status
|
||||||
|
|||||||
@@ -12,7 +12,15 @@ import { DiscoveryDataSchema, DiscoveryData } from '@/types/whisky';
|
|||||||
*/
|
*/
|
||||||
export async function discoverWhiskybaseId(rawBottle: DiscoveryData) {
|
export async function discoverWhiskybaseId(rawBottle: DiscoveryData) {
|
||||||
// Validate input
|
// Validate input
|
||||||
const bottle = DiscoveryDataSchema.parse(rawBottle);
|
const validation = DiscoveryDataSchema.safeParse(rawBottle);
|
||||||
|
if (!validation.success) {
|
||||||
|
console.warn('[discoverWhiskybaseId] Invalid input:', validation.error.format());
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Flaschenname fehlt oder ist ungültig.'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const bottle = validation.data;
|
||||||
// Both Gemini and Custom Search often use the same API key if created via AI Studio
|
// Both Gemini and Custom Search often use the same API key if created via AI Studio
|
||||||
const apiKey = process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY;
|
const apiKey = process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY;
|
||||||
const cx = '37e905eb03fd14e0f'; // Provided by user
|
const cx = '37e905eb03fd14e0f'; // Provided by user
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import { analyzeBottle } from './analyze-bottle';
|
import { scanLabel } from '@/app/actions/scanner';
|
||||||
import { analyzeBottleMistral } from './analyze-bottle-mistral';
|
import { analyzeBottleMistral } from './analyze-bottle-mistral';
|
||||||
import { searchBraveForWhiskybase } from './brave-search';
|
import { searchBraveForWhiskybase } from './brave-search';
|
||||||
import { getAllSystemTags } from './tags';
|
import { getAllSystemTags } from './tags';
|
||||||
@@ -50,7 +50,7 @@ export async function magicScan(input: any): Promise<AnalysisResponse & { wb_id?
|
|||||||
if (provider === 'mistral') {
|
if (provider === 'mistral') {
|
||||||
aiResponse = await analyzeBottleMistral(context);
|
aiResponse = await analyzeBottleMistral(context);
|
||||||
} else {
|
} else {
|
||||||
aiResponse = await analyzeBottle(context);
|
aiResponse = await scanLabel(context);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!aiResponse.success || !aiResponse.data) {
|
if (!aiResponse.success || !aiResponse.data) {
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ export async function saveBottle(
|
|||||||
distilled_at: metadata.distilled_at,
|
distilled_at: metadata.distilled_at,
|
||||||
bottled_at: metadata.bottled_at,
|
bottled_at: metadata.bottled_at,
|
||||||
batch_info: metadata.batch_info,
|
batch_info: metadata.batch_info,
|
||||||
|
cask_type: metadata.cask_type,
|
||||||
purchase_price: metadata.purchase_price,
|
purchase_price: metadata.purchase_price,
|
||||||
suggested_tags: metadata.suggested_tags,
|
suggested_tags: metadata.suggested_tags,
|
||||||
suggested_custom_tags: metadata.suggested_custom_tags,
|
suggested_custom_tags: metadata.suggested_custom_tags,
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export async function updateBottle(bottleId: string, rawData: UpdateBottleData)
|
|||||||
distilled_at: data.distilled_at,
|
distilled_at: data.distilled_at,
|
||||||
bottled_at: data.bottled_at,
|
bottled_at: data.bottled_at,
|
||||||
batch_info: data.batch_info,
|
batch_info: data.batch_info,
|
||||||
|
cask_type: data.cask_type,
|
||||||
status: data.status,
|
status: data.status,
|
||||||
updated_at: new Date().toISOString(),
|
updated_at: new Date().toISOString(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,18 +1,29 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const coerceNumber = z.preprocess((val) => {
|
||||||
|
if (val === null || val === undefined || val === '') return null;
|
||||||
|
const n = Number(val);
|
||||||
|
if (isNaN(n)) return null;
|
||||||
|
// If it looks like a year (e.g. 19xx or 20xx), it's probably not the age
|
||||||
|
if (n > 1900 && n < 2100) return null;
|
||||||
|
return n;
|
||||||
|
}, z.number().min(0).max(200).nullish());
|
||||||
|
|
||||||
export const BottleMetadataSchema = z.object({
|
export const BottleMetadataSchema = z.object({
|
||||||
name: z.string().trim().min(1).max(255).nullish(),
|
name: z.string().trim().min(1).max(255).nullish(),
|
||||||
distillery: z.string().trim().max(255).nullish(),
|
distillery: z.string().trim().max(255).nullish(),
|
||||||
bottler: z.string().trim().max(255).nullish(),
|
bottler: z.string().trim().max(255).nullish(),
|
||||||
|
series: z.string().trim().max(255).nullish(),
|
||||||
category: z.string().trim().max(100).nullish(),
|
category: z.string().trim().max(100).nullish(),
|
||||||
abv: z.number().min(0).max(100).nullish(),
|
abv: coerceNumber,
|
||||||
age: z.number().min(0).max(100).nullish(),
|
age: coerceNumber,
|
||||||
vintage: z.string().trim().max(50).nullish(),
|
vintage: z.string().trim().max(50).nullish(),
|
||||||
bottleCode: z.string().trim().max(100).nullish(),
|
bottleCode: z.string().trim().max(100).nullish(),
|
||||||
whiskybaseId: z.string().trim().max(50).nullish(),
|
whiskybaseId: z.string().trim().max(50).nullish(),
|
||||||
distilled_at: z.string().trim().max(50).nullish(),
|
distilled_at: z.string().trim().max(50).nullish(),
|
||||||
bottled_at: z.string().trim().max(50).nullish(),
|
bottled_at: z.string().trim().max(50).nullish(),
|
||||||
batch_info: z.string().trim().max(255).nullish(),
|
batch_info: z.string().trim().max(255).nullish(),
|
||||||
|
cask_type: z.string().trim().max(255).nullish(),
|
||||||
is_whisky: z.boolean().default(true),
|
is_whisky: z.boolean().default(true),
|
||||||
confidence: z.number().min(0).max(100).default(100),
|
confidence: z.number().min(0).max(100).default(100),
|
||||||
purchase_price: z.number().min(0).nullish(),
|
purchase_price: z.number().min(0).nullish(),
|
||||||
@@ -42,13 +53,14 @@ export const UpdateBottleSchema = z.object({
|
|||||||
name: z.string().trim().min(1).max(255).nullish(),
|
name: z.string().trim().min(1).max(255).nullish(),
|
||||||
distillery: z.string().trim().max(255).nullish(),
|
distillery: z.string().trim().max(255).nullish(),
|
||||||
category: z.string().trim().max(100).nullish(),
|
category: z.string().trim().max(100).nullish(),
|
||||||
abv: z.number().min(0).max(100).nullish(),
|
abv: coerceNumber,
|
||||||
age: z.number().min(0).max(100).nullish(),
|
age: coerceNumber,
|
||||||
whiskybase_id: z.string().trim().max(50).nullish(),
|
whiskybase_id: z.string().trim().max(50).nullish(),
|
||||||
purchase_price: z.number().min(0).nullish(),
|
purchase_price: z.number().min(0).nullish(),
|
||||||
distilled_at: z.string().trim().max(50).nullish(),
|
distilled_at: z.string().trim().max(50).nullish(),
|
||||||
bottled_at: z.string().trim().max(50).nullish(),
|
bottled_at: z.string().trim().max(50).nullish(),
|
||||||
batch_info: z.string().trim().max(255).nullish(),
|
batch_info: z.string().trim().max(255).nullish(),
|
||||||
|
cask_type: z.string().trim().max(255).nullish(),
|
||||||
status: z.enum(['sealed', 'open', 'sampled', 'empty']).nullish(),
|
status: z.enum(['sealed', 'open', 'sampled', 'empty']).nullish(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -81,11 +93,12 @@ export type AdminSettingsData = z.infer<typeof AdminSettingsSchema>;
|
|||||||
export const DiscoveryDataSchema = z.object({
|
export const DiscoveryDataSchema = z.object({
|
||||||
name: z.string().trim().min(1).max(255),
|
name: z.string().trim().min(1).max(255),
|
||||||
distillery: z.string().trim().max(255).nullish(),
|
distillery: z.string().trim().max(255).nullish(),
|
||||||
abv: z.number().min(0).max(100).nullish(),
|
abv: coerceNumber,
|
||||||
age: z.number().min(0).max(100).nullish(),
|
age: coerceNumber,
|
||||||
distilled_at: z.string().trim().max(50).nullish(),
|
distilled_at: z.string().trim().max(50).nullish(),
|
||||||
bottled_at: z.string().trim().max(50).nullish(),
|
bottled_at: z.string().trim().max(50).nullish(),
|
||||||
batch_info: z.string().trim().max(255).nullish(),
|
batch_info: z.string().trim().max(255).nullish(),
|
||||||
|
cask_type: z.string().trim().max(255).nullish(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type DiscoveryData = z.infer<typeof DiscoveryDataSchema>;
|
export type DiscoveryData = z.infer<typeof DiscoveryDataSchema>;
|
||||||
|
|||||||
51
src/utils/formatWhiskyName.ts
Normal file
51
src/utils/formatWhiskyName.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
interface ScanData {
|
||||||
|
distillery: string;
|
||||||
|
bottler?: string | null;
|
||||||
|
series?: string | null;
|
||||||
|
age?: number | null;
|
||||||
|
vintage?: string | null;
|
||||||
|
cask_type?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standardizes whisky names by combining structured metadata fields.
|
||||||
|
* Logic: [Distillery] [Age/Vintage] [Series/Bottler] [Cask Type]
|
||||||
|
*/
|
||||||
|
export function formatWhiskyName(data: ScanData): string {
|
||||||
|
if (!data.distillery) return '';
|
||||||
|
|
||||||
|
// 1. Start with Distillery (Essential)
|
||||||
|
const parts = [data.distillery.trim()];
|
||||||
|
|
||||||
|
// 2. Add Age OR Vintage
|
||||||
|
if (data.age) {
|
||||||
|
parts.push(`${data.age} Year Old`);
|
||||||
|
} else if (data.vintage) {
|
||||||
|
// If it's already a string, check if it already contains "Vintage"
|
||||||
|
const v = data.vintage.toString().trim();
|
||||||
|
if (v.toLowerCase().includes('vintage')) {
|
||||||
|
parts.push(v);
|
||||||
|
} else {
|
||||||
|
parts.push(`Vintage ${v}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Add Series or Bottler
|
||||||
|
// Avoid duplicating distillery name if it appears in the series
|
||||||
|
const distLower = data.distillery.toLowerCase();
|
||||||
|
|
||||||
|
if (data.series && !data.series.toLowerCase().includes(distLower)) {
|
||||||
|
parts.push(data.series.trim());
|
||||||
|
} else if (data.bottler && !data.bottler.toLowerCase().includes(distLower)) {
|
||||||
|
// Only show bottler if it's likely an Independent Bottling and not the distillery
|
||||||
|
parts.push(data.bottler.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Add Cask Type
|
||||||
|
if (data.cask_type) {
|
||||||
|
parts.push(data.cask_type.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Join with spaces and remove double spaces
|
||||||
|
return parts.filter(Boolean).join(" ").replace(/\s+/g, ' ').trim();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user