Backend:
- normalization_engine.py (200 Zeilen): Synonym-Mapping, 5 Statuswerte
* normalize_decision_signal(): Kaskade (exact → case → synonym → invalid)
* apply_synonym_mapping(): DB-basierte Synonyme (case-insensitive)
* normalize_all_signals(): Batch-Processing gegen Katalog
* load_question_catalog(): Lädt normalization_rules aus DB
- workflow_executor.py (440 Zeilen): Sequenzielle Workflow-Ausführung
* execute_workflow(): Traversiert DAG in topologischer Reihenfolge
* execute_node(): Führt analysis nodes aus (start/end = no-op)
* aggregate_results(): Kombiniert analysis_core + normalized_signals
* save_execution_state(): Persistiert in workflow_executions
- workflow_models.py: Erweitert um Phase 2 Models
* SignalStatus Enum (valid, normalized, unclear, invalid, not_decidable)
* NormalizedSignal (question_type, raw_value, normalized_value, status)
* NodeExecutionState (node_id, status, analysis_core, normalized_signals)
* ExecutionResult (execution_id, workflow_id, status, node_states, aggregated_result)
- workflow_engine.py: Neue Funktion get_execution_order()
* Flattened topological sort für sequenzielle Execution
* Phase 7: Wird zu levels (parallele Execution)
- prompt_executor.py: execute_workflow_prompt() Implementierung
* Ruft workflow_executor.execute_workflow() auf
* Konvertiert ExecutionResult zu API-Response
- routers/workflows.py (230 Zeilen): Workflow Execution API
* POST /api/workflows/{id}/execute (mit enable_debug)
* GET /api/workflows/executions/{id} (lädt gespeicherten State)
* GET /api/workflows (listet alle aktiven Workflows)
* GET /api/workflows/{id} (lädt einzelnen Workflow mit Graph)
- main.py: Router-Registrierung (workflows.router)
Tests:
- test_phase2_normalization.py (17 Tests): Alle Normalisierungs-Szenarien
* Exact match, case-insensitive, synonym mapping, invalid, whitespace
* Batch-Normalisierung, not_in_catalog, mixed validity
- test_phase2_workflow_executor.py (10 Tests): Executor + Aggregation
* aggregate_results mit verschiedenen Konstellationen
* execute_node für start/end/analysis/unknown
* Integration mit question_augmenter + result_container_parser
Alle 27 Unit-Tests bestanden.
version: 0.9k (backend)
module: workflow 0.3.0
Konzept: .claude/task/Workflow_engine_prompting_engine/anforderungsanalyse_umsetzungsplan.md (Phase 2)
230 lines
7.6 KiB
Python
230 lines
7.6 KiB
Python
"""
|
|
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"])
|