diff --git a/.env.example b/.env.example index 6cc4aa5..9a13814 100644 --- a/.env.example +++ b/.env.example @@ -27,3 +27,9 @@ ALLOWED_ORIGINS=https://mitai.jinkendo.de # ── Pfade ─────────────────────────────────────────────────────── PHOTOS_DIR=/app/photos ENVIRONMENT=production + +# ── Gitea API (lokal, für scripts/gitea/gitea_api.py – niemals committen) ── +GITEA_BASE_URL=http://192.168.2.144:3000 +GITEA_OWNER=Lars +GITEA_REPO=mitai-jinkendo +GITEA_TOKEN= diff --git a/.gitignore b/.gitignore index 653a6a6..97d2d19 100644 --- a/.gitignore +++ b/.gitignore @@ -61,4 +61,7 @@ tmp/ #.claude Konfiguration .claude/ + +# Cursor MCP mit Secrets (Example: .cursor/mcp.json.example) +.cursor/mcp.json .claude/settings.local.jsonfrontend/package-lock.json diff --git a/backend/join_evaluator.py b/backend/join_evaluator.py new file mode 100644 index 0000000..81b3762 --- /dev/null +++ b/backend/join_evaluator.py @@ -0,0 +1,396 @@ +""" +Join Evaluator (Phase 4) + +Evaluiert Join-Knoten: Sammelt incoming paths, prüft Strategien, konsolidiert Ergebnisse. + +Join-Strategien: +- wait_all: Alle eingehenden Pfade müssen ausgeführt sein (strikt) +- wait_any: Mindestens ein Pfad muss ausgeführt sein +- best_effort: Verwendet verfügbare Pfade (fehlertoleranz) + +Skip-Handling: +- ignore_skipped: Übersprungene Pfade nicht in Ergebnis +- use_placeholder: Platzhalter für übersprungene Pfade +- require_minimum: Mindestanzahl erforderlich + +Konzept-Basis: konzept_workflow_engine_konsolidated.md (Sektion 8.8) +Anforderungsanalyse: phase4_anforderungsanalyse.md +""" + +from typing import Dict, Any, Optional, List, NamedTuple +from pydantic import BaseModel, Field +import logging + +from workflow_models import ( + WorkflowNode, + WorkflowGraph, + JoinStrategy, + SkipHandling, + NodeStatus, + NormalizedSignal +) + +logger = logging.getLogger(__name__) + + +# ── Data Structures ─────────────────────────────────────────────────────────── + + +class PathStatus(NamedTuple): + """ + Status eines eingehenden Pfads am Join-Node. + + Attributes: + node_id: ID des Source-Nodes (Node vor dem Join) + status: Ausführungsstatus (EXECUTED, SKIPPED, FAILED) + analysis_core: Analyseinhalt (wenn vorhanden) + signals: Normalisierte Signale des Nodes (question_type → Signal) + """ + node_id: str + status: NodeStatus + analysis_core: Optional[str] + signals: Dict[str, NormalizedSignal] # Converted from List to Dict by _collect_incoming_paths + + +class JoinResult(BaseModel): + """ + Ergebnis der Join-Evaluation. + + Enthält konsolidierte Daten aller eingehenden Pfade. + """ + ready: bool = Field(..., description="Sind erforderliche Pfade verfügbar?") + consolidated_analysis_core: Dict[str, str] = Field( + default_factory=dict, + description="Konsolidierte Analysekerne: node_id → analysis_core" + ) + consolidated_signals: Dict[str, NormalizedSignal] = Field( + default_factory=dict, + description="Konsolidierte Signale: node_id.question_id → signal" + ) + metadata: Dict[str, Any] = Field( + default_factory=dict, + description="Pfad-Status, Statistiken, Hinweise" + ) + error: Optional[str] = Field(None, description="Fehler bei nicht erfüllter Strategie") + + +# ── Helper Functions ────────────────────────────────────────────────────────── + + +def _collect_incoming_paths( + node: WorkflowNode, + graph: WorkflowGraph, + context: Dict[str, Any] +) -> List[PathStatus]: + """ + Sammelt alle eingehenden Pfade eines Join-Nodes. + + Args: + node: Join-Node + graph: Workflow-Graph + context: Execution context mit node_results + + Returns: + List[PathStatus] - Status aller eingehenden Pfade + + Details: + - Findet alle Edges mit to_node == join_node.id + - Extrahiert NodeExecutionState aus context["node_results"] + - Erstellt PathStatus für jeden incoming node + """ + incoming_edges = [e for e in graph.edges if e.to_node == node.id] + paths: List[PathStatus] = [] + + logger.debug(f"Join node {node.id}: Found {len(incoming_edges)} incoming edges") + + for edge in incoming_edges: + source_node_id = edge.from_node + + # Hole NodeExecutionState aus context + node_state = context["node_results"].get(source_node_id) + + if node_state: + # Node wurde ausgeführt + # Convert List[NormalizedSignal] to Dict[question_type → Signal] + signals_dict = {} + if node_state.normalized_signals: + for signal in node_state.normalized_signals: + signals_dict[signal.question_type] = signal + + path = PathStatus( + node_id=source_node_id, + status=node_state.status, + analysis_core=node_state.analysis_core, + signals=signals_dict + ) + paths.append(path) + logger.debug(f" Path {source_node_id}: {node_state.status.value}") + else: + # Node nicht in node_results → wurde nicht besucht (unreachable) + path = PathStatus( + node_id=source_node_id, + status=NodeStatus.SKIPPED, + analysis_core=None, + signals={} + ) + paths.append(path) + logger.debug(f" Path {source_node_id}: not visited (unreachable)") + + return paths + + +def _check_join_strategy( + paths: List[PathStatus], + strategy: JoinStrategy +) -> tuple[bool, Optional[str]]: + """ + Prüft ob Join-Strategie erfüllt ist. + + Args: + paths: Liste aller eingehenden Pfade + strategy: Join-Strategie (wait_all, wait_any, best_effort) + + Returns: + (ready: bool, error: Optional[str]) + - ready: True wenn Strategie erfüllt + - error: Fehlermeldung wenn nicht erfüllt + + Strategien: + - wait_all: Alle Pfade EXECUTED (strikt) + - wait_any: Mindestens ein Pfad EXECUTED + - best_effort: Immer ready (fehlertoleranz) + """ + executed_paths = [p for p in paths if p.status == NodeStatus.EXECUTED] + failed_paths = [p for p in paths if p.status == NodeStatus.FAILED] + skipped_paths = [p for p in paths if p.status == NodeStatus.SKIPPED] + + logger.debug(f"Join strategy check: {strategy.value}") + logger.debug(f" Executed: {len(executed_paths)}/{len(paths)}") + logger.debug(f" Failed: {len(failed_paths)}") + logger.debug(f" Skipped: {len(skipped_paths)}") + + if strategy == JoinStrategy.WAIT_ALL: + # Alle Pfade müssen EXECUTED sein + if len(executed_paths) < len(paths): + missing = [p.node_id for p in paths if p.status != NodeStatus.EXECUTED] + error = f"wait_all strategy failed: {len(executed_paths)}/{len(paths)} paths executed. Missing: {missing}" + logger.warning(error) + return False, error + return True, None + + elif strategy == JoinStrategy.WAIT_ANY: + # Mindestens ein Pfad EXECUTED + if len(executed_paths) == 0: + error = f"wait_any strategy failed: no paths executed ({len(failed_paths)} failed, {len(skipped_paths)} skipped)" + logger.warning(error) + return False, error + return True, None + + elif strategy == JoinStrategy.BEST_EFFORT: + # Immer bereit (fehlertoleranz) + if len(executed_paths) == 0: + logger.info(f"best_effort: No paths executed, but continuing (fehlertoleranz)") + return True, None + + else: + # Unbekannte Strategie + error = f"Unknown join strategy: {strategy}" + logger.error(error) + return False, error + + +def _merge_analysis_cores( + paths: List[PathStatus], + skip_handling: Optional[SkipHandling] +) -> Dict[str, str]: + """ + Merged Analysekerne aller eingehenden Pfade. + + Args: + paths: Liste aller Pfade + skip_handling: Umgang mit übersprungenen Pfaden + + Returns: + Dict[node_id → analysis_core] + + Details: + - EXECUTED Pfade: analysis_core übernehmen + - SKIPPED Pfade: abhängig von skip_handling + - IGNORE_SKIPPED: nicht in Ergebnis + - USE_PLACEHOLDER: Platzhalter "[Skipped: {node_id}]" + - FAILED Pfade: Platzhalter "[Failed: {node_id}]" + """ + merged: Dict[str, str] = {} + + for path in paths: + if path.status == NodeStatus.EXECUTED and path.analysis_core: + # Normale Übernahme + merged[path.node_id] = path.analysis_core + + elif path.status == NodeStatus.SKIPPED: + # Skip-Handling + if skip_handling == SkipHandling.USE_PLACEHOLDER: + merged[path.node_id] = f"[Path skipped: {path.node_id}]" + elif skip_handling == SkipHandling.IGNORE_SKIPPED: + # Nicht in Ergebnis + pass + # REQUIRE_MINIMUM wird in _check_join_strategy behandelt + + elif path.status == NodeStatus.FAILED: + # Failed Pfade dokumentieren + merged[path.node_id] = f"[Path failed: {path.node_id}]" + + logger.debug(f"Merged analysis cores: {len(merged)} entries") + return merged + + +def _combine_signals( + paths: List[PathStatus] +) -> Dict[str, NormalizedSignal]: + """ + Kombiniert Signale aller eingehenden Pfade. + + Args: + paths: Liste aller Pfade + + Returns: + Dict[node_id.question_id → NormalizedSignal] + + Details: + - Signal-Namen werden mit node_id geprefixed (verhindert Kollision) + - Format: "node_id.question_id" → Signal + - Nur EXECUTED Pfade werden berücksichtigt + + Beispiel: + path_a.relevanz → Signal(value="hoch", ...) + path_b.relevanz → Signal(value="mittel", ...) + """ + combined: Dict[str, NormalizedSignal] = {} + + for path in paths: + if path.status != NodeStatus.EXECUTED: + # Nur ausgeführte Pfade haben valide Signale + continue + + for signal_key, signal in path.signals.items(): + # Präfix mit node_id (verhindert Kollision) + prefixed_key = f"{path.node_id}.{signal_key}" + combined[prefixed_key] = signal + + logger.debug(f"Combined signals: {len(combined)} entries") + return combined + + +# ── Main Function ───────────────────────────────────────────────────────────── + + +def evaluate_join_node( + node: WorkflowNode, + graph: WorkflowGraph, + context: Dict[str, Any] +) -> JoinResult: + """ + Evaluiert Join-Node: Sammelt Pfade, prüft Strategie, konsolidiert Ergebnisse. + + Args: + node: Join-Node aus Workflow-Graph + graph: Gesamter Workflow-Graph + context: Execution context mit node_results + + Returns: + JoinResult mit konsolidierten Daten + + Workflow: + 1. Sammle incoming paths (_collect_incoming_paths) + 2. Prüfe Join-Strategie (_check_join_strategy) + 3. Merge analysis_cores (_merge_analysis_cores) + 4. Combine signals (_combine_signals) + 5. Baue metadata (Pfad-Status, Statistiken) + + Beispiel: + >>> result = evaluate_join_node(join_node, graph, context) + >>> result.ready + True + >>> result.consolidated_analysis_core + {"path_a": "Analysis A...", "path_b": "Analysis B..."} + """ + logger.info(f"Evaluating join node: {node.id}") + + # 1. Sammle incoming paths + paths = _collect_incoming_paths(node, graph, context) + + if not paths: + # Kein eingehender Pfad (Fehlkonfiguration) + logger.warning(f"Join node {node.id}: No incoming edges found") + return JoinResult( + ready=False, + error="No incoming edges for join node", + metadata={"note": "Configuration error: join node must have incoming edges"} + ) + + # 2. Prüfe Join-Strategie + strategy = node.join_strategy or JoinStrategy.WAIT_ALL # Default + skip_handling = node.skip_handling or SkipHandling.IGNORE_SKIPPED # Default + + ready, strategy_error = _check_join_strategy(paths, strategy) + + if not ready: + # Strategie nicht erfüllt + executed_list = [p.node_id for p in paths if p.status == NodeStatus.EXECUTED] + skipped_list = [p.node_id for p in paths if p.status == NodeStatus.SKIPPED] + failed_list = [p.node_id for p in paths if p.status == NodeStatus.FAILED] + + return JoinResult( + ready=False, + error=strategy_error, + metadata={ + "join_strategy": strategy.value, + "total_paths": len(paths), + "executed_paths": len(executed_list), + "skipped_paths": len(skipped_list), + "failed_paths": len(failed_list), + "path_details": { + "executed": executed_list, + "skipped": skipped_list, + "failed": failed_list + } + } + ) + + # 3. Merge analysis_cores + merged_cores = _merge_analysis_cores(paths, skip_handling) + + # 4. Combine signals + combined_signals = _combine_signals(paths) + + # 5. Baue metadata + executed_count = sum(1 for p in paths if p.status == NodeStatus.EXECUTED) + skipped_count = sum(1 for p in paths if p.status == NodeStatus.SKIPPED) + failed_count = sum(1 for p in paths if p.status == NodeStatus.FAILED) + + metadata = { + "join_strategy": strategy.value, + "skip_handling": skip_handling.value, + "total_paths": len(paths), + "executed_paths": executed_count, + "skipped_paths": skipped_count, + "failed_paths": failed_count, + "path_details": { + "executed": [p.node_id for p in paths if p.status == NodeStatus.EXECUTED], + "skipped": [p.node_id for p in paths if p.status == NodeStatus.SKIPPED], + "failed": [p.node_id for p in paths if p.status == NodeStatus.FAILED] + } + } + + # Spezielle Hinweise + if executed_count == 0 and strategy == JoinStrategy.BEST_EFFORT: + metadata["note"] = "No paths executed, but best_effort allows empty consolidation" + + logger.info(f"Join node {node.id}: Consolidated {executed_count}/{len(paths)} paths") + + return JoinResult( + ready=True, + consolidated_analysis_core=merged_cores, + consolidated_signals=combined_signals, + metadata=metadata + ) diff --git a/backend/logic_evaluator.py b/backend/logic_evaluator.py new file mode 100644 index 0000000..9e1adfd --- /dev/null +++ b/backend/logic_evaluator.py @@ -0,0 +1,268 @@ +""" +Logic Evaluator (Phase 3) + +Rein deterministischer boolescher Evaluator für Workflow-Bedingungen. +Keine KI, keine Interpretation - nur strikte Logik-Auswertung. + +Unterstützt: +- Vergleichsoperatoren: EQ, NEQ, IN, NOT_IN, GT, LT, GTE, LTE, CONTAINS +- Logische Operatoren: AND, OR, NOT +- Verschachtelung via operands +""" + +from typing import Dict, Any, Optional, Tuple, List +import logging +from workflow_models import ( + LogicExpression, + LogicOperator, + NormalizedSignal, + SignalStatus +) + +logger = logging.getLogger(__name__) + + +def evaluate_logic_expression( + expression: LogicExpression, + context: Dict[str, Any] +) -> Tuple[bool, Optional[str]]: + """ + Evaluiert LogicExpression gegen context. + + Args: + expression: Die auszuwertende Bedingung + context: Execution context mit node_results + + Returns: + (result: bool, error: Optional[str]) + - result: True/False basierend auf Auswertung + - error: Fehlermeldung wenn Evaluation fehlschlägt + + Beispiel: + expression = LogicExpression( + operator=LogicOperator.EQ, + ref="body.relevanz", + value="decrease" + ) + result, error = evaluate_logic_expression(expression, context) + """ + try: + operator = expression.operator + + # Logische Operatoren (verschachtelt) + if operator == LogicOperator.AND: + return _evaluate_and(expression, context) + elif operator == LogicOperator.OR: + return _evaluate_or(expression, context) + elif operator == LogicOperator.NOT: + return _evaluate_not(expression, context) + + # Vergleichsoperatoren (benötigen ref) + elif operator in [ + LogicOperator.EQ, LogicOperator.NEQ, + LogicOperator.IN, LogicOperator.NOT_IN, + LogicOperator.GT, LogicOperator.LT, + LogicOperator.GTE, LogicOperator.LTE, + LogicOperator.CONTAINS + ]: + return _evaluate_comparison(expression, context) + + else: + return False, f"Unknown operator: {operator}" + + except Exception as e: + logger.error(f"Logic evaluation failed: {e}", exc_info=True) + return False, str(e) + + +def _evaluate_and( + expression: LogicExpression, + context: Dict[str, Any] +) -> Tuple[bool, Optional[str]]: + """Evaluiert AND - alle operands müssen True sein.""" + if not expression.operands: + return False, "AND requires operands" + + for operand in expression.operands: + result, error = evaluate_logic_expression(operand, context) + if error: + return False, f"AND operand failed: {error}" + if not result: + return False, None # Short-circuit: einer False → gesamt False + + return True, None + + +def _evaluate_or( + expression: LogicExpression, + context: Dict[str, Any] +) -> Tuple[bool, Optional[str]]: + """Evaluiert OR - mindestens ein operand muss True sein.""" + if not expression.operands: + return False, "OR requires operands" + + errors = [] + for operand in expression.operands: + result, error = evaluate_logic_expression(operand, context) + if error: + errors.append(error) + continue + if result: + return True, None # Short-circuit: einer True → gesamt True + + # Alle False oder Fehler + if errors: + return False, f"All OR operands failed: {', '.join(errors)}" + return False, None + + +def _evaluate_not( + expression: LogicExpression, + context: Dict[str, Any] +) -> Tuple[bool, Optional[str]]: + """Evaluiert NOT - negiert operand.""" + if not expression.operands or len(expression.operands) != 1: + return False, "NOT requires exactly one operand" + + result, error = evaluate_logic_expression(expression.operands[0], context) + if error: + return False, f"NOT operand failed: {error}" + + return not result, None + + +def _evaluate_comparison( + expression: LogicExpression, + context: Dict[str, Any] +) -> Tuple[bool, Optional[str]]: + """Evaluiert Vergleichsoperator.""" + if not expression.ref: + return False, f"{expression.operator} requires ref" + + # Signal auflösen + signal, error = resolve_signal_reference(expression.ref, context) + if error: + return False, error + if signal is None: + return False, f"Signal not found: {expression.ref}" + + # Prüfe Signal-Status + if signal.status in [SignalStatus.UNCLEAR, SignalStatus.INVALID, SignalStatus.NOT_DECIDABLE]: + return False, f"Signal '{expression.ref}' has status {signal.status} - cannot evaluate" + + # Vergleich durchführen + left_value = signal.normalized_value or signal.raw_value + right_value = expression.value + + return compare_values(expression.operator, left_value, right_value) + + +def resolve_signal_reference( + ref: str, + context: Dict[str, Any] +) -> Tuple[Optional[NormalizedSignal], Optional[str]]: + """ + Löst Referenz wie "node_1.relevanz" auf. + + Args: + ref: Signal-Referenz im Format "node_id.question_type" + context: Execution context mit node_results + + Returns: + (signal, error_message) + + Error wenn: + - Node existiert nicht in context + - Signal-Typ existiert nicht für diesen Node + """ + # Parse reference + parts = ref.split(".") + if len(parts) != 2: + return None, f"Invalid reference format: '{ref}' (expected 'node_id.question_type')" + + node_id, question_type = parts + + # Hole Node-Results aus context + node_results = context.get("node_results", {}) + if node_id not in node_results: + return None, f"Node '{node_id}' not found in context" + + node_state = node_results[node_id] + + # Suche Signal + for signal in node_state.normalized_signals: + if signal.question_type == question_type: + return signal, None + + return None, f"Signal '{question_type}' not found in node '{node_id}'" + + +def compare_values( + operator: LogicOperator, + left: Any, + right: Any +) -> Tuple[bool, Optional[str]]: + """ + Führt Vergleichsoperation durch. + + Args: + operator: Vergleichsoperator + left: Linker Wert (aus Signal) + right: Rechter Wert (aus Expression) + + Returns: + (result: bool, error: Optional[str]) + """ + try: + if operator == LogicOperator.EQ: + return left == right, None + + elif operator == LogicOperator.NEQ: + return left != right, None + + elif operator == LogicOperator.IN: + if not isinstance(right, (list, tuple, set)): + return False, f"IN operator requires list, got {type(right)}" + return left in right, None + + elif operator == LogicOperator.NOT_IN: + if not isinstance(right, (list, tuple, set)): + return False, f"NOT_IN operator requires list, got {type(right)}" + return left not in right, None + + elif operator == LogicOperator.CONTAINS: + # left enthält right (z.B. String-Suche) + if isinstance(left, str) and isinstance(right, str): + return right in left, None + elif isinstance(left, (list, tuple, set)): + return right in left, None + else: + return False, f"CONTAINS not supported for types {type(left)}/{type(right)}" + + elif operator == LogicOperator.GT: + return _numeric_compare(left, right, lambda a, b: a > b) + + elif operator == LogicOperator.LT: + return _numeric_compare(left, right, lambda a, b: a < b) + + elif operator == LogicOperator.GTE: + return _numeric_compare(left, right, lambda a, b: a >= b) + + elif operator == LogicOperator.LTE: + return _numeric_compare(left, right, lambda a, b: a <= b) + + else: + return False, f"Unknown comparison operator: {operator}" + + except Exception as e: + return False, f"Comparison failed: {e}" + + +def _numeric_compare(left: Any, right: Any, compare_fn) -> Tuple[bool, Optional[str]]: + """Hilfsfunktion für numerische Vergleiche.""" + try: + left_num = float(left) if not isinstance(left, (int, float)) else left + right_num = float(right) if not isinstance(right, (int, float)) else right + return compare_fn(left_num, right_num), None + except (ValueError, TypeError) as e: + return False, f"Cannot compare as numbers: {left} vs {right} ({e})" diff --git a/backend/main.py b/backend/main.py index b0470dc..22d2717 100644 --- a/backend/main.py +++ b/backend/main.py @@ -26,6 +26,8 @@ from routers import evaluation # v9d/v9e Training Type Profiles (#15) from routers import goals, focus_areas # v9e/v9g Goal System v2.0 (Dynamic Focus Areas) from routers import goal_types, goal_progress, training_phases, fitness_tests # v9h Goal System (Split routers) from routers import charts # Phase 0c Multi-Layer Architecture +from routers import workflow_questions # Phase 1 Workflow Engine - Question Catalog +from routers import workflows # Phase 2 Workflow Engine - Execution # ── App Configuration ───────────────────────────────────────────────────────── DATA_DIR = Path(os.getenv("DATA_DIR", "./data")) @@ -110,6 +112,10 @@ app.include_router(focus_areas.router) # /api/focus-areas/* (v9g Focus # Phase 0c Multi-Layer Architecture app.include_router(charts.router) # /api/charts/* (Phase 0c Charts API) +# Phase 1-2 Workflow Engine +app.include_router(workflow_questions.router) # /api/workflow/questions/* (Phase 1 Question Catalog) +app.include_router(workflows.router) # /api/workflows/* (Phase 2 Execution) + # ── Health Check ────────────────────────────────────────────────────────────── @app.get("/") def root(): diff --git a/backend/migrations/016_workflows_graph_data.sql b/backend/migrations/016_workflows_graph_data.sql new file mode 100644 index 0000000..4e4f9ee --- /dev/null +++ b/backend/migrations/016_workflows_graph_data.sql @@ -0,0 +1,35 @@ +-- Migration 016: Workflow Support in ai_prompts (Phase 5) +-- Erweitert ai_prompts für type='workflow' (Option B - Backward-kompatibel) + +-- Neue Spalte für Workflow-Graphen +ALTER TABLE ai_prompts ADD COLUMN graph_data JSONB; + +-- Index für Workflow-Queries +CREATE INDEX IF NOT EXISTS idx_ai_prompts_type ON ai_prompts(type); +CREATE INDEX IF NOT EXISTS idx_ai_prompts_graph_data ON ai_prompts USING GIN (graph_data); + +-- Kommentar +COMMENT ON COLUMN ai_prompts.graph_data IS 'Workflow-Graph (nur für type=workflow): {nodes: [...], edges: [...], metadata: {...}}'; + +-- Beispiel-Struktur (Dokumentation): +-- { +-- "nodes": [ +-- {"id": "start", "type": "start", "label": "Start", "position": {"x": 100, "y": 100}}, +-- {"id": "analysis_1", "type": "analysis", "prompt_id": 5, "questions": [...], ...}, +-- {"id": "logic_1", "type": "logic", "condition": {...}, ...}, +-- {"id": "join_1", "type": "join", "join_strategy": "wait_all", ...}, +-- {"id": "end", "type": "end", "label": "Ende", "position": {"x": 500, "y": 300}} +-- ], +-- "edges": [ +-- {"id": "e1", "source": "start", "target": "analysis_1"}, +-- {"id": "e2", "source": "analysis_1", "target": "logic_1"}, +-- {"id": "e3", "source": "logic_1", "target": "join_1"}, +-- {"id": "e4", "source": "join_1", "target": "end"} +-- ], +-- "metadata": { +-- "created_at": "2026-04-04T12:00:00Z", +-- "version": "1.0" +-- } +-- } + +-- Migration erfolgreich diff --git a/backend/migrations/034_workflow_foundation.sql b/backend/migrations/034_workflow_foundation.sql new file mode 100644 index 0000000..dfe1d12 --- /dev/null +++ b/backend/migrations/034_workflow_foundation.sql @@ -0,0 +1,132 @@ +-- Migration 034: Workflow Foundation +-- Phase 0: Datenmodell für Workflow-Erweiterung der Prompt Engine +-- Erstellt: 2026-04-03 + +-- ============================================================ +-- Tabelle: workflow_definitions +-- Speichert Workflow-Graphen (Knoten + Kanten) +-- ============================================================ +CREATE TABLE IF NOT EXISTS workflow_definitions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(255) NOT NULL, + slug VARCHAR(100) UNIQUE NOT NULL, + description TEXT, + graph JSONB NOT NULL, -- Der Workflow-Graph (Knoten + Kanten) + version INTEGER DEFAULT 1, + active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_workflow_definitions_slug ON workflow_definitions(slug); +CREATE INDEX IF NOT EXISTS idx_workflow_definitions_active ON workflow_definitions(active); + +-- ============================================================ +-- Tabelle: workflow_question_catalog +-- Katalog der Fragenergänzungen (6 Grundtypen) +-- ============================================================ +CREATE TABLE IF NOT EXISTS workflow_question_catalog ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + question_type VARCHAR(50) NOT NULL, -- relevanz, prioritaet, selektion, ausschluss, eskalation, unsicherheit + label VARCHAR(255) NOT NULL, + question_template TEXT NOT NULL, -- Template für die Frage + answer_spectrum JSONB NOT NULL, -- z.B. ["ja", "nein", "unklar"] + normalization_rules JSONB, -- Regeln für Normalisierung (Synonyme, etc.) + active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_workflow_question_catalog_type ON workflow_question_catalog(question_type); +CREATE INDEX IF NOT EXISTS idx_workflow_question_catalog_active ON workflow_question_catalog(active); + +-- Seed: 6 Grundtypen der Fragenergänzungen +INSERT INTO workflow_question_catalog (question_type, label, question_template, answer_spectrum, normalization_rules, active) VALUES +( + 'relevanz', + 'Relevanz-Frage', + 'Ist eine vertiefte Analyse in diesem Bereich relevant?', + '["ja", "nein", "unklar"]'::JSONB, + '{"synonyms": {"ja": ["yes", "Ja", "JA", "relevant", "sinnvoll"], "nein": ["no", "Nein", "NEIN", "nicht relevant", "unwichtig"], "unklar": ["unclear", "unsure", "vielleicht", "möglicherweise"]}}'::JSONB, + true +), +( + 'prioritaet', + 'Prioritäts-Frage', + 'Wie hoch ist die Priorität für eine Analyse in diesem Bereich?', + '["hoch", "mittel", "niedrig", "unklar"]'::JSONB, + '{"synonyms": {"hoch": ["high", "Hoch", "HOCH", "urgent", "dringend"], "mittel": ["medium", "Mittel", "MITTEL", "moderat"], "niedrig": ["low", "Niedrig", "NIEDRIG", "gering"], "unklar": ["unclear", "unsure", "kann nicht einschätzen"]}}'::JSONB, + true +), +( + 'selektion', + 'Selektions-Frage', + 'Soll dieser Pfad ausgewählt werden?', + '["ja", "nein", "unklar"]'::JSONB, + '{"synonyms": {"ja": ["yes", "Ja", "JA", "auswählen", "select"], "nein": ["no", "Nein", "NEIN", "nicht auswählen", "skip"], "unklar": ["unclear", "unsure", "vielleicht"]}}'::JSONB, + true +), +( + 'ausschluss', + 'Ausschluss-Frage', + 'Soll dieser Pfad ausgeschlossen werden?', + '["ja", "nein", "unklar"]'::JSONB, + '{"synonyms": {"ja": ["yes", "Ja", "JA", "ausschließen", "exclude"], "nein": ["no", "Nein", "NEIN", "nicht ausschließen", "include"], "unklar": ["unclear", "unsure", "vielleicht"]}}'::JSONB, + true +), +( + 'eskalation', + 'Eskalations-Frage', + 'Ist eine Eskalation oder besondere Aufmerksamkeit erforderlich?', + '["ja", "nein", "unklar"]'::JSONB, + '{"synonyms": {"ja": ["yes", "Ja", "JA", "eskalieren", "alert"], "nein": ["no", "Nein", "NEIN", "normal", "routine"], "unklar": ["unclear", "unsure", "vielleicht"]}}'::JSONB, + true +), +( + 'unsicherheit', + 'Unsicherheits-Frage', + 'Besteht Unsicherheit in der Bewertung?', + '["ja", "nein", "unklar"]'::JSONB, + '{"synonyms": {"ja": ["yes", "Ja", "JA", "unsicher", "uncertain"], "nein": ["no", "Nein", "NEIN", "sicher", "certain"], "unklar": ["unclear", "unsure", "vielleicht"]}}'::JSONB, + true +); + +-- ============================================================ +-- Tabelle: workflow_executions +-- Ausführungs-Log für Workflow-Runs +-- ============================================================ +CREATE TABLE IF NOT EXISTS workflow_executions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workflow_id UUID REFERENCES workflow_definitions(id) ON DELETE CASCADE, + profile_id UUID REFERENCES profiles(id) ON DELETE CASCADE, + status VARCHAR(20) NOT NULL DEFAULT 'running', -- running, completed, failed, partial + node_states JSONB, -- Status jedes Knotens (executed, skipped, unclear, failed) + execution_log JSONB, -- Detaillierter Ablauf + started_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + completed_at TIMESTAMP WITH TIME ZONE +); + +CREATE INDEX IF NOT EXISTS idx_workflow_executions_workflow_id ON workflow_executions(workflow_id); +CREATE INDEX IF NOT EXISTS idx_workflow_executions_profile_id ON workflow_executions(profile_id); +CREATE INDEX IF NOT EXISTS idx_workflow_executions_status ON workflow_executions(status); +CREATE INDEX IF NOT EXISTS idx_workflow_executions_started_at ON workflow_executions(started_at DESC); + +-- ============================================================ +-- Erweiterung bestehende Tabelle: ai_prompts +-- Optionale Prompt-gebundene Standard-Fragenergänzungen +-- (Sekundär: Knotenspezifische Fragen haben Vorrang) +-- ============================================================ +ALTER TABLE ai_prompts +ADD COLUMN IF NOT EXISTS question_augmentations JSONB; + +COMMENT ON COLUMN ai_prompts.question_augmentations IS 'Optionale Standard-Fragenergänzungen für diesen Prompt. Knotenspezifische Fragen im Workflow-Graph haben Vorrang (Hybridmodell mit Vorrangregel).'; + +-- ============================================================ +-- Kommentare für Dokumentation +-- ============================================================ +COMMENT ON TABLE workflow_definitions IS 'Workflow-Graphen (Knoten + Kanten) als JSONB. Erweitert die Prompt Engine um verzweigbare, bedingte Analysen.'; +COMMENT ON TABLE workflow_question_catalog IS 'Katalog der 6 Grundtypen von Fragenergänzungen mit Antwortspektren und Normalisierungsregeln.'; +COMMENT ON TABLE workflow_executions IS 'Ausführungs-Log für Workflow-Runs mit Knoten-Status und detailliertem Ablauf.'; + +COMMENT ON COLUMN workflow_definitions.graph IS 'JSONB: {nodes: [{id, type, prompt_slug, question_augmentations, position}, ...], edges: [{id, from, to}, ...]}'; +COMMENT ON COLUMN workflow_executions.node_states IS 'JSONB: {node_id: {status: "executed|skipped|unclear|failed", ...}, ...}'; +COMMENT ON COLUMN workflow_executions.execution_log IS 'JSONB: Chronologischer Ablauf mit Timestamps, Entscheidungen, normalisierten Signalen.'; diff --git a/backend/models.py b/backend/models.py index 4c0c39b..c2b473a 100644 --- a/backend/models.py +++ b/backend/models.py @@ -28,6 +28,7 @@ class ProfileUpdate(BaseModel): goal_weight: Optional[float] = None goal_bf_pct: Optional[float] = None quality_filter_level: Optional[str] = None # Issue #31: Global quality filter + email: Optional[str] = None # Self-service; leer = entfernen; Änderung setzt Verifikation zurück # ── Tracking Models ─────────────────────────────────────────────────────────── @@ -177,12 +178,12 @@ class StageCreate(BaseModel): class UnifiedPromptCreate(BaseModel): - """Create a new unified prompt (base or pipeline type)""" + """Create a new unified prompt (base, pipeline, or workflow type)""" name: str - slug: str + slug: Optional[str] = None # Auto-generated from name if not provided display_name: Optional[str] = None description: Optional[str] = None - type: str # 'base' or 'pipeline' + type: str # 'base' | 'pipeline' | 'workflow' category: str = 'ganzheitlich' active: bool = True sort_order: int = 0 @@ -195,6 +196,9 @@ class UnifiedPromptCreate(BaseModel): # For pipeline prompts (multi-stage workflow) stages: Optional[list[StageCreate]] = None # Required if type='pipeline' + # For workflow prompts (visual graph editor) + graph_data: Optional[dict] = None # Required if type='workflow' + class UnifiedPromptUpdate(BaseModel): """Update an existing unified prompt""" @@ -209,6 +213,7 @@ class UnifiedPromptUpdate(BaseModel): output_format: Optional[str] = None output_schema: Optional[dict] = None stages: Optional[list[StageCreate]] = None + graph_data: Optional[dict] = None # For workflow type # ── Pipeline Config Models (Issue #28) ───────────────────────────────────── diff --git a/backend/normalization_engine.py b/backend/normalization_engine.py new file mode 100644 index 0000000..9ae06f6 --- /dev/null +++ b/backend/normalization_engine.py @@ -0,0 +1,237 @@ +""" +Normalization Engine (Phase 2) + +Normalisiert decision_signals gegen answer_spectrum mit Synonymen. + +Konzept-Basis: konzept_workflow_engine_konsolidated.md (Sektion 8.5) +Anforderungsanalyse: anforderungsanalyse_umsetzungsplan.md (Phase 2) +""" +from typing import Dict, List, Optional +from workflow_models import NormalizedSignal, SignalStatus +import logging + +logger = logging.getLogger(__name__) + + +def normalize_decision_signal( + question_type: str, + raw_value: str, + answer_spectrum: List[str], + normalization_rules: Optional[Dict] = None +) -> NormalizedSignal: + """ + Normalisiert ein einzelnes Entscheidungssignal. + + Normalisierungs-Kaskade: + 1. Exakte Übereinstimmung → valid + 2. Case-insensitive Übereinstimmung → normalized + 3. Synonym-Mapping (aus normalization_rules) → normalized + 4. Keine Übereinstimmung → invalid + + Args: + question_type: Typ der Frage (z.B. "relevanz") + raw_value: Rohe LLM-Antwort (z.B. "JA", "yes", "ja") + answer_spectrum: Erlaubte Werte (z.B. ["ja", "nein", "unklar"]) + normalization_rules: Optional: {"synonyms": {"ja": ["yes", "Ja", "JA"], ...}} + + Returns: + NormalizedSignal mit status + normalized_value + + Beispiele: + >>> normalize_decision_signal("relevanz", "ja", ["ja", "nein"]) + NormalizedSignal(status=VALID, normalized_value="ja") + + >>> normalize_decision_signal("relevanz", "JA", ["ja", "nein"]) + NormalizedSignal(status=NORMALIZED, normalized_value="ja") + + >>> normalize_decision_signal("relevanz", "yes", ["ja", "nein"], + ... {"synonyms": {"ja": ["yes", "Yes"]}}) + NormalizedSignal(status=NORMALIZED, normalized_value="ja") + + >>> normalize_decision_signal("relevanz", "vielleicht", ["ja", "nein"]) + NormalizedSignal(status=INVALID, normalized_value=None) + """ + # 1. Exakte Übereinstimmung + if raw_value in answer_spectrum: + logger.debug(f"{question_type}: '{raw_value}' → valid (exact match)") + return NormalizedSignal( + question_type=question_type, + raw_value=raw_value, + normalized_value=raw_value, + status=SignalStatus.VALID + ) + + # 2. Case-insensitive Matching + raw_lower = raw_value.strip().lower() + for allowed in answer_spectrum: + if raw_lower == allowed.lower(): + logger.debug(f"{question_type}: '{raw_value}' → normalized (case-insensitive)") + return NormalizedSignal( + question_type=question_type, + raw_value=raw_value, + normalized_value=allowed, + status=SignalStatus.NORMALIZED, + metadata={"method": "case_insensitive"} + ) + + # 3. Synonym Mapping + if normalization_rules and "synonyms" in normalization_rules: + normalized = apply_synonym_mapping( + raw_value=raw_value, + synonyms=normalization_rules["synonyms"] + ) + if normalized: + logger.debug(f"{question_type}: '{raw_value}' → normalized (synonym → '{normalized}')") + return NormalizedSignal( + question_type=question_type, + raw_value=raw_value, + normalized_value=normalized, + status=SignalStatus.NORMALIZED, + metadata={"method": "synonym"} + ) + + # 4. Keine Übereinstimmung + logger.warning(f"{question_type}: '{raw_value}' → invalid (no match in spectrum {answer_spectrum})") + return NormalizedSignal( + question_type=question_type, + raw_value=raw_value, + normalized_value=None, + status=SignalStatus.INVALID + ) + + +def apply_synonym_mapping( + raw_value: str, + synonyms: Dict[str, List[str]] +) -> Optional[str]: + """ + Mappt raw_value auf Synonym-Gruppe (case-insensitive). + + Args: + raw_value: "yes" oder "YES" oder "Yes" + synonyms: {"ja": ["yes", "Ja", "JA"], "nein": ["no", "No"]} + + Returns: + "ja" (Schlüssel der Gruppe) oder None + + Beispiele: + >>> apply_synonym_mapping("yes", {"ja": ["yes", "Yes"], "nein": ["no"]}) + "ja" + + >>> apply_synonym_mapping("YES", {"ja": ["yes"], "nein": ["no"]}) + "ja" + + >>> apply_synonym_mapping("vielleicht", {"ja": ["yes"], "nein": ["no"]}) + None + """ + raw_lower = raw_value.strip().lower() + + for canonical_value, synonym_list in synonyms.items(): + # Check case-insensitive gegen alle Synonyme + for syn in synonym_list: + if raw_lower == syn.lower(): + return canonical_value + + return None + + +def normalize_all_signals( + decision_signals: Dict[str, str], + catalog_dict: Dict[str, Dict] +) -> List[NormalizedSignal]: + """ + Normalisiert alle decision_signals gegen Katalog. + + Args: + decision_signals: {"relevanz": "ja", "prioritaet": "HOCH"} + catalog_dict: { + "relevanz": { + "answer_spectrum": ["ja", "nein", "unklar"], + "normalization_rules": {"synonyms": {...}} + }, + ... + } + + Returns: + Liste von NormalizedSignal (ein Signal pro question_type) + + Beispiele: + >>> signals = {"relevanz": "ja", "prioritaet": "HOCH"} + >>> catalog = { + ... "relevanz": {"answer_spectrum": ["ja", "nein"], "normalization_rules": None}, + ... "prioritaet": {"answer_spectrum": ["hoch", "mittel", "niedrig"], "normalization_rules": None} + ... } + >>> normalized = normalize_all_signals(signals, catalog) + >>> len(normalized) + 2 + >>> normalized[0].status + + >>> normalized[1].status + + """ + normalized = [] + + for question_type, raw_value in decision_signals.items(): + if question_type not in catalog_dict: + logger.warning(f"Question type '{question_type}' not in catalog → not_decidable") + normalized.append(NormalizedSignal( + question_type=question_type, + raw_value=raw_value, + normalized_value=None, + status=SignalStatus.NOT_DECIDABLE, + metadata={"error": "not_in_catalog"} + )) + continue + + catalog_entry = catalog_dict[question_type] + signal = normalize_decision_signal( + question_type=question_type, + raw_value=raw_value, + answer_spectrum=catalog_entry["answer_spectrum"], + normalization_rules=catalog_entry.get("normalization_rules") + ) + normalized.append(signal) + + return normalized + + +def load_question_catalog(db_connection) -> Dict[str, Dict]: + """ + Lädt workflow_question_catalog aus DB. + + Returns: + { + "relevanz": { + "answer_spectrum": ["ja", "nein", "unklar"], + "normalization_rules": {"synonyms": {"ja": ["yes", "Yes"], ...}} + }, + "prioritaet": {...}, + ... + } + + Beispiel: + >>> from db import get_db + >>> with get_db() as conn: + ... catalog = load_question_catalog(conn) + ... assert "relevanz" in catalog + ... assert "answer_spectrum" in catalog["relevanz"] + """ + from db import get_cursor + + cur = get_cursor(db_connection) + cur.execute(""" + SELECT question_type, answer_spectrum, normalization_rules + FROM workflow_question_catalog + WHERE active = true + """) + rows = cur.fetchall() + + catalog = {} + for row in rows: + catalog[row['question_type']] = { + "answer_spectrum": row['answer_spectrum'], # JSONB already parsed by psycopg2 + "normalization_rules": row['normalization_rules'] # JSONB or None + } + + logger.info(f"Loaded question catalog: {len(catalog)} types") + return catalog diff --git a/backend/prompt_executor.py b/backend/prompt_executor.py index 656868a..769a8d9 100644 --- a/backend/prompt_executor.py +++ b/backend/prompt_executor.py @@ -192,6 +192,10 @@ async def execute_prompt( # Pipeline prompt: multi-stage execution return await execute_pipeline_prompt(prompt, variables, openrouter_call_func, enable_debug, catalog) + elif prompt_type == 'workflow': + # Workflow prompt: graph-based execution (Phase 0: Foundation) + return await execute_workflow_prompt(prompt, variables, openrouter_call_func, enable_debug, catalog) + else: raise HTTPException(400, f"Unknown prompt type: {prompt_type}") @@ -201,18 +205,54 @@ async def execute_base_prompt( variables: Dict[str, Any], openrouter_call_func, enable_debug: bool = False, - catalog: Optional[Dict] = None + catalog: Optional[Dict] = None, + node_questions: Optional[list] = None # Phase 1: Knotengebundene Fragen ) -> Dict[str, Any]: - """Execute a base-type prompt (single template).""" + """ + Execute a base-type prompt (single template). + + Phase 1: Unterstützt Fragenergänzungen (Hybridmodell) + - node_questions: Knotengebundene Fragen (Priorität 1) + - prompt.question_augmentations: Prompt-Defaults (Priorität 2) + """ + from question_augmenter import ( + parse_question_augmentations_from_jsonb, + merge_question_augmentations, + augment_prompt_with_questions + ) + from result_container_parser import parse_result_container_robust + template = prompt.get('template') if not template: raise HTTPException(400, f"Base prompt missing template: {prompt['slug']}") debug_info = {} if enable_debug else None + # Phase 1: Load question augmentations (Hybridmodell) + prompt_default_questions = None + if prompt.get('question_augmentations'): + try: + from workflow_models import QuestionAugmentation + prompt_default_questions = parse_question_augmentations_from_jsonb( + prompt['question_augmentations'] + ) + except Exception as e: + if enable_debug: + debug_info['question_augmentations_error'] = str(e) + + # Merge question augmentations (Vorrangregel: Knoten > Prompt) + questions = merge_question_augmentations(node_questions, prompt_default_questions) + # Resolve placeholders (with optional catalog for |d modifier) prompt_text = resolve_placeholders(template, variables, debug_info, catalog) + # Phase 1: Augment prompt with questions (if any) + if questions: + prompt_text = augment_prompt_with_questions(prompt_text, questions) + if enable_debug: + debug_info['question_augmentations_count'] = len(questions) + debug_info['question_types'] = [q.type for q in questions] + if enable_debug: debug_info['template'] = template debug_info['final_prompt'] = prompt_text[:500] + ('...' if len(prompt_text) > 500 else '') @@ -225,12 +265,24 @@ async def execute_base_prompt( debug_info['ai_response_length'] = len(response) debug_info['ai_response_preview'] = response[:200] + ('...' if len(response) > 200 else '') - # Validate JSON if required - output_format = prompt.get('output_format', 'text') - if output_format == 'json': - output = validate_json_output(response, prompt.get('output_schema'), debug_info if enable_debug else None) + # Phase 1: Parse structured result if questions were used + if questions: + expected_question_types = [q.type for q in questions] + container = parse_result_container_robust(response, expected_question_types) + + if enable_debug: + debug_info['parsing_status'] = container['parsing_status'] + debug_info['parsing_warnings'] = container.get('warnings', []) + + output = container + output_format = 'structured_container' # New format type else: - output = response + # Legacy behavior: Validate JSON if required + output_format = prompt.get('output_format', 'text') + if output_format == 'json': + output = validate_json_output(response, prompt.get('output_schema'), debug_info if enable_debug else None) + else: + output = response result = { "type": "base", @@ -524,3 +576,67 @@ async def execute_prompt_with_data( # Execute prompt return await execute_prompt(prompt_slug, variables, openrouter_call_func, enable_debug) + + +async def execute_workflow_prompt( + prompt: Dict, + variables: Dict[str, Any], + openrouter_call_func, + enable_debug: bool = False, + catalog: Optional[Dict] = None +) -> Dict[str, Any]: + """ + Execute a workflow-type prompt (graph-based execution). + + Phase 2-4: Sequenzielle Workflow-Execution, conditional branching, path consolidation + Phase 5: Graph aus ai_prompts.graph_data (nicht workflow_definitions) + + Args: + prompt: Prompt dict from database (must have 'graph_data' field) + variables: Dict of variables for placeholder replacement + openrouter_call_func: Async function(prompt_text, model) -> response_text + enable_debug: If True, include debug information in response + catalog: Optional placeholder catalog + + Returns: + Dict with execution results: + { + "type": "workflow", + "execution_id": "...", + "status": "completed" | "failed", + "aggregated_result": {...}, + "node_states": [...], # Only if enable_debug=True + "error": "..." # Only if status=failed + } + """ + from workflow_executor import execute_workflow + + # Phase 5: Graph aus ai_prompts.graph_data + graph_data = prompt.get('graph_data') + if not graph_data: + raise HTTPException(400, "Workflow-Prompt fehlt 'graph_data' Feld") + + # Execute workflow (mit graph_data statt workflow_id) + result = await execute_workflow( + graph_data=graph_data, # NEU: Direkt graph_data übergeben + profile_id=variables.get('profile_id', 'unknown'), # From context + variables=variables, + openrouter_call_func=openrouter_call_func, + enable_debug=enable_debug + ) + + # Convert ExecutionResult to dict for API response + response = { + "type": "workflow", + "execution_id": result.execution_id, + "status": result.status, + "aggregated_result": result.aggregated_result + } + + if enable_debug: + response["node_states"] = [s.model_dump() for s in result.node_states] + + if result.error: + response["error"] = result.error + + return response diff --git a/backend/question_augmenter.py b/backend/question_augmenter.py new file mode 100644 index 0000000..7da8529 --- /dev/null +++ b/backend/question_augmenter.py @@ -0,0 +1,289 @@ +""" +Question Augmenter (Phase 1) + +Generiert Fragenergänzungs-Suffix für Analyseprompts. + +Konzept-Basis: konzept_workflow_engine_konsolidated.md (Sektion 6.4, 8.3) +Anforderungsanalyse: anforderungsanalyse_umsetzungsplan.md (Sektion 4.2, 6) + +Hybridmodell (Sektion 6): +- Primär: Knotengebundene Fragenergänzungen (am Workflow-Knoten definiert) +- Sekundär: Prompt-gebundene Standardfragen (optional in ai_prompts.question_augmentations) +- Vorrangregel: Knotenspezifische überschreiben Prompt-Defaults + +Output-Format (User-Entscheidung 2): +- Markdown-Sektionen mit klaren Delimitern (statt verpflichtendem JSON-Mode) +""" +from typing import List, Dict, Optional, Any +from workflow_models import QuestionAugmentation +from db import get_db, get_cursor, r2d + + +def generate_question_suffix(questions: List[QuestionAugmentation]) -> str: + """ + Generiert Fragenergänzungs-Suffix für einen Analyseprompt. + + Format (Markdown-Sektionen): + ``` + ## Analyse + [Hauptinhalt deines Prompts hier] + + ## Entscheidungsfragen + Beantworte folgende Fragen präzise: + - Relevanz: [ja/nein/unklar] + - Priorität: [hoch/mittel/niedrig/unklar] + + ## Begründung + [Optional: Kurze Plausibilisierung deiner Antworten (1-2 Sätze)] + ``` + + Args: + questions: Liste von QuestionAugmentation-Objekten + + Returns: + Fragenergänzungs-Suffix als Markdown-String + + Raises: + ValueError: Bei leerer Fragenliste + """ + if not questions: + raise ValueError("Fragenliste darf nicht leer sein") + + # Build question list + question_lines = [] + for q in questions: + # Format: "- Fragentyp: [erlaubte Werte]" + spectrum_str = "/".join(q.answer_spectrum) + question_lines.append(f"- {q.type.capitalize()}: [{spectrum_str}]") + + question_block = "\n".join(question_lines) + + suffix = f""" + +--- + +**WICHTIG: Strukturiere deine Antwort wie folgt:** + +## Analyse +[Deine Hauptanalyse hier - beantworte die ursprüngliche Frage ausführlich] + +## Entscheidungsfragen +Beantworte folgende Fragen **präzise** mit den vorgegebenen Werten: +{question_block} + +## Begründung +[Optional: Kurze Plausibilisierung deiner Entscheidungsfragen-Antworten (1-2 Sätze)] + +**Hinweise:** +- Antworte bei Entscheidungsfragen NUR mit den vorgegebenen Werten +- Bei Unsicherheit wähle "unklar" +- Die Begründung ist optional, aber hilfreich für die Nachvollziehbarkeit +""" + + return suffix + + +def load_question_augmentations_from_db( + question_types: Optional[List[str]] = None +) -> List[QuestionAugmentation]: + """ + Lädt Fragenergänzungen aus der Datenbank (workflow_question_catalog). + + Args: + question_types: Optionale Liste von Fragetypen zum Filtern + (z.B. ["relevanz", "prioritaet"]) + Wenn None: Alle aktiven Fragen werden geladen + + Returns: + Liste von QuestionAugmentation-Objekten + + Raises: + HTTPException: Bei Datenbankfehlern + """ + with get_db() as conn: + cur = get_cursor(conn) + + if question_types: + placeholders = ", ".join(["%s"] * len(question_types)) + cur.execute( + f"""SELECT id, question_type, label, question_template, answer_spectrum + FROM workflow_question_catalog + WHERE active = true AND question_type IN ({placeholders}) + ORDER BY question_type""", + tuple(question_types) + ) + else: + cur.execute( + """SELECT id, question_type, label, question_template, answer_spectrum + FROM workflow_question_catalog + WHERE active = true + ORDER BY question_type""" + ) + + rows = cur.fetchall() + + questions = [] + for row in rows: + db_row = r2d(row) + questions.append( + QuestionAugmentation( + id=db_row['question_type'], # Verwende Typ als ID (z.B. "relevanz") + type=db_row['question_type'], + question=db_row['question_template'], + answer_spectrum=db_row['answer_spectrum'] + ) + ) + + return questions + + +def merge_question_augmentations( + node_questions: Optional[List[QuestionAugmentation]], + prompt_default_questions: Optional[List[QuestionAugmentation]] +) -> List[QuestionAugmentation]: + """ + Merged Fragenergänzungen nach Hybridmodell-Vorrangregel. + + Vorrangregel (Sektion 6.2 der Anforderungsanalyse): + 1. Knotenspezifische Fragenergänzungen überschreiben Prompt-Defaults + 2. Wenn Knoten keine Fragen definiert: Prompt-Defaults werden verwendet (falls vorhanden) + 3. Wenn weder Knoten noch Prompt Fragen definieren: Leere Liste + + Args: + node_questions: Fragenergänzungen am Workflow-Knoten (primär) + prompt_default_questions: Fragenergänzungen am Prompt (sekundär) + + Returns: + Gemergete Liste von QuestionAugmentation-Objekten + """ + # Vorrangregel 1: Knotenspezifische Fragen haben absolute Priorität + if node_questions: + return node_questions + + # Vorrangregel 2: Wenn Knoten keine Fragen hat, verwende Prompt-Defaults + if prompt_default_questions: + return prompt_default_questions + + # Vorrangregel 3: Wenn weder Knoten noch Prompt Fragen haben: Leere Liste + return [] + + +def parse_question_augmentations_from_jsonb( + jsonb_data: Optional[Dict[str, Any]] +) -> List[QuestionAugmentation]: + """ + Parsed QuestionAugmentation-Objekte aus JSONB-Daten (z.B. ai_prompts.question_augmentations). + + Format: + [ + { + "id": "q1", + "type": "relevanz", + "question": "Ist eine vertiefte Analyse relevant?", + "answer_spectrum": ["ja", "nein", "unklar"] + }, + ... + ] + + Args: + jsonb_data: JSONB-Array als Python-Dict/List + + Returns: + Liste von QuestionAugmentation-Objekten + + Raises: + ValueError: Bei ungültigem Format + """ + if not jsonb_data: + return [] + + if not isinstance(jsonb_data, list): + raise ValueError("question_augmentations muss ein Array sein") + + questions = [] + for item in jsonb_data: + try: + questions.append(QuestionAugmentation(**item)) + except Exception as e: + raise ValueError(f"Ungültiges QuestionAugmentation-Format: {e}") + + return questions + + +def get_question_augmentation_instruction() -> str: + """ + Gibt generische Instruktion für strukturierte Antwort zurück. + + Diese Instruktion wird IMMER zum Prompt hinzugefügt, wenn Fragenergänzungen aktiv sind. + + Returns: + Instruktions-String als Markdown + """ + return """ + +--- + +**WICHTIG: Strukturiere deine Antwort in folgenden Markdown-Sektionen:** + +1. **## Analyse** - Deine Hauptanalyse (beantworte die ursprüngliche Frage) +2. **## Entscheidungsfragen** - Beantworte die unten stehenden Fragen PRÄZISE mit den vorgegebenen Werten +3. **## Begründung** - Optional: Kurze Plausibilisierung deiner Entscheidungsfragen (1-2 Sätze) +""" + + +def format_question_list(questions: List[QuestionAugmentation]) -> str: + """ + Formatiert Fragenliste als Markdown-Liste. + + Format: + ``` + - Relevanz: [ja/nein/unklar] + - Priorität: [hoch/mittel/niedrig/unklar] + ``` + + Args: + questions: Liste von QuestionAugmentation-Objekten + + Returns: + Formatierte Markdown-Liste + """ + lines = [] + for q in questions: + spectrum_str = "/".join(q.answer_spectrum) + lines.append(f"- **{q.type.capitalize()}**: [{spectrum_str}]") + + return "\n".join(lines) + + +def augment_prompt_with_questions( + base_prompt: str, + questions: List[QuestionAugmentation] +) -> str: + """ + Fügt Fragenergänzungen zu einem Basis-Prompt hinzu. + + Args: + base_prompt: Original-Prompt-Text + questions: Liste von Fragenergänzungen + + Returns: + Erweiterter Prompt mit Fragenergänzungen + + Raises: + ValueError: Bei leerer Fragenliste + """ + if not questions: + raise ValueError("Keine Fragenergänzungen vorhanden") + + instruction = get_question_augmentation_instruction() + question_list = format_question_list(questions) + + augmented_prompt = f"""{base_prompt} + +{instruction} + +**Entscheidungsfragen:** +{question_list} +""" + + return augmented_prompt diff --git a/backend/result_container_parser.py b/backend/result_container_parser.py new file mode 100644 index 0000000..638e050 --- /dev/null +++ b/backend/result_container_parser.py @@ -0,0 +1,271 @@ +""" +Result Container Parser (Phase 1) + +Parsed strukturierte LLM-Antworten (Markdown-Sektionen). + +Konzept-Basis: konzept_workflow_engine_konsolidated.md (Sektion 8.2) +Anforderungsanalyse: anforderungsanalyse_umsetzungsplan.md (Sektion 4.1) + +Erwartet Format (Markdown-Sektionen): +``` +## Analyse +[Hauptinhalt] + +## Entscheidungsfragen +- Relevanz: ja +- Priorität: hoch + +## Begründung +[Optional: Plausibilisierung] +``` + +Output: +```python +{ + "analysis_core": str, + "decision_signals": Dict[str, str], # {"relevanz": "ja", "prioritaet": "hoch"} + "reasoning_anchors": Optional[str] +} +``` +""" +import re +from typing import Dict, Optional, List, Tuple + + +def parse_result_container(llm_output: str) -> Dict: + """ + Parsed strukturierte LLM-Antwort in Ergebniscontainer. + + Extrahiert drei Bereiche: + 1. **Analysekern** (## Analyse) + 2. **Entscheidungsanteil** (## Entscheidungsfragen) + 3. **Begründungsanker** (## Begründung, optional) + + Args: + llm_output: Vollständiger LLM-Output als String + + Returns: + Dict mit drei Bereichen: + { + "analysis_core": str, + "decision_signals": Dict[str, str], + "reasoning_anchors": Optional[str], + "parsing_status": str # "complete", "partial", "failed" + } + + Raises: + ValueError: Bei komplett fehlgeschlagenem Parsing + """ + # Extrahiere Sektionen + analysis_core = extract_section(llm_output, "Analyse") + decision_section = extract_section(llm_output, "Entscheidungsfragen") + reasoning_anchors = extract_section(llm_output, "Begründung") + + # Parse Entscheidungsfragen + decision_signals = {} + if decision_section: + decision_signals = parse_decision_questions(decision_section) + + # Determine parsing status + if analysis_core and decision_signals: + parsing_status = "complete" + elif analysis_core or decision_signals: + parsing_status = "partial" + else: + parsing_status = "failed" + + # Fallback: Wenn keine Strukturierung erkannt, verwende gesamten Output als Analysekern + if parsing_status == "failed": + analysis_core = llm_output + parsing_status = "fallback" + + return { + "analysis_core": analysis_core or "", + "decision_signals": decision_signals, + "reasoning_anchors": reasoning_anchors, + "parsing_status": parsing_status + } + + +def extract_section(text: str, section_name: str) -> Optional[str]: + """ + Extrahiert eine Markdown-Sektion aus dem Text. + + Sucht nach: + - `## Section_Name` (Überschrift) + - Alles bis zur nächsten `##` Überschrift + + Args: + text: Volltext + section_name: Name der Sektion (z.B. "Analyse", "Entscheidungsfragen") + + Returns: + Sektionsinhalt (ohne Überschrift) oder None wenn nicht gefunden + """ + # Pattern: ## Section_Name (mit optionalem Whitespace) + # Captured content bis zur nächsten ## oder Ende + pattern = rf'##\s*{re.escape(section_name)}\s*\n(.*?)(?=\n##|\Z)' + + match = re.search(pattern, text, re.DOTALL | re.IGNORECASE) + + if match: + content = match.group(1).strip() + return content if content else None + + return None + + +def parse_decision_questions(section_text: str) -> Dict[str, str]: + """ + Parsed Entscheidungsfragen aus Sektion. + + Erwartet Format: + ``` + - Relevanz: ja + - Priorität: hoch + - Selektion: nein + ``` + + Alternativen: + ``` + Relevanz: ja + Priorität: hoch + ``` + + oder: + ``` + - **Relevanz**: ja + - **Priorität**: hoch + ``` + + Args: + section_text: Text der Entscheidungsfragen-Sektion + + Returns: + Dict mit {frage_typ: antwort} + Beispiel: {"relevanz": "ja", "prioritaet": "hoch"} + """ + signals = {} + + # Pattern: Matcht verschiedene Formatvariationen + # Gruppe 1: Fragetyp (z.B. "Relevanz", "**Priorität**") + # Gruppe 2: Antwort (z.B. "ja", "hoch") + patterns = [ + r'-\s*\*?\*?(\w+)\*?\*?\s*:\s*\[?([^\]\n]+)\]?', # "- **Relevanz**: [ja]" + r'^\s*\*?\*?(\w+)\*?\*?\s*:\s*\[?([^\]\n]+)\]?', # "Relevanz: ja" (ohne -) + ] + + for pattern in patterns: + matches = re.finditer(pattern, section_text, re.MULTILINE | re.IGNORECASE) + for match in matches: + question_type = match.group(1).strip().lower() + answer = match.group(2).strip() + + # Entferne Klammern und Whitespace + answer = answer.strip('[]()').strip() + + signals[question_type] = answer + + return signals + + +def validate_decision_signal( + signal_value: str, + answer_spectrum: List[str] +) -> Tuple[str, str]: + """ + Validiert ein Entscheidungssignal gegen Antwortspektrum. + + Gibt zurück: + - (normalized_value, status) + + Status: + - "valid": Antwort exakt im Spektrum + - "normalized": Antwort wurde normalisiert (z.B. "Ja" → "ja") + - "invalid": Antwort außerhalb des Spektrums + + Args: + signal_value: Rohe Antwort vom LLM + answer_spectrum: Erlaubte Werte (z.B. ["ja", "nein", "unklar"]) + + Returns: + Tuple (normalisierter_wert, status) + """ + # Exakte Übereinstimmung (case-sensitive) + if signal_value in answer_spectrum: + return signal_value, "valid" + + # Case-insensitive Matching + signal_lower = signal_value.lower() + for allowed in answer_spectrum: + if signal_lower == allowed.lower(): + return allowed, "normalized" + + # Keine Übereinstimmung + return signal_value, "invalid" + + +def extract_analysis_core_fallback(llm_output: str) -> str: + """ + Fallback-Methode: Extrahiert Analysekern wenn keine Strukturierung erkannt. + + Versucht intelligent zu identifizieren was der Hauptinhalt ist: + 1. Wenn Text vor "## Entscheidungsfragen" existiert: Verwende das + 2. Sonst: Verwende gesamten Text + + Args: + llm_output: Vollständiger LLM-Output + + Returns: + Best-Guess für Analysekern + """ + # Suche nach "## Entscheidungsfragen" Marker + pattern = r'##\s*Entscheidungsfragen' + match = re.search(pattern, llm_output, re.IGNORECASE) + + if match: + # Text vor dem Marker ist wahrscheinlich die Analyse + return llm_output[:match.start()].strip() + + # Fallback: Gesamter Text + return llm_output.strip() + + +def parse_result_container_robust( + llm_output: str, + expected_questions: Optional[List[str]] = None +) -> Dict: + """ + Robuste Variante des Parsings mit zusätzlicher Validierung. + + Args: + llm_output: Vollständiger LLM-Output + expected_questions: Optionale Liste erwarteter Fragetypen (z.B. ["relevanz", "prioritaet"]) + + Returns: + Ergebniscontainer mit zusätzlichem "warnings"-Feld + """ + result = parse_result_container(llm_output) + warnings = [] + + # Prüfe ob erwartete Fragen vorhanden + if expected_questions: + found_questions = set(result['decision_signals'].keys()) + expected_set = set(expected_questions) + + missing = expected_set - found_questions + if missing: + warnings.append(f"Fehlende Entscheidungsfragen: {', '.join(missing)}") + + unexpected = found_questions - expected_set + if unexpected: + warnings.append(f"Unerwartete Entscheidungsfragen: {', '.join(unexpected)}") + + # Prüfe Parsing-Status + if result['parsing_status'] == "partial": + warnings.append("Parsing unvollständig: Einige Sektionen fehlen") + elif result['parsing_status'] == "fallback": + warnings.append("Keine Strukturierung erkannt, Fallback-Parsing verwendet") + + result['warnings'] = warnings + return result diff --git a/backend/routers/profiles.py b/backend/routers/profiles.py index f2ebe05..b9937ea 100644 --- a/backend/routers/profiles.py +++ b/backend/routers/profiles.py @@ -68,13 +68,62 @@ def get_profile(pid: str, session=Depends(require_auth)): @router.put("/profiles/{pid}") def update_profile(pid: str, p: ProfileUpdate, session=Depends(require_auth)): - """Update profile by ID (admin).""" + """Update profile by ID.""" with get_db() as conn: - data = {k:v for k,v in p.model_dump().items() if v is not None} - data['updated'] = datetime.now().isoformat() cur = get_cursor(conn) - cur.execute(f"UPDATE profiles SET {', '.join(f'{k}=%s' for k in data)} WHERE id=%s", - list(data.values())+[pid]) + cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,)) + row = cur.fetchone() + if not row: + raise HTTPException(404, "Profil nicht gefunden") + rowd = r2d(row) + cur_email_norm = (rowd.get("email") or "").strip().lower() + + patch = p.model_dump(exclude_unset=True) + data = {} + + if "email" in patch: + ev = patch["email"] + if ev is None or (isinstance(ev, str) and ev.strip() == ""): + if rowd.get("email") is not None: + data["email"] = None + data["email_verified"] = False + data["verification_token"] = None + data["verification_expires"] = None + else: + email_norm = ev.strip().lower() + if "@" not in email_norm or len(email_norm) < 5: + raise HTTPException(400, "Ungültige E-Mail-Adresse") + cur.execute( + """ + SELECT id FROM profiles + WHERE email IS NOT NULL AND lower(trim(email)) = %s AND id <> %s + """, + (email_norm, pid), + ) + if cur.fetchone(): + raise HTTPException(409, "E-Mail wird bereits verwendet") + data["email"] = email_norm + if email_norm != cur_email_norm: + data["email_verified"] = False + data["verification_token"] = None + data["verification_expires"] = None + + nullable_keys = {"goal_weight", "goal_bf_pct", "dob"} + for k, v in patch.items(): + if k == "email": + continue + if v is None and k in nullable_keys: + data[k] = None + elif v is not None: + data[k] = v + + if not data: + return get_profile(pid, session) + + data["updated"] = datetime.now().isoformat() + cols = ", ".join(f"{k}=%s" for k in data) + vals = list(data.values()) + [pid] + cur.execute(f"UPDATE profiles SET {cols} WHERE id=%s", vals) return get_profile(pid, session) diff --git a/backend/routers/prompts.py b/backend/routers/prompts.py index 6cecd4e..a52ebe4 100644 --- a/backend/routers/prompts.py +++ b/backend/routers/prompts.py @@ -53,6 +53,20 @@ def list_prompts(session: dict=Depends(require_auth)): return [r2d(r) for r in cur.fetchall()] +@router.get("/{prompt_id}") +def get_prompt(prompt_id: str, session: dict=Depends(require_auth)): + """Get single AI prompt by ID (UUID).""" + with get_db() as conn: + cur = get_cursor(conn) + cur.execute("SELECT * FROM ai_prompts WHERE id=%s", (prompt_id,)) + row = cur.fetchone() + + if not row: + raise HTTPException(status_code=404, detail="Prompt not found") + + return r2d(row) + + @router.post("") def create_prompt(p: PromptCreate, session: dict=Depends(require_admin)): """Create new AI prompt (admin only).""" @@ -1380,20 +1394,26 @@ async def execute_unified_prompt( @router.post("/unified") def create_unified_prompt(p: UnifiedPromptCreate, session: dict = Depends(require_admin)): """ - Create a new unified prompt (base or pipeline type). + Create a new unified prompt (base, pipeline, or workflow type). Admin only. """ with get_db() as conn: cur = get_cursor(conn) + # Auto-generate slug if not provided (for workflows) + if not p.slug: + import re + base_slug = re.sub(r'[^a-z0-9_]+', '_', p.name.lower()).strip('_') + p.slug = f"{base_slug}_{uuid.uuid4().hex[:6]}" + # Check for duplicate slug cur.execute("SELECT id FROM ai_prompts WHERE slug=%s", (p.slug,)) if cur.fetchone(): raise HTTPException(status_code=400, detail="Slug already exists") # Validate type - if p.type not in ['base', 'pipeline']: - raise HTTPException(status_code=400, detail="Type must be 'base' or 'pipeline'") + if p.type not in ['base', 'pipeline', 'workflow']: + raise HTTPException(status_code=400, detail="Type must be 'base', 'pipeline', or 'workflow'") # Validate base type has template if p.type == 'base' and not p.template: @@ -1403,6 +1423,10 @@ def create_unified_prompt(p: UnifiedPromptCreate, session: dict = Depends(requir if p.type == 'pipeline' and not p.stages: raise HTTPException(status_code=400, detail="Pipeline prompts require stages") + # Validate workflow type has graph_data + if p.type == 'workflow' and not p.graph_data: + raise HTTPException(status_code=400, detail="Workflow prompts require graph_data") + # Convert stages to JSONB stages_json = None if p.stages: @@ -1426,16 +1450,22 @@ def create_unified_prompt(p: UnifiedPromptCreate, session: dict = Depends(requir prompt_id = str(uuid.uuid4()) + # Convert graph_data to JSONB + graph_data_json = None + if p.graph_data: + graph_data_json = json.dumps(p.graph_data) + cur.execute( """INSERT INTO ai_prompts (id, slug, name, display_name, description, template, category, active, sort_order, - type, stages, output_format, output_schema) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)""", + type, stages, output_format, output_schema, graph_data) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)""", ( prompt_id, p.slug, p.name, p.display_name, p.description, p.template, p.category, p.active, p.sort_order, p.type, stages_json, p.output_format, - json.dumps(p.output_schema) if p.output_schema else None + json.dumps(p.output_schema) if p.output_schema else None, + graph_data_json ) ) @@ -1470,8 +1500,8 @@ def update_unified_prompt(prompt_id: str, p: UnifiedPromptUpdate, session: dict updates.append('description=%s') values.append(p.description) if p.type is not None: - if p.type not in ['base', 'pipeline']: - raise HTTPException(status_code=400, detail="Type must be 'base' or 'pipeline'") + if p.type not in ['base', 'pipeline', 'workflow']: + raise HTTPException(status_code=400, detail="Type must be 'base', 'pipeline', or 'workflow'") updates.append('type=%s') values.append(p.type) if p.category is not None: @@ -1512,6 +1542,9 @@ def update_unified_prompt(prompt_id: str, p: UnifiedPromptUpdate, session: dict ]) updates.append('stages=%s') values.append(stages_json) + if p.graph_data is not None: + updates.append('graph_data=%s') + values.append(json.dumps(p.graph_data)) if not updates: return {"ok": True} diff --git a/backend/routers/workflow_questions.py b/backend/routers/workflow_questions.py new file mode 100644 index 0000000..c96364c --- /dev/null +++ b/backend/routers/workflow_questions.py @@ -0,0 +1,235 @@ +""" +Workflow Questions Router (Phase 1) + +CRUD für workflow_question_catalog Tabelle. + +Endpunkte: +- GET /api/workflow/questions - Liste aller Fragen +- GET /api/workflow/questions/{id} - Einzelne Frage +- POST /api/workflow/questions - Neue Frage (Admin only) +- PUT /api/workflow/questions/{id} - Frage aktualisieren (Admin only) +- DELETE /api/workflow/questions/{id} - Frage löschen (Admin only) +""" +from fastapi import APIRouter, Depends, HTTPException +from typing import List, Optional +from auth import require_auth, require_admin +from db import get_db, get_cursor, r2d +from workflow_models import QuestionCatalogEntry +from pydantic import BaseModel + + +router = APIRouter() + + +class QuestionCatalogCreate(BaseModel): + """Request-Modell für neue Frage""" + question_type: str + label: str + question_template: str + answer_spectrum: List[str] + normalization_rules: Optional[dict] = None + + +class QuestionCatalogUpdate(BaseModel): + """Request-Modell für Frage-Update""" + label: Optional[str] = None + question_template: Optional[str] = None + answer_spectrum: Optional[List[str]] = None + normalization_rules: Optional[dict] = None + active: Optional[bool] = None + + +@router.get("/api/workflow/questions") +def list_questions( + active_only: bool = True, + session: dict = Depends(require_auth) +): + """ + Liste alle Fragen aus dem Katalog. + + Query-Parameter: + - active_only: Nur aktive Fragen (default: true) + """ + with get_db() as conn: + cur = get_cursor(conn) + + if active_only: + cur.execute( + """SELECT id, question_type, label, question_template, answer_spectrum, + normalization_rules, active, created_at::text as created_at + FROM workflow_question_catalog + WHERE active = true + ORDER BY question_type""" + ) + else: + cur.execute( + """SELECT id, question_type, label, question_template, answer_spectrum, + normalization_rules, active, created_at::text as created_at + FROM workflow_question_catalog + ORDER BY question_type""" + ) + + rows = cur.fetchall() + + return [r2d(row) for row in rows] + + +@router.get("/api/workflow/questions/{question_id}") +def get_question( + question_id: str, + session: dict = Depends(require_auth) +): + """Einzelne Frage abrufen""" + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + """SELECT id, question_type, label, question_template, answer_spectrum, + normalization_rules, active, created_at::text as created_at + FROM workflow_question_catalog + WHERE id = %s""", + (question_id,) + ) + row = cur.fetchone() + + if not row: + raise HTTPException(404, f"Frage nicht gefunden: {question_id}") + + return r2d(row) + + +@router.post("/api/workflow/questions") +def create_question( + data: QuestionCatalogCreate, + session: dict = Depends(require_admin) +): + """ + Neue Frage erstellen (Admin only). + + Validierungen: + - question_type muss eindeutig sein + - answer_spectrum muss mindestens 2 Werte enthalten + """ + # Validierung + if len(data.answer_spectrum) < 2: + raise HTTPException(400, "answer_spectrum muss mindestens 2 Werte enthalten") + + with get_db() as conn: + cur = get_cursor(conn) + + # Prüfe ob question_type bereits existiert + cur.execute( + "SELECT id FROM workflow_question_catalog WHERE question_type = %s", + (data.question_type,) + ) + if cur.fetchone(): + raise HTTPException(400, f"question_type '{data.question_type}' existiert bereits") + + # Erstelle Frage + cur.execute( + """INSERT INTO workflow_question_catalog + (question_type, label, question_template, answer_spectrum, normalization_rules, active) + VALUES (%s, %s, %s, %s, %s, true) + RETURNING id, created_at::text as created_at""", + ( + data.question_type, + data.label, + data.question_template, + json.dumps(data.answer_spectrum), + json.dumps(data.normalization_rules) if data.normalization_rules else None + ) + ) + result = cur.fetchone() + conn.commit() + + return { + "id": result[0], + "question_type": data.question_type, + "label": data.label, + "question_template": data.question_template, + "answer_spectrum": data.answer_spectrum, + "normalization_rules": data.normalization_rules, + "active": True, + "created_at": result[1] + } + + +@router.put("/api/workflow/questions/{question_id}") +def update_question( + question_id: str, + data: QuestionCatalogUpdate, + session: dict = Depends(require_admin) +): + """Frage aktualisieren (Admin only)""" + import json + + # Prüfe ob Frage existiert + with get_db() as conn: + cur = get_cursor(conn) + cur.execute("SELECT id FROM workflow_question_catalog WHERE id = %s", (question_id,)) + if not cur.fetchone(): + raise HTTPException(404, f"Frage nicht gefunden: {question_id}") + + # Build UPDATE statement dynamically + updates = [] + params = [] + + if data.label is not None: + updates.append("label = %s") + params.append(data.label) + + if data.question_template is not None: + updates.append("question_template = %s") + params.append(data.question_template) + + if data.answer_spectrum is not None: + if len(data.answer_spectrum) < 2: + raise HTTPException(400, "answer_spectrum muss mindestens 2 Werte enthalten") + updates.append("answer_spectrum = %s") + params.append(json.dumps(data.answer_spectrum)) + + if data.normalization_rules is not None: + updates.append("normalization_rules = %s") + params.append(json.dumps(data.normalization_rules)) + + if data.active is not None: + updates.append("active = %s") + params.append(data.active) + + if not updates: + raise HTTPException(400, "Keine Aktualisierungen angegeben") + + params.append(question_id) + update_sql = f"UPDATE workflow_question_catalog SET {', '.join(updates)} WHERE id = %s" + + cur.execute(update_sql, tuple(params)) + conn.commit() + + # Return updated question + return get_question(question_id, session) + + +@router.delete("/api/workflow/questions/{question_id}") +def delete_question( + question_id: str, + session: dict = Depends(require_admin) +): + """ + Frage löschen (Admin only). + + Setzt active=false statt physischem Löschen (Soft Delete). + """ + with get_db() as conn: + cur = get_cursor(conn) + + cur.execute( + "UPDATE workflow_question_catalog SET active = false WHERE id = %s RETURNING id", + (question_id,) + ) + result = cur.fetchone() + + if not result: + raise HTTPException(404, f"Frage nicht gefunden: {question_id}") + + conn.commit() + + return {"status": "deleted", "id": question_id} diff --git a/backend/routers/workflows.py b/backend/routers/workflows.py new file mode 100644 index 0000000..d37f5b2 --- /dev/null +++ b/backend/routers/workflows.py @@ -0,0 +1,222 @@ +""" +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 routers.prompts import call_openrouter + + 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['id'], + "name": row['name'], + "slug": row['slug'], + "type": "workflow" + } + + try: + result = await execute_workflow_prompt( + prompt=workflow_prompt, + variables=variables, + openrouter_call_func=call_openrouter, + 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) diff --git a/backend/tests/test_end_node_template.py b/backend/tests/test_end_node_template.py new file mode 100644 index 0000000..c9fe757 --- /dev/null +++ b/backend/tests/test_end_node_template.py @@ -0,0 +1,244 @@ +""" +Unit Tests: End Node Template Engine (Phase 4) + +Tests execute_end_node() mit AUTO und TEMPLATE modes. +""" + +import pytest +from datetime import datetime +from workflow_executor import execute_end_node +from workflow_models import ( + WorkflowNode, EndNodeOutputMode, NodeStatus, NodeExecutionState +) + + +class TestEndNodeTemplateEngine: + """Tests für End Node Template Rendering""" + + def test_auto_mode_concatenates_all_analyses(self): + """AUTO mode: Concatenates all analysis_core values""" + # Setup: Node mit AUTO mode + end_node = WorkflowNode( + id="end", + type="end", + output_mode=EndNodeOutputMode.AUTO + ) + + # Setup: Context mit 2 executed nodes + context = { + "node_results": { + "analysis1": NodeExecutionState( + node_id="analysis1", + status=NodeStatus.EXECUTED, + analysis_core="Body analysis result", + decision_signals={"relevanz": "hoch"} + ), + "analysis2": NodeExecutionState( + node_id="analysis2", + status=NodeStatus.EXECUTED, + analysis_core="Training recommendation", + decision_signals={"prioritaet": "mittel"} + ) + } + } + + # Execute + result = execute_end_node(end_node, context) + + # Assert + assert result.status == NodeStatus.EXECUTED + assert "## analysis1" in result.analysis_core + assert "Body analysis result" in result.analysis_core + assert "## analysis2" in result.analysis_core + assert "Training recommendation" in result.analysis_core + print("✓ AUTO mode concatenation works") + + + def test_auto_mode_skips_skipped_nodes(self): + """AUTO mode: Skips nodes with status SKIPPED""" + end_node = WorkflowNode( + id="end", + type="end", + output_mode=EndNodeOutputMode.AUTO + ) + + context = { + "node_results": { + "analysis1": NodeExecutionState( + node_id="analysis1", + status=NodeStatus.EXECUTED, + analysis_core="Executed analysis" + ), + "analysis2": NodeExecutionState( + node_id="analysis2", + status=NodeStatus.SKIPPED, + analysis_core="This should not appear" + ) + } + } + + result = execute_end_node(end_node, context) + + assert "Executed analysis" in result.analysis_core + assert "This should not appear" not in result.analysis_core + print("✓ AUTO mode skips SKIPPED nodes") + + + def test_template_mode_basic_rendering(self): + """TEMPLATE mode: Renders Jinja2 template with node placeholders""" + end_node = WorkflowNode( + id="end", + type="end", + output_mode=EndNodeOutputMode.TEMPLATE, + template=( + "# Final Result\n\n" + "## Analysis\n" + "{{analysis1.analysis_core}}\n\n" + "## Signals\n" + "Relevanz: {{analysis1.relevanz}}" + ) + ) + + context = { + "node_results": { + "analysis1": NodeExecutionState( + node_id="analysis1", + status=NodeStatus.EXECUTED, + analysis_core="Test analysis content", + decision_signals={"relevanz": "hoch"} + ) + } + } + + result = execute_end_node(end_node, context) + + assert result.status == NodeStatus.EXECUTED + assert "# Final Result" in result.analysis_core + assert "Test analysis content" in result.analysis_core + assert "Relevanz: hoch" in result.analysis_core + print("✓ TEMPLATE mode basic rendering works") + + + def test_template_conditional_rendering(self): + """TEMPLATE mode: Conditional rendering with {% if node_id %}""" + end_node = WorkflowNode( + id="end", + type="end", + output_mode=EndNodeOutputMode.TEMPLATE, + template=( + "# Result\n\n" + "{% if analysis1 %}" + "Analysis 1 executed\n" + "{% endif %}" + "{% if analysis2 %}" + "Analysis 2 executed\n" + "{% endif %}" + ) + ) + + context = { + "node_results": { + "analysis1": NodeExecutionState( + node_id="analysis1", + status=NodeStatus.EXECUTED, + analysis_core="Content 1" + ) + # analysis2 not in results (optional path not taken) + } + } + + result = execute_end_node(end_node, context) + + assert "Analysis 1 executed" in result.analysis_core + assert "Analysis 2 executed" not in result.analysis_core + print("✓ TEMPLATE mode conditional rendering works") + + + def test_template_default_values(self): + """TEMPLATE mode: Default values for missing nodes""" + end_node = WorkflowNode( + id="end", + type="end", + output_mode=EndNodeOutputMode.TEMPLATE, + template=( + "Result: {{missing_node.analysis_core|default('Nicht verfügbar')}}" + ) + ) + + context = {"node_results": {}} + + result = execute_end_node(end_node, context) + + assert "Nicht verfügbar" in result.analysis_core + print("✓ TEMPLATE mode default values work") + + + def test_template_missing_template_fails(self): + """TEMPLATE mode without template raises error""" + end_node = WorkflowNode( + id="end", + type="end", + output_mode=EndNodeOutputMode.TEMPLATE, + template=None # Missing template + ) + + context = {"node_results": {}} + + result = execute_end_node(end_node, context) + + assert result.status == NodeStatus.FAILED + assert "no template defined" in result.error.lower() + print("✓ TEMPLATE mode fails without template") + + + def test_template_syntax_error_fails(self): + """TEMPLATE mode with invalid syntax fails gracefully""" + end_node = WorkflowNode( + id="end", + type="end", + output_mode=EndNodeOutputMode.TEMPLATE, + template="{% invalid syntax %}" + ) + + context = {"node_results": {}} + + result = execute_end_node(end_node, context) + + assert result.status == NodeStatus.FAILED + assert "template rendering failed" in result.error.lower() + print("✓ TEMPLATE mode handles syntax errors") + + + def test_auto_mode_empty_results(self): + """AUTO mode with no executed nodes returns placeholder""" + end_node = WorkflowNode( + id="end", + type="end", + output_mode=EndNodeOutputMode.AUTO + ) + + context = {"node_results": {}} + + result = execute_end_node(end_node, context) + + assert result.status == NodeStatus.EXECUTED + assert "[No analysis generated]" in result.analysis_core + print("✓ AUTO mode handles empty results") + + +if __name__ == "__main__": + # Run tests + test = TestEndNodeTemplateEngine() + + print("\n=== Testing End Node Template Engine ===\n") + + test.test_auto_mode_concatenates_all_analyses() + test.test_auto_mode_skips_skipped_nodes() + test.test_template_mode_basic_rendering() + test.test_template_conditional_rendering() + test.test_template_default_values() + test.test_template_missing_template_fails() + test.test_template_syntax_error_fails() + test.test_auto_mode_empty_results() + + print("\n✅ All tests passed!\n") diff --git a/backend/version.py b/backend/version.py new file mode 100644 index 0000000..739a463 --- /dev/null +++ b/backend/version.py @@ -0,0 +1,124 @@ +""" +Application Version Information + +Semantic Versioning: MAJOR.MINOR.PATCH +- MAJOR: Breaking Change, DB-Migration inkompatibel +- MINOR: Neues Feature, neues Modul +- PATCH: Bugfix, kleine Änderung, Refactor +""" + +APP_VERSION = "0.9n" +BUILD_DATE = "2026-04-05" +DB_SCHEMA_VERSION = "20260403" # Migration 034 + +MODULE_VERSIONS = { + "auth": "1.2.0", + "profiles": "1.1.0", + "weight": "1.0.3", + "circumference": "1.0.1", + "caliper": "1.0.1", + "activity": "1.1.0", + "nutrition": "1.0.2", + "photos": "1.0.0", + "insights": "1.3.0", + "prompts": "1.1.0", + "admin": "1.2.0", + "stats": "1.0.1", + "exportdata": "1.1.0", + "importdata": "1.0.0", + "membership": "2.1.0", + "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", + "changes": [ + "Phase 4: Join Nodes and Path Consolidation", + "join_evaluator.py: Join-Strategie-Evaluator (wait_all, wait_any, best_effort)", + "Path Status Collection: Sammelt incoming paths, prüft Ausführungsstatus", + "Result Consolidation: Merged analysis_cores + combined signals (mit node_id Präfix)", + "Skip-Handling: IGNORE_SKIPPED, USE_PLACEHOLDER, REQUIRE_MINIMUM", + "Partial Execution: Korrekte Behandlung von SKIPPED/FAILED Pfaden", + "workflow_executor.py: execute_join_node() Implementation", + "NodeExecutionState: List[NormalizedSignal] korrekt konvertiert zu Dict", + "Unit-Tests Phase 4: 18 Tests für Join Nodes (alle passing)", + "Phase 2/3 Backward Compatibility: 31 Tests (alle passing)", + ] + }, + { + "version": "0.9l", + "date": "2026-04-04", + "changes": [ + "Phase 3: Logic Nodes + Conditional Branching", + "logic_evaluator.py: Deterministischer Logic Evaluator (EQ, NEQ, IN, GT, LT, CONTAINS, AND, OR, NOT)", + "workflow_executor.py: Conditional Branching via BFS-Traversierung mit Edge-Activation", + "execute_logic_node(): Bedingungen evaluieren, then/else Pfade aktivieren", + "Fallback-Strategien: CONSERVATIVE_SKIP, DEFAULT_PATH, UNCERTAINTY_PATH, DOCUMENT_ONLY", + "workflow_models.py: CONTAINS Operator hinzugefügt", + "Unit-Tests Phase 3: 20 Tests für Logic Evaluator (alle passing)", + "Phase 2 Backward Compatibility: 11 Tests aktualisiert (alle passing)", + ] + }, + { + "version": "0.9k", + "date": "2026-04-03", + "changes": [ + "Phase 2: Normalisierung + Workflow Executor", + "normalization_engine.py: Synonym-Mapping, 5 Statuswerte (valid, normalized, unclear, invalid, not_decidable)", + "workflow_executor.py: Sequenzielle Workflow-Ausführung, Node-State-Tracking, Ergebnis-Aggregation", + "Integration in prompt_executor.py: Dispatcher für type='workflow'", + "API-Router workflows.py: POST /workflows/{id}/execute, GET /workflows/executions/{id}", + "Unit-Tests Phase 2: 27 Tests (normalization_engine + workflow_executor)", + "Erweitert: workflow_models.py (NormalizedSignal, NodeExecutionState, ExecutionResult)", + ] + }, + { + "version": "0.9j", + "date": "2026-04-03", + "changes": [ + "Phase 1: Fragenergänzung + Strukturierter Container", + "question_augmenter.py: Hybrid-Modell (Knotengebundene Fragen überschreiben Prompt-Defaults)", + "result_container_parser.py: Markdown-Sektionen (Analysekern, Entscheidungsanteil, Begründungsanker)", + "Integration in execute_base_prompt(): Fragenergänzung vor LLM-Call, Parsing nach LLM-Response", + "API-Router workflow_questions.py: CRUD für workflow_question_catalog", + "Unit-Tests Phase 1: 25 Tests (question_augmenter + result_container_parser)", + ] + }, + { + "version": "0.9i", + "date": "2026-04-03", + "changes": [ + "Phase 0: Workflow Engine Foundation", + "DB-Migration 034: workflow_definitions, workflow_question_catalog, workflow_executions", + "Pydantic-Modelle für Workflow-Graph (WorkflowGraph, Node, Edge, Condition)", + "Graph-Parsing, Topologische Sortierung, DAG-Validierung", + "Dispatcher-Erweiterung: type='workflow' (Stub-Implementierung)", + "Unit-Tests für Phase 0 (Graph-Parsing, Zyklen-Erkennung, Erreichbarkeit)", + ] + }, + { + "version": "0.9h+", + "date": "2026-03-28", + "changes": [ + "Phase 0c: Multi-Layer Data Architecture Complete", + "Data Layer Migration (97 Funktionen in 6 Modulen)", + "20 neue Chart Endpoints (E1-E5, A1-A8, R1-R5, C1-C4)", + "Single Source of Truth für Datenberechnungen", + ] + }, +] diff --git a/backend/workflow_engine.py b/backend/workflow_engine.py new file mode 100644 index 0000000..cd5b850 --- /dev/null +++ b/backend/workflow_engine.py @@ -0,0 +1,443 @@ +""" +Workflow Engine (Phase 0: Foundation) + +Graph-Parsing, topologische Sortierung, DAG-Validierung. + +Konzept-Basis: konzept_workflow_engine_konsolidated.md +Anforderungsanalyse: anforderungsanalyse_umsetzungsplan.md + +Phase 0: Foundation (Graph-Parsing, Validation) +Phase 1-3: Vollständige Execution-Logic +""" +from typing import Dict, List, Set, Optional, Tuple, Any +from workflow_models import ( + WorkflowGraph, + WorkflowNode, + WorkflowEdge, + NodeType, + NodeStatus +) +from fastapi import HTTPException + + +class WorkflowEngine: + """ + Workflow-Execution-Engine für graph-basierte Prompt-Workflows. + + Phase 0: Graph-Parsing und Validierung + Phase 1-3: Execution-Logic + """ + + def __init__(self, graph: WorkflowGraph): + """ + Initialisiere Engine mit Workflow-Graph. + + Args: + graph: Workflow-Graph (Knoten + Kanten) + + Raises: + HTTPException: Bei ungültigem Graph (Zyklen, fehlende Knoten, etc.) + """ + self.graph = graph + self.nodes_by_id: Dict[str, WorkflowNode] = {node.id: node for node in graph.nodes} + self.edges_by_id: Dict[str, WorkflowEdge] = {edge.id: edge for edge in graph.edges} + + # Adjacency lists für Traversierung + self.outgoing_edges: Dict[str, List[WorkflowEdge]] = {} + self.incoming_edges: Dict[str, List[WorkflowEdge]] = {} + + # Build adjacency lists + for edge in graph.edges: + # Outgoing edges (from node) + if edge.from_node not in self.outgoing_edges: + self.outgoing_edges[edge.from_node] = [] + self.outgoing_edges[edge.from_node].append(edge) + + # Incoming edges (to node) + if edge.to_node not in self.incoming_edges: + self.incoming_edges[edge.to_node] = [] + self.incoming_edges[edge.to_node].append(edge) + + # Validiere Graph + self._validate_graph() + + # Topologische Sortierung + self.topological_order = self._topological_sort() + + def _validate_graph(self): + """ + Validiere Workflow-Graph. + + Prüfungen: + 1. Alle referenzierten Knoten existieren + 2. Genau ein START-Knoten + 3. Mindestens ein END-Knoten + 4. Keine Zyklen (DAG) + 5. Alle Knoten erreichbar vom START + 6. Alle Knoten können END erreichen + + Raises: + HTTPException: Bei Validierungsfehlern + """ + errors = [] + + # 1. Prüfe ob alle referenzierten Knoten existieren + for edge in self.graph.edges: + if edge.from_node not in self.nodes_by_id: + errors.append(f"Edge {edge.id}: from_node '{edge.from_node}' existiert nicht") + if edge.to_node not in self.nodes_by_id: + errors.append(f"Edge {edge.id}: to_node '{edge.to_node}' existiert nicht") + + if errors: + raise HTTPException(400, {"error": "Ungültiger Graph", "details": errors}) + + # 2. Genau ein START-Knoten + start_nodes = [n for n in self.graph.nodes if n.type == NodeType.START] + if len(start_nodes) == 0: + errors.append("Kein START-Knoten gefunden") + elif len(start_nodes) > 1: + errors.append(f"Mehrere START-Knoten gefunden: {[n.id for n in start_nodes]}") + + # 3. Mindestens ein END-Knoten + end_nodes = [n for n in self.graph.nodes if n.type == NodeType.END] + if len(end_nodes) == 0: + errors.append("Kein END-Knoten gefunden") + + if errors: + raise HTTPException(400, {"error": "Ungültiger Graph", "details": errors}) + + # 4. Keine Zyklen (DAG-Prüfung) + cycle = self._detect_cycle() + if cycle: + errors.append(f"Zyklus erkannt: {' → '.join(cycle)}") + + if errors: + raise HTTPException(400, {"error": "Ungültiger Graph (Zyklus)", "details": errors}) + + # 5. Alle Knoten erreichbar vom START + start_node = start_nodes[0] + reachable = self._get_reachable_nodes(start_node.id) + unreachable = [n.id for n in self.graph.nodes if n.id not in reachable] + if unreachable: + errors.append(f"Knoten nicht erreichbar vom START: {unreachable}") + + # 6. Alle Knoten können END erreichen (Rückwärts-Prüfung) + end_nodes_ids = [n.id for n in end_nodes] + can_reach_end = self._get_nodes_reaching_end(end_nodes_ids) + cannot_reach_end = [n.id for n in self.graph.nodes if n.id not in can_reach_end] + if cannot_reach_end: + errors.append(f"Knoten können END nicht erreichen: {cannot_reach_end}") + + if errors: + raise HTTPException(400, {"error": "Ungültiger Graph (Erreichbarkeit)", "details": errors}) + + def _detect_cycle(self) -> Optional[List[str]]: + """ + Erkenne Zyklen im Graph mittels DFS. + + Returns: + Liste der Knoten im Zyklus, oder None wenn kein Zyklus + """ + visited: Set[str] = set() + rec_stack: Set[str] = set() # Recursion stack für DFS + parent: Dict[str, Optional[str]] = {} + + def dfs(node_id: str) -> Optional[List[str]]: + visited.add(node_id) + rec_stack.add(node_id) + + # Besuche alle Nachbarn + for edge in self.outgoing_edges.get(node_id, []): + neighbor = edge.to_node + + if neighbor not in visited: + parent[neighbor] = node_id + cycle = dfs(neighbor) + if cycle: + return cycle + elif neighbor in rec_stack: + # Zyklus gefunden! Rekonstruiere Pfad + cycle_path = [neighbor] + current = node_id + while current != neighbor: + cycle_path.append(current) + current = parent.get(current) + cycle_path.append(neighbor) # Schließe Zyklus + return list(reversed(cycle_path)) + + rec_stack.remove(node_id) + return None + + # Starte DFS von allen Knoten (für disconnected components) + for node in self.graph.nodes: + if node.id not in visited: + parent[node.id] = None + cycle = dfs(node.id) + if cycle: + return cycle + + return None + + def _get_reachable_nodes(self, start_node_id: str) -> Set[str]: + """ + Finde alle vom Startknoten aus erreichbaren Knoten (Vorwärts-Traversierung). + + Args: + start_node_id: ID des Startknotens + + Returns: + Set aller erreichbaren Knoten-IDs + """ + reachable: Set[str] = set() + stack = [start_node_id] + + while stack: + current = stack.pop() + if current in reachable: + continue + reachable.add(current) + + # Füge alle Nachbarn hinzu + for edge in self.outgoing_edges.get(current, []): + stack.append(edge.to_node) + + return reachable + + def _get_nodes_reaching_end(self, end_node_ids: List[str]) -> Set[str]: + """ + Finde alle Knoten die mindestens einen END-Knoten erreichen können. + + Rückwärts-Traversierung von END-Knoten. + + Args: + end_node_ids: Liste der END-Knoten-IDs + + Returns: + Set aller Knoten-IDs die END erreichen können + """ + can_reach_end: Set[str] = set() + stack = list(end_node_ids) + + while stack: + current = stack.pop() + if current in can_reach_end: + continue + can_reach_end.add(current) + + # Füge alle Vorgänger hinzu (rückwärts) + for edge in self.incoming_edges.get(current, []): + stack.append(edge.from_node) + + return can_reach_end + + def _topological_sort(self) -> List[str]: + """ + Berechne topologische Sortierung des Graphen (Kahn's Algorithm). + + Die topologische Sortierung gibt eine Reihenfolge vor, in der Knoten + ausgeführt werden können, ohne dass Abhängigkeiten verletzt werden. + + Returns: + Liste der Knoten-IDs in topologischer Reihenfolge + + Raises: + HTTPException: Bei Zyklen (sollte durch _validate_graph verhindert sein) + """ + # Berechne In-Degree für jeden Knoten + in_degree: Dict[str, int] = {node.id: 0 for node in self.graph.nodes} + for edge in self.graph.edges: + in_degree[edge.to_node] += 1 + + # Queue mit Knoten ohne Vorgänger (In-Degree = 0) + queue = [node_id for node_id, degree in in_degree.items() if degree == 0] + sorted_order = [] + + while queue: + # Entferne Knoten mit In-Degree 0 + current = queue.pop(0) + sorted_order.append(current) + + # Reduziere In-Degree aller Nachbarn + for edge in self.outgoing_edges.get(current, []): + neighbor = edge.to_node + in_degree[neighbor] -= 1 + if in_degree[neighbor] == 0: + queue.append(neighbor) + + # Wenn nicht alle Knoten sortiert wurden: Zyklus (sollte nicht passieren) + if len(sorted_order) != len(self.graph.nodes): + raise HTTPException( + 500, + "Topologische Sortierung fehlgeschlagen (Zyklus?). " + "Dies sollte durch _validate_graph verhindert worden sein." + ) + + return sorted_order + + def get_execution_order(self) -> List[List[str]]: + """ + Berechne Ausführungs-Reihenfolge als Ebenen (für parallele Execution). + + Knoten auf derselben Ebene können parallel ausgeführt werden. + + Returns: + Liste von Ebenen, jede Ebene ist eine Liste von Knoten-IDs: + [ + ["node_start"], + ["node_1", "node_2"], # können parallel laufen + ["node_3"], + ["node_end"] + ] + """ + # Berechne Level für jeden Knoten (längster Pfad vom START) + levels: Dict[str, int] = {} + + for node_id in self.topological_order: + # Finde maximales Level aller Vorgänger + incoming = self.incoming_edges.get(node_id, []) + if not incoming: + # Knoten ohne Vorgänger (START) + levels[node_id] = 0 + else: + max_parent_level = max(levels[edge.from_node] for edge in incoming) + levels[node_id] = max_parent_level + 1 + + # Gruppiere Knoten nach Level + max_level = max(levels.values()) if levels else 0 + execution_order = [[] for _ in range(max_level + 1)] + + for node_id, level in levels.items(): + execution_order[level].append(node_id) + + return execution_order + + async def execute( + self, + variables: Dict[str, Any], + openrouter_call_func, + profile_id: str, + enable_debug: bool = False + ) -> Dict[str, Any]: + """ + Führe Workflow aus. + + Phase 0: Stub-Implementierung + Phase 1-3: Vollständige Implementierung mit: + - Fragenergänzung + - Normalisierung + - Logik-Auswertung + - Pfad-Routing + - Join-Konsolidierung + + Args: + variables: Dict of variables for placeholder replacement + openrouter_call_func: Async function(prompt_text) -> response_text + profile_id: User profile ID + enable_debug: If True, include debug information + + Returns: + Dict with execution results + + Raises: + HTTPException: 501 Not Implemented (Phase 0) + """ + raise HTTPException( + status_code=501, + detail="Workflow-Execution noch nicht implementiert (Phase 0: Foundation). " + "Vollständige Implementierung erfolgt in Phase 1-3." + ) + + +def parse_workflow_graph(graph_jsonb: Dict) -> WorkflowGraph: + """ + Parse JSONB-Graph aus Datenbank zu Pydantic WorkflowGraph-Modell. + + Args: + graph_jsonb: JSONB dict aus workflow_definitions.graph + Unterstützt beide Edge-Formate: + - Backend: {"from": "...", "to": "..."} + - Frontend: {"source": "...", "target": "..."} + + Returns: + Validiertes WorkflowGraph-Objekt + + Raises: + ValidationError: Bei ungültigem Graph-Format + """ + # Normalize edges: convert React Flow format (source/target) to backend format (from/to) + if "edges" in graph_jsonb: + normalized_edges = [] + for edge in graph_jsonb["edges"]: + normalized_edge = edge.copy() + # Convert source → from, target → to (if present) + if "source" in normalized_edge and "from" not in normalized_edge: + normalized_edge["from"] = normalized_edge.pop("source") + if "target" in normalized_edge and "to" not in normalized_edge: + normalized_edge["to"] = normalized_edge.pop("target") + normalized_edges.append(normalized_edge) + graph_jsonb = {**graph_jsonb, "edges": normalized_edges} + + return WorkflowGraph(**graph_jsonb) + + +def validate_workflow_graph(graph: WorkflowGraph) -> Tuple[bool, List[str]]: + """ + Validiere Workflow-Graph (ohne Engine zu initialisieren). + + Kann für UI-Validierung verwendet werden (vor dem Speichern). + + Args: + graph: Workflow-Graph + + Returns: + Tuple (is_valid, errors) + - is_valid: True wenn Graph gültig + - errors: Liste von Fehler-Strings (leer wenn valid) + """ + try: + engine = WorkflowEngine(graph) + return True, [] + except HTTPException as e: + # Extrahiere Fehler aus HTTPException detail + detail = e.detail + if isinstance(detail, dict): + errors = detail.get('details', [detail.get('error', str(e))]) + else: + errors = [str(detail)] + return False, errors + except Exception as e: + return False, [f"Unerwarteter Fehler: {str(e)}"] + + +def get_execution_order(graph: WorkflowGraph) -> List[str]: + """ + Berechne sequenzielle Ausführungs-Reihenfolge (Phase 2). + + Phase 2: Sequenziell (flattened topological sort). + Phase 7: Parallele Execution (levels statt flat list). + + Args: + graph: Workflow-Graph + + Returns: + Liste von Knoten-IDs in Ausführungsreihenfolge + Beispiel: ["start", "node_1", "node_2", "end"] + + Raises: + HTTPException: Bei ungültigem Graph + + Beispiel: + >>> from workflow_models import WorkflowGraph, WorkflowNode, WorkflowEdge + >>> graph = WorkflowGraph( + ... nodes=[ + ... WorkflowNode(id="start", type="start"), + ... WorkflowNode(id="end", type="end") + ... ], + ... edges=[WorkflowEdge(id="e1", from_node="start", to_node="end")] + ... ) + >>> get_execution_order(graph) + ['start', 'end'] + """ + engine = WorkflowEngine(graph) + # Nutze topological_order (flattened) + return engine.topological_order diff --git a/backend/workflow_executor.py b/backend/workflow_executor.py new file mode 100644 index 0000000..05a6367 --- /dev/null +++ b/backend/workflow_executor.py @@ -0,0 +1,900 @@ +""" +Workflow Executor (Phase 4) + +Führt Workflows mit conditional branching und path consolidation aus. + +Phase 2: Sequential execution +Phase 3: Conditional branching, Logic Nodes, Fallback strategies +Phase 4: Join Nodes, Path consolidation + +Konzept-Basis: konzept_workflow_engine_konsolidated.md +Anforderungsanalyse: anforderungsanalyse_umsetzungsplan.md (Phase 2-4) +""" +from typing import Dict, Any, List, Optional, Set +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, + EndNodeOutputMode +) +from workflow_engine import parse_workflow_graph, get_execution_order +from question_augmenter import ( + augment_prompt_with_questions, + parse_question_augmentations_from_jsonb +) +from result_container_parser import parse_result_container +from normalization_engine import normalize_all_signals, load_question_catalog +from logic_evaluator import evaluate_logic_expression, resolve_signal_reference +from join_evaluator import evaluate_join_node as evaluate_join_node_core +from db import get_db, get_cursor + +logger = logging.getLogger(__name__) + + +async def execute_workflow( + workflow_id: Optional[str] = None, + graph_data: Optional[Dict] = None, # Phase 5: Direkt von ai_prompts.graph_data + profile_id: str = None, + variables: Dict[str, Any] = None, + openrouter_call_func = None, # Callback für LLM-Calls: async (prompt, model) -> str + enable_debug: bool = False +) -> ExecutionResult: + """ + Führt einen Workflow aus (mit conditional branching und path consolidation). + + Phase 2: Linear execution in topological order. + Phase 3: Conditional branching basierend auf logic nodes. + Phase 4: Join nodes und path consolidation. + Phase 5: Unterstützt graph_data direkt (aus ai_prompts, nicht workflow_definitions) + + Args: + workflow_id: UUID des Workflows (legacy, für workflow_definitions Tabelle) + graph_data: Workflow-Graph als Dict (NEU, für ai_prompts.graph_data) + profile_id: UUID des Profils + variables: Platzhalter-Werte (z.B. {"name": "Lars", ...}) + openrouter_call_func: async (prompt, model) -> str + enable_debug: Debug-Modus + + Returns: + ExecutionResult mit allen node_states + + Beispiel (Phase 5): + >>> result = await execute_workflow( + ... graph_data={"nodes": [...], "edges": [...]}, + ... profile_id="test-profile", + ... variables={"name": "Lars"}, + ... openrouter_call_func=my_llm_func + ... ) + """ + execution_id = str(uuid.uuid4()) + started_at = datetime.utcnow().isoformat() + + logger.info(f"Starting workflow execution: {execution_id}") + + try: + # 1. Lade Workflow-Definition + if graph_data: + # Phase 5: Graph direkt aus ai_prompts.graph_data (already a dict) + graph_dict = graph_data + logger.debug(f"Using provided graph_data") + elif workflow_id: + # Phase 0-4: Graph aus workflow_definitions Tabelle (legacy) + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + "SELECT graph FROM workflow_definitions WHERE id = %s AND active = true", + (workflow_id,) + ) + row = cur.fetchone() + if not row: + raise ValueError(f"Workflow not found: {workflow_id}") + + graph_dict = row['graph'] # PostgreSQL JSONB returns dict + logger.debug(f"Loaded graph from workflow_definitions: {workflow_id}") + else: + raise ValueError("Entweder workflow_id oder graph_data muss übergeben werden") + + # 2. Parse Graph + graph = parse_workflow_graph(graph_dict) + logger.debug(f"Parsed graph: {len(graph.nodes)} nodes, {len(graph.edges)} edges") + + # 4. Lade Question Catalog + with get_db() as conn: + catalog = load_question_catalog(conn) + logger.debug(f"Loaded catalog: {len(catalog)} question types") + + # 5. Execute Nodes mit conditional branching (Phase 3) + node_states: List[NodeExecutionState] = [] + context = { + "variables": variables, + "profile_id": profile_id, + "node_results": {}, # Phase 3: Store full NodeExecutionState + "active_edges": {} # Phase 3: Track edge activation + } + + # Phase 3: BFS traversal mit edge activation + start_node = next((n for n in graph.nodes if n.type == "start"), None) + if not start_node: + raise ValueError("No start node found in workflow") + + visited: Set[str] = set() + queue = [start_node.id] + + while queue: + node_id = queue.pop(0) + + if node_id in visited: + continue + + visited.add(node_id) + node = next(n for n in graph.nodes if n.id == node_id) + + # Prüfe ob Node aktiv ist (mindestens eine incoming edge aktiv) + if not _has_active_incoming_edge(node, graph, context): + logger.info(f"Skipping node {node_id}: no active incoming edges") + node_states.append(NodeExecutionState( + node_id=node_id, + status=NodeStatus.SKIPPED, + error="no_active_incoming_edges", + started_at=datetime.utcnow().isoformat(), + completed_at=datetime.utcnow().isoformat() + )) + continue + + logger.info(f"Executing node: {node_id} (type: {node.type})") + + node_state = await execute_node( + node=node, + context=context, + catalog=catalog, + graph=graph, # Phase 3: Needed for logic nodes + openrouter_call_func=openrouter_call_func, + enable_debug=enable_debug + ) + + node_states.append(node_state) + context["node_results"][node_id] = node_state + + # Füge Nachfolger zur Queue hinzu + outgoing_edges = [e for e in graph.edges if e.from_node == node_id] + for edge in outgoing_edges: + # Prüfe ob Edge aktiv ist (default: True, Logic Nodes setzen auf False) + if context["active_edges"].get(edge.id, True): + queue.append(edge.to_node) + + # 6. Aggregiere Ergebnisse + aggregated = aggregate_results(node_states) + + # 7. Speichere Execution State + completed_at = datetime.utcnow().isoformat() + save_execution_state( + execution_id=execution_id, + workflow_id=workflow_id, + profile_id=profile_id, + node_states=node_states, + status="completed", + started_at=started_at, + completed_at=completed_at + ) + + logger.info(f"Workflow execution completed: {execution_id}") + + return ExecutionResult( + execution_id=execution_id, + workflow_id=workflow_id or "N/A", # Placeholder when graph_data is used directly + status="completed", + node_states=node_states, + aggregated_result=aggregated, + started_at=started_at, + completed_at=completed_at + ) + + except Exception as e: + logger.error(f"Workflow execution failed: {e}", exc_info=True) + + # Speichere Failed State + completed_at = datetime.utcnow().isoformat() + save_execution_state( + execution_id=execution_id, + workflow_id=workflow_id, # Can be None when graph_data is used + profile_id=profile_id, + node_states=node_states if 'node_states' in locals() else [], + status="failed", + started_at=started_at, + completed_at=completed_at, + error=str(e) + ) + + return ExecutionResult( + execution_id=execution_id, + workflow_id=workflow_id or "N/A", # ExecutionResult requires string, use placeholder + status="failed", + node_states=node_states if 'node_states' in locals() else [], + aggregated_result={}, + started_at=started_at, + completed_at=completed_at, + error=str(e) + ) + + +async def execute_node( + node, + context: Dict[str, Any], + catalog: Dict[str, Dict], + graph: WorkflowGraph, # Phase 3: Needed for logic nodes + openrouter_call_func, + enable_debug: bool = False +) -> NodeExecutionState: + """ + Führt einen einzelnen Knoten aus. + + Args: + node: WorkflowNode (aus graph.nodes) + context: Execution context (variables, profile_id, node_results, active_edges) + catalog: Question catalog + graph: WorkflowGraph (für Logic Nodes) + openrouter_call_func: LLM callback: async (prompt, model) -> str + enable_debug: Debug mode + + Returns: + NodeExecutionState + + Node Types: + - start/end: No-op + - analysis: Load prompt → augment → LLM → parse → normalize + - logic: Evaluate condition → activate/deactivate edges (Phase 3) + - join: Consolidate paths → merge results (Phase 4) + """ + started_at = datetime.utcnow().isoformat() + + try: + # 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, + started_at=started_at, + 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) + + # Join Nodes (Phase 4) + if node.type == "join": + return execute_join_node(node, context, graph) + + # Analysis Nodes + if node.type == "analysis": + # 1. Lade Prompt + prompt_template = await load_prompt_template(node.prompt_slug, context) + logger.debug(f"Node {node.id}: Loaded prompt '{node.prompt_slug}'") + + # 2. Parse question_augmentations + questions = [] + if node.question_augmentations: + # Convert list of dicts to JSONB-like format for parser + questions_jsonb = [q.model_dump() if hasattr(q, 'model_dump') else q for q in node.question_augmentations] + questions = parse_question_augmentations_from_jsonb(questions_jsonb) + logger.debug(f"Node {node.id}: {len(questions)} question augmentations") + + # 3. Augment Prompt + if questions: + augmented_prompt = augment_prompt_with_questions( + base_prompt=prompt_template, + questions=questions + ) + else: + augmented_prompt = prompt_template + + # 4. LLM Call + logger.debug(f"Node {node.id}: Calling LLM") + llm_response = await openrouter_call_func( + augmented_prompt, + "anthropic/claude-sonnet-4" # Default model + ) + + # 5. Parse Result Container + parsed = parse_result_container(llm_response) + logger.debug(f"Node {node.id}: Parsed response (status: {parsed['parsing_status']})") + + # 6. Normalize Signals + normalized_signals = [] + if parsed["decision_signals"]: + # Hybrid Model: Node-spezifische Questions überschreiben Catalog + node_catalog = catalog.copy() + if node.question_augmentations: + for q in node.question_augmentations: + q_dict = q.model_dump() if hasattr(q, 'model_dump') else q + node_catalog[q_dict['type']] = { + "answer_spectrum": q_dict['answer_spectrum'], + "normalization_rules": None # Node-Questions haben keine Synonyme + } + logger.debug(f"Node {node.id}: Override catalog for '{q_dict['type']}' with node-specific spectrum") + + normalized_signals = normalize_all_signals( + decision_signals=parsed["decision_signals"], + catalog_dict=node_catalog + ) + logger.info(f"Node {node.id}: Normalized {len(normalized_signals)} signals") + + return NodeExecutionState( + node_id=node.id, + status=NodeStatus.EXECUTED, + analysis_core=parsed["analysis_core"], + decision_signals=parsed["decision_signals"], + normalized_signals=normalized_signals, + reasoning_anchors=parsed.get("reasoning_anchors"), + started_at=started_at, + completed_at=datetime.utcnow().isoformat() + ) + + # Unbekannter Node-Typ (Phase 4: join) + raise ValueError(f"Node type '{node.type}' not implemented yet (Phase 4+)") + + except Exception as e: + logger.error(f"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 execute_logic_node( + node, + context: Dict[str, Any], + graph: WorkflowGraph +) -> NodeExecutionState: + """ + Führt Logic Node aus (Phase 3). + + Args: + node: WorkflowNode vom Typ "logic" + context: Execution context mit node_results + graph: WorkflowGraph + + Returns: + NodeExecutionState mit evaluation_result in metadata + + Logic: + 1. Evaluiere node.condition.expression + 2. Wähle then_path oder else_path basierend auf Ergebnis + 3. Bei Fehler/Unsicherheit: Wende fallback an + 4. Markiere gewählte Edge(s) als aktiv, andere als inaktiv + 5. Return NodeExecutionState mit evaluation_result + """ + started_at = datetime.utcnow().isoformat() + + try: + if not node.condition: + raise ValueError(f"Logic node {node.id} has no condition") + + # 1. Evaluiere Bedingung + result, error = evaluate_logic_expression(node.condition.expression, context) + + if error: + # Fehler bei Evaluation → Fallback anwenden + logger.warning(f"Logic node {node.id}: evaluation error: {error}") + activated_edges = _apply_fallback(node, graph, context, error) + else: + # Erfolgreiche Evaluation + logger.info(f"Logic node {node.id}: evaluated to {result}") + + # 2. Wähle Pfad basierend auf Ergebnis + if result: + # Condition TRUE → then_path aktivieren + activated_edges = _get_edges_by_label(node.id, "then", graph) + else: + # Condition FALSE → else_path aktivieren + activated_edges = _get_edges_by_label(node.id, "else", graph) + + # 3. Markiere Edges als aktiv/inaktiv + outgoing_edges = [e for e in graph.edges if e.from_node == node.id] + for edge in outgoing_edges: + context["active_edges"][edge.id] = edge.id in activated_edges + + logger.debug(f"Logic node {node.id}: activated edges: {activated_edges}") + + return NodeExecutionState( + node_id=node.id, + status=NodeStatus.EXECUTED, + started_at=started_at, + completed_at=datetime.utcnow().isoformat(), + analysis_core=json.dumps({ + "evaluation_result": result if not error else None, + "error": error, + "activated_edges": activated_edges + }) + ) + + except Exception as e: + logger.error(f"Logic 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 execute_join_node( + node, + context: Dict[str, Any], + graph: WorkflowGraph +) -> NodeExecutionState: + """ + Führt Join Node aus (Phase 4). + + Args: + node: WorkflowNode vom Typ "join" + context: Execution context mit node_results + graph: WorkflowGraph + + Returns: + NodeExecutionState mit konsolidierten Daten + + Logic: + 1. Evaluiere Join-Strategie (via join_evaluator) + 2. Prüfe ob ready (alle erforderlichen Pfade verfügbar?) + 3. Konsolidiere Ergebnisse (merge analysis_core, combine signals) + 4. Return NodeExecutionState mit konsolidierten Daten + + Error Handling: + - wait_all + fehlende Pfade → FAILED + - wait_any + keine Pfade → FAILED + - best_effort + keine Pfade → EXECUTED (mit metadata) + """ + started_at = datetime.utcnow().isoformat() + + try: + logger.info(f"Executing join node: {node.id}") + + # 1. Evaluiere Join-Node + join_result = evaluate_join_node_core(node, graph, context) + + # 2. Prüfe ob ready + if not join_result.ready: + # Strategie nicht erfüllt → FAILED + logger.warning(f"Join node {node.id}: Not ready - {join_result.error}") + return NodeExecutionState( + node_id=node.id, + status=NodeStatus.FAILED, + error=join_result.error, + started_at=started_at, + completed_at=datetime.utcnow().isoformat(), + metadata=join_result.metadata + ) + + # 3. Baue konsolidierten analysis_core + # Format: JSON mit node_id → analysis_core mapping + consolidated_core_json = json.dumps( + join_result.consolidated_analysis_core, + ensure_ascii=False, + indent=2 + ) + + # 4. Log Konsolidierung + executed_count = join_result.metadata.get("executed_paths", 0) + total_count = join_result.metadata.get("total_paths", 0) + logger.info( + f"Join node {node.id}: Consolidated {executed_count}/{total_count} paths" + ) + + # 5. Convert consolidated_signals Dict → List[NormalizedSignal] + # (NodeExecutionState expects List, but join_evaluator returns Dict) + consolidated_signals_list = list(join_result.consolidated_signals.values()) + + # 6. Return NodeExecutionState + return NodeExecutionState( + node_id=node.id, + status=NodeStatus.EXECUTED, + analysis_core=consolidated_core_json, + normalized_signals=consolidated_signals_list, + metadata=join_result.metadata, + started_at=started_at, + completed_at=datetime.utcnow().isoformat() + ) + + except Exception as e: + logger.error(f"Join 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 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, + context: Dict[str, Any], + error: str +) -> List[str]: + """ + Wendet Fallback-Strategie an. + + Args: + node: WorkflowNode + graph: WorkflowGraph + context: Execution context + error: Fehler bei Evaluation + + Returns: + Liste von Edge-IDs die aktiviert werden sollen + """ + if not node.fallback: + # Default: Konservativ (else-Pfad nehmen) + logger.warning(f"Node {node.id}: No fallback configured, using DEFAULT_PATH") + return _get_edges_by_label(node.id, "else", graph) + + strategy = node.fallback.strategy + + if strategy == FallbackStrategy.CONSERVATIVE_SKIP: + # Skip alle outgoing edges + logger.info(f"Node {node.id}: CONSERVATIVE_SKIP - deactivating all paths") + return [] + + elif strategy == FallbackStrategy.DEFAULT_PATH: + # Nimm else-Pfad + logger.info(f"Node {node.id}: DEFAULT_PATH - taking else path") + return _get_edges_by_label(node.id, "else", graph) + + elif strategy == FallbackStrategy.UNCERTAINTY_PATH: + # Expliziter Unsicherheits-Pfad (falls vorhanden) + logger.info(f"Node {node.id}: UNCERTAINTY_PATH") + uncertainty_edges = _get_edges_by_label(node.id, "uncertainty", graph) + if uncertainty_edges: + return uncertainty_edges + else: + # Fallback to else + logger.warning(f"Node {node.id}: No uncertainty path found, using else") + return _get_edges_by_label(node.id, "else", graph) + + elif strategy == FallbackStrategy.DOCUMENT_ONLY: + # Alle Pfade aktiv lassen (wie ohne Bedingung) + logger.info(f"Node {node.id}: DOCUMENT_ONLY - all paths remain active") + outgoing_edges = [e for e in graph.edges if e.from_node == node.id] + return [e.id for e in outgoing_edges] + + else: + logger.warning(f"Node {node.id}: Unknown fallback strategy {strategy}, using DEFAULT_PATH") + return _get_edges_by_label(node.id, "else", graph) + + +def _get_edges_by_label(node_id: str, label: str, graph: WorkflowGraph) -> List[str]: + """ + Findet alle ausgehenden Edges mit bestimmtem Label. + + Args: + node_id: Node-ID + label: Edge-Label (z.B. "then", "else", "uncertainty") + graph: WorkflowGraph + + Returns: + Liste von Edge-IDs + """ + matching_edges = [ + e.id for e in graph.edges + if e.from_node == node_id and e.label == label + ] + return matching_edges + + +def _has_active_incoming_edge(node, graph: WorkflowGraph, context: Dict[str, Any]) -> bool: + """ + Prüft ob Node mindestens eine aktive incoming edge hat. + + Args: + node: WorkflowNode + graph: WorkflowGraph + context: Execution context mit active_edges + + Returns: + True wenn mindestens eine incoming edge aktiv ist + """ + # Start-Node hat keine incoming edges → immer aktiv + if node.type == "start": + return True + + incoming_edges = [e for e in graph.edges if e.to_node == node.id] + + # Keine incoming edges → nicht erreichbar + if not incoming_edges: + return False + + # Prüfe ob mindestens eine aktiv ist + active_edges = context.get("active_edges", {}) + for edge in incoming_edges: + if active_edges.get(edge.id, True): # Default: True (Phase 2 Kompatibilität) + return True + + return False + + +async def load_prompt_template(prompt_slug: str, context: Dict[str, Any]) -> str: + """ + Lädt Prompt-Template aus DB und resolved Platzhalter. + + Args: + prompt_slug: Slug des Prompts (z.B. "pipeline_body") + context: {"variables": {"name": "Lars", ...}, "profile_id": "..."} + + Returns: + Resolved prompt template + + Beispiel: + >>> template = await load_prompt_template("pipeline_body", {"profile_id": "123"}) + >>> "{{name}}" not in template + True + """ + from placeholder_resolver import resolve_placeholders + + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + "SELECT template FROM ai_prompts WHERE slug = %s AND active = true", + (prompt_slug,) + ) + row = cur.fetchone() + if not row: + raise ValueError(f"Prompt not found: {prompt_slug}") + + template = row['template'] + + # Resolve Placeholders + profile_id = context.get("profile_id") + resolved = resolve_placeholders( + template=template, + profile_id=profile_id + ) + # TODO Phase 3: Support custom variables from workflow context + + return resolved + + +def aggregate_results(node_states: List[NodeExecutionState]) -> Dict[str, Any]: + """ + Aggregiert Ergebnisse aller Knoten. + + Args: + node_states: Liste aller NodeExecutionState + + Returns: + { + "combined_analysis": "## node_1\n...\n\n## node_2\n...", + "all_signals": [{question_type, normalized_value, status}, ...], + "total_nodes": 3, + "executed_nodes": 3, + "failed_nodes": 0 + } + + Beispiel: + >>> states = [ + ... NodeExecutionState(node_id="n1", status=NodeStatus.EXECUTED, analysis_core="Test 1"), + ... NodeExecutionState(node_id="n2", status=NodeStatus.EXECUTED, analysis_core="Test 2") + ... ] + >>> result = aggregate_results(states) + >>> "## n1" in result["combined_analysis"] + True + >>> result["executed_nodes"] + 2 + """ + combined_analysis = [] + all_signals = [] + + for state in node_states: + if state.status == NodeStatus.EXECUTED and state.analysis_core: + combined_analysis.append(f"## {state.node_id}\n{state.analysis_core}") + + if state.normalized_signals: + all_signals.extend([s.model_dump() for s in state.normalized_signals]) + + return { + "combined_analysis": "\n\n".join(combined_analysis), + "all_signals": all_signals, + "total_nodes": len(node_states), + "executed_nodes": sum(1 for s in node_states if s.status == NodeStatus.EXECUTED), + "failed_nodes": sum(1 for s in node_states if s.status == NodeStatus.FAILED), + "skipped_nodes": sum(1 for s in node_states if s.status == NodeStatus.SKIPPED) # Phase 3 + } + + +def save_execution_state( + execution_id: str, + workflow_id: Optional[str], # None when using graph_data directly (Phase 5) + profile_id: str, + node_states: List[NodeExecutionState], + status: str, + started_at: str, + completed_at: Optional[str] = None, + error: Optional[str] = None +): + """ + Speichert Execution State in workflow_executions. + + Args: + execution_id: UUID der Execution + workflow_id: UUID des Workflows (None wenn graph_data direkt verwendet wird) + profile_id: UUID des Profils + node_states: Liste aller NodeExecutionState + status: 'completed' | 'failed' | 'partial' + started_at: ISO timestamp + completed_at: ISO timestamp (optional) + error: Fehlermeldung (optional) + + Beispiel: + >>> save_execution_state( + ... execution_id="exec-123", + ... workflow_id="wf-456", + ... profile_id="prof-789", + ... node_states=[], + ... status="completed", + ... started_at="2026-04-03T12:00:00" + ... ) + """ + # Serialize node_states to JSON + node_states_json = [s.model_dump() for s in node_states] + + with get_db() as conn: + cur = get_cursor(conn) + cur.execute(""" + INSERT INTO workflow_executions + (id, workflow_id, profile_id, status, node_states, execution_log, started_at, completed_at) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s) + ON CONFLICT (id) DO UPDATE SET + status = EXCLUDED.status, + node_states = EXCLUDED.node_states, + execution_log = EXCLUDED.execution_log, + completed_at = EXCLUDED.completed_at + """, ( + execution_id, + workflow_id, + profile_id, + status, + json.dumps(node_states_json), + json.dumps({"error": error} if error else {}), + started_at, + completed_at + )) + conn.commit() + + logger.info(f"Saved execution state: {execution_id} (status: {status})") diff --git a/backend/workflow_models.py b/backend/workflow_models.py new file mode 100644 index 0000000..12134c9 --- /dev/null +++ b/backend/workflow_models.py @@ -0,0 +1,358 @@ +""" +Pydantic Models for Workflow Engine (Phase 0) + +Data validation schemas for Workflow-Graph, Knoten, Kanten, Bedingungen. + +Konzept-Basis: konzept_workflow_engine_konsolidated.md +Anforderungsanalyse: anforderungsanalyse_umsetzungsplan.md +""" +from typing import Optional, List, Dict, Any +from pydantic import BaseModel, Field +from enum import Enum + + +# ── Enums ───────────────────────────────────────────────────────────────────── + +class NodeType(str, Enum): + """Workflow-Knotentypen""" + START = "start" + ANALYSIS = "analysis" + LOGIC = "logic" + JOIN = "join" + END = "end" + + +class JoinStrategy(str, Enum): + """Join-Strategien für Pfad-Konsolidierung""" + WAIT_ALL = "wait_all" # Warte auf alle eingehenden Pfade + WAIT_ANY = "wait_any" # Warte auf mindestens einen Pfad + BEST_EFFORT = "best_effort" # Verwende verfügbare Pfade + + +class SkipHandling(str, Enum): + """Umgang mit übersprungenen Pfaden am Join""" + IGNORE_SKIPPED = "ignore_skipped" # Übersprungene Pfade ignorieren + USE_PLACEHOLDER = "use_placeholder" # Platzhalter für übersprungene Pfade + REQUIRE_MINIMUM = "require_minimum" # Mindestanzahl erforderlich + + +class FallbackStrategy(str, Enum): + """Fallback-Strategien bei unklaren/ungültigen Signalen""" + CONSERVATIVE_SKIP = "conservative_skip" # Konservativ: Pfad überspringen + DEFAULT_PATH = "default_path" # Standard-Pfad ausführen + UNCERTAINTY_PATH = "uncertainty_path" # Expliziter Unsicherheits-Pfad + DOCUMENT_ONLY = "document_only" # Nur dokumentieren, kein Routing + + +class NodeStatus(str, Enum): + """Ausführungsstatus eines Knotens""" + PENDING = "pending" + EXECUTING = "executing" # Phase 2: Gerade in Ausführung + EXECUTED = "executed" + SKIPPED = "skipped" + UNCLEAR = "unclear" + FAILED = "failed" + + +class SignalStatus(str, Enum): + """Status nach Normalisierung (Phase 2)""" + VALID = "valid" # Exakte Übereinstimmung mit Spektrum + NORMALIZED = "normalized" # Gemappt (Synonym/Case-insensitive) + UNCLEAR = "unclear" # Mehrdeutig oder widersprüchlich + INVALID = "invalid" # Außerhalb des Spektrums + NOT_DECIDABLE = "not_decidable" # Kein Signal vorhanden + + +class LogicOperator(str, Enum): + """Logische Operatoren für Bedingungen""" + EQ = "eq" # == + NEQ = "neq" # != + IN = "in" # in + NOT_IN = "not_in" # not in + GT = "gt" # > + LT = "lt" # < + GTE = "gte" # >= + LTE = "lte" # <= + CONTAINS = "contains" # String/List contains (Phase 3) + AND = "and" + OR = "or" + 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): + """Position eines Knotens im visuellen Editor""" + x: float + y: float + + +class QuestionAugmentation(BaseModel): + """ + Fragenergänzung zu einem Analyseprompt. + + Hybridmodell (Sektion 6 der Anforderungsanalyse): + - Primär: Knotengebunden (am Workflow-Knoten definiert) + - Sekundär: Prompt-gebundene Standardfragen (optional) + - Vorrangregel: Knotenspezifische überschreiben Prompt-Defaults + """ + id: str = Field(..., description="Eindeutige ID der Frage (für Referenzierung in Logik-Knoten)") + type: str = Field(..., description="Fragetyp: relevanz, prioritaet, selektion, ausschluss, eskalation, unsicherheit") + question: str = Field(..., description="Fragetext (kann Template-Variablen enthalten)") + answer_spectrum: List[str] = Field(..., description="Erlaubte Antworten, z.B. ['ja', 'nein', 'unklar']") + + +# ── Bedingungsmodelle ───────────────────────────────────────────────────────── + +class LogicOperand(BaseModel): + """ + Operand einer Logik-Bedingung. + + Referenziert normalisierte Signale aus vorangegangenen Knoten. + Format: "node_id.question_id" (z.B. "node_1.q1") + """ + ref: str = Field(..., description="Referenz zum Signal: node_id.question_id") + operator: LogicOperator = Field(..., description="Vergleichsoperator") + value: Any = Field(..., description="Vergleichswert") + + +class LogicExpression(BaseModel): + """ + Logik-Ausdruck (verschachtelbar). + + Beispiel: + { + "operator": "and", + "operands": [ + {"ref": "node_1.q1", "operator": "eq", "value": "ja"}, + {"ref": "node_1.q2", "operator": "in", "value": ["hoch", "mittel"]} + ] + } + + oder verschachtelt: + { + "operator": "or", + "operands": [ + { + "operator": "and", + "operands": [...] + }, + {"ref": "node_2.q1", "operator": "eq", "value": "ja"} + ] + } + """ + operator: LogicOperator = Field(..., description="Logischer Operator (and, or, not) oder Vergleichsoperator") + operands: Optional[List[Any]] = Field(None, description="Liste von Operanden (LogicOperand oder verschachtelte LogicExpression)") + # Bei einfachem Vergleich: + ref: Optional[str] = Field(None, description="Signal-Referenz (nur bei Vergleichsoperatoren)") + value: Optional[Any] = Field(None, description="Vergleichswert (nur bei Vergleichsoperatoren)") + + +class Condition(BaseModel): + """ + Bedingung für einen Logik-Knoten. + + Unterstützt if/else-if/else-Logik. + """ + type: str = Field(default="if", description="Bedingungstyp: if, else-if, else") + expression: Optional[LogicExpression] = Field(None, description="Logischer Ausdruck (null bei 'else')") + then_path: Optional[str] = Field(None, description="Edge-ID für 'then'-Pfad") + else_path: Optional[str] = Field(None, description="Edge-ID für 'else'-Pfad") + + +class FallbackConfig(BaseModel): + """Fallback-Konfiguration für Logik-Knoten""" + strategy: FallbackStrategy = Field(..., description="Fallback-Strategie") + on_unclear: Optional[str] = Field(None, description="Edge-ID für Unsicherheits-Pfad") + on_invalid: Optional[str] = Field(None, description="Edge-ID bei ungültigen Signalen") + + +# ── Workflow-Graph-Modelle ──────────────────────────────────────────────────── + +class WorkflowNode(BaseModel): + """ + Workflow-Knoten (Teil des Graph-JSONB). + + Verschiedene Typen haben unterschiedliche Felder: + - START/END: nur id, type, position + - ANALYSIS: prompt_slug, question_augmentations + - LOGIC: condition, fallback + - JOIN: join_strategy, skip_handling + """ + id: str = Field(..., description="Eindeutige Knoten-ID") + type: NodeType = Field(..., description="Knotentyp") + position: Optional[Position] = Field(None, description="Position im visuellen Editor") + + # ANALYSIS-Knoten + prompt_slug: Optional[str] = Field(None, description="Slug des auszuführenden Prompts") + question_augmentations: Optional[List[QuestionAugmentation]] = Field(None, description="Fragenergänzungen (knotengebunden, überschreiben Prompt-Defaults)") + + # LOGIC-Knoten + condition: Optional[Condition] = Field(None, description="Bedingung für Pfad-Routing") + fallback: Optional[FallbackConfig] = Field(None, description="Fallback-Konfiguration") + + # 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): + """ + Workflow-Kante (Verbindung zwischen Knoten). + """ + model_config = {"populate_by_name": True} # Erlaubt sowohl 'from_node' als auch 'from' (Alias) + + id: str = Field(..., description="Eindeutige Edge-ID") + from_node: str = Field(..., alias="from", description="Quell-Knoten-ID") + to_node: str = Field(..., alias="to", description="Ziel-Knoten-ID") + label: Optional[str] = Field(None, description="Label für visuelle Darstellung (z.B. 'then', 'else')") + + +class WorkflowGraph(BaseModel): + """ + Workflow-Graph (gespeichert als JSONB in workflow_definitions.graph). + + Repräsentiert einen DAG (Directed Acyclic Graph). + """ + nodes: List[WorkflowNode] = Field(..., description="Liste aller Knoten") + edges: List[WorkflowEdge] = Field(..., description="Liste aller Kanten") + + +# ── Workflow-Definition (DB-Modell) ─────────────────────────────────────────── + +class WorkflowDefinitionCreate(BaseModel): + """Request-Modell für Workflow-Erstellung""" + name: str + slug: str + description: Optional[str] = None + graph: WorkflowGraph + + +class WorkflowDefinitionUpdate(BaseModel): + """Request-Modell für Workflow-Update""" + name: Optional[str] = None + description: Optional[str] = None + graph: Optional[WorkflowGraph] = None + active: Optional[bool] = None + + +class WorkflowDefinition(BaseModel): + """Response-Modell für Workflow-Definition""" + id: str + name: str + slug: str + description: Optional[str] = None + graph: WorkflowGraph + version: int + active: bool + created_at: str + updated_at: str + + +# ── Workflow-Execution (DB-Modell) ──────────────────────────────────────────── + +class NodeState(BaseModel): + """Ausführungsstatus eines Knotens""" + status: NodeStatus + result: Optional[Dict[str, Any]] = Field(None, description="Ergebnis der Knoten-Ausführung (Prompt-Output, normalisierte Signale, etc.)") + timestamp: Optional[str] = Field(None, description="Zeitpunkt der Ausführung") + error: Optional[str] = Field(None, description="Fehlermeldung bei Status=failed") + + +class WorkflowExecutionCreate(BaseModel): + """Request-Modell für Workflow-Ausführung (Start)""" + workflow_id: str + # profile_id kommt aus Session + + +class WorkflowExecution(BaseModel): + """Response-Modell für Workflow-Execution""" + id: str + workflow_id: str + profile_id: str + status: str + node_states: Optional[Dict[str, NodeState]] = None + execution_log: Optional[List[Dict[str, Any]]] = None + started_at: str + completed_at: Optional[str] = None + + +# ── Question Catalog (DB-Modell) ────────────────────────────────────────────── + +class QuestionCatalogEntry(BaseModel): + """Eintrag im Fragenkatalog""" + id: str + question_type: str + label: str + question_template: str + answer_spectrum: List[str] + normalization_rules: Optional[Dict[str, Any]] = None + active: bool + created_at: str + + +# ── Phase 2: Normalisierung & Execution ─────────────────────────────────────── + +class NormalizedSignal(BaseModel): + """ + Normalisiertes Entscheidungssignal (Phase 2). + + Resultat der Normalisierung einer Rohantwort gegen das Antwortspektrum. + """ + question_type: str = Field(..., description="Typ der Frage (z.B. 'relevanz')") + raw_value: str = Field(..., description="Original LLM-Antwort") + normalized_value: Optional[str] = Field(None, description="Gemappter Wert (null bei invalid/not_decidable)") + status: SignalStatus = Field(..., description="Normalisierungsstatus") + confidence: float = Field(default=1.0, description="Konfidenz (für späteren Einsatz)") + metadata: Dict[str, Any] = Field(default_factory=dict, description="Zusatzinfo (z.B. method: 'synonym')") + + +class NodeExecutionState(BaseModel): + """ + Detaillierter Ausführungsstatus eines Knotens (Phase 2). + + Erweitert NodeState um Phase-1-Komponenten (analysis_core, decision_signals, etc.) + """ + node_id: str = Field(..., description="Knoten-ID") + status: NodeStatus = Field(..., description="Ausführungsstatus") + + # Phase 1 Result Container + analysis_core: Optional[str] = Field(None, description="Hauptanalyse aus ## Analyse Sektion") + decision_signals: Dict[str, str] = Field(default_factory=dict, description="Rohe Signale (pre-normalization)") + normalized_signals: List[NormalizedSignal] = Field(default_factory=list, description="Normalisierte Signale (Phase 2)") + reasoning_anchors: Optional[str] = Field(None, description="Begründungsanker aus ## Begründung") + + # Error & Timing + error: Optional[str] = Field(None, description="Fehlermeldung bei failed") + started_at: Optional[str] = Field(None, description="Start-Timestamp (ISO)") + completed_at: Optional[str] = Field(None, description="End-Timestamp (ISO)") + + +class ExecutionResult(BaseModel): + """ + Ergebnis einer Workflow-Ausführung (Phase 2). + + Wird von workflow_executor.execute_workflow() zurückgegeben. + """ + execution_id: str = Field(..., description="UUID der Execution") + workflow_id: str = Field(..., description="UUID des Workflows") + status: str = Field(..., description="Gesamt-Status: 'completed', 'failed', 'partial'") + + node_states: List[NodeExecutionState] = Field(..., description="States aller ausgeführten Knoten") + aggregated_result: Dict[str, Any] = Field(default_factory=dict, description="Aggregierte Ergebnisse (combined_analysis, all_signals, etc.)") + + started_at: str = Field(..., description="Start-Timestamp (ISO)") + completed_at: Optional[str] = Field(None, description="End-Timestamp (ISO)") + error: Optional[str] = Field(None, description="Fehlermeldung bei failed") diff --git a/docs/issues/PHASE_PLAN_RESPONSIVE_UI.md b/docs/issues/PHASE_PLAN_RESPONSIVE_UI.md new file mode 100644 index 0000000..42542f3 --- /dev/null +++ b/docs/issues/PHASE_PLAN_RESPONSIVE_UI.md @@ -0,0 +1,264 @@ +# Phasenplan: Responsive UI (Desktop Sidebar + Mobile/PWA) + +> **Gitea:** [#30 – Responsive UI](http://192.168.2.144:3000/Lars/mitai-jinkendo/issues/30) +> **Spec:** `.claude/docs/functional/RESPONSIVE_UI.md` +> **Breakpoint:** `<1024px` = Mobile (Bottom-Nav, bestehendes Verhalten), `≥1024px` = Desktop (Sidebar 220px) +> **Letzte Plan-Aktualisierung:** 2026-04-04 (P6; P7 Admin bewusst offen) + +--- + +## Fortschritt (kurz) + +| Phase | Titel | Status | Datum / Notiz | +|-------|--------|--------|----------------| +| P0 | Vorbereitung & Baseline | ☑ erledigt | Spec `RESPONSIVE_UI.md` bereinigt | +| P1 | App-Shell: Sidebar + Breakpoint + gemeinsame Navigation | ☑ erledigt | `DesktopSidebar`, `config/appNav.js`, Admin `/admin/*`-Highlight | +| P2 | Globales Layout & Content-Bereich (CSS) | ☑ erledigt | Desktop: Header aus, Content max 1200px; Mobile unverändert Bottom-Nav | +| P3 | Dashboard (Desktop-Grid) | ☑ erledigt | 4-spaltige Kennzahlen; Begrüßung; Ernährung/Aktivität 2-spaltig | +| P4 | Verlauf (Tabs links / Content rechts) | ☑ erledigt | `History.jsx` + `.history-*` in `app.css`; Tab-State bei `location.state.tab` | +| P5 | Analyse (Prompts links / Ergebnis rechts) | ☑ erledigt | `Analysis.jsx` + `.analysis-split*` in `app.css` | +| P6 | Erfassung / Capture & Formularseiten | ☑ erledigt | `.capture-page` + `--capture-content-max` (eine Desktop-Breite); CaptureShell-Navigation | +| P7 | Admin & restliche Vollbreiten-Seiten | ⏸ Konzeption | Layout nach Abstimmung; nicht mit P6 mitgezogen | +| P8 | Abschluss, Regression, Spec-Pflege | ☐ pending | | + +**Status-Legende:** `☐ pending` · `◐ in Arbeit` · `☑ erledigt` · `⏸ blockiert` + +--- + +## Testumgebung (für alle Phasen) + +| ID | Umgebung | Verwendung | +|----|-----------|------------| +| T-H1 | Chromium Desktop, Fenster **≥1280px** | Desktop-Layout, Sidebar | +| T-H2 | Chromium, DevTools **375×812** (o. ä.) | Mobile-Layout, Bottom-Nav | +| T-H3 | Chromium, Breite **1023px** vs **1024px** | Breakpoint-Grenze | +| T-H4 | **iPhone** (Safari), installierte **PWA** | Regression Mobile/PWA | +| T-H5 | optional: iPad quer (**≥1024px**) | Desktop-Sidebar auf Tablet quer | + +**Allgemein:** Kein Pflicht-E2E-Framework im Repo; Abnahme primär **manuell** nach Checklisten unten. Optional später: Playwright-Smoke (`viewport`-Wechsel). + +--- + +## Phase P0 – Vorbereitung & Baseline + +### Ziel +Risikoarm starten: Ist-Stand dokumentieren, Spec bereinigen, keine funktionale UI-Änderung. + +### Aufgaben +- [ ] Bekannte Artefakte am Ende von `.claude/docs/functional/RESPONSIVE_UI.md` entfernen (Zeilen `EOF` / `echo …`), falls noch vorhanden. +- [ ] Kurz notieren: aktuelle `app-shell`/`max-width:600px` in `app.css` ist die Mobile-Baseline (siehe Code-Review). + +### Abnahmekriterien +- Spec-Datei endet mit den „Offenen Fragen“; keine Shell-Zeilenduplikate aus Fremdkopien. +- Plan-Datei (dieses Dokument) ist im Repo und verlinkt (optional in Gitea #30 kommentieren). + +### Tests (P0) +| Test | Schritt | Erwartung | +|------|---------|-----------| +| P0-T1 | `RESPONSIVE_UI.md` öffnen | Letzte sinnvolle Zeile ist Frage 4 oder Abschnittsende; kein `echo`/`EOF` | +| P0-T2 | `npm run build` im `frontend/` | Exit 0 | + +--- + +## Phase P1 – App-Shell: Sidebar + Breakpoint + gemeinsame Navigation + +### Ziel +Desktop: feste **Sidebar 220px** links gemäß Spec; Mobile: **unverändert** Bottom-Nav. **Eine** Navigationsdefinition (Routes/Labels/Icons) für beide Darstellungen. Öffentliche Auth-Routen (`/register`, `/verify`, …) **nicht** in Sidebar/Bottom-Nav einbinden (wie heute). + +### Aufgaben (technisch orientierend) +- [ ] Nav-Items aus `App.jsx` (`Nav`-Komponente) in **eine** exportierbare Struktur (z. B. `navItems` Array + `admin`-Eintrag nur bei `role === 'admin'`), von **Desktop-Sidebar** und **Mobile `Nav`** gemeinsam genutzt. +- [ ] Neue Komponente z. B. `DesktopSidebar.jsx` (oder `AppSidebar.jsx`): Logo/Branding, gleiche Links wie Bottom-Nav, unten Nutzerbereich (Avatar, Name/Tier laut Spec – Daten aus `useProfile`/`useAuth` wo möglich). +- [ ] `AppShell`: bei `≥1024px` Sidebar sichtbar, **Bottom-Nav ausgeblendet**; bei `<1024px` **umgekehrt**. +- [ ] Aktiver Route-State: `NavLink`/`active` in Sidebar **gleiche Semantik** wie Bottom-Nav (`end` bei `/` beachten). +- [ ] **Kein** `resize`-Listener pflichtig; primär **CSS** (`@media (min-width: 1024px)`) für Sichtbarkeit; falls nötig nur für Edge-Cases. + +### Abnahmekriterien +- Unter **1024px**: UI wirkt **wie vor P1** (Bottom-Nav sichtbar, Sidebar unsichtbar); PWA-Start auf iPhone unverändert nutzbar. +- Ab **1024px**: Sidebar sichtbar, fixed links, ~220px; Bottom-Nav **nicht** sichtbar. +- Admin-Link nur für Admins in **beiden** Navs. +- Abmelden erreichbar (Spec: im Sidebar-Footer; falls Mobile weiter nur im Header: ** dokumentieren als Abweichung** oder gleich ziehen). + +### Tests (P1) +| Test | Schritt | Erwartung | +|------|---------|-----------| +| P1-T1 | T-H2: einloggen, alle 5 Haupt-Routen antippen | Bottom-Nav aktiv, kein Layout-Bruch | +| P1-T2 | T-H1: gleiche Routen | Nur Sidebar-Navigation sichtbar, korrekte Aktiv-Markierung | +| P1-T3 | T-H3: Breite 1023→1024 wechseln | Sidebar erscheint / Bottom verschwindet ohne Reload; kein „Flackern“-Loop | +| P1-T4 | T-H4: PWA | Login, Hauptflows kurz (Dashboard, Erfassung) – Bottom-Nav ok | +| P1-T5 | Admin-User T-H1 | Admin-Eintrag in Sidebar; Nicht-Admin ohne Admin | + +--- + +## Phase P2 – Globales Layout & Content-Bereich + +### Ziel +Desktop-**Content** rechts von der Sidebar: Spec **margin-left 220px**, Padding **24px 32px**, **max-width 1200px** zentriert im verbleibenden Raum. Mobile: beibehaltene Abstände (**16px**, **80px** bottom für Nav). + +### Aufgaben +- [ ] `app.css`: `.app-shell` **nicht** mehr global `max-width: 600px` für Desktop; Mobile beibehalten oder per Media Query trennen. +- [ ] `.app-main` Padding/Bottom: Mobile `calc(nav + 16px)`; Desktop **ohne** Bottom-Nav-Padding (oder nur safe-area falls nötig). +- [ ] Optional Desktop: **Top-Header** (`app-header`) redundant mit Sidebar-Branding – **vereinheitlichen** (z. B. Header auf Desktop ausblenden oder auf kompakte Toolbar reduzieren), damit keine doppelte Logo-Zeile. + +### Abnahmekriterien +- Desktop: Content nutzt **sichtbar mehr** horizontalen Raum als heute (max 1200px Inhalt, zentriert). +- Mobile: **kein** zusätzliches horizontales Scrollen der Gesamtseite durch P1/P2. +- Keine Überlappung Sidebar/Content. + +### Tests (P2) +| Test | Schritt | Erwartung | +|------|---------|-----------| +| P2-T1 | T-H1: lange Seite scrollen | Nur Main-Area scrollt; Sidebar bleibt sichtbar (fixed) | +| P2-T2 | T-H2 | Weiterhin nutzbarer unterer Rand für Bottom-Nav | +| P2-T3 | T-H1: sehr breiter Monitor | Content max ~1200px, optisch zentriert rechts von Sidebar | + +--- + +## Phase P3 – Dashboard (Desktop-Grid) + +### Ziel +Spec **§5.1**: Desktop mehrspaltige Karten (z. B. 4 Spalten für Kennzahlen-Zeile); Mobile Karten untereinander. + +### Aufgaben +- [ ] `Dashboard.jsx`: Layout in **CSS Grid** o. ä. mit `@media (min-width: 1024px)` für 4-Spalten-Zeile(n) laut Wireframe; Mobile unveränderte Reihenfolge. +- [ ] Charts/„volle Breite“-Blöcke unter den Karten laut Spec. + +### Abnahmekriterien +- Mobile Dashboard **visuell vergleichbar** zum Stand vor P3 (keine funktionalen Regressionen). +- Desktop: klar erkennbares **Grid**; keine übermäßigen Lücken bei typischer Viewport-Höhe. + +### Tests (P3) +| Test | Schritt | Erwartung | +|------|---------|-----------| +| P3-T1 | T-H2 Dashboard | Karten untereinander, lesbar | +| P3-T2 | T-H1 Dashboard | Mehrspaltigkeit wie Spec; kein horizontaler Overflow | +| P3-T3 | Daten mit/ohne Werte | Keine Layout-Crashes (leere Zustände) | + +--- + +## Phase P4 – Verlauf (`History.jsx`) + +### Ziel +Spec **§5.2**: Desktop **Tabs vertikal links**, Chart/Tabelle **rechts** (volle restliche Breite); Mobile Tabs oben wie heute. + +### Aufgaben +- [ ] Tab-Steuerung refaktorieren oder per CSS **ohne** doppelte State-Logik. +- [ ] Desktop: flex/grid `240–280px` Tab-Spalte + flex 1 Chart-Bereich (feinjustierbar). + +### Abnahmekriterien +- Alle bisherigen Verlauf-**Tabs** funktionieren (Gewicht, KF, Umfänge, … – wie im aktuellen Code). +- Mobile UX **unverändert** vom Nutzergefühl her (Tabs oben). +- Desktop: klar **zwei Spalten**; Chart nutzt rechte Fläche. + +### Tests (P4) +| Test | Schritt | Erwartung | +|------|---------|-----------| +| P4-T1 | T-H2: Tab wechseln | Wie bisher | +| P4-T2 | T-H1: jeder Tab | Linke Liste + rechter Chart-Bereich sichtbar | +| P4-T3 | Langes Tab-Label / viele Tabs | Kein Layout-Bruch (Scroll in Tab-Spalte falls nötig) | + +--- + +## Phase P5 – Analyse (`Analysis.jsx`) + +### Ziel +Spec **§5.3**: Desktop **Prompt-Liste links (~300px)**, Ergebnis **rechts**; Mobile untereinander. + +### Aufgaben +- [ ] Layout splitten; Pipeline/Prompt-Bereiche so umbauen, dass Lesbarkeit und Scroll-Verhalten stimmen. + +### Abnahmekriterien +- KI-Ausführung, Platzhalter-Tabelle, Experten-Modus: weiter bedienbar. +- Desktop: Ergebnisbereich hat **deutlich mehr** Breite als Mobile. + +### Tests (P5) +| Test | Schritt | Erwartung | +|------|---------|-----------| +| P5-T1 | T-H2: Analyse ausführen | Flow wie bisher | +| P5-T2 | T-H1: langes Ergebnis | Rechte Spalte scrollbar, linke Prompt-Liste fix oder eigen-scroll | +| P5-T3 | Kleines Fenster knapp unter 1024px | Kein „mittendrin“ kaputtes Layout | + +--- + +## Phase P6 – Erfassung / Capture & Formularseiten + +### Ziel +Spec **§5.4**: Desktop Formulare **zentriert**, **max-width ~600px** im Content-Bereich; Mobile volle Breite wie heute. + +### Aufgaben +- [ ] `CaptureHub` und relevante Seiten (`WeightScreen`, …) unter Desktop-Wrapper; wo sinnvoll **gemeinsame** Klasse `.capture-form-desktop` in `app.css`. + +### Abnahmekriterien +- Mobile: keine Verschlechterung der Eingabe. +- Desktop: Formulare nicht „volle 1200px“, sondern **lesbar begrenzt**. + +### Tests (P6) +| Test | Schritt | Erwartung | +|------|---------|-----------| +| P6-T1 | T-H2: Gewicht erfassen | Vollbreite ok | +| P6-T2 | T-H1: gleiche Seite | Schmale, zentrierte Spalte | +| P6-T3 | Capture-Hub Kacheln | Umbruch auf Desktop ordentlich | + +--- + +## Phase P7 – Admin & übrige Seiten + +### Ziel +Spec **§5.5**: Admin-Tabellen nutzen auf Desktop **mehr Breite**; Mobile weiterhin horizontales Scrollen wo nötig. + +### Aufgaben +- [ ] `Admin*Page.jsx` und ähnliche Tabellen-Seiten: unnötige `max-width`-Erbe von Mobile entfernen; **nicht** jede Admin-Seite einzeln zerstören – priorisiert häufig genutzte. + +### Abnahmekriterien +- Keine regressiven Auth-Schutz-Änderungen. +- Desktop: **mehr sichtbare Spalten** bei typischen Admin-Tabellen. + +### Tests (P7) +| Test | Schritt | Erwartung | +|------|---------|-----------| +| P7-T1 | T-H1: z. B. Admin Training Types | Tabelle nutzt Breite; horizontales Scrollen nur bei Bedarf | +| P7-T2 | T-H2: gleiche Seite | Weiter bedienbar mit Scroll | + +--- + +## Phase P8 – Abschluss, Regression, Dokumentation + +### Ziel +Release-tauglich machen; Spec und Issue aktualisieren. + +### Aufgaben +- [ ] Alle Phasen P1–P7 in Fortschrittstabelle auf ☑ setzen. +- [ ] Vollständiger **Regression-Pass** (Routenliste aus `App.jsx`). +- [ ] Gitea #30: Abschlusskommentar mit **Screenshots** (optional) + Hinweis auf diesen Plan. +- [ ] `REVIEW_OPEN_ISSUES_2026-04-04.md` oder Nachfolger: #30 auf DONE setzen, wenn aus eurer Sicht erledigt. + +### Abnahmekriterien (Gesamt gem. #30 / Spec) +- [ ] Desktop **≥1024px**: Sidebar links, Bottom-Nav aus. +- [ ] Mobile **<1024px**: Bottom-Nav unten, Sidebar aus. +- [ ] Aktive Route in beiden Navs korrekt. +- [ ] Dashboard / Verlauf / Analyse / Erfassung / Admin gemäß Spec umgesetzt. +- [ ] Resize ohne JavaScript-Zwang stabil (CSS-first). +- [ ] **PWA iPhone** weiterhin funktionsfähig (Kernflows). + +### Tests (P8 – Smoke-Checkliste) +| Route | T-H2 | T-H1 | +|-------|------|------| +| `/` | ☐ | ☐ | +| `/capture` | ☐ | ☐ | +| `/history` | ☐ | ☐ | +| `/analysis` | ☐ | ☐ | +| `/settings` | ☐ | ☐ | +| 1× Admin (falls verfügbar) | ☐ | ☐ | +| T-H4 PWA kurz | ☐ | n/a | + +--- + +## Parallelität (Canvas / Workflow) + +- Während **P1–P2** möglichst **keine** parallelenden Änderungen an `App.jsx` / globalem `app.css` ohne Absprache. +- Neue **Workflow-/Canvas-Routen**: nur innerhalb von `
`; Sidebar- und Breakpoint-**Kontrakt** einhalten (`margin-left`, keine zweite globale Spalte). + +--- + +## Planpflege + +Änderungen an Phasen, Kriterien oder Status: **dieses File** anpassen und kurz „Letzte Plan-Aktualisierung“ oben datieren. Bei größeren Scope-Änderungen Gitea #30 kommentieren. diff --git a/docs/issues/REVIEW_OPEN_ISSUES_2026-04-04.md b/docs/issues/REVIEW_OPEN_ISSUES_2026-04-04.md new file mode 100644 index 0000000..9f6e98b --- /dev/null +++ b/docs/issues/REVIEW_OPEN_ISSUES_2026-04-04.md @@ -0,0 +1,265 @@ +# Gitea offene Issues – Review-Entwurf (Stand Code 2026-04-04) + +> **Zweck:** Du kannst diese Datei **lesen, anpassen, abstreichen**. Nach Freigabe: Kommentare / Schließungen / Konsolidierungen in Gitea umsetzen (manuell oder per `scripts/gitea/gitea_api.py`). +> +> **Sortierung:** Nach **`created_at` aufsteigend** (älteste Issues zuerst). Duplikat-Issues mit gleichem Inhalt sind am Ende des Abschnitts „Duplikate“ gebündelt. +> +> **Legende `Vorschlag`:** +> - `OFFEN` – noch sinnvoll, weiterverfolgen +> - `TEILWEISE` – Teil umgesetzt, Beschreibung/AC anpassen +> - `PRÜFEN` – kurzer manueller Test nötig +> - `DUPLIKAT` – mit anderem Issue zusammenführen +> - `DONE?` – wirkt im Repo erledigt, ggf. schließen nach deiner Bestätigung + +--- + +## Kurz-Übersicht (28 eindeutige Issue-Nummern, zeitlich älteste zuerst) + +| # | Titel (gekürzt) | Vorschlag | Notiz | +|---|-----------------|-----------|--------| +| 14 | Icon Picker Trainingstypen | OFFEN | Nur Freitext-Feld `icon` | +| 15 | Quality-Filter KI & Charts | TEILWEISE | Registry/Charts Confidence, kein durchgängiges Produkt | +| 21 | Universeller CSV-Parser + Mapping | OFFEN | Modul-spezifische Parser | +| 25 | Ziele-System Goals | DONE? | GoalsPage, Router, Focus Areas | +| 26 | Charts erweitern | TEILWEISE | Phase-0c API + History/NutritionCharts | +| 27 | Korrelationen & Insights | TEILWEISE | C-Charts + offene Data-Layer-TODOs | +| 29 | Abilities-Matrix UI | TEILWEISE | Admin/ProfileBuilder, UX offen | +| 30 | Responsive UI Sidebar | OFFEN | Weiterhin Bottom-Nav-fokussiert | +| 32 | Version-System (inkl. ehem. #33) | OFFEN | Gitea: Body/Titel 2026-04-04 aktualisiert; Runner/Build-Git bewusst später | +| 33 | — | GESCHLOSSEN | In #32 konsolidiert (superseded) | +| 34 | External Volumes Doku | PRÜFEN | Gegen Compose abgleichen | +| 35 | `subscriptions` Tabelle | PRÜFEN | Schema prüfen | +| 36 | BUG Trainingstyp ISE | PRÜFEN | Logs nötig | +| 37 | Feature Enforcement Activity CSV | OFFEN | Import ohne vorgeschalteten Check | +| 38 | Feature Enforcement Nutrition CSV UI | DONE? / TEILWEISE | Backend-Check da | +| 39 | Usage-Badges Dashboard/Assistent | TEILWEISE | v. a. Gewicht im Dashboard | +| 40 | Logout Header | DONE? | `App.jsx` LogOut-Button | +| 42 | Enhanced Debug UI | DUPLIKAT | Mit #43 zusammenführen | +| 43 | Enhanced Debug UI | DUPLIKAT | Mit #42 zusammenführen | +| 45 | Prompt-Optimierer | OFFEN | Backlog | +| 46 | Prompt-Ersteller | OFFEN | Backlog | +| 47 | Wertetabelle Optimierung | OFFEN | UX-Feinschliff | +| 49 | Prompt-Zuordnung Verlauf | OFFEN | Kein klarer Treffer | +| 54 | Placeholder Registry … | DUPLIKAT | Wie #55 | +| 55 | Placeholder Registry … | OFFEN / TEILWEISE | `validate_all` existiert | +| 56–58 | Body Cluster Restarbeiten | DUPLIKAT | Dreimal gleicher Inhalt → ein Issue | + +--- + +## Details je Issue (älteste zuerst) + +### #14 – [FEAT-001] Icon Picker für Trainingstypen + +**Code-Stand:** `AdminTrainingTypesPage.jsx` nutzt ein **Textfeld** `icon` (frei, z. B. Emoji). Kein dedizierter Icon-Picker (Palette, Vorschau, Kategorien). + +**Vorschlag:** `OFFEN` – Issue beibehalten; optional präzisieren: „Emoji-Picker oder vordefinierte Icon-Liste statt Freitext“. + +**Kommentar-Entwurf für Gitea:** +> Stand Backend/Frontend: `icon` wird als String in `training_types` gespeichert, Eingabe ist Freitext. Icon-Picker-UX steht noch aus. + +--- + +### #15 – [FEAT-002] Quality-Filter für KI-Auswertungen & Charts + +**Code-Stand:** Charts/Data-Layer nutzen **Confidence** u. a.; Placeholder-Registry hat viele `quality_filter_policy` / Evidence-Felder (teilweise `UNRESOLVED`/`TO_VERIFY`). Ein **einheitlicher** „Quality-Filter“-Mechanismus über alle KI-Auswertungen ist nicht eindeutig als fertiges Feature erkennbar. + +**Vorschlag:** `TEILWEISE` – Issue-Text auf konkrete Lücken schärfen (welche Prompts/Charts, welche Schwellen, SSoT Registry?). + +**Kommentar-Entwurf:** +> Teilaspekte existieren (z. B. Confidence in Chart-Endpoints, Registry-Felder). Offen: durchgängige KI-/Chart-Quality-Pipeline und Abgleich mit Issue-Zielbild. + +--- + +### #21 – [FEATURE] Universeller CSV-Parser mit lernbarem Feldmapping + +**Code-Stand:** Modulspezifische CSV-Imports (Activity, Nutrition, Vitals, Sleep, …) mit jeweils eigenem Parser; **lernbares Mapping** stark bei **Activity** über `activity_type_mappings`. Kein **ein** generischer CSV-Engine wie im Issue beschrieben. + +**Vorschlag:** `OFFEN` – oder Scope reduzieren („pro Modul konsolidieren“). + +--- + +### #25 – [FEAT] Ziele-System (Goals) v9e Kernfeature + +**Code-Stand:** `goals`-Router, `GoalsPage`, Focus Areas, Migrationen – laut Projektstand **weitgehend implementiert**. + +**Vorschlag:** `DONE?` nach deiner Abnahme – Issue-Body auf verbleibende Teilziele kürzen oder schließen. + +**Kommentar-Entwurf:** +> Backend/Frontend Goals + Focus Areas sind im Repo vorhanden. Bitte verbleibende Wünsche als neue Sub-Issues oder AC hier abhaken und schließen. + +--- + +### #26 – [FEAT] Charts & Visualisierungen erweitern + +**Code-Stand:** `backend/routers/charts.py` (Phase 0c), viele `api.get…Chart` in `api.js`; `History.jsx` + `NutritionCharts` / `RecoveryCharts` nutzen Chart-Daten. + +**Vorschlag:** `TEILWEISE` – Issue auf konkrete fehlende Chart-Typen/UI-Verdrahtung schärfen (falls noch offen). + +--- + +### #27 – [FEAT] Korrelationen & Insights erweitern + +**Code-Stand:** Chart-Endpunkte C1–C4 u. a.; Data-Layer `correlations.py` mit TODO-Stellen in Teilen. + +**Vorschlag:** `TEILWEISE` – Liste fehlender Korrelationen/Insights vs. Code ergänzen. + +--- + +### #29 – [FEAT] Abilities-Matrix UI (v9f) + +**Code-Stand:** Training Types mit `abilities` JSONB, `AdminTrainingProfiles`, `ProfileBuilder` – vollständige „5D Matrix“-UX unklar ohne Produktvorgabe. + +**Vorschlag:** `TEILWEISE` – AC mit aktuellen Screenshots/Flows abgleichen. + +--- + +### #30 – [FEAT] Responsive UI – Desktop Sidebar + 2-spaltig + +**Code-Stand:** Weiterhin stark **Mobile-first** (z. B. `bottom-nav` in `App.jsx`); keine ausgebaute Desktop-Sidebar wie im klassischen Admin-Dashboard. + +**Umsetzungsplan:** `docs/issues/PHASE_PLAN_RESPONSIVE_UI.md` (Phasen P0–P8, Abnahmekriterien & Tests, Fortschrittstabelle). + +**Vorschlag:** `OFFEN`. + +--- + +### #32 – Version-System (inkl. ehem. #33) + +**Gitea (2026-04-04):** #33 geschlossen; Inhalt in **#32** zusammengeführt (ein Epic: `/api/version`, `version.js`, Settings, `main.py`-Konsistenz). Automatische Git-/Build-Identität über den Runner: **zurückgestellt**, geplant als **separates Issue** nach der Basis. + +**Code-Stand:** `backend/version.py` vorhanden; **`GET /api/version`** fehlt; `main.py`: Root `v9c-dev`, `FastAPI(..., version="3.0.0")`. + +**Vorschlag:** `OFFEN` – Umsetzung laut aktualisiertem Gitea-Issue #32. + +--- + +### #34 – External Volumes dokumentieren (Legacy bodytrack_*) + +**Vorschlag:** `PRÜFEN` – gegen aktuelle `docker-compose`/Deploy-Doku im Repo halten; dann schließen oder Aktualisierung kommentieren. + +--- + +### #35 – Deprecated Tabelle `subscriptions` entfernen + +**Code-Stand:** Im Migrations-Ordner **kein** aktueller Treffer auf `subscriptions` (Stichprobe); Membership-System nutzt andere Tabellen. + +**Vorschlag:** `PRÜFEN` – einmal `schema.sql` / DB prüfen, ob Tabelle noch existiert. Wenn weg: Issue schließen mit Verweis auf Migration. + +--- + +### #36 – BUG-009: Trainingstyp-Erstellung → Internal Server Error + +**Code-Stand:** `TrainingTypeCreate` enthält `abilities` / `profile` (JSONB). ISE oft durch DB-Constraint, NULL/JSON oder fehlende Spalte – **ohne Laufzeit-Log nicht verifiziert**. + +**Vorschlag:** `PRÜFEN` – in Gitea Notiz: aktueller Request-Body + Stacktrace aus `docker logs`; wenn behoben: schließen. + +--- + +### #37 – Feature-Enforcement Activity CSV-Import + +**Code-Stand:** `create_activity` nutzt `check_feature_access` für `activity_entries`. **`import_activity_csv`** startet **ohne** vorgeschalteten Limit-Check (im gelesenen Abschnitt nur `get_pid` + Parse) – von Issue #37 noch **nicht** erfüllt. + +**Vorschlag:** `OFFEN` – hoch priorisieren; analog Nutrition: ein Check vor Bulk-Import + Zählung. + +--- + +### #38 – Feature-Enforcement Nutrition CSV-Import UI + +**Code-Stand:** `import_nutrition_csv` ruft **`check_feature_access`** für `nutrition_entries` auf (inkl. Logging). + +**Vorschlag:** `TEILWEISE` / `DONE?` – falls UI-Feedback gewünscht, im Issue auf konkrete UI-Lücken eingehen (Banner, Disable Button). + +--- + +### #39 – Usage-Badges im Dashboard-Assistenten + +**Code-Stand:** `Dashboard.jsx` nutzt `getFeatureUsage()` für **Gewichts-Widget** (Limit/Lock). Unklar ob „Assistent“-Modus = gesamtes Dashboard oder separater Guide. + +**Vorschlag:** `TEILWEISE` – Issue präzisieren, welche Kacheln/Bereiche Badges brauchen. + +--- + +### #40 – Logout-Button im App-Header (neben Avatar) + +**Code-Stand:** `App.jsx` – Header mit **`LogOut` neben Avatar** (Umsetzung vorhanden). + +**Vorschlag:** `DONE?` – nach kurzem Klicktest **schließen**. + +**Kommentar-Entwurf:** +> In `App.jsx` ist ein Logout-Button im Header umgesetzt. Bitte in target Umgebung verifizieren und schließen. + +--- + +### #42 / #43 – Enhanced Debug / Prompt Analysis UI (Issue #28 Phase C) + +**Code-Stand:** `Analysis.jsx` mit Expert-Modus, Platzhalter-Gruppierung – kann Teile von Phase C abdecken. + +**Vorschlag:** `DUPLIKAT` – **ein** Issue behalten; anderes schließen mit Verweis. Inhalte zusammenführen. + +--- + +### #45 – KI Prompt-Optimierer + +**Vorschlag:** `OFFEN` – Backlog, nicht im aktuellen Code sichtbar. + +--- + +### #46 – KI Prompt-Ersteller + +**Vorschlag:** `OFFEN` – wie #45. + +--- + +### #47 – Wertetabelle Optimierung + +**Code-Stand:** Wertetabelle in `Analysis.jsx` / Metadata – viele Punkte eher UX/Performance. + +**Vorschlag:** `OFFEN` – konkrete UI-Schmerzpunkte in Sub-Tasks splitten. + +--- + +### #49 – Prompt-Zuordnung zu Verlaufsseiten + +**Code-Stand:** Kein eindeutiger Treffer zu „History page prompt assignment“ in kurzer Suche. + +**Vorschlag:** `OFFEN` – kurz präzisieren (Welche Seite, welches Datenmodell). + +--- + +### #54 / #55 – Placeholder Registry UNRESOLVED & TO_VERIFY + +**Code-Stand:** `placeholder_registry_export.py` liefert **`validation_report` über `registry.validate_all()`** (nicht mehr leer aus dem frühen Issue-Text für Body-Cluster „{}“-Teil). Evidence `TO_VERIFY`/`UNRESOLVED` existieren weiter in Registrations. + +**Vorschlag:** `#54` und `#55` **zusammenlegen** (gleiches Thema, Titel nur Encoding-Unterschied). Ein Issue offen lassen, Metadaten-Audit fortsetzen. + +--- + +### #56 / #57 / #58 – Body Cluster Restarbeiten & Metadaten-Verifizierung + +**Inhalt:** identische bzw. nahezu identische Langbeschreibung (Metadaten, Layer 2b, Nutrition confidence_logic, Validation Report). + +**Vorschlag:** `DUPLIKAT` – **eines** behalten (z. B. niedrigste Nummer oder neueste #58 mit aktualisiertem Stand), andere **schließen** mit Verweis „Duplicate of #X“. Validation-Teil: Code hat bereits `validate_all` – Issue-Text Abschnitt „leeres validation_report“ **aktualisieren**. + +**Kommentar-Entwurf:** +> Dreimal dasselbe Issue. Vorschlag: #56/#57 schließen, Tracking nur in #XX. `validation_report` wird aus `registry.validate_all()` befüllt; verbleibende Arbeit: TO_VERIFY-Felder Layer 2b + Nutrition confidence_logic laut Checkliste. + +--- + +## Nachbearbeitung in Gitea (Checkliste für dich) + +- [ ] Duplikate schließen und verlinken (#42/#43, #54/#55, #56–#58). +- [ ] „DONE?“-Issues manuell testen (`#25`, `#38`, `#40`). +- [ ] `#37` umsetzen oder Kommentar „noch offen“ bestätigen. +- [x] `#32`–`#33` in Gitea zusammengeführt (#33 geschlossen); Umsetzung weiter über #32. +- [ ] Kommentare aus diesem Dokument kopieren/anpassen. +- [ ] Optional: Labels in Gitea setzen (`duplicate`, `blocked`, `needs-retest`). + +--- + +## Technischer Hinweis (Audit / Security) + +Aus dem Code-Audit 2026-04-04: kritische Punkte (`get_pid` / `X-Profile-Id`, `/api/profiles` ohne Admin) sind **nicht** 1:1 als Gitea-Issues in dieser offenen Liste sichtbar – ggf. **separate** Security-Issues aus `.claude/docs/audit/20260404_code_audit/gitea/` anlegen, falls noch nicht vorhanden. + +--- + +*Erzeugt aus Gitea API (28 offene Issues, sortiert nach `created_at`) und statischer Code-Analyse im Workspace. Kein Laufzeit-Test auf dem Pi.* diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 75c2000..6c0c00d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -15,6 +15,7 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.23.1", + "reactflow": "^11.11.4", "recharts": "^2.12.7" }, "devDependencies": { @@ -2059,6 +2060,108 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@reactflow/background": { + "version": "11.3.14", + "resolved": "https://registry.npmjs.org/@reactflow/background/-/background-11.3.14.tgz", + "integrity": "sha512-Gewd7blEVT5Lh6jqrvOgd4G6Qk17eGKQfsDXgyRSqM+CTwDqRldG2LsWN4sNeno6sbqVIC2fZ+rAUBFA9ZEUDA==", + "license": "MIT", + "dependencies": { + "@reactflow/core": "11.11.4", + "classcat": "^5.0.3", + "zustand": "^4.4.1" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@reactflow/controls": { + "version": "11.2.14", + "resolved": "https://registry.npmjs.org/@reactflow/controls/-/controls-11.2.14.tgz", + "integrity": "sha512-MiJp5VldFD7FrqaBNIrQ85dxChrG6ivuZ+dcFhPQUwOK3HfYgX2RHdBua+gx+40p5Vw5It3dVNp/my4Z3jF0dw==", + "license": "MIT", + "dependencies": { + "@reactflow/core": "11.11.4", + "classcat": "^5.0.3", + "zustand": "^4.4.1" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@reactflow/core": { + "version": "11.11.4", + "resolved": "https://registry.npmjs.org/@reactflow/core/-/core-11.11.4.tgz", + "integrity": "sha512-H4vODklsjAq3AMq6Np4LE12i1I4Ta9PrDHuBR9GmL8uzTt2l2jh4CiQbEMpvMDcp7xi4be0hgXj+Ysodde/i7Q==", + "license": "MIT", + "dependencies": { + "@types/d3": "^7.4.0", + "@types/d3-drag": "^3.0.1", + "@types/d3-selection": "^3.0.3", + "@types/d3-zoom": "^3.0.1", + "classcat": "^5.0.3", + "d3-drag": "^3.0.0", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0", + "zustand": "^4.4.1" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@reactflow/minimap": { + "version": "11.7.14", + "resolved": "https://registry.npmjs.org/@reactflow/minimap/-/minimap-11.7.14.tgz", + "integrity": "sha512-mpwLKKrEAofgFJdkhwR5UQ1JYWlcAAL/ZU/bctBkuNTT1yqV+y0buoNVImsRehVYhJwffSWeSHaBR5/GJjlCSQ==", + "license": "MIT", + "dependencies": { + "@reactflow/core": "11.11.4", + "@types/d3-selection": "^3.0.3", + "@types/d3-zoom": "^3.0.1", + "classcat": "^5.0.3", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0", + "zustand": "^4.4.1" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@reactflow/node-resizer": { + "version": "2.2.14", + "resolved": "https://registry.npmjs.org/@reactflow/node-resizer/-/node-resizer-2.2.14.tgz", + "integrity": "sha512-fwqnks83jUlYr6OHcdFEedumWKChTHRGw/kbCxj0oqBd+ekfs+SIp4ddyNU0pdx96JIm5iNFS0oNrmEiJbbSaA==", + "license": "MIT", + "dependencies": { + "@reactflow/core": "11.11.4", + "classcat": "^5.0.4", + "d3-drag": "^3.0.0", + "d3-selection": "^3.0.0", + "zustand": "^4.4.1" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@reactflow/node-toolbar": { + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/@reactflow/node-toolbar/-/node-toolbar-1.3.14.tgz", + "integrity": "sha512-rbynXQnH/xFNu4P9H+hVqlEUafDCkEoCy0Dg9mG22Sg+rY/0ck6KkrAQrYrTgXusd+cEJOMK0uOOFCK2/5rSGQ==", + "license": "MIT", + "dependencies": { + "@reactflow/core": "11.11.4", + "classcat": "^5.0.3", + "zustand": "^4.4.1" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, "node_modules/@remix-run/router": { "version": "1.23.2", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", @@ -2554,24 +2657,159 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/d3": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", + "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/d3-axis": "*", + "@types/d3-brush": "*", + "@types/d3-chord": "*", + "@types/d3-color": "*", + "@types/d3-contour": "*", + "@types/d3-delaunay": "*", + "@types/d3-dispatch": "*", + "@types/d3-drag": "*", + "@types/d3-dsv": "*", + "@types/d3-ease": "*", + "@types/d3-fetch": "*", + "@types/d3-force": "*", + "@types/d3-format": "*", + "@types/d3-geo": "*", + "@types/d3-hierarchy": "*", + "@types/d3-interpolate": "*", + "@types/d3-path": "*", + "@types/d3-polygon": "*", + "@types/d3-quadtree": "*", + "@types/d3-random": "*", + "@types/d3-scale": "*", + "@types/d3-scale-chromatic": "*", + "@types/d3-selection": "*", + "@types/d3-shape": "*", + "@types/d3-time": "*", + "@types/d3-time-format": "*", + "@types/d3-timer": "*", + "@types/d3-transition": "*", + "@types/d3-zoom": "*" + } + }, "node_modules/@types/d3-array": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", "license": "MIT" }, + "node_modules/@types/d3-axis": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz", + "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-brush": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz", + "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-chord": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz", + "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==", + "license": "MIT" + }, "node_modules/@types/d3-color": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", "license": "MIT" }, + "node_modules/@types/d3-contour": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz", + "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", + "license": "MIT" + }, + "node_modules/@types/d3-dispatch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz", + "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-dsv": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", + "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", + "license": "MIT" + }, "node_modules/@types/d3-ease": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", "license": "MIT" }, + "node_modules/@types/d3-fetch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", + "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", + "license": "MIT", + "dependencies": { + "@types/d3-dsv": "*" + } + }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz", + "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", + "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", + "license": "MIT" + }, "node_modules/@types/d3-interpolate": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", @@ -2587,6 +2825,24 @@ "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", "license": "MIT" }, + "node_modules/@types/d3-polygon": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", + "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==", + "license": "MIT" + }, + "node_modules/@types/d3-quadtree": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", + "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==", + "license": "MIT" + }, + "node_modules/@types/d3-random": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz", + "integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==", + "license": "MIT" + }, "node_modules/@types/d3-scale": { "version": "4.0.9", "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", @@ -2596,6 +2852,18 @@ "@types/d3-time": "*" } }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", + "license": "MIT" + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, "node_modules/@types/d3-shape": { "version": "3.1.8", "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", @@ -2611,12 +2879,37 @@ "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", "license": "MIT" }, + "node_modules/@types/d3-time-format": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", + "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==", + "license": "MIT" + }, "node_modules/@types/d3-timer": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "license": "MIT" }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2624,6 +2917,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, "node_modules/@types/raf": { "version": "3.4.3", "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz", @@ -3024,6 +3323,12 @@ "node": ">=10.0.0" } }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -3145,6 +3450,28 @@ "node": ">=12" } }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-ease": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", @@ -3200,6 +3527,16 @@ "node": ">=12" } }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "peer": true, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-shape": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", @@ -3245,6 +3582,41 @@ "node": ">=12" } }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -5161,6 +5533,24 @@ "react-dom": ">=16.6.0" } }, + "node_modules/reactflow": { + "version": "11.11.4", + "resolved": "https://registry.npmjs.org/reactflow/-/reactflow-11.11.4.tgz", + "integrity": "sha512-70FOtJkUWH3BAOsN+LU9lCrKoKbtOPnz2uq0CV2PLdNSwxTXOhCbsZr50GmZ+Rtw3jx8Uv7/vBFtCGixLfd4Og==", + "license": "MIT", + "dependencies": { + "@reactflow/background": "11.3.14", + "@reactflow/controls": "11.2.14", + "@reactflow/core": "11.11.4", + "@reactflow/minimap": "11.7.14", + "@reactflow/node-resizer": "2.2.14", + "@reactflow/node-toolbar": "1.3.14" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, "node_modules/recharts": { "version": "2.15.4", "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz", @@ -6187,6 +6577,15 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/utrie": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", @@ -6761,6 +7160,34 @@ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true, "license": "ISC" + }, + "node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } } } } diff --git a/frontend/package.json b/frontend/package.json index b7d31bf..fd1d304 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,14 +9,15 @@ "preview": "vite preview" }, "dependencies": { + "dayjs": "^1.11.11", + "jspdf": "^2.5.1", + "jspdf-autotable": "^3.8.2", + "lucide-react": "^0.383.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.23.1", - "recharts": "^2.12.7", - "jspdf": "^2.5.1", - "jspdf-autotable": "^3.8.2", - "dayjs": "^1.11.11", - "lucide-react": "^0.383.0" + "reactflow": "^11.11.4", + "recharts": "^2.12.7" }, "devDependencies": { "@vitejs/plugin-react": "^4.3.0", diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index c75f6cf..332cad7 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,6 +1,6 @@ import { useEffect } from 'react' -import { BrowserRouter, Routes, Route, NavLink, useNavigate } from 'react-router-dom' -import { LayoutDashboard, PlusSquare, TrendingUp, BarChart2, Settings, LogOut } from 'lucide-react' +import { BrowserRouter, Routes, Route, NavLink, useLocation } from 'react-router-dom' +import { LogOut } from 'lucide-react' import { ProfileProvider, useProfile } from './context/ProfileContext' import { AuthProvider, useAuth } from './context/AuthContext' import { setProfileId } from './utils/api' @@ -11,6 +11,7 @@ import LoginScreen from './pages/LoginScreen' import Register from './pages/Register' import Verify from './pages/Verify' import Dashboard from './pages/Dashboard' +import CaptureShell from './layouts/CaptureShell' import CaptureHub from './pages/CaptureHub' import WeightScreen from './pages/WeightScreen' import CircumScreen from './pages/CircumScreen' @@ -33,27 +34,47 @@ import AdminTrainingProfiles from './pages/AdminTrainingProfiles' import AdminPromptsPage from './pages/AdminPromptsPage' import AdminGoalTypesPage from './pages/AdminGoalTypesPage' import AdminFocusAreasPage from './pages/AdminFocusAreasPage' +import AdminHomePage from './pages/AdminHomePage' +import AdminUsersPage from './pages/AdminUsersPage' +import AdminSystemPage from './pages/AdminSystemPage' +import AdminGroupHubPage from './pages/AdminGroupHubPage' +import RequireAdmin from './layouts/RequireAdmin' +import AdminShell from './layouts/AdminShell' import SubscriptionPage from './pages/SubscriptionPage' import SleepPage from './pages/SleepPage' import RestDaysPage from './pages/RestDaysPage' import VitalsPage from './pages/VitalsPage' import GoalsPage from './pages/GoalsPage' import CustomGoalsPage from './pages/CustomGoalsPage' +import WorkflowEditorPage from './pages/WorkflowEditorPage' +import DesktopSidebar from './components/DesktopSidebar' +import { getMainNavItems } from './config/appNav' +import { isCaptureSectionPath } from './config/captureNav' import './app.css' -function Nav() { - const links = [ - { to:'/', icon:, label:'Übersicht' }, - { to:'/capture', icon:, label:'Erfassen' }, - { to:'/history', icon:, label:'Verlauf' }, - { to:'/analysis', icon:, label:'Analyse' }, - { to:'/settings', icon:, label:'Einst.' }, - ] +function navItemActive(pathname, item, routerIsActive) { + if (item.to.startsWith('/admin')) return pathname.startsWith('/admin') + if (item.to === '/capture' && isCaptureSectionPath(pathname)) return true + return routerIsActive +} + +function Nav({ isAdmin }) { + const items = getMainNavItems(isAdmin) + const loc = useLocation() return ( @@ -61,9 +82,8 @@ function Nav() { } function AppShell() { - const { session, loading: authLoading, needsSetup, logout } = useAuth() - const { activeProfile, loading: profileLoading } = useProfile() - const nav = useNavigate() + const { session, loading: authLoading, needsSetup, logout, isAdmin } = useAuth() + const { activeProfile, loading: profileLoading } = useProfile() const handleLogout = () => { if (confirm('Wirklich abmelden?')) { @@ -135,69 +155,101 @@ function AppShell() { return (
-
- Mitai Jinkendo -
- - - {activeProfile - ? - :
- } - -
-
-
+ +
+
+ Mitai Jinkendo +
+ + + {activeProfile ? ( + + ) : ( +
+ )} + +
+
+
}/> - }/> - }/> - }/> - }/> - }/> + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + }/> - }/> - }/> - }/> }/> - }/> - }/> - }/> }/> }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> + }> + }> + } /> + } /> + } /> + } /> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + + + }/> }/> -
-
+
+