Backend:
- logic_evaluator.py (NEU, 307 Zeilen): Deterministischer Logic Evaluator
- Vergleichsoperatoren: EQ, NEQ, IN, NOT_IN, GT, LT, GTE, LTE, CONTAINS
- Logische Operatoren: AND, OR, NOT mit Verschachtelung
- Resolve signal references (node_id.question_type)
- Error handling für UNCLEAR/INVALID/NOT_DECIDABLE Signale
- workflow_executor.py (ERWEITERT):
- execute_logic_node(): Bedingungen evaluieren, Pfade aktivieren/deaktivieren
- execute_workflow(): BFS-Traversierung mit Edge-Activation statt Sequential
- _apply_fallback(): 4 Fallback-Strategien (CONSERVATIVE_SKIP, DEFAULT_PATH, UNCERTAINTY_PATH, DOCUMENT_ONLY)
- _has_active_incoming_edge(): Prüft ob Node erreichbar ist
- _get_edges_by_label(): Findet then/else/uncertainty Pfade
- workflow_models.py (ERWEITERT):
- LogicOperator.CONTAINS hinzugefügt
- version.py: 0.9k → 0.9l, workflow 0.3.0 → 0.4.0
Tests:
- test_phase3_logic_evaluator.py (NEU): 20 Unit Tests (alle passing)
- Comparison operators (EQ, NEQ, IN, GT, LT, CONTAINS)
- Logical operators (AND, OR, NOT)
- Nested expressions
- Error handling (missing refs, UNCLEAR/INVALID signals)
- test_phase2_workflow_executor.py (AKTUALISIERT): 11 Tests (alle passing)
- execute_node() graph parameter hinzugefügt (Phase 3 requirement)
- test_execute_node_unknown_type: logic → join (logic jetzt implementiert)
- test_phase3_workflow_branching.py (NEU): Integration Tests vorbereitet
- Erfordert vollständige DB-Mock-Strategie (wird in E2E-Test nachgeholt)
Phase 2 Backward Compatibility: ✅ Alle Phase 2 Tests bestehen weiterhin
Konzept: .claude/task/Workflow_engine_prompting_engine/konzept_workflow_engine_konsolidated.md
Anforderungsanalyse: .claude/task/Workflow_engine_prompting_engine/phase3_anforderungsanalyse.md
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
269 lines
8.2 KiB
Python
269 lines
8.2 KiB
Python
"""
|
|
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})"
|