A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.
This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).
Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
502 lines
16 KiB
Python
502 lines
16 KiB
Python
"""
|
|
Tests für den Communication Service.
|
|
|
|
Testet die KI-gestützte Lehrer-Eltern-Kommunikation mit GFK-Prinzipien.
|
|
"""
|
|
|
|
import pytest
|
|
from unittest.mock import AsyncMock, patch, MagicMock
|
|
|
|
from llm_gateway.services.communication_service import (
|
|
CommunicationService,
|
|
CommunicationType,
|
|
CommunicationTone,
|
|
LegalReference,
|
|
GFKPrinciple,
|
|
get_communication_service,
|
|
fetch_legal_references_from_db,
|
|
parse_db_references_to_legal_refs,
|
|
FALLBACK_LEGAL_REFERENCES,
|
|
GFK_PRINCIPLES,
|
|
)
|
|
|
|
|
|
class TestCommunicationType:
|
|
"""Tests für CommunicationType Enum."""
|
|
|
|
def test_all_communication_types_exist(self):
|
|
"""Test alle erwarteten Kommunikationstypen existieren."""
|
|
expected_types = [
|
|
"general_info",
|
|
"behavior",
|
|
"academic",
|
|
"attendance",
|
|
"meeting_invite",
|
|
"positive_feedback",
|
|
"concern",
|
|
"conflict",
|
|
"special_needs",
|
|
]
|
|
actual_types = [ct.value for ct in CommunicationType]
|
|
assert set(expected_types) == set(actual_types)
|
|
|
|
def test_communication_type_is_string_enum(self):
|
|
"""Test CommunicationType ist String Enum."""
|
|
assert CommunicationType.BEHAVIOR == "behavior"
|
|
assert CommunicationType.ACADEMIC.value == "academic"
|
|
|
|
|
|
class TestCommunicationTone:
|
|
"""Tests für CommunicationTone Enum."""
|
|
|
|
def test_all_tones_exist(self):
|
|
"""Test alle Tonalitäten existieren."""
|
|
expected_tones = ["formal", "professional", "warm", "concerned", "appreciative"]
|
|
actual_tones = [t.value for t in CommunicationTone]
|
|
assert set(expected_tones) == set(actual_tones)
|
|
|
|
|
|
class TestLegalReference:
|
|
"""Tests für LegalReference Dataclass."""
|
|
|
|
def test_legal_reference_creation(self):
|
|
"""Test LegalReference erstellen."""
|
|
ref = LegalReference(
|
|
law="SchulG NRW",
|
|
paragraph="§ 42",
|
|
title="Pflichten der Eltern",
|
|
summary="Eltern unterstützen die Schule.",
|
|
relevance="Kooperationsaufforderungen",
|
|
)
|
|
assert ref.law == "SchulG NRW"
|
|
assert ref.paragraph == "§ 42"
|
|
assert "Eltern" in ref.title
|
|
|
|
|
|
class TestGFKPrinciple:
|
|
"""Tests für GFKPrinciple Dataclass."""
|
|
|
|
def test_gfk_principle_creation(self):
|
|
"""Test GFKPrinciple erstellen."""
|
|
principle = GFKPrinciple(
|
|
principle="Beobachtung",
|
|
description="Konkrete Handlungen beschreiben",
|
|
example="Ich habe bemerkt...",
|
|
)
|
|
assert principle.principle == "Beobachtung"
|
|
assert "beschreiben" in principle.description
|
|
|
|
|
|
class TestFallbackLegalReferences:
|
|
"""Tests für die Fallback-Referenzen."""
|
|
|
|
def test_default_references_exist(self):
|
|
"""Test DEFAULT Referenzen existieren."""
|
|
assert "DEFAULT" in FALLBACK_LEGAL_REFERENCES
|
|
assert "elternpflichten" in FALLBACK_LEGAL_REFERENCES["DEFAULT"]
|
|
assert "schulpflicht" in FALLBACK_LEGAL_REFERENCES["DEFAULT"]
|
|
|
|
def test_fallback_references_are_legal_reference(self):
|
|
"""Test Fallback Referenzen sind LegalReference Objekte."""
|
|
ref = FALLBACK_LEGAL_REFERENCES["DEFAULT"]["elternpflichten"]
|
|
assert isinstance(ref, LegalReference)
|
|
assert ref.law == "Landesschulgesetz"
|
|
|
|
|
|
class TestGFKPrinciples:
|
|
"""Tests für GFK-Prinzipien."""
|
|
|
|
def test_four_gfk_principles_exist(self):
|
|
"""Test alle 4 GFK-Prinzipien existieren."""
|
|
assert len(GFK_PRINCIPLES) == 4
|
|
principles = [p.principle for p in GFK_PRINCIPLES]
|
|
assert "Beobachtung" in principles
|
|
assert "Gefühle" in principles
|
|
assert "Bedürfnisse" in principles
|
|
assert "Bitten" in principles
|
|
|
|
|
|
class TestCommunicationService:
|
|
"""Tests für CommunicationService Klasse."""
|
|
|
|
def test_service_initialization(self):
|
|
"""Test Service wird korrekt initialisiert."""
|
|
service = CommunicationService()
|
|
assert service.fallback_references is not None
|
|
assert service.gfk_principles is not None
|
|
assert service.templates is not None
|
|
assert service._cached_references == {}
|
|
|
|
def test_get_legal_references_sync(self):
|
|
"""Test synchrone get_legal_references Methode (Fallback)."""
|
|
service = CommunicationService()
|
|
refs = service.get_legal_references("NRW", "elternpflichten")
|
|
|
|
assert len(refs) > 0
|
|
assert isinstance(refs[0], LegalReference)
|
|
|
|
def test_get_fallback_references(self):
|
|
"""Test _get_fallback_references Methode."""
|
|
service = CommunicationService()
|
|
refs = service._get_fallback_references("DEFAULT", "elternpflichten")
|
|
|
|
assert len(refs) == 1
|
|
assert refs[0].law == "Landesschulgesetz"
|
|
|
|
def test_get_gfk_guidance(self):
|
|
"""Test get_gfk_guidance gibt GFK-Prinzipien zurück."""
|
|
service = CommunicationService()
|
|
guidance = service.get_gfk_guidance(CommunicationType.BEHAVIOR)
|
|
|
|
assert len(guidance) == 4
|
|
assert all(isinstance(g, GFKPrinciple) for g in guidance)
|
|
|
|
def test_get_template(self):
|
|
"""Test get_template gibt Vorlage zurück."""
|
|
service = CommunicationService()
|
|
template = service.get_template(CommunicationType.MEETING_INVITE)
|
|
|
|
assert "subject" in template
|
|
assert "opening" in template
|
|
assert "closing" in template
|
|
assert "Einladung" in template["subject"]
|
|
|
|
def test_get_template_fallback(self):
|
|
"""Test get_template Fallback zu GENERAL_INFO."""
|
|
service = CommunicationService()
|
|
# Unbekannter Typ sollte auf GENERAL_INFO fallen
|
|
template = service.get_template(CommunicationType.GENERAL_INFO)
|
|
|
|
assert "Information" in template["subject"]
|
|
|
|
|
|
class TestCommunicationServiceAsync:
|
|
"""Async Tests für CommunicationService."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_legal_references_async_with_db(self):
|
|
"""Test async get_legal_references_async mit DB-Daten."""
|
|
service = CommunicationService()
|
|
|
|
# Mock die fetch_legal_references_from_db Funktion
|
|
mock_docs = [
|
|
{
|
|
"law_name": "SchulG NRW",
|
|
"title": "Schulgesetz NRW",
|
|
"paragraphs": [
|
|
{"nr": "§ 42", "title": "Pflichten der Eltern"},
|
|
{"nr": "§ 41", "title": "Schulpflicht"},
|
|
],
|
|
}
|
|
]
|
|
|
|
with patch(
|
|
"llm_gateway.services.communication_service.fetch_legal_references_from_db",
|
|
new_callable=AsyncMock,
|
|
) as mock_fetch:
|
|
mock_fetch.return_value = mock_docs
|
|
|
|
refs = await service.get_legal_references_async("NRW", "elternpflichten")
|
|
|
|
assert len(refs) > 0
|
|
mock_fetch.assert_called_once_with("NRW")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_legal_references_async_fallback(self):
|
|
"""Test async get_legal_references_async Fallback wenn DB leer."""
|
|
service = CommunicationService()
|
|
|
|
with patch(
|
|
"llm_gateway.services.communication_service.fetch_legal_references_from_db",
|
|
new_callable=AsyncMock,
|
|
) as mock_fetch:
|
|
mock_fetch.return_value = [] # Leere DB
|
|
|
|
refs = await service.get_legal_references_async("NRW", "elternpflichten")
|
|
|
|
# Sollte Fallback nutzen
|
|
assert len(refs) > 0
|
|
assert refs[0].law == "Landesschulgesetz"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_legal_references_async_caching(self):
|
|
"""Test dass Ergebnisse gecached werden."""
|
|
service = CommunicationService()
|
|
|
|
mock_docs = [
|
|
{
|
|
"law_name": "SchulG NRW",
|
|
"paragraphs": [{"nr": "§ 42", "title": "Pflichten der Eltern"}],
|
|
}
|
|
]
|
|
|
|
with patch(
|
|
"llm_gateway.services.communication_service.fetch_legal_references_from_db",
|
|
new_callable=AsyncMock,
|
|
) as mock_fetch:
|
|
mock_fetch.return_value = mock_docs
|
|
|
|
# Erster Aufruf
|
|
await service.get_legal_references_async("NRW", "elternpflichten")
|
|
|
|
# Zweiter Aufruf sollte Cache nutzen
|
|
await service.get_legal_references_async("NRW", "elternpflichten")
|
|
|
|
# fetch sollte nur einmal aufgerufen werden
|
|
assert mock_fetch.call_count == 1
|
|
|
|
|
|
class TestFetchLegalReferencesFromDB:
|
|
"""Tests für fetch_legal_references_from_db Funktion."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fetch_success(self):
|
|
"""Test erfolgreicher API-Aufruf."""
|
|
mock_response = MagicMock()
|
|
mock_response.status_code = 200
|
|
mock_response.json.return_value = {
|
|
"documents": [
|
|
{"law_name": "SchulG NRW", "paragraphs": []},
|
|
]
|
|
}
|
|
|
|
with patch("httpx.AsyncClient") as mock_client:
|
|
mock_instance = AsyncMock()
|
|
mock_instance.get.return_value = mock_response
|
|
mock_instance.__aenter__.return_value = mock_instance
|
|
mock_instance.__aexit__.return_value = None
|
|
mock_client.return_value = mock_instance
|
|
|
|
docs = await fetch_legal_references_from_db("NRW")
|
|
|
|
assert len(docs) == 1
|
|
assert docs[0]["law_name"] == "SchulG NRW"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fetch_api_error(self):
|
|
"""Test API-Fehler gibt leere Liste zurück."""
|
|
mock_response = MagicMock()
|
|
mock_response.status_code = 500
|
|
|
|
with patch("httpx.AsyncClient") as mock_client:
|
|
mock_instance = AsyncMock()
|
|
mock_instance.get.return_value = mock_response
|
|
mock_instance.__aenter__.return_value = mock_instance
|
|
mock_instance.__aexit__.return_value = None
|
|
mock_client.return_value = mock_instance
|
|
|
|
docs = await fetch_legal_references_from_db("NRW")
|
|
|
|
assert docs == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fetch_network_error(self):
|
|
"""Test Netzwerkfehler gibt leere Liste zurück."""
|
|
import httpx
|
|
|
|
with patch("httpx.AsyncClient") as mock_client:
|
|
mock_instance = AsyncMock()
|
|
mock_instance.get.side_effect = httpx.ConnectError("Connection failed")
|
|
mock_instance.__aenter__.return_value = mock_instance
|
|
mock_instance.__aexit__.return_value = None
|
|
mock_client.return_value = mock_instance
|
|
|
|
docs = await fetch_legal_references_from_db("NRW")
|
|
|
|
assert docs == []
|
|
|
|
|
|
class TestParseDbReferencesToLegalRefs:
|
|
"""Tests für parse_db_references_to_legal_refs Funktion."""
|
|
|
|
def test_parse_with_matching_paragraphs(self):
|
|
"""Test Parsing mit passenden Paragraphen."""
|
|
db_docs = [
|
|
{
|
|
"law_name": "SchulG NRW",
|
|
"title": "Schulgesetz",
|
|
"paragraphs": [
|
|
{"nr": "§ 42", "title": "Pflichten der Eltern"},
|
|
{"nr": "§ 1", "title": "Bildungsauftrag"},
|
|
],
|
|
}
|
|
]
|
|
|
|
refs = parse_db_references_to_legal_refs(db_docs, "elternpflichten")
|
|
|
|
assert len(refs) > 0
|
|
# § 42 sollte für elternpflichten relevant sein
|
|
assert any("42" in r.paragraph for r in refs)
|
|
|
|
def test_parse_without_paragraphs(self):
|
|
"""Test Parsing ohne Paragraphen."""
|
|
db_docs = [
|
|
{
|
|
"law_name": "SchulG NRW",
|
|
"title": "Schulgesetz",
|
|
"paragraphs": [],
|
|
}
|
|
]
|
|
|
|
refs = parse_db_references_to_legal_refs(db_docs, "elternpflichten")
|
|
|
|
# Sollte trotzdem Referenz erstellen
|
|
assert len(refs) == 1
|
|
assert refs[0].law == "SchulG NRW"
|
|
|
|
def test_parse_empty_docs(self):
|
|
"""Test Parsing mit leerer Dokumentenliste."""
|
|
refs = parse_db_references_to_legal_refs([], "elternpflichten")
|
|
|
|
assert refs == []
|
|
|
|
|
|
class TestBuildSystemPrompt:
|
|
"""Tests für build_system_prompt Methode."""
|
|
|
|
def test_build_system_prompt_contains_gfk(self):
|
|
"""Test System-Prompt enthält GFK-Prinzipien."""
|
|
service = CommunicationService()
|
|
prompt = service.build_system_prompt(
|
|
CommunicationType.BEHAVIOR,
|
|
"NRW",
|
|
CommunicationTone.PROFESSIONAL,
|
|
)
|
|
|
|
# Prüfe Großbuchstaben-Varianten (wie im Prompt verwendet)
|
|
assert "BEOBACHTUNG" in prompt
|
|
assert "GEFÜHLE" in prompt
|
|
assert "BEDÜRFNISSE" in prompt
|
|
assert "BITTEN" in prompt
|
|
|
|
def test_build_system_prompt_contains_tone(self):
|
|
"""Test System-Prompt enthält Tonalität."""
|
|
service = CommunicationService()
|
|
prompt = service.build_system_prompt(
|
|
CommunicationType.BEHAVIOR,
|
|
"NRW",
|
|
CommunicationTone.WARM,
|
|
)
|
|
|
|
assert "warmherzig" in prompt.lower()
|
|
|
|
|
|
class TestBuildUserPrompt:
|
|
"""Tests für build_user_prompt Methode."""
|
|
|
|
def test_build_user_prompt_with_context(self):
|
|
"""Test User-Prompt mit Kontext."""
|
|
service = CommunicationService()
|
|
prompt = service.build_user_prompt(
|
|
CommunicationType.BEHAVIOR,
|
|
{
|
|
"student_name": "Max",
|
|
"parent_name": "Frau Müller",
|
|
"situation": "Max stört häufig den Unterricht.",
|
|
"additional_info": "Bereits 3x ermahnt.",
|
|
},
|
|
)
|
|
|
|
assert "Max" in prompt
|
|
assert "Frau Müller" in prompt
|
|
assert "stört" in prompt
|
|
assert "ermahnt" in prompt
|
|
|
|
|
|
class TestValidateCommunication:
|
|
"""Tests für validate_communication Methode."""
|
|
|
|
def test_validate_good_communication(self):
|
|
"""Test Validierung einer guten Kommunikation."""
|
|
service = CommunicationService()
|
|
text = """
|
|
Sehr geehrte Frau Müller,
|
|
|
|
ich habe bemerkt, dass Max in letzter Zeit häufiger abwesend war.
|
|
Ich möchte Sie gerne zu einem Gespräch einladen, da mir eine gute
|
|
Zusammenarbeit sehr wichtig ist.
|
|
|
|
Wären Sie bereit, nächste Woche zu einem Termin zu kommen?
|
|
|
|
Mit freundlichen Grüßen
|
|
"""
|
|
|
|
result = service.validate_communication(text)
|
|
|
|
assert result["is_valid"] is True
|
|
assert len(result["positive_elements"]) > 0
|
|
assert result["gfk_score"] > 0.5
|
|
|
|
def test_validate_bad_communication(self):
|
|
"""Test Validierung einer problematischen Kommunikation."""
|
|
service = CommunicationService()
|
|
text = """
|
|
Sehr geehrte Frau Müller,
|
|
|
|
Sie müssen endlich etwas tun! Das Kind ist faul und respektlos.
|
|
Sie sollten mehr kontrollieren.
|
|
"""
|
|
|
|
result = service.validate_communication(text)
|
|
|
|
assert result["is_valid"] is False
|
|
assert len(result["issues"]) > 0
|
|
assert len(result["suggestions"]) > 0
|
|
|
|
|
|
class TestGetAllCommunicationTypes:
|
|
"""Tests für get_all_communication_types Methode."""
|
|
|
|
def test_returns_all_types(self):
|
|
"""Test gibt alle Typen zurück."""
|
|
service = CommunicationService()
|
|
types = service.get_all_communication_types()
|
|
|
|
assert len(types) == 9
|
|
assert all("value" in t and "label" in t for t in types)
|
|
|
|
|
|
class TestGetAllTones:
|
|
"""Tests für get_all_tones Methode."""
|
|
|
|
def test_returns_all_tones(self):
|
|
"""Test gibt alle Tonalitäten zurück."""
|
|
service = CommunicationService()
|
|
tones = service.get_all_tones()
|
|
|
|
assert len(tones) == 5
|
|
assert all("value" in t and "label" in t for t in tones)
|
|
|
|
|
|
class TestGetStates:
|
|
"""Tests für get_states Methode."""
|
|
|
|
def test_returns_all_16_bundeslaender(self):
|
|
"""Test gibt alle 16 Bundesländer zurück."""
|
|
service = CommunicationService()
|
|
states = service.get_states()
|
|
|
|
assert len(states) == 16
|
|
# Prüfe einige
|
|
state_values = [s["value"] for s in states]
|
|
assert "NRW" in state_values
|
|
assert "BY" in state_values
|
|
assert "BE" in state_values
|
|
|
|
|
|
class TestGetCommunicationService:
|
|
"""Tests für Singleton-Pattern."""
|
|
|
|
def test_singleton_pattern(self):
|
|
"""Test dass get_communication_service immer dieselbe Instanz zurückgibt."""
|
|
service1 = get_communication_service()
|
|
service2 = get_communication_service()
|
|
|
|
assert service1 is service2
|
|
|
|
def test_returns_communication_service(self):
|
|
"""Test dass CommunicationService zurückgegeben wird."""
|
|
service = get_communication_service()
|
|
|
|
assert isinstance(service, CommunicationService)
|