mitai-jinkendo/tests/backend/test_phase2_normalization.py
Lars 1f8791f4dd
All checks were successful
Deploy Development / deploy (push) Successful in 44s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 14s
feat: Phase 2 - Normalisierung + Workflow Executor
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)
2026-04-03 21:20:23 +02:00

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