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:
2025-12-25 13:14:08 +01:00
parent a1a91795d1
commit afe9197776
17 changed files with 3642 additions and 262 deletions

View File

@@ -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>

View File

@@ -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 () => {