Responsive Gui - partially Workflow #61

Merged
Lars merged 47 commits from develop into main 2026-04-05 11:27:44 +02:00
98 changed files with 15287 additions and 939 deletions

View File

@ -27,3 +27,9 @@ ALLOWED_ORIGINS=https://mitai.jinkendo.de
# ── Pfade ───────────────────────────────────────────────────────
PHOTOS_DIR=/app/photos
ENVIRONMENT=production
# ── Gitea API (lokal, für scripts/gitea/gitea_api.py niemals committen) ──
GITEA_BASE_URL=http://192.168.2.144:3000
GITEA_OWNER=Lars
GITEA_REPO=mitai-jinkendo
GITEA_TOKEN=

3
.gitignore vendored
View File

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

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 goal_types, goal_progress, training_phases, fitness_tests # v9h Goal System (Split routers)
from routers import charts # Phase 0c Multi-Layer Architecture
from routers import workflow_questions # Phase 1 Workflow Engine - Question Catalog
from routers import workflows # Phase 2 Workflow Engine - Execution
# ── App Configuration ─────────────────────────────────────────────────────────
DATA_DIR = Path(os.getenv("DATA_DIR", "./data"))
@ -110,6 +112,10 @@ app.include_router(focus_areas.router) # /api/focus-areas/* (v9g Focus
# Phase 0c Multi-Layer Architecture
app.include_router(charts.router) # /api/charts/* (Phase 0c Charts API)
# Phase 1-2 Workflow Engine
app.include_router(workflow_questions.router) # /api/workflow/questions/* (Phase 1 Question Catalog)
app.include_router(workflows.router) # /api/workflows/* (Phase 2 Execution)
# ── Health Check ──────────────────────────────────────────────────────────────
@app.get("/")
def root():

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_bf_pct: Optional[float] = None
quality_filter_level: Optional[str] = None # Issue #31: Global quality filter
email: Optional[str] = None # Self-service; leer = entfernen; Änderung setzt Verifikation zurück
# ── Tracking Models ───────────────────────────────────────────────────────────
@ -177,12 +178,12 @@ class StageCreate(BaseModel):
class UnifiedPromptCreate(BaseModel):
"""Create a new unified prompt (base or pipeline type)"""
"""Create a new unified prompt (base, pipeline, or workflow type)"""
name: str
slug: str
slug: Optional[str] = None # Auto-generated from name if not provided
display_name: Optional[str] = None
description: Optional[str] = None
type: str # 'base' or 'pipeline'
type: str # 'base' | 'pipeline' | 'workflow'
category: str = 'ganzheitlich'
active: bool = True
sort_order: int = 0
@ -195,6 +196,9 @@ class UnifiedPromptCreate(BaseModel):
# For pipeline prompts (multi-stage workflow)
stages: Optional[list[StageCreate]] = None # Required if type='pipeline'
# For workflow prompts (visual graph editor)
graph_data: Optional[dict] = None # Required if type='workflow'
class UnifiedPromptUpdate(BaseModel):
"""Update an existing unified prompt"""
@ -209,6 +213,7 @@ class UnifiedPromptUpdate(BaseModel):
output_format: Optional[str] = None
output_schema: Optional[dict] = None
stages: Optional[list[StageCreate]] = None
graph_data: Optional[dict] = None # For workflow type
# ── Pipeline Config Models (Issue #28) ─────────────────────────────────────

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
return await execute_pipeline_prompt(prompt, variables, openrouter_call_func, enable_debug, catalog)
elif prompt_type == 'workflow':
# Workflow prompt: graph-based execution (Phase 0: Foundation)
return await execute_workflow_prompt(prompt, variables, openrouter_call_func, enable_debug, catalog)
else:
raise HTTPException(400, f"Unknown prompt type: {prompt_type}")
@ -201,18 +205,54 @@ async def execute_base_prompt(
variables: Dict[str, Any],
openrouter_call_func,
enable_debug: bool = False,
catalog: Optional[Dict] = None
catalog: Optional[Dict] = None,
node_questions: Optional[list] = None # Phase 1: Knotengebundene Fragen
) -> Dict[str, Any]:
"""Execute a base-type prompt (single template)."""
"""
Execute a base-type prompt (single template).
Phase 1: Unterstützt Fragenergänzungen (Hybridmodell)
- node_questions: Knotengebundene Fragen (Priorität 1)
- prompt.question_augmentations: Prompt-Defaults (Priorität 2)
"""
from question_augmenter import (
parse_question_augmentations_from_jsonb,
merge_question_augmentations,
augment_prompt_with_questions
)
from result_container_parser import parse_result_container_robust
template = prompt.get('template')
if not template:
raise HTTPException(400, f"Base prompt missing template: {prompt['slug']}")
debug_info = {} if enable_debug else None
# Phase 1: Load question augmentations (Hybridmodell)
prompt_default_questions = None
if prompt.get('question_augmentations'):
try:
from workflow_models import QuestionAugmentation
prompt_default_questions = parse_question_augmentations_from_jsonb(
prompt['question_augmentations']
)
except Exception as e:
if enable_debug:
debug_info['question_augmentations_error'] = str(e)
# Merge question augmentations (Vorrangregel: Knoten > Prompt)
questions = merge_question_augmentations(node_questions, prompt_default_questions)
# Resolve placeholders (with optional catalog for |d modifier)
prompt_text = resolve_placeholders(template, variables, debug_info, catalog)
# Phase 1: Augment prompt with questions (if any)
if questions:
prompt_text = augment_prompt_with_questions(prompt_text, questions)
if enable_debug:
debug_info['question_augmentations_count'] = len(questions)
debug_info['question_types'] = [q.type for q in questions]
if enable_debug:
debug_info['template'] = template
debug_info['final_prompt'] = prompt_text[:500] + ('...' if len(prompt_text) > 500 else '')
@ -225,12 +265,24 @@ async def execute_base_prompt(
debug_info['ai_response_length'] = len(response)
debug_info['ai_response_preview'] = response[:200] + ('...' if len(response) > 200 else '')
# Validate JSON if required
output_format = prompt.get('output_format', 'text')
if output_format == 'json':
output = validate_json_output(response, prompt.get('output_schema'), debug_info if enable_debug else None)
# Phase 1: Parse structured result if questions were used
if questions:
expected_question_types = [q.type for q in questions]
container = parse_result_container_robust(response, expected_question_types)
if enable_debug:
debug_info['parsing_status'] = container['parsing_status']
debug_info['parsing_warnings'] = container.get('warnings', [])
output = container
output_format = 'structured_container' # New format type
else:
output = response
# Legacy behavior: Validate JSON if required
output_format = prompt.get('output_format', 'text')
if output_format == 'json':
output = validate_json_output(response, prompt.get('output_schema'), debug_info if enable_debug else None)
else:
output = response
result = {
"type": "base",
@ -524,3 +576,67 @@ async def execute_prompt_with_data(
# Execute prompt
return await execute_prompt(prompt_slug, variables, openrouter_call_func, enable_debug)
async def execute_workflow_prompt(
prompt: Dict,
variables: Dict[str, Any],
openrouter_call_func,
enable_debug: bool = False,
catalog: Optional[Dict] = None
) -> Dict[str, Any]:
"""
Execute a workflow-type prompt (graph-based execution).
Phase 2-4: Sequenzielle Workflow-Execution, conditional branching, path consolidation
Phase 5: Graph aus ai_prompts.graph_data (nicht workflow_definitions)
Args:
prompt: Prompt dict from database (must have 'graph_data' field)
variables: Dict of variables for placeholder replacement
openrouter_call_func: Async function(prompt_text, model) -> response_text
enable_debug: If True, include debug information in response
catalog: Optional placeholder catalog
Returns:
Dict with execution results:
{
"type": "workflow",
"execution_id": "...",
"status": "completed" | "failed",
"aggregated_result": {...},
"node_states": [...], # Only if enable_debug=True
"error": "..." # Only if status=failed
}
"""
from workflow_executor import execute_workflow
# Phase 5: Graph aus ai_prompts.graph_data
graph_data = prompt.get('graph_data')
if not graph_data:
raise HTTPException(400, "Workflow-Prompt fehlt 'graph_data' Feld")
# Execute workflow (mit graph_data statt workflow_id)
result = await execute_workflow(
graph_data=graph_data, # NEU: Direkt graph_data übergeben
profile_id=variables.get('profile_id', 'unknown'), # From context
variables=variables,
openrouter_call_func=openrouter_call_func,
enable_debug=enable_debug
)
# Convert ExecutionResult to dict for API response
response = {
"type": "workflow",
"execution_id": result.execution_id,
"status": result.status,
"aggregated_result": result.aggregated_result
}
if enable_debug:
response["node_states"] = [s.model_dump() for s in result.node_states]
if result.error:
response["error"] = result.error
return response

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}")
def update_profile(pid: str, p: ProfileUpdate, session=Depends(require_auth)):
"""Update profile by ID (admin)."""
"""Update profile by ID."""
with get_db() as conn:
data = {k:v for k,v in p.model_dump().items() if v is not None}
data['updated'] = datetime.now().isoformat()
cur = get_cursor(conn)
cur.execute(f"UPDATE profiles SET {', '.join(f'{k}=%s' for k in data)} WHERE id=%s",
list(data.values())+[pid])
cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,))
row = cur.fetchone()
if not row:
raise HTTPException(404, "Profil nicht gefunden")
rowd = r2d(row)
cur_email_norm = (rowd.get("email") or "").strip().lower()
patch = p.model_dump(exclude_unset=True)
data = {}
if "email" in patch:
ev = patch["email"]
if ev is None or (isinstance(ev, str) and ev.strip() == ""):
if rowd.get("email") is not None:
data["email"] = None
data["email_verified"] = False
data["verification_token"] = None
data["verification_expires"] = None
else:
email_norm = ev.strip().lower()
if "@" not in email_norm or len(email_norm) < 5:
raise HTTPException(400, "Ungültige E-Mail-Adresse")
cur.execute(
"""
SELECT id FROM profiles
WHERE email IS NOT NULL AND lower(trim(email)) = %s AND id <> %s
""",
(email_norm, pid),
)
if cur.fetchone():
raise HTTPException(409, "E-Mail wird bereits verwendet")
data["email"] = email_norm
if email_norm != cur_email_norm:
data["email_verified"] = False
data["verification_token"] = None
data["verification_expires"] = None
nullable_keys = {"goal_weight", "goal_bf_pct", "dob"}
for k, v in patch.items():
if k == "email":
continue
if v is None and k in nullable_keys:
data[k] = None
elif v is not None:
data[k] = v
if not data:
return get_profile(pid, session)
data["updated"] = datetime.now().isoformat()
cols = ", ".join(f"{k}=%s" for k in data)
vals = list(data.values()) + [pid]
cur.execute(f"UPDATE profiles SET {cols} WHERE id=%s", vals)
return get_profile(pid, session)

View File

@ -53,6 +53,20 @@ def list_prompts(session: dict=Depends(require_auth)):
return [r2d(r) for r in cur.fetchall()]
@router.get("/{prompt_id}")
def get_prompt(prompt_id: str, session: dict=Depends(require_auth)):
"""Get single AI prompt by ID (UUID)."""
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("SELECT * FROM ai_prompts WHERE id=%s", (prompt_id,))
row = cur.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Prompt not found")
return r2d(row)
@router.post("")
def create_prompt(p: PromptCreate, session: dict=Depends(require_admin)):
"""Create new AI prompt (admin only)."""
@ -1380,20 +1394,26 @@ async def execute_unified_prompt(
@router.post("/unified")
def create_unified_prompt(p: UnifiedPromptCreate, session: dict = Depends(require_admin)):
"""
Create a new unified prompt (base or pipeline type).
Create a new unified prompt (base, pipeline, or workflow type).
Admin only.
"""
with get_db() as conn:
cur = get_cursor(conn)
# Auto-generate slug if not provided (for workflows)
if not p.slug:
import re
base_slug = re.sub(r'[^a-z0-9_]+', '_', p.name.lower()).strip('_')
p.slug = f"{base_slug}_{uuid.uuid4().hex[:6]}"
# Check for duplicate slug
cur.execute("SELECT id FROM ai_prompts WHERE slug=%s", (p.slug,))
if cur.fetchone():
raise HTTPException(status_code=400, detail="Slug already exists")
# Validate type
if p.type not in ['base', 'pipeline']:
raise HTTPException(status_code=400, detail="Type must be 'base' or 'pipeline'")
if p.type not in ['base', 'pipeline', 'workflow']:
raise HTTPException(status_code=400, detail="Type must be 'base', 'pipeline', or 'workflow'")
# Validate base type has template
if p.type == 'base' and not p.template:
@ -1403,6 +1423,10 @@ def create_unified_prompt(p: UnifiedPromptCreate, session: dict = Depends(requir
if p.type == 'pipeline' and not p.stages:
raise HTTPException(status_code=400, detail="Pipeline prompts require stages")
# Validate workflow type has graph_data
if p.type == 'workflow' and not p.graph_data:
raise HTTPException(status_code=400, detail="Workflow prompts require graph_data")
# Convert stages to JSONB
stages_json = None
if p.stages:
@ -1426,16 +1450,22 @@ def create_unified_prompt(p: UnifiedPromptCreate, session: dict = Depends(requir
prompt_id = str(uuid.uuid4())
# Convert graph_data to JSONB
graph_data_json = None
if p.graph_data:
graph_data_json = json.dumps(p.graph_data)
cur.execute(
"""INSERT INTO ai_prompts
(id, slug, name, display_name, description, template, category, active, sort_order,
type, stages, output_format, output_schema)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)""",
type, stages, output_format, output_schema, graph_data)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)""",
(
prompt_id, p.slug, p.name, p.display_name, p.description,
p.template, p.category, p.active, p.sort_order,
p.type, stages_json, p.output_format,
json.dumps(p.output_schema) if p.output_schema else None
json.dumps(p.output_schema) if p.output_schema else None,
graph_data_json
)
)
@ -1470,8 +1500,8 @@ def update_unified_prompt(prompt_id: str, p: UnifiedPromptUpdate, session: dict
updates.append('description=%s')
values.append(p.description)
if p.type is not None:
if p.type not in ['base', 'pipeline']:
raise HTTPException(status_code=400, detail="Type must be 'base' or 'pipeline'")
if p.type not in ['base', 'pipeline', 'workflow']:
raise HTTPException(status_code=400, detail="Type must be 'base', 'pipeline', or 'workflow'")
updates.append('type=%s')
values.append(p.type)
if p.category is not None:
@ -1512,6 +1542,9 @@ def update_unified_prompt(prompt_id: str, p: UnifiedPromptUpdate, session: dict
])
updates.append('stages=%s')
values.append(stages_json)
if p.graph_data is not None:
updates.append('graph_data=%s')
values.append(json.dumps(p.graph_data))
if not updates:
return {"ok": True}

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-dom": "^18.3.1",
"react-router-dom": "^6.23.1",
"reactflow": "^11.11.4",
"recharts": "^2.12.7"
},
"devDependencies": {
@ -2059,6 +2060,108 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@reactflow/background": {
"version": "11.3.14",
"resolved": "https://registry.npmjs.org/@reactflow/background/-/background-11.3.14.tgz",
"integrity": "sha512-Gewd7blEVT5Lh6jqrvOgd4G6Qk17eGKQfsDXgyRSqM+CTwDqRldG2LsWN4sNeno6sbqVIC2fZ+rAUBFA9ZEUDA==",
"license": "MIT",
"dependencies": {
"@reactflow/core": "11.11.4",
"classcat": "^5.0.3",
"zustand": "^4.4.1"
},
"peerDependencies": {
"react": ">=17",
"react-dom": ">=17"
}
},
"node_modules/@reactflow/controls": {
"version": "11.2.14",
"resolved": "https://registry.npmjs.org/@reactflow/controls/-/controls-11.2.14.tgz",
"integrity": "sha512-MiJp5VldFD7FrqaBNIrQ85dxChrG6ivuZ+dcFhPQUwOK3HfYgX2RHdBua+gx+40p5Vw5It3dVNp/my4Z3jF0dw==",
"license": "MIT",
"dependencies": {
"@reactflow/core": "11.11.4",
"classcat": "^5.0.3",
"zustand": "^4.4.1"
},
"peerDependencies": {
"react": ">=17",
"react-dom": ">=17"
}
},
"node_modules/@reactflow/core": {
"version": "11.11.4",
"resolved": "https://registry.npmjs.org/@reactflow/core/-/core-11.11.4.tgz",
"integrity": "sha512-H4vODklsjAq3AMq6Np4LE12i1I4Ta9PrDHuBR9GmL8uzTt2l2jh4CiQbEMpvMDcp7xi4be0hgXj+Ysodde/i7Q==",
"license": "MIT",
"dependencies": {
"@types/d3": "^7.4.0",
"@types/d3-drag": "^3.0.1",
"@types/d3-selection": "^3.0.3",
"@types/d3-zoom": "^3.0.1",
"classcat": "^5.0.3",
"d3-drag": "^3.0.0",
"d3-selection": "^3.0.0",
"d3-zoom": "^3.0.0",
"zustand": "^4.4.1"
},
"peerDependencies": {
"react": ">=17",
"react-dom": ">=17"
}
},
"node_modules/@reactflow/minimap": {
"version": "11.7.14",
"resolved": "https://registry.npmjs.org/@reactflow/minimap/-/minimap-11.7.14.tgz",
"integrity": "sha512-mpwLKKrEAofgFJdkhwR5UQ1JYWlcAAL/ZU/bctBkuNTT1yqV+y0buoNVImsRehVYhJwffSWeSHaBR5/GJjlCSQ==",
"license": "MIT",
"dependencies": {
"@reactflow/core": "11.11.4",
"@types/d3-selection": "^3.0.3",
"@types/d3-zoom": "^3.0.1",
"classcat": "^5.0.3",
"d3-selection": "^3.0.0",
"d3-zoom": "^3.0.0",
"zustand": "^4.4.1"
},
"peerDependencies": {
"react": ">=17",
"react-dom": ">=17"
}
},
"node_modules/@reactflow/node-resizer": {
"version": "2.2.14",
"resolved": "https://registry.npmjs.org/@reactflow/node-resizer/-/node-resizer-2.2.14.tgz",
"integrity": "sha512-fwqnks83jUlYr6OHcdFEedumWKChTHRGw/kbCxj0oqBd+ekfs+SIp4ddyNU0pdx96JIm5iNFS0oNrmEiJbbSaA==",
"license": "MIT",
"dependencies": {
"@reactflow/core": "11.11.4",
"classcat": "^5.0.4",
"d3-drag": "^3.0.0",
"d3-selection": "^3.0.0",
"zustand": "^4.4.1"
},
"peerDependencies": {
"react": ">=17",
"react-dom": ">=17"
}
},
"node_modules/@reactflow/node-toolbar": {
"version": "1.3.14",
"resolved": "https://registry.npmjs.org/@reactflow/node-toolbar/-/node-toolbar-1.3.14.tgz",
"integrity": "sha512-rbynXQnH/xFNu4P9H+hVqlEUafDCkEoCy0Dg9mG22Sg+rY/0ck6KkrAQrYrTgXusd+cEJOMK0uOOFCK2/5rSGQ==",
"license": "MIT",
"dependencies": {
"@reactflow/core": "11.11.4",
"classcat": "^5.0.3",
"zustand": "^4.4.1"
},
"peerDependencies": {
"react": ">=17",
"react-dom": ">=17"
}
},
"node_modules/@remix-run/router": {
"version": "1.23.2",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz",
@ -2554,24 +2657,159 @@
"@babel/types": "^7.28.2"
}
},
"node_modules/@types/d3": {
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz",
"integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==",
"license": "MIT",
"dependencies": {
"@types/d3-array": "*",
"@types/d3-axis": "*",
"@types/d3-brush": "*",
"@types/d3-chord": "*",
"@types/d3-color": "*",
"@types/d3-contour": "*",
"@types/d3-delaunay": "*",
"@types/d3-dispatch": "*",
"@types/d3-drag": "*",
"@types/d3-dsv": "*",
"@types/d3-ease": "*",
"@types/d3-fetch": "*",
"@types/d3-force": "*",
"@types/d3-format": "*",
"@types/d3-geo": "*",
"@types/d3-hierarchy": "*",
"@types/d3-interpolate": "*",
"@types/d3-path": "*",
"@types/d3-polygon": "*",
"@types/d3-quadtree": "*",
"@types/d3-random": "*",
"@types/d3-scale": "*",
"@types/d3-scale-chromatic": "*",
"@types/d3-selection": "*",
"@types/d3-shape": "*",
"@types/d3-time": "*",
"@types/d3-time-format": "*",
"@types/d3-timer": "*",
"@types/d3-transition": "*",
"@types/d3-zoom": "*"
}
},
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
"license": "MIT"
},
"node_modules/@types/d3-axis": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz",
"integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==",
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-brush": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz",
"integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==",
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-chord": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz",
"integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==",
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-contour": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz",
"integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==",
"license": "MIT",
"dependencies": {
"@types/d3-array": "*",
"@types/geojson": "*"
}
},
"node_modules/@types/d3-delaunay": {
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
"integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==",
"license": "MIT"
},
"node_modules/@types/d3-dispatch": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz",
"integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==",
"license": "MIT"
},
"node_modules/@types/d3-drag": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
"integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-dsv": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz",
"integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==",
"license": "MIT"
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT"
},
"node_modules/@types/d3-fetch": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz",
"integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==",
"license": "MIT",
"dependencies": {
"@types/d3-dsv": "*"
}
},
"node_modules/@types/d3-force": {
"version": "3.0.10",
"resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz",
"integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==",
"license": "MIT"
},
"node_modules/@types/d3-format": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz",
"integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==",
"license": "MIT"
},
"node_modules/@types/d3-geo": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz",
"integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==",
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/d3-hierarchy": {
"version": "3.1.7",
"resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz",
"integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==",
"license": "MIT"
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
@ -2587,6 +2825,24 @@
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT"
},
"node_modules/@types/d3-polygon": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz",
"integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==",
"license": "MIT"
},
"node_modules/@types/d3-quadtree": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz",
"integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==",
"license": "MIT"
},
"node_modules/@types/d3-random": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz",
"integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==",
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
@ -2596,6 +2852,18 @@
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-scale-chromatic": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
"integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==",
"license": "MIT"
},
"node_modules/@types/d3-selection": {
"version": "3.0.11",
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
"license": "MIT"
},
"node_modules/@types/d3-shape": {
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
@ -2611,12 +2879,37 @@
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT"
},
"node_modules/@types/d3-time-format": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz",
"integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==",
"license": "MIT"
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/d3-transition": {
"version": "3.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
"integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-zoom": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
"integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
"license": "MIT",
"dependencies": {
"@types/d3-interpolate": "*",
"@types/d3-selection": "*"
}
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@ -2624,6 +2917,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"license": "MIT"
},
"node_modules/@types/raf": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
@ -3024,6 +3323,12 @@
"node": ">=10.0.0"
}
},
"node_modules/classcat": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
"license": "MIT"
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@ -3145,6 +3450,28 @@
"node": ">=12"
}
},
"node_modules/d3-dispatch": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-drag": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-selection": "3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
@ -3200,6 +3527,16 @@
"node": ">=12"
}
},
"node_modules/d3-selection": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"peer": true,
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
@ -3245,6 +3582,41 @@
"node": ">=12"
}
},
"node_modules/d3-transition": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3",
"d3-dispatch": "1 - 3",
"d3-ease": "1 - 3",
"d3-interpolate": "1 - 3",
"d3-timer": "1 - 3"
},
"engines": {
"node": ">=12"
},
"peerDependencies": {
"d3-selection": "2 - 3"
}
},
"node_modules/d3-zoom": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-drag": "2 - 3",
"d3-interpolate": "1 - 3",
"d3-selection": "2 - 3",
"d3-transition": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/data-view-buffer": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz",
@ -5161,6 +5533,24 @@
"react-dom": ">=16.6.0"
}
},
"node_modules/reactflow": {
"version": "11.11.4",
"resolved": "https://registry.npmjs.org/reactflow/-/reactflow-11.11.4.tgz",
"integrity": "sha512-70FOtJkUWH3BAOsN+LU9lCrKoKbtOPnz2uq0CV2PLdNSwxTXOhCbsZr50GmZ+Rtw3jx8Uv7/vBFtCGixLfd4Og==",
"license": "MIT",
"dependencies": {
"@reactflow/background": "11.3.14",
"@reactflow/controls": "11.2.14",
"@reactflow/core": "11.11.4",
"@reactflow/minimap": "11.7.14",
"@reactflow/node-resizer": "2.2.14",
"@reactflow/node-toolbar": "1.3.14"
},
"peerDependencies": {
"react": ">=17",
"react-dom": ">=17"
}
},
"node_modules/recharts": {
"version": "2.15.4",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz",
@ -6187,6 +6577,15 @@
"browserslist": ">= 4.21.0"
}
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/utrie": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
@ -6761,6 +7160,34 @@
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"dev": true,
"license": "ISC"
},
"node_modules/zustand": {
"version": "4.5.7",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
"integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
"license": "MIT",
"dependencies": {
"use-sync-external-store": "^1.2.2"
},
"engines": {
"node": ">=12.7.0"
},
"peerDependencies": {
"@types/react": ">=16.8",
"immer": ">=9.0.6",
"react": ">=16.8"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
}
}
}
}
}

View File

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

View File

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

View File

@ -15,6 +15,7 @@
--nav-h: 64px;
--header-h: 52px;
--font: system-ui, -apple-system, 'Segoe UI', sans-serif;
--capture-content-max: 800px;
}
@media (prefers-color-scheme: dark) {
: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-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 {
font-family: var(--font); font-size: 13px; color: var(--text1);
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-gap { 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; }
.empty-state { text-align: center; padding: 48px 16px; color: var(--text3); }
.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 */
.app-header { display:flex; align-items:center; justify-content:space-between; }
.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 (
<div>
<div className="capture-page">
<h1 className="page-title">Aktivität</h1>
<div className="tabs" style={{overflowX:'auto',flexWrap:'nowrap'}}>

View File

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

View File

@ -1,6 +1,7 @@
import { useState, useEffect } from 'react'
import { Settings, Plus, Pencil, Trash2, Database } from 'lucide-react'
import { api } from '../utils/api'
import EmojiIconPicker from '../components/EmojiIconPicker'
export default function AdminGoalTypesPage() {
const [goalTypes, setGoalTypes] = useState([])
@ -367,14 +368,15 @@ export default function AdminGoalTypesPage() {
/>
</div>
<div>
<label className="form-label">Icon (Emoji)</label>
<input
type="text"
className="form-input"
style={{ width: '100%' }}
<label className="form-label" htmlFor="admin-goal-type-icon">
Icon (Emoji)
</label>
<EmojiIconPicker
id="admin-goal-type-icon"
value={formData.icon}
onChange={e => setFormData(f => ({ ...f, icon: e.target.value }))}
onChange={(icon) => setFormData((f) => ({ ...f, icon }))}
placeholder="🧘"
maxLength={10}
/>
</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 { useNavigate } from 'react-router-dom'
import { api } from '../utils/api'
import UnifiedPromptModal from '../components/UnifiedPromptModal'
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.
*/
export default function AdminPromptsPage() {
const navigate = useNavigate()
const [prompts, setPrompts] = 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 [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
@ -44,6 +46,8 @@ export default function AdminPromptsPage() {
filtered = filtered.filter(p => p.type === 'base')
} else if (typeFilter === 'pipeline') {
filtered = filtered.filter(p => p.type === 'pipeline')
} else if (typeFilter === 'workflow') {
filtered = filtered.filter(p => p.type === 'workflow')
}
// Filter by category
@ -256,6 +260,13 @@ export default function AdminPromptsPage() {
>
+ Neuer Prompt
</button>
<button
className="btn btn-secondary"
onClick={() => navigate('/workflow-editor/new')}
style={{ marginLeft: 8 }}
>
🔀 Neuer Workflow
</button>
</div>
</div>
@ -329,6 +340,13 @@ export default function AdminPromptsPage() {
>
Pipelines ({prompts.filter(p => p.type === 'pipeline' || !p.type).length})
</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 style={{
@ -512,7 +530,13 @@ export default function AdminPromptsPage() {
justifyContent: 'flex-end'
}}>
<button
onClick={() => setEditingPrompt(prompt)}
onClick={() => {
if (prompt.type === 'workflow') {
navigate(`/workflow-editor/${prompt.id}`)
} else {
setEditingPrompt(prompt)
}
}}
style={{
background: '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 { api } from '../utils/api'
import ProfileBuilder from '../components/ProfileBuilder'
import EmojiIconPicker from '../components/EmojiIconPicker'
/**
* AdminTrainingTypesPage - CRUD for training types
@ -254,13 +255,11 @@ export default function AdminTrainingTypesPage() {
<div>
<div className="form-label">Icon (Emoji)</div>
<input
className="form-input"
<EmojiIconPicker
value={formData.icon}
onChange={e => setFormData({ ...formData, icon: e.target.value })}
onChange={(icon) => setFormData({ ...formData, icon })}
placeholder="🏃"
maxLength={10}
style={{ width: '100%' }}
/>
</div>
@ -495,13 +494,11 @@ export default function AdminTrainingTypesPage() {
<div>
<div className="form-label">Icon (Emoji)</div>
<input
className="form-input"
<EmojiIconPicker
value={formData.icon}
onChange={e => setFormData({ ...formData, icon: e.target.value })}
onChange={(icon) => setFormData({ ...formData, icon })}
placeholder="🏃"
maxLength={10}
style={{ width: '100%' }}
/>
</div>

View File

@ -1,5 +1,5 @@
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 { useAuth } from '../context/AuthContext'
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 }) {
const [form, setForm] = useState({
name:'', pin:'', email:'', avatar_color:COLORS[0],
@ -210,7 +193,6 @@ function ProfileCard({ profile, currentId, onRefresh }) {
{expanded && (
<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={{marginBottom:8}}>
@ -231,7 +213,6 @@ function ProfileCard({ profile, currentId, onRefresh }) {
</button>
</div>
{/* Feature-Overrides */}
<div style={{marginBottom:12,padding:10,background:'var(--accent-light)',borderRadius:6,fontSize:12}}>
<strong>Feature-Limits:</strong> Nutze die neue{' '}
<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.
</div>
{/* Email */}
<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>
<EmailEditor profileId={profile.id} currentEmail={profile.email} onSaved={onRefresh}/>
</div>
{/* PIN change */}
<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}}>
<Key size={12}/> PIN / PASSWORT ÄNDERN
@ -264,84 +243,7 @@ function ProfileCard({ profile, currentId, onRefresh }) {
)
}
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: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() {
export default function AdminUsersPage() {
const { session } = useAuth()
const [profiles, setProfiles] = useState([])
const [creating, setCreating] = useState(false)
@ -367,7 +269,7 @@ export default function AdminPanel() {
<div style={{padding:'10px 12px',background:'var(--accent-light)',borderRadius:8,
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>
{creating && (
@ -384,171 +286,6 @@ export default function AdminPanel() {
<Plus size={14}/> Neues Profil anlegen
</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>
)
}

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 { useNavigate } from 'react-router-dom'
import { api } from '../utils/api'
@ -14,6 +14,58 @@ const SLUG_LABELS = {
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=[] }) {
const [open, setOpen] = useState(defaultOpen)
@ -286,6 +338,9 @@ export default function Analysis() {
const [tab, setTab] = useState('run')
const [newResult, setNewResult] = 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 [p, i] = await Promise.all([
@ -305,6 +360,25 @@ export default function Analysis() {
}).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) => {
setLoading(slug); setError(null); setNewResult(null)
try {
@ -383,14 +457,26 @@ export default function Analysis() {
grouped[key].push(ins)
})
// Show only active pipeline-type prompts
const pipelinePrompts = prompts.filter(p => p.active && p.type === 'pipeline')
// Show only active pipeline-type prompts (und nach DB-Kategorie gruppiert)
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 (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<div className="analysis-page">
<div className="analysis-page__header">
<h1 className="page-title" style={{ margin: 0 }}>KI-Analyse</h1>
<button
type="button"
className="btn btn-secondary"
onClick={() => navigate('/goals')}
style={{ fontSize: 13, padding: '6px 12px' }}
@ -400,8 +486,8 @@ export default function Analysis() {
</div>
<div className="tabs">
<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==='run'?' active':'')} onClick={()=>setTab('run')}>Analysen starten</button>
<button type="button" className={'tab'+(tab==='history'?' active':'')} onClick={()=>setTab('history')}>
Verlauf
{allInsights.length>0 && <span style={{marginLeft:4,fontSize:10,background:'var(--accent)',
color:'white',padding:'1px 5px',borderRadius:8}}>{allInsights.length}</span>}
@ -452,64 +538,101 @@ export default function Analysis() {
)}
{canUseAI && pipelinePrompts.length > 0 && (
<p style={{fontSize:13,color:'var(--text2)',marginBottom:14,lineHeight:1.6}}>
Wähle eine mehrstufige KI-Analyse:
</p>
)}
{pipelinePrompts.map(p => {
const existing = allInsights.find(i=>i.scope===p.slug)
return (
<div key={p.id} className="card" style={{marginBottom:16,borderColor:'var(--accent)',borderWidth:2}}>
<div style={{display:'flex',alignItems:'flex-start',gap:12}}>
<div style={{flex:1}}>
<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
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>
<>
<p style={{fontSize:13,color:'var(--text2)',marginBottom:14,lineHeight:1.6}}>
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.
</p>
<div className="analysis-split">
<div className="analysis-split__nav-wrap">
<nav className="analysis-split__nav" aria-label="KI-Analyse-Kategorien">
{pipelineGroups.map(({ categoryKey, label, prompts: inGroup }) => (
<button
key={categoryKey}
type="button"
className={
'analysis-split__nav-item' +
(activeCategoryKey === categoryKey ? ' analysis-split__nav-item--active' : '')
}
onClick={() => setActiveCategoryKey(categoryKey)}
aria-current={activeCategoryKey === categoryKey ? 'page' : undefined}
>
{label}
<span className="analysis-split__nav-cat-count">({inGroup.length})</span>
</button>
))}
</nav>
</div>
<div className="analysis-split__main">
{activeCategoryKey && (() => {
const group = pipelineGroups.find(g => g.categoryKey === activeCategoryKey)
if (!group?.prompts?.length) return null
return (
<>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 12 }}>
{group.label} · {group.prompts.length} {group.prompts.length === 1 ? 'Analyse' : 'Analysen'}
</div>
{group.prompts.map(p => {
const existing = allInsights.find(i => i.scope === p.slug)
return (
<div key={p.id} className="card" style={{marginBottom:16,borderColor:'var(--accent)',borderWidth:2}}>
<div style={{display:'flex',alignItems:'flex-start',gap:12,flexWrap:'wrap'}}>
<div style={{flex:1,minWidth:0}}>
<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>
{/* 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>
)
})}
</>
)}
{canUseAI && pipelinePrompts.length === 0 && (
<div className="empty-state">
<p>Keine aktiven Pipeline-Prompts verfügbar.</p>
<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>
</div>
)}
@ -519,18 +642,33 @@ export default function Analysis() {
{/* ── Verlauf gruppiert ── */}
{tab==='history' && (
<div>
{allInsights.length===0
? <div className="empty-state"><h3>Noch keine Analysen</h3></div>
: Object.entries(grouped).map(([scope, ins]) => (
<div key={scope} style={{marginBottom:20}}>
<div style={{fontSize:13,fontWeight:700,color:'var(--text3)',
textTransform:'uppercase',letterSpacing:'0.05em',marginBottom:8}}>
{prompts.find(p => p.slug === scope)?.display_name || SLUG_LABELS[scope] || scope} ({ins.length})
</div>
{ins.map(i => <InsightCard key={i.id} ins={i} onDelete={deleteInsight} prompts={prompts}/>)}
{allInsights.length===0 ? (
<div className="empty-state"><h3>Noch keine Analysen</h3></div>
) : (
<div className="analysis-split">
<div className="analysis-split__nav-wrap">
<nav className="analysis-split__nav" aria-label="Gespeicherte Analysen">
{historyScopeKeys.map(scope => (
<button
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 className="analysis-split__main">
{activeHistoryScope && grouped[activeHistoryScope]?.map(i => (
<InsightCard key={i.id} ins={i} onDelete={deleteInsight} prompts={prompts}/>
))}
</div>
</div>
)}
</div>
)}
</div>

View File

@ -160,7 +160,7 @@ export default function CaliperScreen() {
}
return (
<div>
<div className="capture-page">
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between',marginBottom:16}}>
<h1 className="page-title" style={{margin:0}}>Caliper</h1>
<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 { ChevronRight } from 'lucide-react'
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',
},
]
import { CAPTURE_HUB_TILES } from '../config/captureNav'
export default function CaptureHub() {
const nav = useNavigate()
return (
<div>
<div className="capture-page">
<h1 className="page-title">Erfassen</h1>
<div style={{display:'flex',flexDirection:'column',gap:10}}>
{ENTRIES.map(e => (
{CAPTURE_HUB_TILES.map(e => (
<button key={e.to} onClick={()=>nav(e.to)}
style={{
display:'flex', alignItems:'center', gap:14,

View File

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

View File

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

View File

@ -985,6 +985,11 @@ export default function History() {
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) => {
setLoadingSlug(slug)
try {
@ -1007,27 +1012,33 @@ export default function History() {
const sp={insights,onRequest:requestInsight,loadingSlug,filterActiveSlugs}
return (
<div>
<h1 className="page-title">Verlauf & Auswertung</h1>
<div style={{display:'flex',gap:6,overflowX:'auto',paddingBottom:6,marginBottom:16,
msOverflowStyle:'none',scrollbarWidth:'none'}}>
{TABS.map(t=>(
<button key={t.id} onClick={()=>setTab(t.id)}
style={{whiteSpace:'nowrap',padding:'7px 14px',borderRadius:20,flexShrink:0,
border:`1.5px solid ${tab===t.id?'var(--accent)':'var(--border2)'}`,
background:tab===t.id?'var(--accent)':'var(--surface)',
color:tab===t.id?'white':'var(--text2)',
fontFamily:'var(--font)',fontSize:13,fontWeight:500,cursor:'pointer'}}>
{t.label}
</button>
))}
<div className="history-page">
<h1 className="page-title history-page__title">Verlauf & Auswertung</h1>
<div className="history-page__layout">
<nav className="history-tabs" aria-label="Verlauf-Kategorien">
<div className="history-tabs__scroller">
{TABS.map(t => (
<button
key={t.id}
type="button"
className={`history-tab-btn${tab === t.id ? ' history-tab-btn--active' : ''}`}
onClick={() => setTab(t.id)}
aria-current={tab === t.id ? 'page' : undefined}
>
{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>
{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>
)
}

View File

@ -344,7 +344,7 @@ export default function MeasureWizard() {
if (done) {
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'}}>
<div style={{fontSize:48}}></div>
<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 === 'caliper') return <CaliperWizard onDone={()=>setDone(true)} onCancel={()=>setMode(null)} profile={profile}/>
if (mode === 'circum') return (
<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 (
<div>
<div className="capture-page">
<h1 className="page-title">Assistent</h1>
<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.

View File

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

View File

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

View File

@ -1,103 +1,24 @@
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 { useAuth } from '../context/AuthContext'
import { Avatar } from './ProfileSelect'
import { api } from '../utils/api'
import AdminPanel from './AdminPanel'
import FeatureUsageOverview from '../components/FeatureUsageOverview'
import UsageBadge from '../components/UsageBadge'
const COLORS = ['#1D9E75','#378ADD','#D85A30','#EF9F27','#7F77DD','#D4537E','#639922','#888780']
function ProfileForm({ profile, onSave, onCancel, title }) {
const [form, setForm] = useState({
name: profile?.name || '',
sex: profile?.sex || 'm',
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>
)
function dobInputValue(dob) {
if (!dob) return ''
const s = String(dob)
return s.length >= 10 ? s.slice(0, 10) : s
}
export default function SettingsPage() {
const { profiles, activeProfile, setActiveProfile, refreshProfiles } = useProfile()
const { logout, isAdmin, canExport } = useAuth()
const [adminOpen, setAdminOpen] = useState(false)
const { logout, canExport, isAdmin } = useAuth()
const [pinOpen, setPinOpen] = useState(false)
const [newPin, setNewPin] = useState('')
const [pinMsg, setPinMsg] = useState(null)
@ -131,8 +52,19 @@ export default function SettingsPage() {
setTimeout(()=>setPinMsg(null), 2000)
} catch(e) { setPinMsg('Fehler beim Speichern') }
}
// editingId: string ID of profile being edited, or 'new' for new profile, or null
const [editingId, setEditingId] = useState(null)
const [form, setForm] = useState({
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 [importing, setImporting] = useState(false)
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) => {
// Issue #31: Update global quality filter
await api.updateActiveProfile({ quality_filter_level: level })
await refreshProfiles()
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)
setTimeout(() => setSaved(false), 2000)
}
const handleSave = async (form, profileId) => {
const data = {}
if (form.name) data.name = form.name
if (form.sex) data.sex = form.sex
if (form.dob) data.dob = form.dob
if (form.height) data.height = parseFloat(form.height)
if (form.avatar_color) data.avatar_color = form.avatar_color
if (form.goal_weight) data.goal_weight = parseFloat(form.goal_weight)
if (form.goal_bf_pct) data.goal_bf_pct = parseFloat(form.goal_bf_pct)
if (profileId === 'new') {
const p = await api.createProfile({ ...data, name: form.name || 'Neues Profil' })
await refreshProfiles()
// Don't auto-switch just close the form
} else {
await api.updateProfile(profileId, data)
await refreshProfiles()
// If editing active profile, update it
if (profileId === activeProfile?.id) {
const updated = profiles.find(p => p.id === profileId)
if (updated) setActiveProfile({...updated, ...data})
const handleSaveMyProfile = async () => {
if (!activeProfile) return
const name = form.name.trim()
if (!name) {
setProfileErr('Bitte einen Namen eingeben.')
return
}
const h = parseFloat(form.height)
if (!form.height || Number.isNaN(h) || h < 100 || h > 250) {
setProfileErr('Bitte eine gültige Größe (100250 cm) eingeben.')
return
}
let goal_weight = null
if (form.goal_weight !== '') {
goal_weight = parseFloat(form.goal_weight)
if (Number.isNaN(goal_weight)) {
setProfileErr('Zielgewicht: bitte eine gültige Zahl eingeben oder leer lassen.')
return
}
}
setEditingId(null)
setSaved(true)
setTimeout(() => setSaved(false), 2000)
}
const handleDelete = async (id) => {
if (!confirm('Profil und ALLE zugehörigen Daten unwiderruflich löschen?')) return
await api.deleteProfile(id)
await refreshProfiles()
if (activeProfile?.id === id) {
const remaining = profiles.filter(p => p.id !== id)
if (remaining.length) setActiveProfile(remaining[0])
let goal_bf_pct = null
if (form.goal_bf_pct !== '') {
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.')
return
}
}
setProfileErr(null)
try {
const payload = {
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 () => {
@ -272,69 +229,203 @@ export default function SettingsPage() {
<div>
<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-title">Profile ({profiles.length})</div>
{profiles.map(p => (
<div key={p.id}>
<div style={{display:'flex',alignItems:'center',gap:10,padding:'10px 0',
borderBottom:'1px solid var(--border)'}}>
<Avatar profile={p} size={40}/>
<div style={{flex:1}}>
<div style={{fontSize:14,fontWeight:600}}>{p.name}</div>
<div style={{fontSize:11,color:'var(--text3)'}}>
{p.sex==='m'?'Männlich':'Weiblich'}
{p.height ? ` · ${p.height} cm` : ''}
{p.goal_weight ? ` · Ziel: ${p.goal_weight} kg` : ''}
</div>
</div>
<div style={{display:'flex',gap:6,alignItems:'center'}}>
{activeProfile?.id === p.id
? <span style={{fontSize:11,color:'var(--accent)',fontWeight:600,padding:'3px 8px',
background:'var(--accent-light)',borderRadius:6}}>Aktiv</span>
: <button className="btn btn-secondary" style={{padding:'4px 10px',fontSize:12}}
onClick={handleLogout}>
Nutzer wechseln
</button>
}
<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 className="card-title" style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<Avatar profile={{ ...form, name: form.name || '?' }} size={40} />
Mein Profil
</div>
<p style={{ fontSize: 13, color: 'var(--text2)', marginBottom: 14, lineHeight: 1.6 }}>
Hier bearbeitest du nur das <strong>aktive Profil</strong>. Zum Anlegen weiterer Profile oder zum
Verwalten anderer Nutzer nutzt du den Admin-Bereich (Zugriff nur als Administrator).
</p>
{isAdmin && (
<div
style={{
fontSize: 12,
color: 'var(--accent-dark)',
background: 'var(--accent-light)',
padding: '10px 12px',
borderRadius: 8,
marginBottom: 14,
lineHeight: 1.5,
}}
>
Admin:{' '}
<Link to="/admin/g/users" style={{ fontWeight: 600, color: 'var(--accent-dark)' }}>
Benutzerverwaltung
</Link>
</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>
{/* Auth actions */}
@ -375,22 +466,6 @@ export default function SettingsPage() {
<FeatureUsageOverview />
</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 */}
<div className="card section-gap">
<div className="card-title">Daten exportieren</div>

View File

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

View File

@ -1064,7 +1064,7 @@ export default function VitalsPage() {
}
return (
<div>
<div className="capture-page">
<h1 className="page-title">Vitalwerte</h1>
<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
return (
<div>
<div className="capture-page">
<h1 className="page-title">Gewicht</h1>
{/* 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)
listAdminPrompts: () => req('/prompts'),
getPrompt: (id) => req(`/prompts/${id}`),
createPrompt: (d) => req('/prompts', json(d)),
updatePrompt: (id,d) => req(`/prompts/${id}`, jput(d)),
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)