diff --git a/backend/app/models/results.py b/backend/app/models/results.py index ac33245..9c6389a 100644 --- a/backend/app/models/results.py +++ b/backend/app/models/results.py @@ -11,7 +11,7 @@ from datetime import datetime from enum import Enum -from typing import List +from typing import List, Optional from pydantic import BaseModel, ConfigDict, Field @@ -63,18 +63,25 @@ def get_rating_tier(score: int) -> RatingTier: return RatingTier.Corked -class SommelierOutput(BaseModel): - """Model for individual sommelier AI agent evaluation output. +class TechniqueDetail(BaseModel): + id: str = Field(..., description="Technique ID") + name: str = Field(..., description="Technique name") + status: str = Field(..., description="success, failed, or skipped") + score: Optional[float] = Field(None, description="Score if evaluated") + max_score: Optional[float] = Field(None, description="Maximum possible score") + error: Optional[str] = Field(None, description="Error message if failed") - Each of the six AI sommeliers produces this output. - """ +class SommelierOutput(BaseModel): sommelier_name: str = Field(..., description="Name of the sommelier agent") score: int = Field(..., ge=0, le=100, description="Score from 0 to 100") summary: str = Field(..., description="Brief summary of the evaluation") recommendations: List[str] = Field( default_factory=list, description="List of recommendations" ) + technique_details: List[TechniqueDetail] = Field( + default_factory=list, description="Detailed technique results" + ) class FinalEvaluation(BaseModel): diff --git a/backend/app/services/evaluation_service.py b/backend/app/services/evaluation_service.py index a5ef479..ba6f7ab 100644 --- a/backend/app/services/evaluation_service.py +++ b/backend/app/services/evaluation_service.py @@ -24,6 +24,19 @@ logger = logging.getLogger(__name__) +# Module-level constant for full_techniques mode tasting note categories +# Maps category key -> (display_name, role, expected_technique_count) +TASTING_NOTE_CONFIG = { + "aroma": ("Aroma Notes", "Problem Analysis", 11), + "palate": ("Palate Notes", "Innovation", 13), + "body": ("Body Notes", "Risk Analysis", 7), + "finish": ("Finish Notes", "User-Centricity", 7), + "balance": ("Balance Notes", "Feasibility", 8), + "vintage": ("Vintage Notes", "Opportunity", 8), + "terroir": ("Terroir Notes", "Presentation", 6), + "cellar": ("Cellar Notes", "Synthesis", 15), +} + async def _get_stored_key( user_id: str, provider: str = "google" @@ -303,12 +316,18 @@ async def save_evaluation_results( """ repo = ResultRepository() - from app.models.results import get_rating_tier, SommelierOutput, FinalEvaluation + from app.models.results import ( + get_rating_tier, + SommelierOutput, + FinalEvaluation, + TechniqueDetail, + ) cellar_result = evaluation_data.get("cellar_result") jeanpierre_result = evaluation_data.get("jeanpierre_result") is_grand_tasting = evaluation_mode == "grand_tasting" + is_full_techniques = evaluation_mode == "full_techniques" if is_grand_tasting: cellar_result = cellar_result or {} @@ -341,6 +360,93 @@ async def save_evaluation_results( recommendations=technique_names, ) ) + elif is_full_techniques: + from app.techniques.router import TechniqueRouter + + normalized_score = evaluation_data.get("normalized_score", 0) + overall_score = round(normalized_score) + rating_tier = get_rating_tier(overall_score) + quality_gate = evaluation_data.get("quality_gate", "") + coverage = evaluation_data.get("coverage_rate", 0) + + summary = ( + f"Comprehensive evaluation using 75 techniques. " + f"Quality Gate: {quality_gate}. " + f"Coverage: {coverage * 100:.1f}%." + ) + + trace_metadata = evaluation_data.get("trace_metadata", {}) + techniques_used = set(evaluation_data.get("techniques_used", [])) + + router = TechniqueRouter() + + sommelier_outputs = [] + for cat_key, (name, role, expected_count) in TASTING_NOTE_CONFIG.items(): + cat_trace = trace_metadata.get(cat_key, {}) + succeeded = cat_trace.get("techniques_succeeded", 0) + total = cat_trace.get("techniques_count", expected_count) + failed_list = cat_trace.get("failed_techniques", []) + + success_rate = (succeeded / total * 100) if total > 0 else 0 + scaled_score = min(max(int(success_rate), 0), 100) + + cat_summary = ( + f"{name} ({role}): {succeeded}/{total} techniques succeeded. " + f"Success rate: {success_rate:.1f}%." + ) + + technique_info = [] + if succeeded > 0: + technique_info.append(f"{succeeded} passed") + if failed_list: + technique_info.append(f"{len(failed_list)} failed") + + cat_technique_ids = router.select_techniques( + mode="full_techniques", category=cat_key + ) + technique_details = [] + for tech_id in cat_technique_ids: + tech_def = router.get_technique(tech_id) + tech_name = tech_def.name if tech_def else tech_id + + failed_entry = next( + (f for f in failed_list if f.get("technique_id") == tech_id), None + ) + if failed_entry: + technique_details.append( + TechniqueDetail( + id=tech_id, + name=tech_name, + status="failed", + error=failed_entry.get("error"), + ) + ) + elif tech_id in techniques_used: + technique_details.append( + TechniqueDetail( + id=tech_id, + name=tech_name, + status="success", + ) + ) + else: + technique_details.append( + TechniqueDetail( + id=tech_id, + name=tech_name, + status="skipped", + ) + ) + + sommelier_outputs.append( + SommelierOutput( + sommelier_name=name, + score=scaled_score, + summary=cat_summary, + recommendations=technique_info, + technique_details=technique_details, + ) + ) else: jeanpierre_result = jeanpierre_result or {} overall_score = jeanpierre_result.get("total_score", 0) diff --git a/frontend/src/components/SommelierCard.tsx b/frontend/src/components/SommelierCard.tsx index 02f40b0..7b60612 100644 --- a/frontend/src/components/SommelierCard.tsx +++ b/frontend/src/components/SommelierCard.tsx @@ -2,8 +2,9 @@ import React, { useState } from 'react'; import Image from 'next/image'; -import { ChevronDown, ChevronUp, Lightbulb } from 'lucide-react'; +import { ChevronDown, ChevronUp, Lightbulb, CheckCircle2, XCircle, MinusCircle } from 'lucide-react'; import { getSommelierTheme } from '../lib/sommeliers'; +import { TechniqueDetail } from '../types'; interface SommelierCardProps { id: string; @@ -13,6 +14,7 @@ interface SommelierCardProps { feedback: string; recommendations?: string[]; pairingSuggestion?: string; + techniqueDetails?: TechniqueDetail[]; delay?: number; } @@ -48,9 +50,11 @@ export function SommelierCard({ feedback, recommendations, pairingSuggestion, + techniqueDetails, delay = 0, }: SommelierCardProps) { const [isExpanded, setIsExpanded] = useState(false); + const [showTechniques, setShowTechniques] = useState(false); const theme = getSommelierTheme(id); return ( @@ -165,6 +169,59 @@ export function SommelierCard({

)} + + {/* Technique Details - Expandable */} + {techniqueDetails && techniqueDetails.length > 0 && ( +
+ + + {showTechniques && ( +
+ {techniqueDetails.map((tech) => ( +
+ {tech.status === 'success' && ( + + )} + {tech.status === 'failed' && ( + + )} + {tech.status === 'skipped' && ( + + )} +
+

+ {tech.name} +

+ {tech.error && ( +

{tech.error}

+ )} +
+ {tech.score !== undefined && tech.maxScore !== undefined && ( + + {tech.score}/{tech.maxScore} + + )} +
+ ))} +
+ )} +
+ )} ); } diff --git a/frontend/src/components/TastingNotesTab.tsx b/frontend/src/components/TastingNotesTab.tsx index 4c2efce..6a06180 100644 --- a/frontend/src/components/TastingNotesTab.tsx +++ b/frontend/src/components/TastingNotesTab.tsx @@ -199,6 +199,7 @@ export function TastingNotesTab({ result }: TastingNotesTabProps) { feedback={somm.feedback} recommendations={somm.recommendations} pairingSuggestion={somm.pairingSuggestion} + techniqueDetails={somm.techniqueDetails} delay={index * 100} /> ))} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 61b5dad..4e6eb1d 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -44,11 +44,21 @@ export interface QuotaStatus { const BASE_API_URL = process.env.NEXT_PUBLIC_API_URL || 'https://api.somm.dev'; const TOKEN_STORAGE_KEY = 'somm_auth_token'; +interface BackendTechniqueDetail { + id: string; + name: string; + status: string; + score?: number; + max_score?: number; + error?: string; +} + interface BackendSommelierOutput { sommelier_name?: string; score?: number; summary?: string; recommendations?: string[]; + technique_details?: BackendTechniqueDetail[]; } interface BackendHistoryItem { @@ -193,6 +203,7 @@ export const api = { 'Balance Notes': 'Feasibility', 'Vintage Notes': 'Opportunity', 'Terroir Notes': 'Presentation', + 'Cellar Notes': 'Synthesis', }; const sommelierOutputs = response.final_evaluation?.sommelier_outputs || []; @@ -204,6 +215,14 @@ export const api = { feedback: output.summary || '', recommendations: output.recommendations || [], pairingSuggestion: output.recommendations?.[0] || undefined, + techniqueDetails: output.technique_details?.map((t: BackendTechniqueDetail) => ({ + id: t.id, + name: t.name, + status: t.status as 'success' | 'failed' | 'skipped', + score: t.score, + maxScore: t.max_score, + error: t.error, + })), })); return { diff --git a/frontend/src/lib/sommeliers.ts b/frontend/src/lib/sommeliers.ts index e69e14f..6ae4258 100644 --- a/frontend/src/lib/sommeliers.ts +++ b/frontend/src/lib/sommeliers.ts @@ -96,6 +96,110 @@ export const SOMMELIER_THEMES: Record = { description: 'Final Synthesis', image: '/sommeliers/jeanpierre.png', }, + aromanotes: { + id: 'aromanotes', + name: 'Aroma Notes', + role: 'Problem Analysis', + emoji: '🔍', + color: '#8B7355', + bgColor: 'bg-amber-800', + borderColor: 'border-amber-700', + textColor: 'text-amber-800', + lightBg: 'bg-amber-50', + description: 'Problem Analysis', + image: '/sommeliers/marcel.png', + }, + palatenotes: { + id: 'palatenotes', + name: 'Palate Notes', + role: 'Innovation', + emoji: '🎭', + color: '#C41E3A', + bgColor: 'bg-rose-700', + borderColor: 'border-rose-600', + textColor: 'text-rose-700', + lightBg: 'bg-rose-50', + description: 'Innovation', + image: '/sommeliers/isabella.png', + }, + bodynotes: { + id: 'bodynotes', + name: 'Body Notes', + role: 'Risk Analysis', + emoji: '🔍', + color: '#2F4F4F', + bgColor: 'bg-slate-700', + borderColor: 'border-slate-600', + textColor: 'text-slate-700', + lightBg: 'bg-slate-50', + description: 'Risk Analysis', + image: '/sommeliers/heinrich.png', + }, + finishnotes: { + id: 'finishnotes', + name: 'Finish Notes', + role: 'User-Centricity', + emoji: '🎯', + color: '#722F37', + bgColor: 'bg-[#722F37]', + borderColor: 'border-[#722F37]', + textColor: 'text-[#722F37]', + lightBg: 'bg-[#F7E7CE]', + description: 'User-Centricity', + image: '/sommeliers/jeanpierre.png', + }, + balancenotes: { + id: 'balancenotes', + name: 'Balance Notes', + role: 'Feasibility', + emoji: '⚖️', + color: '#228B22', + bgColor: 'bg-emerald-700', + borderColor: 'border-emerald-600', + textColor: 'text-emerald-700', + lightBg: 'bg-emerald-50', + description: 'Feasibility', + image: '/sommeliers/laurent.png', + }, + vintagenotes: { + id: 'vintagenotes', + name: 'Vintage Notes', + role: 'Opportunity', + emoji: '🌱', + color: '#DAA520', + bgColor: 'bg-yellow-600', + borderColor: 'border-yellow-500', + textColor: 'text-yellow-700', + lightBg: 'bg-yellow-50', + description: 'Opportunity', + image: '/sommeliers/sofia.png', + }, + terroirnotes: { + id: 'terroirnotes', + name: 'Terroir Notes', + role: 'Presentation', + emoji: '🏛️', + color: '#6B4423', + bgColor: 'bg-amber-900', + borderColor: 'border-amber-800', + textColor: 'text-amber-900', + lightBg: 'bg-amber-50', + description: 'Presentation', + image: '/sommeliers/marcel.png', + }, + cellarnotes: { + id: 'cellarnotes', + name: 'Cellar Notes', + role: 'Synthesis', + emoji: '🍷', + color: '#4A1C24', + bgColor: 'bg-[#4A1C24]', + borderColor: 'border-[#4A1C24]', + textColor: 'text-[#4A1C24]', + lightBg: 'bg-rose-50', + description: 'Synthesis', + image: '/sommeliers/jeanpierre.png', + }, }; export function getSommelierTheme(id: string): SommelierTheme { diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index e0a10ab..1023753 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -10,6 +10,15 @@ export interface EvaluationRequest { evaluationMode: EvaluationMode; } +export interface TechniqueDetail { + id: string; + name: string; + status: 'success' | 'failed' | 'skipped'; + score?: number; + maxScore?: number; + error?: string; +} + export interface SommelierResult { id: string; name: string; @@ -18,6 +27,7 @@ export interface SommelierResult { feedback: string; recommendations?: string[]; pairingSuggestion?: string; + techniqueDetails?: TechniqueDetail[]; } export interface EvaluationResult {