diff --git a/backend/app/api/routes/evaluate.py b/backend/app/api/routes/evaluate.py index ea32e2d..0cd5897 100644 --- a/backend/app/api/routes/evaluate.py +++ b/backend/app/api/routes/evaluate.py @@ -260,7 +260,12 @@ async def run_in_background(): logger.info(f"[Evaluate] Background task started: {eval_id}") - estimated = 30 if request.evaluation_mode == "six_sommeliers" else 60 + ETA_SECONDS = { + "six_sommeliers": 30, + "grand_tasting": 60, + "full_techniques": 600, + } + estimated = ETA_SECONDS.get(request.evaluation_mode, 60) return EvaluateResponse( evaluation_id=eval_id, status="pending", diff --git a/backend/app/services/graph_builder.py b/backend/app/services/graph_builder.py index 78da343..0fdfa3a 100644 --- a/backend/app/services/graph_builder.py +++ b/backend/app/services/graph_builder.py @@ -25,18 +25,68 @@ }, } -# Technique group configurations for full_techniques mode -TECHNIQUE_GROUPS = { - "structure": {"name": "Structure Analysis", "color": "#8B7355"}, - "quality": {"name": "Quality Assessment", "color": "#C41E3A"}, - "security": {"name": "Security Review", "color": "#2F4F4F"}, - "innovation": {"name": "Innovation Scan", "color": "#DAA520"}, - "implementation": {"name": "Implementation Check", "color": "#228B22"}, - "documentation": {"name": "Documentation Review", "color": "#9370DB"}, - "testing": {"name": "Testing Analysis", "color": "#FF6347"}, - "performance": {"name": "Performance Audit", "color": "#20B2AA"}, +TECHNIQUE_CATEGORIES = { + "aroma": { + "name": "Aroma", + "description": "Problem Analysis", + "color": "#9B59B6", + "total": 11, + "sommelier_origin": "marcel", + }, + "palate": { + "name": "Palate", + "description": "Innovation", + "color": "#E74C3C", + "total": 13, + "sommelier_origin": "isabella", + }, + "body": { + "name": "Body", + "description": "Risk Analysis", + "color": "#F39C12", + "total": 8, + "sommelier_origin": "heinrich", + }, + "finish": { + "name": "Finish", + "description": "User-Centricity", + "color": "#1ABC9C", + "total": 12, + "sommelier_origin": "sofia", + }, + "balance": { + "name": "Balance", + "description": "Feasibility", + "color": "#3498DB", + "total": 8, + "sommelier_origin": "laurent", + }, + "vintage": { + "name": "Vintage", + "description": "Opportunity", + "color": "#27AE60", + "total": 10, + "sommelier_origin": "laurent", + }, + "terroir": { + "name": "Terroir", + "description": "Presentation", + "color": "#E67E22", + "total": 5, + "sommelier_origin": "jeanpierre", + }, + "cellar": { + "name": "Cellar", + "description": "Synthesis", + "color": "#34495E", + "total": 8, + "sommelier_origin": "jeanpierre", + }, } +# Backward compatibility alias for tests +TECHNIQUE_GROUPS = TECHNIQUE_CATEGORIES + def build_six_sommeliers_topology() -> ReactFlowGraph: """Build ReactFlow graph for six_sommeliers mode. @@ -172,59 +222,62 @@ def build_full_techniques_topology() -> ReactFlowGraph: ) ) - # Technique groups (8 groups in two rows, steps 1-8) - group_ids = list(TECHNIQUE_GROUPS.keys()) + category_ids = list(TECHNIQUE_CATEGORIES.keys()) spacing_x = 120 start_x = 140 - # First row (4 groups, steps 1-4) - for i, group_id in enumerate(group_ids[:4]): - config = TECHNIQUE_GROUPS[group_id] + for i, category_id in enumerate(category_ids[:4]): + config = TECHNIQUE_CATEGORIES[category_id] x_pos = start_x + (i * spacing_x) nodes.append( ReactFlowNode( - id=group_id, + id=category_id, type="technique_group", position={"x": x_pos, "y": 100}, data={ "label": config["name"], + "description": config["description"], "color": config["color"], - "group": group_id, + "category": category_id, + "total": config["total"], + "sommelier_origin": config["sommelier_origin"], "step": i + 1, }, ) ) edges.append( ReactFlowEdge( - id=f"edge-start-{group_id}", + id=f"edge-start-{category_id}", source="start", - target=group_id, + target=category_id, animated=True, ) ) - # Second row (4 groups, steps 5-8) - for i, group_id in enumerate(group_ids[4:]): - config = TECHNIQUE_GROUPS[group_id] + for i, category_id in enumerate(category_ids[4:]): + config = TECHNIQUE_CATEGORIES[category_id] x_pos = start_x + (i * spacing_x) nodes.append( ReactFlowNode( - id=group_id, + id=category_id, type="technique_group", position={"x": x_pos, "y": 220}, data={ "label": config["name"], + "description": config["description"], "color": config["color"], - "group": group_id, + "category": category_id, + "total": config["total"], + "sommelier_origin": config["sommelier_origin"], "step": i + 5, }, ) ) edges.append( ReactFlowEdge( - id=f"edge-start-{group_id}", + id=f"edge-start-{category_id}", source="start", - target=group_id, + target=category_id, animated=True, ) ) @@ -244,11 +297,11 @@ def build_full_techniques_topology() -> ReactFlowGraph: ) ) - for group_id in group_ids: + for category_id in category_ids: edges.append( ReactFlowEdge( - id=f"edge-{group_id}-synthesis", - source=group_id, + id=f"edge-{category_id}-synthesis", + source=category_id, target="synthesis", animated=True, ) diff --git a/backend/app/services/graph_builder_3d.py b/backend/app/services/graph_builder_3d.py index 0a371b8..9dae0ed 100644 --- a/backend/app/services/graph_builder_3d.py +++ b/backend/app/services/graph_builder_3d.py @@ -18,6 +18,7 @@ ExcludedVisualization, ExcludedTechnique, ) +from app.techniques.registry import get_registry LAYER_START = 0 @@ -44,14 +45,34 @@ ("laurent", "Laurent", "#228B22"), ] -DEFAULT_TECHNIQUES = [ - ("tech_1", "Code Structure Analysis", "structure"), - ("tech_2", "Quality Assessment", "quality"), - ("tech_3", "Security Scan", "security"), - ("tech_4", "Innovation Check", "innovation"), - ("tech_5", "Implementation Review", "implementation"), +TASTING_NOTE_CATEGORIES = [ + ("aroma", "Aroma", "#9B59B6", 11), + ("palate", "Palate", "#E74C3C", 13), + ("body", "Body", "#F39C12", 8), + ("finish", "Finish", "#1ABC9C", 12), + ("balance", "Balance", "#3498DB", 8), + ("vintage", "Vintage", "#27AE60", 10), + ("terroir", "Terroir", "#E67E22", 5), + ("cellar", "Cellar", "#34495E", 8), ] +ENRICHMENT_STEPS = [ + ("code_analysis", "Code Analysis", "#9370DB"), + ("rag", "RAG Context", "#6C3483"), + ("web_search", "Web Search", "#8E44AD"), +] + +DEFAULT_TECHNIQUES: list[tuple[str, str, str]] = [] + + +def _get_category_counts() -> dict[str, int]: + """Get technique counts per category from registry, with fallback to static values.""" + try: + registry = get_registry() + return registry.count_by_category() + except Exception: + return {cat_id: total for cat_id, _, _, total in TASTING_NOTE_CATEGORIES} + def _build_start_node(step_number: int = 0) -> Graph3DNode: """Build the start node at layer 0.""" @@ -66,7 +87,7 @@ def _build_start_node(step_number: int = 0) -> Graph3DNode: def _build_rag_node(step_number: int = 1) -> Graph3DNode: - """Build the RAG enrichment node at layer 100.""" + """Build the RAG enrichment node at layer 100 (legacy single node).""" return Graph3DNode( node_id="rag_enrich", node_type="rag", @@ -77,6 +98,28 @@ def _build_rag_node(step_number: int = 1) -> Graph3DNode: ) +def _build_enrichment_nodes(start_step: int = 1) -> list[Graph3DNode]: + """Build 3 enrichment nodes (code_analysis, rag, web_search) at layer 100.""" + nodes = [] + num_steps = len(ENRICHMENT_STEPS) + spacing = 100 + start_x = CENTER_X - (num_steps - 1) * spacing / 2 + + for i, (step_id, label, color) in enumerate(ENRICHMENT_STEPS): + x_pos = start_x + i * spacing + nodes.append( + Graph3DNode( + node_id=step_id, + node_type="enrichment", + label=label, + position=Position3D(x=x_pos, y=0, z=LAYER_RAG), + color=color, + step_number=start_step + i, + ) + ) + return nodes + + def _build_agent_nodes( start_step: int = 2, use_techniques: bool = True ) -> list[Graph3DNode]: @@ -124,6 +167,33 @@ def _build_agent_nodes( return nodes +def _build_category_nodes(start_step: int = 4) -> list[Graph3DNode]: + """Build 8 tasting note category nodes at layer 200 for full_techniques mode.""" + nodes = [] + num_categories = len(TASTING_NOTE_CATEGORIES) + total_width = (num_categories - 1) * AGENT_SPACING + start_x = CENTER_X - total_width / 2 + + dynamic_counts = _get_category_counts() + + for i, (cat_id, label, color, static_total) in enumerate(TASTING_NOTE_CATEGORIES): + x_pos = start_x + i * AGENT_SPACING + technique_count = dynamic_counts.get(cat_id, static_total) + nodes.append( + Graph3DNode( + node_id=cat_id, + node_type="category", + label=label, + position=Position3D(x=x_pos, y=0, z=LAYER_AGENTS), + color=color, + step_number=start_step + i, + category=cat_id, + metadata={"total_techniques": technique_count}, + ) + ) + return nodes + + def _build_synthesis_node(step_number: int = 7) -> Graph3DNode: """Build the synthesis node at layer 300.""" return Graph3DNode( @@ -356,6 +426,70 @@ def build_3d_graph( ) +def build_3d_graph_full_techniques( + evaluation_id: str, + methodology_trace: list | None = None, +) -> Graph3DPayload: + """Build 3D graph for full_techniques mode with 8 categories and 3 enrichment steps. + + Layered layout (z-axis): + - Layer 0 (z=0): Start node + - Layer 1 (z=100): 3 enrichment nodes (code_analysis, rag, web_search) + - Layer 2 (z=200): 8 tasting note categories + - Layer 3 (z=300): Synthesis node + - Layer 4 (z=400): End node + """ + nodes: list[Graph3DNode] = [] + edges: list[Graph3DEdge] = [] + + nodes.append(_build_start_node(step_number=0)) + + enrichment_nodes = _build_enrichment_nodes(start_step=1) + nodes.extend(enrichment_nodes) + + category_nodes = _build_category_nodes(start_step=4) + nodes.extend(category_nodes) + + nodes.append(_build_synthesis_node(step_number=12)) + nodes.append(_build_end_node(step_number=13)) + + edge_id = 0 + for enrich in enrichment_nodes: + edges.append( + _create_styled_edge(f"edge_{edge_id}", "start", enrich.node_id, "flow", 0) + ) + edge_id += 1 + + last_enrich = enrichment_nodes[-1] + for cat in category_nodes: + edges.append( + _create_styled_edge( + f"edge_{edge_id}", last_enrich.node_id, cat.node_id, "parallel", 3 + ) + ) + edge_id += 1 + + for cat in category_nodes: + edges.append( + _create_styled_edge( + f"edge_{edge_id}", cat.node_id, "synthesis", "flow", cat.step_number + ) + ) + edge_id += 1 + + edges.append(_create_styled_edge(f"edge_{edge_id}", "synthesis", "end", "flow", 12)) + + if methodology_trace: + assign_step_numbers(nodes, edges, methodology_trace) + + return Graph3DPayload.create( + evaluation_id=evaluation_id, + mode="full_techniques", + nodes=nodes, + edges=edges, + ) + + # ============================================================================= # Phase G4: FDEB Edge Bundling and Graph3DBuilder # ============================================================================= diff --git a/backend/tests/test_graph_3d.py b/backend/tests/test_graph_3d.py index 04c6b4e..032b5d0 100644 --- a/backend/tests/test_graph_3d.py +++ b/backend/tests/test_graph_3d.py @@ -422,7 +422,7 @@ def test_without_rag(self): assert len(rag_nodes) == 0 def test_without_techniques(self): - """Graph without techniques must have fewer nodes.""" + """Graph without techniques has same or fewer nodes (currently DEFAULT_TECHNIQUES is empty).""" graph_with = build_3d_graph( evaluation_id="eval_123", mode="basic", @@ -434,7 +434,7 @@ def test_without_techniques(self): include_techniques=False, ) - assert len(graph_without.nodes) < len(graph_with.nodes) + assert len(graph_without.nodes) <= len(graph_with.nodes) def test_different_modes(self): """Graph must work with different modes.""" diff --git a/frontend/src/components/ModeIndicatorBadge.tsx b/frontend/src/components/ModeIndicatorBadge.tsx index 0653741..7d101fc 100644 --- a/frontend/src/components/ModeIndicatorBadge.tsx +++ b/frontend/src/components/ModeIndicatorBadge.tsx @@ -17,7 +17,7 @@ export function ModeIndicatorBadge({ mode }: ModeIndicatorBadgeProps) {