Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 12 additions & 5 deletions backend/app/models/results.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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):
Expand Down
108 changes: 107 additions & 1 deletion backend/app/services/evaluation_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 {}
Expand Down Expand Up @@ -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}%."
)
Comment on lines +371 to +376

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

For better maintainability and performance, consider defining BMAD_CATEGORY_CONFIG as a module-level constant outside of this function. Since it's a static configuration, it doesn't need to be redefined on every function call. This also makes it clearer that this is a fixed configuration for the full_techniques mode.


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)
Expand Down
59 changes: 58 additions & 1 deletion frontend/src/components/SommelierCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -13,6 +14,7 @@ interface SommelierCardProps {
feedback: string;
recommendations?: string[];
pairingSuggestion?: string;
techniqueDetails?: TechniqueDetail[];
delay?: number;
}

Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -165,6 +169,59 @@ export function SommelierCard({
</p>
</div>
)}

{/* Technique Details - Expandable */}
{techniqueDetails && techniqueDetails.length > 0 && (
<div className="border-t border-gray-100">
<button
onClick={() => setShowTechniques(!showTechniques)}
className="w-full px-5 py-3 flex items-center justify-between hover:bg-gray-50 transition-colors"
>
<span className="text-xs font-bold uppercase tracking-wider text-gray-500">
Technique Details ({techniqueDetails.length})
</span>
{showTechniques ? (
<ChevronUp size={16} className="text-gray-400" />
) : (
<ChevronDown size={16} className="text-gray-400" />
)}
</button>

{showTechniques && (
<div className="px-5 pb-4 space-y-2 max-h-64 overflow-y-auto">
{techniqueDetails.map((tech) => (
<div
key={tech.id}
className="flex items-center gap-2 py-1.5 px-2 rounded-lg bg-gray-50"
>
{tech.status === 'success' && (
<CheckCircle2 size={14} className="text-green-500 flex-shrink-0" />
)}
{tech.status === 'failed' && (
<XCircle size={14} className="text-red-500 flex-shrink-0" />
)}
{tech.status === 'skipped' && (
<MinusCircle size={14} className="text-gray-400 flex-shrink-0" />
)}
<div className="flex-1 min-w-0">
<p className="text-xs font-medium text-gray-700 truncate">
{tech.name}
</p>
{tech.error && (
<p className="text-xs text-red-500 truncate">{tech.error}</p>
)}
</div>
{tech.score !== undefined && tech.maxScore !== undefined && (
<span className="text-xs text-gray-500 flex-shrink-0">
{tech.score}/{tech.maxScore}
</span>
)}
</div>
))}
</div>
)}
</div>
)}
</div>
);
}
1 change: 1 addition & 0 deletions frontend/src/components/TastingNotesTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ export function TastingNotesTab({ result }: TastingNotesTabProps) {
feedback={somm.feedback}
recommendations={somm.recommendations}
pairingSuggestion={somm.pairingSuggestion}
techniqueDetails={somm.techniqueDetails}
delay={index * 100}
/>
))}
Expand Down
19 changes: 19 additions & 0 deletions frontend/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 || [];
Expand All @@ -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 {
Expand Down
Loading
Loading