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>
721 lines
21 KiB
Python
721 lines
21 KiB
Python
"""
|
|
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"])
|