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 <noreply@anthropic.com>
This commit is contained in:
parent
cab5758b0d
commit
b888f5d3c8
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user