mitai-jinkendo/backend/routers/workflows.py
Lars 1f8791f4dd
All checks were successful
Deploy Development / deploy (push) Successful in 44s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 14s
feat: Phase 2 - Normalisierung + Workflow Executor
Backend:
- normalization_engine.py (200 Zeilen): Synonym-Mapping, 5 Statuswerte
  * normalize_decision_signal(): Kaskade (exact → case → synonym → invalid)
  * apply_synonym_mapping(): DB-basierte Synonyme (case-insensitive)
  * normalize_all_signals(): Batch-Processing gegen Katalog
  * load_question_catalog(): Lädt normalization_rules aus DB
- workflow_executor.py (440 Zeilen): Sequenzielle Workflow-Ausführung
  * execute_workflow(): Traversiert DAG in topologischer Reihenfolge
  * execute_node(): Führt analysis nodes aus (start/end = no-op)
  * aggregate_results(): Kombiniert analysis_core + normalized_signals
  * save_execution_state(): Persistiert in workflow_executions
- workflow_models.py: Erweitert um Phase 2 Models
  * SignalStatus Enum (valid, normalized, unclear, invalid, not_decidable)
  * NormalizedSignal (question_type, raw_value, normalized_value, status)
  * NodeExecutionState (node_id, status, analysis_core, normalized_signals)
  * ExecutionResult (execution_id, workflow_id, status, node_states, aggregated_result)
- workflow_engine.py: Neue Funktion get_execution_order()
  * Flattened topological sort für sequenzielle Execution
  * Phase 7: Wird zu levels (parallele Execution)
- prompt_executor.py: execute_workflow_prompt() Implementierung
  * Ruft workflow_executor.execute_workflow() auf
  * Konvertiert ExecutionResult zu API-Response
- routers/workflows.py (230 Zeilen): Workflow Execution API
  * POST /api/workflows/{id}/execute (mit enable_debug)
  * GET /api/workflows/executions/{id} (lädt gespeicherten State)
  * GET /api/workflows (listet alle aktiven Workflows)
  * GET /api/workflows/{id} (lädt einzelnen Workflow mit Graph)
- main.py: Router-Registrierung (workflows.router)

Tests:
- test_phase2_normalization.py (17 Tests): Alle Normalisierungs-Szenarien
  * Exact match, case-insensitive, synonym mapping, invalid, whitespace
  * Batch-Normalisierung, not_in_catalog, mixed validity
- test_phase2_workflow_executor.py (10 Tests): Executor + Aggregation
  * aggregate_results mit verschiedenen Konstellationen
  * execute_node für start/end/analysis/unknown
  * Integration mit question_augmenter + result_container_parser

Alle 27 Unit-Tests bestanden.

version: 0.9k (backend)
module:  workflow 0.3.0

Konzept: .claude/task/Workflow_engine_prompting_engine/anforderungsanalyse_umsetzungsplan.md (Phase 2)
2026-04-03 21:20:23 +02:00

223 lines
5.9 KiB
Python

"""
Workflow Execution Router (Phase 2)
Endpunkte für Workflow-Execution und Ergebnis-Abruf.
Phase 2: Sequenzielle Execution
Phase 3: Conditional branching
"""
from fastapi import APIRouter, Depends, HTTPException
from auth import require_auth
from db import get_db, get_cursor, r2d
from pydantic import BaseModel
from typing import Dict, Any, Optional
import logging
logger = logging.getLogger(__name__)
router = APIRouter()
class WorkflowExecuteRequest(BaseModel):
"""Request-Body für Workflow-Execution"""
variables: Dict[str, Any] = {}
enable_debug: bool = False
@router.post("/api/workflows/{workflow_id}/execute")
async def execute_workflow_endpoint(
workflow_id: str,
request: WorkflowExecuteRequest,
session: dict = Depends(require_auth)
):
"""
Führt einen Workflow aus.
Args:
workflow_id: UUID des Workflows (aus workflow_definitions)
request.variables: Platzhalter-Werte (optional, z.B. {"name": "Lars"})
request.enable_debug: Debug-Modus (zeigt node_states im Response)
Returns:
{
"execution_id": "...",
"status": "completed" | "failed",
"aggregated_result": {
"combined_analysis": "...",
"all_signals": [...],
"total_nodes": 3,
"executed_nodes": 3,
"failed_nodes": 0
},
"node_states": [...], # Nur wenn enable_debug=true
"error": "..." # Nur wenn failed
}
Beispiel:
POST /api/workflows/abc123/execute
{
"variables": {"name": "Lars"},
"enable_debug": true
}
"""
from prompt_executor import execute_workflow_prompt
from openrouter import call_openrouter_async
profile_id = session["profile_id"]
# Add profile_id to variables (für placeholder_resolver)
variables = {**request.variables, "profile_id": profile_id}
# Load workflow as "prompt" (für execute_workflow_prompt)
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"SELECT id, name, slug FROM workflow_definitions WHERE id = %s AND active = true",
(workflow_id,)
)
row = cur.fetchone()
if not row:
raise HTTPException(404, f"Workflow nicht gefunden: {workflow_id}")
workflow_prompt = {
"id": row[0],
"name": row[1],
"slug": row[2],
"type": "workflow"
}
try:
result = await execute_workflow_prompt(
prompt=workflow_prompt,
variables=variables,
openrouter_call_func=call_openrouter_async,
enable_debug=request.enable_debug
)
return result
except Exception as e:
logger.error(f"Workflow execution failed: {e}", exc_info=True)
raise HTTPException(500, f"Workflow-Ausführung fehlgeschlagen: {str(e)}")
@router.get("/api/workflows/executions/{execution_id}")
def get_execution_result(
execution_id: str,
session: dict = Depends(require_auth)
):
"""
Lädt gespeicherten Execution State aus DB.
Args:
execution_id: UUID der Execution (aus workflow_executions)
Returns:
{
"id": "...",
"workflow_id": "...",
"profile_id": "...",
"status": "completed" | "failed",
"node_states": [...], # JSONB
"execution_log": {...},
"started_at": "2026-04-03T12:00:00",
"completed_at": "2026-04-03T12:00:10"
}
Beispiel:
GET /api/workflows/executions/abc123
"""
profile_id = session["profile_id"]
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("""
SELECT id, workflow_id, profile_id, status, node_states, execution_log,
started_at::text, completed_at::text
FROM workflow_executions
WHERE id = %s AND profile_id = %s
""", (execution_id, profile_id))
row = cur.fetchone()
if not row:
raise HTTPException(404, "Execution nicht gefunden")
return r2d(row)
@router.get("/api/workflows")
def list_workflows(
session: dict = Depends(require_auth)
):
"""
Listet alle aktiven Workflows auf.
Returns:
[
{
"id": "...",
"name": "...",
"slug": "...",
"description": "...",
"version": 1,
"created_at": "...",
"updated_at": "..."
},
...
]
Beispiel:
GET /api/workflows
"""
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("""
SELECT id, name, slug, description, version,
created_at::text, updated_at::text
FROM workflow_definitions
WHERE active = true
ORDER BY name
""")
rows = cur.fetchall()
return [r2d(row) for row in rows]
@router.get("/api/workflows/{workflow_id}")
def get_workflow(
workflow_id: str,
session: dict = Depends(require_auth)
):
"""
Lädt einen einzelnen Workflow mit Graph.
Args:
workflow_id: UUID des Workflows
Returns:
{
"id": "...",
"name": "...",
"slug": "...",
"description": "...",
"graph": {...}, # JSONB
"version": 1,
"active": true,
"created_at": "...",
"updated_at": "..."
}
"""
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("""
SELECT id, name, slug, description, graph, version, active,
created_at::text, updated_at::text
FROM workflow_definitions
WHERE id = %s AND active = true
""", (workflow_id,))
row = cur.fetchone()
if not row:
raise HTTPException(404, "Workflow nicht gefunden")
return r2d(row)