From b888f5d3c8ee785cffa06cd9fbdeee1bf68e5c42 Mon Sep 17 00:00:00 2001 From: Lars Date: Sun, 5 Apr 2026 07:07:49 +0200 Subject: [PATCH] feat: Phase 4 - End Node Template Engine (v0.9n) Backend: - workflow_models.py: EndNodeOutputMode enum (AUTO, TEMPLATE) - workflow_executor.py: execute_end_node() with Jinja2 rendering - Template Context: {{node_id.analysis_core}}, {{node_id.decision_signals.key}} - Conditional Rendering: {% if node_id %} for optional paths - AUTO Mode: Backward compatible (concatenates all analyses) - TEMPLATE Mode: Custom Jinja2 templates with placeholders Features: - Access node results: {{node_id.analysis_core}} - Access signals: {{node_id.decision_signals.relevanz}} - Optional paths: {% if node_id %}...{% endif %} - Default values: {{node_id|default("N/A")}} Version: 0.9n Module: workflow 0.6.0 Konzept: konzept_workflow_engine_konsolidated.md (Section 11) Co-Authored-By: Claude Opus 4.6 --- backend/version.py | 19 ++++- backend/workflow_executor.py | 136 +++++++++++++++++++++++++++++++++-- backend/workflow_models.py | 14 +++- 3 files changed, 161 insertions(+), 8 deletions(-) diff --git a/backend/version.py b/backend/version.py index 63fe25b..739a463 100644 --- a/backend/version.py +++ b/backend/version.py @@ -7,8 +7,8 @@ Semantic Versioning: MAJOR.MINOR.PATCH - PATCH: Bugfix, kleine Änderung, Refactor """ -APP_VERSION = "0.9m" -BUILD_DATE = "2026-04-04" +APP_VERSION = "0.9n" +BUILD_DATE = "2026-04-05" DB_SCHEMA_VERSION = "20260403" # Migration 034 MODULE_VERSIONS = { @@ -27,10 +27,23 @@ MODULE_VERSIONS = { "exportdata": "1.1.0", "importdata": "1.0.0", "membership": "2.1.0", - "workflow": "0.5.0", # Phase 4: Join Nodes + Path Consolidation + "workflow": "0.6.0", # Phase 4: End Node Template Engine } CHANGELOG = [ + { + "version": "0.9n", + "date": "2026-04-05", + "changes": [ + "Phase 4: End Node Template Engine", + "workflow_models.py: EndNodeOutputMode enum (AUTO, TEMPLATE)", + "workflow_executor.py: execute_end_node() with Jinja2 template rendering", + "Template Context: {{node_id.analysis_core}}, {{node_id.decision_signals.key}}", + "Conditional Rendering: {% if node_id %} for optional paths", + "AUTO Mode: Backward compatible concatenation of all analyses", + "TEMPLATE Mode: Custom Jinja2 templates with placeholder support", + ] + }, { "version": "0.9m", "date": "2026-04-04", diff --git a/backend/workflow_executor.py b/backend/workflow_executor.py index 262688a..ca5761e 100644 --- a/backend/workflow_executor.py +++ b/backend/workflow_executor.py @@ -15,10 +15,12 @@ from datetime import datetime import uuid import logging import json +from jinja2 import Template, TemplateError from workflow_models import ( WorkflowGraph, NodeExecutionState, ExecutionResult, - NodeStatus, NormalizedSignal, FallbackStrategy, SignalStatus + NodeStatus, NormalizedSignal, FallbackStrategy, SignalStatus, + EndNodeOutputMode ) from workflow_engine import parse_workflow_graph, get_execution_order from question_augmenter import ( @@ -251,9 +253,9 @@ async def execute_node( started_at = datetime.utcnow().isoformat() try: - # Start/End Nodes: No-Op - if node.type in ["start", "end"]: - logger.debug(f"Node {node.id}: No-op ({node.type})") + # Start Node: No-Op + if node.type == "start": + logger.debug(f"Node {node.id}: No-op (start)") return NodeExecutionState( node_id=node.id, status=NodeStatus.EXECUTED, @@ -261,6 +263,10 @@ async def execute_node( completed_at=datetime.utcnow().isoformat() ) + # End Node: Output Generation (Phase 4) + if node.type == "end": + return execute_end_node(node, context) + # Logic Nodes (Phase 3) if node.type == "logic": return execute_logic_node(node, context, graph) @@ -515,6 +521,128 @@ def execute_join_node( ) +def execute_end_node( + node, + context: Dict[str, Any] +) -> NodeExecutionState: + """ + Führt End Node aus (Phase 4 - Template Engine). + + Args: + node: WorkflowNode vom Typ "end" + context: Execution context mit node_results + + Returns: + NodeExecutionState mit finaler Ausgabe in analysis_core + + Output Modes: + - AUTO: Concatenates all analysis_core values (backward compatible) + - TEMPLATE: Renders Jinja2 template with {{node_id.property}} placeholders + + Template Context: + - {{node_id.analysis_core}}: Analysis text from node + - {{node_id.decision_signals}}: Dict of raw decision signals + - {{node_id.decision_signals.key}}: Specific signal value + - {{node_id.status}}: Node execution status + - Conditional rendering: {% if node_id %}...{% endif %} + - Default values: {{node_id|default("fallback")}} + + Example Template: + ``` + # Final Analysis + + ## Body Composition + {{body_analysis.analysis_core}} + + {% if training_analysis %} + ## Training Recommendation + {{training_analysis.analysis_core}} + {% endif %} + + ## Decision Factors + - Relevance: {{body_analysis.decision_signals.relevanz}} + - Priority: {{body_analysis.decision_signals.prioritaet}} + ``` + """ + started_at = datetime.utcnow().isoformat() + + try: + logger.info(f"Executing end node: {node.id}") + + # Determine output mode (default: AUTO for backward compatibility) + output_mode = node.output_mode or EndNodeOutputMode.AUTO + + if output_mode == EndNodeOutputMode.AUTO: + # AUTO mode: Concatenate all analysis_core values + logger.debug(f"End node {node.id}: Using AUTO output mode") + + combined_analysis = [] + for node_id, node_state in context.get("node_results", {}).items(): + if node_state.status == NodeStatus.EXECUTED and node_state.analysis_core: + combined_analysis.append(f"## {node_id}\n{node_state.analysis_core}") + + final_output = "\n\n".join(combined_analysis) if combined_analysis else "[No analysis generated]" + + elif output_mode == EndNodeOutputMode.TEMPLATE: + # TEMPLATE mode: Render Jinja2 template + logger.debug(f"End node {node.id}: Using TEMPLATE output mode") + + if not node.template: + raise ValueError(f"End node {node.id} has output_mode=TEMPLATE but no template defined") + + # Build template context: {{node_id}} → {analysis_core, decision_signals, status} + template_context = {} + for node_id, node_state in context.get("node_results", {}).items(): + template_context[node_id] = { + "analysis_core": node_state.analysis_core or "", + "decision_signals": node_state.decision_signals or {}, + "reasoning_anchors": node_state.reasoning_anchors or "", + "status": node_state.status.value if node_state.status else "unknown", + # Add individual signal access: {{node_id.signal_name}} + **node_state.decision_signals # Flatten signals into node context + } + + logger.debug(f"End node {node.id}: Built template context for {len(template_context)} nodes") + + # Render template + try: + jinja_template = Template(node.template) + final_output = jinja_template.render(template_context) + logger.info(f"End node {node.id}: Template rendered successfully ({len(final_output)} chars)") + except TemplateError as te: + error_msg = f"Template rendering failed: {str(te)}" + logger.error(f"End node {node.id}: {error_msg}") + return NodeExecutionState( + node_id=node.id, + status=NodeStatus.FAILED, + error=error_msg, + started_at=started_at, + completed_at=datetime.utcnow().isoformat() + ) + + else: + raise ValueError(f"Unknown output_mode: {output_mode}") + + # Return NodeExecutionState with final output + return NodeExecutionState( + node_id=node.id, + status=NodeStatus.EXECUTED, + analysis_core=final_output, + started_at=started_at, + completed_at=datetime.utcnow().isoformat() + ) + + except Exception as e: + logger.error(f"End node execution failed ({node.id}): {e}", exc_info=True) + return NodeExecutionState( + node_id=node.id, + status=NodeStatus.FAILED, + error=str(e), + started_at=started_at, + completed_at=datetime.utcnow().isoformat() + ) + + def _apply_fallback( node, graph: WorkflowGraph, diff --git a/backend/workflow_models.py b/backend/workflow_models.py index 3d6d2ab..12134c9 100644 --- a/backend/workflow_models.py +++ b/backend/workflow_models.py @@ -79,6 +79,12 @@ class LogicOperator(str, Enum): NOT = "not" +class EndNodeOutputMode(str, Enum): + """Output-Modi für End Node (Phase 4)""" + AUTO = "auto" # Automatisch: Concatenate all analyses + TEMPLATE = "template" # Custom Jinja2 template + + # ── Hilfsmodelle ────────────────────────────────────────────────────────────── class Position(BaseModel): @@ -191,9 +197,15 @@ class WorkflowNode(BaseModel): condition: Optional[Condition] = Field(None, description="Bedingung für Pfad-Routing") fallback: Optional[FallbackConfig] = Field(None, description="Fallback-Konfiguration") - # JOIN-Knoten + # JOIN-Knoten (Phase 4) join_strategy: Optional[JoinStrategy] = Field(None, description="Join-Strategie") skip_handling: Optional[SkipHandling] = Field(None, description="Umgang mit übersprungenen Pfaden") + min_paths: Optional[int] = Field(None, description="Mindestanzahl erforderlicher Pfade (für REQUIRE_MINIMUM)") + timeout_seconds: Optional[int] = Field(None, description="Timeout für BEST_EFFORT-Strategie") + + # END-Knoten (Phase 4) + output_mode: Optional[EndNodeOutputMode] = Field(None, description="Output-Modus: auto oder template") + template: Optional[str] = Field(None, description="Jinja2 template für finales Ergebnis (wenn output_mode=template)") class WorkflowEdge(BaseModel):