Merge pull request 'Responsive Gui - partially Workflow' (#61) from develop into main
Reviewed-on: #61
This commit is contained in:
commit
9fb04af7df
|
|
@ -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
3
.gitignore
vendored
|
|
@ -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
396
backend/join_evaluator.py
Normal 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
268
backend/logic_evaluator.py
Normal 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})"
|
||||
|
|
@ -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():
|
||||
|
|
|
|||
35
backend/migrations/016_workflows_graph_data.sql
Normal file
35
backend/migrations/016_workflows_graph_data.sql
Normal 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
|
||||
132
backend/migrations/034_workflow_foundation.sql
Normal file
132
backend/migrations/034_workflow_foundation.sql
Normal 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.';
|
||||
|
|
@ -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) ─────────────────────────────────────
|
||||
|
|
|
|||
237
backend/normalization_engine.py
Normal file
237
backend/normalization_engine.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
289
backend/question_augmenter.py
Normal file
289
backend/question_augmenter.py
Normal 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
|
||||
271
backend/result_container_parser.py
Normal file
271
backend/result_container_parser.py
Normal 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
|
||||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
235
backend/routers/workflow_questions.py
Normal file
235
backend/routers/workflow_questions.py
Normal 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}
|
||||
222
backend/routers/workflows.py
Normal file
222
backend/routers/workflows.py
Normal 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)
|
||||
244
backend/tests/test_end_node_template.py
Normal file
244
backend/tests/test_end_node_template.py
Normal 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
124
backend/version.py
Normal 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
443
backend/workflow_engine.py
Normal 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
|
||||
900
backend/workflow_executor.py
Normal file
900
backend/workflow_executor.py
Normal 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
358
backend/workflow_models.py
Normal 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")
|
||||
264
docs/issues/PHASE_PLAN_RESPONSIVE_UI.md
Normal file
264
docs/issues/PHASE_PLAN_RESPONSIVE_UI.md
Normal 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 `240–280px` Tab-Spalte + flex 1 Chart-Bereich (feinjustierbar).
|
||||
|
||||
### Abnahmekriterien
|
||||
- Alle bisherigen Verlauf-**Tabs** funktionieren (Gewicht, KF, Umfänge, … – wie im aktuellen Code).
|
||||
- Mobile UX **unverändert** vom Nutzergefühl her (Tabs oben).
|
||||
- Desktop: klar **zwei Spalten**; Chart nutzt rechte Fläche.
|
||||
|
||||
### Tests (P4)
|
||||
| Test | Schritt | Erwartung |
|
||||
|------|---------|-----------|
|
||||
| P4-T1 | T-H2: Tab wechseln | Wie bisher |
|
||||
| P4-T2 | T-H1: jeder Tab | Linke Liste + rechter Chart-Bereich sichtbar |
|
||||
| P4-T3 | Langes Tab-Label / viele Tabs | Kein Layout-Bruch (Scroll in Tab-Spalte falls nötig) |
|
||||
|
||||
---
|
||||
|
||||
## Phase P5 – Analyse (`Analysis.jsx`)
|
||||
|
||||
### Ziel
|
||||
Spec **§5.3**: Desktop **Prompt-Liste links (~300px)**, Ergebnis **rechts**; Mobile untereinander.
|
||||
|
||||
### Aufgaben
|
||||
- [ ] Layout splitten; Pipeline/Prompt-Bereiche so umbauen, dass Lesbarkeit und Scroll-Verhalten stimmen.
|
||||
|
||||
### Abnahmekriterien
|
||||
- KI-Ausführung, Platzhalter-Tabelle, Experten-Modus: weiter bedienbar.
|
||||
- Desktop: Ergebnisbereich hat **deutlich mehr** Breite als Mobile.
|
||||
|
||||
### Tests (P5)
|
||||
| Test | Schritt | Erwartung |
|
||||
|------|---------|-----------|
|
||||
| P5-T1 | T-H2: Analyse ausführen | Flow wie bisher |
|
||||
| P5-T2 | T-H1: langes Ergebnis | Rechte Spalte scrollbar, linke Prompt-Liste fix oder eigen-scroll |
|
||||
| P5-T3 | Kleines Fenster knapp unter 1024px | Kein „mittendrin“ kaputtes Layout |
|
||||
|
||||
---
|
||||
|
||||
## Phase P6 – Erfassung / Capture & Formularseiten
|
||||
|
||||
### Ziel
|
||||
Spec **§5.4**: Desktop Formulare **zentriert**, **max-width ~600px** im Content-Bereich; Mobile volle Breite wie heute.
|
||||
|
||||
### Aufgaben
|
||||
- [ ] `CaptureHub` und relevante Seiten (`WeightScreen`, …) unter Desktop-Wrapper; wo sinnvoll **gemeinsame** Klasse `.capture-form-desktop` in `app.css`.
|
||||
|
||||
### Abnahmekriterien
|
||||
- Mobile: keine Verschlechterung der Eingabe.
|
||||
- Desktop: Formulare nicht „volle 1200px“, sondern **lesbar begrenzt**.
|
||||
|
||||
### Tests (P6)
|
||||
| Test | Schritt | Erwartung |
|
||||
|------|---------|-----------|
|
||||
| P6-T1 | T-H2: Gewicht erfassen | Vollbreite ok |
|
||||
| P6-T2 | T-H1: gleiche Seite | Schmale, zentrierte Spalte |
|
||||
| P6-T3 | Capture-Hub Kacheln | Umbruch auf Desktop ordentlich |
|
||||
|
||||
---
|
||||
|
||||
## Phase P7 – Admin & übrige Seiten
|
||||
|
||||
### Ziel
|
||||
Spec **§5.5**: Admin-Tabellen nutzen auf Desktop **mehr Breite**; Mobile weiterhin horizontales Scrollen wo nötig.
|
||||
|
||||
### Aufgaben
|
||||
- [ ] `Admin*Page.jsx` und ähnliche Tabellen-Seiten: unnötige `max-width`-Erbe von Mobile entfernen; **nicht** jede Admin-Seite einzeln zerstören – priorisiert häufig genutzte.
|
||||
|
||||
### Abnahmekriterien
|
||||
- Keine regressiven Auth-Schutz-Änderungen.
|
||||
- Desktop: **mehr sichtbare Spalten** bei typischen Admin-Tabellen.
|
||||
|
||||
### Tests (P7)
|
||||
| Test | Schritt | Erwartung |
|
||||
|------|---------|-----------|
|
||||
| P7-T1 | T-H1: z. B. Admin Training Types | Tabelle nutzt Breite; horizontales Scrollen nur bei Bedarf |
|
||||
| P7-T2 | T-H2: gleiche Seite | Weiter bedienbar mit Scroll |
|
||||
|
||||
---
|
||||
|
||||
## Phase P8 – Abschluss, Regression, Dokumentation
|
||||
|
||||
### Ziel
|
||||
Release-tauglich machen; Spec und Issue aktualisieren.
|
||||
|
||||
### Aufgaben
|
||||
- [ ] Alle Phasen P1–P7 in Fortschrittstabelle auf ☑ setzen.
|
||||
- [ ] Vollständiger **Regression-Pass** (Routenliste aus `App.jsx`).
|
||||
- [ ] Gitea #30: Abschlusskommentar mit **Screenshots** (optional) + Hinweis auf diesen Plan.
|
||||
- [ ] `REVIEW_OPEN_ISSUES_2026-04-04.md` oder Nachfolger: #30 auf DONE setzen, wenn aus eurer Sicht erledigt.
|
||||
|
||||
### Abnahmekriterien (Gesamt gem. #30 / Spec)
|
||||
- [ ] Desktop **≥1024px**: Sidebar links, Bottom-Nav aus.
|
||||
- [ ] Mobile **<1024px**: Bottom-Nav unten, Sidebar aus.
|
||||
- [ ] Aktive Route in beiden Navs korrekt.
|
||||
- [ ] Dashboard / Verlauf / Analyse / Erfassung / Admin gemäß Spec umgesetzt.
|
||||
- [ ] Resize ohne JavaScript-Zwang stabil (CSS-first).
|
||||
- [ ] **PWA iPhone** weiterhin funktionsfähig (Kernflows).
|
||||
|
||||
### Tests (P8 – Smoke-Checkliste)
|
||||
| Route | T-H2 | T-H1 |
|
||||
|-------|------|------|
|
||||
| `/` | ☐ | ☐ |
|
||||
| `/capture` | ☐ | ☐ |
|
||||
| `/history` | ☐ | ☐ |
|
||||
| `/analysis` | ☐ | ☐ |
|
||||
| `/settings` | ☐ | ☐ |
|
||||
| 1× Admin (falls verfügbar) | ☐ | ☐ |
|
||||
| T-H4 PWA kurz | ☐ | n/a |
|
||||
|
||||
---
|
||||
|
||||
## Parallelität (Canvas / Workflow)
|
||||
|
||||
- Während **P1–P2** möglichst **keine** parallelenden Änderungen an `App.jsx` / globalem `app.css` ohne Absprache.
|
||||
- Neue **Workflow-/Canvas-Routen**: nur innerhalb von `<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.
|
||||
265
docs/issues/REVIEW_OPEN_ISSUES_2026-04-04.md
Normal file
265
docs/issues/REVIEW_OPEN_ISSUES_2026-04-04.md
Normal 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 |
|
||||
| 56–58 | Body Cluster Restarbeiten | DUPLIKAT | Dreimal gleicher Inhalt → ein Issue |
|
||||
|
||||
---
|
||||
|
||||
## Details je Issue (älteste zuerst)
|
||||
|
||||
### #14 – [FEAT-001] Icon Picker für Trainingstypen
|
||||
|
||||
**Code-Stand:** `AdminTrainingTypesPage.jsx` nutzt ein **Textfeld** `icon` (frei, z. B. Emoji). Kein dedizierter Icon-Picker (Palette, Vorschau, Kategorien).
|
||||
|
||||
**Vorschlag:** `OFFEN` – Issue beibehalten; optional präzisieren: „Emoji-Picker oder vordefinierte Icon-Liste statt Freitext“.
|
||||
|
||||
**Kommentar-Entwurf für Gitea:**
|
||||
> Stand Backend/Frontend: `icon` wird als String in `training_types` gespeichert, Eingabe ist Freitext. Icon-Picker-UX steht noch aus.
|
||||
|
||||
---
|
||||
|
||||
### #15 – [FEAT-002] Quality-Filter für KI-Auswertungen & Charts
|
||||
|
||||
**Code-Stand:** Charts/Data-Layer nutzen **Confidence** u. a.; Placeholder-Registry hat viele `quality_filter_policy` / Evidence-Felder (teilweise `UNRESOLVED`/`TO_VERIFY`). Ein **einheitlicher** „Quality-Filter“-Mechanismus über alle KI-Auswertungen ist nicht eindeutig als fertiges Feature erkennbar.
|
||||
|
||||
**Vorschlag:** `TEILWEISE` – Issue-Text auf konkrete Lücken schärfen (welche Prompts/Charts, welche Schwellen, SSoT Registry?).
|
||||
|
||||
**Kommentar-Entwurf:**
|
||||
> Teilaspekte existieren (z. B. Confidence in Chart-Endpoints, Registry-Felder). Offen: durchgängige KI-/Chart-Quality-Pipeline und Abgleich mit Issue-Zielbild.
|
||||
|
||||
---
|
||||
|
||||
### #21 – [FEATURE] Universeller CSV-Parser mit lernbarem Feldmapping
|
||||
|
||||
**Code-Stand:** Modulspezifische CSV-Imports (Activity, Nutrition, Vitals, Sleep, …) mit jeweils eigenem Parser; **lernbares Mapping** stark bei **Activity** über `activity_type_mappings`. Kein **ein** generischer CSV-Engine wie im Issue beschrieben.
|
||||
|
||||
**Vorschlag:** `OFFEN` – oder Scope reduzieren („pro Modul konsolidieren“).
|
||||
|
||||
---
|
||||
|
||||
### #25 – [FEAT] Ziele-System (Goals) v9e Kernfeature
|
||||
|
||||
**Code-Stand:** `goals`-Router, `GoalsPage`, Focus Areas, Migrationen – laut Projektstand **weitgehend implementiert**.
|
||||
|
||||
**Vorschlag:** `DONE?` nach deiner Abnahme – Issue-Body auf verbleibende Teilziele kürzen oder schließen.
|
||||
|
||||
**Kommentar-Entwurf:**
|
||||
> Backend/Frontend Goals + Focus Areas sind im Repo vorhanden. Bitte verbleibende Wünsche als neue Sub-Issues oder AC hier abhaken und schließen.
|
||||
|
||||
---
|
||||
|
||||
### #26 – [FEAT] Charts & Visualisierungen erweitern
|
||||
|
||||
**Code-Stand:** `backend/routers/charts.py` (Phase 0c), viele `api.get…Chart` in `api.js`; `History.jsx` + `NutritionCharts` / `RecoveryCharts` nutzen Chart-Daten.
|
||||
|
||||
**Vorschlag:** `TEILWEISE` – Issue auf konkrete fehlende Chart-Typen/UI-Verdrahtung schärfen (falls noch offen).
|
||||
|
||||
---
|
||||
|
||||
### #27 – [FEAT] Korrelationen & Insights erweitern
|
||||
|
||||
**Code-Stand:** Chart-Endpunkte C1–C4 u. a.; Data-Layer `correlations.py` mit TODO-Stellen in Teilen.
|
||||
|
||||
**Vorschlag:** `TEILWEISE` – Liste fehlender Korrelationen/Insights vs. Code ergänzen.
|
||||
|
||||
---
|
||||
|
||||
### #29 – [FEAT] Abilities-Matrix UI (v9f)
|
||||
|
||||
**Code-Stand:** Training Types mit `abilities` JSONB, `AdminTrainingProfiles`, `ProfileBuilder` – vollständige „5D Matrix“-UX unklar ohne Produktvorgabe.
|
||||
|
||||
**Vorschlag:** `TEILWEISE` – AC mit aktuellen Screenshots/Flows abgleichen.
|
||||
|
||||
---
|
||||
|
||||
### #30 – [FEAT] Responsive UI – Desktop Sidebar + 2-spaltig
|
||||
|
||||
**Code-Stand:** Weiterhin stark **Mobile-first** (z. B. `bottom-nav` in `App.jsx`); keine ausgebaute Desktop-Sidebar wie im klassischen Admin-Dashboard.
|
||||
|
||||
**Umsetzungsplan:** `docs/issues/PHASE_PLAN_RESPONSIVE_UI.md` (Phasen P0–P8, Abnahmekriterien & Tests, Fortschrittstabelle).
|
||||
|
||||
**Vorschlag:** `OFFEN`.
|
||||
|
||||
---
|
||||
|
||||
### #32 – Version-System (inkl. ehem. #33)
|
||||
|
||||
**Gitea (2026-04-04):** #33 geschlossen; Inhalt in **#32** zusammengeführt (ein Epic: `/api/version`, `version.js`, Settings, `main.py`-Konsistenz). Automatische Git-/Build-Identität über den Runner: **zurückgestellt**, geplant als **separates Issue** nach der Basis.
|
||||
|
||||
**Code-Stand:** `backend/version.py` vorhanden; **`GET /api/version`** fehlt; `main.py`: Root `v9c-dev`, `FastAPI(..., version="3.0.0")`.
|
||||
|
||||
**Vorschlag:** `OFFEN` – Umsetzung laut aktualisiertem Gitea-Issue #32.
|
||||
|
||||
---
|
||||
|
||||
### #34 – External Volumes dokumentieren (Legacy bodytrack_*)
|
||||
|
||||
**Vorschlag:** `PRÜFEN` – gegen aktuelle `docker-compose`/Deploy-Doku im Repo halten; dann schließen oder Aktualisierung kommentieren.
|
||||
|
||||
---
|
||||
|
||||
### #35 – Deprecated Tabelle `subscriptions` entfernen
|
||||
|
||||
**Code-Stand:** Im Migrations-Ordner **kein** aktueller Treffer auf `subscriptions` (Stichprobe); Membership-System nutzt andere Tabellen.
|
||||
|
||||
**Vorschlag:** `PRÜFEN` – einmal `schema.sql` / DB prüfen, ob Tabelle noch existiert. Wenn weg: Issue schließen mit Verweis auf Migration.
|
||||
|
||||
---
|
||||
|
||||
### #36 – BUG-009: Trainingstyp-Erstellung → Internal Server Error
|
||||
|
||||
**Code-Stand:** `TrainingTypeCreate` enthält `abilities` / `profile` (JSONB). ISE oft durch DB-Constraint, NULL/JSON oder fehlende Spalte – **ohne Laufzeit-Log nicht verifiziert**.
|
||||
|
||||
**Vorschlag:** `PRÜFEN` – in Gitea Notiz: aktueller Request-Body + Stacktrace aus `docker logs`; wenn behoben: schließen.
|
||||
|
||||
---
|
||||
|
||||
### #37 – Feature-Enforcement Activity CSV-Import
|
||||
|
||||
**Code-Stand:** `create_activity` nutzt `check_feature_access` für `activity_entries`. **`import_activity_csv`** startet **ohne** vorgeschalteten Limit-Check (im gelesenen Abschnitt nur `get_pid` + Parse) – von Issue #37 noch **nicht** erfüllt.
|
||||
|
||||
**Vorschlag:** `OFFEN` – hoch priorisieren; analog Nutrition: ein Check vor Bulk-Import + Zählung.
|
||||
|
||||
---
|
||||
|
||||
### #38 – Feature-Enforcement Nutrition CSV-Import UI
|
||||
|
||||
**Code-Stand:** `import_nutrition_csv` ruft **`check_feature_access`** für `nutrition_entries` auf (inkl. Logging).
|
||||
|
||||
**Vorschlag:** `TEILWEISE` / `DONE?` – falls UI-Feedback gewünscht, im Issue auf konkrete UI-Lücken eingehen (Banner, Disable Button).
|
||||
|
||||
---
|
||||
|
||||
### #39 – Usage-Badges im Dashboard-Assistenten
|
||||
|
||||
**Code-Stand:** `Dashboard.jsx` nutzt `getFeatureUsage()` für **Gewichts-Widget** (Limit/Lock). Unklar ob „Assistent“-Modus = gesamtes Dashboard oder separater Guide.
|
||||
|
||||
**Vorschlag:** `TEILWEISE` – Issue präzisieren, welche Kacheln/Bereiche Badges brauchen.
|
||||
|
||||
---
|
||||
|
||||
### #40 – Logout-Button im App-Header (neben Avatar)
|
||||
|
||||
**Code-Stand:** `App.jsx` – Header mit **`LogOut` neben Avatar** (Umsetzung vorhanden).
|
||||
|
||||
**Vorschlag:** `DONE?` – nach kurzem Klicktest **schließen**.
|
||||
|
||||
**Kommentar-Entwurf:**
|
||||
> In `App.jsx` ist ein Logout-Button im Header umgesetzt. Bitte in target Umgebung verifizieren und schließen.
|
||||
|
||||
---
|
||||
|
||||
### #42 / #43 – Enhanced Debug / Prompt Analysis UI (Issue #28 Phase C)
|
||||
|
||||
**Code-Stand:** `Analysis.jsx` mit Expert-Modus, Platzhalter-Gruppierung – kann Teile von Phase C abdecken.
|
||||
|
||||
**Vorschlag:** `DUPLIKAT` – **ein** Issue behalten; anderes schließen mit Verweis. Inhalte zusammenführen.
|
||||
|
||||
---
|
||||
|
||||
### #45 – KI Prompt-Optimierer
|
||||
|
||||
**Vorschlag:** `OFFEN` – Backlog, nicht im aktuellen Code sichtbar.
|
||||
|
||||
---
|
||||
|
||||
### #46 – KI Prompt-Ersteller
|
||||
|
||||
**Vorschlag:** `OFFEN` – wie #45.
|
||||
|
||||
---
|
||||
|
||||
### #47 – Wertetabelle Optimierung
|
||||
|
||||
**Code-Stand:** Wertetabelle in `Analysis.jsx` / Metadata – viele Punkte eher UX/Performance.
|
||||
|
||||
**Vorschlag:** `OFFEN` – konkrete UI-Schmerzpunkte in Sub-Tasks splitten.
|
||||
|
||||
---
|
||||
|
||||
### #49 – Prompt-Zuordnung zu Verlaufsseiten
|
||||
|
||||
**Code-Stand:** Kein eindeutiger Treffer zu „History page prompt assignment“ in kurzer Suche.
|
||||
|
||||
**Vorschlag:** `OFFEN` – kurz präzisieren (Welche Seite, welches Datenmodell).
|
||||
|
||||
---
|
||||
|
||||
### #54 / #55 – Placeholder Registry UNRESOLVED & TO_VERIFY
|
||||
|
||||
**Code-Stand:** `placeholder_registry_export.py` liefert **`validation_report` über `registry.validate_all()`** (nicht mehr leer aus dem frühen Issue-Text für Body-Cluster „{}“-Teil). Evidence `TO_VERIFY`/`UNRESOLVED` existieren weiter in Registrations.
|
||||
|
||||
**Vorschlag:** `#54` und `#55` **zusammenlegen** (gleiches Thema, Titel nur Encoding-Unterschied). Ein Issue offen lassen, Metadaten-Audit fortsetzen.
|
||||
|
||||
---
|
||||
|
||||
### #56 / #57 / #58 – Body Cluster Restarbeiten & Metadaten-Verifizierung
|
||||
|
||||
**Inhalt:** identische bzw. nahezu identische Langbeschreibung (Metadaten, Layer 2b, Nutrition confidence_logic, Validation Report).
|
||||
|
||||
**Vorschlag:** `DUPLIKAT` – **eines** behalten (z. B. niedrigste Nummer oder neueste #58 mit aktualisiertem Stand), andere **schließen** mit Verweis „Duplicate of #X“. Validation-Teil: Code hat bereits `validate_all` – Issue-Text Abschnitt „leeres validation_report“ **aktualisieren**.
|
||||
|
||||
**Kommentar-Entwurf:**
|
||||
> Dreimal dasselbe Issue. Vorschlag: #56/#57 schließen, Tracking nur in #XX. `validation_report` wird aus `registry.validate_all()` befüllt; verbleibende Arbeit: TO_VERIFY-Felder Layer 2b + Nutrition confidence_logic laut Checkliste.
|
||||
|
||||
---
|
||||
|
||||
## Nachbearbeitung in Gitea (Checkliste für dich)
|
||||
|
||||
- [ ] Duplikate schließen und verlinken (#42/#43, #54/#55, #56–#58).
|
||||
- [ ] „DONE?“-Issues manuell testen (`#25`, `#38`, `#40`).
|
||||
- [ ] `#37` umsetzen oder Kommentar „noch offen“ bestätigen.
|
||||
- [x] `#32`–`#33` in Gitea zusammengeführt (#33 geschlossen); Umsetzung weiter über #32.
|
||||
- [ ] Kommentare aus diesem Dokument kopieren/anpassen.
|
||||
- [ ] Optional: Labels in Gitea setzen (`duplicate`, `blocked`, `needs-retest`).
|
||||
|
||||
---
|
||||
|
||||
## Technischer Hinweis (Audit / Security)
|
||||
|
||||
Aus dem Code-Audit 2026-04-04: kritische Punkte (`get_pid` / `X-Profile-Id`, `/api/profiles` ohne Admin) sind **nicht** 1:1 als Gitea-Issues in dieser offenen Liste sichtbar – ggf. **separate** Security-Issues aus `.claude/docs/audit/20260404_code_audit/gitea/` anlegen, falls noch nicht vorhanden.
|
||||
|
||||
---
|
||||
|
||||
*Erzeugt aus Gitea API (28 offene Issues, sortiert nach `created_at`) und statischer Code-Analyse im Workspace. Kein Laufzeit-Test auf dem Pi.*
|
||||
427
frontend/package-lock.json
generated
427
frontend/package-lock.json
generated
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
31
frontend/src/components/DashboardSection.jsx
Normal file
31
frontend/src/components/DashboardSection.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
29
frontend/src/components/DashboardTile.jsx
Normal file
29
frontend/src/components/DashboardTile.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
86
frontend/src/components/DesktopSidebar.jsx
Normal file
86
frontend/src/components/DesktopSidebar.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
78
frontend/src/components/EmailSettings.jsx
Normal file
78
frontend/src/components/EmailSettings.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
422
frontend/src/components/EmojiIconPicker.jsx
Normal file
422
frontend/src/components/EmojiIconPicker.jsx
Normal 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. B. Win + .).
|
||||
</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. 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>
|
||||
)
|
||||
}
|
||||
529
frontend/src/components/emojiIconPickerKeywords.js
Normal file
529
frontend/src/components/emojiIconPickerKeywords.js
Normal 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))
|
||||
}
|
||||
71
frontend/src/components/workflow/WorkflowCanvas.jsx
Normal file
71
frontend/src/components/workflow/WorkflowCanvas.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
51
frontend/src/components/workflow/nodes/AnalysisNode.jsx
Normal file
51
frontend/src/components/workflow/nodes/AnalysisNode.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
25
frontend/src/components/workflow/nodes/EndNode.jsx
Normal file
25
frontend/src/components/workflow/nodes/EndNode.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
74
frontend/src/components/workflow/nodes/JoinNode.jsx
Normal file
74
frontend/src/components/workflow/nodes/JoinNode.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
68
frontend/src/components/workflow/nodes/LogicNode.jsx
Normal file
68
frontend/src/components/workflow/nodes/LogicNode.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
25
frontend/src/components/workflow/nodes/StartNode.jsx
Normal file
25
frontend/src/components/workflow/nodes/StartNode.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
77
frontend/src/components/workflow/panels/FallbackConfig.jsx
Normal file
77
frontend/src/components/workflow/panels/FallbackConfig.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
67
frontend/src/components/workflow/panels/JoinConfig.jsx
Normal file
67
frontend/src/components/workflow/panels/JoinConfig.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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">></option>
|
||||
<option value="lt"><</option>
|
||||
<option value="gte">>=</option>
|
||||
<option value="lte"><=</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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
154
frontend/src/config/adminNav.js
Normal file
154
frontend/src/config/adminNav.js
Normal 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
|
||||
}
|
||||
43
frontend/src/config/appNav.js
Normal file
43
frontend/src/config/appNav.js
Normal 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
|
||||
}
|
||||
103
frontend/src/config/captureNav.js
Normal file
103
frontend/src/config/captureNav.js
Normal 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)
|
||||
}
|
||||
51
frontend/src/layouts/AdminShell.jsx
Normal file
51
frontend/src/layouts/AdminShell.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
36
frontend/src/layouts/CaptureShell.jsx
Normal file
36
frontend/src/layouts/CaptureShell.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
11
frontend/src/layouts/RequireAdmin.jsx
Normal file
11
frontend/src/layouts/RequireAdmin.jsx
Normal 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 />
|
||||
}
|
||||
|
|
@ -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'}}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
57
frontend/src/pages/AdminGroupHubPage.jsx
Normal file
57
frontend/src/pages/AdminGroupHubPage.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
41
frontend/src/pages/AdminHomePage.jsx
Normal file
41
frontend/src/pages/AdminHomePage.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
65
frontend/src/pages/AdminSystemPage.jsx
Normal file
65
frontend/src/pages/AdminSystemPage.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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')}>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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')}>
|
||||
|
|
|
|||
|
|
@ -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%)',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -177,7 +177,7 @@ export default function RestDaysPage() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="capture-page">
|
||||
<h1 className="page-title">Ruhetage</h1>
|
||||
|
||||
{/* Toast Notification */}
|
||||
|
|
|
|||
|
|
@ -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 (100–250 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>
|
||||
|
|
|
|||
|
|
@ -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={{
|
||||
|
|
|
|||
|
|
@ -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' }}>
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
503
frontend/src/pages/WorkflowEditorPage.jsx
Normal file
503
frontend/src/pages/WorkflowEditorPage.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
471
frontend/src/styles/workflowEditor.css
Normal file
471
frontend/src/styles/workflowEditor.css
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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'}),
|
||||
|
|
|
|||
26
frontend/src/utils/dashboardLayout.js
Normal file
26
frontend/src/utils/dashboardLayout.js
Normal 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
|
||||
}
|
||||
118
frontend/src/utils/workflowSerializer.js
Normal file
118
frontend/src/utils/workflowSerializer.js
Normal 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 || {}
|
||||
}
|
||||
}
|
||||
226
frontend/src/utils/workflowValidation.js
Normal file
226
frontend/src/utils/workflowValidation.js
Normal 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
|
||||
}
|
||||
64
scripts/gitea/MCP_SETUP.md
Normal file
64
scripts/gitea/MCP_SETUP.md
Normal 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
68
scripts/gitea/README.md
Normal 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
183
scripts/gitea/gitea_api.py
Normal 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
226
scripts/gitea/gitea_lib.py
Normal 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
|
||||
127
scripts/gitea/mcp_server_gitea.py
Normal file
127
scripts/gitea/mcp_server_gitea.py
Normal 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()
|
||||
2
scripts/gitea/requirements-mcp.txt
Normal file
2
scripts/gitea/requirements-mcp.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# Nur für MCP-Server (nicht im Backend-Container nötig)
|
||||
mcp>=1.2.0
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
{
|
||||
"status": "failed",
|
||||
"failedTests": [
|
||||
"d6ae548bbe32e0652471-816c0db33a38f27f1eaf"
|
||||
]
|
||||
}
|
||||
135
tests/backend/test_phase1_question_augmenter.py
Normal file
135
tests/backend/test_phase1_question_augmenter.py
Normal 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"])
|
||||
234
tests/backend/test_phase1_result_container_parser.py
Normal file
234
tests/backend/test_phase1_result_container_parser.py
Normal 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"])
|
||||
229
tests/backend/test_phase2_normalization.py
Normal file
229
tests/backend/test_phase2_normalization.py
Normal 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"])
|
||||
401
tests/backend/test_phase2_workflow_executor.py
Normal file
401
tests/backend/test_phase2_workflow_executor.py
Normal 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"])
|
||||
720
tests/backend/test_phase3_logic_evaluator.py
Normal file
720
tests/backend/test_phase3_logic_evaluator.py
Normal 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"])
|
||||
459
tests/backend/test_phase3_workflow_branching.py
Normal file
459
tests/backend/test_phase3_workflow_branching.py
Normal 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"])
|
||||
511
tests/backend/test_phase4_join_nodes.py
Normal file
511
tests/backend/test_phase4_join_nodes.py
Normal 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
|
||||
413
tests/backend/test_workflow_engine.py
Normal file
413
tests/backend/test_workflow_engine.py
Normal 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
94
tests/phase3_e2e_test.sql
Normal 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
123
tests/phase4_e2e_test.sql
Normal 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
|
||||
58
tests/test_join_deployed.py
Normal file
58
tests/test_join_deployed.py
Normal 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!")
|
||||
105
tests/test_join_integration.py
Normal file
105
tests/test_join_integration.py
Normal 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)
|
||||
Loading…
Reference in New Issue
Block a user