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