feat: improved local OCR with Strip & Match distillery detection
- Added comprehensive distillery database (200+ entries) - Implemented Strip & Match heuristic for fuzzy matching - Added contextual age detection from distillery lines - Added whitespace normalization for OCR text - Disabled local name extraction (too noisy, let Gemini handle it) - Fixed confidence scale normalization in TastingEditor (0-1 vs 0-100) - Improved extractName filter (60% letters required) - Relaxed Fuse.js thresholds for partial matches
This commit is contained in:
@@ -1,22 +1,19 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { X, Loader2, Sparkles, AlertCircle, Clock } from 'lucide-react';
|
||||
import { X, Loader2, Sparkles, AlertCircle, Clock, Eye, Cloud, Cpu } from 'lucide-react';
|
||||
import TastingEditor from './TastingEditor';
|
||||
import SessionBottomSheet from './SessionBottomSheet';
|
||||
import ResultCard from './ResultCard';
|
||||
import { useSession } from '@/context/SessionContext';
|
||||
import { scanLabel } from '@/app/actions/scan-label';
|
||||
import { enrichData } from '@/app/actions/enrich-data';
|
||||
|
||||
import { saveBottle } from '@/services/save-bottle';
|
||||
import { saveTasting } from '@/services/save-tasting';
|
||||
import { BottleMetadata } from '@/types/whisky';
|
||||
import { useI18n } from '@/i18n/I18nContext';
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
import { processImageForAI, ProcessedImage } from '@/utils/image-processing';
|
||||
import { generateDummyMetadata } from '@/utils/generate-dummy-metadata';
|
||||
import { useScanner, ScanStatus } from '@/hooks/useScanner';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
type FlowState = 'IDLE' | 'SCANNING' | 'EDITOR' | 'RESULT' | 'ERROR';
|
||||
@@ -32,7 +29,6 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile, onBottleS
|
||||
const [state, setState] = useState<FlowState>('IDLE');
|
||||
const [isSessionsOpen, setIsSessionsOpen] = useState(false);
|
||||
const { activeSession } = useSession();
|
||||
const [processedImage, setProcessedImage] = useState<ProcessedImage | null>(null);
|
||||
const [tastingData, setTastingData] = useState<any>(null);
|
||||
const [bottleMetadata, setBottleMetadata] = useState<BottleMetadata | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -40,24 +36,38 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile, onBottleS
|
||||
const { locale } = useI18n();
|
||||
const supabase = createClient();
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [isOffline, setIsOffline] = useState(!navigator.onLine);
|
||||
const [perfMetrics, setPerfMetrics] = useState<{
|
||||
comp: number;
|
||||
aiTotal: number;
|
||||
aiApi: number;
|
||||
aiParse: number;
|
||||
uploadSize: number;
|
||||
prep: number;
|
||||
// Detailed metrics
|
||||
imagePrep?: number;
|
||||
cacheCheck?: number;
|
||||
encoding?: number;
|
||||
modelInit?: number;
|
||||
validation?: number;
|
||||
dbOps?: number;
|
||||
total?: number;
|
||||
cacheHit?: boolean;
|
||||
} | null>(null);
|
||||
const [isOffline, setIsOffline] = useState(typeof navigator !== 'undefined' ? !navigator.onLine : false);
|
||||
const [isEnriching, setIsEnriching] = useState(false);
|
||||
const [aiFallbackActive, setAiFallbackActive] = useState(false);
|
||||
|
||||
// Use the new hybrid scanner hook
|
||||
const scanner = useScanner({
|
||||
locale,
|
||||
onLocalComplete: (localResult) => {
|
||||
console.log('[ScanFlow] Local OCR complete, updating preview:', localResult);
|
||||
// Immediately update bottleMetadata with local results for optimistic UI
|
||||
setBottleMetadata(prev => ({
|
||||
...prev,
|
||||
name: localResult.name || prev?.name || null,
|
||||
distillery: localResult.distillery || prev?.distillery || null,
|
||||
abv: localResult.abv ?? prev?.abv ?? null,
|
||||
age: localResult.age ?? prev?.age ?? null,
|
||||
vintage: localResult.vintage || prev?.vintage || null,
|
||||
is_whisky: true,
|
||||
confidence: 50,
|
||||
} as BottleMetadata));
|
||||
},
|
||||
onCloudComplete: (cloudResult) => {
|
||||
console.log('[ScanFlow] Cloud vision complete:', cloudResult);
|
||||
// Update with cloud results (this is the "truth")
|
||||
setBottleMetadata(cloudResult);
|
||||
|
||||
// Trigger background enrichment if we have name and distillery
|
||||
if (cloudResult.name && cloudResult.distillery) {
|
||||
runEnrichment(cloudResult.name, cloudResult.distillery);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Admin Check
|
||||
useEffect(() => {
|
||||
@@ -81,8 +91,31 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile, onBottleS
|
||||
checkAdmin();
|
||||
}, [supabase]);
|
||||
|
||||
const [aiFallbackActive, setAiFallbackActive] = useState(false);
|
||||
const [isEnriching, setIsEnriching] = useState(false);
|
||||
// Background enrichment function
|
||||
const runEnrichment = useCallback(async (name: string, distillery: string) => {
|
||||
setIsEnriching(true);
|
||||
console.log('[ScanFlow] Starting background enrichment for:', name);
|
||||
try {
|
||||
const enrichResult = await enrichData(name, distillery, undefined, locale);
|
||||
if (enrichResult.success && enrichResult.data) {
|
||||
console.log('[ScanFlow] Enrichment data received:', enrichResult.data);
|
||||
setBottleMetadata(prev => {
|
||||
if (!prev) return prev;
|
||||
return {
|
||||
...prev,
|
||||
suggested_tags: enrichResult.data.suggested_tags,
|
||||
suggested_custom_tags: enrichResult.data.suggested_custom_tags,
|
||||
};
|
||||
});
|
||||
} else {
|
||||
console.warn('[ScanFlow] Enrichment unsuccessful:', enrichResult.error);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[ScanFlow] Enrichment failed:', err);
|
||||
} finally {
|
||||
setIsEnriching(false);
|
||||
}
|
||||
}, [locale]);
|
||||
|
||||
// Trigger scan when open and image provided
|
||||
useEffect(() => {
|
||||
@@ -93,10 +126,10 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile, onBottleS
|
||||
setState('IDLE');
|
||||
setTastingData(null);
|
||||
setBottleMetadata(null);
|
||||
setProcessedImage(null);
|
||||
setError(null);
|
||||
setIsSaving(false);
|
||||
setAiFallbackActive(false);
|
||||
scanner.reset();
|
||||
}
|
||||
}, [isOpen, imageFile]);
|
||||
|
||||
@@ -114,146 +147,68 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile, onBottleS
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Sync scanner status to UI state
|
||||
useEffect(() => {
|
||||
const scannerStatus = scanner.status;
|
||||
|
||||
if (scannerStatus === 'idle') {
|
||||
// Don't change state on idle
|
||||
} else if (scannerStatus === 'compressing' || scannerStatus === 'analyzing_local') {
|
||||
setState('SCANNING');
|
||||
} else if (scannerStatus === 'analyzing_cloud') {
|
||||
// Show EDITOR immediately when we have local results - don't wait for cloud
|
||||
if (scanner.localResult || scanner.mergedResult) {
|
||||
setState('EDITOR');
|
||||
} else {
|
||||
setState('SCANNING');
|
||||
}
|
||||
} else if (scannerStatus === 'complete' || scannerStatus === 'queued') {
|
||||
// Use merged result from scanner
|
||||
if (scanner.mergedResult) {
|
||||
setBottleMetadata(scanner.mergedResult);
|
||||
}
|
||||
setState('EDITOR');
|
||||
|
||||
// If this was a queued offline scan, mark as fallback
|
||||
if (scannerStatus === 'queued') {
|
||||
setAiFallbackActive(true);
|
||||
setIsOffline(true);
|
||||
}
|
||||
} else if (scannerStatus === 'error') {
|
||||
if (scanner.mergedResult) {
|
||||
// We have partial results, show editor anyway
|
||||
setBottleMetadata(scanner.mergedResult);
|
||||
setState('EDITOR');
|
||||
setAiFallbackActive(true);
|
||||
} else {
|
||||
setError(scanner.error || 'Scan failed');
|
||||
setState('ERROR');
|
||||
}
|
||||
}
|
||||
}, [scanner.status, scanner.mergedResult, scanner.localResult, scanner.error]);
|
||||
|
||||
const handleScan = async (file: File) => {
|
||||
setState('SCANNING');
|
||||
setError(null);
|
||||
setPerfMetrics(null);
|
||||
|
||||
try {
|
||||
const startComp = performance.now();
|
||||
const processed = await processImageForAI(file);
|
||||
const endComp = performance.now();
|
||||
setProcessedImage(processed);
|
||||
|
||||
// OFFLINE: Skip AI scan, use dummy metadata
|
||||
if (isOffline) {
|
||||
const dummyMetadata = generateDummyMetadata(file);
|
||||
setBottleMetadata(dummyMetadata);
|
||||
setState('EDITOR');
|
||||
|
||||
if (isAdmin) {
|
||||
setPerfMetrics({
|
||||
comp: endComp - startComp,
|
||||
aiTotal: 0,
|
||||
aiApi: 0,
|
||||
aiParse: 0,
|
||||
uploadSize: processed.file.size,
|
||||
prep: 0,
|
||||
cacheCheck: 0,
|
||||
cacheHit: false
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ONLINE: Normal AI scan
|
||||
const formData = new FormData();
|
||||
formData.append('file', processed.file);
|
||||
|
||||
const startAi = performance.now();
|
||||
const result = await scanLabel(formData);
|
||||
const endAi = performance.now();
|
||||
|
||||
const startPrep = performance.now();
|
||||
if (result.success && result.data) {
|
||||
setBottleMetadata(result.data);
|
||||
|
||||
const endPrep = performance.now();
|
||||
if (isAdmin && result.perf) {
|
||||
setPerfMetrics({
|
||||
comp: endComp - startComp,
|
||||
aiTotal: endAi - startAi,
|
||||
aiApi: result.perf.apiCall || result.perf.apiDuration || 0,
|
||||
aiParse: result.perf.parsing || result.perf.parseDuration || 0,
|
||||
uploadSize: result.perf.uploadSize || 0,
|
||||
prep: endPrep - startPrep,
|
||||
imagePrep: result.perf.imagePrep,
|
||||
cacheCheck: result.perf.cacheCheck,
|
||||
encoding: result.perf.encoding,
|
||||
modelInit: result.perf.modelInit,
|
||||
validation: result.perf.validation,
|
||||
dbOps: result.perf.dbOps,
|
||||
total: result.perf.total,
|
||||
cacheHit: result.perf.cacheHit
|
||||
});
|
||||
}
|
||||
|
||||
setState('EDITOR');
|
||||
|
||||
// Step 2: Background Enrichment
|
||||
if (result.data.name && result.data.distillery) {
|
||||
setIsEnriching(true);
|
||||
console.log('[ScanFlow] Starting background enrichment for:', result.data.name);
|
||||
enrichData(result.data.name, result.data.distillery, undefined, locale)
|
||||
.then(enrichResult => {
|
||||
if (enrichResult.success && enrichResult.data) {
|
||||
console.log('[ScanFlow] Enrichment data received:', enrichResult.data);
|
||||
setBottleMetadata(prev => {
|
||||
if (!prev) return prev;
|
||||
const updated = {
|
||||
...prev,
|
||||
suggested_tags: enrichResult.data.suggested_tags,
|
||||
suggested_custom_tags: enrichResult.data.suggested_custom_tags,
|
||||
search_string: enrichResult.data.search_string
|
||||
};
|
||||
console.log('[ScanFlow] State updated with enriched metadata');
|
||||
return updated;
|
||||
});
|
||||
} else {
|
||||
console.warn('[ScanFlow] Enrichment result unsuccessful:', enrichResult.error);
|
||||
}
|
||||
})
|
||||
.catch(err => console.error('[ScanFlow] Enrichment failed:', err))
|
||||
.finally(() => setIsEnriching(false));
|
||||
}
|
||||
} else if (result.isAiError) {
|
||||
console.warn('[ScanFlow] AI Analysis failed, falling back to offline mode');
|
||||
setIsOffline(true);
|
||||
setAiFallbackActive(true);
|
||||
const dummyMetadata = generateDummyMetadata(file);
|
||||
setBottleMetadata(dummyMetadata);
|
||||
setState('EDITOR');
|
||||
return;
|
||||
} else {
|
||||
throw new Error(result.error || 'Fehler bei der Analyse.');
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('[ScanFlow] handleScan error:', err);
|
||||
|
||||
// Check if this is a network error (offline)
|
||||
if (err.message?.includes('Failed to fetch') || err.message?.includes('NetworkError') || err.message?.includes('ERR_INTERNET_DISCONNECTED')) {
|
||||
console.log('[ScanFlow] Network error detected - switching to offline mode');
|
||||
setIsOffline(true);
|
||||
|
||||
// Use dummy metadata for offline scan
|
||||
const dummyMetadata = generateDummyMetadata(file);
|
||||
setBottleMetadata(dummyMetadata);
|
||||
setState('EDITOR');
|
||||
return;
|
||||
}
|
||||
|
||||
// Other errors
|
||||
setError(err.message);
|
||||
setState('ERROR');
|
||||
}
|
||||
scanner.handleScan(file);
|
||||
};
|
||||
|
||||
const handleSaveTasting = async (formData: any) => {
|
||||
if (!bottleMetadata || !processedImage) return;
|
||||
if (!bottleMetadata || !scanner.processedImage) return;
|
||||
|
||||
setIsSaving(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// OFFLINE: Save to IndexedDB queue (skip auth check)
|
||||
// OFFLINE: Save to IndexedDB queue
|
||||
if (isOffline) {
|
||||
console.log('[ScanFlow] Offline mode - queuing for upload');
|
||||
const tempId = `temp_${Date.now()}`;
|
||||
const bottleDataToSave = formData.bottleMetadata || bottleMetadata;
|
||||
|
||||
// Check for existing pending scan with same image to prevent duplicates
|
||||
// Check for existing pending scan with same image
|
||||
const existingScan = await db.pending_scans
|
||||
.filter(s => s.imageBase64 === processedImage.base64)
|
||||
.filter(s => s.imageBase64 === scanner.processedImage!.base64)
|
||||
.first();
|
||||
|
||||
let currentTempId = tempId;
|
||||
@@ -262,13 +217,11 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile, onBottleS
|
||||
console.log('[ScanFlow] Existing pending scan found, reusing temp_id:', existingScan.temp_id);
|
||||
currentTempId = existingScan.temp_id;
|
||||
} else {
|
||||
// Save pending scan with metadata
|
||||
await db.pending_scans.add({
|
||||
temp_id: tempId,
|
||||
imageBase64: processedImage.base64,
|
||||
imageBase64: scanner.processedImage.base64,
|
||||
timestamp: Date.now(),
|
||||
locale,
|
||||
// Store bottle metadata in a custom field
|
||||
metadata: bottleDataToSave as any
|
||||
});
|
||||
}
|
||||
@@ -302,69 +255,25 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile, onBottleS
|
||||
if (!authUser) throw new Error('Nicht autorisiert');
|
||||
user = authUser;
|
||||
} catch (authError: any) {
|
||||
// If auth fails due to network, treat as offline
|
||||
if (authError.message?.includes('Failed to fetch') || authError.message?.includes('NetworkError')) {
|
||||
console.log('[ScanFlow] Auth failed due to network - switching to offline mode');
|
||||
setIsOffline(true);
|
||||
|
||||
// Save to queue instead
|
||||
const tempId = `temp_${Date.now()}`;
|
||||
const bottleDataToSave = formData.bottleMetadata || bottleMetadata;
|
||||
|
||||
// Check for existing pending scan with same image to prevent duplicates
|
||||
const existingScan = await db.pending_scans
|
||||
.filter(s => s.imageBase64 === processedImage.base64)
|
||||
.first();
|
||||
|
||||
let currentTempId = tempId;
|
||||
|
||||
if (existingScan) {
|
||||
console.log('[ScanFlow] Existing pending scan found, reusing temp_id:', existingScan.temp_id);
|
||||
currentTempId = existingScan.temp_id;
|
||||
} else {
|
||||
await db.pending_scans.add({
|
||||
temp_id: tempId,
|
||||
imageBase64: processedImage.base64,
|
||||
timestamp: Date.now(),
|
||||
locale,
|
||||
metadata: bottleDataToSave as any
|
||||
});
|
||||
}
|
||||
|
||||
await db.pending_tastings.add({
|
||||
pending_bottle_id: currentTempId,
|
||||
data: {
|
||||
session_id: activeSession?.id,
|
||||
rating: formData.rating,
|
||||
nose_notes: formData.nose_notes,
|
||||
palate_notes: formData.palate_notes,
|
||||
finish_notes: formData.finish_notes,
|
||||
is_sample: formData.is_sample,
|
||||
buddy_ids: formData.buddy_ids,
|
||||
tag_ids: formData.tag_ids,
|
||||
},
|
||||
tasted_at: new Date().toISOString()
|
||||
});
|
||||
|
||||
setTastingData(formData);
|
||||
setState('RESULT');
|
||||
setIsSaving(false);
|
||||
return;
|
||||
// Retry save in offline mode
|
||||
return handleSaveTasting(formData);
|
||||
}
|
||||
// Other auth errors
|
||||
throw authError;
|
||||
}
|
||||
|
||||
// 1. Save Bottle - Use edited metadata if provided
|
||||
// Save Bottle
|
||||
const bottleDataToSave = formData.bottleMetadata || bottleMetadata;
|
||||
const bottleResult = await saveBottle(bottleDataToSave, processedImage.base64, user.id);
|
||||
const bottleResult = await saveBottle(bottleDataToSave, scanner.processedImage.base64, user.id);
|
||||
if (!bottleResult.success || !bottleResult.data) {
|
||||
throw new Error(bottleResult.error || 'Fehler beim Speichern der Flasche');
|
||||
}
|
||||
|
||||
const bottleId = bottleResult.data.id;
|
||||
|
||||
// 2. Save Tasting
|
||||
// Save Tasting
|
||||
const tastingNote = {
|
||||
...formData,
|
||||
bottle_id: bottleId,
|
||||
@@ -378,7 +287,6 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile, onBottleS
|
||||
setTastingData(tastingNote);
|
||||
setState('RESULT');
|
||||
|
||||
// Trigger bottle list refresh in parent
|
||||
if (onBottleSaved) {
|
||||
onBottleSaved(bottleId);
|
||||
}
|
||||
@@ -406,6 +314,22 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile, onBottleS
|
||||
}
|
||||
};
|
||||
|
||||
// Map scanner status to display text
|
||||
const getScanStatusDisplay = (status: ScanStatus): { text: string; icon: React.ReactNode } => {
|
||||
switch (status) {
|
||||
case 'compressing':
|
||||
return { text: 'Bild optimieren...', icon: <Loader2 size={12} className="animate-spin" /> };
|
||||
case 'analyzing_local':
|
||||
return { text: 'Lokale OCR-Analyse...', icon: <Cpu size={12} /> };
|
||||
case 'analyzing_cloud':
|
||||
return { text: 'KI-Vision-Analyse...', icon: <Cloud size={12} /> };
|
||||
default:
|
||||
return { text: 'Analysiere Etikett...', icon: <Loader2 size={12} className="animate-spin" /> };
|
||||
}
|
||||
};
|
||||
|
||||
const statusDisplay = getScanStatusDisplay(scanner.status);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
@@ -424,11 +348,6 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile, onBottleS
|
||||
</button>
|
||||
|
||||
<div className="flex-1 w-full h-full flex flex-col relative min-h-0">
|
||||
{/*
|
||||
Robust state check:
|
||||
If we are IDLE but have an image, we are essentially SCANNING (or about to be).
|
||||
If we have no image, we shouldn't really be here, but show error just in case.
|
||||
*/}
|
||||
{(state === 'SCANNING' || (state === 'IDLE' && imageFile)) && (
|
||||
<div className="flex-1 flex flex-col items-center justify-center">
|
||||
<motion.div
|
||||
@@ -449,43 +368,39 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile, onBottleS
|
||||
<div className="text-center space-y-2">
|
||||
<h2 className="text-2xl font-bold text-zinc-50 uppercase tracking-tight">Analysiere Etikett...</h2>
|
||||
<p className="text-orange-500 font-bold uppercase tracking-widest text-[10px] flex items-center justify-center gap-2">
|
||||
<Sparkles size={12} /> KI-gestütztes Scanning
|
||||
{statusDisplay.icon}
|
||||
{statusDisplay.text}
|
||||
</p>
|
||||
{/* Show scan stage indicators */}
|
||||
<div className="flex items-center justify-center gap-2 mt-4">
|
||||
<div className={`w-2 h-2 rounded-full transition-colors ${['compressing', 'analyzing_local', 'analyzing_cloud', 'complete'].includes(scanner.status)
|
||||
? 'bg-orange-500' : 'bg-zinc-700'
|
||||
}`} />
|
||||
<div className={`w-2 h-2 rounded-full transition-colors ${['analyzing_local', 'analyzing_cloud', 'complete'].includes(scanner.status)
|
||||
? 'bg-orange-500' : 'bg-zinc-700'
|
||||
}`} />
|
||||
<div className={`w-2 h-2 rounded-full transition-colors ${['analyzing_cloud', 'complete'].includes(scanner.status)
|
||||
? 'bg-orange-500' : 'bg-zinc-700'
|
||||
}`} />
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{isAdmin && perfMetrics && (
|
||||
{/* Admin perf metrics */}
|
||||
{isAdmin && scanner.perf && (
|
||||
<div className="mt-8 p-6 bg-zinc-950/80 backdrop-blur-xl rounded-3xl border border-orange-500/20 text-[10px] font-mono text-zinc-400 animate-in fade-in slide-in-from-bottom-4 shadow-2xl">
|
||||
<div className="grid grid-cols-3 gap-6 text-center">
|
||||
<div>
|
||||
<p className="text-zinc-500 mb-2 uppercase tracking-widest text-[8px]">Client</p>
|
||||
<p className="text-orange-500 font-bold">{perfMetrics.comp.toFixed(0)}ms</p>
|
||||
<p className="text-[8px] opacity-40 mt-1">{(perfMetrics.uploadSize / 1024).toFixed(0)} KB</p>
|
||||
<p className="text-zinc-500 mb-2 uppercase tracking-widest text-[8px]">Compress</p>
|
||||
<p className="text-orange-500 font-bold">{scanner.perf.compression.toFixed(0)}ms</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-zinc-500 mb-2 uppercase tracking-widest text-[8px]">AI Engine</p>
|
||||
{perfMetrics.cacheHit ? (
|
||||
<div className="flex flex-col items-center">
|
||||
<p className="text-green-500 font-bold tracking-tighter">CACHE HIT</p>
|
||||
<p className="text-[7px] opacity-40 mt-1">DB RESULTS</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-orange-500 font-bold">{perfMetrics.aiTotal.toFixed(0)}ms</p>
|
||||
<div className="flex flex-col gap-0.5 mt-1 text-[7px] opacity-60">
|
||||
{perfMetrics.imagePrep !== undefined && <span>Prep: {perfMetrics.imagePrep.toFixed(0)}ms</span>}
|
||||
{perfMetrics.encoding !== undefined && <span>Encode: {perfMetrics.encoding.toFixed(0)}ms</span>}
|
||||
{perfMetrics.modelInit !== undefined && <span>Init: {perfMetrics.modelInit.toFixed(0)}ms</span>}
|
||||
<span className="text-orange-400">API: {perfMetrics.aiApi.toFixed(0)}ms</span>
|
||||
{perfMetrics.validation !== undefined && <span>Valid: {perfMetrics.validation.toFixed(0)}ms</span>}
|
||||
{perfMetrics.dbOps !== undefined && <span>DB: {perfMetrics.dbOps.toFixed(0)}ms</span>}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<p className="text-zinc-500 mb-2 uppercase tracking-widest text-[8px]">Local OCR</p>
|
||||
<p className="text-orange-500 font-bold">{scanner.perf.localOcr.toFixed(0)}ms</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-zinc-500 mb-2 uppercase tracking-widest text-[8px]">App Logic</p>
|
||||
<p className="text-orange-500 font-bold">{perfMetrics.prep.toFixed(0)}ms</p>
|
||||
<p className="text-zinc-500 mb-2 uppercase tracking-widest text-[8px]">Cloud Vision</p>
|
||||
<p className="text-orange-500 font-bold">{scanner.perf.cloudVision.toFixed(0)}ms</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -525,6 +440,7 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile, onBottleS
|
||||
exit={{ y: -50, opacity: 0 }}
|
||||
className="flex-1 w-full h-full flex flex-col min-h-0"
|
||||
>
|
||||
{/* Status banners */}
|
||||
{isOffline && (
|
||||
<div className="bg-orange-500/10 border-b border-orange-500/20 p-4">
|
||||
<div className="max-w-2xl mx-auto flex items-center gap-3">
|
||||
@@ -537,9 +453,26 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile, onBottleS
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Local preview indicator */}
|
||||
{scanner.status === 'analyzing_cloud' && scanner.localResult && (
|
||||
<div className="bg-blue-500/10 border-b border-blue-500/20 p-3">
|
||||
<div className="max-w-2xl mx-auto flex items-center gap-3">
|
||||
<Eye size={14} className="text-blue-500" />
|
||||
<p className="text-xs font-bold text-blue-500 uppercase tracking-wider flex items-center gap-2">
|
||||
Lokale Vorschau
|
||||
<span className="flex items-center gap-1 text-zinc-400 font-normal normal-case">
|
||||
<Loader2 size={10} className="animate-spin" />
|
||||
KI-Vision verfeinert Ergebnisse...
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<TastingEditor
|
||||
bottleMetadata={bottleMetadata}
|
||||
image={processedImage?.base64 || null}
|
||||
image={scanner.processedImage?.base64 || null}
|
||||
onSave={handleSaveTasting}
|
||||
onOpenSessions={() => setIsSessionsOpen(true)}
|
||||
activeSessionName={activeSession?.name}
|
||||
@@ -547,38 +480,27 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile, onBottleS
|
||||
isEnriching={isEnriching}
|
||||
defaultExpanded={true}
|
||||
/>
|
||||
{isAdmin && perfMetrics && (
|
||||
<div className="absolute top-24 left-6 right-6 z-50 p-3 bg-zinc-950/80 backdrop-blur-md rounded-2xl border border-orange-500/20 text-[9px] font-mono text-white/90 shadow-xl overflow-x-auto">
|
||||
|
||||
{/* Admin perf overlay - positioned at bottom */}
|
||||
{isAdmin && scanner.perf && (
|
||||
<div className="fixed bottom-24 left-6 right-6 z-50 p-3 bg-zinc-950/80 backdrop-blur-md rounded-2xl border border-orange-500/20 text-[9px] font-mono text-white/90 shadow-xl overflow-x-auto">
|
||||
<div className="flex items-center justify-between gap-4 whitespace-nowrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock size={10} className="text-orange-500" />
|
||||
<span className="text-zinc-500">CLIENT:</span>
|
||||
<span className="text-orange-500 font-bold">{perfMetrics.comp.toFixed(0)}ms</span>
|
||||
<span className="text-zinc-600">({(perfMetrics.uploadSize / 1024).toFixed(0)}KB)</span>
|
||||
<span className="text-zinc-500">COMPRESS:</span>
|
||||
<span className="text-orange-500 font-bold">{scanner.perf.compression.toFixed(0)}ms</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-zinc-500">AI:</span>
|
||||
{perfMetrics.cacheHit ? (
|
||||
<span className="text-green-500 font-bold tracking-tight">CACHE HIT</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-orange-500 font-bold">{perfMetrics.aiTotal.toFixed(0)}ms</span>
|
||||
<span className="text-zinc-600 ml-1 text-[10px]">
|
||||
(
|
||||
{perfMetrics.imagePrep !== undefined && `Prep:${perfMetrics.imagePrep.toFixed(0)} `}
|
||||
{perfMetrics.encoding !== undefined && `Enc:${perfMetrics.encoding.toFixed(0)} `}
|
||||
{perfMetrics.modelInit !== undefined && `Init:${perfMetrics.modelInit.toFixed(0)} `}
|
||||
<span className="text-orange-400 font-bold">API:{perfMetrics.aiApi.toFixed(0)}</span>
|
||||
{perfMetrics.validation !== undefined && ` Val:${perfMetrics.validation.toFixed(0)}`}
|
||||
{perfMetrics.dbOps !== undefined && ` DB:${perfMetrics.dbOps.toFixed(0)}`}
|
||||
)
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<span className="text-zinc-500">LOCAL:</span>
|
||||
<span className="text-blue-500 font-bold">{scanner.perf.localOcr.toFixed(0)}ms</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-zinc-500">APP:</span>
|
||||
<span className="text-orange-500 font-bold">{perfMetrics.prep.toFixed(0)}ms</span>
|
||||
<span className="text-zinc-500">CLOUD:</span>
|
||||
<span className="text-green-500 font-bold">{scanner.perf.cloudVision.toFixed(0)}ms</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-zinc-500">TOTAL:</span>
|
||||
<span className="text-orange-500 font-bold">{scanner.perf.total.toFixed(0)}ms</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -613,7 +535,7 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile, onBottleS
|
||||
balance: tastingData.balance || 85,
|
||||
}}
|
||||
bottleName={bottleMetadata.name || 'Unknown Whisky'}
|
||||
image={processedImage?.base64 || null}
|
||||
image={scanner.processedImage?.base64 || null}
|
||||
onShare={handleShare}
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
@@ -76,6 +76,57 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
|
||||
const suggestedTags = bottleMetadata.suggested_tags || [];
|
||||
const suggestedCustomTags = bottleMetadata.suggested_custom_tags || [];
|
||||
|
||||
// Track last seen confidence to detect cloud vs local updates
|
||||
const lastConfidenceRef = React.useRef<number>(0);
|
||||
|
||||
// Sync bottleMetadata prop changes to internal state (for live Gemini updates)
|
||||
// Cloud data (confidence >= 0.6 OR >= 60) overrides local OCR (confidence ~50 or ~0.5)
|
||||
useEffect(() => {
|
||||
// Normalize confidence to 0-100 scale (Gemini returns 0-1, local returns 0-100)
|
||||
const rawConfidence = bottleMetadata.confidence ?? 0;
|
||||
const newConfidence = rawConfidence <= 1 ? rawConfidence * 100 : rawConfidence;
|
||||
const isCloudUpdate = newConfidence >= 60;
|
||||
const isUpgrade = newConfidence > lastConfidenceRef.current;
|
||||
|
||||
console.log('[TastingEditor] Syncing bottleMetadata update:', {
|
||||
rawConfidence,
|
||||
normalizedConfidence: newConfidence,
|
||||
isCloudUpdate,
|
||||
isUpgrade,
|
||||
previousConfidence: lastConfidenceRef.current,
|
||||
});
|
||||
|
||||
// For cloud updates (higher confidence), allow overwriting local OCR values
|
||||
// For local updates, only fill empty fields
|
||||
if (isCloudUpdate || isUpgrade) {
|
||||
// Cloud vision: update all fields that have new data
|
||||
if (bottleMetadata.name) setBottleName(bottleMetadata.name);
|
||||
if (bottleMetadata.distillery) setBottleDistillery(bottleMetadata.distillery);
|
||||
if (bottleMetadata.abv) setBottleAbv(bottleMetadata.abv.toString());
|
||||
if (bottleMetadata.age) setBottleAge(bottleMetadata.age.toString());
|
||||
if (bottleMetadata.category) setBottleCategory(bottleMetadata.category);
|
||||
if (bottleMetadata.vintage) setBottleVintage(bottleMetadata.vintage);
|
||||
if (bottleMetadata.bottler) setBottleBottler(bottleMetadata.bottler);
|
||||
if (bottleMetadata.batch_info) setBottleBatchInfo(bottleMetadata.batch_info);
|
||||
if (bottleMetadata.distilled_at) setBottleDistilledAt(bottleMetadata.distilled_at);
|
||||
if (bottleMetadata.bottled_at) setBottleBottledAt(bottleMetadata.bottled_at);
|
||||
} else {
|
||||
// Local OCR or initial: only update empty fields
|
||||
if (!bottleName && bottleMetadata.name) setBottleName(bottleMetadata.name);
|
||||
if (!bottleDistillery && bottleMetadata.distillery) setBottleDistillery(bottleMetadata.distillery);
|
||||
if (!bottleAbv && bottleMetadata.abv) setBottleAbv(bottleMetadata.abv.toString());
|
||||
if (!bottleAge && bottleMetadata.age) setBottleAge(bottleMetadata.age.toString());
|
||||
if ((!bottleCategory || bottleCategory === 'Whisky') && bottleMetadata.category) setBottleCategory(bottleMetadata.category);
|
||||
if (!bottleVintage && bottleMetadata.vintage) setBottleVintage(bottleMetadata.vintage);
|
||||
if (!bottleBottler && bottleMetadata.bottler) setBottleBottler(bottleMetadata.bottler);
|
||||
if (!bottleBatchInfo && bottleMetadata.batch_info) setBottleBatchInfo(bottleMetadata.batch_info);
|
||||
if (!bottleDistilledAt && bottleMetadata.distilled_at) setBottleDistilledAt(bottleMetadata.distilled_at);
|
||||
if (!bottleBottledAt && bottleMetadata.bottled_at) setBottleBottledAt(bottleMetadata.bottled_at);
|
||||
}
|
||||
|
||||
lastConfidenceRef.current = newConfidence;
|
||||
}, [bottleMetadata]);
|
||||
|
||||
// Session-based pre-fill and Palette Checker
|
||||
useEffect(() => {
|
||||
const fetchSessionData = async () => {
|
||||
|
||||
Reference in New Issue
Block a user