Merge pull request 'Responsive Gui - partially Workflow' (#61) from develop into main
Some checks failed
Deploy Production / deploy (push) Failing after 1s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 13s

Reviewed-on: #61
This commit is contained in:
Lars 2026-04-05 11:27:43 +02:00
commit 9fb04af7df
98 changed files with 15287 additions and 939 deletions

View File

@ -27,3 +27,9 @@ ALLOWED_ORIGINS=https://mitai.jinkendo.de
# ── Pfade ─────────────────────────────────────────────────────── # ── Pfade ───────────────────────────────────────────────────────
PHOTOS_DIR=/app/photos PHOTOS_DIR=/app/photos
ENVIRONMENT=production 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=

3
.gitignore vendored
View File

@ -61,4 +61,7 @@ tmp/
#.claude Konfiguration #.claude Konfiguration
.claude/ .claude/
# Cursor MCP mit Secrets (Example: .cursor/mcp.json.example)
.cursor/mcp.json
.claude/settings.local.jsonfrontend/package-lock.json .claude/settings.local.jsonfrontend/package-lock.json

396
backend/join_evaluator.py Normal file
View File

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

268
backend/logic_evaluator.py Normal file
View File

@ -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})"

View File

@ -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 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 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 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 ───────────────────────────────────────────────────────── # ── App Configuration ─────────────────────────────────────────────────────────
DATA_DIR = Path(os.getenv("DATA_DIR", "./data")) 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 # Phase 0c Multi-Layer Architecture
app.include_router(charts.router) # /api/charts/* (Phase 0c Charts API) 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 ────────────────────────────────────────────────────────────── # ── Health Check ──────────────────────────────────────────────────────────────
@app.get("/") @app.get("/")
def root(): def root():

View File

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

View File

@ -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.';

View File

@ -28,6 +28,7 @@ class ProfileUpdate(BaseModel):
goal_weight: Optional[float] = None goal_weight: Optional[float] = None
goal_bf_pct: Optional[float] = None goal_bf_pct: Optional[float] = None
quality_filter_level: Optional[str] = None # Issue #31: Global quality filter 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 ─────────────────────────────────────────────────────────── # ── Tracking Models ───────────────────────────────────────────────────────────
@ -177,12 +178,12 @@ class StageCreate(BaseModel):
class UnifiedPromptCreate(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 name: str
slug: str slug: Optional[str] = None # Auto-generated from name if not provided
display_name: Optional[str] = None display_name: Optional[str] = None
description: Optional[str] = None description: Optional[str] = None
type: str # 'base' or 'pipeline' type: str # 'base' | 'pipeline' | 'workflow'
category: str = 'ganzheitlich' category: str = 'ganzheitlich'
active: bool = True active: bool = True
sort_order: int = 0 sort_order: int = 0
@ -195,6 +196,9 @@ class UnifiedPromptCreate(BaseModel):
# For pipeline prompts (multi-stage workflow) # For pipeline prompts (multi-stage workflow)
stages: Optional[list[StageCreate]] = None # Required if type='pipeline' 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): class UnifiedPromptUpdate(BaseModel):
"""Update an existing unified prompt""" """Update an existing unified prompt"""
@ -209,6 +213,7 @@ class UnifiedPromptUpdate(BaseModel):
output_format: Optional[str] = None output_format: Optional[str] = None
output_schema: Optional[dict] = None output_schema: Optional[dict] = None
stages: Optional[list[StageCreate]] = None stages: Optional[list[StageCreate]] = None
graph_data: Optional[dict] = None # For workflow type
# ── Pipeline Config Models (Issue #28) ───────────────────────────────────── # ── Pipeline Config Models (Issue #28) ─────────────────────────────────────

View File

@ -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
<SignalStatus.VALID: 'valid'>
>>> normalized[1].status
<SignalStatus.NORMALIZED: 'normalized'>
"""
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

View File

@ -192,6 +192,10 @@ async def execute_prompt(
# Pipeline prompt: multi-stage execution # Pipeline prompt: multi-stage execution
return await execute_pipeline_prompt(prompt, variables, openrouter_call_func, enable_debug, catalog) 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: else:
raise HTTPException(400, f"Unknown prompt type: {prompt_type}") raise HTTPException(400, f"Unknown prompt type: {prompt_type}")
@ -201,18 +205,54 @@ async def execute_base_prompt(
variables: Dict[str, Any], variables: Dict[str, Any],
openrouter_call_func, openrouter_call_func,
enable_debug: bool = False, enable_debug: bool = False,
catalog: Optional[Dict] = None catalog: Optional[Dict] = None,
node_questions: Optional[list] = None # Phase 1: Knotengebundene Fragen
) -> Dict[str, Any]: ) -> 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') template = prompt.get('template')
if not template: if not template:
raise HTTPException(400, f"Base prompt missing template: {prompt['slug']}") raise HTTPException(400, f"Base prompt missing template: {prompt['slug']}")
debug_info = {} if enable_debug else None 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) # Resolve placeholders (with optional catalog for |d modifier)
prompt_text = resolve_placeholders(template, variables, debug_info, catalog) 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: if enable_debug:
debug_info['template'] = template debug_info['template'] = template
debug_info['final_prompt'] = prompt_text[:500] + ('...' if len(prompt_text) > 500 else '') 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_length'] = len(response)
debug_info['ai_response_preview'] = response[:200] + ('...' if len(response) > 200 else '') debug_info['ai_response_preview'] = response[:200] + ('...' if len(response) > 200 else '')
# Validate JSON if required # Phase 1: Parse structured result if questions were used
output_format = prompt.get('output_format', 'text') if questions:
if output_format == 'json': expected_question_types = [q.type for q in questions]
output = validate_json_output(response, prompt.get('output_schema'), debug_info if enable_debug else None) 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: 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 = { result = {
"type": "base", "type": "base",
@ -524,3 +576,67 @@ async def execute_prompt_with_data(
# Execute prompt # Execute prompt
return await execute_prompt(prompt_slug, variables, openrouter_call_func, enable_debug) 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

View File

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

View File

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

View File

@ -68,13 +68,62 @@ def get_profile(pid: str, session=Depends(require_auth)):
@router.put("/profiles/{pid}") @router.put("/profiles/{pid}")
def update_profile(pid: str, p: ProfileUpdate, session=Depends(require_auth)): 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: 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 = get_cursor(conn)
cur.execute(f"UPDATE profiles SET {', '.join(f'{k}=%s' for k in data)} WHERE id=%s", cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,))
list(data.values())+[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) return get_profile(pid, session)

View File

@ -53,6 +53,20 @@ def list_prompts(session: dict=Depends(require_auth)):
return [r2d(r) for r in cur.fetchall()] 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("") @router.post("")
def create_prompt(p: PromptCreate, session: dict=Depends(require_admin)): def create_prompt(p: PromptCreate, session: dict=Depends(require_admin)):
"""Create new AI prompt (admin only).""" """Create new AI prompt (admin only)."""
@ -1380,20 +1394,26 @@ async def execute_unified_prompt(
@router.post("/unified") @router.post("/unified")
def create_unified_prompt(p: UnifiedPromptCreate, session: dict = Depends(require_admin)): 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. Admin only.
""" """
with get_db() as conn: with get_db() as conn:
cur = get_cursor(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 # Check for duplicate slug
cur.execute("SELECT id FROM ai_prompts WHERE slug=%s", (p.slug,)) cur.execute("SELECT id FROM ai_prompts WHERE slug=%s", (p.slug,))
if cur.fetchone(): if cur.fetchone():
raise HTTPException(status_code=400, detail="Slug already exists") raise HTTPException(status_code=400, detail="Slug already exists")
# Validate type # Validate type
if p.type not in ['base', 'pipeline']: if p.type not in ['base', 'pipeline', 'workflow']:
raise HTTPException(status_code=400, detail="Type must be 'base' or 'pipeline'") raise HTTPException(status_code=400, detail="Type must be 'base', 'pipeline', or 'workflow'")
# Validate base type has template # Validate base type has template
if p.type == 'base' and not p.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: if p.type == 'pipeline' and not p.stages:
raise HTTPException(status_code=400, detail="Pipeline prompts require 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 # Convert stages to JSONB
stages_json = None stages_json = None
if p.stages: if p.stages:
@ -1426,16 +1450,22 @@ def create_unified_prompt(p: UnifiedPromptCreate, session: dict = Depends(requir
prompt_id = str(uuid.uuid4()) 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( cur.execute(
"""INSERT INTO ai_prompts """INSERT INTO ai_prompts
(id, slug, name, display_name, description, template, category, active, sort_order, (id, slug, name, display_name, description, template, category, active, sort_order,
type, stages, output_format, output_schema) type, stages, output_format, output_schema, graph_data)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)""", 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, prompt_id, p.slug, p.name, p.display_name, p.description,
p.template, p.category, p.active, p.sort_order, p.template, p.category, p.active, p.sort_order,
p.type, stages_json, p.output_format, 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') updates.append('description=%s')
values.append(p.description) values.append(p.description)
if p.type is not None: if p.type is not None:
if p.type not in ['base', 'pipeline']: if p.type not in ['base', 'pipeline', 'workflow']:
raise HTTPException(status_code=400, detail="Type must be 'base' or 'pipeline'") raise HTTPException(status_code=400, detail="Type must be 'base', 'pipeline', or 'workflow'")
updates.append('type=%s') updates.append('type=%s')
values.append(p.type) values.append(p.type)
if p.category is not None: 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') updates.append('stages=%s')
values.append(stages_json) 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: if not updates:
return {"ok": True} return {"ok": True}

View File

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

View File

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

View File

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

124
backend/version.py Normal file
View File

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

443
backend/workflow_engine.py Normal file
View File

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

View File

@ -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})")

358
backend/workflow_models.py Normal file
View File

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

View File

@ -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 `240280px` 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 P1P7 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 **P1P2** möglichst **keine** parallelenden Änderungen an `App.jsx` / globalem `app.css` ohne Absprache.
- Neue **Workflow-/Canvas-Routen**: nur innerhalb von `<main>`; 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.

View File

@ -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 |
| 5658 | 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 C1C4 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 P0P8, 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.*

View File

@ -15,6 +15,7 @@
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-router-dom": "^6.23.1", "react-router-dom": "^6.23.1",
"reactflow": "^11.11.4",
"recharts": "^2.12.7" "recharts": "^2.12.7"
}, },
"devDependencies": { "devDependencies": {
@ -2059,6 +2060,108 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@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": { "node_modules/@remix-run/router": {
"version": "1.23.2", "version": "1.23.2",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz",
@ -2554,24 +2657,159 @@
"@babel/types": "^7.28.2" "@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": { "node_modules/@types/d3-array": {
"version": "3.2.2", "version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
"license": "MIT" "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": { "node_modules/@types/d3-color": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT" "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": { "node_modules/@types/d3-ease": {
"version": "3.0.2", "version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT" "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": { "node_modules/@types/d3-interpolate": {
"version": "3.0.4", "version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
@ -2587,6 +2825,24 @@
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT" "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": { "node_modules/@types/d3-scale": {
"version": "4.0.9", "version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
@ -2596,6 +2852,18 @@
"@types/d3-time": "*" "@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": { "node_modules/@types/d3-shape": {
"version": "3.1.8", "version": "3.1.8",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
@ -2611,12 +2879,37 @@
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT" "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": { "node_modules/@types/d3-timer": {
"version": "3.0.2", "version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT" "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": { "node_modules/@types/estree": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@ -2624,6 +2917,12 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@types/raf": {
"version": "3.4.3", "version": "3.4.3",
"resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz", "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
@ -3024,6 +3323,12 @@
"node": ">=10.0.0" "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": { "node_modules/clsx": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@ -3145,6 +3450,28 @@
"node": ">=12" "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": { "node_modules/d3-ease": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
@ -3200,6 +3527,16 @@
"node": ">=12" "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": { "node_modules/d3-shape": {
"version": "3.2.0", "version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
@ -3245,6 +3582,41 @@
"node": ">=12" "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": { "node_modules/data-view-buffer": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz",
@ -5161,6 +5533,24 @@
"react-dom": ">=16.6.0" "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": { "node_modules/recharts": {
"version": "2.15.4", "version": "2.15.4",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz", "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz",
@ -6187,6 +6577,15 @@
"browserslist": ">= 4.21.0" "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": { "node_modules/utrie": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
@ -6761,6 +7160,34 @@
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"dev": true, "dev": true,
"license": "ISC" "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
}
}
} }
} }
} }

View File

@ -9,14 +9,15 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"dayjs": "^1.11.11",
"jspdf": "^2.5.1",
"jspdf-autotable": "^3.8.2",
"lucide-react": "^0.383.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-router-dom": "^6.23.1", "react-router-dom": "^6.23.1",
"recharts": "^2.12.7", "reactflow": "^11.11.4",
"jspdf": "^2.5.1", "recharts": "^2.12.7"
"jspdf-autotable": "^3.8.2",
"dayjs": "^1.11.11",
"lucide-react": "^0.383.0"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-react": "^4.3.0", "@vitejs/plugin-react": "^4.3.0",

View File

@ -1,6 +1,6 @@
import { useEffect } from 'react' import { useEffect } from 'react'
import { BrowserRouter, Routes, Route, NavLink, useNavigate } from 'react-router-dom' import { BrowserRouter, Routes, Route, NavLink, useLocation } from 'react-router-dom'
import { LayoutDashboard, PlusSquare, TrendingUp, BarChart2, Settings, LogOut } from 'lucide-react' import { LogOut } from 'lucide-react'
import { ProfileProvider, useProfile } from './context/ProfileContext' import { ProfileProvider, useProfile } from './context/ProfileContext'
import { AuthProvider, useAuth } from './context/AuthContext' import { AuthProvider, useAuth } from './context/AuthContext'
import { setProfileId } from './utils/api' import { setProfileId } from './utils/api'
@ -11,6 +11,7 @@ import LoginScreen from './pages/LoginScreen'
import Register from './pages/Register' import Register from './pages/Register'
import Verify from './pages/Verify' import Verify from './pages/Verify'
import Dashboard from './pages/Dashboard' import Dashboard from './pages/Dashboard'
import CaptureShell from './layouts/CaptureShell'
import CaptureHub from './pages/CaptureHub' import CaptureHub from './pages/CaptureHub'
import WeightScreen from './pages/WeightScreen' import WeightScreen from './pages/WeightScreen'
import CircumScreen from './pages/CircumScreen' import CircumScreen from './pages/CircumScreen'
@ -33,27 +34,47 @@ import AdminTrainingProfiles from './pages/AdminTrainingProfiles'
import AdminPromptsPage from './pages/AdminPromptsPage' import AdminPromptsPage from './pages/AdminPromptsPage'
import AdminGoalTypesPage from './pages/AdminGoalTypesPage' import AdminGoalTypesPage from './pages/AdminGoalTypesPage'
import AdminFocusAreasPage from './pages/AdminFocusAreasPage' 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 SubscriptionPage from './pages/SubscriptionPage'
import SleepPage from './pages/SleepPage' import SleepPage from './pages/SleepPage'
import RestDaysPage from './pages/RestDaysPage' import RestDaysPage from './pages/RestDaysPage'
import VitalsPage from './pages/VitalsPage' import VitalsPage from './pages/VitalsPage'
import GoalsPage from './pages/GoalsPage' import GoalsPage from './pages/GoalsPage'
import CustomGoalsPage from './pages/CustomGoalsPage' 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' import './app.css'
function Nav() { function navItemActive(pathname, item, routerIsActive) {
const links = [ if (item.to.startsWith('/admin')) return pathname.startsWith('/admin')
{ to:'/', icon:<LayoutDashboard size={20}/>, label:'Übersicht' }, if (item.to === '/capture' && isCaptureSectionPath(pathname)) return true
{ to:'/capture', icon:<PlusSquare size={20}/>, label:'Erfassen' }, return routerIsActive
{ to:'/history', icon:<TrendingUp size={20}/>, label:'Verlauf' }, }
{ to:'/analysis', icon:<BarChart2 size={20}/>, label:'Analyse' },
{ to:'/settings', icon:<Settings size={20}/>, label:'Einst.' }, function Nav({ isAdmin }) {
] const items = getMainNavItems(isAdmin)
const loc = useLocation()
return ( return (
<nav className="bottom-nav"> <nav className="bottom-nav">
{links.map(l=>( {items.map((item) => (
<NavLink key={l.to} to={l.to} end={l.to==='/'} className={({isActive})=>'nav-item'+(isActive?' active':'')}> <NavLink
{l.icon}<span>{l.label}</span> key={item.to}
to={item.to}
end={!!item.end}
className={({ isActive }) =>
'nav-item' +
(navItemActive(loc.pathname, item, isActive) ? ' active' : '')
}
>
<item.Icon size={20} strokeWidth={2} />
<span>{item.shortLabel || item.label}</span>
</NavLink> </NavLink>
))} ))}
</nav> </nav>
@ -61,9 +82,8 @@ function Nav() {
} }
function AppShell() { function AppShell() {
const { session, loading: authLoading, needsSetup, logout } = useAuth() const { session, loading: authLoading, needsSetup, logout, isAdmin } = useAuth()
const { activeProfile, loading: profileLoading } = useProfile() const { activeProfile, loading: profileLoading } = useProfile()
const nav = useNavigate()
const handleLogout = () => { const handleLogout = () => {
if (confirm('Wirklich abmelden?')) { if (confirm('Wirklich abmelden?')) {
@ -135,69 +155,101 @@ function AppShell() {
return ( return (
<div className="app-shell"> <div className="app-shell">
<header className="app-header"> <DesktopSidebar
<span className="app-logo">Mitai Jinkendo</span> isAdmin={isAdmin}
<div style={{display:'flex', gap:12, alignItems:'center'}}> activeProfile={activeProfile}
<button sessionProfile={session?.profile}
onClick={handleLogout} onLogout={handleLogout}
title="Abmelden" />
style={{ <div className="app-shell__column">
background:'none', <header className="app-header app-header--mobile">
border:'none', <span className="app-logo">Mitai Jinkendo</span>
cursor:'pointer', <div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
padding:6, <button
display:'flex', type="button"
alignItems:'center', onClick={handleLogout}
color:'var(--text2)', title="Abmelden"
transition:'color 0.15s' style={{
}} background: 'none',
onMouseEnter={e => e.currentTarget.style.color = '#D85A30'} border: 'none',
onMouseLeave={e => e.currentTarget.style.color = 'var(--text2)'} cursor: 'pointer',
> padding: 6,
<LogOut size={18}/> display: 'flex',
</button> alignItems: 'center',
<NavLink to="/settings" style={{textDecoration:'none'}}> color: 'var(--text2)',
{activeProfile transition: 'color 0.15s'
? <Avatar profile={activeProfile} size={30}/> }}
: <div style={{width:30,height:30,borderRadius:'50%',background:'var(--accent)'}}/> onMouseEnter={(e) => {
} e.currentTarget.style.color = '#D85A30'
</NavLink> }}
</div> onMouseLeave={(e) => {
</header> e.currentTarget.style.color = 'var(--text2)'
<main className="app-main"> }}
>
<LogOut size={18} />
</button>
<NavLink to="/settings" style={{ textDecoration: 'none' }}>
{activeProfile ? (
<Avatar profile={activeProfile} size={30} />
) : (
<div
style={{
width: 30,
height: 30,
borderRadius: '50%',
background: 'var(--accent)'
}}
/>
)}
</NavLink>
</div>
</header>
<main className="app-main">
<Routes> <Routes>
<Route path="/" element={<Dashboard/>}/> <Route path="/" element={<Dashboard/>}/>
<Route path="/capture" element={<CaptureHub/>}/> <Route element={<CaptureShell />}>
<Route path="/wizard" element={<MeasureWizard/>}/> <Route path="/capture" element={<CaptureHub />} />
<Route path="/weight" element={<WeightScreen/>}/> <Route path="/wizard" element={<MeasureWizard />} />
<Route path="/circum" element={<CircumScreen/>}/> <Route path="/weight" element={<WeightScreen />} />
<Route path="/caliper" element={<CaliperScreen/>}/> <Route path="/circum" element={<CircumScreen />} />
<Route path="/caliper" element={<CaliperScreen />} />
<Route path="/sleep" element={<SleepPage />} />
<Route path="/rest-days" element={<RestDaysPage />} />
<Route path="/vitals" element={<VitalsPage />} />
<Route path="/custom-goals" element={<CustomGoalsPage />} />
<Route path="/nutrition" element={<NutritionPage />} />
<Route path="/activity" element={<ActivityPage />} />
<Route path="/guide" element={<GuidePage />} />
</Route>
<Route path="/history" element={<History/>}/> <Route path="/history" element={<History/>}/>
<Route path="/sleep" element={<SleepPage/>}/>
<Route path="/rest-days" element={<RestDaysPage/>}/>
<Route path="/vitals" element={<VitalsPage/>}/>
<Route path="/goals" element={<GoalsPage/>}/> <Route path="/goals" element={<GoalsPage/>}/>
<Route path="/custom-goals" element={<CustomGoalsPage/>}/>
<Route path="/nutrition" element={<NutritionPage/>}/>
<Route path="/activity" element={<ActivityPage/>}/>
<Route path="/analysis" element={<Analysis/>}/> <Route path="/analysis" element={<Analysis/>}/>
<Route path="/settings" element={<SettingsPage/>}/> <Route path="/settings" element={<SettingsPage/>}/>
<Route path="/guide" element={<GuidePage/>}/> <Route element={<RequireAdmin />}>
<Route path="/admin/tier-limits" element={<AdminTierLimitsPage/>}/> <Route path="admin" element={<AdminShell />}>
<Route path="/admin/features" element={<AdminFeaturesPage/>}/> <Route index element={<AdminHomePage />} />
<Route path="/admin/tiers" element={<AdminTiersPage/>}/> <Route path="g/:groupId" element={<AdminGroupHubPage />} />
<Route path="/admin/coupons" element={<AdminCouponsPage/>}/> <Route path="users" element={<AdminUsersPage />} />
<Route path="/admin/user-restrictions" element={<AdminUserRestrictionsPage/>}/> <Route path="system" element={<AdminSystemPage />} />
<Route path="/admin/training-types" element={<AdminTrainingTypesPage/>}/> <Route path="tier-limits" element={<AdminTierLimitsPage/>}/>
<Route path="/admin/activity-mappings" element={<AdminActivityMappingsPage/>}/> <Route path="features" element={<AdminFeaturesPage/>}/>
<Route path="/admin/training-profiles" element={<AdminTrainingProfiles/>}/> <Route path="tiers" element={<AdminTiersPage/>}/>
<Route path="/admin/prompts" element={<AdminPromptsPage/>}/> <Route path="coupons" element={<AdminCouponsPage/>}/>
<Route path="/admin/goal-types" element={<AdminGoalTypesPage/>}/> <Route path="user-restrictions" element={<AdminUserRestrictionsPage/>}/>
<Route path="/admin/focus-areas" element={<AdminFocusAreasPage/>}/> <Route path="training-types" element={<AdminTrainingTypesPage/>}/>
<Route path="activity-mappings" element={<AdminActivityMappingsPage/>}/>
<Route path="training-profiles" element={<AdminTrainingProfiles/>}/>
<Route path="prompts" element={<AdminPromptsPage/>}/>
<Route path="goal-types" element={<AdminGoalTypesPage/>}/>
<Route path="focus-areas" element={<AdminFocusAreasPage/>}/>
</Route>
</Route>
<Route path="/workflow-editor/:id" element={<WorkflowEditorPage/>}/>
<Route path="/subscription" element={<SubscriptionPage/>}/> <Route path="/subscription" element={<SubscriptionPage/>}/>
</Routes> </Routes>
</main> </main>
<Nav/> </div>
<Nav isAdmin={isAdmin} />
</div> </div>
) )
} }

View File

@ -15,6 +15,7 @@
--nav-h: 64px; --nav-h: 64px;
--header-h: 52px; --header-h: 52px;
--font: system-ui, -apple-system, 'Segoe UI', sans-serif; --font: system-ui, -apple-system, 'Segoe UI', sans-serif;
--capture-content-max: 800px;
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root { :root {
@ -86,6 +87,32 @@ body { font-family: var(--font); background: var(--bg); color: var(--text1); -we
} }
.form-input:focus { outline: none; border-color: var(--accent); } .form-input:focus { outline: none; border-color: var(--accent); }
.form-unit { font-size: 12px; color: var(--text3); width: 24px; } .form-unit { font-size: 12px; color: var(--text3); width: 24px; }
/* Einstellungen Profil: Label als Überschrift oben, volle Breite, linksbündig */
.settings-page__field {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 8px;
padding: 12px 0;
border-bottom: 1px solid var(--border);
text-align: left;
}
.settings-page__field-label {
display: block;
font-size: 14px;
font-weight: 600;
color: var(--text1);
text-align: left;
line-height: 1.3;
}
.settings-page__field .form-input {
width: 100%;
max-width: 100%;
min-width: 0;
text-align: left;
box-sizing: border-box;
}
.form-select { .form-select {
font-family: var(--font); font-size: 13px; color: var(--text1); font-family: var(--font); font-size: 13px; color: var(--text1);
background: var(--surface2); border: 1.5px solid var(--border2); background: var(--surface2); border: 1.5px solid var(--border2);
@ -125,6 +152,376 @@ body { font-family: var(--font); background: var(--bg); color: var(--text1); -we
/* Section */ /* Section */
.section-gap { margin-bottom: 16px; } .section-gap { margin-bottom: 16px; }
.page-title { font-size: 20px; font-weight: 700; margin-bottom: 16px; } .page-title { font-size: 20px; font-weight: 700; margin-bottom: 16px; }
/* Verlauf: Mobile Tabs horizontale Leiste, Desktop vertikal links (P4 / RESPONSIVE_UI §5.2) */
.history-page__title {
margin-bottom: 12px;
}
.history-page__layout {
display: flex;
flex-direction: column;
gap: 16px;
}
.history-tabs {
margin-bottom: 0;
}
.history-tabs__scroller {
display: flex;
flex-direction: row;
gap: 6px;
overflow-x: auto;
padding-bottom: 6px;
-ms-overflow-style: none;
scrollbar-width: none;
}
.history-tabs__scroller::-webkit-scrollbar {
display: none;
}
.history-tab-btn {
white-space: nowrap;
flex-shrink: 0;
padding: 7px 14px;
border-radius: 20px;
border: 1.5px solid var(--border2);
background: var(--surface);
color: var(--text2);
font-family: var(--font);
font-size: 13px;
font-weight: 500;
cursor: pointer;
}
.history-tab-btn:hover {
border-color: var(--accent);
color: var(--text1);
}
.history-tab-btn.history-tab-btn--active {
border-color: var(--accent);
background: var(--accent);
color: white;
}
.history-tab-btn.history-tab-btn--active:hover {
color: white;
}
@media (min-width: 1024px) {
.history-page__layout {
flex-direction: row;
align-items: flex-start;
gap: 24px;
}
.history-tabs {
flex: 0 0 260px;
max-width: 280px;
position: sticky;
top: 16px;
align-self: flex-start;
}
.history-tabs__scroller {
flex-direction: column;
overflow-x: visible;
overflow-y: auto;
max-height: calc(100vh - 120px);
padding-bottom: 0;
gap: 8px;
}
.history-tab-btn {
display: flex;
align-items: center;
width: 100%;
text-align: left;
border-radius: 10px;
white-space: normal;
flex-shrink: 0;
padding: 10px 14px;
}
.history-content {
flex: 1;
min-width: 0;
}
}
/* KI-Analyse (P5): Mobile Prompt-Leiste oben / horizontal, Desktop links ~300px (RESPONSIVE_UI §5.3) */
.analysis-page__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
gap: 12px;
flex-wrap: wrap;
}
.analysis-split {
display: flex;
flex-direction: column;
gap: 16px;
}
.analysis-split__nav {
display: flex;
flex-direction: row;
gap: 6px;
overflow-x: auto;
padding-bottom: 6px;
-ms-overflow-style: none;
scrollbar-width: none;
}
.analysis-split__nav::-webkit-scrollbar {
display: none;
}
.analysis-split__nav-item {
flex-shrink: 0;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
border-radius: 20px;
border: 1.5px solid var(--border2);
background: var(--surface);
color: var(--text2);
font-family: var(--font);
font-size: 13px;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
}
.analysis-split__nav-item:hover {
border-color: var(--accent);
color: var(--text1);
}
.analysis-split__nav-item--active {
border-color: var(--accent);
background: var(--accent);
color: white;
}
.analysis-split__nav-item--active:hover {
color: white;
}
.analysis-split__nav-item--active .muted {
color: rgba(255, 255, 255, 0.88) !important;
}
.analysis-split__nav-cat-count {
margin-left: 6px;
font-size: 11px;
font-weight: 500;
opacity: 0.92;
}
.analysis-split__nav-item--active .analysis-split__nav-cat-count {
color: rgba(255, 255, 255, 0.95);
opacity: 1;
}
a.analysis-split__nav-item {
text-decoration: none;
box-sizing: border-box;
}
.analysis-split__main {
min-width: 0;
}
@media (min-width: 1024px) {
.analysis-split {
flex-direction: row;
align-items: flex-start;
gap: 24px;
}
.analysis-split__nav-wrap {
flex: 0 0 300px;
max-width: 320px;
position: sticky;
top: 16px;
align-self: flex-start;
}
.analysis-split__nav {
flex-direction: column;
overflow-x: visible;
overflow-y: auto;
max-height: calc(100vh - 140px);
padding-bottom: 0;
gap: 8px;
}
.analysis-split__nav-item {
width: 100%;
justify-content: flex-start;
text-align: left;
border-radius: 10px;
white-space: normal;
}
.analysis-split__main {
flex: 1;
}
}
/* Erfassung: eine einheitliche Inhaltsbreite (Desktop), zentriert; mobil volle Breite */
.capture-page {
width: 100%;
max-width: 100%;
box-sizing: border-box;
}
@media (min-width: 1024px) {
.capture-page {
max-width: var(--capture-content-max);
margin-left: auto;
margin-right: auto;
}
}
/* Erfassung: Sub-Navigation (Mobil = Chips, Desktop = linke Spalte) */
.capture-shell {
width: 100%;
}
.capture-shell__layout {
display: flex;
flex-direction: column;
gap: 16px;
}
.capture-shell__nav {
display: flex;
flex-direction: row;
gap: 6px;
overflow-x: auto;
padding-bottom: 6px;
-ms-overflow-style: none;
scrollbar-width: none;
}
.capture-shell__nav::-webkit-scrollbar {
display: none;
}
.capture-shell__nav-item {
flex-shrink: 0;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 12px;
border-radius: 20px;
border: 1.5px solid var(--border2);
background: var(--surface);
color: var(--text2);
font-family: var(--font);
font-size: 13px;
font-weight: 500;
text-decoration: none;
white-space: nowrap;
cursor: pointer;
box-sizing: border-box;
}
.capture-shell__nav-item:hover {
border-color: var(--accent);
color: var(--text1);
}
.capture-shell__nav-item--active {
border-color: var(--accent);
background: var(--accent);
color: white;
}
.capture-shell__nav-item--active:hover {
color: white;
}
.capture-shell__nav-item--highlight:not(.capture-shell__nav-item--active) {
border-color: #7f77dd88;
background: #7f77dd14;
}
.capture-shell__nav-icon {
font-size: 15px;
line-height: 1;
}
.capture-shell__nav-label {
line-height: 1.2;
}
.capture-shell__main {
min-width: 0;
}
@media (min-width: 1024px) {
.capture-shell__layout {
flex-direction: row;
align-items: flex-start;
gap: 24px;
}
.capture-shell__nav-wrap {
flex: 0 0 260px;
max-width: 280px;
position: sticky;
top: 16px;
align-self: flex-start;
}
.capture-shell__nav {
flex-direction: column;
overflow-x: visible;
overflow-y: auto;
max-height: calc(100vh - 140px);
padding-bottom: 0;
gap: 8px;
}
.capture-shell__nav-item {
width: 100%;
justify-content: flex-start;
border-radius: 10px;
white-space: normal;
padding: 9px 12px;
}
.capture-shell__main {
flex: 1;
}
}
/* Admin: Split-Layout wie .analysis-split (nur Gruppen in der Nav) */
.admin-shell {
width: 100%;
}
.admin-page {
width: 100%;
}
@media (min-width: 1024px) {
.admin-page {
max-width: var(--capture-content-max);
margin-left: auto;
margin-right: auto;
}
}
.muted { color: var(--text3); font-size: 13px; } .muted { color: var(--text3); font-size: 13px; }
.empty-state { text-align: center; padding: 48px 16px; color: var(--text3); } .empty-state { text-align: center; padding: 48px 16px; color: var(--text3); }
.empty-state h3 { font-size: 16px; color: var(--text2); margin-bottom: 6px; } .empty-state h3 { font-size: 16px; color: var(--text2); margin-bottom: 6px; }
@ -158,3 +555,329 @@ body { font-family: var(--font); background: var(--bg); color: var(--text1); -we
/* Header with profile avatar */ /* Header with profile avatar */
.app-header { display:flex; align-items:center; justify-content:space-between; } .app-header { display:flex; align-items:center; justify-content:space-between; }
.app-header a { display:flex; } .app-header a { display:flex; }
/* ── Responsive shell: Desktop sidebar (≥1024px) — spec RESPONSIVE_UI.md ───── */
.app-shell__column {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
min-height: 0;
}
.desktop-sidebar {
display: none;
flex-direction: column;
width: 220px;
height: 100vh;
position: fixed;
left: 0;
top: 0;
z-index: 30;
background: var(--surface);
border-right: 1px solid var(--border);
padding: 16px 0 16px;
}
.desktop-sidebar__brand {
display: flex;
align-items: center;
gap: 10px;
padding: 0 16px 20px;
border-bottom: 1px solid var(--border);
margin-bottom: 12px;
}
.desktop-sidebar__logo {
width: 40px;
height: 40px;
border-radius: 50%;
border: 3px solid var(--accent);
flex-shrink: 0;
opacity: 0.85;
}
.desktop-sidebar__title {
font-size: 15px;
font-weight: 700;
color: var(--accent);
letter-spacing: -0.02em;
line-height: 1.2;
}
.desktop-sidebar__nav {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
overflow-y: auto;
padding: 0 0 12px;
}
.desktop-sidebar__link {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 16px 10px 13px;
text-decoration: none;
color: var(--text2);
font-size: 14px;
font-weight: 500;
border-left: 3px solid transparent;
border-radius: 0 8px 8px 0;
transition: background 0.15s, color 0.15s;
}
.desktop-sidebar__link:hover {
background: var(--surface2);
color: var(--text1);
}
.desktop-sidebar__link.desktop-sidebar__link--active {
background: var(--accent-light);
color: var(--accent);
border-left-color: var(--accent);
}
.desktop-sidebar__footer {
border-top: 1px solid var(--border);
padding: 16px 12px 0;
display: flex;
align-items: center;
gap: 8px;
}
.desktop-sidebar__user {
display: flex;
align-items: center;
gap: 10px;
flex: 1;
min-width: 0;
text-decoration: none;
color: inherit;
}
.desktop-sidebar__user-text {
display: flex;
flex-direction: column;
min-width: 0;
}
.desktop-sidebar__user-name {
font-size: 13px;
font-weight: 600;
color: var(--text1);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.desktop-sidebar__user-tier {
font-size: 11px;
color: var(--text3);
text-transform: lowercase;
}
.desktop-sidebar__logout {
flex-shrink: 0;
background: none;
border: none;
padding: 8px;
cursor: pointer;
color: var(--text3);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
}
.desktop-sidebar__logout:hover {
color: var(--danger);
background: rgba(216, 90, 48, 0.08);
}
@media (max-width: 1023px) {
.app-shell {
display: flex;
flex-direction: column;
height: 100%;
max-width: 600px;
margin: 0 auto;
}
}
@media (min-width: 1024px) {
.app-shell {
display: block;
max-width: none;
margin: 0;
width: 100%;
min-height: 100%;
}
.desktop-sidebar {
display: flex;
}
.app-shell__column {
margin-left: 220px;
min-height: 100vh;
}
.app-header--mobile {
display: none !important;
}
.bottom-nav {
display: none !important;
}
.app-main {
padding: 24px 32px 32px;
padding-bottom: max(32px, env(safe-area-inset-bottom, 0px));
max-width: 1200px;
margin-left: auto;
margin-right: auto;
width: 100%;
box-sizing: border-box;
}
/* Dashboard (P3): Begrüßung + Kennzahlen-Zeile */
.dashboard-greeting {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
margin-bottom: 16px;
}
.dashboard-greeting__meta {
margin-top: 0 !important;
text-align: right;
}
}
/* ── Dashboard layout (Mobile baseline + Desktop im Block oben teilweise) ─ */
.dashboard-page {
width: 100%;
}
.dashboard-greeting {
margin-bottom: 16px;
}
/*
* Dashboard-Raster (KPI, Nebeneinander-Kacheln): 2 / 4 Spalten.
* StatCard, DashboardTile: span via --tile-sm / --tile-lg (JS clamp).
*/
.dashboard-stat-grid,
.dashboard-tile-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.dashboard-stat-grid--mobile-4col,
.dashboard-tile-grid--mobile-4col {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.dashboard-stat-card {
background: var(--surface);
border-radius: 12px;
padding: 12px 10px;
border: 1px solid var(--border);
transition: border-color 0.15s;
}
.dashboard-stat-card,
.dashboard-tile {
min-width: 0;
box-sizing: border-box;
grid-column: span var(--tile-sm, 1);
}
@media (min-width: 1024px) {
.dashboard-stat-card,
.dashboard-tile {
grid-column: span var(--tile-lg, 1);
}
}
/* ── Dashboard-Abschnitte (Überschrift + Trennlinie) ─ */
.dashboard-section {
margin-bottom: 22px;
}
.dashboard-section:last-child {
margin-bottom: 0;
}
.dashboard-section__header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
padding-bottom: 10px;
margin-bottom: 12px;
border-bottom: 1px solid var(--border);
}
.dashboard-section__headline {
flex: 1;
min-width: 0;
}
.dashboard-section__title {
font-size: 13px;
font-weight: 700;
color: var(--text3);
text-transform: uppercase;
letter-spacing: 0.05em;
margin: 0;
}
.dashboard-section__description {
font-size: 12px;
color: var(--text3);
margin: 4px 0 0 0;
line-height: 1.35;
max-width: 640px;
}
.dashboard-section__body {
display: flex;
flex-direction: column;
gap: 12px;
}
.dashboard-section__actions {
flex-shrink: 0;
}
.dashboard-pill-row {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
/* Ernährung/Aktivität: Raster wie KPI; Kacheln per DashboardTile steuerbar */
.dashboard-summary-row.dashboard-tile-grid {
margin-bottom: 0;
}
.dashboard-erholung-grid .dashboard-tile > .card,
.dashboard-summary-row .dashboard-tile > .card {
height: 100%;
}
@media (min-width: 1024px) {
.dashboard-stat-grid,
.dashboard-tile-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
}
}

View File

@ -0,0 +1,31 @@
/**
* Abschnitt mit optionalem Kopf (Titel, Beschreibung, Aktionen) und unterem Rand zur optischen Trennung.
*/
export default function DashboardSection({
title,
description,
headerRight,
children,
className = '',
bodyClassName = ''
}) {
const showHeader = title || description || headerRight
return (
<section className={`dashboard-section ${className}`.trim()}>
{showHeader && (
<header className="dashboard-section__header">
<div className="dashboard-section__headline">
{title ? <h2 className="dashboard-section__title">{title}</h2> : null}
{description ? (
<p className="dashboard-section__description">{description}</p>
) : null}
</div>
{headerRight ? (
<div className="dashboard-section__actions">{headerRight}</div>
) : null}
</header>
)}
<div className={`dashboard-section__body ${bodyClassName}`.trim()}>{children}</div>
</section>
)
}

View File

@ -0,0 +1,29 @@
import {
clampTileSpan,
DASHBOARD_TILE_GRID_COLS
} from '../utils/dashboardLayout'
/**
* Kachel im Raster `.dashboard-tile-grid` / `.dashboard-stat-grid`.
* Standard: volle Zeile (Mobile 2/2, Desktop 4/4). Anpassbar via spanMobile / spanDesktop.
*/
export default function DashboardTile({
children,
spanMobile = DASHBOARD_TILE_GRID_COLS.mobile,
spanDesktop = DASHBOARD_TILE_GRID_COLS.desktop,
className = ''
}) {
const sm = clampTileSpan(spanMobile, DASHBOARD_TILE_GRID_COLS.mobile)
const lg = clampTileSpan(spanDesktop, DASHBOARD_TILE_GRID_COLS.desktop)
return (
<div
className={`dashboard-tile ${className}`.trim()}
style={{
'--tile-sm': String(sm),
'--tile-lg': String(lg)
}}
>
{children}
</div>
)
}

View File

@ -0,0 +1,86 @@
import { NavLink, useLocation } from 'react-router-dom'
import { LogOut } from 'lucide-react'
import { Avatar } from '../pages/ProfileSelect'
import { getMainNavItems } from '../config/appNav'
import { isCaptureSectionPath } from '../config/captureNav'
function sidebarLinkActive(pathname, item, routerIsActive) {
if (item.to.startsWith('/admin')) return pathname.startsWith('/admin')
if (item.to === '/capture' && isCaptureSectionPath(pathname)) return true
return routerIsActive
}
/**
* Desktop-Sidebar (1024px) Sichtbarkeit via CSS (.desktop-sidebar).
*/
export default function DesktopSidebar({
isAdmin,
activeProfile,
sessionProfile,
onLogout
}) {
const loc = useLocation()
const items = getMainNavItems(isAdmin)
const tier = (activeProfile && activeProfile.tier) || sessionProfile?.tier || ''
return (
<aside className="desktop-sidebar" aria-label="Hauptnavigation">
<div className="desktop-sidebar__brand">
<div className="desktop-sidebar__logo" aria-hidden />
<div className="desktop-sidebar__title">Mitai Jinkendo</div>
</div>
<nav className="desktop-sidebar__nav">
{items.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={!!item.end}
className={({ isActive }) =>
'desktop-sidebar__link' +
(sidebarLinkActive(loc.pathname, item, isActive)
? ' desktop-sidebar__link--active'
: '')
}
>
<item.Icon size={20} strokeWidth={2} />
<span>{item.label}</span>
</NavLink>
))}
</nav>
<div className="desktop-sidebar__footer">
<NavLink to="/settings" className="desktop-sidebar__user">
{activeProfile ? (
<Avatar profile={activeProfile} size={32} />
) : (
<div
style={{
width: 32,
height: 32,
borderRadius: '50%',
background: 'var(--accent)'
}}
/>
)}
<div className="desktop-sidebar__user-text">
<span className="desktop-sidebar__user-name">
{activeProfile?.name || 'Profil'}
</span>
{tier ? (
<span className="desktop-sidebar__user-tier">{tier}</span>
) : null}
</div>
</NavLink>
<button
type="button"
className="desktop-sidebar__logout"
onClick={onLogout}
title="Abmelden"
>
<LogOut size={18} />
</button>
</div>
</aside>
)
}

View File

@ -0,0 +1,78 @@
import { useState, useEffect } from 'react'
export default function EmailSettings() {
const [status, setStatus] = useState(null)
const [testTo, setTestTo] = useState('')
const [testing, setTesting] = useState(false)
const [testMsg, setTestMsg] = useState(null)
useEffect(()=>{
const token = localStorage.getItem('bodytrack_token')||''
fetch('/api/admin/email/status',{headers:{'X-Auth-Token':token}})
.then(r=>r.json()).then(setStatus)
},[])
const sendTest = async () => {
if (!testTo) return
setTesting(true); setTestMsg(null)
try {
const token = localStorage.getItem('bodytrack_token')||''
const r = await fetch('/api/admin/email/test',{
method:'POST',headers:{'Content-Type':'application/json','X-Auth-Token':token},
body:JSON.stringify({to:testTo})
})
if(!r.ok) throw new Error((await r.json()).detail)
setTestMsg('✓ Test-E-Mail gesendet!')
} catch(e){ setTestMsg('✗ Fehler: '+e.message) }
finally{ setTesting(false) }
}
return (
<div className="card section-gap" style={{marginTop:0}}>
<div style={{fontWeight:700,fontSize:14,marginBottom:10,display:'flex',alignItems:'center',gap:6}}>
📧 E-Mail Konfiguration (SMTP)
</div>
{!status ? <div className="spinner" style={{width:16,height:16}}/> : (
<>
<div style={{padding:'8px 12px',borderRadius:8,marginBottom:12,
background:status.configured?'var(--accent-light)':'var(--warn-bg)',
fontSize:12,color:status.configured?'var(--accent-dark)':'var(--warn-text)'}}>
{status.configured
? <> Konfiguriert: <strong>{status.smtp_user}</strong> via {status.smtp_host}</>
: <> Nicht konfiguriert. SMTP-Einstellungen in der <code>.env</code> Datei setzen.</>}
</div>
{status.configured && (
<>
<div style={{fontSize:11,color:'var(--text3)',marginBottom:10,lineHeight:1.5}}>
<strong>App-URL:</strong> {status.app_url}<br/>
<span style={{fontSize:10}}>Für korrekte Links in E-Mails (z.B. Recovery-Links). In .env als APP_URL setzen.</span>
</div>
<div style={{display:'flex',gap:8}}>
<input type="email" className="form-input" placeholder="test@beispiel.de"
value={testTo} onChange={e=>setTestTo(e.target.value)} style={{flex:1}}/>
<button className="btn btn-secondary" onClick={sendTest} disabled={testing}>
{testing?'…':'Test'}
</button>
</div>
{testMsg && <div style={{fontSize:12,marginTop:6,
color:testMsg.startsWith('✓')?'var(--accent)':'#D85A30'}}>{testMsg}</div>}
</>
)}
{!status.configured && (
<div style={{fontSize:11,color:'var(--text3)',lineHeight:1.6}}>
Füge folgende Zeilen zur <code>.env</code> Datei hinzu:<br/>
<code style={{background:'var(--surface2)',padding:'6px 8px',borderRadius:4,
display:'block',marginTop:6,fontSize:11}}>
SMTP_HOST=smtp.gmail.com<br/>
SMTP_PORT=587<br/>
SMTP_USER=deine@gmail.com<br/>
SMTP_PASS=dein_app_passwort<br/>
APP_URL=http://192.168.2.49:3002
</code>
</div>
)}
</>
)}
</div>
)
}

View File

@ -0,0 +1,422 @@
import { useState, useId, useMemo, useEffect } from 'react'
import { ChevronDown, ChevronUp } from 'lucide-react'
import { haystackForEmoji, matchesEmojiSearch } from './emojiIconPickerKeywords.js'
/**
* Kuratierte Emoji-Gruppen (viele Einzel-Glyphen für breite OS-Unterstützung).
* Erste Sport-Gruppe: typische Vereins-/Breitensportarten in Deutschland (DOSB/Nischensport mit abgedeckt).
* Erweiterbar: prop `extraGroups` an EmojiIconPicker, oder diese Konstante editieren.
*/
export const EMOJI_ICON_GROUPS = [
{
label: 'Sportarten (typisch Deutschland)',
emojis: [
// Vereins- & Breitensport (Reihenfolge: Fußball, Handball, Kampfsport Gi = Karate/Judo/, )
'⚽',
'🤾',
'🤾‍♂️',
'🤾‍♀️',
'🥋',
'🏐',
'🏀',
'🎾',
'🏓',
'🏸',
'🏒',
'🏑',
'🥍',
'🏈',
'🏉',
'⚾',
'🥎',
'⛹️',
'⛹️‍♂️',
'⛹️‍♀️',
'🥅',
'⛷️',
'🎿',
'🏂',
'🛷',
'⛸️',
'🥌',
'🚴',
'🚴‍♂️',
'🚴‍♀️',
'🚵',
'🚵‍♂️',
'🚵‍♀️',
'🏃',
'🏃‍♂️',
'🏃‍♀️',
'🏃‍➡️',
'🚶',
'🥾',
'🏊',
'🏊‍♂️',
'🏊‍♀️',
'🤽',
'🤽‍♂️',
'🤽‍♀️',
'🤿',
'🏄',
'🏄‍♂️',
'🏄‍♀️',
'🚣',
'🚣‍♂️',
'🚣‍♀️',
'⛵',
'🧗',
'🧗‍♂️',
'🧗‍♀️',
'🏋️',
'🏋️‍♂️',
'🏋️‍♀️',
'🤸',
'🤸‍♂️',
'🤸‍♀️',
'🥊',
'🤼',
'🤺',
'🏇',
'⛳',
'🏌️',
'🏌️‍♂️',
'🏌️‍♀️',
'💃',
'🕺',
'🧘',
'🧘‍♂️',
'🧘‍♀️',
'🛼',
'🛹',
'🎯',
'🎳',
'🏟️',
'🏆',
'🥇',
'🥈',
'🥉'
]
},
{
label: 'Weitere Sportarten & Hobbysport',
emojis: [
'🎱',
'🎣',
'🤹',
'🪁',
'🥏',
'🛶',
'🏹',
'🌊',
'🏖️',
'🛣️',
'🧭',
'🏕️',
'⛺'
]
},
{
label: 'Yoga, Geist, Balance',
emojis: [
'🧘', '🧘‍♂️', '🧘‍♀️', '🪷', '☯️', '🕉️', '🙏', '🧎', '🧍', '💭', '📿', '🎼',
'🎹', '🥁', '🎸', '🎺', '🔔', '✨', '🌟', '💫', '🔮'
]
},
{
label: 'Outdoor & Natur',
emojis: [
'⛰️', '🏔️', '🗻', '🌋', '🏕️', '⛺', '🧭', '🗺️', '🌲', '🌳', '🌴', '🍃',
'🍂', '🌿', '☘️', '🪨', '🏞️', '🏜️', '🏖️', '🌅', '🌄', '🌈', '⛅', '🌤️',
'☀️', '🌙', '⭐', '🌠', '❄️', '☃️', '⛄'
]
},
{
label: 'Körper & Medizin',
emojis: [
'💪', '🦾', '🦵', '🦶', '🖐️', '✋', '👣', '❤️', '🩷', '💙', '💚', '🫀',
'🫁', '🧠', '👁️', '👂', '🦷', '🦴', '🧬', '⚕️', '🩺', '🩹', '🩼', '💊',
'🌡️', '🔬', '🧪', '🧫', '♿', '⚖️', '📏', '📐'
]
},
{
label: 'Ernährung & Getränke',
emojis: [
'🍎', '🍐', '🍊', '🍋', '🍌', '🍉', '🍇', '🍓', '🫐', '🍒', '🍑', '🥭',
'🍍', '🥝', '🍅', '🥑', '🥦', '🥬', '🥒', '🌶️', '🫑', '🌽', '🥕', '🫒',
'🧄', '🧅', '🥔', '🍠', '🥐', '🍞', '🥖', '🥨', '🧀', '🥚', '🍳', '🧈',
'🥞', '🧇', '🥓', '🥩', '🍗', '🍖', '🌭', '🍔', '🍟', '🍕', '🫓', '🥙',
'🌮', '🌯', '🥗', '🍝', '🍜', '🍲', '🍛', '🍣', '🍱', '🥟', '🦪', '🍤',
'🍙', '🍚', '🍘', '🍥', '🥠', '🥮', '🍢', '🍡', '🍧', '🍨', '🍦', '🥧',
'🧁', '🍰', '🎂', '🍮', '🍭', '🍬', '🍫', '🍿', '🍩', '🍪', '🌰', '🥜',
'🍯', '🥛', '🍼', '🫖', '☕', '🍵', '🧃', '🥤', '🧋', '🍶', '🍺', '🍻',
'🥂', '🍷', '🥃', '🍸', '🍹', '🧉', '🍾', '💧', '🧊'
]
},
{
label: 'Schlaf & Erholung',
emojis: [
'😴', '🛌', '🛏️', '💤', '🌙', '🌛', '🌜', '💆', '💆‍♂️', '💆‍♀️', '🧖', '🧖‍♂️',
'🧖‍♀️', '🧴', '🛁', '🚿', '🪥', '🩴', '🧘', '🕯️'
]
},
{
label: 'Stimmung & Motivation Smileys',
emojis: [
'😊', '🙂', '😌', '😎', '🤩', '🥳', '😤', '💯', '🙌', '👏', '🤝', '👍',
'👎', '✊', '🤛', '🤜', '💪', '🦵', '🧗', '🔥', '💥', '⚡', '🎉', '🏆',
'🥇', '🥈', '🥉', '🎖️', '🏅', '😅', '🤔', '🧐', '😇'
]
},
{
label: 'Tiere (Maskottchen)',
emojis: [
'🐶', '🐱', '🐭', '🐹', '🐰', '🦊', '🐻', '🐼', '🐨', '🐯', '🦁', '🐮',
'🐷', '🐸', '🐵', '🐔', '🐧', '🐦', '🐤', '🦆', '🦅', '🦉', '🦇', '🐺',
'🐗', '🐴', '🦄', '🐝', '🐛', '🦋', '🐌', '🐞', '🐜', '🦗', '🕷️', '🦂',
'🐢', '🐍', '🦎', '🦖', '🦕', '🐙', '🦑', '🦐', '🦞', '🐠', '🐟', '🐬',
'🐳', '🐋', '🦈', '🐊'
]
},
{
label: 'Symbole & Pointers',
emojis: [
'🎯', '📊', '📈', '📉', '🧮', '📋', '📌', '📍', '🔖', '🏷️', '✏️', '✒️',
'🖊️', '📎', '🔗', '⛓️', '🔒', '🔓', '🔑', '🗝️', '🔨', '🛠️', '⚙️', '🧰',
'💡', '🔦', '🏮', '🪔', '📣', '📢', '🔔', '🔕', '⏱️', '⏰', '🕐', '📅',
'🗓️', '✅', '☑️', '✔️', '❌', '⭕', '❗', '❓', '💬', '🗨️', '📝', '📖',
'🪄', '🎪', '🎭', '🎬', '🎨', '🖼️', '🧩', '♟️', '🎲', '🧸'
]
},
{
label: 'Fahrzeuge & Weg',
emojis: [
'🚗', '🚕', '🚙', '🚌', '🚎', '🏎️', '🚓', '🚑', '🚒', '🚐', '🛻', '🚚',
'🚛', '🚜', '🛵', '🏍️', '🛺', '🚲', '🛴', '🛹', '🚁', '✈️', '🛫', '🛬',
'🪂', '🚀', '🛶', '⛵', '🚤', '🛥️', '🛳️', '⛴️', '🚢', '⚓', '🗼', '🏟️'
]
}
]
/**
* Wiederverwendbare Emoji-/Icon-Auswahl: Vorschau, Freitext (inkl. System-Emoji-Picker),
* optional ausklappbare Vorschläge.
*
* @param {string} value aktueller Icon-String (meist ein Emoji)
* @param {(next: string) => void} onChange
* @param {string} [placeholder]
* @param {number} [maxLength=10]
* @param {boolean} [disabled]
* @param {string} [id] optionale ID für das Textfeld (Label for=)
* @param {boolean} [defaultExpanded=false] Vorschlags-Bereich initial offen
* @param {{ label: string, emojis: string[] }[]} [extraGroups=[]] eigene Gruppe(n) anhängen (z. B. Projekt-Favoriten)
*/
export default function EmojiIconPicker({
value,
onChange,
placeholder = '📝',
maxLength = 10,
disabled = false,
id: idProp,
defaultExpanded = false,
extraGroups = []
}) {
const uid = useId()
const inputId = idProp || `emoji-icon-${uid}`
const searchInputId = `${inputId}-picker-search`
const [open, setOpen] = useState(defaultExpanded)
const [pickerSearch, setPickerSearch] = useState('')
const groups =
extraGroups.length > 0 ? [...EMOJI_ICON_GROUPS, ...extraGroups] : EMOJI_ICON_GROUPS
useEffect(() => {
if (!open) setPickerSearch('')
}, [open])
const filteredGroups = useMemo(() => {
const q = pickerSearch
if (!q.trim()) {
return groups
}
return groups
.map((g) => ({
...g,
emojis: g.emojis.filter((em) => matchesEmojiSearch(haystackForEmoji(em, g.label), q))
}))
.filter((g) => g.emojis.length > 0)
}, [groups, pickerSearch])
const handleInput = (e) => {
onChange(e.target.value.slice(0, maxLength))
}
const pick = (em) => {
onChange(em.slice(0, maxLength))
}
return (
<div className="emoji-icon-picker" style={{ width: '100%' }}>
<div
style={{
display: 'flex',
gap: 8,
alignItems: 'center',
flexWrap: 'wrap'
}}
>
<span
style={{
fontSize: 28,
lineHeight: 1,
minWidth: 40,
textAlign: 'center',
opacity: value ? 1 : 0.35
}}
aria-hidden
>
{value || '·'}
</span>
<input
id={inputId}
type="text"
className="form-input"
style={{ flex: 1, minWidth: 120 }}
value={value}
onChange={handleInput}
placeholder={placeholder}
maxLength={maxLength}
disabled={disabled}
autoComplete="off"
spellCheck={false}
inputMode="text"
/>
<button
type="button"
className="btn btn-secondary"
disabled={disabled}
onClick={() => setOpen((o) => !o)}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 4,
padding: '8px 12px',
fontSize: 13
}}
aria-expanded={open}
>
{open ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
Vorschläge
</button>
{!!value && !disabled && (
<button
type="button"
className="btn btn-secondary"
onClick={() => onChange('')}
style={{ padding: '8px 12px', fontSize: 13 }}
>
Leeren
</button>
)}
</div>
{open && (
<div
role="listbox"
aria-label="Emoji-Vorschläge"
style={{
marginTop: 10,
padding: 12,
background: 'var(--surface2)',
borderRadius: 12,
border: '1px solid var(--border)',
maxHeight: 'min(72vh, 460px)',
overflowY: 'auto'
}}
>
<label htmlFor={searchInputId} className="form-label" style={{ marginBottom: 6 }}>
In Vorschlägen suchen
</label>
<input
id={searchInputId}
type="search"
className="form-input"
value={pickerSearch}
onChange={(e) => setPickerSearch(e.target.value)}
placeholder="z. B. rollschuh, karate, apfel…"
disabled={disabled}
autoComplete="off"
spellCheck={false}
inputMode="search"
style={{ width: '100%', marginBottom: 12, boxSizing: 'border-box' }}
/>
{filteredGroups.length === 0 && pickerSearch.trim() && (
<p
style={{
fontSize: 13,
color: 'var(--text2)',
margin: '0 0 12px 0',
lineHeight: 1.45
}}
>
Keine Treffer für {pickerSearch.trim()}. Andere Begriffe probieren oder oben ein Emoji
einfügen (z.&nbsp;B. Win&nbsp;+&nbsp;.).
</p>
)}
{filteredGroups.map((group, gi) => (
<div key={`${group.label}-${gi}`} style={{ marginBottom: 14 }}>
<div
style={{
fontSize: 11,
fontWeight: 600,
color: 'var(--text3)',
marginBottom: 8,
textTransform: 'uppercase',
letterSpacing: '0.04em'
}}
>
{group.label}
</div>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: 6
}}
>
{group.emojis.map((em, ei) => (
<button
key={`${group.label}-${gi}-${ei}-${em}`}
type="button"
onClick={() => pick(em)}
disabled={disabled}
title={em}
aria-label={`Icon wählen: ${em}`}
style={{
fontSize: 22,
lineHeight: 1,
padding: '8px 10px',
border: `1px solid ${value === em ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: 8,
background:
value === em ? 'var(--accent-light)' : 'var(--surface)',
cursor: disabled ? 'not-allowed' : 'pointer',
opacity: disabled ? 0.5 : 1
}}
>
{em}
</button>
))}
</div>
</div>
))}
<p style={{ fontSize: 11, color: 'var(--text3)', margin: '12px 0 0 0', lineHeight: 1.5 }}>
Du kannst auch direkt in das Feld tippen oder das Betriebssystem-Emoji-Menü nutzen
(z.&nbsp;B. Win + . unter Windows). Mehrere Wörter verfeinern die Suche: jedes muss in den
Stichwörtern vorkommen.{' '}
<strong>Inline Skating:</strong> Es gibt kein separates Inliner-Emoji; 🛼 (Rollschuh) wird dafür oft genutzt Suchbegriffe: rollschuh, inline, inliner.{' '}
<strong>Karate / Kampfsport mit Gi:</strong> 🥋 (gemeinsames Unicode-Symbol für u. a. Karate, Judo, Ju-Jitsu).
</p>
</div>
)}
</div>
)
}

View File

@ -0,0 +1,529 @@
/**
* Suchbegriffe für EmojiIconPicker (kleingeschrieben, DE + ggf. EN).
* Gruppenüberschriften werden zusätzlich immer indexiert.
* @type {Record<string, string>}
*/
export const EMOJI_KEYWORDS = {
// —— Rollen / Skates (wichtig für „Inline“, „Rollschuh“) ——
'🛼':
'rollschuh rollschuhe inline inliner skaten roller quad rollschlittschuh blade blades rollerblade skates',
// —— Sport (typisch DE-Gruppe) ——
'⚽': 'fußball fussball soccer football',
'🤾': 'handball',
'🤾‍♂️': 'handball mann',
'🤾‍♀️': 'handball frau',
'🥋': 'karate judo ju jitsu jujitsu kampfsport gi anzug martial arts',
'🏐': 'volleyball beach',
'🏀': 'basketball',
'🎾': 'tennis',
'🏓': 'tischtennis pingpong',
'🏸': 'badminton federball',
'🏒': 'eishockey hockey',
'🏑': 'feldhockey hockey',
'🥍': 'lacrosse',
'🏈': 'american football football nfl',
'🏉': 'rugby',
'⚾': 'baseball',
'🥎': 'softball',
'⛹️': 'basketball person',
'⛹️‍♂️': 'basketball mann',
'⛹️‍♀️': 'basketball frau',
'🥅': 'tor goal netz',
'⛷️': 'ski skifahren skilaufen alpin',
'🎿': 'ski skier langlauf',
'🏂': 'snowboard',
'🛷': 'schlitten rodeln',
'⛸️': 'eislauf schlittschuh eisbahn kunstlauf',
'🥌': 'curling',
'🚴': 'rad fahrrad rennrad cycling',
'🚴‍♂️': 'rad fahrrad mann',
'🚴‍♀️': 'rad fahrrad frau',
'🚵': 'mountainbike mtb rad',
'🚵‍♂️': 'mountainbike mann',
'🚵‍♀️': 'mountainbike frau',
'🏃': 'laufen joggen jogging marathon laufsport',
'🏃‍♂️': 'laufen mann',
'🏃‍♀️': 'laufen frau',
'🏃‍➡️': 'laufen sprint',
'🚶': 'gehen spazieren walking',
'🥾': 'wandern hiking bergsport bergtouren',
'🏊': 'schwimmen schwimm sport',
'🏊‍♂️': 'schwimmen mann',
'🏊‍♀️': 'schwimmen frau',
'🤽': 'wasserball',
'🤽‍♂️': 'wasserball mann',
'🤽‍♀️': 'wasserball frau',
'🤿': 'tauchen schnorcheln diving',
'🏄': 'surfen wellen',
'🏄‍♂️': 'surfen mann',
'🏄‍♀️': 'surfen frau',
'🚣': 'rudern boot row',
'🚣‍♂️': 'rudern mann',
'🚣‍♀️': 'rudern frau',
'⛵': 'segeln segelboot yacht',
'🧗': 'klettern bouldern bergsteigen',
'🧗‍♂️': 'klettern mann',
'🧗‍♀️': 'klettern frau',
'🏋️': 'krafttraining gewichte fitnessstudio gym hanteln',
'🏋️‍♂️': 'krafttraining mann',
'🏋️‍♀️': 'krafttraining frau',
'🤸': 'turnen gymnastics',
'🤸‍♂️': 'turnen mann',
'🤸‍♀️': 'turnen frau',
'🥊': 'boxen boxing',
'🤼': 'ringen wrestling',
'🤺': 'fechten fencing',
'🏇': 'reiten pferd reitsport galopp derby',
'⛳': 'golf golfplatz loch',
'🏌️': 'golf',
'🏌️‍♂️': 'golf mann',
'🏌️‍♀️': 'golf frau',
'💃': 'tanzen tanz salsa tanzsport',
'🕺': 'tanzen mann disco',
'🧘': 'yoga pilates meditation entspannung',
'🧘‍♂️': 'yoga mann meditation',
'🧘‍♀️': 'yoga frau meditation',
'🛹': 'skateboard longboard street',
'🎯': 'dart darts schießen treffer zielscheibe',
'🎳': 'bowling kegeln',
'🏟️': 'stadion arena',
'🏆': 'pokal sieg trophy',
'🥇': 'gold medaille erste platz',
'🥈': 'silber medaille',
'🥉': 'bronze medaille',
// —— Weitere Sport / Hobby ——
'🎱': 'billard pool snooker',
'🎣': 'angeln fishing',
'🤹': 'jonglieren zirkus',
'🪁': 'drachen steigen lassen drachen',
'🥏': 'frisbee ultimate',
'🛶': 'kanu kayak paddeln',
'🏹': 'bogenschießen pfeil bogen',
'🌊': 'welle meer wasser',
'🏖️': 'strand beach',
'🛣️': 'straße laufen strecke',
'🧭': 'kompass navigation orientierung',
'🏕️': 'camping zelten outdoor',
'⛺': 'zelt camping',
// —— Yoga / Geist ——
'🪷': 'lotus blume meditation',
'☯️': 'yin yang',
'🕉️': 'hindu om meditation',
'🙏': 'beten danken namaste',
'🧎': 'knien',
'🧍': 'stehen',
'💭': 'gedanke idee',
'📿': 'gebetskette',
'🎼': 'noten musik',
'🎹': 'klavier keyboard piano',
'🥁': 'schlagzeug trommel',
'🎸': 'gitarre',
'🎺': 'trompete blasinstrument',
'🔔': 'glocke',
'✨': 'sterne glitzer',
'🌟': 'stern glanz',
'💫': 'schwindel meteor',
'🔮': 'kristallkugel',
// —— Outdoor —— (Stichworte zu Bergen/Wetter)
'⛰️': 'berg berge alpen',
'🏔️': 'schneeberg gipfel',
'🗻': 'fuji vulkan berg',
'🌋': 'vulkan lava',
'🗺️': 'karte landkarte',
'🌲': 'wald tannenbaum',
'🌳': 'baum baumpark',
'🌴': 'palme urlaub',
'🍃': 'blatt grün frühling',
'🍂': 'herbst blatt',
'🌿': 'kraut pflanze',
'☘️': 'klee glück',
'🪨': 'stein fels',
'🏞️': 'nationalpark natur',
'🏜️': 'wüste',
'🌅': 'sonnenaufgang morgenrot',
'🌄': 'sonne berg horizont',
'🌈': 'regenbogen',
'⛅': 'wolke wetter',
'🌤️': 'sonne wolke',
'☀️': 'sonne sonnenschein',
'🌙': 'mond nacht',
'⭐': 'stern',
'🌠': 'sternschnuppe',
'❄️': 'schnee winter frost',
'☃️': 'schneemann',
'⛄': 'schneemann kalt',
// —— Körper / Medizin ——
'💪': 'muskel kraft arm bizeps fit',
'🦾': 'prothese arm roboter',
'🦵': 'bein knie',
'🦶': 'fuß zehen',
'🖐️': 'hand fünf',
'✋': 'hand stop',
'👣': 'fußspuren laufen',
'❤️': 'herz liebe health',
'🩷': 'herz pink',
'💙': 'herz blau',
'💚': 'herz grün vegan gesund',
'🫀': 'herz organ anatomie',
'🫁': 'lunge atem',
'🧠': 'gehirn denken kognition',
'👁️': 'auge sehen',
'👂': 'ohr hören',
'🦷': 'zahn zahnarzt',
'🦴': 'knochen skelett',
'🧬': 'dna genetik',
'⚕️': 'medizin asclepius arzt',
'🩺': 'stethoskop arzt',
'🩹': 'pflaster verband',
'🩼': 'krücke verletzt',
'💊': 'tablette medikament',
'🌡️': 'fieberthermometer temperatur',
'🔬': 'mikroskop labor',
'🧪': 'reagenzglas chemie',
'🧫': 'petrischale',
'♿': 'rollstuhl barrierefrei',
'⚖️': 'waage gerechtigkeit gewicht',
'📏': 'lineal messen',
'📐': 'winkel geometrie',
// —— Ernährung (Auswahl häufige Begriffe) ——
'🍎': 'apfel apple obst',
'🍐': 'birne pear',
'🍊': 'orange mandarine zitrus',
'🍋': 'zitrone lemon',
'🍌': 'banane banana',
'🍉': 'wassermelone melone',
'🍇': 'trauben weintrauben',
'🍓': 'erdbeere',
'🫐': 'heidelbeeren blaubeeren',
'🍒': 'kirschen',
'🍑': 'pfirsich',
'🥭': 'mango',
'🍍': 'ananas',
'🥝': 'kiwi',
'🍅': 'tomate',
'🥑': 'avocado',
'🥦': 'brokkoli',
'🥬': 'salat blattspinat',
'🥒': 'gurke',
'🌶️': 'chili scharf peperoni',
'🫑': 'paprika',
'🌽': 'mais',
'🥕': 'möhre karotte',
'🫒': 'olive',
'🧄': 'knoblauch',
'🧅': 'zwiebel',
'🥔': 'kartoffel',
'🍠': 'süßkartoffel',
'🥐': 'croissant frühstück',
'🍞': 'brot toast',
'🥖': 'baguette',
'🥨': 'breze brezel',
'🧀': 'käse',
'🥚': 'ei eier',
'🍳': 'braten pfanne frühstück',
'🧈': 'butter',
'🥞': 'pfannkuchen pancakes',
'🧇': 'waffel waffle',
'🥓': 'speck bacon',
'🥩': 'steak fleisch',
'🍗': 'hähnchenkeule geflügel',
'🍖': 'fleisch knochen',
'🌭': 'hotdog wurst',
'🍔': 'burger hamburger',
'🍟': 'pommes fritten',
'🍕': 'pizza',
'🫓': 'fladenbrot naan',
'🥙': 'döner wrap',
'🌮': 'taco',
'🌯': 'burrito',
'🥗': 'salat bowl',
'🍝': 'pasta spaghetti',
'🍜': 'suppe nudeln ramen',
'🍲': 'eintopf topf',
'🍛': 'curry reis',
'🍣': 'sushi',
'🍱': 'bento lunchbox',
'🥟': 'dumpling gyoza',
'🦪': 'austern',
'🍤': 'garnelen tempura',
'🍙': 'onigiri reisbällchen',
'🍚': 'reis bowl',
'🍘': 'reiscracker',
'🍥': 'narutomaki fischkuchen',
'🥠': 'glückskeks',
'🥮': 'mondkuchen',
'🍢': 'oden spieß',
'🍡': 'dango',
'🍧': 'wassereis shaved ice',
'🍨': 'eiscreme',
'🍦': 'softeis eis',
'🥧': 'kuchen tarte pie',
'🧁': 'cupcake muffin',
'🍰': 'torte kuchen slice',
'🎂': 'geburtstag torte',
'🍮': 'pudding dessert',
'🍭': 'lutscher lolly',
'🍬': 'bonbon süßigkeit',
'🍫': 'schokolade riegel',
'🍿': 'popcorn kino',
'🍩': 'donut',
'🍪': 'keks cookie',
'🌰': 'kastanie',
'🥜': 'erdnuss nüsse',
'🍯': 'honig',
'🥛': 'milch',
'🍼': 'baby flasche',
'🫖': 'teekanne tee',
'☕': 'kaffee espresso',
'🍵': 'matcha tee grüntee',
'🧃': 'saft packung',
'🥤': 'becher strohhalm',
'🧋': 'bubble tea boba',
'🍶': 'sake',
'🍺': 'bier krug',
'🍻': 'anstoßen bier',
'🥂': 'sekt champagner anstoßen',
'🍷': 'wein glas',
'🥃': 'whiskey tumbler',
'🍸': 'cocktail martini',
'🍹': 'cocktail drink',
'🧉': 'mate',
'🍾': 'sekt flasche party',
'💧': 'wasser trinken hydrieren',
'🧊': 'eiswürfel kalt',
// —— Schlaf ——
'😴': 'schlafen müde schlaf',
'🛌': 'bett schlafen',
'🛏️': 'bett',
'💤': 'zzz schnarchen',
'🌛': 'mond halbmond',
'🌜': 'mond mondgesicht',
'💆': 'massage wellness',
'🧴': 'lotion cream',
'🛁': 'badewanne bad',
'🚿': 'dusche',
'🪥': 'zahnbürste hygiene',
'🩴': 'badelatschen flipflop',
'🕯️': 'kerze ruhe',
// —— Smileys ——
'😊': 'lächeln glücklich',
'🙂': 'leichtes lächeln',
'😌': 'zufrieden erleichtert',
'😎': 'cool sonnenbrille',
'🤩': 'star augen begeistert',
'🥳': 'party hut feier',
'😤': 'stolz triumphiert',
'💯': 'hundert prozent perfekt',
'🙌': 'jubel hände',
'👏': 'applaus klatschen',
'🤝': 'handschlag deal',
'👍': 'daumen hoch gut',
'👎': 'daumen runter schlecht',
'✊': 'faust solidarität',
'🤛': 'faust links',
'🤜': 'faust rechts',
'😅': 'schweiß awkward',
'🤔': 'nachdenken frage',
'🧐': 'monokel prüfen',
'😇': 'heilig engel',
// —— Tiere (Stichworte) ——
'🐶': 'hund dog',
'🐱': 'katze cat',
'🐭': 'maus mouse',
'🐹': 'hamster',
'🐰': 'hase kaninchen',
'🦊': 'fuchs',
'🐻': 'bär',
'🐼': 'panda',
'🐨': 'koala',
'🐯': 'tiger',
'🦁': 'löwe',
'🐮': 'kuh rind',
'🐷': 'schwein',
'🐸': 'frosch',
'🐵': 'affe',
'🐔': 'huhn',
'🐧': 'pinguin',
'🐦': 'vogel',
'🐤': 'küken',
'🦆': 'ente',
'🦅': 'adler',
'🦉': 'eule',
'🦇': 'fledermaus',
'🐺': 'wolf',
'🐗': 'wildschwein keiler',
'🐴': 'pferd pony',
'🦄': 'einhorn',
'🐝': 'biene',
'🐛': 'raupe',
'🦋': 'schmetterling',
'🐌': 'schnecke',
'🐞': 'marienkäfer',
'🐜': 'ameise',
'🦗': 'grille',
'🕷️': 'spinne',
'🦂': 'skorpion',
'🐢': 'schildkröte',
'🐍': 'schlange',
'🦎': 'eidechse',
'🦖': 't-rex dinosaurier',
'🦕': 'dino langhals',
'🐙': 'oktopus krake',
'🦑': 'tintenfisch',
'🦐': 'garnele',
'🦞': 'hummer',
'🐠': 'tropenfisch',
'🐟': 'fisch',
'🐬': 'delfin',
'🐳': 'wal bläst',
'🐋': 'wal',
'🦈': 'hai',
'🐊': 'krokodil alligator',
// —— Symbole ——
'📊': 'diagramm balken statistik',
'📈': 'chart steigend trend',
'📉': 'chart fallend',
'🧮': 'abacus rechenbrett',
'📋': 'clipboard checkliste',
'📌': 'pin pinnwand',
'📍': 'ort marker standort',
'🔖': 'lesezeichen bookmark',
'🏷️': 'etikett label',
'✏️': 'bleistift schreiben',
'✒️': 'feder',
'🖊️': 'kugelschreiber',
'📎': 'büroklammer',
'🔗': 'link verknüpfung',
'⛓️': 'kette',
'🔒': 'schloss zu sicherheit',
'🔓': 'schloss offen',
'🔑': 'schlüssel',
'🗝️': 'altschlüssel',
'🔨': 'hammer',
'🛠️': 'werkzeug',
'⚙️': 'zahnrad einstellungen',
'🧰': 'werkzeugkasten',
'💡': 'idee glühbirne lampe',
'🔦': 'taschenlampe',
'🏮': 'laterne',
'🪔': 'öllampe diwali',
'📣': 'megaphone megafon',
'📢': 'lautsprecher',
'🔔': 'benachrichtigung glocke',
'🔕': 'lautlos stumm',
'⏱️': 'stoppuhr zeit',
'⏰': 'wecker uhr',
'🕐': 'uhr eins zeit',
'📅': 'kalender datum',
'🗓️': 'kalender spiral',
'✅': 'häkchen erledigt ok',
'☑️': 'box angehakt',
'✔️': 'check mark',
'❌': 'kreuz nein fehler',
'⭕': 'kreis groß',
'❗': 'ausrufezeichen wichtig',
'❓': 'fragezeichen',
'💬': 'sprechblase chat',
'🗨️': 'sprechblase links',
'📝': 'memo notizen',
'📖': 'buch lesen',
'🪄': 'zauberstab magie',
'🎪': 'zirkus zelt',
'🎭': 'theater masken',
'🎬': 'film klappe',
'🎨': 'palette malen kunst',
'🖼️': 'bild rahmen',
'🧩': 'puzzle teil',
'♟️': 'schach bauer',
'🎲': 'würfel zufall spiel',
'🧸': 'teddy bär spielzeug',
// —— Fahrzeuge ——
'🚗': 'auto pkw',
'🚕': 'taxi',
'🚙': 'suv geländewagen',
'🚌': 'bus',
'🚎': 'oberleitungsbus trolley',
'🏎️': 'rennwagen formel',
'🚓': 'polizei streifenwagen',
'🚑': 'krankenwagen rettung',
'🚒': 'feuerwehr',
'🚐': 'kleinbus',
'🛻': 'pickup',
'🚚': 'lkw lieferwagen',
'🚛': 'sattelzug',
'🚜': 'traktor',
'🛵': 'roller motorroller',
'🏍️': 'motorrad',
'🛺': 'rikscha',
'🚲': 'fahrrad',
'🛴': 'tretroller scooter',
'🚁': 'helikopter hubschrauber',
'✈️': 'flugzeug',
'🛫': 'abflug',
'🛬': 'landung',
'🪂': 'fallschirm parachute',
'🚀': 'rakete startup',
'🛶': 'boot kanu',
'🚤': 'speedboot',
'🛥️': 'motorboot',
'🛳️': 'kreuzfahrtschiff',
'⛴️': 'fähre ferry',
'🚢': 'frachtschiff',
'⚓': 'anker hafen',
'🗼': 'turm fernsehturm'
}
/**
* @param {string} groupLabel
*/
export function slugifyGroupLabel(groupLabel) {
return groupLabel
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[()]/g, ' ')
.replace(/[^\p{L}\p{N}]+/gu, ' ')
.replace(/\s+/g, ' ')
.trim()
}
/**
* @param {string} emoji
* @param {string} groupLabel
*/
export function haystackForEmoji(emoji, groupLabel) {
const extra = EMOJI_KEYWORDS[emoji] || ''
const slug = slugifyGroupLabel(groupLabel)
return `${extra} ${slug}`.replace(/\s+/g, ' ').trim()
}
/**
* Alle Such-Tokens müssen im Haystack vorkommen (UND).
* @param {string} haystack
* @param {string} query
*/
export function matchesEmojiSearch(haystack, query) {
const q = query.trim().toLowerCase()
if (!q) return true
const norm = haystack
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
const tokens = q
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.split(/\s+/)
.filter(Boolean)
return tokens.every((t) => norm.includes(t))
}

View File

@ -0,0 +1,71 @@
import ReactFlow, { Background, Controls, MiniMap } from 'reactflow'
import 'reactflow/dist/style.css'
/**
* WorkflowCanvas - React Flow Wrapper Component
*
* Kapselt React Flow Setup (Background, Controls, MiniMap).
* Separation of Concerns: Canvas-Logik getrennt von Editor-Orchestrierung.
*
* Props:
* - nodes: Array of React Flow nodes
* - edges: Array of React Flow edges
* - nodeTypes: Object mapping node type to component
* - onNodesChange: Handler for node changes (drag, delete, etc.)
* - onEdgesChange: Handler for edge changes
* - onConnect: Handler for new edge connections
* - onNodeClick: Handler for node selection
*/
export function WorkflowCanvas({
nodes,
edges,
nodeTypes,
onNodesChange,
onEdgesChange,
onConnect,
onNodeClick
}) {
return (
<div style={{ width: '100%', height: '100%' }}>
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onNodeClick={onNodeClick}
nodeTypes={nodeTypes}
fitView
className="workflow-canvas"
minZoom={0.2}
maxZoom={2}
defaultEdgeOptions={{
animated: false,
style: { strokeWidth: 2 },
deletable: true
}}
>
<Background
variant="dots"
gap={16}
size={1}
color="var(--border)"
/>
<Controls
showZoom={true}
showFitView={true}
showInteractive={true}
/>
<MiniMap
nodeStrokeWidth={3}
zoomable
pannable
style={{
backgroundColor: 'var(--surface)',
border: '1px solid var(--border)'
}}
/>
</ReactFlow>
</div>
)
}

View File

@ -0,0 +1,51 @@
import { Handle, Position } from 'reactflow'
/**
* AnalysisNode - KI-Prompt Knoten
*
* Properties:
* - data.label: Node-Label
* - data.prompt_id: ID des referenzierten Basis-Prompts
* - data.prompt_name: Name des Prompts (optional, für Display)
* - data.questions: Array von Question Augmentations
* - selected: Boolean
*/
export function AnalysisNode({ data, selected }) {
const hasQuestions = data.questions?.length > 0
const promptName = data.prompt_name || (data.prompt_id ? `Prompt #${data.prompt_id}` : 'Kein Prompt')
const questionCount = data.questions?.length || 0
return (
<div className={`workflow-node analysis-node ${selected ? 'selected' : ''}`}>
<div className="node-header">
<div className="node-icon">🤖</div>
<div className="node-label">{data.label || 'Analyse'}</div>
</div>
<div className="node-body">
<div className="prompt-name" title={promptName}>
{promptName}
</div>
{hasQuestions && (
<div className="questions-indicator">
📋 {questionCount} {questionCount === 1 ? 'Frage' : 'Fragen'}
</div>
)}
</div>
<Handle
type="target"
position={Position.Top}
id="in"
style={{ background: 'var(--accent)' }}
/>
<Handle
type="source"
position={Position.Bottom}
id="out"
style={{ background: 'var(--accent)' }}
/>
</div>
)
}

View File

@ -0,0 +1,25 @@
import { Handle, Position } from 'reactflow'
/**
* EndNode - Workflow Austritt
*
* Properties:
* - data.label: Node-Label (default: "Ende")
* - selected: Boolean (Node ist ausgewählt)
*/
export function EndNode({ data, selected }) {
return (
<div className={`workflow-node end-node ${selected ? 'selected' : ''}`}>
<div className="node-icon">🏁</div>
<div className="node-label">{data.label || 'Ende'}</div>
{/* Nur Target Handle (kein Source, da Endpunkt) */}
<Handle
type="target"
position={Position.Top}
id="in"
style={{ background: 'var(--danger)' }}
/>
</div>
)
}

View File

@ -0,0 +1,74 @@
import { Handle, Position } from 'reactflow'
/**
* JoinNode - Pfad-Konsolidierung
*
* Properties:
* - data.label: Node-Label
* - data.join_strategy: 'wait_all' | 'wait_any' | 'best_effort'
* - data.skip_handling: 'ignore_skipped' | 'use_placeholder' | 'require_minimum'
* - selected: Boolean
*/
export function JoinNode({ data, selected }) {
const strategy = data.join_strategy || 'wait_all'
const skipHandling = data.skip_handling || 'ignore_skipped'
// Strategy Display Names
const strategyLabels = {
'wait_all': 'Alle warten',
'wait_any': 'Beliebig',
'best_effort': 'Best Effort'
}
const skipLabels = {
'ignore_skipped': 'Ignorieren',
'use_placeholder': 'Platzhalter',
'require_minimum': 'Minimum'
}
return (
<div className={`workflow-node join-node ${selected ? 'selected' : ''}`}>
<div className="node-header">
<div className="node-icon">🔀</div>
<div className="node-label">{data.label || 'Join'}</div>
</div>
<div className="node-body">
<div className="strategy">
<strong>Strategie:</strong> {strategyLabels[strategy] || strategy}
</div>
<div className="skip-handling">
<strong>Skip:</strong> {skipLabels[skipHandling] || skipHandling}
</div>
</div>
{/* Mehrere Target Handles für eingehende Pfade */}
<Handle
type="target"
position={Position.Top}
id="path_1"
style={{ left: '25%', background: '#17A2B8' }}
/>
<Handle
type="target"
position={Position.Top}
id="path_2"
style={{ left: '50%', background: '#17A2B8' }}
/>
<Handle
type="target"
position={Position.Top}
id="path_3"
style={{ left: '75%', background: '#17A2B8' }}
/>
{/* Ein Source Handle für konsolidierten Ausgang */}
<Handle
type="source"
position={Position.Bottom}
id="out"
style={{ background: '#17A2B8' }}
/>
</div>
)
}

View File

@ -0,0 +1,68 @@
import { Handle, Position } from 'reactflow'
/**
* LogicNode - Bedingungs-Knoten (If-Then-Else)
*
* Properties:
* - data.label: Node-Label
* - data.condition: Logic Expression Object
* - selected: Boolean
*/
export function LogicNode({ data, selected }) {
const hasCondition = !!data.condition && !!data.condition.operator
// Condition Summary (vereinfacht für Display)
const getConditionSummary = () => {
if (!hasCondition) return 'Keine Bedingung'
const op = data.condition.operator?.toUpperCase()
const operandCount = data.condition.operands?.length || 0
if (op === 'AND' || op === 'OR' || op === 'NOT') {
return `${op} (${operandCount} Bedingungen)`
}
// Simple condition (ref, operator, value)
if (data.condition.ref) {
return `${data.condition.ref} ${data.condition.operator} ...`
}
return 'Bedingung definiert'
}
return (
<div className={`workflow-node logic-node ${selected ? 'selected' : ''}`}>
<div className="node-header">
<div className="node-icon"></div>
<div className="node-label">{data.label || 'Logik'}</div>
</div>
<div className="node-body">
<div className={`condition-summary ${hasCondition ? 'has-condition' : 'no-condition'}`}>
{getConditionSummary()}
</div>
</div>
<Handle
type="target"
position={Position.Top}
id="in"
style={{ background: '#FFC107' }}
/>
{/* Zwei Source Handles für True/False Pfade */}
<Handle
type="source"
position={Position.Bottom}
id="true"
style={{ left: '33%', background: '#4CAF50' }}
/>
<Handle
type="source"
position={Position.Bottom}
id="false"
style={{ left: '66%', background: '#F44336' }}
/>
</div>
)
}

View File

@ -0,0 +1,25 @@
import { Handle, Position } from 'reactflow'
/**
* StartNode - Workflow Einstiegspunkt
*
* Properties:
* - data.label: Node-Label (default: "Start")
* - selected: Boolean (Node ist ausgewählt)
*/
export function StartNode({ data, selected }) {
return (
<div className={`workflow-node start-node ${selected ? 'selected' : ''}`}>
<div className="node-icon">🚀</div>
<div className="node-label">{data.label || 'Start'}</div>
{/* Nur Source Handle (kein Target, da Einstiegspunkt) */}
<Handle
type="source"
position={Position.Bottom}
id="out"
style={{ background: 'var(--accent)' }}
/>
</div>
)
}

View File

@ -0,0 +1,77 @@
/**
* FallbackConfig - Fallback-Strategie Konfiguration
*
* Props:
* - node: React Flow Node object
* - edges: Array of React Flow edges
* - nodes: Array of React Flow nodes (for label lookup)
* - onChange: (nodeId, updates) => void
*/
export function FallbackConfig({ node, edges, nodes, onChange }) {
const fallbackStrategy = node.data.fallback_strategy || 'conservative_skip'
const fallbackEdge = node.data.fallback_edge || null
// Outgoing Edges von diesem Node
const outgoingEdges = edges.filter(e => e.source === node.id)
// Helper: Get node label by ID
const getNodeLabel = (nodeId) => {
const targetNode = nodes.find(n => n.id === nodeId)
return targetNode?.data?.label || nodeId
}
const handleStrategyChange = (e) => {
const strategy = e.target.value
onChange(node.id, { fallback_strategy: strategy })
// Reset fallback_edge wenn strategy geändert wird
if (strategy === 'conservative_skip' || strategy === 'document_only') {
onChange(node.id, { fallback_edge: null })
}
}
const handleEdgeChange = (e) => {
onChange(node.id, { fallback_edge: e.target.value || null })
}
return (
<div className="config-section">
<h3>Fallback-Strategie</h3>
<label>Strategie bei unklaren Signalen</label>
<select value={fallbackStrategy} onChange={handleStrategyChange}>
<option value="conservative_skip">Konservativ überspringen</option>
<option value="default_path">Standardpfad ausführen</option>
<option value="uncertainty_path">Unsicherheits-Pfad</option>
<option value="document_only">Nur dokumentieren</option>
</select>
<div className="help-text">
{fallbackStrategy === 'conservative_skip' && 'Bei Unklarheit: Pfad nicht routen.'}
{fallbackStrategy === 'default_path' && 'Bei Unklarheit: Definierter Standardpfad wird ausgeführt.'}
{fallbackStrategy === 'uncertainty_path' && 'Bei Unklarheit: Expliziter Klärungspfad.'}
{fallbackStrategy === 'document_only' && 'Unklarheit dokumentieren, aber kein Routing.'}
</div>
{(fallbackStrategy === 'default_path' || fallbackStrategy === 'uncertainty_path') && (
<>
<label style={{ marginTop: '16px' }}>Ziel-Edge</label>
<select value={fallbackEdge || ''} onChange={handleEdgeChange}>
<option value="">-- Kante wählen --</option>
{outgoingEdges.map(e => (
<option key={e.id} value={e.id}>
{e.data?.label || `Edge → ${getNodeLabel(e.target)}`}
</option>
))}
</select>
{outgoingEdges.length === 0 && (
<div className="help-text" style={{ color: 'var(--danger)' }}>
Keine ausgehenden Kanten gefunden. Bitte verbinden Sie diesen Node zuerst.
</div>
)}
</>
)}
</div>
)
}

View File

@ -0,0 +1,67 @@
/**
* JoinConfig - Konfiguration für Join Nodes
*
* Props:
* - node: React Flow Node object (type='join')
* - onChange: (nodeId, updates) => void
*/
export function JoinConfig({ node, onChange }) {
const joinStrategy = node.data.join_strategy || 'wait_all'
const skipHandling = node.data.skip_handling || 'ignore_skipped'
const minPaths = node.data.min_paths || 2
const handleStrategyChange = (e) => {
onChange(node.id, { join_strategy: e.target.value })
}
const handleSkipChange = (e) => {
onChange(node.id, { skip_handling: e.target.value })
}
const handleMinPathsChange = (e) => {
const value = parseInt(e.target.value) || 2
onChange(node.id, { min_paths: value })
}
return (
<div className="config-section">
<h3>Join-Konfiguration</h3>
<label>Join-Strategie</label>
<select value={joinStrategy} onChange={handleStrategyChange}>
<option value="wait_all">Alle Pfade warten (wait_all)</option>
<option value="wait_any">Mindestens ein Pfad (wait_any)</option>
<option value="best_effort">Verfügbare nutzen (best_effort)</option>
</select>
<div className="help-text">
{joinStrategy === 'wait_all' && 'Wartet auf alle eingehenden Pfade. Fehler wenn einer fehlt.'}
{joinStrategy === 'wait_any' && 'Wartet auf mindestens einen Pfad. Erste verfügbare Ausführung.'}
{joinStrategy === 'best_effort' && 'Fehlertoleranz: Nutzt verfügbare Pfade, auch wenn nicht alle da sind.'}
</div>
<label style={{ marginTop: '16px' }}>Skip-Handling</label>
<select value={skipHandling} onChange={handleSkipChange}>
<option value="ignore_skipped">Übersprungene ignorieren</option>
<option value="use_placeholder">Platzhalter verwenden</option>
<option value="require_minimum">Mindestanzahl erforderlich</option>
</select>
{skipHandling === 'require_minimum' && (
<>
<label style={{ marginTop: '12px' }}>Mindestanzahl Pfade</label>
<input
type="number"
min="1"
value={minPaths}
onChange={handleMinPathsChange}
/>
</>
)}
<div className="help-text" style={{ marginTop: '8px', fontSize: '11px', color: 'var(--text3)' }}>
💡 Phase 4: Path Consolidation
</div>
</div>
)
}

View File

@ -0,0 +1,353 @@
import { useState, useEffect, useMemo } from 'react'
/**
* LogicExpressionEditor - Visueller Baukasten für Logik-Bedingungen
*
* KEIN JSON-Editor! Visuelles Drag & Drop Interface.
*
* Props:
* - node: React Flow Node object (type='logic')
* - nodes: Array of all nodes (for signal reference lookup)
* - edges: Array of all edges (for topological sort)
* - onChange: (nodeId, updates) => void
*/
export function LogicExpressionEditor({ node, nodes, edges, onChange }) {
const [expression, setExpression] = useState(node.data.condition || {
operator: 'and',
operands: []
})
// Verfügbare Signale aus vorangegangenen Nodes
const availableSignals = useMemo(() => {
return getAvailableSignals(node.id, nodes, edges)
}, [node.id, nodes, edges])
// Sync to parent when expression changes
useEffect(() => {
onChange(node.id, { condition: expression })
}, [expression])
const handleOperatorChange = (e) => {
setExpression({ ...expression, operator: e.target.value })
}
const handleAddCondition = () => {
setExpression({
...expression,
operands: [...(expression.operands || []), {
ref: '',
operator: 'eq',
value: ''
}]
})
}
const handleAddGroup = () => {
setExpression({
...expression,
operands: [...(expression.operands || []), {
operator: 'and',
operands: []
}]
})
}
const handleOperandChange = (index, updates) => {
const updated = [...(expression.operands || [])]
updated[index] = { ...updated[index], ...updates }
setExpression({ ...expression, operands: updated })
}
const handleRemoveOperand = (index) => {
setExpression({
...expression,
operands: (expression.operands || []).filter((_, i) => i !== index)
})
}
return (
<div className="config-section">
<h3>Logik-Bedingung</h3>
{availableSignals.length === 0 && (
<div className="help-text" style={{ color: 'var(--danger)', marginBottom: '12px' }}>
Keine Signale verfügbar. Fügen Sie zuerst einen Analysis-Node VOR diesem Node hinzu.
</div>
)}
<div className="logic-root">
<label>Verknüpfung (Root-Operator)</label>
<select value={expression.operator || 'and'} onChange={handleOperatorChange}>
<option value="and">UND (alle müssen zutreffen)</option>
<option value="or">ODER (mind. eine muss zutreffen)</option>
<option value="not">NICHT (umkehren)</option>
</select>
</div>
<div className="logic-operands">
{(expression.operands || []).map((operand, idx) => (
<ConditionBlock
key={idx}
operand={operand}
availableSignals={availableSignals}
onChange={(updates) => handleOperandChange(idx, updates)}
onRemove={() => handleRemoveOperand(idx)}
/>
))}
{(!expression.operands || expression.operands.length === 0) && (
<div className="help-text" style={{ marginBottom: '12px' }}>
Keine Bedingungen definiert. Fügen Sie Bedingungen oder Gruppen hinzu.
</div>
)}
</div>
<div className="logic-actions">
<button className="btn-secondary" onClick={handleAddCondition}>
+ Bedingung
</button>
<button className="btn-secondary" onClick={handleAddGroup}>
+ Gruppe (AND/OR)
</button>
</div>
<div className="help-text" style={{ marginTop: '12px', fontSize: '11px', color: 'var(--text3)' }}>
💡 Signale: {availableSignals.length} verfügbar
</div>
</div>
)
}
/**
* ConditionBlock - Einzelne Bedingung oder Gruppe (rekursiv)
*/
function ConditionBlock({ operand, availableSignals, onChange, onRemove }) {
// Verschachtelte Gruppe? (AND/OR/NOT)
const isGroup = operand.operator === 'and' || operand.operator === 'or' || operand.operator === 'not'
if (isGroup) {
return (
<div className="condition-group">
<div className="group-header">
<select
value={operand.operator}
onChange={(e) => onChange({ operator: e.target.value })}
style={{ flex: 1 }}
>
<option value="and">UND</option>
<option value="or">ODER</option>
<option value="not">NICHT</option>
</select>
<button className="btn-icon" onClick={onRemove}>🗑</button>
</div>
{/* Rekursiv: Nested operands */}
<div style={{ paddingLeft: '12px', borderLeft: '3px solid var(--accent)' }}>
{(operand.operands || []).map((subOp, idx) => (
<ConditionBlock
key={idx}
operand={subOp}
availableSignals={availableSignals}
onChange={(updates) => {
const updated = [...(operand.operands || [])]
updated[idx] = { ...updated[idx], ...updates }
onChange({ operands: updated })
}}
onRemove={() => {
onChange({ operands: (operand.operands || []).filter((_, i) => i !== idx) })
}}
/>
))}
<button
className="btn-secondary"
onClick={() => {
onChange({
operands: [...(operand.operands || []), { ref: '', operator: 'eq', value: '' }]
})
}}
style={{ marginTop: '8px', fontSize: '12px' }}
>
+ Bedingung
</button>
</div>
</div>
)
}
// Einfache Bedingung
const selectedSignal = availableSignals.find(s => s.ref === operand.ref)
return (
<div className="condition-simple">
{/* Signal-Referenz (Dropdown) */}
<select
value={operand.ref || ''}
onChange={(e) => onChange({ ref: e.target.value })}
className="signal-select"
style={{ flex: 2 }}
>
<option value="">-- Signal wählen --</option>
{availableSignals.map(sig => (
<option key={sig.ref} value={sig.ref}>
{sig.label}
</option>
))}
</select>
{/* Operator */}
<select
value={operand.operator || 'eq'}
onChange={(e) => onChange({ operator: e.target.value })}
className="operator-select"
style={{ flex: 1 }}
>
<option value="eq">==</option>
<option value="neq">!=</option>
<option value="in">IN</option>
<option value="not_in">NOT IN</option>
<option value="gt">&gt;</option>
<option value="lt">&lt;</option>
<option value="gte">&gt;=</option>
<option value="lte">&lt;=</option>
<option value="contains">CONTAINS</option>
</select>
{/* Wert (abhängig von Operator) */}
{(operand.operator === 'in' || operand.operator === 'not_in') ? (
<MultiSelect
options={selectedSignal?.spectrum || []}
value={operand.value || []}
onChange={(val) => onChange({ value: val })}
placeholder="Werte wählen"
/>
) : (
<input
type="text"
value={operand.value || ''}
onChange={(e) => onChange({ value: e.target.value })}
placeholder="Wert"
className="value-input"
style={{ flex: 1 }}
/>
)}
<button className="btn-icon" onClick={onRemove}>🗑</button>
</div>
)
}
/**
* MultiSelect - Multi-Auswahl für IN/NOT_IN Operatoren
*/
function MultiSelect({ options, value, onChange, placeholder }) {
const [isOpen, setIsOpen] = useState(false)
const selected = Array.isArray(value) ? value : []
const handleToggle = (option) => {
if (selected.includes(option)) {
onChange(selected.filter(v => v !== option))
} else {
onChange([...selected, option])
}
}
return (
<div className="multi-select" style={{ flex: 1, position: 'relative' }}>
<div
className="multi-select-display"
onClick={() => setIsOpen(!isOpen)}
style={{
padding: '6px',
border: '1px solid var(--border)',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '12px',
background: 'var(--bg)'
}}
>
{selected.length > 0 ? selected.join(', ') : placeholder}
</div>
{isOpen && (
<div
className="multi-select-dropdown"
style={{
position: 'absolute',
top: '100%',
left: 0,
right: 0,
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: '4px',
marginTop: '4px',
maxHeight: '200px',
overflowY: 'auto',
zIndex: 1000
}}
>
{options.map((option, i) => (
<div
key={i}
onClick={() => handleToggle(option)}
style={{
padding: '8px',
cursor: 'pointer',
fontSize: '12px',
background: selected.includes(option) ? 'var(--accent)' : 'transparent',
color: selected.includes(option) ? 'white' : 'var(--text1)'
}}
>
{selected.includes(option) && '✓ '}
{option}
</div>
))}
{options.length === 0 && (
<div style={{ padding: '8px', fontSize: '12px', color: 'var(--text3)' }}>
Keine Optionen verfügbar
</div>
)}
</div>
)}
</div>
)
}
/**
* Helper: Verfügbare Signale aus vorangegangenen Nodes extrahieren
*/
function getAvailableSignals(nodeId, nodes, edges) {
// Topologische Sortierung: Alle Nodes VOR diesem Node
const predecessors = new Set()
function collectPredecessors(currentId) {
const incoming = edges.filter(e => e.target === currentId)
for (const edge of incoming) {
if (!predecessors.has(edge.source)) {
predecessors.add(edge.source)
collectPredecessors(edge.source)
}
}
}
collectPredecessors(nodeId)
// Signale extrahieren (nur von ANALYSIS Nodes)
const signals = []
for (const predId of predecessors) {
const predNode = nodes.find(n => n.id === predId)
if (predNode && predNode.type === 'analysis') {
const questions = predNode.data.questions || []
for (const q of questions) {
signals.push({
ref: `${predId}.${q.id}`,
label: `${predNode.data.label}${q.question || q.id}`,
spectrum: q.answer_spectrum || []
})
}
}
}
return signals
}

View File

@ -0,0 +1,218 @@
import { useState, useEffect } from 'react'
/**
* QuestionAugmentationPanel - Fragenergänzungs-Konfiguration
*
* Props:
* - node: React Flow Node object (type='analysis')
* - onChange: (nodeId, updates) => void
*/
export function QuestionAugmentationPanel({ node, onChange }) {
const [questions, setQuestions] = useState(node.data.questions || [])
// Sync to parent when questions change
useEffect(() => {
onChange(node.id, { questions })
}, [questions])
const handleAddQuestion = () => {
const newQuestion = {
id: `q${Date.now()}`,
type: 'relevanz',
question: '',
answer_spectrum: []
}
setQuestions([...questions, newQuestion])
}
const handleRemoveQuestion = (index) => {
setQuestions(questions.filter((_, i) => i !== index))
}
const handleQuestionChange = (index, field, value) => {
const updated = [...questions]
updated[index] = { ...updated[index], [field]: value }
setQuestions(updated)
}
return (
<div className="config-section">
<h3>Fragenergänzung</h3>
{questions.length === 0 && (
<div className="help-text" style={{ marginBottom: '12px' }}>
Keine Fragen definiert. Fügen Sie Fragen hinzu, um Signale für Logik-Knoten zu erzeugen.
</div>
)}
{questions.map((q, idx) => (
<QuestionEditor
key={q.id}
question={q}
index={idx}
onChange={(field, value) => handleQuestionChange(idx, field, value)}
onRemove={() => handleRemoveQuestion(idx)}
/>
))}
<button className="btn-secondary btn-full" onClick={handleAddQuestion}>
+ Frage hinzufügen
</button>
</div>
)
}
/**
* QuestionEditor - Einzelne Frage editieren
*/
function QuestionEditor({ question, index, onChange, onRemove }) {
const [spectrumInput, setSpectrumInput] = useState('')
const handleAddAnswer = () => {
if (!spectrumInput.trim()) return
const newSpectrum = [...(question.answer_spectrum || []), spectrumInput.trim()]
onChange('answer_spectrum', newSpectrum)
setSpectrumInput('')
}
const handleRemoveAnswer = (answer) => {
const newSpectrum = question.answer_spectrum.filter(a => a !== answer)
onChange('answer_spectrum', newSpectrum)
}
const handleKeyPress = (e) => {
if (e.key === 'Enter') {
e.preventDefault()
handleAddAnswer()
}
}
return (
<div className="question-editor">
<div className="question-header">
<span>Frage {index + 1}</span>
<button className="btn-icon" onClick={onRemove} title="Frage löschen">
🗑
</button>
</div>
<label>Frage-ID</label>
<input
type="text"
value={question.id || ''}
onChange={(e) => onChange('id', e.target.value)}
placeholder="z.B. relevanz"
/>
<label>Fragetyp</label>
<select
value={question.type || 'relevanz'}
onChange={(e) => onChange('type', e.target.value)}
>
<option value="relevanz">Relevanz</option>
<option value="prioritaet">Priorität</option>
<option value="selektion">Selektion</option>
<option value="ausschluss">Ausschluss</option>
<option value="eskalation">Eskalation</option>
<option value="unsicherheit">Unsicherheit</option>
</select>
<label>Fragetext</label>
<textarea
value={question.question || ''}
onChange={(e) => onChange('question', e.target.value)}
placeholder="z.B. Ist diese Analyse relevant für den Nutzer?"
rows={3}
/>
<label>Antwortmöglichkeiten (mind. 2)</label>
<div className="tag-input-container">
<div className="tags">
{(question.answer_spectrum || []).map((answer, i) => (
<span key={i} className="tag">
{answer}
<button
onClick={() => handleRemoveAnswer(answer)}
className="tag-remove"
title="Entfernen"
>
×
</button>
</span>
))}
</div>
<div style={{ display: 'flex', gap: '8px', marginTop: '8px' }}>
<input
type="text"
value={spectrumInput}
onChange={(e) => setSpectrumInput(e.target.value)}
onKeyPress={handleKeyPress}
placeholder="z.B. ja, nein, unklar"
style={{ flex: 1 }}
/>
<button className="btn-secondary" onClick={handleAddAnswer}>
+
</button>
</div>
</div>
{question.answer_spectrum && question.answer_spectrum.length < 2 && (
<div className="help-text" style={{ color: 'var(--danger)' }}>
Mindestens 2 Antworten erforderlich
</div>
)}
</div>
)
}
// Zusätzliches CSS für Tags (inline styles)
const tagStyles = `
.tag-input-container {
margin-bottom: 8px;
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 8px;
}
.tag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
background: var(--accent);
color: white;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
}
.tag-remove {
background: none;
border: none;
color: white;
cursor: pointer;
font-size: 16px;
line-height: 1;
padding: 0;
margin-left: 2px;
opacity: 0.8;
}
.tag-remove:hover {
opacity: 1;
}
`
// Inject styles (nur einmal)
if (typeof document !== 'undefined' && !document.getElementById('question-panel-styles')) {
const styleEl = document.createElement('style')
styleEl.id = 'question-panel-styles'
styleEl.textContent = tagStyles
document.head.appendChild(styleEl)
}

View File

@ -0,0 +1,154 @@
/**
* Admin: Nur Gruppen in der Shell-Navigation; konkrete Seiten wählt man auf der Hub-Seite (/admin/g/:id).
* @typedef {{ to: string, label: string, description?: string }} AdminGroupItem
* @typedef {{ id: string, label: string, description: string, items: AdminGroupItem[] }} AdminGroup
*/
/** @type {AdminGroup[]} */
export const ADMIN_GROUPS = [
{
id: 'users',
label: 'Benutzerverwaltung',
description: 'Profile anlegen, Rollen setzen und Recovery-E-Mails pflegen.',
items: [
{
to: '/admin/users',
label: 'Profile & Rollen',
description: 'Neue Profile, Admin-Rolle, PIN und E-Mail pro Nutzer.',
},
],
},
{
id: 'features',
label: 'Features',
description: 'Feature-Registry, Kontingente und individuelle Overrides.',
items: [
{
to: '/admin/features',
label: 'Feature-Registry',
description: 'Features definieren und Kategorien zuordnen.',
},
{
to: '/admin/tier-limits',
label: 'Tier-Limits',
description: 'Limit-Matrix pro Tier bearbeiten.',
},
{
to: '/admin/user-restrictions',
label: 'User-Overrides',
description: 'Individuelle Feature-Limits setzen.',
},
],
},
{
id: 'subscription',
label: 'Subscription',
description: 'Tiers und Gutscheine für das Freemium-System.',
items: [
{ to: '/admin/tiers', label: 'Tiers', description: 'Abo-Stufen und Zuordnung.' },
{ to: '/admin/coupons', label: 'Coupons', description: 'Gutscheincodes verwalten.' },
],
},
{
id: 'training',
label: 'Trainingstypen',
description: 'Trainingstypen, Mappings und Trainings-Profile.',
items: [
{
to: '/admin/training-types',
label: 'Trainingstypen',
description: 'Kategorien und Typen verwalten.',
},
{
to: '/admin/activity-mappings',
label: 'Activity-Mappings',
description: 'Lernendes Zuordnungssystem (Sprache / Apple Health).',
},
{
to: '/admin/training-profiles',
label: 'Trainings-Profile',
description: 'Training-Type-Profile (#15).',
},
],
},
{
id: 'goals',
label: 'Ziele & Fokus',
description: 'Ziel-Typen und Focus Areas.',
items: [
{
to: '/admin/goal-types',
label: 'Ziel-Typen',
description: 'Custom Goal Types mit oder ohne Datenquelle.',
},
{
to: '/admin/focus-areas',
label: 'Focus Areas',
description: 'Dynamische Fokusbereiche und Kategorien.',
},
],
},
{
id: 'prompts',
label: 'KI-Prompts',
description: 'Pipeline- und Basis-Prompts für die KI-Analyse.',
items: [
{
to: '/admin/prompts',
label: 'KI-Prompts verwalten',
description: 'Prompts bearbeiten, Stages, Test & Export.',
},
],
},
{
id: 'system',
label: 'Basiseinstellungen',
description: 'System-E-Mail und Platzhalter-Metadaten.',
items: [
{
to: '/admin/system',
label: 'SMTP & Metadaten-Export',
description: 'SMTP-Status, Test-Mail und Placeholder-Katalog (JSON/ZIP).',
},
],
},
]
export function adminGroupHubPath(groupId) {
return `/admin/g/${groupId}`
}
/**
* Shell-Navigation: Übersicht + eine Zeile/Spalte pro Gruppe (ohne Einzelseiten).
* @typedef {{ id: string, label: string, to: string, end?: boolean }} AdminShellNavEntry
* @returns {AdminShellNavEntry[]}
*/
export function getAdminShellNavEntries() {
return [
{ id: 'overview', label: 'Übersicht', to: '/admin', end: true },
...ADMIN_GROUPS.map((g) => ({
id: g.id,
label: g.label,
to: adminGroupHubPath(g.id),
end: false,
})),
]
}
/** Aktiver Shell-Eintrag inkl. Leaf-Routen der Gruppe (z. B. /admin/features → Gruppe „Features“). */
export function adminShellEntryIsActive(pathname, entry) {
if (entry.id === 'overview') {
return pathname === '/admin'
}
const group = ADMIN_GROUPS.find((x) => x.id === entry.id)
if (!group) return false
if (pathname === adminGroupHubPath(group.id)) return true
return group.items.some((it) => pathname === it.to)
}
/** Anzahl Unterseiten für Chip-Badge (wie KI-Analyse). */
export function adminShellEntryItemCount(entry) {
if (entry.id === 'overview') return 0
const g = ADMIN_GROUPS.find((x) => x.id === entry.id)
return g ? g.items.length : 0
}

View File

@ -0,0 +1,43 @@
import {
LayoutDashboard,
PlusSquare,
TrendingUp,
BarChart2,
Settings,
Shield
} from 'lucide-react'
/**
* Eine Quelle für Hauptnavigation (Bottom-Nav + Desktop-Sidebar).
* @typedef {{ to: string, label: string, shortLabel?: string, end?: boolean, Icon: import('react').ForwardRefExoticComponent }} AppNavItem
*/
/** @returns {Omit<AppNavItem, 'Icon'>[]} */
function baseItems() {
return [
{ to: '/', label: 'Übersicht', end: true },
{ to: '/capture', label: 'Erfassen' },
{ to: '/history', label: 'Verlauf' },
{ to: '/analysis', label: 'Analyse' },
{ to: '/settings', label: 'Einstellungen', shortLabel: 'Einst.' }
]
}
/** @param {boolean} isAdmin */
export function getMainNavItems(isAdmin) {
const icons = [
LayoutDashboard,
PlusSquare,
TrendingUp,
BarChart2,
Settings
]
const raw = baseItems().map((item, i) => ({
...item,
Icon: icons[i]
}))
if (isAdmin) {
raw.push({ to: '/admin', label: 'Admin', end: false, Icon: Shield })
}
return raw
}

View File

@ -0,0 +1,103 @@
/**
* Erfassungs-Routen: Kachel-Hub + Sub-Navigation (Chip / Seitenleiste).
* Pfade müssen mit den Routes in App.jsx unter CaptureShell übereinstimmen.
*/
export const CAPTURE_HUB_TILES = [
{
icon: '⚖️',
label: 'Gewicht',
sub: 'Tägliche Gewichtseingabe',
to: '/weight',
color: '#378ADD',
},
{
icon: '🪄',
label: 'Assistent',
sub: 'Schritt-für-Schritt Messung (Umfänge & Caliper)',
to: '/wizard',
color: '#7F77DD',
highlight: true,
},
{
icon: '📏',
label: 'Umfänge',
sub: 'Hals, Brust, Taille, Bauch, Hüfte, Oberschenkel, Wade, Arm',
to: '/circum',
color: '#1D9E75',
},
{
icon: '📐',
label: 'Caliper',
sub: 'Körperfett per Hautfaltenmessung',
to: '/caliper',
color: '#D85A30',
},
{
icon: '🍽️',
label: 'Ernährung',
sub: 'FDDB CSV importieren',
to: '/nutrition',
color: '#EF9F27',
},
{
icon: '🏋️',
label: 'Aktivität',
sub: 'Training manuell oder Apple Health importieren',
to: '/activity',
color: '#D4537E',
},
{
icon: '🌙',
label: 'Schlaf',
sub: 'Schlafdaten erfassen oder Apple Health importieren',
to: '/sleep',
color: '#7B68EE',
},
{
icon: '🛌',
label: 'Ruhetage',
sub: 'Kraft-, Cardio-, oder Entspannungs-Ruhetag erfassen',
to: '/rest-days',
color: '#9B59B6',
},
{
icon: '❤️',
label: 'Vitalwerte',
sub: 'Ruhepuls und HRV morgens erfassen',
to: '/vitals',
color: '#E74C3C',
},
{
icon: '🎯',
label: 'Eigene Ziele',
sub: 'Fortschritte für individuelle Ziele erfassen',
to: '/custom-goals',
color: '#1D9E75',
},
{
icon: '📖',
label: 'Messanleitung',
sub: 'Wie und wo genau messen?',
to: '/guide',
color: '#888780',
},
]
/** Erster Eintrag: zurück zur Kachel-Übersicht */
const OVERVIEW_ENTRY = {
icon: '📋',
label: 'Übersicht',
sub: 'Alle Erfassungsarten',
to: '/capture',
color: 'var(--accent)',
}
/** Reihenfolge für Chip- / Seitenleiste (inkl. Übersicht) */
export const CAPTURE_SHELL_NAV_ITEMS = [OVERVIEW_ENTRY, ...CAPTURE_HUB_TILES]
export const CAPTURE_SECTION_PATHS = CAPTURE_SHELL_NAV_ITEMS.map((e) => e.to)
export function isCaptureSectionPath(pathname) {
return CAPTURE_SECTION_PATHS.includes(pathname)
}

View File

@ -0,0 +1,51 @@
import { Outlet, NavLink, useLocation } from 'react-router-dom'
import {
getAdminShellNavEntries,
adminShellEntryIsActive,
adminShellEntryItemCount,
} from '../config/adminNav'
/**
* Wie KI-Analyse: nur Gruppen-Chips (mobil) bzw. Seitenleiste (desktop);
* konkrete Admin-Seiten über Hub unter /admin/g/:groupId.
*/
export default function AdminShell() {
const loc = useLocation()
const entries = getAdminShellNavEntries()
return (
<div className="admin-shell">
<div className="analysis-split">
<div className="analysis-split__nav-wrap">
<nav className="analysis-split__nav" aria-label="Adminbereich">
{entries.map((entry) => {
const active = adminShellEntryIsActive(loc.pathname, entry)
const count = adminShellEntryItemCount(entry)
return (
<NavLink
key={entry.id}
to={entry.to}
end={!!entry.end}
className={() =>
'analysis-split__nav-item' +
(active ? ' analysis-split__nav-item--active' : '')
}
>
{entry.label}
{count > 0 && (
<span className="analysis-split__nav-cat-count">({count})</span>
)}
</NavLink>
)
})}
</nav>
</div>
<div className="analysis-split__main">
<div className="admin-page">
<Outlet />
</div>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,36 @@
import { Outlet, NavLink } from 'react-router-dom'
import { CAPTURE_SHELL_NAV_ITEMS } from '../config/captureNav'
/**
* Erfassung: Mobil Chip-Leiste, Desktop linke Spalte Wechsel zwischen Masken ohne Hub.
*/
export default function CaptureShell() {
return (
<div className="capture-shell">
<div className="capture-shell__layout">
<nav className="capture-shell__nav-wrap" aria-label="Erfassungsbereiche">
<div className="capture-shell__nav">
{CAPTURE_SHELL_NAV_ITEMS.map((e) => (
<NavLink
key={e.to}
to={e.to}
end={e.to === '/capture'}
className={({ isActive }) =>
'capture-shell__nav-item' +
(isActive ? ' capture-shell__nav-item--active' : '') +
(e.highlight ? ' capture-shell__nav-item--highlight' : '')
}
>
<span className="capture-shell__nav-icon" aria-hidden>{e.icon}</span>
<span className="capture-shell__nav-label">{e.label}</span>
</NavLink>
))}
</div>
</nav>
<div className="capture-shell__main">
<Outlet />
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,11 @@
import { Navigate, Outlet, useLocation } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
export default function RequireAdmin() {
const { isAdmin } = useAuth()
const loc = useLocation()
if (!isAdmin) {
return <Navigate to="/" replace state={{ from: loc.pathname, adminDenied: true }} />
}
return <Outlet />
}

View File

@ -254,7 +254,7 @@ export default function ActivityPage() {
} }
return ( return (
<div> <div className="capture-page">
<h1 className="page-title">Aktivität</h1> <h1 className="page-title">Aktivität</h1>
<div className="tabs" style={{overflowX:'auto',flexWrap:'nowrap'}}> <div className="tabs" style={{overflowX:'auto',flexWrap:'nowrap'}}>

View File

@ -1,6 +1,7 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { Plus, Pencil, Trash2, Save, X, Eye, EyeOff } from 'lucide-react' import { Plus, Pencil, Trash2, Save, X, Eye, EyeOff } from 'lucide-react'
import { api } from '../utils/api' import { api } from '../utils/api'
import EmojiIconPicker from '../components/EmojiIconPicker'
const CATEGORIES = [ const CATEGORIES = [
{ value: 'body_composition', label: 'Körperzusammensetzung' }, { value: 'body_composition', label: 'Körperzusammensetzung' },
@ -220,15 +221,18 @@ export default function AdminFocusAreasPage() {
</div> </div>
<div> <div>
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 4 }}> <label
htmlFor="admin-focus-area-new-icon"
style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 4 }}
>
Icon (Emoji) Icon (Emoji)
</label> </label>
<input <EmojiIconPicker
className="form-input" id="admin-focus-area-new-icon"
value={formData.icon} value={formData.icon}
onChange={(e) => setFormData({ ...formData, icon: e.target.value })} onChange={(icon) => setFormData({ ...formData, icon })}
placeholder="💥" placeholder="💥"
style={{ width: '100%' }} maxLength={10}
/> />
</div> </div>
@ -332,14 +336,18 @@ export default function AdminFocusAreasPage() {
</div> </div>
<div> <div>
<label style={{ fontSize: 12, fontWeight: 600, display: 'block', marginBottom: 4 }}> <label
htmlFor={`admin-focus-area-icon-${area.id}`}
style={{ fontSize: 12, fontWeight: 600, display: 'block', marginBottom: 4 }}
>
Icon Icon
</label> </label>
<input <EmojiIconPicker
className="form-input" id={`admin-focus-area-icon-${area.id}`}
value={area.icon || ''} value={area.icon || ''}
onChange={(e) => updateField(area.id, 'icon', e.target.value)} onChange={(icon) => updateField(area.id, 'icon', icon)}
style={{ width: '100%' }} placeholder="💥"
maxLength={10}
/> />
</div> </div>

View File

@ -1,6 +1,7 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { Settings, Plus, Pencil, Trash2, Database } from 'lucide-react' import { Settings, Plus, Pencil, Trash2, Database } from 'lucide-react'
import { api } from '../utils/api' import { api } from '../utils/api'
import EmojiIconPicker from '../components/EmojiIconPicker'
export default function AdminGoalTypesPage() { export default function AdminGoalTypesPage() {
const [goalTypes, setGoalTypes] = useState([]) const [goalTypes, setGoalTypes] = useState([])
@ -367,14 +368,15 @@ export default function AdminGoalTypesPage() {
/> />
</div> </div>
<div> <div>
<label className="form-label">Icon (Emoji)</label> <label className="form-label" htmlFor="admin-goal-type-icon">
<input Icon (Emoji)
type="text" </label>
className="form-input" <EmojiIconPicker
style={{ width: '100%' }} id="admin-goal-type-icon"
value={formData.icon} value={formData.icon}
onChange={e => setFormData(f => ({ ...f, icon: e.target.value }))} onChange={(icon) => setFormData((f) => ({ ...f, icon }))}
placeholder="🧘" placeholder="🧘"
maxLength={10}
/> />
</div> </div>
</div> </div>

View File

@ -0,0 +1,57 @@
import { useParams, Navigate, Link } from 'react-router-dom'
import { ADMIN_GROUPS } from '../config/adminNav'
export default function AdminGroupHubPage() {
const { groupId } = useParams()
const group = ADMIN_GROUPS.find((g) => g.id === groupId)
if (!group) {
return <Navigate to="/admin" replace />
}
return (
<div>
<h2 className="page-title" style={{ margin: '0 0 8px', fontSize: 18 }}>
{group.label}
</h2>
<p style={{ fontSize: 13, color: 'var(--text2)', marginBottom: 18, lineHeight: 1.6 }}>
{group.description}
</p>
<p style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 12 }}>
Bereich wählen · {group.items.length}{' '}
{group.items.length === 1 ? 'Seite' : 'Seiten'}
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{group.items.map((item) => (
<Link
key={item.to}
to={item.to}
className="card section-gap"
style={{
margin: 0,
textDecoration: 'none',
color: 'inherit',
borderColor: 'var(--accent)',
borderWidth: 2,
display: 'block',
}}
>
<div style={{ fontWeight: 700, fontSize: 15, color: 'var(--accent)', marginBottom: 4 }}>
{item.label}
</div>
{item.description && (
<div style={{ fontSize: 12, color: 'var(--text2)', lineHeight: 1.5 }}>
{item.description}
</div>
)}
</Link>
))}
</div>
<div style={{ marginTop: 20 }}>
<Link to="/admin" className="btn btn-secondary" style={{ fontSize: 13 }}>
Zur Übersicht
</Link>
</div>
</div>
)
}

View File

@ -0,0 +1,41 @@
import { Link } from 'react-router-dom'
import { Shield } from 'lucide-react'
import { ADMIN_GROUPS, adminGroupHubPath } from '../config/adminNav'
export default function AdminHomePage() {
return (
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
<Shield size={20} color="var(--accent)" />
<h1 className="page-title" style={{ margin: 0 }}>Adminbereich</h1>
</div>
<p style={{ fontSize: 13, color: 'var(--text2)', marginBottom: 18, lineHeight: 1.6 }}>
Wähle links oder oben eine <strong>Gruppe</strong>. Die einzelnen Verwaltungsseiten erreichst du auf der
folgenden Gruppenseite wie bei der KI-Analyse (Kategorie Detail).
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{ADMIN_GROUPS.map((group) => (
<Link
key={group.id}
to={adminGroupHubPath(group.id)}
className="card section-gap"
style={{
margin: 0,
textDecoration: 'none',
color: 'inherit',
display: 'block',
}}
>
<div style={{ fontWeight: 700, fontSize: 15, marginBottom: 6 }}>{group.label}</div>
<div style={{ fontSize: 12, color: 'var(--text2)', lineHeight: 1.5 }}>
{group.description}
</div>
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 8 }}>
{group.items.length} {group.items.length === 1 ? 'Bereich' : 'Bereiche'}
</div>
</Link>
))}
</div>
</div>
)
}

View File

@ -1,4 +1,5 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { api } from '../utils/api' import { api } from '../utils/api'
import UnifiedPromptModal from '../components/UnifiedPromptModal' import UnifiedPromptModal from '../components/UnifiedPromptModal'
import { Star, Trash2, Edit, Copy, Filter, ArrowDownToLine } from 'lucide-react' import { Star, Trash2, Edit, Copy, Filter, ArrowDownToLine } from 'lucide-react'
@ -9,9 +10,10 @@ import { Star, Trash2, Edit, Copy, Filter, ArrowDownToLine } from 'lucide-react'
* Manages both base and pipeline-type prompts in one interface. * Manages both base and pipeline-type prompts in one interface.
*/ */
export default function AdminPromptsPage() { export default function AdminPromptsPage() {
const navigate = useNavigate()
const [prompts, setPrompts] = useState([]) const [prompts, setPrompts] = useState([])
const [filteredPrompts, setFilteredPrompts] = useState([]) const [filteredPrompts, setFilteredPrompts] = useState([])
const [typeFilter, setTypeFilter] = useState('all') // 'all' | 'base' | 'pipeline' const [typeFilter, setTypeFilter] = useState('all') // 'all' | 'base' | 'pipeline' | 'workflow'
const [category, setCategory] = useState('all') const [category, setCategory] = useState('all')
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState(null) const [error, setError] = useState(null)
@ -44,6 +46,8 @@ export default function AdminPromptsPage() {
filtered = filtered.filter(p => p.type === 'base') filtered = filtered.filter(p => p.type === 'base')
} else if (typeFilter === 'pipeline') { } else if (typeFilter === 'pipeline') {
filtered = filtered.filter(p => p.type === 'pipeline') filtered = filtered.filter(p => p.type === 'pipeline')
} else if (typeFilter === 'workflow') {
filtered = filtered.filter(p => p.type === 'workflow')
} }
// Filter by category // Filter by category
@ -256,6 +260,13 @@ export default function AdminPromptsPage() {
> >
+ Neuer Prompt + Neuer Prompt
</button> </button>
<button
className="btn btn-secondary"
onClick={() => navigate('/workflow-editor/new')}
style={{ marginLeft: 8 }}
>
🔀 Neuer Workflow
</button>
</div> </div>
</div> </div>
@ -329,6 +340,13 @@ export default function AdminPromptsPage() {
> >
Pipelines ({prompts.filter(p => p.type === 'pipeline' || !p.type).length}) Pipelines ({prompts.filter(p => p.type === 'pipeline' || !p.type).length})
</button> </button>
<button
className={typeFilter === 'workflow' ? 'btn btn-primary' : 'btn'}
onClick={() => setTypeFilter('workflow')}
style={{ fontSize: 13, padding: '6px 12px' }}
>
🔀 Workflows ({prompts.filter(p => p.type === 'workflow').length})
</button>
</div> </div>
<div style={{ <div style={{
@ -512,7 +530,13 @@ export default function AdminPromptsPage() {
justifyContent: 'flex-end' justifyContent: 'flex-end'
}}> }}>
<button <button
onClick={() => setEditingPrompt(prompt)} onClick={() => {
if (prompt.type === 'workflow') {
navigate(`/workflow-editor/${prompt.id}`)
} else {
setEditingPrompt(prompt)
}
}}
style={{ style={{
background: 'none', background: 'none',
border: 'none', border: 'none',

View File

@ -0,0 +1,65 @@
import { Settings } from 'lucide-react'
import EmailSettings from '../components/EmailSettings'
import { api } from '../utils/api'
export default function AdminSystemPage() {
return (
<div>
<div style={{display:'flex',alignItems:'center',gap:8,marginBottom:16}}>
<Settings size={18} color="var(--accent)"/>
<h2 style={{fontSize:17,fontWeight:700,margin:0}}>Basiseinstellungen</h2>
</div>
<p style={{fontSize:13,color:'var(--text2)',marginBottom:16,lineHeight:1.6}}>
SMTP für System-E-Mails und Export der Placeholder-Metadaten für Dokumentation und Compliance.
</p>
<EmailSettings />
<div className="card section-gap" style={{marginTop:16}}>
<div style={{fontWeight:700,fontSize:14,marginBottom:12,display:'flex',alignItems:'center',gap:6}}>
<Settings size={16} color="var(--accent)"/> Placeholder-Metadaten (Export)
</div>
<div style={{fontSize:12,color:'var(--text3)',marginBottom:12,lineHeight:1.5}}>
Vollständige Metadaten aller registrierten Platzhalter (JSON/ZIP für Katalog und Reports).
</div>
<div style={{display:'grid',gap:8}}>
<button type="button" className="btn btn-secondary btn-full"
onClick={async()=>{
try {
const data = await api.exportPlaceholdersExtendedJson()
const blob = new Blob([JSON.stringify(data, null, 2)], {type:'application/json'})
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `placeholder-metadata-extended-${new Date().toISOString().split('T')[0]}.json`
a.click()
window.URL.revokeObjectURL(url)
} catch(e) {
alert('Fehler beim Export: '+e.message)
}
}}>
📄 Complete JSON exportieren
</button>
<button type="button" className="btn btn-secondary btn-full"
onClick={()=>{
try {
const token = localStorage.getItem('bodytrack_token')
const a = document.createElement('a')
a.href = `/api/prompts/placeholders/export-catalog-zip?token=${token}`
a.download = `placeholder-catalog-${new Date().toISOString().split('T')[0]}.zip`
a.click()
} catch(e) {
alert('Fehler beim Export: '+e.message)
}
}}>
📦 Complete ZIP (JSON + Markdown + Reports)
</button>
</div>
<div style={{fontSize:11,color:'var(--text3)',marginTop:8,lineHeight:1.5}}>
<strong>JSON:</strong> Maschinenlesbare Metadaten ·{' '}
<strong>ZIP:</strong> Katalog, Gap Report, Export Spec
</div>
</div>
</div>
)
}

View File

@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom'
import { Pencil, Trash2, Plus, Save, X, ArrowLeft, Settings } from 'lucide-react' import { Pencil, Trash2, Plus, Save, X, ArrowLeft, Settings } from 'lucide-react'
import { api } from '../utils/api' import { api } from '../utils/api'
import ProfileBuilder from '../components/ProfileBuilder' import ProfileBuilder from '../components/ProfileBuilder'
import EmojiIconPicker from '../components/EmojiIconPicker'
/** /**
* AdminTrainingTypesPage - CRUD for training types * AdminTrainingTypesPage - CRUD for training types
@ -254,13 +255,11 @@ export default function AdminTrainingTypesPage() {
<div> <div>
<div className="form-label">Icon (Emoji)</div> <div className="form-label">Icon (Emoji)</div>
<input <EmojiIconPicker
className="form-input"
value={formData.icon} value={formData.icon}
onChange={e => setFormData({ ...formData, icon: e.target.value })} onChange={(icon) => setFormData({ ...formData, icon })}
placeholder="🏃" placeholder="🏃"
maxLength={10} maxLength={10}
style={{ width: '100%' }}
/> />
</div> </div>
@ -495,13 +494,11 @@ export default function AdminTrainingTypesPage() {
<div> <div>
<div className="form-label">Icon (Emoji)</div> <div className="form-label">Icon (Emoji)</div>
<input <EmojiIconPicker
className="form-input"
value={formData.icon} value={formData.icon}
onChange={e => setFormData({ ...formData, icon: e.target.value })} onChange={(icon) => setFormData({ ...formData, icon })}
placeholder="🏃" placeholder="🏃"
maxLength={10} maxLength={10}
style={{ width: '100%' }}
/> />
</div> </div>

View File

@ -1,5 +1,5 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { Plus, Trash2, Pencil, Check, X, Shield, Key, Settings } from 'lucide-react' import { Plus, Trash2, Pencil, Check, X, Shield, Key } from 'lucide-react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import { api } from '../utils/api' import { api } from '../utils/api'
@ -17,23 +17,6 @@ function Avatar({ profile, size=36 }) {
) )
} }
function Toggle({ value, onChange, label, disabled=false }) {
return (
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between',
padding:'8px 0',borderBottom:'1px solid var(--border)'}}>
<span style={{fontSize:13,color:disabled?'var(--text3)':'var(--text1)'}}>{label}</span>
<div onClick={()=>!disabled&&onChange(!value)}
style={{width:40,height:22,borderRadius:11,background:value?'var(--accent)':'var(--border)',
position:'relative',cursor:disabled?'not-allowed':'pointer',transition:'background 0.2s',
opacity:disabled?0.5:1}}>
<div style={{position:'absolute',top:2,left:value?18:2,width:18,height:18,
borderRadius:'50%',background:'white',transition:'left 0.2s',
boxShadow:'0 1px 3px rgba(0,0,0,0.2)'}}/>
</div>
</div>
)
}
function NewProfileForm({ onSave, onCancel }) { function NewProfileForm({ onSave, onCancel }) {
const [form, setForm] = useState({ const [form, setForm] = useState({
name:'', pin:'', email:'', avatar_color:COLORS[0], name:'', pin:'', email:'', avatar_color:COLORS[0],
@ -210,7 +193,6 @@ function ProfileCard({ profile, currentId, onRefresh }) {
{expanded && ( {expanded && (
<div style={{marginTop:12,paddingTop:12,borderTop:'1px solid var(--border)'}}> <div style={{marginTop:12,paddingTop:12,borderTop:'1px solid var(--border)'}}>
{/* Permissions */}
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>BERECHTIGUNGEN</div> <div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>BERECHTIGUNGEN</div>
<div style={{marginBottom:8}}> <div style={{marginBottom:8}}>
@ -231,7 +213,6 @@ function ProfileCard({ profile, currentId, onRefresh }) {
</button> </button>
</div> </div>
{/* Feature-Overrides */}
<div style={{marginBottom:12,padding:10,background:'var(--accent-light)',borderRadius:6,fontSize:12}}> <div style={{marginBottom:12,padding:10,background:'var(--accent-light)',borderRadius:6,fontSize:12}}>
<strong>Feature-Limits:</strong> Nutze die neue{' '} <strong>Feature-Limits:</strong> Nutze die neue{' '}
<Link to="/admin/user-restrictions" style={{color:'var(--accent-dark)',fontWeight:600}}> <Link to="/admin/user-restrictions" style={{color:'var(--accent-dark)',fontWeight:600}}>
@ -240,13 +221,11 @@ function ProfileCard({ profile, currentId, onRefresh }) {
Seite um individuelle Limits zu setzen. Seite um individuelle Limits zu setzen.
</div> </div>
{/* Email */}
<div style={{marginTop:12,paddingTop:12,borderTop:'1px solid var(--border)'}}> <div style={{marginTop:12,paddingTop:12,borderTop:'1px solid var(--border)'}}>
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:6}}>E-MAIL (für Recovery & Zusammenfassungen)</div> <div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:6}}>E-MAIL (für Recovery & Zusammenfassungen)</div>
<EmailEditor profileId={profile.id} currentEmail={profile.email} onSaved={onRefresh}/> <EmailEditor profileId={profile.id} currentEmail={profile.email} onSaved={onRefresh}/>
</div> </div>
{/* PIN change */}
<div style={{marginTop:14,paddingTop:12,borderTop:'1px solid var(--border)'}}> <div style={{marginTop:14,paddingTop:12,borderTop:'1px solid var(--border)'}}>
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8,display:'flex',alignItems:'center',gap:4}}> <div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8,display:'flex',alignItems:'center',gap:4}}>
<Key size={12}/> PIN / PASSWORT ÄNDERN <Key size={12}/> PIN / PASSWORT ÄNDERN
@ -264,84 +243,7 @@ function ProfileCard({ profile, currentId, onRefresh }) {
) )
} }
function EmailSettings() { export default function AdminUsersPage() {
const [status, setStatus] = useState(null)
const [testTo, setTestTo] = useState('')
const [testing, setTesting] = useState(false)
const [testMsg, setTestMsg] = useState(null)
useEffect(()=>{
const token = localStorage.getItem('bodytrack_token')||''
fetch('/api/admin/email/status',{headers:{'X-Auth-Token':token}})
.then(r=>r.json()).then(setStatus)
},[])
const sendTest = async () => {
if (!testTo) return
setTesting(true); setTestMsg(null)
try {
const token = localStorage.getItem('bodytrack_token')||''
const r = await fetch('/api/admin/email/test',{
method:'POST',headers:{'Content-Type':'application/json','X-Auth-Token':token},
body:JSON.stringify({to:testTo})
})
if(!r.ok) throw new Error((await r.json()).detail)
setTestMsg('✓ Test-E-Mail gesendet!')
} catch(e){ setTestMsg('✗ Fehler: '+e.message) }
finally{ setTesting(false) }
}
return (
<div className="card section-gap" style={{marginTop:16}}>
<div style={{fontWeight:700,fontSize:14,marginBottom:10,display:'flex',alignItems:'center',gap:6}}>
📧 E-Mail Konfiguration
</div>
{!status ? <div className="spinner" style={{width:16,height:16}}/> : (
<>
<div style={{padding:'8px 12px',borderRadius:8,marginBottom:12,
background:status.configured?'var(--accent-light)':'var(--warn-bg)',
fontSize:12,color:status.configured?'var(--accent-dark)':'var(--warn-text)'}}>
{status.configured
? <> Konfiguriert: <strong>{status.smtp_user}</strong> via {status.smtp_host}</>
: <> Nicht konfiguriert. SMTP-Einstellungen in der <code>.env</code> Datei setzen.</>}
</div>
{status.configured && (
<>
<div style={{fontSize:11,color:'var(--text3)',marginBottom:10,lineHeight:1.5}}>
<strong>App-URL:</strong> {status.app_url}<br/>
<span style={{fontSize:10}}>Für korrekte Links in E-Mails (z.B. Recovery-Links). In .env als APP_URL setzen.</span>
</div>
<div style={{display:'flex',gap:8}}>
<input type="email" className="form-input" placeholder="test@beispiel.de"
value={testTo} onChange={e=>setTestTo(e.target.value)} style={{flex:1}}/>
<button className="btn btn-secondary" onClick={sendTest} disabled={testing}>
{testing?'…':'Test'}
</button>
</div>
{testMsg && <div style={{fontSize:12,marginTop:6,
color:testMsg.startsWith('✓')?'var(--accent)':'#D85A30'}}>{testMsg}</div>}
</>
)}
{!status.configured && (
<div style={{fontSize:11,color:'var(--text3)',lineHeight:1.6}}>
Füge folgende Zeilen zur <code>.env</code> Datei hinzu:<br/>
<code style={{background:'var(--surface2)',padding:'6px 8px',borderRadius:4,
display:'block',marginTop:6,fontSize:11}}>
SMTP_HOST=smtp.gmail.com<br/>
SMTP_PORT=587<br/>
SMTP_USER=deine@gmail.com<br/>
SMTP_PASS=dein_app_passwort<br/>
APP_URL=http://192.168.2.49:3002
</code>
</div>
)}
</>
)}
</div>
)
}
export default function AdminPanel() {
const { session } = useAuth() const { session } = useAuth()
const [profiles, setProfiles] = useState([]) const [profiles, setProfiles] = useState([])
const [creating, setCreating] = useState(false) const [creating, setCreating] = useState(false)
@ -367,7 +269,7 @@ export default function AdminPanel() {
<div style={{padding:'10px 12px',background:'var(--accent-light)',borderRadius:8, <div style={{padding:'10px 12px',background:'var(--accent-light)',borderRadius:8,
fontSize:12,color:'var(--accent-dark)',marginBottom:16,lineHeight:1.5}}> fontSize:12,color:'var(--accent-dark)',marginBottom:16,lineHeight:1.5}}>
👑 Du bist Admin. Hier kannst du Profile verwalten, Berechtigungen setzen und KI-Limits konfigurieren. 👑 Profile anlegen, Rollen setzen und Recovery-E-Mail pro Nutzer pflegen. Feature-Limits über User-Overrides in der Seitenleiste.
</div> </div>
{creating && ( {creating && (
@ -384,171 +286,6 @@ export default function AdminPanel() {
<Plus size={14}/> Neues Profil anlegen <Plus size={14}/> Neues Profil anlegen
</button> </button>
)} )}
{/* Email Settings */}
<EmailSettings/>
{/* v9c Subscription Management */}
<div className="card section-gap" style={{marginTop:16}}>
<div style={{fontWeight:700,fontSize:14,marginBottom:12,display:'flex',alignItems:'center',gap:6}}>
<Settings size={16} color="var(--accent)"/> Subscription-System (v9c)
</div>
<div style={{fontSize:12,color:'var(--text3)',marginBottom:12,lineHeight:1.5}}>
Verwalte Tiers, Features und Limits für das neue Freemium-System.
</div>
<div style={{display:'grid',gap:8}}>
<Link to="/admin/tiers">
<button className="btn btn-secondary btn-full">
🎯 Tiers verwalten
</button>
</Link>
<Link to="/admin/features">
<button className="btn btn-secondary btn-full">
🔧 Feature-Registry verwalten
</button>
</Link>
<Link to="/admin/tier-limits">
<button className="btn btn-secondary btn-full">
📊 Tier Limits Matrix bearbeiten
</button>
</Link>
<Link to="/admin/coupons">
<button className="btn btn-secondary btn-full">
🎟 Coupons verwalten
</button>
</Link>
<Link to="/admin/user-restrictions">
<button className="btn btn-secondary btn-full">
👤 User Feature-Overrides
</button>
</Link>
</div>
</div>
{/* v9d Training Types Management */}
<div className="card section-gap" style={{marginTop:16}}>
<div style={{fontWeight:700,fontSize:14,marginBottom:12,display:'flex',alignItems:'center',gap:6}}>
<Settings size={16} color="var(--accent)"/> Trainingstypen (v9d)
</div>
<div style={{fontSize:12,color:'var(--text3)',marginBottom:12,lineHeight:1.5}}>
Verwalte Trainingstypen, Kategorien und Activity-Mappings (lernendes System).
</div>
<div style={{display:'grid',gap:8}}>
<Link to="/admin/training-types">
<button className="btn btn-secondary btn-full">
🏋 Trainingstypen verwalten
</button>
</Link>
<Link to="/admin/activity-mappings">
<button className="btn btn-secondary btn-full">
🔗 Activity-Mappings (lernendes System)
</button>
</Link>
<Link to="/admin/training-profiles">
<button className="btn btn-secondary btn-full">
Training Type Profiles (#15)
</button>
</Link>
</div>
</div>
{/* KI-Prompts Section */}
<div className="card section-gap" style={{marginTop:16}}>
<div style={{fontWeight:700,fontSize:14,marginBottom:12,display:'flex',alignItems:'center',gap:6}}>
<Settings size={16} color="var(--accent)"/> KI-Prompts (v9f)
</div>
<div style={{fontSize:12,color:'var(--text3)',marginBottom:12,lineHeight:1.5}}>
Verwalte AI-Prompts mit KI-Unterstützung: Generiere, optimiere und organisiere Prompts.
</div>
<div style={{display:'grid',gap:8}}>
<Link to="/admin/prompts">
<button className="btn btn-secondary btn-full">
🤖 KI-Prompts verwalten
</button>
</Link>
</div>
</div>
{/* Goal Types Section */}
<div className="card section-gap" style={{marginTop:16}}>
<div style={{fontWeight:700,fontSize:14,marginBottom:12,display:'flex',alignItems:'center',gap:6}}>
<Settings size={16} color="var(--accent)"/> Ziel-Typen (v9e)
</div>
<div style={{fontSize:12,color:'var(--text3)',marginBottom:12,lineHeight:1.5}}>
Verwalte Goal-Type-Definitionen: Erstelle custom goal types mit oder ohne automatische Datenquelle.
</div>
<div style={{display:'grid',gap:8}}>
<Link to="/admin/goal-types">
<button className="btn btn-secondary btn-full">
🎯 Ziel-Typen verwalten
</button>
</Link>
</div>
</div>
{/* Focus Areas Section */}
<div className="card section-gap" style={{marginTop:16}}>
<div style={{fontWeight:700,fontSize:14,marginBottom:12,display:'flex',alignItems:'center',gap:6}}>
<Settings size={16} color="var(--accent)"/> Focus Areas (v9g)
</div>
<div style={{fontSize:12,color:'var(--text3)',marginBottom:12,lineHeight:1.5}}>
Verwalte Focus Area Definitionen: Dynamisches, erweiterbares System mit 26+ Bereichen über 7 Kategorien.
</div>
<div style={{display:'grid',gap:8}}>
<Link to="/admin/focus-areas">
<button className="btn btn-secondary btn-full">
🎯 Focus Areas verwalten
</button>
</Link>
</div>
</div>
{/* Placeholder Metadata Export Section */}
<div className="card section-gap" style={{marginTop:16}}>
<div style={{fontWeight:700,fontSize:14,marginBottom:12,display:'flex',alignItems:'center',gap:6}}>
<Settings size={16} color="var(--accent)"/> Placeholder Metadata Export (v1.0)
</div>
<div style={{fontSize:12,color:'var(--text3)',marginBottom:12,lineHeight:1.5}}>
Exportiere vollständige Metadaten aller 116 Placeholders. Normative Compliance v1.0.0.
</div>
<div style={{display:'grid',gap:8}}>
<button className="btn btn-secondary btn-full"
onClick={async()=>{
try {
const data = await api.exportPlaceholdersExtendedJson()
const blob = new Blob([JSON.stringify(data, null, 2)], {type:'application/json'})
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `placeholder-metadata-extended-${new Date().toISOString().split('T')[0]}.json`
a.click()
window.URL.revokeObjectURL(url)
} catch(e) {
alert('Fehler beim Export: '+e.message)
}
}}>
📄 Complete JSON exportieren
</button>
<button className="btn btn-secondary btn-full"
onClick={async()=>{
try {
const token = localStorage.getItem('bodytrack_token')
const a = document.createElement('a')
a.href = `/api/prompts/placeholders/export-catalog-zip?token=${token}`
a.download = `placeholder-catalog-${new Date().toISOString().split('T')[0]}.zip`
a.click()
} catch(e) {
alert('Fehler beim Export: '+e.message)
}
}}>
📦 Complete ZIP (JSON + Markdown + Reports)
</button>
</div>
<div style={{fontSize:11,color:'var(--text3)',marginTop:8,lineHeight:1.5}}>
<strong>JSON:</strong> Maschinenlesbare Metadaten aller Placeholders<br/>
<strong>ZIP:</strong> Katalog (JSON + MD), Gap Report, Export Spec (4 Dateien)
</div>
</div>
</div> </div>
) )
} }

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react' import React, { useState, useEffect, useMemo } from 'react'
import { Brain, Trash2, ChevronDown, ChevronUp, Target } from 'lucide-react' import { Brain, Trash2, ChevronDown, ChevronUp, Target } from 'lucide-react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { api } from '../utils/api' import { api } from '../utils/api'
@ -14,6 +14,58 @@ const SLUG_LABELS = {
pipeline: '🔬 Mehrstufige Gesamtanalyse' pipeline: '🔬 Mehrstufige Gesamtanalyse'
} }
/** DB `ai_prompts.category` Reihenfolge der Gruppen in der Analyse-Navigation */
const ANALYSIS_CATEGORY_ORDER = ['körper', 'ernährung', 'training', 'schlaf', 'vitalwerte', 'ziele', 'ganzheitlich']
const ANALYSIS_CATEGORY_LABELS = {
körper: 'Körper',
ernährung: 'Ernährung',
training: 'Training',
schlaf: 'Schlaf',
vitalwerte: 'Vitalwerte',
ziele: 'Ziele',
ganzheitlich: 'Ganzheitlich',
}
function sortAnalysisCategoryKeys(keys) {
return [...keys].sort((a, b) => {
const na = String(a).toLowerCase()
const nb = String(b).toLowerCase()
const ia = ANALYSIS_CATEGORY_ORDER.indexOf(na)
const ib = ANALYSIS_CATEGORY_ORDER.indexOf(nb)
if (ia === -1 && ib === -1) return String(a).localeCompare(String(b), 'de')
if (ia === -1) return 1
if (ib === -1) return -1
return ia - ib
})
}
function analysisCategoryLabel(key) {
const k = String(key).toLowerCase()
return ANALYSIS_CATEGORY_LABELS[k] || String(key)
}
/** Pipeline-Prompts nach `category` gruppieren (Backend-Feld), innerhalb Gruppe nach sort_order */
function buildPipelineGroups(pipelinePrompts) {
const m = new Map()
for (const p of pipelinePrompts) {
const raw =
p.category != null && String(p.category).trim() !== ''
? String(p.category).trim()
: 'ganzheitlich'
if (!m.has(raw)) m.set(raw, [])
m.get(raw).push(p)
}
for (const arr of m.values()) {
arr.sort((a, b) => (Number(a.sort_order) || 0) - (Number(b.sort_order) || 0))
}
return sortAnalysisCategoryKeys([...m.keys()]).map(cat => ({
categoryKey: cat,
label: analysisCategoryLabel(cat),
prompts: m.get(cat),
}))
}
function InsightCard({ ins, onDelete, defaultOpen=false, prompts=[] }) { function InsightCard({ ins, onDelete, defaultOpen=false, prompts=[] }) {
const [open, setOpen] = useState(defaultOpen) const [open, setOpen] = useState(defaultOpen)
@ -286,6 +338,9 @@ export default function Analysis() {
const [tab, setTab] = useState('run') const [tab, setTab] = useState('run')
const [newResult, setNewResult] = useState(null) const [newResult, setNewResult] = useState(null)
const [aiUsage, setAiUsage] = useState(null) const [aiUsage, setAiUsage] = useState(null)
/** Kategorie-Schlüssel aus `buildPipelineGroups` (Navigation); Detail = alle Pipelines dieser Kategorie */
const [activeCategoryKey, setActiveCategoryKey] = useState(null)
const [historyScopePick, setHistoryScopePick] = useState(null)
const loadAll = async () => { const loadAll = async () => {
const [p, i] = await Promise.all([ const [p, i] = await Promise.all([
@ -305,6 +360,25 @@ export default function Analysis() {
}).catch(err => console.error('Failed to load usage:', err)) }).catch(err => console.error('Failed to load usage:', err))
},[]) },[])
useEffect(() => {
const list = prompts.filter(p => p.active && p.type === 'pipeline')
setActiveCategoryKey(prev => {
if (!list.length) return null
const groups = buildPipelineGroups(list)
const keys = groups.map(g => g.categoryKey)
if (prev && keys.includes(prev)) return prev
return groups[0]?.categoryKey ?? null
})
}, [prompts])
useEffect(() => {
if (!newResult?.scope) return
const list = prompts.filter(p => p.active && p.type === 'pipeline')
const groups = buildPipelineGroups(list)
const g = groups.find(gg => gg.prompts.some(p => p.slug === newResult.scope))
if (g) setActiveCategoryKey(g.categoryKey)
}, [newResult?.scope, prompts])
const runPrompt = async (slug) => { const runPrompt = async (slug) => {
setLoading(slug); setError(null); setNewResult(null) setLoading(slug); setError(null); setNewResult(null)
try { try {
@ -383,14 +457,26 @@ export default function Analysis() {
grouped[key].push(ins) grouped[key].push(ins)
}) })
// Show only active pipeline-type prompts // Show only active pipeline-type prompts (und nach DB-Kategorie gruppiert)
const pipelinePrompts = prompts.filter(p => p.active && p.type === 'pipeline') const { pipelinePrompts, pipelineGroups } = useMemo(() => {
const list = prompts.filter(p => p.active && p.type === 'pipeline')
return { pipelinePrompts: list, pipelineGroups: buildPipelineGroups(list) }
}, [prompts])
const historyScopeKeys = Object.keys(grouped).sort((a, b) => a.localeCompare(b))
const activeHistoryScope =
historyScopeKeys.length === 0
? null
: historyScopeKeys.includes(historyScopePick)
? historyScopePick
: historyScopeKeys[0]
return ( return (
<div> <div className="analysis-page">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}> <div className="analysis-page__header">
<h1 className="page-title" style={{ margin: 0 }}>KI-Analyse</h1> <h1 className="page-title" style={{ margin: 0 }}>KI-Analyse</h1>
<button <button
type="button"
className="btn btn-secondary" className="btn btn-secondary"
onClick={() => navigate('/goals')} onClick={() => navigate('/goals')}
style={{ fontSize: 13, padding: '6px 12px' }} style={{ fontSize: 13, padding: '6px 12px' }}
@ -400,8 +486,8 @@ export default function Analysis() {
</div> </div>
<div className="tabs"> <div className="tabs">
<button className={'tab'+(tab==='run'?' active':'')} onClick={()=>setTab('run')}>Analysen starten</button> <button type="button" className={'tab'+(tab==='run'?' active':'')} onClick={()=>setTab('run')}>Analysen starten</button>
<button className={'tab'+(tab==='history'?' active':'')} onClick={()=>setTab('history')}> <button type="button" className={'tab'+(tab==='history'?' active':'')} onClick={()=>setTab('history')}>
Verlauf Verlauf
{allInsights.length>0 && <span style={{marginLeft:4,fontSize:10,background:'var(--accent)', {allInsights.length>0 && <span style={{marginLeft:4,fontSize:10,background:'var(--accent)',
color:'white',padding:'1px 5px',borderRadius:8}}>{allInsights.length}</span>} color:'white',padding:'1px 5px',borderRadius:8}}>{allInsights.length}</span>}
@ -452,64 +538,101 @@ export default function Analysis() {
)} )}
{canUseAI && pipelinePrompts.length > 0 && ( {canUseAI && pipelinePrompts.length > 0 && (
<p style={{fontSize:13,color:'var(--text2)',marginBottom:14,lineHeight:1.6}}> <>
Wähle eine mehrstufige KI-Analyse: <p style={{fontSize:13,color:'var(--text2)',marginBottom:14,lineHeight:1.6}}>
</p> Zuerst die <strong>Kategorie</strong> wählen (Chip-Leiste bzw. Seitenleiste). Alle Pipeline-Analysen
)} dieser Kategorie erscheinen im Detailbereich (rechts auf Desktop, darunter auf Mobil).
Kategorien kommen aus dem Feld Kategorie beim jeweiligen Prompt im Admin.
{pipelinePrompts.map(p => { </p>
const existing = allInsights.find(i=>i.scope===p.slug) <div className="analysis-split">
return ( <div className="analysis-split__nav-wrap">
<div key={p.id} className="card" style={{marginBottom:16,borderColor:'var(--accent)',borderWidth:2}}> <nav className="analysis-split__nav" aria-label="KI-Analyse-Kategorien">
<div style={{display:'flex',alignItems:'flex-start',gap:12}}> {pipelineGroups.map(({ categoryKey, label, prompts: inGroup }) => (
<div style={{flex:1}}> <button
<div className="badge-container-right" style={{fontWeight:700,fontSize:15,color:'var(--accent)'}}> key={categoryKey}
<span>{p.display_name || SLUG_LABELS[p.slug] || p.name}</span> type="button"
{aiUsage && <UsageBadge {...aiUsage} />} className={
</div> 'analysis-split__nav-item' +
{p.description && ( (activeCategoryKey === categoryKey ? ' analysis-split__nav-item--active' : '')
<div style={{fontSize:12,color:'var(--text2)',marginTop:3,lineHeight:1.5}}> }
{p.description} onClick={() => setActiveCategoryKey(categoryKey)}
</div> aria-current={activeCategoryKey === categoryKey ? 'page' : undefined}
)} >
{existing && ( {label}
<div style={{fontSize:11,color:'var(--text3)',marginTop:3}}> <span className="analysis-split__nav-cat-count">({inGroup.length})</span>
Letzte Analyse: {dayjs(existing.created).format('DD.MM.YYYY, HH:mm')} </button>
</div> ))}
)} </nav>
</div> </div>
<div <div className="analysis-split__main">
title={aiUsage && !aiUsage.allowed ? `Limit erreicht (${aiUsage.used}/${aiUsage.limit}). Kontaktiere den Admin oder warte bis zum nächsten Reset.` : ''} {activeCategoryKey && (() => {
style={{display:'inline-block'}} const group = pipelineGroups.find(g => g.categoryKey === activeCategoryKey)
> if (!group?.prompts?.length) return null
<button return (
className="btn btn-primary" <>
style={{flexShrink:0,minWidth:100, cursor: (aiUsage && !aiUsage.allowed) ? 'not-allowed' : 'pointer'}} <div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 12 }}>
onClick={()=>runPrompt(p.slug)} {group.label} · {group.prompts.length} {group.prompts.length === 1 ? 'Analyse' : 'Analysen'}
disabled={!!loading||!canUseAI||(aiUsage && !aiUsage.allowed)} </div>
> {group.prompts.map(p => {
{loading===p.slug const existing = allInsights.find(i => i.scope === p.slug)
? <><div className="spinner" style={{width:13,height:13}}/> Läuft</> return (
: (aiUsage && !aiUsage.allowed) ? '🔒 Limit' <div key={p.id} className="card" style={{marginBottom:16,borderColor:'var(--accent)',borderWidth:2}}>
: <><Brain size={13}/> Starten</>} <div style={{display:'flex',alignItems:'flex-start',gap:12,flexWrap:'wrap'}}>
</button> <div style={{flex:1,minWidth:0}}>
</div> <div className="badge-container-right" style={{fontWeight:700,fontSize:15,color:'var(--accent)'}}>
<span>{p.display_name || SLUG_LABELS[p.slug] || p.name}</span>
{aiUsage && <UsageBadge {...aiUsage} />}
</div>
{p.description && (
<div style={{fontSize:12,color:'var(--text2)',marginTop:3,lineHeight:1.5}}>
{p.description}
</div>
)}
{existing && (
<div style={{fontSize:11,color:'var(--text3)',marginTop:3}}>
Letzte Analyse: {dayjs(existing.created).format('DD.MM.YYYY, HH:mm')}
</div>
)}
</div>
<div
title={aiUsage && !aiUsage.allowed ? `Limit erreicht (${aiUsage.used}/${aiUsage.limit}). Kontaktiere den Admin oder warte bis zum nächsten Reset.` : ''}
style={{display:'inline-block'}}
>
<button
type="button"
className="btn btn-primary"
style={{flexShrink:0,minWidth:100, cursor: (aiUsage && !aiUsage.allowed) ? 'not-allowed' : 'pointer'}}
onClick={() => runPrompt(p.slug)}
disabled={!!loading||!canUseAI||(aiUsage && !aiUsage.allowed)}
>
{loading===p.slug
? <><div className="spinner" style={{width:13,height:13}}/> Läuft</>
: (aiUsage && !aiUsage.allowed) ? '🔒 Limit'
: <><Brain size={13}/> Starten</>}
</button>
</div>
</div>
{existing && newResult?.id !== existing.id && (
<div style={{marginTop:8,borderTop:'1px solid var(--border)',paddingTop:8}}>
<InsightCard ins={existing} onDelete={deleteInsight} defaultOpen={false} prompts={prompts}/>
</div>
)}
</div>
)
})}
</>
)
})()}
</div> </div>
{/* Show existing result collapsed */}
{existing && newResult?.id !== existing.id && (
<div style={{marginTop:8,borderTop:'1px solid var(--border)',paddingTop:8}}>
<InsightCard ins={existing} onDelete={deleteInsight} defaultOpen={false} prompts={prompts}/>
</div>
)}
</div> </div>
) </>
})} )}
{canUseAI && pipelinePrompts.length === 0 && ( {canUseAI && pipelinePrompts.length === 0 && (
<div className="empty-state"> <div className="empty-state">
<p>Keine aktiven Pipeline-Prompts verfügbar.</p> <p>Keine aktiven Pipeline-Prompts verfügbar.</p>
<p style={{fontSize:12,color:'var(--text3)',marginTop:8}}> <p style={{fontSize:12,color:'var(--text3)',marginTop:8}}>
Erstelle Pipeline-Prompts im Admin-Bereich (Einstellungen Admin KI-Prompts). Erstelle Pipeline-Prompts im Admin-Bereich unter Admin KI-Prompts.
</p> </p>
</div> </div>
)} )}
@ -519,18 +642,33 @@ export default function Analysis() {
{/* ── Verlauf gruppiert ── */} {/* ── Verlauf gruppiert ── */}
{tab==='history' && ( {tab==='history' && (
<div> <div>
{allInsights.length===0 {allInsights.length===0 ? (
? <div className="empty-state"><h3>Noch keine Analysen</h3></div> <div className="empty-state"><h3>Noch keine Analysen</h3></div>
: Object.entries(grouped).map(([scope, ins]) => ( ) : (
<div key={scope} style={{marginBottom:20}}> <div className="analysis-split">
<div style={{fontSize:13,fontWeight:700,color:'var(--text3)', <div className="analysis-split__nav-wrap">
textTransform:'uppercase',letterSpacing:'0.05em',marginBottom:8}}> <nav className="analysis-split__nav" aria-label="Gespeicherte Analysen">
{prompts.find(p => p.slug === scope)?.display_name || SLUG_LABELS[scope] || scope} ({ins.length}) {historyScopeKeys.map(scope => (
</div> <button
{ins.map(i => <InsightCard key={i.id} ins={i} onDelete={deleteInsight} prompts={prompts}/>)} key={scope}
type="button"
className={'analysis-split__nav-item' + (activeHistoryScope === scope ? ' analysis-split__nav-item--active' : '')}
onClick={() => setHistoryScopePick(scope)}
aria-current={activeHistoryScope === scope ? 'page' : undefined}
>
{prompts.find(pr => pr.slug === scope)?.display_name || SLUG_LABELS[scope] || scope}
<span className="muted" style={{ fontSize: 12 }}> ({grouped[scope].length})</span>
</button>
))}
</nav>
</div> </div>
)) <div className="analysis-split__main">
} {activeHistoryScope && grouped[activeHistoryScope]?.map(i => (
<InsightCard key={i.id} ins={i} onDelete={deleteInsight} prompts={prompts}/>
))}
</div>
</div>
)}
</div> </div>
)} )}
</div> </div>

View File

@ -160,7 +160,7 @@ export default function CaliperScreen() {
} }
return ( return (
<div> <div className="capture-page">
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between',marginBottom:16}}> <div style={{display:'flex',alignItems:'center',justifyContent:'space-between',marginBottom:16}}>
<h1 className="page-title" style={{margin:0}}>Caliper</h1> <h1 className="page-title" style={{margin:0}}>Caliper</h1>
<button className="btn btn-secondary" style={{fontSize:12,padding:'6px 10px'}} onClick={()=>nav('/guide')}> <button className="btn btn-secondary" style={{fontSize:12,padding:'6px 10px'}} onClick={()=>nav('/guide')}>

View File

@ -1,94 +1,14 @@
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { ChevronRight } from 'lucide-react' import { ChevronRight } from 'lucide-react'
import { CAPTURE_HUB_TILES } from '../config/captureNav'
const ENTRIES = [
{
icon: '⚖️',
label: 'Gewicht',
sub: 'Tägliche Gewichtseingabe',
to: '/weight',
color: '#378ADD',
},
{
icon: '🪄',
label: 'Assistent',
sub: 'Schritt-für-Schritt Messung (Umfänge & Caliper)',
to: '/wizard',
color: '#7F77DD',
highlight: true,
},
{
icon: '📏',
label: 'Umfänge',
sub: 'Hals, Brust, Taille, Bauch, Hüfte, Oberschenkel, Wade, Arm',
to: '/circum',
color: '#1D9E75',
},
{
icon: '📐',
label: 'Caliper',
sub: 'Körperfett per Hautfaltenmessung',
to: '/caliper',
color: '#D85A30',
},
{
icon: '🍽️',
label: 'Ernährung',
sub: 'FDDB CSV importieren',
to: '/nutrition',
color: '#EF9F27',
},
{
icon: '🏋️',
label: 'Aktivität',
sub: 'Training manuell oder Apple Health importieren',
to: '/activity',
color: '#D4537E',
},
{
icon: '🌙',
label: 'Schlaf',
sub: 'Schlafdaten erfassen oder Apple Health importieren',
to: '/sleep',
color: '#7B68EE',
},
{
icon: '🛌',
label: 'Ruhetage',
sub: 'Kraft-, Cardio-, oder Entspannungs-Ruhetag erfassen',
to: '/rest-days',
color: '#9B59B6',
},
{
icon: '❤️',
label: 'Vitalwerte',
sub: 'Ruhepuls und HRV morgens erfassen',
to: '/vitals',
color: '#E74C3C',
},
{
icon: '🎯',
label: 'Eigene Ziele',
sub: 'Fortschritte für individuelle Ziele erfassen',
to: '/custom-goals',
color: '#1D9E75',
},
{
icon: '📖',
label: 'Messanleitung',
sub: 'Wie und wo genau messen?',
to: '/guide',
color: '#888780',
},
]
export default function CaptureHub() { export default function CaptureHub() {
const nav = useNavigate() const nav = useNavigate()
return ( return (
<div> <div className="capture-page">
<h1 className="page-title">Erfassen</h1> <h1 className="page-title">Erfassen</h1>
<div style={{display:'flex',flexDirection:'column',gap:10}}> <div style={{display:'flex',flexDirection:'column',gap:10}}>
{ENTRIES.map(e => ( {CAPTURE_HUB_TILES.map(e => (
<button key={e.to} onClick={()=>nav(e.to)} <button key={e.to} onClick={()=>nav(e.to)}
style={{ style={{
display:'flex', alignItems:'center', gap:14, display:'flex', alignItems:'center', gap:14,

View File

@ -85,7 +85,7 @@ export default function CircumScreen() {
} }
return ( return (
<div> <div className="capture-page">
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between',marginBottom:16}}> <div style={{display:'flex',alignItems:'center',justifyContent:'space-between',marginBottom:16}}>
<h1 className="page-title" style={{margin:0}}>Umfänge</h1> <h1 className="page-title" style={{margin:0}}>Umfänge</h1>
<button className="btn btn-secondary" style={{fontSize:12,padding:'6px 10px'}} onClick={()=>nav('/guide')}> <button className="btn btn-secondary" style={{fontSize:12,padding:'6px 10px'}} onClick={()=>nav('/guide')}>

View File

@ -117,7 +117,7 @@ export default function CustomGoalsPage() {
} }
return ( return (
<div style={{ paddingBottom: 80 }}> <div className="capture-page" style={{ paddingBottom: 80 }}>
{/* Header */} {/* Header */}
<div style={{ <div style={{
background: 'linear-gradient(135deg, var(--accent) 0%, var(--accent-dark) 100%)', background: 'linear-gradient(135deg, var(--accent) 0%, var(--accent-dark) 100%)',

View File

@ -1,6 +1,6 @@
import { useState, useEffect, useRef } from 'react' import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { Check, ChevronRight, Brain } from 'lucide-react' import { Check, Brain } from 'lucide-react'
import { import {
LineChart, Line, XAxis, YAxis, Tooltip, LineChart, Line, XAxis, YAxis, Tooltip,
ResponsiveContainer, CartesianGrid ResponsiveContainer, CartesianGrid
@ -13,10 +13,17 @@ import EmailVerificationBanner from '../components/EmailVerificationBanner'
import TrainingTypeDistribution from '../components/TrainingTypeDistribution' import TrainingTypeDistribution from '../components/TrainingTypeDistribution'
import SleepWidget from '../components/SleepWidget' import SleepWidget from '../components/SleepWidget'
import RestDaysWidget from '../components/RestDaysWidget' import RestDaysWidget from '../components/RestDaysWidget'
import { getInterpretation, getStatusColor, getStatusBg } from '../utils/interpret'
import Markdown from '../utils/Markdown' import Markdown from '../utils/Markdown'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import 'dayjs/locale/de' import 'dayjs/locale/de'
import DashboardSection from '../components/DashboardSection'
import DashboardTile from '../components/DashboardTile'
import {
clampTileSpan,
DASHBOARD_TILE_GRID_COLS,
dashboardStatGridClassName,
dashboardTileGridClassName
} from '../utils/dashboardLayout'
dayjs.locale('de') dayjs.locale('de')
// Helpers // Helpers
@ -144,15 +151,37 @@ function Pill({ label, value, status, sub }) {
} }
// Stat Card // Stat Card
function StatCard({ icon, label, value, unit, delta, deltaGoodWhenNeg=false, sub, onClick, color }) { /**
* KPI-Kachel im Dashboard-Raster (`dashboard-stat-grid` / `dashboard-tile-grid`).
* @param {number} [spanMobile=1] Spaltenbreite unter 1024px (max. = Raster-Spalten mobile)
* @param {number} [spanDesktop=1] Spaltenbreite 1024px (max. 4)
*/
function StatCard({
icon,
label,
value,
unit,
delta,
deltaGoodWhenNeg = false,
sub,
onClick,
color,
spanMobile = 1,
spanDesktop = 1
}) {
const deltaColor = delta==null ? null const deltaColor = delta==null ? null
: (deltaGoodWhenNeg ? delta<0 : delta>0) ? 'var(--accent)' : 'var(--warn)' : (deltaGoodWhenNeg ? delta<0 : delta>0) ? 'var(--accent)' : 'var(--warn)'
const sm = clampTileSpan(spanMobile, DASHBOARD_TILE_GRID_COLS.mobile)
const lg = clampTileSpan(spanDesktop, DASHBOARD_TILE_GRID_COLS.desktop)
return ( return (
<div onClick={onClick} style={{ <div
flex:1, minWidth:80, background:'var(--surface)', borderRadius:12, className="dashboard-stat-card"
padding:'12px 10px', cursor:onClick?'pointer':'default', onClick={onClick}
border:'1px solid var(--border)', transition:'border-color 0.15s', style={{
}} cursor: onClick ? 'pointer' : 'default',
'--tile-sm': String(sm),
'--tile-lg': String(lg)
}}
onMouseEnter={e=>onClick&&(e.currentTarget.style.borderColor='var(--accent)')} onMouseEnter={e=>onClick&&(e.currentTarget.style.borderColor='var(--accent)')}
onMouseLeave={e=>onClick&&(e.currentTarget.style.borderColor='var(--border)')}> onMouseLeave={e=>onClick&&(e.currentTarget.style.borderColor='var(--border)')}>
<div style={{fontSize:18,marginBottom:4}}>{icon}</div> <div style={{fontSize:18,marginBottom:4}}>{icon}</div>
@ -261,7 +290,6 @@ export default function Dashboard() {
const runPipeline = async () => { const runPipeline = async () => {
setPipelineLoading(true); setPipelineError(null) setPipelineLoading(true); setPipelineError(null)
try { try {
const pid = localStorage.getItem('mitai-jinkendo_active_profile')||''
await api.insightPipeline() await api.insightPipeline()
await load() await load()
} catch(e) { } catch(e) {
@ -269,12 +297,7 @@ export default function Dashboard() {
} finally { setPipelineLoading(false) } } finally { setPipelineLoading(false) }
} }
useEffect(()=>{ useEffect(()=>{ load() },[])
console.log('[Dashboard] Component mounted, loading data...')
load()
},[])
console.log('[Dashboard] Rendering, loading=', loading, 'activeProfile=', activeProfile?.name)
if (loading) return <div className="empty-state"><div className="spinner"/></div> if (loading) return <div className="empty-state"><div className="spinner"/></div>
@ -318,16 +341,20 @@ export default function Dashboard() {
const hasAnyData = latestW||latestCal||nutrition.length>0 const hasAnyData = latestW||latestCal||nutrition.length>0
console.log('[Dashboard] hasAnyData=', hasAnyData, 'latestW=', !!latestW, 'latestCal=', !!latestCal, 'nutrition.length=', nutrition.length) const showNutrSummary = !!(avgKcal || avgProtein)
const showActSummary = actKcal != null
const summaryBoth = showNutrSummary && showActSummary
const summarySpanM = summaryBoth ? 1 : 2
const summarySpanD = summaryBoth ? 2 : 4
return ( return (
<div> <div className="dashboard-page">
{/* Header greeting */} {/* Header greeting */}
<div style={{marginBottom:16}}> <div className="dashboard-greeting">
<h1 style={{fontSize:22,fontWeight:800,margin:0,color:'var(--text1)'}}> <h1 style={{fontSize:22,fontWeight:800,margin:0,color:'var(--text1)'}}>
Hallo, {activeProfile?.name||'Nutzer'} 👋 Hallo, {activeProfile?.name||'Nutzer'} 👋
</h1> </h1>
<div style={{fontSize:12,color:'var(--text3)',marginTop:2}}> <div className="dashboard-greeting__meta" style={{fontSize:12,color:'var(--text3)',marginTop:2}}>
{dayjs().format('dddd, DD. MMMM YYYY')} {dayjs().format('dddd, DD. MMMM YYYY')}
{latestW && ` · Letztes Update ${dayjs(latestW.date).format('DD.MM.')}`} {latestW && ` · Letztes Update ${dayjs(latestW.date).format('DD.MM.')}`}
</div> </div>
@ -350,46 +377,54 @@ export default function Dashboard() {
)} )}
{hasAnyData && <> {hasAnyData && <>
{/* Quick weight entry */} <DashboardSection
<div className="card section-gap"> title="Gewicht heute"
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between',marginBottom:10}}> description="Tageswert erfassen Grundlage für Trends und Ziele."
<div style={{fontWeight:600,fontSize:14}}> Gewicht heute</div> headerRight={
<button style={{background:'none',border:'none',fontSize:12,color:'var(--accent)',cursor:'pointer'}} <button type="button" className="btn btn-secondary"
onClick={()=>nav('/weight')}> style={{ fontSize: 12, padding: '6px 12px' }}
onClick={() => nav('/weight')}>
Alle Einträge Alle Einträge
</button> </button>
}
>
<div className="card section-gap">
<QuickWeight onSaved={load}/>
</div> </div>
<QuickWeight onSaved={load}/> </DashboardSection>
</div>
{/* Key metrics */} <DashboardSection
<div style={{display:'flex',gap:8,marginBottom:16,flexWrap:'wrap'}}> title="Kennzahlen"
<StatCard icon="⚖️" label="Gewicht" value={latestW?.weight??''} unit="kg" description="Aktuelle Messwerte und Ernährungs-Schnitt (7 Tage)."
delta={wDelta} deltaGoodWhenNeg={true} >
sub={latestW ? dayjs(latestW.date).format('DD.MM.') : ''} <div className={dashboardStatGridClassName(DASHBOARD_TILE_GRID_COLS.mobile)}>
onClick={()=>nav('/history')} color="#378ADD"/> <StatCard icon="⚖️" label="Gewicht" value={latestW?.weight??''} unit="kg"
{latestCal?.body_fat_pct && <StatCard icon="🫧" label="Körperfett" value={latestCal.body_fat_pct} unit="%" delta={wDelta} deltaGoodWhenNeg={true}
delta={bfDelta} deltaGoodWhenNeg={true} sub={latestW ? dayjs(latestW.date).format('DD.MM.') : ''}
sub={bfCat?.label} onClick={()=>nav('/history')} color="#378ADD"/>
onClick={()=>nav('/history',{state:{tab:'body'}})} color={bfCat?.color}/>} {latestCal?.body_fat_pct && <StatCard icon="🫧" label="Körperfett" value={latestCal.body_fat_pct} unit="%"
{latestCal?.lean_mass && <StatCard icon="💪" label="Magermasse" value={latestCal.lean_mass} unit="kg" delta={bfDelta} deltaGoodWhenNeg={true}
sub={latestCal.date ? dayjs(latestCal.date).format('DD.MM.') : ''} sub={bfCat?.label}
onClick={()=>nav('/history',{state:{tab:'body'}})}/>} onClick={()=>nav('/history',{state:{tab:'body'}})} color={bfCat?.color}/>}
{avgKcal && <StatCard icon="🍽️" label="Ø Kalorien" value={avgKcal} unit="kcal" {latestCal?.lean_mass && <StatCard icon="💪" label="Magermasse" value={latestCal.lean_mass} unit="kg"
sub="letzte 7 Tage" onClick={()=>nav('/history',{state:{tab:'nutrition'}})} color="#EF9F27"/>} sub={latestCal.date ? dayjs(latestCal.date).format('DD.MM.') : ''}
</div> onClick={()=>nav('/history',{state:{tab:'body'}})}/>}
{avgKcal && <StatCard icon="🍽️" label="Ø Kalorien" value={avgKcal} unit="kcal"
{/* Status pills */} sub="letzte 7 Tage" onClick={()=>nav('/history',{state:{tab:'nutrition'}})} color="#EF9F27"/>}
{pills.length > 0 && (
<div style={{display:'flex',gap:6,flexWrap:'wrap',marginBottom:16}}>
{pills.map((p,i)=><Pill key={i} {...p}/>)}
</div> </div>
)} {pills.length > 0 && (
<div className="dashboard-pill-row">
{pills.map((p,i)=><Pill key={i} {...p}/>)}
</div>
)}
</DashboardSection>
{/* Goals progress */}
{(activeProfile?.goal_weight||activeProfile?.goal_bf_pct) && latestW && ( {(activeProfile?.goal_weight||activeProfile?.goal_bf_pct) && latestW && (
<div className="card section-gap" style={{marginBottom:16}}> <DashboardSection
<div style={{fontWeight:600,fontSize:13,marginBottom:10}}>🎯 Ziele</div> title="Profil-Ziele"
description="Fortschritt zu den Zielwerten in deinem Profil."
>
<div className="card section-gap">
{activeProfile?.goal_weight && latestW && (()=>{ {activeProfile?.goal_weight && latestW && (()=>{
const start = Math.max(...weights.map(w=>w.weight)) const start = Math.max(...weights.map(w=>w.weight))
const curr = latestW.weight const curr = latestW.weight
@ -430,134 +465,167 @@ export default function Dashboard() {
) )
})()} })()}
</div> </div>
</DashboardSection>
)} )}
{/* Combined chart */}
{(weights.length>2||nutrition.length>2) && ( {(weights.length>2||nutrition.length>2) && (
<div className="card section-gap" style={{marginBottom:16}}> <DashboardSection
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:8}}> title="Trends"
<div style={{fontWeight:600,fontSize:13}}>📊 Kalorien + Gewicht (30 Tage)</div> description="Kalorien und Gewicht der letzten 30 Tage."
<button style={{background:'none',border:'none',fontSize:12,color:'var(--accent)',cursor:'pointer'}} headerRight={
<button type="button" className="btn btn-secondary" style={{ fontSize: 12, padding: '6px 12px' }}
onClick={()=>nav('/history',{state:{tab:'body'}})}> onClick={()=>nav('/history',{state:{tab:'body'}})}>
Details Details
</button> </button>
</div> }
<ComboChart weights={weights} nutrition={nutrition}/> >
<div style={{display:'flex',gap:16,justifyContent:'center',marginTop:6,fontSize:10,color:'var(--text3)'}}> <DashboardTile>
<span><span style={{display:'inline-block',width:12,height:2,background:'#EF9F27',verticalAlign:'middle',marginRight:3}}/>Ø Kalorien</span> <div className="card section-gap">
<span><span style={{display:'inline-block',width:12,height:2,background:'#378ADD',verticalAlign:'middle',marginRight:3}}/>Gewicht</span> <ComboChart weights={weights} nutrition={nutrition}/>
</div> <div style={{display:'flex',gap:16,justifyContent:'center',marginTop:6,fontSize:10,color:'var(--text3)'}}>
</div> <span><span style={{display:'inline-block',width:12,height:2,background:'#EF9F27',verticalAlign:'middle',marginRight:3}}/>Ø Kalorien</span>
<span><span style={{display:'inline-block',width:12,height:2,background:'#378ADD',verticalAlign:'middle',marginRight:3}}/>Gewicht</span>
</div>
</div>
</DashboardTile>
</DashboardSection>
)} )}
{/* Activity + Nutrition summary row */} {(showNutrSummary || showActSummary) && (
<div style={{display:'flex',gap:8,marginBottom:16}}> <DashboardSection
{(avgKcal||avgProtein) && ( title="Ernährung & Aktivität"
<div className="card" style={{flex:1,cursor:'pointer'}} onClick={()=>nav('/history',{state:{tab:'nutrition'}})}> description="Kurzüberblick; volle Verläufe unter Historie."
<div style={{fontWeight:600,fontSize:12,marginBottom:8,color:'var(--text3)'}}>🍽 ERNÄHRUNG (Ø 7T)</div> >
{avgKcal && <div style={{fontSize:16,fontWeight:700,color:'#EF9F27'}}>{avgKcal} kcal</div>} <div className={`dashboard-summary-row ${dashboardTileGridClassName(DASHBOARD_TILE_GRID_COLS.mobile)}`}>
{avgProtein && <div style={{fontSize:13,fontWeight:600, {showNutrSummary && (
color:proteinOk?'var(--accent)':'var(--warn)'}}> <DashboardTile spanMobile={summarySpanM} spanDesktop={summarySpanD}>
{avgProtein}g Protein {proteinOk?'✓':'⚠️'} <div className="card" style={{ cursor: 'pointer', height: '100%' }} onClick={()=>nav('/history',{state:{tab:'nutrition'}})}>
</div>} <div style={{fontWeight:600,fontSize:12,marginBottom:8,color:'var(--text3)'}}>🍽 ERNÄHRUNG (Ø 7T)</div>
<div style={{fontSize:10,color:'var(--text3)',marginTop:4}}> Verlauf Ernährung</div> {avgKcal && <div style={{fontSize:16,fontWeight:700,color:'#EF9F27'}}>{avgKcal} kcal</div>}
{avgProtein && <div style={{fontSize:13,fontWeight:600,
color:proteinOk?'var(--accent)':'var(--warn)'}}>
{avgProtein}g Protein {proteinOk?'✓':'⚠️'}
</div>}
<div style={{fontSize:10,color:'var(--text3)',marginTop:4}}> Verlauf Ernährung</div>
</div>
</DashboardTile>
)}
{showActSummary && (
<DashboardTile spanMobile={summarySpanM} spanDesktop={summarySpanD}>
<div className="card" style={{ cursor: 'pointer', height: '100%' }} onClick={()=>nav('/history',{state:{tab:'activity'}})}>
<div style={{fontWeight:600,fontSize:12,marginBottom:8,color:'var(--text3)'}}>🏋 AKTIVITÄT (7T)</div>
<div style={{fontSize:16,fontWeight:700,color:'#EF9F27'}}>{actKcal} kcal</div>
<div style={{fontSize:13,color:'var(--text2)'}}>{recentAct.length} Trainings</div>
<div style={{fontSize:10,color:'var(--text3)',marginTop:4}}> Verlauf Aktivität</div>
</div>
</DashboardTile>
)}
</div> </div>
)} </DashboardSection>
{actKcal!=null && ( )}
<div className="card" style={{flex:1,cursor:'pointer'}} onClick={()=>nav('/history',{state:{tab:'activity'}})}>
<div style={{fontWeight:600,fontSize:12,marginBottom:8,color:'var(--text3)'}}>🏋 AKTIVITÄT (7T)</div>
<div style={{fontSize:16,fontWeight:700,color:'#EF9F27'}}>{actKcal} kcal</div>
<div style={{fontSize:13,color:'var(--text2)'}}>{recentAct.length} Trainings</div>
<div style={{fontSize:10,color:'var(--text3)',marginTop:4}}> Verlauf Aktivität</div>
</div>
)}
</div>
{/* Sleep Widget */} <DashboardSection
<div style={{marginBottom:16}}> title="Erholung"
<SleepWidget/> description="Schlaf und Ruhetage im Überblick."
</div> >
<div className={`dashboard-erholung-grid ${dashboardTileGridClassName(DASHBOARD_TILE_GRID_COLS.mobile)}`}>
<DashboardTile spanMobile={1} spanDesktop={2}>
<SleepWidget/>
</DashboardTile>
<DashboardTile spanMobile={1} spanDesktop={2}>
<RestDaysWidget/>
</DashboardTile>
</div>
</DashboardSection>
{/* Rest Days Widget */}
<div style={{marginBottom:16}}>
<RestDaysWidget/>
</div>
{/* Training Type Distribution */}
{activities.length > 0 && ( {activities.length > 0 && (
<div className="card section-gap" style={{marginBottom:16}}> <DashboardSection
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:12}}> title="Training"
<div style={{fontWeight:600,fontSize:13}}>🏋 Trainingstyp-Verteilung</div> description="Verteilung der Trainingstypen (28 Tage)."
<button style={{background:'none',border:'none',fontSize:12,color:'var(--accent)',cursor:'pointer'}} headerRight={
<button type="button" className="btn btn-secondary" style={{ fontSize: 12, padding: '6px 12px' }}
onClick={()=>nav('/activity')}> onClick={()=>nav('/activity')}>
Details Details
</button> </button>
</div> }
<TrainingTypeDistribution days={28} /> >
</div> <DashboardTile>
<div className="card section-gap">
<TrainingTypeDistribution days={28} />
</div>
</DashboardTile>
</DashboardSection>
)} )}
{/* Goals Preview */} <DashboardSection
<div className="card section-gap" style={{marginBottom:16,cursor:'pointer'}} title="Ziele & Fokus"
onClick={()=>nav('/goals')}> description="Trainingsmodus, Schwerpunkte und konkrete Ziele für die KI."
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:12}}> headerRight={
<div style={{fontWeight:600,fontSize:13}}>🎯 Ziele</div> <button type="button" className="btn btn-secondary" style={{ fontSize: 12, padding: '6px 12px' }}
<button style={{background:'none',border:'none',fontSize:12,color:'var(--accent)',cursor:'pointer'}} onClick={(e)=>{ e.stopPropagation(); nav('/goals') }}>
onClick={(e)=>{e.stopPropagation();nav('/goals')}}>
Verwalten Verwalten
</button> </button>
</div> }
<div style={{fontSize:12,color:'var(--text2)',padding:'8px 0'}}> >
Definiere deine Trainingsmodus und konkrete Ziele für bessere KI-Analysen <DashboardTile>
</div> <div className="card section-gap" style={{ cursor: 'pointer' }} onClick={()=>nav('/goals')}>
</div> <div style={{fontSize:12,color:'var(--text2)',padding:'8px 0'}}>
Definiere deine Trainingsmodus und konkrete Ziele für bessere KI-Analysen
</div>
</div>
</DashboardTile>
</DashboardSection>
{/* Latest AI insight */} <DashboardSection
<div className="card section-gap"> title="KI-Auswertung"
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between',marginBottom:8}}> description="Mehrstufige Pipeline und letzte Zusammenfassung."
<div style={{fontWeight:600,fontSize:13}}>🤖 KI-Auswertung</div> headerRight={
<button className="btn btn-secondary" style={{fontSize:11,padding:'4px 10px'}} <button type="button" className="btn btn-secondary" style={{ fontSize: 11, padding: '4px 10px' }}
onClick={()=>nav('/analysis')}> onClick={()=>nav('/analysis')}>
<Brain size={11}/> Analysen <Brain size={11}/> Analysen
</button> </button>
</div> }
{/* Pipeline trigger */} >
<button className="btn btn-primary btn-full" style={{marginBottom:10}} <DashboardTile>
onClick={runPipeline} disabled={pipelineLoading}> <div className="card section-gap">
{pipelineLoading <button type="button" className="btn btn-primary btn-full" style={{marginBottom:10}}
? <><div className="spinner" style={{width:13,height:13}}/> Analyse läuft (3 Stufen)</> onClick={runPipeline} disabled={pipelineLoading}>
: <><Brain size={13}/> 🔬 Mehrstufige Analyse starten</>} {pipelineLoading
</button> ? <><div className="spinner" style={{width:13,height:13}}/> Analyse läuft (3 Stufen)</>
{pipelineError && <div style={{fontSize:12,color:'#D85A30',marginBottom:8}}>{pipelineError}</div>} : <><Brain size={13}/> 🔬 Mehrstufige Analyse starten</>}
</button>
{pipelineError && <div style={{fontSize:12,color:'#D85A30',marginBottom:8}}>{pipelineError}</div>}
{latestInsight ? ( {latestInsight ? (
<> <>
<div style={{fontSize:11,color:'var(--text3)',marginBottom:6}}> <div style={{fontSize:11,color:'var(--text3)',marginBottom:6}}>
Letzte Analyse: {dayjs(latestInsight.created).format('DD. MMMM YYYY, HH:mm')} Letzte Analyse: {dayjs(latestInsight.created).format('DD. MMMM YYYY, HH:mm')}
</div> </div>
<div style={{maxHeight: showInsight?'none':120, overflow:'hidden', position:'relative'}}> <div style={{maxHeight: showInsight?'none':120, overflow:'hidden', position:'relative'}}>
<Markdown text={latestInsight.content}/> <Markdown text={latestInsight.content}/>
{!showInsight && ( {!showInsight && (
<div style={{position:'absolute',bottom:0,left:0,right:0,height:40, <div style={{position:'absolute',bottom:0,left:0,right:0,height:40,
background:'linear-gradient(transparent,var(--surface))'}}/> background:'linear-gradient(transparent,var(--surface))'}}/>
)} )}
</div> </div>
<button style={{background:'none',border:'none',cursor:'pointer', <button type="button" style={{background:'none',border:'none',cursor:'pointer',
fontSize:12,color:'var(--accent)',marginTop:6,padding:0}} fontSize:12,color:'var(--accent)',marginTop:6,padding:0}}
onClick={()=>setShowInsight(s=>!s)}> onClick={()=>setShowInsight(s=>!s)}>
{showInsight?'▲ Weniger anzeigen':'▼ Vollständig anzeigen'} {showInsight?'▲ Weniger anzeigen':'▼ Vollständig anzeigen'}
</button> </button>
</> </>
) : ( ) : (
<div style={{fontSize:13,color:'var(--text3)',padding:'8px 0'}}> <div style={{fontSize:13,color:'var(--text3)',padding:'8px 0'}}>
Noch keine KI-Auswertung vorhanden. Noch keine KI-Auswertung vorhanden.
<button className="btn btn-primary" style={{marginTop:8,display:'block',fontSize:12}} <button type="button" className="btn btn-primary" style={{marginTop:8,display:'block',fontSize:12}}
onClick={()=>nav('/analysis')}> onClick={()=>nav('/analysis')}>
Erste Analyse erstellen Erste Analyse erstellen
</button> </button>
</div>
)}
</div> </div>
)} </DashboardTile>
</div> </DashboardSection>
</>} </>}
</div> </div>
) )

View File

@ -36,7 +36,7 @@ export default function GuidePage() {
const methodPoints = CALIPER_METHODS[caliperMethod]?.points_m || [] const methodPoints = CALIPER_METHODS[caliperMethod]?.points_m || []
return ( return (
<div> <div className="capture-page">
<h1 className="page-title">Messanleitung</h1> <h1 className="page-title">Messanleitung</h1>
<div className="tabs"> <div className="tabs">

View File

@ -985,6 +985,11 @@ export default function History() {
useEffect(()=>{ loadAll() },[]) useEffect(()=>{ loadAll() },[])
useEffect(() => {
const t = location.state?.tab
if (t && TABS.some(x => x.id === t)) setTab(t)
}, [location.state?.tab])
const requestInsight = async (slug) => { const requestInsight = async (slug) => {
setLoadingSlug(slug) setLoadingSlug(slug)
try { try {
@ -1007,27 +1012,33 @@ export default function History() {
const sp={insights,onRequest:requestInsight,loadingSlug,filterActiveSlugs} const sp={insights,onRequest:requestInsight,loadingSlug,filterActiveSlugs}
return ( return (
<div> <div className="history-page">
<h1 className="page-title">Verlauf & Auswertung</h1> <h1 className="page-title history-page__title">Verlauf & Auswertung</h1>
<div style={{display:'flex',gap:6,overflowX:'auto',paddingBottom:6,marginBottom:16, <div className="history-page__layout">
msOverflowStyle:'none',scrollbarWidth:'none'}}> <nav className="history-tabs" aria-label="Verlauf-Kategorien">
{TABS.map(t=>( <div className="history-tabs__scroller">
<button key={t.id} onClick={()=>setTab(t.id)} {TABS.map(t => (
style={{whiteSpace:'nowrap',padding:'7px 14px',borderRadius:20,flexShrink:0, <button
border:`1.5px solid ${tab===t.id?'var(--accent)':'var(--border2)'}`, key={t.id}
background:tab===t.id?'var(--accent)':'var(--surface)', type="button"
color:tab===t.id?'white':'var(--text2)', className={`history-tab-btn${tab === t.id ? ' history-tab-btn--active' : ''}`}
fontFamily:'var(--font)',fontSize:13,fontWeight:500,cursor:'pointer'}}> onClick={() => setTab(t.id)}
{t.label} aria-current={tab === t.id ? 'page' : undefined}
</button> >
))} {t.label}
</button>
))}
</div>
</nav>
<div className="history-content">
{tab==='body' && <BodySection weights={weights} calipers={calipers} circs={circs} profile={profile} {...sp}/>}
{tab==='nutrition' && <NutritionSection nutrition={nutrition} weights={weights} profile={profile} {...sp}/>}
{tab==='activity' && <ActivitySection activities={activities} globalQualityLevel={activeProfile?.quality_filter_level} {...sp}/>}
{tab==='recovery' && <RecoverySection {...sp}/>}
{tab==='correlation' && <CorrelationSection corrData={corrData} profile={profile} {...sp}/>}
{tab==='photos' && <PhotoGrid/>}
</div>
</div> </div>
{tab==='body' && <BodySection weights={weights} calipers={calipers} circs={circs} profile={profile} {...sp}/>}
{tab==='nutrition' && <NutritionSection nutrition={nutrition} weights={weights} profile={profile} {...sp}/>}
{tab==='activity' && <ActivitySection activities={activities} globalQualityLevel={activeProfile?.quality_filter_level} {...sp}/>}
{tab==='recovery' && <RecoverySection {...sp}/>}
{tab==='correlation' && <CorrelationSection corrData={corrData} profile={profile} {...sp}/>}
{tab==='photos' && <PhotoGrid/>}
</div> </div>
) )
} }

View File

@ -344,7 +344,7 @@ export default function MeasureWizard() {
if (done) { if (done) {
return ( return (
<div style={{display:'flex',flexDirection:'column',alignItems:'center',justifyContent:'center', <div className="capture-page" style={{display:'flex',flexDirection:'column',alignItems:'center',justifyContent:'center',
minHeight:'60vh',gap:16,textAlign:'center'}}> minHeight:'60vh',gap:16,textAlign:'center'}}>
<div style={{fontSize:48}}></div> <div style={{fontSize:48}}></div>
<h2 style={{fontSize:20,fontWeight:700}}>Gespeichert!</h2> <h2 style={{fontSize:20,fontWeight:700}}>Gespeichert!</h2>
@ -361,11 +361,19 @@ export default function MeasureWizard() {
) )
} }
if (mode === 'circum') return <CircumWizard onDone={()=>setDone(true)} onCancel={()=>setMode(null)}/> if (mode === 'circum') return (
if (mode === 'caliper') return <CaliperWizard onDone={()=>setDone(true)} onCancel={()=>setMode(null)} profile={profile}/> <div className="capture-page">
<CircumWizard onDone={()=>setDone(true)} onCancel={()=>setMode(null)}/>
</div>
)
if (mode === 'caliper') return (
<div className="capture-page">
<CaliperWizard onDone={()=>setDone(true)} onCancel={()=>setMode(null)} profile={profile}/>
</div>
)
return ( return (
<div> <div className="capture-page">
<h1 className="page-title">Assistent</h1> <h1 className="page-title">Assistent</h1>
<p style={{fontSize:13,color:'var(--text2)',marginBottom:20,lineHeight:1.6}}> <p style={{fontSize:13,color:'var(--text2)',marginBottom:20,lineHeight:1.6}}>
Der Assistent führt dich Schritt für Schritt durch die Messung mit Anleitung für jeden Messpunkt. Der Assistent führt dich Schritt für Schritt durch die Messung mit Anleitung für jeden Messpunkt.

View File

@ -802,7 +802,7 @@ export default function NutritionPage() {
useEffect(() => { load() }, []) useEffect(() => { load() }, [])
return ( return (
<div> <div className="capture-page">
<h1 className="page-title">Ernährung</h1> <h1 className="page-title">Ernährung</h1>
{/* Input Method Tabs */} {/* Input Method Tabs */}

View File

@ -177,7 +177,7 @@ export default function RestDaysPage() {
} }
return ( return (
<div> <div className="capture-page">
<h1 className="page-title">Ruhetage</h1> <h1 className="page-title">Ruhetage</h1>
{/* Toast Notification */} {/* Toast Notification */}

View File

@ -1,103 +1,24 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { Save, Download, Upload, Trash2, Plus, Check, Pencil, X, LogOut, Shield, Key, BarChart3 } from 'lucide-react' import { Save, Download, Upload, Check, LogOut, Key, BarChart3 } from 'lucide-react'
import { Link } from 'react-router-dom'
import { useProfile } from '../context/ProfileContext' import { useProfile } from '../context/ProfileContext'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import { Avatar } from './ProfileSelect' import { Avatar } from './ProfileSelect'
import { api } from '../utils/api' import { api } from '../utils/api'
import AdminPanel from './AdminPanel'
import FeatureUsageOverview from '../components/FeatureUsageOverview' import FeatureUsageOverview from '../components/FeatureUsageOverview'
import UsageBadge from '../components/UsageBadge' import UsageBadge from '../components/UsageBadge'
const COLORS = ['#1D9E75','#378ADD','#D85A30','#EF9F27','#7F77DD','#D4537E','#639922','#888780'] const COLORS = ['#1D9E75','#378ADD','#D85A30','#EF9F27','#7F77DD','#D4537E','#639922','#888780']
function ProfileForm({ profile, onSave, onCancel, title }) { function dobInputValue(dob) {
const [form, setForm] = useState({ if (!dob) return ''
name: profile?.name || '', const s = String(dob)
sex: profile?.sex || 'm', return s.length >= 10 ? s.slice(0, 10) : s
dob: profile?.dob || '',
height: profile?.height || '',
goal_weight: profile?.goal_weight || '',
goal_bf_pct: profile?.goal_bf_pct || '',
avatar_color: profile?.avatar_color || COLORS[0],
})
const set = (k,v) => setForm(f=>({...f,[k]:v}))
return (
<div style={{background:'var(--surface2)',borderRadius:10,padding:14,marginTop:8,
border:'1.5px solid var(--accent)'}}>
{title && <div style={{fontWeight:600,fontSize:14,marginBottom:12,color:'var(--accent)'}}>{title}</div>}
<div className="form-row">
<label className="form-label">Name</label>
<input type="text" className="form-input" value={form.name}
onChange={e=>set('name',e.target.value)} autoFocus/>
<span className="form-unit"/>
</div>
<div style={{marginBottom:12}}>
<div style={{fontSize:12,color:'var(--text3)',marginBottom:8}}>Avatar-Farbe</div>
<div style={{display:'flex',gap:8,alignItems:'center'}}>
<Avatar profile={{...form}} size={36}/>
<div style={{display:'flex',gap:6,flexWrap:'wrap'}}>
{COLORS.map(c=>(
<div key={c} onClick={()=>set('avatar_color',c)}
style={{width:26,height:26,borderRadius:'50%',background:c,cursor:'pointer',
border:`3px solid ${form.avatar_color===c?'white':'transparent'}`,
boxShadow:form.avatar_color===c?`0 0 0 2px ${c}`:'none'}}/>
))}
</div>
</div>
</div>
<div className="form-row">
<label className="form-label">Geschlecht</label>
<select className="form-select" value={form.sex} onChange={e=>set('sex',e.target.value)}>
<option value="m">Männlich</option>
<option value="f">Weiblich</option>
</select>
</div>
<div className="form-row">
<label className="form-label">Geburtsdatum</label>
<input type="date" className="form-input" style={{width:140}} value={form.dob||''}
onChange={e=>set('dob',e.target.value)}/>
<span className="form-unit"/>
</div>
<div className="form-row">
<label className="form-label">Größe</label>
<input type="number" className="form-input" min={100} max={250} value={form.height||''}
onChange={e=>set('height',e.target.value)}/>
<span className="form-unit">cm</span>
</div>
<div style={{fontSize:11,fontWeight:600,color:'var(--text3)',textTransform:'uppercase',
letterSpacing:'0.04em',margin:'10px 0 6px'}}>Ziele (optional)</div>
<div className="form-row">
<label className="form-label">Zielgewicht</label>
<input type="number" className="form-input" min={30} max={300} step={0.1}
value={form.goal_weight||''} onChange={e=>set('goal_weight',e.target.value)} placeholder=""/>
<span className="form-unit">kg</span>
</div>
<div className="form-row">
<label className="form-label">Ziel-KF%</label>
<input type="number" className="form-input" min={3} max={50} step={0.1}
value={form.goal_bf_pct||''} onChange={e=>set('goal_bf_pct',e.target.value)} placeholder=""/>
<span className="form-unit">%</span>
</div>
<div style={{display:'flex',gap:8,marginTop:12}}>
<button className="btn btn-primary" style={{flex:1}} onClick={()=>onSave(form)}>
<Save size={13}/> Speichern
</button>
<button className="btn btn-secondary" style={{flex:1}} onClick={onCancel}>
<X size={13}/> Abbrechen
</button>
</div>
</div>
)
} }
export default function SettingsPage() { export default function SettingsPage() {
const { profiles, activeProfile, setActiveProfile, refreshProfiles } = useProfile() const { profiles, activeProfile, setActiveProfile, refreshProfiles } = useProfile()
const { logout, isAdmin, canExport } = useAuth() const { logout, canExport, isAdmin } = useAuth()
const [adminOpen, setAdminOpen] = useState(false)
const [pinOpen, setPinOpen] = useState(false) const [pinOpen, setPinOpen] = useState(false)
const [newPin, setNewPin] = useState('') const [newPin, setNewPin] = useState('')
const [pinMsg, setPinMsg] = useState(null) const [pinMsg, setPinMsg] = useState(null)
@ -131,8 +52,19 @@ export default function SettingsPage() {
setTimeout(()=>setPinMsg(null), 2000) setTimeout(()=>setPinMsg(null), 2000)
} catch(e) { setPinMsg('Fehler beim Speichern') } } catch(e) { setPinMsg('Fehler beim Speichern') }
} }
// editingId: string ID of profile being edited, or 'new' for new profile, or null const [form, setForm] = useState({
const [editingId, setEditingId] = useState(null) name: '',
email: '',
sex: 'm',
dob: '',
height: '',
goal_weight: '',
goal_bf_pct: '',
avatar_color: COLORS[0],
})
const setF = (k, v) => setForm((f) => ({ ...f, [k]: v }))
const [profileErr, setProfileErr] = useState(null)
const [saved, setSaved] = useState(false) const [saved, setSaved] = useState(false)
const [importing, setImporting] = useState(false) const [importing, setImporting] = useState(false)
const [importMsg, setImportMsg] = useState(null) const [importMsg, setImportMsg] = useState(null)
@ -202,53 +134,78 @@ export default function SettingsPage() {
} }
} }
useEffect(() => {
if (!activeProfile) return
const sexRaw = activeProfile.sex || 'm'
setForm({
name: activeProfile.name || '',
email: activeProfile.email || '',
sex: sexRaw === 'f' ? 'w' : sexRaw,
dob: dobInputValue(activeProfile.dob),
height: activeProfile.height != null ? String(activeProfile.height) : '',
goal_weight: activeProfile.goal_weight != null ? String(activeProfile.goal_weight) : '',
goal_bf_pct: activeProfile.goal_bf_pct != null ? String(activeProfile.goal_bf_pct) : '',
avatar_color: activeProfile.avatar_color || COLORS[0],
})
setProfileErr(null)
}, [activeProfile?.id])
const handleQualityFilterChange = async (level) => { const handleQualityFilterChange = async (level) => {
// Issue #31: Update global quality filter
await api.updateActiveProfile({ quality_filter_level: level }) await api.updateActiveProfile({ quality_filter_level: level })
await refreshProfiles() await refreshProfiles()
const updated = profiles.find(p => p.id === activeProfile?.id) const updated = profiles.find(p => p.id === activeProfile?.id)
if (updated) setActiveProfile({...updated, quality_filter_level: level}) if (updated) setActiveProfile({ ...updated, quality_filter_level: level })
setSaved(true) setSaved(true)
setTimeout(() => setSaved(false), 2000) setTimeout(() => setSaved(false), 2000)
} }
const handleSave = async (form, profileId) => { const handleSaveMyProfile = async () => {
const data = {} if (!activeProfile) return
if (form.name) data.name = form.name const name = form.name.trim()
if (form.sex) data.sex = form.sex if (!name) {
if (form.dob) data.dob = form.dob setProfileErr('Bitte einen Namen eingeben.')
if (form.height) data.height = parseFloat(form.height) return
if (form.avatar_color) data.avatar_color = form.avatar_color }
if (form.goal_weight) data.goal_weight = parseFloat(form.goal_weight) const h = parseFloat(form.height)
if (form.goal_bf_pct) data.goal_bf_pct = parseFloat(form.goal_bf_pct) if (!form.height || Number.isNaN(h) || h < 100 || h > 250) {
setProfileErr('Bitte eine gültige Größe (100250 cm) eingeben.')
if (profileId === 'new') { return
const p = await api.createProfile({ ...data, name: form.name || 'Neues Profil' }) }
await refreshProfiles() let goal_weight = null
// Don't auto-switch just close the form if (form.goal_weight !== '') {
} else { goal_weight = parseFloat(form.goal_weight)
await api.updateProfile(profileId, data) if (Number.isNaN(goal_weight)) {
await refreshProfiles() setProfileErr('Zielgewicht: bitte eine gültige Zahl eingeben oder leer lassen.')
// If editing active profile, update it return
if (profileId === activeProfile?.id) {
const updated = profiles.find(p => p.id === profileId)
if (updated) setActiveProfile({...updated, ...data})
} }
} }
setEditingId(null) let goal_bf_pct = null
setSaved(true) if (form.goal_bf_pct !== '') {
setTimeout(() => setSaved(false), 2000) goal_bf_pct = parseFloat(form.goal_bf_pct)
} if (Number.isNaN(goal_bf_pct)) {
setProfileErr('Ziel-KF%: bitte eine gültige Zahl eingeben oder leer lassen.')
const handleDelete = async (id) => { return
if (!confirm('Profil und ALLE zugehörigen Daten unwiderruflich löschen?')) return }
await api.deleteProfile(id) }
await refreshProfiles() setProfileErr(null)
if (activeProfile?.id === id) { try {
const remaining = profiles.filter(p => p.id !== id) const payload = {
if (remaining.length) setActiveProfile(remaining[0]) name,
sex: form.sex,
dob: form.dob ? form.dob : null,
height: h,
avatar_color: form.avatar_color,
goal_weight,
goal_bf_pct,
email: form.email.trim() === '' ? null : form.email.trim(),
}
await api.updateActiveProfile(payload)
await refreshProfiles()
setSaved(true)
setTimeout(() => setSaved(false), 2000)
} catch (e) {
setProfileErr(e.message || 'Speichern fehlgeschlagen')
} }
setEditingId(null)
} }
const handleExportPlaceholders = async () => { const handleExportPlaceholders = async () => {
@ -272,69 +229,203 @@ export default function SettingsPage() {
<div> <div>
<h1 className="page-title">Einstellungen</h1> <h1 className="page-title">Einstellungen</h1>
{/* Profile list */} {/* Aktives Profil (nur eigenes Profil; weitere Profile nur im Admin) */}
<div className="card section-gap"> <div className="card section-gap">
<div className="card-title">Profile ({profiles.length})</div> <div className="card-title" style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<Avatar profile={{ ...form, name: form.name || '?' }} size={40} />
{profiles.map(p => ( Mein Profil
<div key={p.id}> </div>
<div style={{display:'flex',alignItems:'center',gap:10,padding:'10px 0', <p style={{ fontSize: 13, color: 'var(--text2)', marginBottom: 14, lineHeight: 1.6 }}>
borderBottom:'1px solid var(--border)'}}> Hier bearbeitest du nur das <strong>aktive Profil</strong>. Zum Anlegen weiterer Profile oder zum
<Avatar profile={p} size={40}/> Verwalten anderer Nutzer nutzt du den Admin-Bereich (Zugriff nur als Administrator).
<div style={{flex:1}}> </p>
<div style={{fontSize:14,fontWeight:600}}>{p.name}</div> {isAdmin && (
<div style={{fontSize:11,color:'var(--text3)'}}> <div
{p.sex==='m'?'Männlich':'Weiblich'} style={{
{p.height ? ` · ${p.height} cm` : ''} fontSize: 12,
{p.goal_weight ? ` · Ziel: ${p.goal_weight} kg` : ''} color: 'var(--accent-dark)',
</div> background: 'var(--accent-light)',
</div> padding: '10px 12px',
<div style={{display:'flex',gap:6,alignItems:'center'}}> borderRadius: 8,
{activeProfile?.id === p.id marginBottom: 14,
? <span style={{fontSize:11,color:'var(--accent)',fontWeight:600,padding:'3px 8px', lineHeight: 1.5,
background:'var(--accent-light)',borderRadius:6}}>Aktiv</span> }}
: <button className="btn btn-secondary" style={{padding:'4px 10px',fontSize:12}} >
onClick={handleLogout}> Admin:{' '}
Nutzer wechseln <Link to="/admin/g/users" style={{ fontWeight: 600, color: 'var(--accent-dark)' }}>
</button> Benutzerverwaltung
} </Link>
<button className="btn btn-secondary" style={{padding:'4px 8px'}}
onClick={()=>setEditingId(editingId===p.id ? null : p.id)}>
<Pencil size={12}/>
</button>
{profiles.length > 1 && (
<button className="btn btn-danger" style={{padding:'4px 8px'}}
onClick={()=>handleDelete(p.id)}>
<Trash2 size={12}/>
</button>
)}
</div>
</div>
{/* Edit form only shown for THIS profile */}
{editingId === p.id && (
<ProfileForm
profile={p}
onSave={(form) => handleSave(form, p.id)}
onCancel={() => setEditingId(null)}
/>
)}
</div> </div>
))}
{/* New profile */}
{editingId === 'new' ? (
<ProfileForm
title="Neues Profil"
onSave={(form) => handleSave(form, 'new')}
onCancel={() => setEditingId(null)}
/>
) : (
<button className="btn btn-secondary btn-full" style={{marginTop:12}}
onClick={() => setEditingId('new')}>
<Plus size={14}/> Neues Profil anlegen
</button>
)} )}
<div className="settings-page__field">
<label className="settings-page__field-label" htmlFor="settings-profile-name">
Name
</label>
<input
id="settings-profile-name"
type="text"
className="form-input"
value={form.name}
onChange={(e) => setF('name', e.target.value)}
autoComplete="name"
/>
</div>
<div className="settings-page__field">
<label className="settings-page__field-label" htmlFor="settings-profile-email">
E-Mail
</label>
<input
id="settings-profile-email"
type="email"
className="form-input"
placeholder="für Login, Recovery & Zusammenfassungen"
value={form.email}
onChange={(e) => setF('email', e.target.value)}
autoComplete="email"
/>
</div>
{activeProfile?.email && activeProfile?.email_verified === false && (
<div
style={{
fontSize: 12,
color: 'var(--warn-text)',
background: 'var(--warn-bg)',
padding: '8px 10px',
borderRadius: 8,
marginBottom: 12,
lineHeight: 1.5,
}}
>
Diese E-Mail ist noch nicht bestätigt. Nach einer Änderung der Adresse ist ggf. erneut eine
Bestätigung nötig.
</div>
)}
<div style={{ marginBottom: 12 }}>
<div style={{ fontSize: 12, color: 'var(--text3)', marginBottom: 8 }}>Avatar-Farbe</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<Avatar profile={{ ...form, name: form.name || '?' }} size={36} />
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{COLORS.map((c) => (
<div
key={c}
role="button"
tabIndex={0}
onClick={() => setF('avatar_color', c)}
onKeyDown={(e) => e.key === 'Enter' && setF('avatar_color', c)}
style={{
width: 26,
height: 26,
borderRadius: '50%',
background: c,
cursor: 'pointer',
border: `3px solid ${form.avatar_color === c ? 'white' : 'transparent'}`,
boxShadow: form.avatar_color === c ? `0 0 0 2px ${c}` : 'none',
}}
/>
))}
</div>
</div>
</div>
<div className="form-row">
<label className="form-label">Geschlecht</label>
<select className="form-select" value={form.sex} onChange={(e) => setF('sex', e.target.value)}>
<option value="m">Männlich</option>
<option value="w">Weiblich</option>
<option value="d">Divers</option>
</select>
</div>
<div className="form-row">
<label className="form-label">Geburtsdatum</label>
<input
type="date"
className="form-input"
style={{ width: 'auto', minWidth: 140 }}
value={form.dob}
onChange={(e) => setF('dob', e.target.value)}
/>
<span className="form-unit" />
</div>
<div className="form-row">
<label className="form-label">Größe</label>
<input
type="number"
className="form-input"
min={100}
max={250}
value={form.height}
onChange={(e) => setF('height', e.target.value)}
/>
<span className="form-unit">cm</span>
</div>
<div
style={{
fontSize: 11,
fontWeight: 600,
color: 'var(--text3)',
textTransform: 'uppercase',
letterSpacing: '0.04em',
margin: '14px 0 6px',
}}
>
Ziele (optional, Legacy)
</div>
<p style={{ fontSize: 12, color: 'var(--text3)', marginBottom: 10, lineHeight: 1.5 }}>
Diese Felder bleiben vorerst erhalten; strategische Ziele verwaltest du unter{' '}
<Link to="/goals">Analyse Ziele</Link>.
</p>
<div className="form-row">
<label className="form-label">Zielgewicht</label>
<input
type="number"
className="form-input"
min={30}
max={300}
step={0.1}
value={form.goal_weight}
onChange={(e) => setF('goal_weight', e.target.value)}
placeholder=""
/>
<span className="form-unit">kg</span>
</div>
<div className="form-row">
<label className="form-label">Ziel-KF%</label>
<input
type="number"
className="form-input"
min={3}
max={50}
step={0.1}
value={form.goal_bf_pct}
onChange={(e) => setF('goal_bf_pct', e.target.value)}
placeholder=""
/>
<span className="form-unit">%</span>
</div>
{profileErr && (
<div
style={{
fontSize: 13,
color: '#D85A30',
background: '#FCEBEB',
padding: '10px 12px',
borderRadius: 8,
marginBottom: 12,
lineHeight: 1.4,
}}
>
{profileErr}
</div>
)}
<button type="button" className="btn btn-primary btn-full" style={{ marginTop: 8 }} onClick={handleSaveMyProfile}>
<Save size={14} /> Profil speichern
</button>
</div> </div>
{/* Auth actions */} {/* Auth actions */}
@ -375,22 +466,6 @@ export default function SettingsPage() {
<FeatureUsageOverview /> <FeatureUsageOverview />
</div> </div>
{/* Admin Panel */}
{isAdmin && (
<div className="card section-gap">
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between'}}>
<div className="card-title" style={{margin:0,display:'flex',alignItems:'center',gap:6}}>
<Shield size={15} color="var(--accent)"/> Admin
</div>
<button className="btn btn-secondary" style={{fontSize:12}}
onClick={()=>setAdminOpen(o=>!o)}>
{adminOpen?'Schließen':'Öffnen'}
</button>
</div>
{adminOpen && <div style={{marginTop:12}}><AdminPanel/></div>}
</div>
)}
{/* Export */} {/* Export */}
<div className="card section-gap"> <div className="card section-gap">
<div className="card-title">Daten exportieren</div> <div className="card-title">Daten exportieren</div>

View File

@ -142,7 +142,7 @@ export default function SleepPage() {
} }
return ( return (
<div style={{ padding: '16px 16px 80px' }}> <div className="capture-page" style={{ padding: '16px 16px 80px' }}>
{/* Toast Notification */} {/* Toast Notification */}
{toast && ( {toast && (
<div style={{ <div style={{

View File

@ -1064,7 +1064,7 @@ export default function VitalsPage() {
} }
return ( return (
<div> <div className="capture-page">
<h1 className="page-title">Vitalwerte</h1> <h1 className="page-title">Vitalwerte</h1>
<div className="tabs" style={{ overflowX: 'auto', flexWrap: 'nowrap' }}> <div className="tabs" style={{ overflowX: 'auto', flexWrap: 'nowrap' }}>

View File

@ -78,7 +78,7 @@ export default function WeightScreen() {
const avgAll = weights.length ? Math.round(weights.reduce((a,b)=>a+b,0)/weights.length*10)/10 : null const avgAll = weights.length ? Math.round(weights.reduce((a,b)=>a+b,0)/weights.length*10)/10 : null
return ( return (
<div> <div className="capture-page">
<h1 className="page-title">Gewicht</h1> <h1 className="page-title">Gewicht</h1>
{/* Eingabe */} {/* Eingabe */}

View File

@ -0,0 +1,503 @@
import { useState, useCallback, useEffect } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { useNodesState, useEdgesState, addEdge } from 'reactflow'
import { api } from '../utils/api'
import { validateWorkflowGraph } from '../utils/workflowValidation'
import { serializeToWorkflowGraph, deserializeFromWorkflowGraph } from '../utils/workflowSerializer'
import { WorkflowCanvas } from '../components/workflow/WorkflowCanvas'
import { StartNode } from '../components/workflow/nodes/StartNode'
import { EndNode } from '../components/workflow/nodes/EndNode'
import { AnalysisNode } from '../components/workflow/nodes/AnalysisNode'
import { LogicNode } from '../components/workflow/nodes/LogicNode'
import { JoinNode } from '../components/workflow/nodes/JoinNode'
import { QuestionAugmentationPanel } from '../components/workflow/panels/QuestionAugmentationPanel'
import { LogicExpressionEditor } from '../components/workflow/panels/LogicExpressionEditor'
import { FallbackConfig } from '../components/workflow/panels/FallbackConfig'
import { JoinConfig } from '../components/workflow/panels/JoinConfig'
import '../styles/workflowEditor.css'
// Node-Type Mapping
const nodeTypes = {
start: StartNode,
end: EndNode,
analysis: AnalysisNode,
logic: LogicNode,
join: JoinNode
}
let nodeIdCounter = 1
export default function WorkflowEditorPage() {
const navigate = useNavigate()
const { id } = useParams() // prompt_id wenn vorhanden
// State
const [nodes, setNodes, onNodesChange] = useNodesState([])
const [edges, setEdges, onEdgesChange] = useEdgesState([])
const [selectedNodeId, setSelectedNodeId] = useState(null)
const selectedNode = selectedNodeId ? nodes.find(n => n.id === selectedNodeId) : null
const [currentPrompt, setCurrentPrompt] = useState(null)
const [workflowName, setWorkflowName] = useState('Neuer Workflow')
const [workflowDescription, setWorkflowDescription] = useState('')
const [validationErrors, setValidationErrors] = useState([])
const [validationWarnings, setValidationWarnings] = useState([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const [availablePrompts, setAvailablePrompts] = useState([])
// Load available basis prompts for Analysis nodes
useEffect(() => {
async function loadPrompts() {
try {
const prompts = await api.listAdminPrompts()
// Filter nur type='base' Prompts
const basisPrompts = prompts.filter(p => p.type === 'base')
setAvailablePrompts(basisPrompts)
} catch (e) {
console.error('Failed to load prompts:', e)
}
}
loadPrompts()
}, [])
// Load workflow wenn ID vorhanden
useEffect(() => {
if (id && id !== 'new') {
console.log('🔍 useEffect: Loading workflow with ID:', id)
loadWorkflow(id) // UUID as string, no parseInt!
}
}, [id])
// Auto-Validation
useEffect(() => {
const { errors, warnings } = validateWorkflowGraph(nodes, edges)
setValidationErrors(errors)
setValidationWarnings(warnings)
}, [nodes, edges])
// Handlers
const onConnect = useCallback(
(params) => setEdges((eds) => addEdge(params, eds)),
[setEdges]
)
const onNodeClick = useCallback((event, node) => {
setSelectedNodeId(node.id)
}, [])
const handleAddNode = (nodeType) => {
const newNode = {
id: `node_${nodeIdCounter++}`,
type: nodeType,
position: { x: 250, y: 100 + nodes.length * 100 },
data: {
label: `${nodeType.charAt(0).toUpperCase() + nodeType.slice(1)} ${nodeIdCounter - 1}`
}
}
setNodes((nds) => [...nds, newNode])
}
const handleNodeUpdate = (nodeId, updates) => {
console.log('🔧 handleNodeUpdate:', { nodeId, updates })
setNodes((nds) => {
const updated = nds.map((n) => (n.id === nodeId ? { ...n, data: { ...n.data, ...updates } } : n))
console.log('📝 Nodes after update:', updated.find(n => n.id === nodeId))
return updated
})
}
const handleDeleteNode = () => {
if (!selectedNode) return
setNodes((nds) => nds.filter((n) => n.id !== selectedNode.id))
setEdges((eds) => eds.filter((e) => e.source !== selectedNode.id && e.target !== selectedNode.id))
setSelectedNodeId(null)
}
const handleSave = async () => {
console.log('💾 handleSave called')
try {
setLoading(true)
setError(null)
// Validierung
const { errors, isValid } = validateWorkflowGraph(nodes, edges)
if (!isValid) {
setError(`Validierung fehlgeschlagen: ${errors.length} Fehler gefunden`)
return
}
// Serialisieren
const graph_data = serializeToWorkflowGraph(nodes, edges, {
created_at: currentPrompt?.created_at,
version: '1.0'
})
console.log('📊 Serialized graph_data:', graph_data)
if (currentPrompt) {
// Update existing
console.log('📝 Updating existing workflow:', currentPrompt.id)
await api.updateUnifiedPrompt(currentPrompt.id, {
type: 'workflow',
name: workflowName,
description: workflowDescription,
graph_data
})
alert('Workflow gespeichert!')
} else {
// Create new
console.log('✨ Creating new workflow')
const result = await api.createUnifiedPrompt({
type: 'workflow',
name: workflowName,
description: workflowDescription,
graph_data
})
console.log('✅ Workflow created:', result)
setCurrentPrompt({ id: result.id, name: workflowName })
alert('Workflow erstellt!')
console.log('🚀 Navigating to:', `/workflow-editor/${result.id}`)
navigate(`/workflow-editor/${result.id}`)
}
} catch (e) {
console.error('❌ handleSave error:', e)
setError(e.message)
} finally {
setLoading(false)
}
}
const loadWorkflow = async (promptId) => {
console.log('📦 loadWorkflow called with:', promptId)
try {
setLoading(true)
setError(null)
const prompt = await api.getPrompt(promptId)
console.log('✅ Prompt loaded:', prompt)
console.log('📊 graph_data:', prompt.graph_data)
if (prompt.type !== 'workflow') {
throw new Error('Nicht ein Workflow')
}
// Deserialisieren
const { nodes: loadedNodes, edges: loadedEdges } = deserializeFromWorkflowGraph(prompt.graph_data)
console.log('🎯 Deserialized:', { nodes: loadedNodes, edges: loadedEdges })
setNodes(loadedNodes)
setEdges(loadedEdges)
setCurrentPrompt(prompt)
setWorkflowName(prompt.name)
setWorkflowDescription(prompt.description || '')
// nodeIdCounter aktualisieren
const maxId = Math.max(
...loadedNodes.map((n) => parseInt(n.id.replace('node_', '')) || 0),
0
)
nodeIdCounter = maxId + 1
console.log('✅ Workflow loaded successfully, nodes:', loadedNodes.length, 'edges:', loadedEdges.length)
} catch (e) {
console.error('❌ loadWorkflow error:', e)
setError(e.message)
} finally {
setLoading(false)
}
}
const handleValidate = () => {
const { errors, warnings } = validateWorkflowGraph(nodes, edges)
setValidationErrors(errors)
setValidationWarnings(warnings)
if (errors.length === 0) {
alert(`✅ Workflow ist valide!\n\n${warnings.length} Warnungen`)
} else {
alert(`❌ Validierung fehlgeschlagen!\n\n${errors.length} Fehler, ${warnings.length} Warnungen`)
}
}
const handleNew = () => {
if (confirm('Neuen Workflow erstellen? Ungespeicherte Änderungen gehen verloren.')) {
setNodes([])
setEdges([])
setCurrentPrompt(null)
setWorkflowName('Neuer Workflow')
setWorkflowDescription('')
setSelectedNodeId(null)
navigate('/workflow-editor/new')
}
}
const handleDelete = async () => {
if (!currentPrompt) return
if (!confirm(`Workflow "${workflowName}" wirklich löschen?`)) return
try {
setLoading(true)
await api.deletePrompt(currentPrompt.id)
alert('Workflow gelöscht')
navigate('/admin/prompts')
} catch (e) {
setError(e.message)
} finally {
setLoading(false)
}
}
// Render
return (
<div className="workflow-editor">
{/* Toolbar */}
<div className="workflow-toolbar">
<button className="btn-secondary" onClick={() => navigate('/admin/prompts')}>
Zurück
</button>
<input
type="text"
value={workflowName}
onChange={(e) => setWorkflowName(e.target.value)}
placeholder="Workflow-Name"
style={{ flex: 1, padding: '8px', borderRadius: '4px', border: '1px solid var(--border)' }}
/>
<button className="btn-secondary" onClick={handleNew}>
Neu
</button>
<button className="btn-secondary" onClick={handleValidate}>
Validieren {validationErrors.length > 0 ? `(${validationErrors.length} ⚠️)` : ''}
</button>
<button
className="btn-primary"
onClick={handleSave}
disabled={loading}
title={validationErrors.length > 0
? `Speichern blockiert: ${validationErrors.length} Validierungsfehler`
: 'Workflow in Datenbank speichern'}
>
{loading ? 'Speichern...' : validationErrors.length > 0 ? '🔒 Speichern' : '💾 Speichern'}
</button>
{currentPrompt && (
<button className="btn-secondary" onClick={handleDelete} disabled={loading}>
Löschen
</button>
)}
</div>
{error && (
<div style={{ padding: '12px', background: 'var(--danger)', color: 'white', borderRadius: '4px', marginBottom: '8px' }}>
{error}
{validationErrors.length > 0 && (
<div style={{ marginTop: 8, fontSize: 12 }}>
Tipp: Behebe die Validierungsfehler unten, um speichern zu können.
</div>
)}
</div>
)}
{/* Main Content */}
<div className="workflow-content">
{/* Sidebar */}
<div className="workflow-sidebar" style={{ display: selectedNode ? 'none' : 'block' }}>
<div className="sidebar-section">
<h3>Workflow-Knoten</h3>
<div className="node-palette">
<button className="node-palette-button" onClick={() => handleAddNode('start')}>
<span className="icon">🚀</span> Start
</button>
<button className="node-palette-button" onClick={() => handleAddNode('analysis')}>
<span className="icon">🤖</span> Analyse
</button>
<button className="node-palette-button" onClick={() => handleAddNode('logic')}>
<span className="icon"></span> Logik
</button>
<button className="node-palette-button" onClick={() => handleAddNode('join')}>
<span className="icon">🔀</span> Join
</button>
<button className="node-palette-button" onClick={() => handleAddNode('end')}>
<span className="icon">🏁</span> Ende
</button>
</div>
</div>
{selectedNode && (
<div className="sidebar-section">
<h3>Aktionen</h3>
<button className="btn-secondary btn-full" onClick={handleDeleteNode}>
🗑 Node löschen
</button>
</div>
)}
<div className="sidebar-section">
<h3>Info</h3>
<div style={{ fontSize: '12px', color: 'var(--text3)' }}>
<div>Nodes: {nodes.length}</div>
<div>Edges: {edges.length}</div>
<div>Errors: {validationErrors.length}</div>
<div>Warnings: {validationWarnings.length}</div>
</div>
</div>
</div>
{/* Canvas */}
<div className="workflow-canvas-container">
<WorkflowCanvas
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onNodeClick={onNodeClick}
/>
</div>
{/* Config Panel */}
{selectedNode && (
<div className="workflow-config-panel">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<h2 style={{ margin: 0 }}>Node-Konfiguration</h2>
<button
onClick={() => setSelectedNodeId(null)}
style={{
background: 'none',
border: 'none',
fontSize: 24,
cursor: 'pointer',
color: 'var(--text3)',
padding: 4,
lineHeight: 1
}}
title="Schließen"
>
×
</button>
</div>
{/* Basis-Konfiguration */}
<div className="config-section">
<label>Node-Name</label>
<input
type="text"
value={selectedNode.data.label || ''}
onChange={(e) => handleNodeUpdate(selectedNode.id, { label: e.target.value })}
placeholder="z.B. Gewichtsanalyse"
style={{
width: '100%',
padding: '8px',
borderRadius: '4px',
border: '1px solid var(--border)',
background: 'var(--surface)',
color: 'var(--text1)',
fontSize: '14px'
}}
/>
<div style={{ marginTop: 4, fontSize: 11, color: 'var(--text3)' }}>
Änderungen werden automatisch übernommen
</div>
</div>
{/* Type-spezifische Konfiguration */}
{selectedNode.type === 'analysis' && (
<>
<div className="config-section">
<label>KI-Prompt auswählen</label>
<select
value={selectedNode.data.prompt_id ? String(selectedNode.data.prompt_id) : ''}
onChange={(e) => {
const promptId = e.target.value
console.log('🎯 Prompt selected:', promptId, 'Type:', typeof promptId)
const selectedPrompt = availablePrompts.find(p => String(p.id) === promptId)
console.log('📋 Selected prompt object:', selectedPrompt)
handleNodeUpdate(selectedNode.id, {
prompt_id: promptId || null, // UUID as string, no parseInt!
prompt_name: selectedPrompt?.name || null
})
}}
style={{
width: '100%',
padding: '8px',
borderRadius: '4px',
border: '1px solid var(--border)',
background: 'var(--surface)',
color: 'var(--text1)'
}}
>
<option value="">-- Basis-Prompt wählen --</option>
{availablePrompts.map(prompt => (
<option key={prompt.id} value={String(prompt.id)}>
{prompt.name}
</option>
))}
</select>
{selectedNode.data.prompt_id && (
<div style={{ marginTop: 8, fontSize: 12, color: 'var(--text3)' }}>
Prompt ID: {selectedNode.data.prompt_id} ({selectedNode.data.prompt_name || 'unbekannt'})
</div>
)}
</div>
<QuestionAugmentationPanel node={selectedNode} onChange={handleNodeUpdate} />
<FallbackConfig node={selectedNode} edges={edges} nodes={nodes} onChange={handleNodeUpdate} />
</>
)}
{selectedNode.type === 'logic' && (
<>
<LogicExpressionEditor
node={selectedNode}
nodes={nodes}
edges={edges}
onChange={handleNodeUpdate}
/>
<FallbackConfig node={selectedNode} edges={edges} nodes={nodes} onChange={handleNodeUpdate} />
</>
)}
{selectedNode.type === 'join' && (
<JoinConfig node={selectedNode} onChange={handleNodeUpdate} />
)}
</div>
)}
</div>
{/* Validation Panel */}
{(validationErrors.length > 0 || validationWarnings.length > 0) && (
<div className="validation-panel">
{validationErrors.map((err, i) => (
<div key={i} className="validation-error" onClick={() => {
if (err.nodeId) {
setSelectedNodeId(err.nodeId)
}
}}>
{err.message}
</div>
))}
{validationWarnings.map((warn, i) => (
<div key={i} className="validation-warning" onClick={() => {
if (warn.nodeId) {
setSelectedNodeId(warn.nodeId)
}
}}>
{warn.message}
</div>
))}
{validationErrors.length === 0 && validationWarnings.length > 0 && (
<div className="validation-success">
Workflow ist valide ({validationWarnings.length} Warnungen)
</div>
)}
</div>
)}
</div>
)
}

View File

@ -0,0 +1,471 @@
/* Workflow Editor Styles (Phase 5) */
/* ── Editor Layout ────────────────────────────────────────────────────────── */
.workflow-editor {
display: flex;
flex-direction: column;
height: calc(100vh - 60px);
background: var(--bg);
}
.workflow-toolbar {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: var(--surface);
border-bottom: 1px solid var(--border);
}
.workflow-content {
display: flex;
flex: 1;
overflow: hidden;
}
/* ── Sidebar (Node Palette) ─────────────────────────────────────────────── */
.workflow-sidebar {
width: 250px;
background: var(--surface);
border-right: 1px solid var(--border);
padding: 16px;
overflow-y: auto;
}
.sidebar-section {
margin-bottom: 24px;
}
.sidebar-section h3 {
margin: 0 0 12px 0;
font-size: 14px;
font-weight: 600;
color: var(--text2);
text-transform: uppercase;
}
.node-palette {
display: flex;
flex-direction: column;
gap: 8px;
}
.node-palette-button {
padding: 12px;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 8px;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: var(--text1);
transition: all 0.2s;
}
.node-palette-button:hover {
background: var(--accent);
color: white;
border-color: var(--accent);
transform: translateY(-1px);
}
.node-palette-button .icon {
font-size: 20px;
}
/* ── Canvas ──────────────────────────────────────────────────────────────── */
.workflow-canvas-container {
flex: 1;
position: relative;
background: var(--bg);
}
/* React Flow Overrides */
.react-flow {
background: var(--bg);
}
.react-flow__node {
border-radius: 8px;
}
.react-flow__node.selected {
box-shadow: 0 0 0 3px rgba(29, 158, 117, 0.3);
}
.react-flow__edge-path {
stroke: var(--text3);
stroke-width: 2;
}
.react-flow__edge.selected .react-flow__edge-path {
stroke: var(--accent);
stroke-width: 3;
}
.react-flow__handle {
width: 10px;
height: 10px;
border: 2px solid white;
}
.react-flow__handle-connecting {
background: var(--accent) !important;
}
.react-flow__handle-valid {
background: #4CAF50 !important;
}
/* ── Custom Nodes ────────────────────────────────────────────────────────── */
.workflow-node {
min-width: 180px;
background: var(--surface);
border: 2px solid var(--border);
border-radius: 8px;
padding: 12px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
font-family: inherit;
transition: all 0.2s;
}
.workflow-node:hover {
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
transform: translateY(-1px);
}
.workflow-node.selected {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(29, 158, 117, 0.2);
}
.node-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.node-icon {
font-size: 20px;
line-height: 1;
}
.node-label {
font-weight: 600;
font-size: 14px;
color: var(--text1);
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.node-body {
font-size: 12px;
color: var(--text2);
}
/* Start Node */
.workflow-node.start-node {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border-color: #667eea;
text-align: center;
}
.workflow-node.start-node .node-label,
.workflow-node.start-node .node-body {
color: white;
}
/* End Node */
.workflow-node.end-node {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
color: white;
border-color: #f093fb;
text-align: center;
}
.workflow-node.end-node .node-label,
.workflow-node.end-node .node-body {
color: white;
}
/* Analysis Node */
.workflow-node.analysis-node {
background: var(--surface2);
border-color: var(--accent);
}
.workflow-node.analysis-node .prompt-name {
font-weight: 500;
color: var(--text1);
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.workflow-node.analysis-node .questions-indicator {
font-size: 11px;
color: var(--accent);
padding: 4px 8px;
background: rgba(29, 158, 117, 0.1);
border-radius: 4px;
display: inline-block;
margin-top: 4px;
}
/* Logic Node */
.workflow-node.logic-node {
background: #FFF3CD;
border-color: #FFC107;
}
.workflow-node.logic-node .condition-summary {
padding: 6px 8px;
background: rgba(255, 193, 7, 0.2);
border-radius: 4px;
font-size: 11px;
font-weight: 500;
}
.workflow-node.logic-node .condition-summary.has-condition {
color: #856404;
}
.workflow-node.logic-node .condition-summary.no-condition {
color: #6c757d;
font-style: italic;
}
/* Join Node */
.workflow-node.join-node {
background: #D1ECF1;
border-color: #17A2B8;
}
.workflow-node.join-node .node-body {
font-size: 11px;
}
.workflow-node.join-node .strategy,
.workflow-node.join-node .skip-handling {
margin-bottom: 4px;
}
.workflow-node.join-node strong {
color: #0c5460;
}
/* ── Config Panel ────────────────────────────────────────────────────────── */
.workflow-config-panel {
width: 400px;
background: var(--surface);
border-left: 1px solid var(--border);
padding: 16px;
overflow-y: auto;
}
.config-section {
margin-bottom: 24px;
}
.config-section h3 {
margin: 0 0 12px 0;
font-size: 16px;
font-weight: 600;
color: var(--text1);
}
.config-section label {
display: block;
margin-bottom: 4px;
font-size: 12px;
font-weight: 600;
color: var(--text2);
}
.config-section input,
.config-section select,
.config-section textarea {
width: 100%;
padding: 8px;
border: 1px solid var(--border);
border-radius: 4px;
margin-bottom: 8px;
font-family: inherit;
font-size: 14px;
background: var(--bg);
color: var(--text1);
}
.config-section textarea {
resize: vertical;
min-height: 60px;
}
/* ── Question Editor ─────────────────────────────────────────────────────── */
.question-editor {
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px;
margin-bottom: 12px;
}
.question-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.question-header span {
font-weight: 600;
font-size: 14px;
color: var(--text1);
}
.btn-icon {
background: none;
border: none;
cursor: pointer;
font-size: 18px;
padding: 4px;
opacity: 0.7;
transition: opacity 0.2s;
}
.btn-icon:hover {
opacity: 1;
}
/* ── Logic Expression Editor ─────────────────────────────────────────────── */
.logic-root {
margin-bottom: 16px;
}
.logic-operands {
margin-top: 12px;
}
.condition-block {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 4px;
padding: 8px;
margin-bottom: 8px;
}
.condition-simple {
display: flex;
gap: 8px;
align-items: center;
}
.condition-simple select,
.condition-simple input {
flex: 1;
padding: 6px;
border: 1px solid var(--border);
border-radius: 4px;
font-size: 12px;
}
.condition-group {
border-left: 3px solid var(--accent);
padding-left: 12px;
margin-bottom: 8px;
}
.group-header {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 8px;
}
.logic-actions {
display: flex;
gap: 8px;
margin-top: 12px;
}
/* ── Validation Panel ────────────────────────────────────────────────────── */
.validation-panel {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: var(--surface);
border-top: 1px solid var(--border);
padding: 12px 16px;
max-height: 200px;
overflow-y: auto;
z-index: 1000;
}
.validation-error {
color: var(--danger);
margin-bottom: 4px;
font-size: 14px;
cursor: pointer;
}
.validation-error:hover {
text-decoration: underline;
}
.validation-warning {
color: #FFC107;
margin-bottom: 4px;
font-size: 14px;
cursor: pointer;
}
.validation-warning:hover {
text-decoration: underline;
}
.validation-success {
color: #4CAF50;
font-size: 14px;
font-weight: 600;
}
/* ── Responsive ──────────────────────────────────────────────────────────── */
@media (max-width: 1200px) {
.workflow-sidebar {
width: 200px;
}
.workflow-config-panel {
width: 350px;
}
}
@media (max-width: 900px) {
.workflow-content {
flex-direction: column;
}
.workflow-sidebar,
.workflow-config-panel {
width: 100%;
border: none;
border-bottom: 1px solid var(--border);
}
}

View File

@ -286,6 +286,7 @@ export const api = {
// AI Prompts Management (Issue #28) // AI Prompts Management (Issue #28)
listAdminPrompts: () => req('/prompts'), listAdminPrompts: () => req('/prompts'),
getPrompt: (id) => req(`/prompts/${id}`),
createPrompt: (d) => req('/prompts', json(d)), createPrompt: (d) => req('/prompts', json(d)),
updatePrompt: (id,d) => req(`/prompts/${id}`, jput(d)), updatePrompt: (id,d) => req(`/prompts/${id}`, jput(d)),
deletePrompt: (id) => req(`/prompts/${id}`, {method:'DELETE'}), deletePrompt: (id) => req(`/prompts/${id}`, {method:'DELETE'}),

View File

@ -0,0 +1,26 @@
/**
* Gemeinsames Raster für Dashboard-Kacheln (Mobile 2 / Desktop 4 Spalten).
* Optional Mobile 4: mobile auf 4 setzen + Klasse dashboard-tile-grid--mobile-4col.
*/
export const DASHBOARD_TILE_GRID_COLS = { mobile: 2, desktop: 4 }
/** @param {number} span @param {number} maxCols */
export function clampTileSpan(span, maxCols) {
const n = Number(span)
if (!Number.isFinite(n)) return 1
return Math.min(maxCols, Math.max(1, Math.round(n)))
}
/** @param {number} [mobileCols] 2 oder 4 */
export function dashboardTileGridClassName(mobileCols = DASHBOARD_TILE_GRID_COLS.mobile) {
let c = 'dashboard-tile-grid'
if (mobileCols === 4) c += ' dashboard-tile-grid--mobile-4col'
return c
}
/** KPI-Raster: dieselben Regeln wie `dashboard-tile-grid`, plus Legacy-Klasse `dashboard-stat-grid`. */
export function dashboardStatGridClassName(mobileCols = DASHBOARD_TILE_GRID_COLS.mobile) {
let c = 'dashboard-stat-grid dashboard-tile-grid'
if (mobileCols === 4) c += ' dashboard-stat-grid--mobile-4col dashboard-tile-grid--mobile-4col'
return c
}

View File

@ -0,0 +1,118 @@
/**
* Workflow Serialization Utilities
*
* Konvertiert zwischen React Flow (Canvas) und Backend-Format (JSONB).
*/
/**
* Serialisiert React Flow Graph zu Backend-kompatiblem Format
*
* @param {Array} nodes - React Flow nodes
* @param {Array} edges - React Flow edges
* @param {Object} metadata - Zusätzliche Metadaten
* @returns {Object} JSONB-kompatibles Objekt für ai_prompts.graph_data
*/
export function serializeToWorkflowGraph(nodes, edges, metadata = {}) {
const workflowNodes = nodes.map(node => ({
id: node.id,
type: node.type,
label: node.data.label || node.type,
position: { x: node.position.x, y: node.position.y },
// Type-spezifische Felder
...(node.type === 'analysis' && {
prompt_id: node.data.prompt_id || null,
prompt_name: node.data.prompt_name || null,
questions: node.data.questions || [],
fallback_strategy: node.data.fallback_strategy || 'conservative_skip'
}),
...(node.type === 'logic' && {
condition: node.data.condition || null,
fallback_strategy: node.data.fallback_strategy || 'conservative_skip'
}),
...(node.type === 'join' && {
join_strategy: node.data.join_strategy || 'wait_all',
skip_handling: node.data.skip_handling || 'ignore_skipped',
min_paths: node.data.min_paths || 2
})
}))
const workflowEdges = edges.map(edge => ({
id: edge.id,
source: edge.source,
target: edge.target,
label: edge.data?.label || null,
sourceHandle: edge.sourceHandle || null,
targetHandle: edge.targetHandle || null
}))
return {
nodes: workflowNodes,
edges: workflowEdges,
metadata: {
created_at: metadata.created_at || new Date().toISOString(),
updated_at: new Date().toISOString(),
version: metadata.version || '1.0'
}
}
}
/**
* Deserialisiert Backend-Format zu React Flow Graph
*
* @param {Object} jsonbData - ai_prompts.graph_data (JSONB)
* @returns {Object} { nodes, edges, metadata }
*/
export function deserializeFromWorkflowGraph(jsonbData) {
if (!jsonbData || !jsonbData.nodes || !jsonbData.edges) {
throw new Error('Invalid workflow graph data')
}
const reactFlowNodes = jsonbData.nodes.map(node => ({
id: node.id,
type: node.type,
position: { x: node.position.x, y: node.position.y },
data: {
label: node.label,
...(node.type === 'analysis' && {
prompt_id: node.prompt_id,
prompt_name: node.prompt_name || null, // Falls vom Backend mitgeliefert
questions: node.questions || [],
fallback_strategy: node.fallback_strategy || 'conservative_skip'
}),
...(node.type === 'logic' && {
condition: node.condition || null,
fallback_strategy: node.fallback_strategy || 'conservative_skip'
}),
...(node.type === 'join' && {
join_strategy: node.join_strategy || 'wait_all',
skip_handling: node.skip_handling || 'ignore_skipped',
min_paths: node.min_paths || 2
})
}
}))
const reactFlowEdges = jsonbData.edges.map(edge => ({
id: edge.id,
source: edge.source,
target: edge.target,
sourceHandle: edge.sourceHandle || null,
targetHandle: edge.targetHandle || null,
data: {
label: edge.label || null
},
type: 'default',
animated: false
}))
return {
nodes: reactFlowNodes,
edges: reactFlowEdges,
metadata: jsonbData.metadata || {}
}
}

View File

@ -0,0 +1,226 @@
/**
* Workflow Validation Utilities
*
* Validiert Workflow-Graphen (Struktur + Logik).
*/
export function validateWorkflowGraph(nodes, edges) {
const errors = []
const warnings = []
// 1. Strukturelle Validierung
validateStructure(nodes, edges, errors, warnings)
// 2. Logische Validierung
validateLogic(nodes, edges, errors, warnings)
return {
errors,
warnings,
isValid: errors.length === 0
}
}
/**
* Strukturelle Validierung (DAG, START/END, Zyklen)
*/
function validateStructure(nodes, edges, errors, warnings) {
// START Node
const startNodes = nodes.filter(n => n.type === 'start')
if (startNodes.length === 0) {
errors.push({
type: 'structure',
message: 'Kein START-Node vorhanden',
severity: 'error'
})
} else if (startNodes.length > 1) {
errors.push({
type: 'structure',
message: `${startNodes.length} START-Nodes gefunden (max. 1 erlaubt)`,
severity: 'error'
})
}
// END Node
const endNodes = nodes.filter(n => n.type === 'end')
if (endNodes.length === 0) {
errors.push({
type: 'structure',
message: 'Kein END-Node vorhanden',
severity: 'error'
})
}
// Zyklen-Erkennung
if (detectCycles(nodes, edges)) {
errors.push({
type: 'structure',
message: 'Workflow enthält Zyklen (nicht erlaubt)',
severity: 'error'
})
}
// Isolierte Nodes
nodes.forEach(node => {
if (node.type === 'start' || node.type === 'end') return
const hasIncoming = edges.some(e => e.target === node.id)
const hasOutgoing = edges.some(e => e.source === node.id)
if (!hasIncoming || !hasOutgoing) {
warnings.push({
type: 'isolation',
message: `Node "${node.data.label}" ist isoliert (keine/fehlende Verbindungen)`,
nodeId: node.id,
severity: 'warning'
})
}
})
}
/**
* Logische Validierung (Node-Konfiguration)
*/
function validateLogic(nodes, edges, errors, warnings) {
nodes.forEach(node => {
// Analysis Nodes
if (node.type === 'analysis') {
const questions = node.data.questions || []
// Prompt ausgewählt?
if (!node.data.prompt_id) {
errors.push({
type: 'config',
message: `Analysis-Node "${node.data.label}" hat keinen Prompt`,
nodeId: node.id,
severity: 'error'
})
}
// Fragen validieren
questions.forEach((q, idx) => {
if (!q.question?.trim()) {
errors.push({
type: 'config',
message: `Frage ${idx + 1} in "${node.data.label}" hat keinen Text`,
nodeId: node.id,
severity: 'error'
})
}
if (!q.answer_spectrum || q.answer_spectrum.length < 2) {
errors.push({
type: 'config',
message: `Frage ${idx + 1} in "${node.data.label}" braucht mind. 2 Antworten`,
nodeId: node.id,
severity: 'error'
})
}
})
}
// Logic Nodes
if (node.type === 'logic') {
const condition = node.data.condition
if (!condition || !condition.operator) {
errors.push({
type: 'config',
message: `Logic-Node "${node.data.label}" hat keine Bedingung`,
nodeId: node.id,
severity: 'error'
})
} else {
// Bedingung vollständig?
const incomplete = findIncompleteConditions(condition)
if (incomplete.length > 0) {
errors.push({
type: 'config',
message: `Logic-Node "${node.data.label}" hat ${incomplete.length} unvollständige Bedingung(en)`,
nodeId: node.id,
severity: 'error'
})
}
}
// Mind. 2 Outgoing Edges (true/false Pfade)
const outgoing = edges.filter(e => e.source === node.id)
if (outgoing.length < 2) {
warnings.push({
type: 'config',
message: `Logic-Node "${node.data.label}" hat nur ${outgoing.length} Ausgang (sollte mind. 2 haben)`,
nodeId: node.id,
severity: 'warning'
})
}
}
// Join Nodes
if (node.type === 'join') {
const incoming = edges.filter(e => e.target === node.id)
if (incoming.length < 2) {
warnings.push({
type: 'config',
message: `Join-Node "${node.data.label}" hat nur ${incoming.length} eingehende Kante (sollte mind. 2 haben)`,
nodeId: node.id,
severity: 'warning'
})
}
}
})
}
/**
* Zyklen-Erkennung (DFS-basiert)
*/
function detectCycles(nodes, edges) {
const visited = new Set()
const recStack = new Set()
function dfs(nodeId) {
visited.add(nodeId)
recStack.add(nodeId)
const outgoing = edges.filter(e => e.source === nodeId)
for (const edge of outgoing) {
if (!visited.has(edge.target)) {
if (dfs(edge.target)) return true
} else if (recStack.has(edge.target)) {
return true // Cycle detected
}
}
recStack.delete(nodeId)
return false
}
for (const node of nodes) {
if (!visited.has(node.id)) {
if (dfs(node.id)) return true
}
}
return false
}
/**
* Unvollständige Bedingungen finden (rekursiv)
*/
function findIncompleteConditions(condition) {
const incomplete = []
// Verschachtelte Gruppe?
if (condition.operands && Array.isArray(condition.operands)) {
for (const op of condition.operands) {
incomplete.push(...findIncompleteConditions(op))
}
} else {
// Einfache Bedingung: ref, operator, value müssen gesetzt sein
if (!condition.ref || !condition.operator || condition.value === undefined || condition.value === '') {
incomplete.push(condition)
}
}
return incomplete
}

View File

@ -0,0 +1,64 @@
# Gitea MCP-Server für Cursor
Damit der Agent **strukturierte Tools** nutzen kann (`gitea_list_issues`, `gitea_close_issue`, …), registrierst du diesen MCP-S **lokal** in Cursor.
## 1. Abhängigkeit
```powershell
pip install -r scripts/gitea/requirements-mcp.txt
```
Oder: `pip install "mcp>=1.2.0"`
## 2. Secrets
Wie beim CLI: **`GITEA_*` in der Repo-Root `.env`** (wird von `gitea_lib` geladen), **oder** dieselben Variablen in der MCP-`env` (siehe unten).
**Niemals** Tokens in Git committen.
## 3. Cursor MCP konfigurieren
**Variante A UI:** Einstellungen → **MCP** / Tools → Server hinzufügen → Typ „Command“:
- **Command:** `python` (oder voller Pfad zu `python.exe`)
- **Args:** vollständiger Pfad zu `mcp_server_gitea.py`, z. B.
`C:\Dev\mitai-jinkendo\scripts\gitea\mcp_server_gitea.py`
- **Working directory (optional):** `C:\Dev\mitai-jinkendo\scripts\gitea`
- **Env:** nur nötig, wenn du **keine** `.env` im Repo nutzt:
```text
GITEA_BASE_URL=http://192.168.2.144:3000
GITEA_OWNER=Lars
GITEA_REPO=mitai-jinkendo
GITEA_TOKEN=…
```
**Variante B JSON:** Datei `~/.cursor/mcp.json` (Benutzer) oder projektbezogen laut Cursor-Doku. Beispielinhalt siehe **`.cursor/mcp.json.example`** im Repo (Platzhalter, ohne echtes Token).
Cursor nach Änderung **vollständig neu starten**.
## 4. Netzwerk
Die Gitea-URL muss von deinem Rechner erreichbar sein (z. B. `192.168.2.144:3000` im LAN).
## 5. Repo-Zugriff
- **API:** Tool `gitea_get_repo_file` (Dateiinhalt / Metadaten).
- **Git (lokal):** unverändert `git pull` / Agent liest Workspace-Dateien dafür brauchst du kein MCP.
## Bereitgestellte Tools (Kurzüberblick)
| Tool | Zweck |
|------|--------|
| `gitea_list_issues` | Issues listen, optional alle Seiten |
| `gitea_get_issue` | Ein Issue mit Body |
| `gitea_comment_issue` | Kommentar |
| `gitea_create_issue` | Neu anlegen |
| `gitea_close_issue` / `gitea_reopen_issue` | Status |
| `gitea_get_repo_file` | Datei remote via API |
## Issue-Triage durch den Agent
Sinnvoller Ablauf: Issues listen → je Issue **Code/Commits prüfen** → bei eindeutig erledigt: kurzer Kommentar + **close**; bei teilweise: Kommentar mit Checkboxen; bei unklar: nur Kommentar, **nicht** schließen.
Autonom alles schließen ist fehleranfällig; klare Regeln oder manuelle Freigabe für `close` empfohlen.

68
scripts/gitea/README.md Normal file
View File

@ -0,0 +1,68 @@
# Gitea API lokales CLI
Dient dazu, **Issues** auf deiner Gitea-Instanz zu lesen und anzulegen mit den in **`.env`** gesetzten Variablen (nicht committen).
## Umgebungsvariablen (Root `.env`)
| Variable | Beispiel |
|----------|----------|
| `GITEA_BASE_URL` | `http://192.168.2.144:3000` |
| `GITEA_TOKEN` | Personal Access Token (nur Scope **repo** + **issue** nötig) |
| `GITEA_OWNER` | `Lars` |
| `GITEA_REPO` | `mitai-jinkendo` |
## Voraussetzung
Python 3.10+ (nur Standardbibliothek).
## Aufruf (im Repo-Root)
```powershell
# Issues auflisten (offen)
python scripts/gitea/gitea_api.py issues list
# Issues mit State
python scripts/gitea/gitea_api.py issues list --state all
# Ein Issue lesen
python scripts/gitea/gitea_api.py issues get 42
# Issue anlegen (Titel + Body aus Datei oder direkt)
python scripts/gitea/gitea_api.py issues create --title "Fix: …" --body "…"
python scripts/gitea/gitea_api.py issues create --title "Fix: …" --body-file path/to/body.md
# Kommentar
python scripts/gitea/gitea_api.py issues comment 42 --body "…"
# Schließen / wieder öffnen
python scripts/gitea/gitea_api.py issues close 42
python scripts/gitea/gitea_api.py issues reopen 42
# Alle Issues (alle Seiten, Vorsicht bei großen Repos)
python scripts/gitea/gitea_api.py issues list --all-pages --state open
# Markdown-Datei (z. B. Audit-Template) als Issue-Body
python scripts/gitea/gitea_api.py issues create --title "…" --body-file .claude/docs/audit/.../gitea/TEMPLATE_P0-....md
```
## Repository-Inhalt (read-only)
```powershell
# Datei über Gitea-API (bei Dateien: Text-Inhalt; bei Verzeichnissen: JSON-Listing)
python scripts/gitea/gitea_api.py repo file README.md
python scripts/gitea/gitea_api.py repo file backend/main.py --ref develop
# Clone/Push: normales `git remote` Token nicht dauerhaft in der Remote-URL; SSH oder Credential Helper.
```
## Sicherheit
- **Niemals** `GITEA_TOKEN` ins Git oder in Issues/Pastebins.
- Token, das in Chat oder Logs gelandet ist, in Gitea **widerrufen** und **neu erzeugen**.
- Cursor-Agenten können das CLI über das Terminal nutzen, wenn `.env` gesetzt und Netzwerk zu `GITEA_BASE_URL` erreichbar ist.
## MCP (Tools direkt im Agent)
Siehe [`MCP_SETUP.md`](./MCP_SETUP.md) und [`../.cursor/mcp.json.example`](../../.cursor/mcp.json.example).

183
scripts/gitea/gitea_api.py Normal file
View File

@ -0,0 +1,183 @@
#!/usr/bin/env python3
"""
Minimal Gitea API client. Reads GITEA_* from environment or .env in repo root.
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from gitea_lib import (
issues_comment,
issues_create,
issues_get,
issues_list_all,
issues_list_page,
issues_patch,
load_dotenv,
repo_file_content,
repo_root,
require_config,
)
def cmd_issues_list(args: argparse.Namespace, base: str, token: str, owner: str, repo: str) -> None:
if args.all_pages:
items = issues_list_all(
base, token, owner, repo, state=args.state, limit=args.limit
)
else:
_, items = issues_list_page(
base,
token,
owner,
repo,
state=args.state,
page=args.page,
limit=args.limit,
)
for it in items:
num = it.get("number")
title = it.get("title")
st = it.get("state")
print(f"#{num} [{st}] {title}")
def cmd_issues_get(args: argparse.Namespace, base: str, token: str, owner: str, repo: str) -> None:
status, payload = issues_get(base, token, owner, repo, args.number)
print(json.dumps(payload, indent=2, ensure_ascii=False))
if status >= 400:
sys.exit(1)
def cmd_issues_create(args: argparse.Namespace, base: str, token: str, owner: str, repo: str) -> None:
body = args.body or ""
if args.body_file:
body = Path(args.body_file).read_text(encoding="utf-8")
status, payload = issues_create(
base,
token,
owner,
repo,
title=args.title,
body=body,
labels=args.labels or [],
)
print(json.dumps(payload, indent=2, ensure_ascii=False))
if status >= 400:
sys.exit(1)
def cmd_issues_comment(args: argparse.Namespace, base: str, token: str, owner: str, repo: str) -> None:
status, payload = issues_comment(
base, token, owner, repo, args.number, args.body
)
print(json.dumps(payload, indent=2, ensure_ascii=False))
if status >= 400:
sys.exit(1)
def cmd_issues_close(args: argparse.Namespace, base: str, token: str, owner: str, repo: str) -> None:
status, payload = issues_patch(
base, token, owner, repo, args.number, {"state": "closed"}
)
print(json.dumps(payload, indent=2, ensure_ascii=False))
if status >= 400:
sys.exit(1)
def cmd_issues_reopen(args: argparse.Namespace, base: str, token: str, owner: str, repo: str) -> None:
status, payload = issues_patch(
base, token, owner, repo, args.number, {"state": "open"}
)
print(json.dumps(payload, indent=2, ensure_ascii=False))
if status >= 400:
sys.exit(1)
def cmd_repo_contents(args: argparse.Namespace, base: str, token: str, owner: str, repo: str) -> None:
status, payload = repo_file_content(
base, token, owner, repo, args.path, ref=args.ref or ""
)
if status >= 400:
print(json.dumps(payload, indent=2, ensure_ascii=False))
sys.exit(1)
if isinstance(payload, dict) and payload.get("encoding") == "text":
print(payload.get("content", ""))
else:
print(json.dumps(payload, indent=2, ensure_ascii=False))
def main() -> None:
if hasattr(sys.stdout, "reconfigure"):
try:
sys.stdout.reconfigure(encoding="utf-8")
except Exception:
pass
root = repo_root()
load_dotenv(root)
parser = argparse.ArgumentParser(description="Gitea API helper")
sub = parser.add_subparsers(dest="domain", required=True)
p_issues = sub.add_parser("issues", help="Issues")
i_sub = p_issues.add_subparsers(dest="issues_cmd", required=True)
p_il = i_sub.add_parser("list", help="List issues")
p_il.add_argument("--state", default="open", choices=["open", "closed", "all"])
p_il.add_argument("--limit", type=int, default=50)
p_il.add_argument("--page", type=int, default=1)
p_il.add_argument(
"--all-pages",
action="store_true",
help="Alle Seiten abfragen (Vorsicht bei sehr vielen Issues)",
)
p_il.set_defaults(_handler=cmd_issues_list)
p_ig = i_sub.add_parser("get", help="Get one issue")
p_ig.add_argument("number", type=int)
p_ig.set_defaults(_handler=cmd_issues_get)
p_ic = i_sub.add_parser("create", help="Create issue")
p_ic.add_argument("--title", required=True)
p_ic.add_argument("--body", default="")
p_ic.add_argument("--body-file")
p_ic.add_argument("--labels", nargs="*", default=[])
p_ic.set_defaults(_handler=cmd_issues_create)
p_co = i_sub.add_parser("comment", help="Add comment")
p_co.add_argument("number", type=int)
p_co.add_argument("--body", required=True)
p_co.set_defaults(_handler=cmd_issues_comment)
p_cl = i_sub.add_parser("close", help="Close issue")
p_cl.add_argument("number", type=int)
p_cl.set_defaults(_handler=cmd_issues_close)
p_ro = i_sub.add_parser("reopen", help="Reopen issue")
p_ro.add_argument("number", type=int)
p_ro.set_defaults(_handler=cmd_issues_reopen)
p_repo = sub.add_parser("repo", help="Repository (API)")
r_sub = p_repo.add_subparsers(dest="repo_cmd", required=True)
p_rc = r_sub.add_parser("file", help="Get file or directory metadata/content")
p_rc.add_argument("path")
p_rc.add_argument("--ref", default="", help="branch/tag/commit")
p_rc.set_defaults(_handler=cmd_repo_contents)
args = parser.parse_args()
try:
base, token, owner, reponame = require_config()
except RuntimeError as e:
sys.stderr.write(str(e) + "\n")
sys.exit(1)
handler = args._handler
handler(args, base, token, owner, reponame)
if __name__ == "__main__":
main()

226
scripts/gitea/gitea_lib.py Normal file
View File

@ -0,0 +1,226 @@
"""
Shared Gitea REST helpers (stdlib). Used by gitea_api.py CLI and mcp_server_gitea.py.
"""
from __future__ import annotations
import json
import os
import urllib.error
import urllib.request
from pathlib import Path
from typing import Any
def load_dotenv(repo_root: Path) -> None:
env_path = repo_root / ".env"
if not env_path.is_file():
return
for line in env_path.read_text(encoding="utf-8", errors="replace").splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
if "=" not in line:
continue
k, _, v = line.partition("=")
k, v = k.strip(), v.strip().strip('"').strip("'")
if k and k not in os.environ:
os.environ[k] = v
def repo_root() -> Path:
return Path(__file__).resolve().parents[2]
def get_config() -> tuple[str, str, str, str]:
base = os.getenv("GITEA_BASE_URL", "").rstrip("/")
token = os.getenv("GITEA_TOKEN", "")
owner = os.getenv("GITEA_OWNER", "")
reponame = os.getenv("GITEA_REPO", "")
return base, token, owner, reponame
def require_config() -> tuple[str, str, str, str]:
base, token, owner, reponame = get_config()
missing = [n for n, v in (
("GITEA_BASE_URL", base),
("GITEA_TOKEN", token),
("GITEA_OWNER", owner),
("GITEA_REPO", reponame),
) if not v]
if missing:
raise RuntimeError(
"Fehlende Umgebungsvariablen: " + ", ".join(missing)
+ " — setze sie in .env im Repo-Root oder in der MCP-env."
)
return base, token, owner, reponame
def request_json(
method: str,
url: str,
token: str,
data: dict | None = None,
) -> tuple[int, Any]:
body = None if data is None else json.dumps(data).encode("utf-8")
req = urllib.request.Request(url, data=body, method=method)
req.add_header("Authorization", f"token {token}")
req.add_header("Accept", "application/json")
if body is not None:
req.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(req, timeout=120) as resp:
raw = resp.read().decode("utf-8", errors="replace")
status = resp.status
except urllib.error.HTTPError as e:
raw = e.read().decode("utf-8", errors="replace")
try:
return e.code, json.loads(raw) if raw else {}
except json.JSONDecodeError:
return e.code, {"message": raw or str(e)}
if not raw:
return status, {}
try:
return status, json.loads(raw)
except json.JSONDecodeError:
return status, raw
def issues_list_page(
base: str,
token: str,
owner: str,
repo: str,
*,
state: str = "open",
page: int = 1,
limit: int = 50,
) -> tuple[int, list]:
if state == "all":
open_st, open_i = issues_list_page(
base, token, owner, repo, state="open", page=page, limit=limit
)
closed_st, closed_i = issues_list_page(
base, token, owner, repo, state="closed", page=page, limit=limit
)
merged = (open_i or []) + (closed_i or [])
st = max(open_st, closed_st) if open_st >= 400 or closed_st >= 400 else 200
return st, merged[:limit]
q = f"?state={state}&page={page}&limit={limit}"
url = f"{base}/api/v1/repos/{owner}/{repo}/issues{q}"
status, payload = request_json("GET", url, token)
if status >= 400:
return status, []
if not isinstance(payload, list):
return status, []
return status, payload
def issues_list_all(
base: str,
token: str,
owner: str,
repo: str,
*,
state: str = "open",
limit: int = 50,
) -> list[dict]:
if state == "all":
o = issues_list_all(
base, token, owner, repo, state="open", limit=limit
)
c = issues_list_all(
base, token, owner, repo, state="closed", limit=limit
)
return o + c
out: list[dict] = []
page = 1
while True:
_, batch = issues_list_page(
base, token, owner, repo, state=state, page=page, limit=limit
)
if not batch:
break
out.extend(batch)
if len(batch) < limit:
break
page += 1
return out
def issues_get(
base: str, token: str, owner: str, repo: str, number: int
) -> tuple[int, Any]:
url = f"{base}/api/v1/repos/{owner}/{repo}/issues/{number}"
return request_json("GET", url, token)
def issues_create(
base: str,
token: str,
owner: str,
repo: str,
*,
title: str,
body: str = "",
labels: list[str] | None = None,
) -> tuple[int, Any]:
url = f"{base}/api/v1/repos/{owner}/{repo}/issues"
return request_json(
"POST",
url,
token,
{"title": title, "body": body, "labels": labels or []},
)
def issues_comment(
base: str,
token: str,
owner: str,
repo: str,
number: int,
body: str,
) -> tuple[int, Any]:
url = f"{base}/api/v1/repos/{owner}/{repo}/issues/{number}/comments"
return request_json("POST", url, token, {"body": body})
def issues_patch(
base: str,
token: str,
owner: str,
repo: str,
number: int,
fields: dict,
) -> tuple[int, Any]:
"""Gitea: PATCH issue (state, title, body, …)."""
url = f"{base}/api/v1/repos/{owner}/{repo}/issues/{number}"
return request_json("PATCH", url, token, fields)
def repo_file_content(
base: str,
token: str,
owner: str,
repo: str,
path: str,
ref: str = "",
) -> tuple[int, Any]:
from urllib.parse import quote
from base64 import b64decode
p = quote(path, safe="/")
r = f"?ref={ref}" if ref else ""
url = f"{base}/api/v1/repos/{owner}/{repo}/contents/{p}{r}"
st, payload = request_json("GET", url, token)
if st >= 400:
return st, payload
if isinstance(payload, dict) and payload.get("type") == "file" and payload.get(
"content"
):
try:
text = b64decode(payload["content"]).decode("utf-8", errors="replace")
return st, {"path": path, "encoding": "text", "content": text}
except Exception:
return st, payload
return st, payload

View File

@ -0,0 +1,127 @@
#!/usr/bin/env python3
"""
MCP-Server für Gitea (Issues + Datei-Inhalt via API).
Cursor: in den MCP-Einstellungen dieses Skript starten (siehe MCP_SETUP.md).
Transport: stdio (Standard FastMCP).
Abhängigkeit: pip install "mcp>=1.2.0" (siehe requirements-mcp.txt)
"""
from __future__ import annotations
import json
import sys
from gitea_lib import (
issues_comment,
issues_create,
issues_get,
issues_list_all,
issues_list_page,
issues_patch,
load_dotenv,
repo_file_content,
repo_root,
require_config,
)
from mcp.server.fastmcp import FastMCP # noqa: E402
mcp = FastMCP(
"mitai-gitea",
instructions=(
"Gitea-Tools für das Repo aus GITEA_OWNER/GITEA_REPO. "
"Schließe Issues nur nach klarer Code-Verifikation; sonst Kommentar mit offenen Punkten."
),
)
def _cfg():
load_dotenv(repo_root())
return require_config()
def _json(obj) -> str:
return json.dumps(obj, indent=2, ensure_ascii=False)
@mcp.tool()
def gitea_list_issues(
state: str = "open",
limit_per_page: int = 50,
fetch_all_pages: bool = False,
) -> str:
"""Listet Issues. state: open | closed | all. fetch_all_pages=true holt alle Seiten (kann langsam sein)."""
base, token, owner, repo = _cfg()
if fetch_all_pages:
items = issues_list_all(
base, token, owner, repo, state=state, limit=limit_per_page
)
return _json(
[{"number": i.get("number"), "title": i.get("title"), "state": i.get("state")} for i in items]
)
_, items = issues_list_page(
base, token, owner, repo, state=state, page=1, limit=limit_per_page
)
return _json(
[{"number": i.get("number"), "title": i.get("title"), "state": i.get("state")} for i in items]
)
@mcp.tool()
def gitea_get_issue(issue_number: int) -> str:
"""Holt ein Issue inkl. Body, Labels, State (JSON)."""
base, token, owner, repo = _cfg()
st, payload = issues_get(base, token, owner, repo, issue_number)
return _json({"http_status": st, "issue": payload})
@mcp.tool()
def gitea_create_issue(title: str, body: str = "", labels: str = "") -> str:
"""Legt ein Issue an. labels: kommagetrennte Namen, z.B. \"bug,backend\"."""
base, token, owner, repo = _cfg()
lab = [x.strip() for x in labels.split(",") if x.strip()]
st, payload = issues_create(
base, token, owner, repo, title=title, body=body, labels=lab
)
return _json({"http_status": st, "result": payload})
@mcp.tool()
def gitea_comment_issue(issue_number: int, body: str) -> str:
"""Kommentar an ein Issue anhängen."""
base, token, owner, repo = _cfg()
st, payload = issues_comment(base, token, owner, repo, issue_number, body)
return _json({"http_status": st, "result": payload})
@mcp.tool()
def gitea_close_issue(issue_number: int) -> str:
"""Issue schließen (state=closed)."""
base, token, owner, repo = _cfg()
st, payload = issues_patch(
base, token, owner, repo, issue_number, {"state": "closed"}
)
return _json({"http_status": st, "result": payload})
@mcp.tool()
def gitea_reopen_issue(issue_number: int) -> str:
"""Geschlossenes Issue wieder öffnen."""
base, token, owner, repo = _cfg()
st, payload = issues_patch(
base, token, owner, repo, issue_number, {"state": "open"}
)
return _json({"http_status": st, "result": payload})
@mcp.tool()
def gitea_get_repo_file(path: str, git_ref: str = "") -> str:
"""Liest eine Datei aus dem Repo über die Gitea-API (Standard: Default-Branch)."""
base, token, owner, repo = _cfg()
st, payload = repo_file_content(base, token, owner, repo, path, ref=git_ref)
return _json({"http_status": st, "payload": payload})
if __name__ == "__main__":
mcp.run()

View File

@ -0,0 +1,2 @@
# Nur für MCP-Server (nicht im Backend-Container nötig)
mcp>=1.2.0

View File

@ -1,6 +0,0 @@
{
"status": "failed",
"failedTests": [
"d6ae548bbe32e0652471-816c0db33a38f27f1eaf"
]
}

View File

@ -0,0 +1,135 @@
"""
Unit Tests für question_augmenter.py (Phase 1)
Run with: PYTHONPATH=./backend pytest tests/backend/test_phase1_question_augmenter.py -v
"""
import pytest
from workflow_models import QuestionAugmentation
from question_augmenter import (
augment_prompt_with_questions,
merge_question_augmentations,
format_question_list,
parse_question_augmentations_from_jsonb
)
def test_format_question_list():
"""Test: Formatierung der Fragenliste"""
questions = [
QuestionAugmentation(
id="q1",
type="relevanz",
question="Ist relevant?",
answer_spectrum=["ja", "nein", "unklar"]
),
QuestionAugmentation(
id="q2",
type="prioritaet",
question="Wie hoch?",
answer_spectrum=["hoch", "mittel", "niedrig", "unklar"]
)
]
result = format_question_list(questions)
assert "Relevanz" in result
assert "[ja/nein/unklar]" in result
assert "Prioritaet" in result # Lowercase wird capitalized
assert "[hoch/mittel/niedrig/unklar]" in result
def test_augment_prompt_with_questions():
"""Test: Prompt-Erweiterung mit Fragenergänzungen"""
base_prompt = "Analysiere die Körperdaten."
questions = [
QuestionAugmentation(
id="q1",
type="relevanz",
question="Ist relevant?",
answer_spectrum=["ja", "nein", "unklar"]
)
]
augmented = augment_prompt_with_questions(base_prompt, questions)
assert "Analysiere die Körperdaten." in augmented
assert "## Analyse" in augmented
assert "## Entscheidungsfragen" in augmented
assert "Relevanz" in augmented
assert "[ja/nein/unklar]" in augmented
def test_merge_question_augmentations_node_priority():
"""Test: Knotengebundene Fragen haben Vorrang (Hybridmodell)"""
node_questions = [
QuestionAugmentation(id="q1", type="relevanz", question="Q1", answer_spectrum=["ja", "nein"])
]
prompt_questions = [
QuestionAugmentation(id="q2", type="prioritaet", question="Q2", answer_spectrum=["hoch", "niedrig"])
]
result = merge_question_augmentations(node_questions, prompt_questions)
# Knotengebundene haben Vorrang
assert len(result) == 1
assert result[0].type == "relevanz"
def test_merge_question_augmentations_prompt_fallback():
"""Test: Prompt-Defaults werden verwendet wenn Knoten leer"""
node_questions = None
prompt_questions = [
QuestionAugmentation(id="q2", type="prioritaet", question="Q2", answer_spectrum=["hoch", "niedrig"])
]
result = merge_question_augmentations(node_questions, prompt_questions)
# Prompt-Defaults werden verwendet
assert len(result) == 1
assert result[0].type == "prioritaet"
def test_merge_question_augmentations_empty():
"""Test: Leere Liste wenn weder Knoten noch Prompt Fragen haben"""
result = merge_question_augmentations(None, None)
assert result == []
def test_parse_question_augmentations_from_jsonb():
"""Test: Parsing aus JSONB-Format"""
jsonb_data = [
{
"id": "q1",
"type": "relevanz",
"question": "Ist relevant?",
"answer_spectrum": ["ja", "nein", "unklar"]
},
{
"id": "q2",
"type": "prioritaet",
"question": "Wie hoch?",
"answer_spectrum": ["hoch", "mittel", "niedrig"]
}
]
result = parse_question_augmentations_from_jsonb(jsonb_data)
assert len(result) == 2
assert result[0].type == "relevanz"
assert result[1].type == "prioritaet"
def test_parse_question_augmentations_empty_jsonb():
"""Test: Leere Liste bei None JSONB"""
result = parse_question_augmentations_from_jsonb(None)
assert result == []
def test_parse_question_augmentations_invalid_jsonb():
"""Test: ValueError bei ungültigem JSONB"""
with pytest.raises(ValueError, match="muss ein Array sein"):
parse_question_augmentations_from_jsonb({"invalid": "format"})
if __name__ == "__main__":
pytest.main([__file__, "-v"])

View File

@ -0,0 +1,234 @@
"""
Unit Tests für result_container_parser.py (Phase 1)
Run with: PYTHONPATH=./backend pytest tests/backend/test_phase1_result_container_parser.py -v
"""
import pytest
from result_container_parser import (
parse_result_container,
extract_section,
parse_decision_questions,
validate_decision_signal,
parse_result_container_robust
)
def test_extract_section_basic():
"""Test: Einfache Sektion extrahieren"""
text = """
## Analyse
Das ist der Analysekern.
Mehrere Zeilen.
## Entscheidungsfragen
- Relevanz: ja
"""
result = extract_section(text, "Analyse")
assert result == "Das ist der Analysekern.\nMehrere Zeilen."
def test_extract_section_not_found():
"""Test: Nicht vorhandene Sektion"""
text = "## Analyse\nInhalt"
result = extract_section(text, "Begründung")
assert result is None
def test_extract_section_empty():
"""Test: Leere Sektion (nur Whitespace am Ende)"""
text = "## Analyse\n\n"
result = extract_section(text, "Analyse")
assert result is None
def test_parse_decision_questions_basic():
"""Test: Standard-Format parsen"""
section = """
- Relevanz: ja
- Priorität: hoch
- Selektion: nein
"""
result = parse_decision_questions(section)
assert result == {
"relevanz": "ja",
"priorität": "hoch",
"selektion": "nein"
}
def test_parse_decision_questions_bold():
"""Test: Format mit **bold** Markup"""
section = """
- **Relevanz**: ja
- **Priorität**: hoch
"""
result = parse_decision_questions(section)
assert result == {
"relevanz": "ja",
"priorität": "hoch"
}
def test_parse_decision_questions_without_dash():
"""Test: Format ohne führendes Minus"""
section = """
Relevanz: ja
Priorität: hoch
"""
result = parse_decision_questions(section)
assert result == {
"relevanz": "ja",
"priorität": "hoch"
}
def test_parse_decision_questions_brackets():
"""Test: Format mit [Klammern]"""
section = """
- Relevanz: [ja]
- Priorität: [hoch]
"""
result = parse_decision_questions(section)
assert result == {
"relevanz": "ja",
"priorität": "hoch"
}
def test_validate_decision_signal_exact_match():
"""Test: Exakte Übereinstimmung"""
value, status = validate_decision_signal("ja", ["ja", "nein", "unklar"])
assert value == "ja"
assert status == "valid"
def test_validate_decision_signal_normalized():
"""Test: Case-insensitive Normalisierung"""
value, status = validate_decision_signal("JA", ["ja", "nein", "unklar"])
assert value == "ja"
assert status == "normalized"
def test_validate_decision_signal_invalid():
"""Test: Ungültige Antwort"""
value, status = validate_decision_signal("vielleicht", ["ja", "nein", "unklar"])
assert value == "vielleicht"
assert status == "invalid"
def test_parse_result_container_complete():
"""Test: Vollständiger Container mit allen Sektionen"""
llm_output = """
## Analyse
Der Nutzer zeigt eine positive Gewichtsentwicklung.
Kaloriendefizit wird eingehalten.
## Entscheidungsfragen
- Relevanz: ja
- Priorität: hoch
## Begründung
Die Gewichtsabnahme ist im Zielbereich von 0.5-1% pro Woche.
"""
result = parse_result_container(llm_output)
assert result["parsing_status"] == "complete"
assert "Gewichtsentwicklung" in result["analysis_core"]
assert result["decision_signals"]["relevanz"] == "ja"
assert result["decision_signals"]["priorität"] == "hoch"
assert "Zielbereich" in result["reasoning_anchors"]
def test_parse_result_container_partial():
"""Test: Container ohne Begründung (partial)"""
llm_output = """
## Analyse
Analyse-Inhalt
## Entscheidungsfragen
- Relevanz: ja
"""
result = parse_result_container(llm_output)
assert result["parsing_status"] == "complete"
assert result["analysis_core"] == "Analyse-Inhalt"
assert result["decision_signals"]["relevanz"] == "ja"
assert result["reasoning_anchors"] is None
def test_parse_result_container_no_structure():
"""Test: Unstrukturierte Antwort (Fallback)"""
llm_output = "Einfache Textantwort ohne Strukturierung."
result = parse_result_container(llm_output)
assert result["parsing_status"] == "fallback"
assert result["analysis_core"] == "Einfache Textantwort ohne Strukturierung."
assert result["decision_signals"] == {}
assert result["reasoning_anchors"] is None
def test_parse_result_container_only_questions():
"""Test: Nur Entscheidungsfragen, keine Analyse (partial)"""
llm_output = """
## Entscheidungsfragen
- Relevanz: nein
- Priorität: niedrig
"""
result = parse_result_container(llm_output)
assert result["parsing_status"] == "partial"
assert result["analysis_core"] == ""
assert result["decision_signals"]["relevanz"] == "nein"
def test_parse_result_container_robust_with_warnings():
"""Test: Robuste Variante mit erwarteten Fragen"""
llm_output = """
## Analyse
Inhalt
## Entscheidungsfragen
- Relevanz: ja
"""
expected_questions = ["relevanz", "prioritaet", "selektion"]
result = parse_result_container_robust(llm_output, expected_questions)
assert "warnings" in result
assert any("Fehlende Entscheidungsfragen" in w for w in result["warnings"])
assert any("prioritaet" in w for w in result["warnings"])
def test_parse_result_container_case_insensitive():
"""Test: Case-insensitive Sektion-Matching"""
llm_output = """
## ANALYSE
Großgeschrieben
## entscheidungsfragen
- Relevanz: ja
"""
result = parse_result_container(llm_output)
assert result["parsing_status"] == "complete"
assert result["analysis_core"] == "Großgeschrieben"
assert result["decision_signals"]["relevanz"] == "ja"
def test_parse_decision_questions_mixed_formats():
"""Test: Gemischte Formate in einer Sektion"""
section = """
- **Relevanz**: ja
Priorität: hoch
- Selektion: [nein]
"""
result = parse_decision_questions(section)
assert result["relevanz"] == "ja"
assert result["priorität"] == "hoch"
assert result["selektion"] == "nein"
if __name__ == "__main__":
pytest.main([__file__, "-v"])

View File

@ -0,0 +1,229 @@
"""
Unit Tests für normalization_engine.py (Phase 2)
Run with: PYTHONPATH=./backend pytest tests/backend/test_phase2_normalization.py -v
"""
import pytest
from workflow_models import SignalStatus
from normalization_engine import (
normalize_decision_signal,
apply_synonym_mapping,
normalize_all_signals
)
# ── normalize_decision_signal Tests ────────────────────────────────────────────
def test_exact_match():
"""Test: Exakte Übereinstimmung mit Spektrum → valid"""
signal = normalize_decision_signal(
question_type="relevanz",
raw_value="ja",
answer_spectrum=["ja", "nein", "unklar"]
)
assert signal.status == SignalStatus.VALID
assert signal.normalized_value == "ja"
assert signal.raw_value == "ja"
def test_case_insensitive_uppercase():
"""Test: Case-insensitive Matching (Großbuchstaben) → normalized"""
signal = normalize_decision_signal(
question_type="relevanz",
raw_value="JA",
answer_spectrum=["ja", "nein", "unklar"]
)
assert signal.status == SignalStatus.NORMALIZED
assert signal.normalized_value == "ja"
assert signal.metadata["method"] == "case_insensitive"
def test_case_insensitive_mixed():
"""Test: Case-insensitive Matching (Mixed Case) → normalized"""
signal = normalize_decision_signal(
question_type="prioritaet",
raw_value="Hoch",
answer_spectrum=["hoch", "mittel", "niedrig"]
)
assert signal.status == SignalStatus.NORMALIZED
assert signal.normalized_value == "hoch"
def test_synonym_mapping_simple():
"""Test: Synonym-Mapping → normalized"""
rules = {"synonyms": {"ja": ["yes", "Yes", "YES"]}}
signal = normalize_decision_signal(
question_type="relevanz",
raw_value="yes",
answer_spectrum=["ja", "nein"],
normalization_rules=rules
)
assert signal.status == SignalStatus.NORMALIZED
assert signal.normalized_value == "ja"
assert signal.metadata["method"] == "synonym"
def test_synonym_mapping_case_insensitive():
"""Test: Synonym-Mapping mit case-insensitive → normalized"""
rules = {"synonyms": {"ja": ["yes"]}}
signal = normalize_decision_signal(
question_type="relevanz",
raw_value="YES",
answer_spectrum=["ja", "nein"],
normalization_rules=rules
)
assert signal.status == SignalStatus.NORMALIZED
assert signal.normalized_value == "ja"
def test_invalid_value():
"""Test: Wert außerhalb des Spektrums → invalid"""
signal = normalize_decision_signal(
question_type="relevanz",
raw_value="vielleicht",
answer_spectrum=["ja", "nein", "unklar"]
)
assert signal.status == SignalStatus.INVALID
assert signal.normalized_value is None
def test_whitespace_handling():
"""Test: Whitespace wird getrimmt → normalized"""
signal = normalize_decision_signal(
question_type="relevanz",
raw_value=" ja ",
answer_spectrum=["ja", "nein"]
)
assert signal.status == SignalStatus.NORMALIZED # Wegen strip()
assert signal.normalized_value == "ja"
def test_synonym_no_match():
"""Test: Synonym-Rules vorhanden, aber kein Match → invalid"""
rules = {"synonyms": {"ja": ["yes"], "nein": ["no"]}}
signal = normalize_decision_signal(
question_type="relevanz",
raw_value="maybe",
answer_spectrum=["ja", "nein"],
normalization_rules=rules
)
assert signal.status == SignalStatus.INVALID
# ── apply_synonym_mapping Tests ────────────────────────────────────────────────
def test_apply_synonym_exact():
"""Test: Exakte Synonym-Übereinstimmung"""
synonyms = {"ja": ["yes", "Yes"], "nein": ["no", "No"]}
result = apply_synonym_mapping("yes", synonyms)
assert result == "ja"
def test_apply_synonym_case_insensitive():
"""Test: Case-insensitive Synonym-Matching"""
synonyms = {"ja": ["yes"], "nein": ["no"]}
result = apply_synonym_mapping("YES", synonyms)
assert result == "ja"
def test_apply_synonym_no_match():
"""Test: Kein Synonym-Match → None"""
synonyms = {"ja": ["yes"], "nein": ["no"]}
result = apply_synonym_mapping("vielleicht", synonyms)
assert result is None
def test_apply_synonym_whitespace():
"""Test: Synonym mit Whitespace"""
synonyms = {"ja": ["yes"]}
result = apply_synonym_mapping(" yes ", synonyms)
assert result == "ja"
# ── normalize_all_signals Tests ────────────────────────────────────────────────
def test_normalize_all_signals_basic():
"""Test: Mehrere Signale normalisieren"""
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)
assert len(normalized) == 2
assert normalized[0].question_type == "relevanz"
assert normalized[0].status == SignalStatus.VALID
assert normalized[1].question_type == "prioritaet"
assert normalized[1].status == SignalStatus.NORMALIZED
def test_normalize_all_signals_with_synonyms():
"""Test: Normalisierung mit Synonymen"""
signals = {
"relevanz": "yes",
"prioritaet": "high"
}
catalog = {
"relevanz": {
"answer_spectrum": ["ja", "nein"],
"normalization_rules": {"synonyms": {"ja": ["yes"], "nein": ["no"]}}
},
"prioritaet": {
"answer_spectrum": ["hoch", "mittel", "niedrig"],
"normalization_rules": {"synonyms": {"hoch": ["high"], "niedrig": ["low"]}}
}
}
normalized = normalize_all_signals(signals, catalog)
assert len(normalized) == 2
assert normalized[0].normalized_value == "ja"
assert normalized[1].normalized_value == "hoch"
def test_normalize_all_signals_not_in_catalog():
"""Test: Question type nicht im Katalog → not_decidable"""
signals = {"unknown_type": "value"}
catalog = {"relevanz": {"answer_spectrum": ["ja", "nein"], "normalization_rules": None}}
normalized = normalize_all_signals(signals, catalog)
assert len(normalized) == 1
assert normalized[0].status == SignalStatus.NOT_DECIDABLE
assert normalized[0].metadata["error"] == "not_in_catalog"
def test_normalize_all_signals_mixed_validity():
"""Test: Gemischte Gültigkeit (valid, normalized, invalid)"""
signals = {
"relevanz": "ja", # valid
"prioritaet": "HOCH", # normalized (case)
"selektion": "vielleicht" # invalid
}
catalog = {
"relevanz": {"answer_spectrum": ["ja", "nein"], "normalization_rules": None},
"prioritaet": {"answer_spectrum": ["hoch", "mittel", "niedrig"], "normalization_rules": None},
"selektion": {"answer_spectrum": ["ja", "nein"], "normalization_rules": None}
}
normalized = normalize_all_signals(signals, catalog)
assert len(normalized) == 3
assert normalized[0].status == SignalStatus.VALID
assert normalized[1].status == SignalStatus.NORMALIZED
assert normalized[2].status == SignalStatus.INVALID
def test_normalize_all_signals_empty():
"""Test: Leere Signal-Liste"""
normalized = normalize_all_signals({}, {})
assert len(normalized) == 0
if __name__ == "__main__":
pytest.main([__file__, "-v"])

View File

@ -0,0 +1,401 @@
"""
Unit Tests für workflow_executor.py (Phase 2)
Run with: PYTHONPATH=./backend pytest tests/backend/test_phase2_workflow_executor.py -v
"""
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from workflow_executor import aggregate_results
from workflow_models import NodeExecutionState, NodeStatus, NormalizedSignal, SignalStatus
# ── aggregate_results Tests ────────────────────────────────────────────────────
def test_aggregate_results_basic():
"""Test: Aggregation mit zwei executed nodes"""
states = [
NodeExecutionState(
node_id="start",
status=NodeStatus.EXECUTED,
started_at="2026-04-03T12:00:00",
completed_at="2026-04-03T12:00:01"
),
NodeExecutionState(
node_id="body",
status=NodeStatus.EXECUTED,
analysis_core="Gewichtsentwicklung positiv",
normalized_signals=[
NormalizedSignal(
question_type="relevanz",
raw_value="ja",
normalized_value="ja",
status=SignalStatus.VALID
)
],
started_at="2026-04-03T12:00:01",
completed_at="2026-04-03T12:00:05"
),
NodeExecutionState(
node_id="end",
status=NodeStatus.EXECUTED,
started_at="2026-04-03T12:00:05",
completed_at="2026-04-03T12:00:06"
)
]
result = aggregate_results(states)
assert "## body" in result["combined_analysis"]
assert "Gewichtsentwicklung" in result["combined_analysis"]
assert result["total_nodes"] == 3
assert result["executed_nodes"] == 3
assert result["failed_nodes"] == 0
assert len(result["all_signals"]) == 1
assert result["all_signals"][0]["question_type"] == "relevanz"
def test_aggregate_results_with_failed_node():
"""Test: Aggregation mit einem fehlgeschlagenen Knoten"""
states = [
NodeExecutionState(
node_id="node1",
status=NodeStatus.EXECUTED,
analysis_core="Success",
started_at="2026-04-03T12:00:00",
completed_at="2026-04-03T12:00:01"
),
NodeExecutionState(
node_id="node2",
status=NodeStatus.FAILED,
error="LLM timeout",
started_at="2026-04-03T12:00:01",
completed_at="2026-04-03T12:00:02"
)
]
result = aggregate_results(states)
assert result["total_nodes"] == 2
assert result["executed_nodes"] == 1
assert result["failed_nodes"] == 1
assert "## node1" in result["combined_analysis"]
assert "## node2" not in result["combined_analysis"]
def test_aggregate_results_multiple_signals():
"""Test: Aggregation mit mehreren normalisierten Signalen"""
states = [
NodeExecutionState(
node_id="node1",
status=NodeStatus.EXECUTED,
analysis_core="Analysis 1",
normalized_signals=[
NormalizedSignal(
question_type="relevanz",
raw_value="ja",
normalized_value="ja",
status=SignalStatus.VALID
),
NormalizedSignal(
question_type="prioritaet",
raw_value="hoch",
normalized_value="hoch",
status=SignalStatus.VALID
)
],
started_at="2026-04-03T12:00:00",
completed_at="2026-04-03T12:00:01"
),
NodeExecutionState(
node_id="node2",
status=NodeStatus.EXECUTED,
analysis_core="Analysis 2",
normalized_signals=[
NormalizedSignal(
question_type="selektion",
raw_value="nein",
normalized_value="nein",
status=SignalStatus.VALID
)
],
started_at="2026-04-03T12:00:01",
completed_at="2026-04-03T12:00:02"
)
]
result = aggregate_results(states)
assert len(result["all_signals"]) == 3
assert result["all_signals"][0]["question_type"] == "relevanz"
assert result["all_signals"][1]["question_type"] == "prioritaet"
assert result["all_signals"][2]["question_type"] == "selektion"
def test_aggregate_results_empty():
"""Test: Aggregation mit leerer node_states Liste"""
result = aggregate_results([])
assert result["combined_analysis"] == ""
assert result["all_signals"] == []
assert result["total_nodes"] == 0
assert result["executed_nodes"] == 0
assert result["failed_nodes"] == 0
def test_aggregate_results_no_analysis_core():
"""Test: Aggregation mit nodes ohne analysis_core"""
states = [
NodeExecutionState(
node_id="start",
status=NodeStatus.EXECUTED,
started_at="2026-04-03T12:00:00",
completed_at="2026-04-03T12:00:01"
)
]
result = aggregate_results(states)
assert result["combined_analysis"] == ""
assert result["executed_nodes"] == 1
def test_aggregate_results_formatting():
"""Test: Formatierung der combined_analysis"""
states = [
NodeExecutionState(
node_id="node1",
status=NodeStatus.EXECUTED,
analysis_core="First analysis",
started_at="2026-04-03T12:00:00",
completed_at="2026-04-03T12:00:01"
),
NodeExecutionState(
node_id="node2",
status=NodeStatus.EXECUTED,
analysis_core="Second analysis",
started_at="2026-04-03T12:00:01",
completed_at="2026-04-03T12:00:02"
)
]
result = aggregate_results(states)
# Prüfe Format: ## node_id\nanalysis_core\n\n## node_id\nanalysis_core
assert result["combined_analysis"].startswith("## node1\nFirst analysis")
assert "## node2\nSecond analysis" in result["combined_analysis"]
assert "\n\n" in result["combined_analysis"] # Separator zwischen Knoten
# ── Integration-ähnliche Tests (ohne echte DB/LLM) ─────────────────────────────
@pytest.mark.asyncio
async def test_execute_node_start_end():
"""Test: Start/End Nodes sind No-Ops"""
from workflow_executor import execute_node
from workflow_models import WorkflowNode, WorkflowGraph
start_node = WorkflowNode(id="start", type="start")
end_node = WorkflowNode(id="end", type="end")
context = {"variables": {}, "profile_id": "test"}
catalog = {}
mock_graph = WorkflowGraph(nodes=[], edges=[]) # Phase 3: graph parameter required
async def mock_llm(prompt, model):
return "should not be called"
# Test start
result = await execute_node(start_node, context, catalog, mock_graph, mock_llm)
assert result.status == NodeStatus.EXECUTED
assert result.analysis_core is None
# Test end
result = await execute_node(end_node, context, catalog, mock_graph, mock_llm)
assert result.status == NodeStatus.EXECUTED
assert result.analysis_core is None
@pytest.mark.asyncio
async def test_execute_node_join_implemented():
"""Test: Join Node ist jetzt implementiert (Phase 4)"""
from workflow_executor import execute_node
from workflow_models import WorkflowNode, WorkflowGraph, JoinStrategy
# Phase 4: join nodes sind jetzt implementiert
join_node = WorkflowNode(id="join1", type="join", join_strategy=JoinStrategy.BEST_EFFORT)
# Minimal-context (kein incoming path vorhanden)
context = {
"variables": {},
"profile_id": "test",
"node_results": {}, # Keine incoming paths
"active_edges": {}
}
catalog = {}
mock_graph = WorkflowGraph(nodes=[join_node], edges=[])
async def mock_llm(prompt, model):
return ""
result = await execute_node(join_node, context, catalog, mock_graph, mock_llm)
# Join node sollte erfolgreich ausgeführt werden (best_effort mit 0 Pfaden)
# oder FAILED mit sinnvoller Fehlermeldung (keine incoming edges)
assert result.status in [NodeStatus.EXECUTED, NodeStatus.FAILED]
assert result.node_id == "join1"
@pytest.mark.asyncio
async def test_execute_node_analysis_simple():
"""Test: Analysis Node ohne Fragenergänzung"""
from workflow_executor import execute_node
from workflow_models import WorkflowNode, WorkflowGraph
node = WorkflowNode(
id="test_node",
type="analysis",
prompt_slug="test_prompt",
question_augmentations=None
)
context = {"variables": {"name": "Test"}, "profile_id": "test"}
catalog = {}
mock_graph = WorkflowGraph(nodes=[], edges=[])
# Mock LLM
async def mock_llm(prompt, model):
return "## Analyse\nTest analysis content"
# Mock load_prompt_template
with patch('workflow_executor.load_prompt_template') as mock_load:
mock_load.return_value = "Test prompt for {{name}}"
result = await execute_node(node, context, catalog, mock_graph, mock_llm)
assert result.status == NodeStatus.EXECUTED
assert result.analysis_core == "Test analysis content"
assert len(result.normalized_signals) == 0 # Keine Fragen
@pytest.mark.asyncio
async def test_execute_node_analysis_with_questions():
"""Test: Analysis Node mit Fragenergänzung und Normalisierung"""
from workflow_executor import execute_node
from workflow_models import WorkflowNode, QuestionAugmentation, WorkflowGraph
node = WorkflowNode(
id="test_node",
type="analysis",
prompt_slug="test_prompt",
question_augmentations=[
QuestionAugmentation(
id="q1",
type="relevanz",
question="Ist relevant?",
answer_spectrum=["ja", "nein", "unklar"]
)
]
)
context = {"variables": {}, "profile_id": "test"}
catalog = {
"relevanz": {
"answer_spectrum": ["ja", "nein", "unklar"],
"normalization_rules": None
}
}
mock_graph = WorkflowGraph(nodes=[], edges=[])
# Mock LLM
async def mock_llm(prompt, model):
# LLM antwortet mit Fragenergänzung
return """## Analyse
Test analysis
## Entscheidungsfragen
- Relevanz: ja
"""
# Mock load_prompt_template
with patch('workflow_executor.load_prompt_template') as mock_load:
mock_load.return_value = "Base prompt"
result = await execute_node(node, context, catalog, mock_graph, mock_llm)
assert result.status == NodeStatus.EXECUTED
assert result.analysis_core == "Test analysis"
assert len(result.normalized_signals) == 1
assert result.normalized_signals[0].question_type == "relevanz"
assert result.normalized_signals[0].normalized_value == "ja"
assert result.normalized_signals[0].status == SignalStatus.VALID
@pytest.mark.asyncio
async def test_execute_node_hybrid_model_override():
"""
Test: Hybrid Model - Node-spezifisches Spektrum überschreibt Catalog
Kritischer Test für Bug-Fix: Node mit answer_spectrum ["increase", "stable", "decrease"]
muss Catalog-Spektrum ["ja", "nein", "unklar"] überschreiben.
Regression-Test für: https://github.com/anthropics/claude-code/issues/XXX
"""
from workflow_executor import execute_node
from workflow_models import WorkflowNode, QuestionAugmentation, WorkflowGraph
# Node mit ANDEREM Spektrum als Catalog
node = WorkflowNode(
id="test_node",
type="analysis",
prompt_slug="test_prompt",
question_augmentations=[
QuestionAugmentation(
id="q1",
type="relevanz",
question="Hat sich die Fettmasse verändert?",
answer_spectrum=["increase", "stable", "decrease"] # ← Node-spezifisch
)
]
)
context = {"variables": {}, "profile_id": "test"}
# Catalog hat ANDERES Spektrum
catalog = {
"relevanz": {
"answer_spectrum": ["ja", "nein", "unklar"], # ← Catalog-Standard
"normalization_rules": None
}
}
mock_graph = WorkflowGraph(nodes=[], edges=[])
# Mock LLM gibt "decrease" zurück (gültig für Node, ungültig für Catalog)
async def mock_llm(prompt, model):
return """## Analyse
Gewicht gesunken
## Entscheidungsfragen
- Relevanz: decrease
"""
# Mock load_prompt_template
with patch('workflow_executor.load_prompt_template') as mock_load:
mock_load.return_value = "Base prompt"
result = await execute_node(node, context, catalog, mock_graph, mock_llm)
# Assertions: "decrease" muss VALID sein (Node-Spektrum), nicht INVALID (Catalog)
assert result.status == NodeStatus.EXECUTED
assert len(result.normalized_signals) == 1
signal = result.normalized_signals[0]
assert signal.question_type == "relevanz"
assert signal.raw_value == "decrease"
assert signal.normalized_value == "decrease"
assert signal.status == SignalStatus.VALID # ← KRITISCH: Muss VALID sein, nicht INVALID!
# Wenn dieser Test fehlschlägt, wurde der Catalog benutzt statt Node-Spektrum
if __name__ == "__main__":
pytest.main([__file__, "-v"])

View File

@ -0,0 +1,720 @@
"""
Unit Tests für logic_evaluator.py (Phase 3)
Run with: PYTHONPATH=./backend pytest tests/backend/test_phase3_logic_evaluator.py -v
"""
import pytest
from logic_evaluator import (
evaluate_logic_expression,
resolve_signal_reference,
compare_values
)
from workflow_models import (
LogicExpression,
LogicOperator,
NormalizedSignal,
SignalStatus,
NodeExecutionState,
NodeStatus
)
# ── Comparison Operator Tests ──────────────────────────────────────────────────
def test_evaluate_eq_true():
"""Test: EQ operator - match"""
expression = LogicExpression(
operator=LogicOperator.EQ,
ref="body.relevanz",
value="decrease"
)
context = {
"node_results": {
"body": NodeExecutionState(
node_id="body",
status=NodeStatus.EXECUTED,
normalized_signals=[
NormalizedSignal(
question_type="relevanz",
raw_value="decrease",
normalized_value="decrease",
status=SignalStatus.VALID
)
]
)
}
}
result, error = evaluate_logic_expression(expression, context)
assert result is True
assert error is None
def test_evaluate_eq_false():
"""Test: EQ operator - no match"""
expression = LogicExpression(
operator=LogicOperator.EQ,
ref="body.relevanz",
value="increase"
)
context = {
"node_results": {
"body": NodeExecutionState(
node_id="body",
status=NodeStatus.EXECUTED,
normalized_signals=[
NormalizedSignal(
question_type="relevanz",
raw_value="decrease",
normalized_value="decrease",
status=SignalStatus.VALID
)
]
)
}
}
result, error = evaluate_logic_expression(expression, context)
assert result is False
assert error is None
def test_evaluate_neq():
"""Test: NEQ operator"""
expression = LogicExpression(
operator=LogicOperator.NEQ,
ref="body.relevanz",
value="stable"
)
context = {
"node_results": {
"body": NodeExecutionState(
node_id="body",
status=NodeStatus.EXECUTED,
normalized_signals=[
NormalizedSignal(
question_type="relevanz",
raw_value="decrease",
normalized_value="decrease",
status=SignalStatus.VALID
)
]
)
}
}
result, error = evaluate_logic_expression(expression, context)
assert result is True
assert error is None
def test_evaluate_in_true():
"""Test: IN operator - value in list"""
expression = LogicExpression(
operator=LogicOperator.IN,
ref="body.prioritaet",
value=["hoch", "mittel"]
)
context = {
"node_results": {
"body": NodeExecutionState(
node_id="body",
status=NodeStatus.EXECUTED,
normalized_signals=[
NormalizedSignal(
question_type="prioritaet",
raw_value="hoch",
normalized_value="hoch",
status=SignalStatus.VALID
)
]
)
}
}
result, error = evaluate_logic_expression(expression, context)
assert result is True
assert error is None
def test_evaluate_in_false():
"""Test: IN operator - value not in list"""
expression = LogicExpression(
operator=LogicOperator.IN,
ref="body.prioritaet",
value=["hoch", "mittel"]
)
context = {
"node_results": {
"body": NodeExecutionState(
node_id="body",
status=NodeStatus.EXECUTED,
normalized_signals=[
NormalizedSignal(
question_type="prioritaet",
raw_value="niedrig",
normalized_value="niedrig",
status=SignalStatus.VALID
)
]
)
}
}
result, error = evaluate_logic_expression(expression, context)
assert result is False
assert error is None
def test_evaluate_gt():
"""Test: GT operator - greater than"""
expression = LogicExpression(
operator=LogicOperator.GT,
ref="body.score",
value=50
)
context = {
"node_results": {
"body": NodeExecutionState(
node_id="body",
status=NodeStatus.EXECUTED,
normalized_signals=[
NormalizedSignal(
question_type="score",
raw_value="75",
normalized_value="75",
status=SignalStatus.VALID
)
]
)
}
}
result, error = evaluate_logic_expression(expression, context)
assert result is True
assert error is None
def test_evaluate_lt():
"""Test: LT operator - less than"""
expression = LogicExpression(
operator=LogicOperator.LT,
ref="body.score",
value=50
)
context = {
"node_results": {
"body": NodeExecutionState(
node_id="body",
status=NodeStatus.EXECUTED,
normalized_signals=[
NormalizedSignal(
question_type="score",
raw_value="25",
normalized_value="25",
status=SignalStatus.VALID
)
]
)
}
}
result, error = evaluate_logic_expression(expression, context)
assert result is True
assert error is None
def test_evaluate_contains_string():
"""Test: CONTAINS operator - string contains substring"""
expression = LogicExpression(
operator=LogicOperator.CONTAINS,
ref="body.kategorie",
value="Gewicht"
)
context = {
"node_results": {
"body": NodeExecutionState(
node_id="body",
status=NodeStatus.EXECUTED,
normalized_signals=[
NormalizedSignal(
question_type="kategorie",
raw_value="Gewichtsverlust positiv",
normalized_value="Gewichtsverlust positiv",
status=SignalStatus.VALID
)
]
)
}
}
result, error = evaluate_logic_expression(expression, context)
assert result is True
assert error is None
# ── Logical Operator Tests ──────────────────────────────────────────────────
def test_evaluate_and_both_true():
"""Test: AND operator - both operands true"""
expression = LogicExpression(
operator=LogicOperator.AND,
operands=[
LogicExpression(
operator=LogicOperator.EQ,
ref="body.relevanz",
value="decrease"
),
LogicExpression(
operator=LogicOperator.EQ,
ref="activity.intensitaet",
value="hoch"
)
]
)
context = {
"node_results": {
"body": NodeExecutionState(
node_id="body",
status=NodeStatus.EXECUTED,
normalized_signals=[
NormalizedSignal(
question_type="relevanz",
raw_value="decrease",
normalized_value="decrease",
status=SignalStatus.VALID
)
]
),
"activity": NodeExecutionState(
node_id="activity",
status=NodeStatus.EXECUTED,
normalized_signals=[
NormalizedSignal(
question_type="intensitaet",
raw_value="hoch",
normalized_value="hoch",
status=SignalStatus.VALID
)
]
)
}
}
result, error = evaluate_logic_expression(expression, context)
assert result is True
assert error is None
def test_evaluate_and_one_false():
"""Test: AND operator - one operand false"""
expression = LogicExpression(
operator=LogicOperator.AND,
operands=[
LogicExpression(
operator=LogicOperator.EQ,
ref="body.relevanz",
value="decrease"
),
LogicExpression(
operator=LogicOperator.EQ,
ref="activity.intensitaet",
value="niedrig"
)
]
)
context = {
"node_results": {
"body": NodeExecutionState(
node_id="body",
status=NodeStatus.EXECUTED,
normalized_signals=[
NormalizedSignal(
question_type="relevanz",
raw_value="decrease",
normalized_value="decrease",
status=SignalStatus.VALID
)
]
),
"activity": NodeExecutionState(
node_id="activity",
status=NodeStatus.EXECUTED,
normalized_signals=[
NormalizedSignal(
question_type="intensitaet",
raw_value="hoch",
normalized_value="hoch",
status=SignalStatus.VALID
)
]
)
}
}
result, error = evaluate_logic_expression(expression, context)
assert result is False
assert error is None
def test_evaluate_or_one_true():
"""Test: OR operator - one operand true"""
expression = LogicExpression(
operator=LogicOperator.OR,
operands=[
LogicExpression(
operator=LogicOperator.EQ,
ref="body.relevanz",
value="decrease"
),
LogicExpression(
operator=LogicOperator.EQ,
ref="activity.intensitaet",
value="niedrig"
)
]
)
context = {
"node_results": {
"body": NodeExecutionState(
node_id="body",
status=NodeStatus.EXECUTED,
normalized_signals=[
NormalizedSignal(
question_type="relevanz",
raw_value="decrease",
normalized_value="decrease",
status=SignalStatus.VALID
)
]
),
"activity": NodeExecutionState(
node_id="activity",
status=NodeStatus.EXECUTED,
normalized_signals=[
NormalizedSignal(
question_type="intensitaet",
raw_value="hoch",
normalized_value="hoch",
status=SignalStatus.VALID
)
]
)
}
}
result, error = evaluate_logic_expression(expression, context)
assert result is True
assert error is None
def test_evaluate_or_both_false():
"""Test: OR operator - both operands false"""
expression = LogicExpression(
operator=LogicOperator.OR,
operands=[
LogicExpression(
operator=LogicOperator.EQ,
ref="body.relevanz",
value="increase"
),
LogicExpression(
operator=LogicOperator.EQ,
ref="activity.intensitaet",
value="niedrig"
)
]
)
context = {
"node_results": {
"body": NodeExecutionState(
node_id="body",
status=NodeStatus.EXECUTED,
normalized_signals=[
NormalizedSignal(
question_type="relevanz",
raw_value="decrease",
normalized_value="decrease",
status=SignalStatus.VALID
)
]
),
"activity": NodeExecutionState(
node_id="activity",
status=NodeStatus.EXECUTED,
normalized_signals=[
NormalizedSignal(
question_type="intensitaet",
raw_value="hoch",
normalized_value="hoch",
status=SignalStatus.VALID
)
]
)
}
}
result, error = evaluate_logic_expression(expression, context)
assert result is False
assert error is None
def test_evaluate_not():
"""Test: NOT operator - negation"""
expression = LogicExpression(
operator=LogicOperator.NOT,
operands=[
LogicExpression(
operator=LogicOperator.EQ,
ref="body.relevanz",
value="increase"
)
]
)
context = {
"node_results": {
"body": NodeExecutionState(
node_id="body",
status=NodeStatus.EXECUTED,
normalized_signals=[
NormalizedSignal(
question_type="relevanz",
raw_value="decrease",
normalized_value="decrease",
status=SignalStatus.VALID
)
]
)
}
}
result, error = evaluate_logic_expression(expression, context)
assert result is True # NOT (decrease == increase) = NOT False = True
assert error is None
# ── Nested Expressions Tests ──────────────────────────────────────────────────
def test_evaluate_nested_and_or():
"""Test: Nested expression - (A AND B) OR C"""
expression = LogicExpression(
operator=LogicOperator.OR,
operands=[
LogicExpression(
operator=LogicOperator.AND,
operands=[
LogicExpression(
operator=LogicOperator.EQ,
ref="body.relevanz",
value="decrease"
),
LogicExpression(
operator=LogicOperator.EQ,
ref="activity.intensitaet",
value="niedrig"
)
]
),
LogicExpression(
operator=LogicOperator.EQ,
ref="nutrition.defizit",
value="hoch"
)
]
)
context = {
"node_results": {
"body": NodeExecutionState(
node_id="body",
status=NodeStatus.EXECUTED,
normalized_signals=[
NormalizedSignal(
question_type="relevanz",
raw_value="decrease",
normalized_value="decrease",
status=SignalStatus.VALID
)
]
),
"activity": NodeExecutionState(
node_id="activity",
status=NodeStatus.EXECUTED,
normalized_signals=[
NormalizedSignal(
question_type="intensitaet",
raw_value="hoch", # FALSE für AND-Teil
normalized_value="hoch",
status=SignalStatus.VALID
)
]
),
"nutrition": NodeExecutionState(
node_id="nutrition",
status=NodeStatus.EXECUTED,
normalized_signals=[
NormalizedSignal(
question_type="defizit",
raw_value="hoch", # TRUE für OR-Teil
normalized_value="hoch",
status=SignalStatus.VALID
)
]
)
}
}
result, error = evaluate_logic_expression(expression, context)
# (decrease AND niedrig) OR hoch = (True AND False) OR True = False OR True = True
assert result is True
assert error is None
# ── Error Handling Tests ──────────────────────────────────────────────────
def test_evaluate_missing_node():
"""Test: Error handling - node not found"""
expression = LogicExpression(
operator=LogicOperator.EQ,
ref="missing_node.relevanz",
value="decrease"
)
context = {
"node_results": {}
}
result, error = evaluate_logic_expression(expression, context)
assert result is False
assert error is not None
assert "not found" in error.lower()
def test_evaluate_missing_signal():
"""Test: Error handling - signal not found in node"""
expression = LogicExpression(
operator=LogicOperator.EQ,
ref="body.missing_signal",
value="decrease"
)
context = {
"node_results": {
"body": NodeExecutionState(
node_id="body",
status=NodeStatus.EXECUTED,
normalized_signals=[
NormalizedSignal(
question_type="relevanz",
raw_value="decrease",
normalized_value="decrease",
status=SignalStatus.VALID
)
]
)
}
}
result, error = evaluate_logic_expression(expression, context)
assert result is False
assert error is not None
assert "not found" in error.lower()
def test_evaluate_unclear_signal():
"""Test: Error handling - signal has UNCLEAR status"""
expression = LogicExpression(
operator=LogicOperator.EQ,
ref="body.relevanz",
value="decrease"
)
context = {
"node_results": {
"body": NodeExecutionState(
node_id="body",
status=NodeStatus.EXECUTED,
normalized_signals=[
NormalizedSignal(
question_type="relevanz",
raw_value="maybe",
normalized_value="unklar",
status=SignalStatus.UNCLEAR
)
]
)
}
}
result, error = evaluate_logic_expression(expression, context)
assert result is False
assert error is not None
assert "unclear" in error.lower() or "status" in error.lower()
def test_evaluate_invalid_signal():
"""Test: Error handling - signal has INVALID status"""
expression = LogicExpression(
operator=LogicOperator.EQ,
ref="body.relevanz",
value="decrease"
)
context = {
"node_results": {
"body": NodeExecutionState(
node_id="body",
status=NodeStatus.EXECUTED,
normalized_signals=[
NormalizedSignal(
question_type="relevanz",
raw_value="invalid_value",
normalized_value=None,
status=SignalStatus.INVALID
)
]
)
}
}
result, error = evaluate_logic_expression(expression, context)
assert result is False
assert error is not None
assert "invalid" in error.lower() or "status" in error.lower()
def test_compare_values_gt_non_numeric():
"""Test: Error handling - GT with non-numeric values"""
result, error = compare_values(LogicOperator.GT, "text", 50)
assert result is False
assert error is not None
assert "cannot compare" in error.lower()
def test_compare_values_in_non_list():
"""Test: Error handling - IN with non-list right value"""
result, error = compare_values(LogicOperator.IN, "value", "not_a_list")
assert result is False
assert error is not None
assert "requires list" in error.lower()
if __name__ == "__main__":
pytest.main([__file__, "-v"])

View File

@ -0,0 +1,459 @@
"""
Integration Tests für Workflow Branching (Phase 3)
Testet conditional execution mit Logic Nodes.
Run with: PYTHONPATH=./backend pytest tests/backend/test_phase3_workflow_branching.py -v
"""
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from workflow_executor import execute_workflow
from workflow_models import (
WorkflowGraph, WorkflowNode, WorkflowEdge,
LogicExpression, LogicOperator, Condition, FallbackConfig, FallbackStrategy,
QuestionAugmentation, NodeStatus
)
# ── Helper Functions ────────────────────────────────────────────────────────
def create_mock_db():
"""Creates mock DB connection with cursor"""
conn = MagicMock()
cur = MagicMock()
cur.fetchone = MagicMock()
cur.fetchall = MagicMock(return_value=[])
# Mock get_cursor()
def mock_get_cursor(c):
return cur
# Context manager support
conn.__enter__ = MagicMock(return_value=conn)
conn.__exit__ = MagicMock(return_value=None)
return conn, cur, mock_get_cursor
@pytest.mark.asyncio
async def test_simple_if_else_branching():
"""Test: Simple if/else branching - then path taken"""
# Workflow: start → analysis → logic → then_path / else_path → end
workflow_graph = {
"nodes": [
{"id": "start", "type": "start"},
{"id": "analysis", "type": "analysis", "prompt_slug": "test_prompt",
"question_augmentations": [
{"id": "q1", "type": "relevanz", "question": "Relevant?", "answer_spectrum": ["ja", "nein"]}
]},
{"id": "logic", "type": "logic",
"condition": {
"expression": {
"operator": "eq",
"ref": "analysis.relevanz",
"value": "ja"
}
}},
{"id": "then_path", "type": "analysis", "prompt_slug": "then_prompt"},
{"id": "else_path", "type": "analysis", "prompt_slug": "else_prompt"},
{"id": "end", "type": "end"}
],
"edges": [
{"id": "e1", "from": "start", "to": "analysis"},
{"id": "e2", "from": "analysis", "to": "logic"},
{"id": "e3", "from": "logic", "to": "then_path", "label": "then"},
{"id": "e4", "from": "logic", "to": "else_path", "label": "else"},
{"id": "e5", "from": "then_path", "to": "end"},
{"id": "e6", "from": "else_path", "to": "end"}
]
}
# Mock DB
conn, cur, mock_get_cursor = create_mock_db()
cur.fetchone.side_effect = [
{"graph": workflow_graph}, # Workflow definition
{"template": "Test prompt"} # Prompt template
]
cur.fetchall.return_value = [
{"question_type": "relevanz", "answer_spectrum": ["ja", "nein"], "normalization_rules": None}
]
# Mock LLM - returns "ja" signal
async def mock_llm(prompt, model):
return """## Analyse
Test analysis
## Entscheidungsfragen
- Relevanz: ja
"""
with patch('workflow_executor.get_db', return_value=conn):
with patch('workflow_executor.get_cursor', side_effect=mock_get_cursor):
with patch('placeholder_resolver.resolve_placeholders', return_value="Test prompt"):
result = await execute_workflow(
workflow_id="test-workflow",
profile_id="test-profile",
variables={},
openrouter_call_func=mock_llm
)
# Assertions
assert result.status == "completed"
assert len(result.node_states) == 5 # start, analysis, logic, then_path, end (else_path skipped)
# Check which nodes were executed
executed_nodes = [s.node_id for s in result.node_states if s.status == NodeStatus.EXECUTED]
skipped_nodes = [s.node_id for s in result.node_states if s.status == NodeStatus.SKIPPED]
assert "then_path" in executed_nodes
assert "else_path" in skipped_nodes
# Check aggregation
assert result.aggregated_result["executed_nodes"] == 4 # start, analysis, logic, then_path (end is no-op)
assert result.aggregated_result["skipped_nodes"] == 1 # else_path
@pytest.mark.asyncio
async def test_else_path_taken():
"""Test: Simple if/else branching - else path taken"""
workflow_graph = {
"nodes": [
{"id": "start", "type": "start"},
{"id": "analysis", "type": "analysis", "prompt_slug": "test_prompt",
"question_augmentations": [
{"id": "q1", "type": "relevanz", "question": "Relevant?", "answer_spectrum": ["ja", "nein"]}
]},
{"id": "logic", "type": "logic",
"condition": {
"expression": {
"operator": "eq",
"ref": "analysis.relevanz",
"value": "ja"
}
}},
{"id": "then_path", "type": "analysis", "prompt_slug": "then_prompt"},
{"id": "else_path", "type": "analysis", "prompt_slug": "else_prompt"},
{"id": "end", "type": "end"}
],
"edges": [
{"id": "e1", "from": "start", "to": "analysis"},
{"id": "e2", "from": "analysis", "to": "logic"},
{"id": "e3", "from": "logic", "to": "then_path", "label": "then"},
{"id": "e4", "from": "logic", "to": "else_path", "label": "else"},
{"id": "e5", "from": "then_path", "to": "end"},
{"id": "e6", "from": "else_path", "to": "end"}
]
}
conn, cur = create_mock_db()
cur.fetchone.side_effect = [
{"graph": workflow_graph},
{"template": "Test prompt"}
]
cur.fetchall.return_value = [
{"question_type": "relevanz", "answer_spectrum": ["ja", "nein"], "normalization_rules": None}
]
# Mock LLM - returns "nein" signal (condition false)
async def mock_llm(prompt, model):
return """## Analyse
Test analysis
## Entscheidungsfragen
- Relevanz: nein
"""
with patch('workflow_executor.get_db', return_value=conn):
with patch('placeholder_resolver.resolve_placeholders', return_value="Test prompt"):
result = await execute_workflow(
workflow_id="test-workflow",
profile_id="test-profile",
variables={},
openrouter_call_func=mock_llm
)
# Assertions
executed_nodes = [s.node_id for s in result.node_states if s.status == NodeStatus.EXECUTED]
skipped_nodes = [s.node_id for s in result.node_states if s.status == NodeStatus.SKIPPED]
assert "else_path" in executed_nodes
assert "then_path" in skipped_nodes
@pytest.mark.asyncio
async def test_and_condition():
"""Test: AND condition - both must be true"""
workflow_graph = {
"nodes": [
{"id": "start", "type": "start"},
{"id": "analysis1", "type": "analysis", "prompt_slug": "test_prompt",
"question_augmentations": [
{"id": "q1", "type": "relevanz", "question": "Relevant?", "answer_spectrum": ["ja", "nein"]}
]},
{"id": "analysis2", "type": "analysis", "prompt_slug": "test_prompt",
"question_augmentations": [
{"id": "q2", "type": "prioritaet", "question": "Priority?", "answer_spectrum": ["hoch", "niedrig"]}
]},
{"id": "logic", "type": "logic",
"condition": {
"expression": {
"operator": "and",
"operands": [
{"operator": "eq", "ref": "analysis1.relevanz", "value": "ja"},
{"operator": "eq", "ref": "analysis2.prioritaet", "value": "hoch"}
]
}
}},
{"id": "then_path", "type": "analysis", "prompt_slug": "then_prompt"},
{"id": "else_path", "type": "analysis", "prompt_slug": "else_prompt"},
{"id": "end", "type": "end"}
],
"edges": [
{"id": "e1", "from": "start", "to": "analysis1"},
{"id": "e2", "from": "analysis1", "to": "analysis2"},
{"id": "e3", "from": "analysis2", "to": "logic"},
{"id": "e4", "from": "logic", "to": "then_path", "label": "then"},
{"id": "e5", "from": "logic", "to": "else_path", "label": "else"},
{"id": "e6", "from": "then_path", "to": "end"},
{"id": "e7", "from": "else_path", "to": "end"}
]
}
conn, cur = create_mock_db()
cur.fetchone.side_effect = [
{"graph": workflow_graph},
{"template": "Test prompt"},
{"template": "Test prompt"}
]
cur.fetchall.return_value = [
{"question_type": "relevanz", "answer_spectrum": ["ja", "nein"], "normalization_rules": None},
{"question_type": "prioritaet", "answer_spectrum": ["hoch", "niedrig"], "normalization_rules": None}
]
# Mock LLM - returns ja AND hoch (both true)
call_count = 0
async def mock_llm(prompt, model):
nonlocal call_count
call_count += 1
if call_count == 1:
return """## Analyse
Analysis 1
## Entscheidungsfragen
- Relevanz: ja
"""
else:
return """## Analyse
Analysis 2
## Entscheidungsfragen
- Prioritaet: hoch
"""
with patch('workflow_executor.get_db', return_value=conn):
with patch('placeholder_resolver.resolve_placeholders', return_value="Test prompt"):
result = await execute_workflow(
workflow_id="test-workflow",
profile_id="test-profile",
variables={},
openrouter_call_func=mock_llm
)
# Assertions: Both true → then path taken
executed_nodes = [s.node_id for s in result.node_states if s.status == NodeStatus.EXECUTED]
skipped_nodes = [s.node_id for s in result.node_states if s.status == NodeStatus.SKIPPED]
assert "then_path" in executed_nodes
assert "else_path" in skipped_nodes
@pytest.mark.asyncio
async def test_fallback_conservative_skip():
"""Test: Fallback strategy CONSERVATIVE_SKIP"""
workflow_graph = {
"nodes": [
{"id": "start", "type": "start"},
{"id": "analysis", "type": "analysis", "prompt_slug": "test_prompt",
"question_augmentations": [
{"id": "q1", "type": "relevanz", "question": "Relevant?", "answer_spectrum": ["ja", "nein"]}
]},
{"id": "logic", "type": "logic",
"condition": {
"expression": {
"operator": "eq",
"ref": "analysis.relevanz",
"value": "ja"
}
},
"fallback": {
"strategy": "conservative_skip"
}},
{"id": "then_path", "type": "analysis", "prompt_slug": "then_prompt"},
{"id": "else_path", "type": "analysis", "prompt_slug": "else_prompt"},
{"id": "end", "type": "end"}
],
"edges": [
{"id": "e1", "from": "start", "to": "analysis"},
{"id": "e2", "from": "analysis", "to": "logic"},
{"id": "e3", "from": "logic", "to": "then_path", "label": "then"},
{"id": "e4", "from": "logic", "to": "else_path", "label": "else"},
{"id": "e5", "from": "then_path", "to": "end"},
{"id": "e6", "from": "else_path", "to": "end"}
]
}
conn, cur = create_mock_db()
cur.fetchone.side_effect = [
{"graph": workflow_graph},
{"template": "Test prompt"}
]
cur.fetchall.return_value = [
{"question_type": "relevanz", "answer_spectrum": ["ja", "nein"], "normalization_rules": None}
]
# Mock LLM - returns UNCLEAR signal (triggers fallback)
async def mock_llm(prompt, model):
return """## Analyse
Test analysis
## Entscheidungsfragen
- Relevanz: unklar
"""
with patch('workflow_executor.get_db', return_value=conn):
with patch('placeholder_resolver.resolve_placeholders', return_value="Test prompt"):
result = await execute_workflow(
workflow_id="test-workflow",
profile_id="test-profile",
variables={},
openrouter_call_func=mock_llm
)
# Assertions: CONSERVATIVE_SKIP → both paths skipped
skipped_nodes = [s.node_id for s in result.node_states if s.status == NodeStatus.SKIPPED]
assert "then_path" in skipped_nodes
assert "else_path" in skipped_nodes
assert result.aggregated_result["skipped_nodes"] == 2
@pytest.mark.asyncio
async def test_fallback_default_path():
"""Test: Fallback strategy DEFAULT_PATH"""
workflow_graph = {
"nodes": [
{"id": "start", "type": "start"},
{"id": "analysis", "type": "analysis", "prompt_slug": "test_prompt",
"question_augmentations": [
{"id": "q1", "type": "relevanz", "question": "Relevant?", "answer_spectrum": ["ja", "nein"]}
]},
{"id": "logic", "type": "logic",
"condition": {
"expression": {
"operator": "eq",
"ref": "analysis.relevanz",
"value": "ja"
}
},
"fallback": {
"strategy": "default_path"
}},
{"id": "then_path", "type": "analysis", "prompt_slug": "then_prompt"},
{"id": "else_path", "type": "analysis", "prompt_slug": "else_prompt"},
{"id": "end", "type": "end"}
],
"edges": [
{"id": "e1", "from": "start", "to": "analysis"},
{"id": "e2", "from": "analysis", "to": "logic"},
{"id": "e3", "from": "logic", "to": "then_path", "label": "then"},
{"id": "e4", "from": "logic", "to": "else_path", "label": "else"},
{"id": "e5", "from": "then_path", "to": "end"},
{"id": "e6", "from": "else_path", "to": "end"}
]
}
conn, cur = create_mock_db()
cur.fetchone.side_effect = [
{"graph": workflow_graph},
{"template": "Test prompt"}
]
cur.fetchall.return_value = [
{"question_type": "relevanz", "answer_spectrum": ["ja", "nein"], "normalization_rules": None}
]
# Mock LLM - returns INVALID signal (triggers fallback)
async def mock_llm(prompt, model):
return """## Analyse
Test analysis
## Entscheidungsfragen
- Relevanz: totally_invalid_value
"""
with patch('workflow_executor.get_db', return_value=conn):
with patch('placeholder_resolver.resolve_placeholders', return_value="Test prompt"):
result = await execute_workflow(
workflow_id="test-workflow",
profile_id="test-profile",
variables={},
openrouter_call_func=mock_llm
)
# Assertions: DEFAULT_PATH → else path taken
executed_nodes = [s.node_id for s in result.node_states if s.status == NodeStatus.EXECUTED]
skipped_nodes = [s.node_id for s in result.node_states if s.status == NodeStatus.SKIPPED]
assert "else_path" in executed_nodes
assert "then_path" in skipped_nodes
@pytest.mark.asyncio
async def test_linear_workflow_still_works():
"""Test: Linear workflow (no logic nodes) still works (Phase 2 compatibility)"""
workflow_graph = {
"nodes": [
{"id": "start", "type": "start"},
{"id": "analysis", "type": "analysis", "prompt_slug": "test_prompt"},
{"id": "end", "type": "end"}
],
"edges": [
{"id": "e1", "from": "start", "to": "analysis"},
{"id": "e2", "from": "analysis", "to": "end"}
]
}
conn, cur = create_mock_db()
cur.fetchone.side_effect = [
{"graph": workflow_graph},
{"template": "Test prompt"}
]
cur.fetchall.return_value = []
async def mock_llm(prompt, model):
return "## Analyse\nTest analysis"
with patch('workflow_executor.get_db', return_value=conn):
with patch('placeholder_resolver.resolve_placeholders', return_value="Test prompt"):
result = await execute_workflow(
workflow_id="test-workflow",
profile_id="test-profile",
variables={},
openrouter_call_func=mock_llm
)
# Assertions: All nodes executed
assert result.status == "completed"
assert len(result.node_states) == 3
assert all(s.status == NodeStatus.EXECUTED for s in result.node_states)
assert result.aggregated_result["skipped_nodes"] == 0
if __name__ == "__main__":
pytest.main([__file__, "-v"])

View File

@ -0,0 +1,511 @@
"""
Phase 4 Tests: Join Nodes and Path Consolidation
Tests für join_evaluator.py und execute_join_node() Funktionalität.
Test-Kategorien:
- Join Strategy Tests (wait_all, wait_any, best_effort)
- Skip Handling Tests (ignore_skipped, use_placeholder)
- Result Consolidation Tests (merge analysis_cores, combine signals)
- Partial Execution Tests (failed paths, skipped paths, mixed status)
"""
import pytest
from typing import Dict, Any
from workflow_models import (
WorkflowNode,
WorkflowGraph,
WorkflowEdge,
JoinStrategy,
SkipHandling,
NodeStatus,
NormalizedSignal,
SignalStatus,
NodeExecutionState
)
from join_evaluator import (
evaluate_join_node,
_collect_incoming_paths,
_check_join_strategy,
_merge_analysis_cores,
_combine_signals,
PathStatus
)
# ── Fixtures ──────────────────────────────────────────────────────────────────
@pytest.fixture
def simple_graph():
"""
Einfacher Graph mit 2 Pfaden + Join:
start path_a join end
path_b
"""
nodes = [
WorkflowNode(id="start", type="start"),
WorkflowNode(id="path_a", type="analysis", prompt_slug="prompt_a"),
WorkflowNode(id="path_b", type="analysis", prompt_slug="prompt_b"),
WorkflowNode(id="join", type="join", join_strategy=JoinStrategy.WAIT_ALL),
WorkflowNode(id="end", type="end")
]
edges = [
WorkflowEdge(id="e1", from_node="start", to_node="path_a"),
WorkflowEdge(id="e2", from_node="start", to_node="path_b"),
WorkflowEdge(id="e3", from_node="path_a", to_node="join"),
WorkflowEdge(id="e4", from_node="path_b", to_node="join"),
WorkflowEdge(id="e5", from_node="join", to_node="end")
]
return WorkflowGraph(nodes=nodes, edges=edges)
@pytest.fixture
def context_all_executed():
"""Context mit beiden Pfaden EXECUTED"""
return {
"node_results": {
"path_a": NodeExecutionState(
node_id="path_a",
status=NodeStatus.EXECUTED,
analysis_core="Analysis from path A",
normalized_signals=[
NormalizedSignal(
question_type="relevanz",
raw_value="hoch",
normalized_value="hoch",
status=SignalStatus.VALID
)
]
),
"path_b": NodeExecutionState(
node_id="path_b",
status=NodeStatus.EXECUTED,
analysis_core="Analysis from path B",
normalized_signals=[
NormalizedSignal(
question_type="prioritaet",
raw_value="mittel",
normalized_value="mittel",
status=SignalStatus.VALID
)
]
)
}
}
@pytest.fixture
def context_one_skipped():
"""Context mit einem Pfad SKIPPED"""
return {
"node_results": {
"path_a": NodeExecutionState(
node_id="path_a",
status=NodeStatus.EXECUTED,
analysis_core="Analysis from path A"
),
"path_b": NodeExecutionState(
node_id="path_b",
status=NodeStatus.SKIPPED,
analysis_core=None
)
}
}
@pytest.fixture
def context_one_failed():
"""Context mit einem Pfad FAILED"""
return {
"node_results": {
"path_a": NodeExecutionState(
node_id="path_a",
status=NodeStatus.EXECUTED,
analysis_core="Analysis from path A"
),
"path_b": NodeExecutionState(
node_id="path_b",
status=NodeStatus.FAILED,
analysis_core=None,
error="LLM call failed"
)
}
}
@pytest.fixture
def context_no_paths():
"""Context ohne ausgeführte Pfade"""
return {
"node_results": {
"path_a": NodeExecutionState(
node_id="path_a",
status=NodeStatus.SKIPPED,
analysis_core=None
),
"path_b": NodeExecutionState(
node_id="path_b",
status=NodeStatus.SKIPPED,
analysis_core=None
)
}
}
# ── Join Strategy Tests ───────────────────────────────────────────────────────
def test_wait_all_success(simple_graph, context_all_executed):
"""wait_all: Alle Pfade verfügbar → ready=True, EXECUTED"""
join_node = next(n for n in simple_graph.nodes if n.id == "join")
join_node.join_strategy = JoinStrategy.WAIT_ALL
result = evaluate_join_node(join_node, simple_graph, context_all_executed)
assert result.ready is True
assert result.error is None
assert len(result.consolidated_analysis_core) == 2
assert "path_a" in result.consolidated_analysis_core
assert "path_b" in result.consolidated_analysis_core
assert result.metadata["executed_paths"] == 2
def test_wait_all_missing_path(simple_graph, context_one_skipped):
"""wait_all: Ein Pfad fehlt → ready=False, FAILED"""
join_node = next(n for n in simple_graph.nodes if n.id == "join")
join_node.join_strategy = JoinStrategy.WAIT_ALL
result = evaluate_join_node(join_node, simple_graph, context_one_skipped)
assert result.ready is False
assert result.error is not None
assert "wait_all strategy failed" in result.error
assert "path_b" in result.error
assert result.metadata["executed_paths"] == 1
assert result.metadata["skipped_paths"] == 1
def test_wait_any_one_path(simple_graph, context_one_skipped):
"""wait_any: Mindestens ein Pfad → ready=True, EXECUTED"""
join_node = next(n for n in simple_graph.nodes if n.id == "join")
join_node.join_strategy = JoinStrategy.WAIT_ANY
result = evaluate_join_node(join_node, simple_graph, context_one_skipped)
assert result.ready is True
assert result.error is None
assert len(result.consolidated_analysis_core) == 1
assert "path_a" in result.consolidated_analysis_core
assert result.metadata["executed_paths"] == 1
def test_wait_any_no_paths(simple_graph, context_no_paths):
"""wait_any: Keine Pfade → ready=False, FAILED"""
join_node = next(n for n in simple_graph.nodes if n.id == "join")
join_node.join_strategy = JoinStrategy.WAIT_ANY
result = evaluate_join_node(join_node, simple_graph, context_no_paths)
assert result.ready is False
assert result.error is not None
assert "wait_any strategy failed" in result.error
assert result.metadata["executed_paths"] == 0
def test_best_effort_partial(simple_graph, context_one_skipped):
"""best_effort: Einige Pfade fehlen → ready=True, EXECUTED"""
join_node = next(n for n in simple_graph.nodes if n.id == "join")
join_node.join_strategy = JoinStrategy.BEST_EFFORT
result = evaluate_join_node(join_node, simple_graph, context_one_skipped)
assert result.ready is True
assert result.error is None
assert len(result.consolidated_analysis_core) == 1
assert result.metadata["executed_paths"] == 1
assert result.metadata["skipped_paths"] == 1
def test_best_effort_no_paths(simple_graph, context_no_paths):
"""best_effort: Keine Pfade → ready=True, EXECUTED (leere Konsolidierung)"""
join_node = next(n for n in simple_graph.nodes if n.id == "join")
join_node.join_strategy = JoinStrategy.BEST_EFFORT
result = evaluate_join_node(join_node, simple_graph, context_no_paths)
assert result.ready is True
assert result.error is None
assert len(result.consolidated_analysis_core) == 0 # Leer
assert result.metadata["executed_paths"] == 0
assert "note" in result.metadata # Hinweis auf leere Konsolidierung
# ── Skip Handling Tests ───────────────────────────────────────────────────────
def test_ignore_skipped(simple_graph, context_one_skipped):
"""IGNORE_SKIPPED: Übersprungene Pfade nicht in Ergebnis"""
join_node = next(n for n in simple_graph.nodes if n.id == "join")
join_node.join_strategy = JoinStrategy.BEST_EFFORT
join_node.skip_handling = SkipHandling.IGNORE_SKIPPED
result = evaluate_join_node(join_node, simple_graph, context_one_skipped)
assert result.ready is True
assert len(result.consolidated_analysis_core) == 1
assert "path_a" in result.consolidated_analysis_core
assert "path_b" not in result.consolidated_analysis_core
def test_use_placeholder(simple_graph, context_one_skipped):
"""USE_PLACEHOLDER: Platzhalter für übersprungene Pfade"""
join_node = next(n for n in simple_graph.nodes if n.id == "join")
join_node.join_strategy = JoinStrategy.BEST_EFFORT
join_node.skip_handling = SkipHandling.USE_PLACEHOLDER
result = evaluate_join_node(join_node, simple_graph, context_one_skipped)
assert result.ready is True
assert len(result.consolidated_analysis_core) == 2
assert "path_a" in result.consolidated_analysis_core
assert "path_b" in result.consolidated_analysis_core
assert "[Path skipped:" in result.consolidated_analysis_core["path_b"]
def test_failed_path_placeholder(simple_graph, context_one_failed):
"""FAILED Pfade bekommen Platzhalter (unabhängig von skip_handling)"""
join_node = next(n for n in simple_graph.nodes if n.id == "join")
join_node.join_strategy = JoinStrategy.BEST_EFFORT
result = evaluate_join_node(join_node, simple_graph, context_one_failed)
assert result.ready is True
assert len(result.consolidated_analysis_core) == 2
assert "path_a" in result.consolidated_analysis_core
assert "[Path failed:" in result.consolidated_analysis_core["path_b"]
# ── Result Consolidation Tests ────────────────────────────────────────────────
def test_merge_analysis_cores(simple_graph, context_all_executed):
"""Analyse-Kerne korrekt merged"""
join_node = next(n for n in simple_graph.nodes if n.id == "join")
result = evaluate_join_node(join_node, simple_graph, context_all_executed)
assert result.ready is True
assert len(result.consolidated_analysis_core) == 2
assert result.consolidated_analysis_core["path_a"] == "Analysis from path A"
assert result.consolidated_analysis_core["path_b"] == "Analysis from path B"
def test_combine_signals(simple_graph, context_all_executed):
"""Signale aller Pfade kombiniert (mit node_id Präfix)"""
join_node = next(n for n in simple_graph.nodes if n.id == "join")
result = evaluate_join_node(join_node, simple_graph, context_all_executed)
assert result.ready is True
assert len(result.consolidated_signals) == 2
# Signale sind mit node_id geprefixed
assert "path_a.relevanz" in result.consolidated_signals
assert "path_b.prioritaet" in result.consolidated_signals
# Signal-Werte korrekt übernommen
assert result.consolidated_signals["path_a.relevanz"].normalized_value == "hoch"
assert result.consolidated_signals["path_b.prioritaet"].normalized_value == "mittel"
def test_signal_name_collision():
"""Gleiche Signal-Namen in verschiedenen Pfaden (Präfix verhindert Kollision)"""
graph = WorkflowGraph(
nodes=[
WorkflowNode(id="path_a", type="analysis"),
WorkflowNode(id="path_b", type="analysis"),
WorkflowNode(id="join", type="join", join_strategy=JoinStrategy.WAIT_ALL)
],
edges=[
WorkflowEdge(id="e1", from_node="path_a", to_node="join"),
WorkflowEdge(id="e2", from_node="path_b", to_node="join")
]
)
context = {
"node_results": {
"path_a": NodeExecutionState(
node_id="path_a",
status=NodeStatus.EXECUTED,
analysis_core="A",
normalized_signals=[
NormalizedSignal(
question_type="relevanz",
raw_value="hoch",
normalized_value="hoch",
status=SignalStatus.VALID
)
]
),
"path_b": NodeExecutionState(
node_id="path_b",
status=NodeStatus.EXECUTED,
analysis_core="B",
normalized_signals=[
NormalizedSignal( # Gleicher Name!
question_type="relevanz",
raw_value="mittel",
normalized_value="mittel",
status=SignalStatus.VALID
)
]
)
}
}
join_node = graph.nodes[2]
result = evaluate_join_node(join_node, graph, context)
# Beide Signale vorhanden (durch Präfix unterscheidbar)
assert "path_a.relevanz" in result.consolidated_signals
assert "path_b.relevanz" in result.consolidated_signals
assert result.consolidated_signals["path_a.relevanz"].normalized_value == "hoch"
assert result.consolidated_signals["path_b.relevanz"].normalized_value == "mittel"
# ── Partial Execution Tests ───────────────────────────────────────────────────
def test_mixed_status_paths():
"""Kombination EXECUTED/SKIPPED/FAILED"""
graph = WorkflowGraph(
nodes=[
WorkflowNode(id="path_a", type="analysis"),
WorkflowNode(id="path_b", type="analysis"),
WorkflowNode(id="path_c", type="analysis"),
WorkflowNode(id="join", type="join", join_strategy=JoinStrategy.BEST_EFFORT)
],
edges=[
WorkflowEdge(id="e1", from_node="path_a", to_node="join"),
WorkflowEdge(id="e2", from_node="path_b", to_node="join"),
WorkflowEdge(id="e3", from_node="path_c", to_node="join")
]
)
context = {
"node_results": {
"path_a": NodeExecutionState(
node_id="path_a",
status=NodeStatus.EXECUTED,
analysis_core="Analysis A"
),
"path_b": NodeExecutionState(
node_id="path_b",
status=NodeStatus.SKIPPED,
analysis_core=None
),
"path_c": NodeExecutionState(
node_id="path_c",
status=NodeStatus.FAILED,
error="Error",
analysis_core=None
)
}
}
join_node = graph.nodes[3]
result = evaluate_join_node(join_node, graph, context)
assert result.ready is True
assert result.metadata["executed_paths"] == 1
assert result.metadata["skipped_paths"] == 1
assert result.metadata["failed_paths"] == 1
# Analysis core nur von path_a (executed)
assert "path_a" in result.consolidated_analysis_core
assert "path_c" in result.consolidated_analysis_core # Failed → Placeholder
assert "[Path failed:" in result.consolidated_analysis_core["path_c"]
# ── Helper Function Tests ─────────────────────────────────────────────────────
def test_collect_incoming_paths(simple_graph, context_all_executed):
"""_collect_incoming_paths sammelt alle Pfade"""
join_node = next(n for n in simple_graph.nodes if n.id == "join")
paths = _collect_incoming_paths(join_node, simple_graph, context_all_executed)
assert len(paths) == 2
assert paths[0].node_id in ["path_a", "path_b"]
assert paths[1].node_id in ["path_a", "path_b"]
assert all(p.status == NodeStatus.EXECUTED for p in paths)
def test_check_join_strategy_wait_all():
"""_check_join_strategy für wait_all"""
paths = [
PathStatus("path_a", NodeStatus.EXECUTED, "A", {}),
PathStatus("path_b", NodeStatus.EXECUTED, "B", {})
]
ready, error = _check_join_strategy(paths, JoinStrategy.WAIT_ALL)
assert ready is True
assert error is None
def test_check_join_strategy_wait_all_failed():
"""_check_join_strategy für wait_all mit fehlenden Pfaden"""
paths = [
PathStatus("path_a", NodeStatus.EXECUTED, "A", {}),
PathStatus("path_b", NodeStatus.SKIPPED, None, {})
]
ready, error = _check_join_strategy(paths, JoinStrategy.WAIT_ALL)
assert ready is False
assert error is not None
assert "wait_all strategy failed" in error
def test_merge_analysis_cores_helper():
"""_merge_analysis_cores merged Kerne korrekt"""
paths = [
PathStatus("path_a", NodeStatus.EXECUTED, "Analysis A", {}),
PathStatus("path_b", NodeStatus.EXECUTED, "Analysis B", {}),
PathStatus("path_c", NodeStatus.SKIPPED, None, {})
]
merged = _merge_analysis_cores(paths, SkipHandling.IGNORE_SKIPPED)
assert len(merged) == 2
assert merged["path_a"] == "Analysis A"
assert merged["path_b"] == "Analysis B"
assert "path_c" not in merged
def test_combine_signals_helper():
"""_combine_signals kombiniert Signale mit Präfix"""
signal_a = NormalizedSignal(
question_type="relevanz",
raw_value="hoch",
normalized_value="hoch",
status=SignalStatus.VALID
)
signal_b = NormalizedSignal(
question_type="prioritaet",
raw_value="mittel",
normalized_value="mittel",
status=SignalStatus.VALID
)
paths = [
PathStatus("path_a", NodeStatus.EXECUTED, "A", {"relevanz": signal_a}),
PathStatus("path_b", NodeStatus.EXECUTED, "B", {"prioritaet": signal_b})
]
combined = _combine_signals(paths)
assert len(combined) == 2
assert "path_a.relevanz" in combined
assert "path_b.prioritaet" in combined

View File

@ -0,0 +1,413 @@
"""
Unit Tests für Workflow Engine (Phase 0: Foundation)
Tests für:
- Graph-Parsing
- Topologische Sortierung
- DAG-Validierung (Zyklen-Erkennung)
- Erreichbarkeits-Prüfungen
Run with: pytest tests/backend/test_workflow_engine.py -v
"""
import pytest
import sys
import os
# Add backend to path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../../backend'))
from workflow_models import WorkflowGraph, WorkflowNode, WorkflowEdge, NodeType, Position
from workflow_engine import WorkflowEngine, parse_workflow_graph, validate_workflow_graph
from fastapi import HTTPException
# ── Fixtures ──────────────────────────────────────────────────────────────────
@pytest.fixture
def simple_valid_graph():
"""Einfacher gültiger Graph: START → ANALYSIS → END"""
return WorkflowGraph(
nodes=[
WorkflowNode(id="start", type=NodeType.START, position=Position(x=0, y=0)),
WorkflowNode(id="analysis1", type=NodeType.ANALYSIS, position=Position(x=100, y=0), prompt_slug="test_prompt"),
WorkflowNode(id="end", type=NodeType.END, position=Position(x=200, y=0))
],
edges=[
WorkflowEdge(id="e1", from_node="start", to_node="analysis1"),
WorkflowEdge(id="e2", from_node="analysis1", to_node="end")
]
)
@pytest.fixture
def parallel_graph():
"""Graph mit Parallelität: START → (A1 || A2) → JOIN → END"""
return WorkflowGraph(
nodes=[
WorkflowNode(id="start", type=NodeType.START, position=Position(x=0, y=50)),
WorkflowNode(id="analysis1", type=NodeType.ANALYSIS, position=Position(x=100, y=0), prompt_slug="prompt1"),
WorkflowNode(id="analysis2", type=NodeType.ANALYSIS, position=Position(x=100, y=100), prompt_slug="prompt2"),
WorkflowNode(id="join", type=NodeType.JOIN, position=Position(x=200, y=50)),
WorkflowNode(id="end", type=NodeType.END, position=Position(x=300, y=50))
],
edges=[
WorkflowEdge(id="e1", from_node="start", to_node="analysis1"),
WorkflowEdge(id="e2", from_node="start", to_node="analysis2"),
WorkflowEdge(id="e3", from_node="analysis1", to_node="join"),
WorkflowEdge(id="e4", from_node="analysis2", to_node="join"),
WorkflowEdge(id="e5", from_node="join", to_node="end")
]
)
@pytest.fixture
def branching_graph():
"""Graph mit Verzweigung: START → LOGIC → (A1 | A2) → END"""
return WorkflowGraph(
nodes=[
WorkflowNode(id="start", type=NodeType.START, position=Position(x=0, y=50)),
WorkflowNode(id="logic1", type=NodeType.LOGIC, position=Position(x=100, y=50)),
WorkflowNode(id="analysis1", type=NodeType.ANALYSIS, position=Position(x=200, y=0), prompt_slug="prompt1"),
WorkflowNode(id="analysis2", type=NodeType.ANALYSIS, position=Position(x=200, y=100), prompt_slug="prompt2"),
WorkflowNode(id="end", type=NodeType.END, position=Position(x=300, y=50))
],
edges=[
WorkflowEdge(id="e1", from_node="start", to_node="logic1"),
WorkflowEdge(id="e2", from_node="logic1", to_node="analysis1", label="then"),
WorkflowEdge(id="e3", from_node="logic1", to_node="analysis2", label="else"),
WorkflowEdge(id="e4", from_node="analysis1", to_node="end"),
WorkflowEdge(id="e5", from_node="analysis2", to_node="end")
]
)
# ── Test: Graph-Parsing ───────────────────────────────────────────────────────
def test_parse_workflow_graph_valid(simple_valid_graph):
"""Test: Gültiger Graph wird korrekt geparst"""
graph_dict = simple_valid_graph.model_dump()
parsed = parse_workflow_graph(graph_dict)
assert len(parsed.nodes) == 3
assert len(parsed.edges) == 2
assert parsed.nodes[0].type == NodeType.START
assert parsed.nodes[2].type == NodeType.END
def test_parse_workflow_graph_invalid_format():
"""Test: Ungültiges Format wirft ValidationError"""
invalid_graph = {"nodes": "not a list", "edges": []}
with pytest.raises(Exception): # Pydantic ValidationError
parse_workflow_graph(invalid_graph)
# ── Test: Graph-Validierung ──────────────────────────────────────────────────
def test_valid_graph_initialization(simple_valid_graph):
"""Test: Gültiger Graph kann initialisiert werden"""
engine = WorkflowEngine(simple_valid_graph)
assert len(engine.nodes_by_id) == 3
assert len(engine.edges_by_id) == 2
assert engine.topological_order == ["start", "analysis1", "end"]
def test_validate_graph_no_start_node():
"""Test: Graph ohne START-Knoten wird abgelehnt"""
graph = WorkflowGraph(
nodes=[
WorkflowNode(id="analysis1", type=NodeType.ANALYSIS, position=Position(x=0, y=0), prompt_slug="test"),
WorkflowNode(id="end", type=NodeType.END, position=Position(x=100, y=0))
],
edges=[WorkflowEdge(id="e1", from_node="analysis1", to_node="end")]
)
with pytest.raises(HTTPException) as exc_info:
WorkflowEngine(graph)
assert exc_info.value.status_code == 400
assert "Kein START-Knoten" in str(exc_info.value.detail)
def test_validate_graph_no_end_node():
"""Test: Graph ohne END-Knoten wird abgelehnt"""
graph = WorkflowGraph(
nodes=[
WorkflowNode(id="start", type=NodeType.START, position=Position(x=0, y=0)),
WorkflowNode(id="analysis1", type=NodeType.ANALYSIS, position=Position(x=100, y=0), prompt_slug="test")
],
edges=[WorkflowEdge(id="e1", from_node="start", to_node="analysis1")]
)
with pytest.raises(HTTPException) as exc_info:
WorkflowEngine(graph)
assert exc_info.value.status_code == 400
assert "Kein END-Knoten" in str(exc_info.value.detail)
def test_validate_graph_multiple_start_nodes():
"""Test: Graph mit mehreren START-Knoten wird abgelehnt"""
graph = WorkflowGraph(
nodes=[
WorkflowNode(id="start1", type=NodeType.START, position=Position(x=0, y=0)),
WorkflowNode(id="start2", type=NodeType.START, position=Position(x=0, y=100)),
WorkflowNode(id="end", type=NodeType.END, position=Position(x=100, y=0))
],
edges=[
WorkflowEdge(id="e1", from_node="start1", to_node="end"),
WorkflowEdge(id="e2", from_node="start2", to_node="end")
]
)
with pytest.raises(HTTPException) as exc_info:
WorkflowEngine(graph)
assert exc_info.value.status_code == 400
assert "Mehrere START-Knoten" in str(exc_info.value.detail)
def test_validate_graph_missing_node_reference():
"""Test: Edge referenziert nicht-existierenden Knoten"""
graph = WorkflowGraph(
nodes=[
WorkflowNode(id="start", type=NodeType.START, position=Position(x=0, y=0)),
WorkflowNode(id="end", type=NodeType.END, position=Position(x=100, y=0))
],
edges=[WorkflowEdge(id="e1", from_node="start", to_node="nonexistent")]
)
with pytest.raises(HTTPException) as exc_info:
WorkflowEngine(graph)
assert exc_info.value.status_code == 400
assert "existiert nicht" in str(exc_info.value.detail)
# ── Test: Zyklen-Erkennung ────────────────────────────────────────────────────
def test_detect_cycle_simple():
"""Test: Einfacher Zyklus wird erkannt (A → B → A)"""
graph = WorkflowGraph(
nodes=[
WorkflowNode(id="start", type=NodeType.START, position=Position(x=0, y=0)),
WorkflowNode(id="a", type=NodeType.ANALYSIS, position=Position(x=100, y=0), prompt_slug="test"),
WorkflowNode(id="b", type=NodeType.ANALYSIS, position=Position(x=200, y=0), prompt_slug="test"),
WorkflowNode(id="end", type=NodeType.END, position=Position(x=300, y=0))
],
edges=[
WorkflowEdge(id="e1", from_node="start", to_node="a"),
WorkflowEdge(id="e2", from_node="a", to_node="b"),
WorkflowEdge(id="e3", from_node="b", to_node="a"), # Zyklus!
WorkflowEdge(id="e4", from_node="b", to_node="end")
]
)
with pytest.raises(HTTPException) as exc_info:
WorkflowEngine(graph)
assert exc_info.value.status_code == 400
assert "Zyklus erkannt" in str(exc_info.value.detail)
def test_detect_cycle_self_loop():
"""Test: Selbst-Zyklus wird erkannt (A → A)"""
graph = WorkflowGraph(
nodes=[
WorkflowNode(id="start", type=NodeType.START, position=Position(x=0, y=0)),
WorkflowNode(id="a", type=NodeType.ANALYSIS, position=Position(x=100, y=0), prompt_slug="test"),
WorkflowNode(id="end", type=NodeType.END, position=Position(x=200, y=0))
],
edges=[
WorkflowEdge(id="e1", from_node="start", to_node="a"),
WorkflowEdge(id="e2", from_node="a", to_node="a"), # Selbst-Zyklus!
WorkflowEdge(id="e3", from_node="a", to_node="end")
]
)
with pytest.raises(HTTPException) as exc_info:
WorkflowEngine(graph)
assert exc_info.value.status_code == 400
assert "Zyklus erkannt" in str(exc_info.value.detail)
def test_no_cycle_branching(branching_graph):
"""Test: Verzweigung ohne Zyklus wird akzeptiert"""
engine = WorkflowEngine(branching_graph)
assert engine is not None # Kein Fehler
# ── Test: Erreichbarkeit ──────────────────────────────────────────────────────
def test_unreachable_node_from_start():
"""Test: Nicht vom START erreichbarer Knoten wird erkannt"""
graph = WorkflowGraph(
nodes=[
WorkflowNode(id="start", type=NodeType.START, position=Position(x=0, y=0)),
WorkflowNode(id="a", type=NodeType.ANALYSIS, position=Position(x=100, y=0), prompt_slug="test"),
WorkflowNode(id="isolated", type=NodeType.ANALYSIS, position=Position(x=100, y=100), prompt_slug="test"), # Isoliert!
WorkflowNode(id="end", type=NodeType.END, position=Position(x=200, y=0))
],
edges=[
WorkflowEdge(id="e1", from_node="start", to_node="a"),
WorkflowEdge(id="e2", from_node="a", to_node="end")
# 'isolated' ist nicht verbunden
]
)
with pytest.raises(HTTPException) as exc_info:
WorkflowEngine(graph)
assert exc_info.value.status_code == 400
assert "nicht erreichbar vom START" in str(exc_info.value.detail)
def test_node_cannot_reach_end():
"""Test: Knoten der END nicht erreichen kann wird erkannt"""
graph = WorkflowGraph(
nodes=[
WorkflowNode(id="start", type=NodeType.START, position=Position(x=0, y=0)),
WorkflowNode(id="a", type=NodeType.ANALYSIS, position=Position(x=100, y=0), prompt_slug="test"),
WorkflowNode(id="dead_end", type=NodeType.ANALYSIS, position=Position(x=200, y=0), prompt_slug="test"),
WorkflowNode(id="end", type=NodeType.END, position=Position(x=200, y=100))
],
edges=[
WorkflowEdge(id="e1", from_node="start", to_node="a"),
WorkflowEdge(id="e2", from_node="a", to_node="dead_end")
# 'dead_end' kann END nicht erreichen (keine Verbindung)
]
)
with pytest.raises(HTTPException) as exc_info:
WorkflowEngine(graph)
assert exc_info.value.status_code == 400
assert "END nicht erreichen" in str(exc_info.value.detail)
# ── Test: Topologische Sortierung ─────────────────────────────────────────────
def test_topological_sort_simple(simple_valid_graph):
"""Test: Einfacher linearer Graph hat korrekte topologische Sortierung"""
engine = WorkflowEngine(simple_valid_graph)
assert engine.topological_order == ["start", "analysis1", "end"]
def test_topological_sort_parallel(parallel_graph):
"""Test: Paralleler Graph - Topologische Sortierung"""
engine = WorkflowEngine(parallel_graph)
# START muss zuerst kommen
assert engine.topological_order[0] == "start"
# analysis1 und analysis2 können in beliebiger Reihenfolge kommen (parallel)
assert set(engine.topological_order[1:3]) == {"analysis1", "analysis2"}
# JOIN kommt nach beiden Analysen
assert engine.topological_order[3] == "join"
# END kommt zuletzt
assert engine.topological_order[4] == "end"
def test_topological_sort_branching(branching_graph):
"""Test: Verzweigter Graph - Topologische Sortierung"""
engine = WorkflowEngine(branching_graph)
# START → LOGIC muss zuerst kommen
assert engine.topological_order[:2] == ["start", "logic1"]
# analysis1 und analysis2 können in beliebiger Reihenfolge kommen (alternative Pfade)
assert set(engine.topological_order[2:4]) == {"analysis1", "analysis2"}
# END kommt zuletzt
assert engine.topological_order[4] == "end"
# ── Test: Ausführungs-Ebenen (Parallelität) ───────────────────────────────────
def test_execution_order_simple(simple_valid_graph):
"""Test: Einfacher Graph hat 3 Ebenen (keine Parallelität)"""
engine = WorkflowEngine(simple_valid_graph)
execution_order = engine.get_execution_order()
assert len(execution_order) == 3
assert execution_order[0] == ["start"]
assert execution_order[1] == ["analysis1"]
assert execution_order[2] == ["end"]
def test_execution_order_parallel(parallel_graph):
"""Test: Paralleler Graph - Ebene 2 hat 2 Knoten (können parallel laufen)"""
engine = WorkflowEngine(parallel_graph)
execution_order = engine.get_execution_order()
assert len(execution_order) == 4
assert execution_order[0] == ["start"]
assert set(execution_order[1]) == {"analysis1", "analysis2"} # Parallel!
assert execution_order[2] == ["join"]
assert execution_order[3] == ["end"]
def test_execution_order_branching(branching_graph):
"""Test: Verzweigter Graph - Alternative Pfade sind auf derselben Ebene"""
engine = WorkflowEngine(branching_graph)
execution_order = engine.get_execution_order()
assert len(execution_order) == 4
assert execution_order[0] == ["start"]
assert execution_order[1] == ["logic1"]
assert set(execution_order[2]) == {"analysis1", "analysis2"} # Alternative Pfade
assert execution_order[3] == ["end"]
# ── Test: Validierungs-Hilfsfunktion ──────────────────────────────────────────
def test_validate_workflow_graph_valid(simple_valid_graph):
"""Test: Hilfsfunktion validate_workflow_graph für gültigen Graph"""
is_valid, errors = validate_workflow_graph(simple_valid_graph)
assert is_valid is True
assert errors == []
def test_validate_workflow_graph_invalid():
"""Test: Hilfsfunktion validate_workflow_graph für ungültigen Graph"""
graph = WorkflowGraph(
nodes=[
WorkflowNode(id="start", type=NodeType.START, position=Position(x=0, y=0))
# Kein END-Knoten!
],
edges=[]
)
is_valid, errors = validate_workflow_graph(graph)
assert is_valid is False
assert len(errors) > 0
assert any("END-Knoten" in str(e) for e in errors)
# ── Test: Adjacency Lists ─────────────────────────────────────────────────────
def test_adjacency_lists_creation(parallel_graph):
"""Test: Adjacency Lists werden korrekt erstellt"""
engine = WorkflowEngine(parallel_graph)
# Outgoing edges vom START
start_outgoing = engine.outgoing_edges.get("start", [])
assert len(start_outgoing) == 2
assert set(e.to_node for e in start_outgoing) == {"analysis1", "analysis2"}
# Incoming edges zu JOIN
join_incoming = engine.incoming_edges.get("join", [])
assert len(join_incoming) == 2
assert set(e.from_node for e in join_incoming) == {"analysis1", "analysis2"}
# ── Run Tests ─────────────────────────────────────────────────────────────────
if __name__ == "__main__":
pytest.main([__file__, "-v"])

94
tests/phase3_e2e_test.sql Normal file
View File

@ -0,0 +1,94 @@
-- Phase 3 E2E Test: Branching Workflow
-- Test workflow: body analysis → logic (if relevanz = "decrease") → then/else paths
-- 1. Cleanup (use slug for lookup)
DELETE FROM workflow_executions WHERE workflow_id IN (
SELECT id FROM workflow_definitions WHERE slug = 'phase3-e2e-branching'
);
DELETE FROM workflow_definitions WHERE slug = 'phase3-e2e-branching';
-- 2. Create test workflow
INSERT INTO workflow_definitions (name, slug, description, graph, active)
VALUES (
'Phase 3 E2E Test - Branching',
'phase3-e2e-branching',
'Test workflow with logic node and conditional branching',
'{
"nodes": [
{
"id": "start",
"type": "start",
"position": {"x": 100, "y": 100}
},
{
"id": "body_analysis",
"type": "analysis",
"prompt_slug": "pipeline_body",
"question_augmentations": [
{
"id": "q1",
"type": "relevanz",
"question": "Hat sich die Fettmasse relevant verändert?",
"answer_spectrum": ["increase", "stable", "decrease"],
"reasoning_required": true
}
],
"position": {"x": 100, "y": 200}
},
{
"id": "logic_check",
"type": "logic",
"condition": {
"expression": {
"operator": "eq",
"ref": "body_analysis.relevanz",
"value": "decrease"
},
"then_path": "e3",
"else_path": "e4"
},
"fallback": {
"strategy": "default_path"
},
"position": {"x": 100, "y": 300}
},
{
"id": "decrease_path",
"type": "analysis",
"prompt_slug": "pipeline_body",
"position": {"x": 50, "y": 400}
},
{
"id": "not_decrease_path",
"type": "analysis",
"prompt_slug": "pipeline_body",
"position": {"x": 150, "y": 400}
},
{
"id": "end",
"type": "end",
"position": {"x": 100, "y": 500}
}
],
"edges": [
{"id": "e1", "from": "start", "to": "body_analysis"},
{"id": "e2", "from": "body_analysis", "to": "logic_check"},
{"id": "e3", "from": "logic_check", "to": "decrease_path", "label": "then"},
{"id": "e4", "from": "logic_check", "to": "not_decrease_path", "label": "else"},
{"id": "e5", "from": "decrease_path", "to": "end"},
{"id": "e6", "from": "not_decrease_path", "to": "end"}
]
}',
true
);
-- 3. Verify workflow was created
SELECT
id,
name,
slug,
active,
jsonb_array_length(graph->'nodes') as node_count,
jsonb_array_length(graph->'edges') as edge_count
FROM workflow_definitions
WHERE slug = 'phase3-e2e-branching';

123
tests/phase4_e2e_test.sql Normal file
View File

@ -0,0 +1,123 @@
-- Phase 4 E2E Test: Branching + Join Workflow
-- Test workflow mit 2 parallelen Pfaden, die wieder zusammengeführt werden
-- 1. Insert workflow definition
INSERT INTO workflow_definitions (
id,
name,
description,
graph,
active,
created_by
) VALUES (
'phase4-join-test',
'Phase 4 Join Test Workflow',
'Test workflow with branching and join node',
'{
"nodes": [
{
"id": "start",
"type": "start",
"position": {"x": 100, "y": 100}
},
{
"id": "body_analysis",
"type": "analysis",
"prompt_slug": "body",
"position": {"x": 100, "y": 200},
"question_augmentations": [
{
"id": "relevanz",
"type": "relevanz",
"question": "Ist eine Gewichtsveränderung relevant?",
"answer_spectrum": ["ja", "nein", "unklar"]
}
]
},
{
"id": "decision_logic",
"type": "logic",
"position": {"x": 100, "y": 300},
"condition": {
"type": "if",
"expression": {
"operator": "eq",
"ref": "body_analysis.relevanz",
"value": "ja"
}
},
"fallback": {
"strategy": "default_path"
}
},
{
"id": "path_a_nutrition",
"type": "analysis",
"prompt_slug": "nutrition",
"position": {"x": 50, "y": 400},
"question_augmentations": [
{
"id": "prioritaet",
"type": "prioritaet",
"question": "Wie wichtig ist Ernährungsoptimierung?",
"answer_spectrum": ["hoch", "mittel", "niedrig", "unklar"]
}
]
},
{
"id": "path_b_activity",
"type": "analysis",
"prompt_slug": "activity",
"position": {"x": 150, "y": 400},
"question_augmentations": [
{
"id": "prioritaet",
"type": "prioritaet",
"question": "Wie wichtig ist Trainingsoptimierung?",
"answer_spectrum": ["hoch", "mittel", "niedrig", "unklar"]
}
]
},
{
"id": "join_consolidation",
"type": "join",
"position": {"x": 100, "y": 500},
"join_strategy": "best_effort",
"skip_handling": "ignore_skipped"
},
{
"id": "end",
"type": "end",
"position": {"x": 100, "y": 600}
}
],
"edges": [
{"id": "e1", "from_node": "start", "to_node": "body_analysis"},
{"id": "e2", "from_node": "body_analysis", "to_node": "decision_logic"},
{"id": "e3", "from_node": "decision_logic", "to_node": "path_a_nutrition", "label": "then"},
{"id": "e4", "from_node": "decision_logic", "to_node": "path_b_activity", "label": "else"},
{"id": "e5", "from_node": "path_a_nutrition", "to_node": "join_consolidation"},
{"id": "e6", "from_node": "path_b_activity", "to_node": "join_consolidation"},
{"id": "e7", "from_node": "join_consolidation", "to_node": "end"}
]
}'::jsonb,
true,
'test-user'
);
-- 2. Verify workflow was created
SELECT
id,
name,
active,
jsonb_array_length(graph->'nodes') as node_count,
jsonb_array_length(graph->'edges') as edge_count
FROM workflow_definitions
WHERE id = 'phase4-join-test';
-- Expected result:
-- id: phase4-join-test
-- name: Phase 4 Join Test Workflow
-- active: true
-- node_count: 7
-- edge_count: 7

View File

@ -0,0 +1,58 @@
#!/usr/bin/env python3
"""Quick test for deployed join_evaluator.py"""
from join_evaluator import evaluate_join_node
from workflow_models import (
WorkflowNode, WorkflowGraph, WorkflowEdge,
JoinStrategy, NodeStatus, NodeExecutionState
)
# Minimal test: 2 paths → join
join_node = WorkflowNode(
id="test_join",
type="join",
join_strategy=JoinStrategy.BEST_EFFORT
)
graph = WorkflowGraph(
nodes=[
WorkflowNode(id="path_a", type="analysis"),
WorkflowNode(id="path_b", type="analysis"),
join_node
],
edges=[
WorkflowEdge(id="e1", from_node="path_a", to_node="test_join"),
WorkflowEdge(id="e2", from_node="path_b", to_node="test_join")
]
)
context = {
"node_results": {
"path_a": NodeExecutionState(
node_id="path_a",
status=NodeStatus.EXECUTED,
analysis_core="Analysis from path A"
),
"path_b": NodeExecutionState(
node_id="path_b",
status=NodeStatus.EXECUTED,
analysis_core="Analysis from path B"
)
}
}
# Execute
result = evaluate_join_node(join_node, graph, context)
# Verify
print(f"✅ Ready: {result.ready}")
print(f"✅ Consolidated cores: {len(result.consolidated_analysis_core)}")
print(f"✅ Executed paths: {result.metadata.get('executed_paths')}")
print(f"✅ Strategy: {result.metadata.get('join_strategy')}")
assert result.ready is True
assert len(result.consolidated_analysis_core) == 2
assert "path_a" in result.consolidated_analysis_core
assert "path_b" in result.consolidated_analysis_core
print("\n🎉 Phase 4 Join Evaluator: DEPLOYED AND WORKING!")

View File

@ -0,0 +1,105 @@
#!/usr/bin/env python3
"""Integration test: Full workflow with join node"""
import asyncio
import sys
from workflow_executor import execute_node
from workflow_models import (
WorkflowNode, WorkflowGraph, WorkflowEdge,
JoinStrategy, NodeStatus, NodeExecutionState
)
async def test_join_node_integration():
"""Test execute_join_node via execute_node dispatcher"""
# Setup: 2 executed paths
path_a_state = NodeExecutionState(
node_id="path_a",
status=NodeStatus.EXECUTED,
analysis_core="Path A completed successfully"
)
path_b_state = NodeExecutionState(
node_id="path_b",
status=NodeStatus.EXECUTED,
analysis_core="Path B completed successfully"
)
# Join node
join_node = WorkflowNode(
id="join",
type="join",
join_strategy=JoinStrategy.WAIT_ALL
)
# Graph
graph = WorkflowGraph(
nodes=[
WorkflowNode(id="path_a", type="analysis"),
WorkflowNode(id="path_b", type="analysis"),
join_node
],
edges=[
WorkflowEdge(id="e1", from_node="path_a", to_node="join"),
WorkflowEdge(id="e2", from_node="path_b", to_node="join")
]
)
# Context with previous node results
context = {
"variables": {},
"profile_id": "test-profile",
"node_results": {
"path_a": path_a_state,
"path_b": path_b_state
},
"active_edges": {}
}
# Execute join node via dispatcher
async def mock_llm(prompt, model):
return "Mock LLM response"
result = await execute_node(
node=join_node,
context=context,
catalog={},
graph=graph,
openrouter_call_func=mock_llm
)
# Verify
print(f"✅ Node executed: {result.node_id}")
print(f"✅ Status: {result.status.value}")
print(f"✅ Analysis core exists: {result.analysis_core is not None}")
assert result.node_id == "join"
assert result.status == NodeStatus.EXECUTED
assert result.analysis_core is not None
# Check consolidated data
import json
consolidated = json.loads(result.analysis_core)
print(f"✅ Consolidated paths: {len(consolidated)}")
assert len(consolidated) == 2
assert "path_a" in consolidated
assert "path_b" in consolidated
print(f"✅ Path A analysis: {consolidated['path_a'][:50]}...")
print(f"✅ Path B analysis: {consolidated['path_b'][:50]}...")
print("\n🎉 Integration Test: JOIN NODE WORKING IN WORKFLOW EXECUTOR!")
print(" - Both paths consolidated successfully")
print(" - Analysis cores merged correctly")
print(" - Join strategy executed properly")
return True
if __name__ == "__main__":
try:
success = asyncio.run(test_join_node_integration())
sys.exit(0 if success else 1)
except Exception as e:
print(f"❌ Test failed: {e}")
import traceback
traceback.print_exc()
sys.exit(1)