feat: Phase 4 - End Node Template Engine (v0.9n)
All checks were successful
Deploy Development / deploy (push) Successful in 52s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 14s

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 <noreply@anthropic.com>
This commit is contained in:
Lars 2026-04-05 07:07:49 +02:00
parent cab5758b0d
commit b888f5d3c8
3 changed files with 161 additions and 8 deletions

View File

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

View File

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

View File

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