mitai-jinkendo/backend/logic_evaluator.py
Lars 2ce0874dcb
All checks were successful
Deploy Development / deploy (push) Successful in 50s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 14s
feat: Phase 3 - Logic Nodes + Conditional Branching
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>
2026-04-04 08:02:22 +02:00

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