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
|
- PATCH: Bugfix, kleine Änderung, Refactor
|
||||||
"""
|
"""
|
||||||
|
|
||||||
APP_VERSION = "0.9m"
|
APP_VERSION = "0.9n"
|
||||||
BUILD_DATE = "2026-04-04"
|
BUILD_DATE = "2026-04-05"
|
||||||
DB_SCHEMA_VERSION = "20260403" # Migration 034
|
DB_SCHEMA_VERSION = "20260403" # Migration 034
|
||||||
|
|
||||||
MODULE_VERSIONS = {
|
MODULE_VERSIONS = {
|
||||||
|
|
@ -27,10 +27,23 @@ MODULE_VERSIONS = {
|
||||||
"exportdata": "1.1.0",
|
"exportdata": "1.1.0",
|
||||||
"importdata": "1.0.0",
|
"importdata": "1.0.0",
|
||||||
"membership": "2.1.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 = [
|
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",
|
"version": "0.9m",
|
||||||
"date": "2026-04-04",
|
"date": "2026-04-04",
|
||||||
|
|
|
||||||
|
|
@ -15,10 +15,12 @@ from datetime import datetime
|
||||||
import uuid
|
import uuid
|
||||||
import logging
|
import logging
|
||||||
import json
|
import json
|
||||||
|
from jinja2 import Template, TemplateError
|
||||||
|
|
||||||
from workflow_models import (
|
from workflow_models import (
|
||||||
WorkflowGraph, NodeExecutionState, ExecutionResult,
|
WorkflowGraph, NodeExecutionState, ExecutionResult,
|
||||||
NodeStatus, NormalizedSignal, FallbackStrategy, SignalStatus
|
NodeStatus, NormalizedSignal, FallbackStrategy, SignalStatus,
|
||||||
|
EndNodeOutputMode
|
||||||
)
|
)
|
||||||
from workflow_engine import parse_workflow_graph, get_execution_order
|
from workflow_engine import parse_workflow_graph, get_execution_order
|
||||||
from question_augmenter import (
|
from question_augmenter import (
|
||||||
|
|
@ -251,9 +253,9 @@ async def execute_node(
|
||||||
started_at = datetime.utcnow().isoformat()
|
started_at = datetime.utcnow().isoformat()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Start/End Nodes: No-Op
|
# Start Node: No-Op
|
||||||
if node.type in ["start", "end"]:
|
if node.type == "start":
|
||||||
logger.debug(f"Node {node.id}: No-op ({node.type})")
|
logger.debug(f"Node {node.id}: No-op (start)")
|
||||||
return NodeExecutionState(
|
return NodeExecutionState(
|
||||||
node_id=node.id,
|
node_id=node.id,
|
||||||
status=NodeStatus.EXECUTED,
|
status=NodeStatus.EXECUTED,
|
||||||
|
|
@ -261,6 +263,10 @@ async def execute_node(
|
||||||
completed_at=datetime.utcnow().isoformat()
|
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)
|
# Logic Nodes (Phase 3)
|
||||||
if node.type == "logic":
|
if node.type == "logic":
|
||||||
return execute_logic_node(node, context, graph)
|
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(
|
def _apply_fallback(
|
||||||
node,
|
node,
|
||||||
graph: WorkflowGraph,
|
graph: WorkflowGraph,
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,12 @@ class LogicOperator(str, Enum):
|
||||||
NOT = "not"
|
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 ──────────────────────────────────────────────────────────────
|
# ── Hilfsmodelle ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class Position(BaseModel):
|
class Position(BaseModel):
|
||||||
|
|
@ -191,9 +197,15 @@ class WorkflowNode(BaseModel):
|
||||||
condition: Optional[Condition] = Field(None, description="Bedingung für Pfad-Routing")
|
condition: Optional[Condition] = Field(None, description="Bedingung für Pfad-Routing")
|
||||||
fallback: Optional[FallbackConfig] = Field(None, description="Fallback-Konfiguration")
|
fallback: Optional[FallbackConfig] = Field(None, description="Fallback-Konfiguration")
|
||||||
|
|
||||||
# JOIN-Knoten
|
# JOIN-Knoten (Phase 4)
|
||||||
join_strategy: Optional[JoinStrategy] = Field(None, description="Join-Strategie")
|
join_strategy: Optional[JoinStrategy] = Field(None, description="Join-Strategie")
|
||||||
skip_handling: Optional[SkipHandling] = Field(None, description="Umgang mit übersprungenen Pfaden")
|
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):
|
class WorkflowEdge(BaseModel):
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user