fix: Restore all files lost during destructive rebase

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>
This commit is contained in:
Benjamin Admin
2026-02-09 09:51:32 +01:00
parent f7487ee240
commit bfdaf63ba9
2009 changed files with 749983 additions and 1731 deletions

View File

@@ -0,0 +1,3 @@
"""
Tests für LLM Gateway.
"""

View File

@@ -0,0 +1,501 @@
"""
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)

View File

@@ -0,0 +1,175 @@
"""
Tests für LLM Gateway Config.
"""
import pytest
import os
from unittest.mock import patch
from llm_gateway.config import (
GatewayConfig,
LLMBackendConfig,
load_config,
get_config,
)
class TestGatewayConfig:
"""Tests für GatewayConfig Dataclass."""
def test_default_values(self):
"""Test Standardwerte."""
config = GatewayConfig()
assert config.host == "0.0.0.0"
assert config.port == 8002
assert config.debug is False
assert config.rate_limit_requests_per_minute == 60
assert config.log_level == "INFO"
def test_custom_values(self):
"""Test benutzerdefinierte Werte."""
config = GatewayConfig(
host="127.0.0.1",
port=9000,
debug=True,
rate_limit_requests_per_minute=100,
)
assert config.host == "127.0.0.1"
assert config.port == 9000
assert config.debug is True
assert config.rate_limit_requests_per_minute == 100
class TestLLMBackendConfig:
"""Tests für LLMBackendConfig."""
def test_minimal_config(self):
"""Test minimale Backend-Konfiguration."""
config = LLMBackendConfig(
name="test",
base_url="http://localhost:8000",
)
assert config.name == "test"
assert config.base_url == "http://localhost:8000"
assert config.api_key is None
assert config.enabled is True
def test_full_config(self):
"""Test vollständige Backend-Konfiguration."""
config = LLMBackendConfig(
name="vllm",
base_url="http://gpu-server:8000",
api_key="secret-key",
default_model="llama-3.1-8b",
timeout=180,
enabled=True,
)
assert config.api_key == "secret-key"
assert config.default_model == "llama-3.1-8b"
assert config.timeout == 180
class TestLoadConfig:
"""Tests für load_config Funktion."""
def test_load_config_defaults(self):
"""Test Laden mit Standardwerten."""
with patch.dict(os.environ, {}, clear=True):
config = load_config()
assert config.host == "0.0.0.0"
assert config.port == 8002
assert config.debug is False
def test_load_config_with_env_vars(self):
"""Test Laden mit Umgebungsvariablen."""
env = {
"LLM_GATEWAY_HOST": "127.0.0.1",
"LLM_GATEWAY_PORT": "9000",
"LLM_GATEWAY_DEBUG": "true",
"LLM_RATE_LIMIT_RPM": "120",
"LLM_LOG_LEVEL": "DEBUG",
}
with patch.dict(os.environ, env, clear=True):
config = load_config()
assert config.host == "127.0.0.1"
assert config.port == 9000
assert config.debug is True
assert config.rate_limit_requests_per_minute == 120
assert config.log_level == "DEBUG"
def test_load_config_ollama_backend(self):
"""Test Ollama Backend Konfiguration."""
env = {
"OLLAMA_BASE_URL": "http://localhost:11434",
"OLLAMA_DEFAULT_MODEL": "mistral:7b",
"OLLAMA_TIMEOUT": "60",
"OLLAMA_ENABLED": "true",
}
with patch.dict(os.environ, env, clear=True):
config = load_config()
assert config.ollama is not None
assert config.ollama.base_url == "http://localhost:11434"
assert config.ollama.default_model == "mistral:7b"
assert config.ollama.timeout == 60
assert config.ollama.enabled is True
def test_load_config_vllm_backend(self):
"""Test vLLM Backend Konfiguration."""
env = {
"VLLM_BASE_URL": "http://gpu-server:8000",
"VLLM_API_KEY": "secret-key",
"VLLM_DEFAULT_MODEL": "meta-llama/Llama-3.1-8B-Instruct",
"VLLM_ENABLED": "true",
}
with patch.dict(os.environ, env, clear=True):
config = load_config()
assert config.vllm is not None
assert config.vllm.base_url == "http://gpu-server:8000"
assert config.vllm.api_key == "secret-key"
assert config.vllm.enabled is True
def test_load_config_anthropic_backend(self):
"""Test Anthropic Backend Konfiguration."""
env = {
"ANTHROPIC_API_KEY": "sk-ant-xxx",
"ANTHROPIC_DEFAULT_MODEL": "claude-3-5-sonnet-20241022",
"ANTHROPIC_ENABLED": "true",
}
with patch.dict(os.environ, env, clear=True):
config = load_config()
assert config.anthropic is not None
assert config.anthropic.api_key == "sk-ant-xxx"
assert config.anthropic.default_model == "claude-3-5-sonnet-20241022"
assert config.anthropic.enabled is True
def test_load_config_no_anthropic_without_key(self):
"""Test dass Anthropic ohne Key nicht konfiguriert wird."""
with patch.dict(os.environ, {}, clear=True):
config = load_config()
assert config.anthropic is None
def test_load_config_backend_priority(self):
"""Test Backend Priorität."""
env = {
"LLM_BACKEND_PRIORITY": "vllm,anthropic,ollama",
}
with patch.dict(os.environ, env, clear=True):
config = load_config()
assert config.backend_priority == ["vllm", "anthropic", "ollama"]
def test_load_config_api_keys(self):
"""Test API Keys Liste."""
env = {
"LLM_API_KEYS": "key1,key2,key3",
}
with patch.dict(os.environ, env, clear=True):
config = load_config()
assert config.api_keys == ["key1", "key2", "key3"]
def test_load_config_jwt_secret(self):
"""Test JWT Secret."""
env = {
"JWT_SECRET": "my-secret-key",
}
with patch.dict(os.environ, env, clear=True):
config = load_config()
assert config.jwt_secret == "my-secret-key"

View File

@@ -0,0 +1,195 @@
"""
Tests für Inference Service.
"""
import pytest
from unittest.mock import AsyncMock, patch, MagicMock
from llm_gateway.services.inference import (
InferenceService,
InferenceResult,
get_inference_service,
)
from llm_gateway.models.chat import (
ChatCompletionRequest,
ChatMessage,
Usage,
)
class TestInferenceServiceModelMapping:
"""Tests für Model Mapping."""
def setup_method(self):
"""Setup für jeden Test."""
self.service = InferenceService()
def test_map_breakpilot_model_to_ollama(self):
"""Test Mapping von BreakPilot Modell zu Ollama."""
# Mock Ollama als verfügbares Backend
with patch.object(self.service, 'config') as mock_config:
mock_config.ollama = MagicMock()
mock_config.ollama.name = "ollama"
mock_config.ollama.enabled = True
mock_config.vllm = None
mock_config.anthropic = None
mock_config.backend_priority = ["ollama", "vllm", "anthropic"]
actual_model, backend = self.service._map_model_to_backend("breakpilot-teacher-8b")
assert actual_model == "llama3.1:8b"
assert backend.name == "ollama"
def test_map_breakpilot_70b_model(self):
"""Test Mapping von 70B Modell."""
with patch.object(self.service, 'config') as mock_config:
mock_config.ollama = MagicMock()
mock_config.ollama.name = "ollama"
mock_config.ollama.enabled = True
mock_config.vllm = None
mock_config.anthropic = None
mock_config.backend_priority = ["ollama"]
actual_model, backend = self.service._map_model_to_backend("breakpilot-teacher-70b")
assert "70b" in actual_model.lower()
def test_map_claude_model_to_anthropic(self):
"""Test Mapping von Claude Modell zu Anthropic."""
with patch.object(self.service, 'config') as mock_config:
mock_config.ollama = None
mock_config.vllm = None
mock_config.anthropic = MagicMock()
mock_config.anthropic.name = "anthropic"
mock_config.anthropic.enabled = True
mock_config.anthropic.default_model = "claude-3-5-sonnet-20241022"
mock_config.backend_priority = ["anthropic"]
actual_model, backend = self.service._map_model_to_backend("claude-3-5-sonnet")
assert backend.name == "anthropic"
assert "claude" in actual_model.lower()
def test_map_model_no_backend_available(self):
"""Test Fehler wenn kein Backend verfügbar."""
with patch.object(self.service, 'config') as mock_config:
mock_config.ollama = None
mock_config.vllm = None
mock_config.anthropic = None
mock_config.backend_priority = []
with pytest.raises(ValueError, match="No LLM backend available"):
self.service._map_model_to_backend("breakpilot-teacher-8b")
class TestInferenceServiceBackendSelection:
"""Tests für Backend-Auswahl."""
def setup_method(self):
"""Setup für jeden Test."""
self.service = InferenceService()
def test_get_available_backend_priority(self):
"""Test Backend-Auswahl nach Priorität."""
with patch.object(self.service, 'config') as mock_config:
# Beide Backends verfügbar
mock_config.ollama = MagicMock()
mock_config.ollama.enabled = True
mock_config.vllm = MagicMock()
mock_config.vllm.enabled = True
mock_config.anthropic = None
mock_config.backend_priority = ["vllm", "ollama"]
backend = self.service._get_available_backend()
# vLLM hat höhere Priorität
assert backend == mock_config.vllm
def test_get_available_backend_fallback(self):
"""Test Fallback wenn primäres Backend nicht verfügbar."""
with patch.object(self.service, 'config') as mock_config:
mock_config.ollama = MagicMock()
mock_config.ollama.enabled = True
mock_config.vllm = MagicMock()
mock_config.vllm.enabled = False # Deaktiviert
mock_config.anthropic = None
mock_config.backend_priority = ["vllm", "ollama"]
backend = self.service._get_available_backend()
# Ollama als Fallback
assert backend == mock_config.ollama
def test_get_available_backend_none_available(self):
"""Test wenn kein Backend verfügbar."""
with patch.object(self.service, 'config') as mock_config:
mock_config.ollama = None
mock_config.vllm = None
mock_config.anthropic = None
mock_config.backend_priority = ["ollama", "vllm", "anthropic"]
backend = self.service._get_available_backend()
assert backend is None
class TestInferenceResult:
"""Tests für InferenceResult Dataclass."""
def test_inference_result_creation(self):
"""Test InferenceResult erstellen."""
result = InferenceResult(
content="Hello, world!",
model="llama3.1:8b",
backend="ollama",
usage=Usage(prompt_tokens=10, completion_tokens=5, total_tokens=15),
finish_reason="stop",
)
assert result.content == "Hello, world!"
assert result.model == "llama3.1:8b"
assert result.backend == "ollama"
assert result.usage.total_tokens == 15
def test_inference_result_defaults(self):
"""Test Standardwerte."""
result = InferenceResult(
content="Test",
model="test",
backend="test",
)
assert result.usage is None
assert result.finish_reason == "stop"
class TestInferenceServiceComplete:
"""Tests für complete() Methode."""
@pytest.mark.asyncio
async def test_complete_calls_correct_backend(self):
"""Test dass correct Backend aufgerufen wird."""
service = InferenceService()
request = ChatCompletionRequest(
model="breakpilot-teacher-8b",
messages=[ChatMessage(role="user", content="Hello")],
)
# Mock das Backend
with patch.object(service, '_map_model_to_backend') as mock_map:
with patch.object(service, '_call_ollama') as mock_call:
mock_backend = MagicMock()
mock_backend.name = "ollama"
mock_map.return_value = ("llama3.1:8b", mock_backend)
mock_call.return_value = InferenceResult(
content="Hello!",
model="llama3.1:8b",
backend="ollama",
)
response = await service.complete(request)
mock_call.assert_called_once()
assert response.choices[0].message.content == "Hello!"
class TestGetInferenceServiceSingleton:
"""Tests für Singleton Pattern."""
def test_singleton_returns_same_instance(self):
"""Test dass get_inference_service Singleton zurückgibt."""
service1 = get_inference_service()
service2 = get_inference_service()
assert service1 is service2

View File

@@ -0,0 +1,237 @@
"""
Tests für den Legal Crawler Service.
Testet das Crawlen und Parsen von rechtlichen Bildungsinhalten.
"""
import pytest
from unittest.mock import AsyncMock, patch, MagicMock
import httpx
from llm_gateway.services.legal_crawler import (
LegalCrawler,
CrawledDocument,
get_legal_crawler,
)
class TestLegalCrawler:
"""Tests für LegalCrawler Klasse."""
def test_crawler_initialization(self):
"""Test Crawler wird korrekt initialisiert."""
crawler = LegalCrawler()
assert crawler.user_agent == "BreakPilot-Crawler/1.0 (Educational Purpose)"
assert crawler.timeout == 30.0
assert crawler.rate_limit_delay == 1.0
assert crawler.db_pool is None
def test_crawler_with_db_pool(self):
"""Test Crawler mit DB Pool."""
mock_pool = MagicMock()
crawler = LegalCrawler(db_pool=mock_pool)
assert crawler.db_pool == mock_pool
class TestCrawledDocument:
"""Tests für CrawledDocument Dataclass."""
def test_document_creation(self):
"""Test CrawledDocument erstellen."""
doc = CrawledDocument(
url="https://example.com/schulgesetz",
canonical_url="https://example.com/schulgesetz",
title="Schulgesetz NRW",
content="§ 1 Bildungsauftrag...",
content_hash="abc123",
category="legal",
doc_type="schulgesetz",
state="NW",
law_name="SchulG NRW",
paragraphs=[{"nr": "§ 1", "title": "Bildungsauftrag"}],
trust_score=0.9,
)
assert doc.url == "https://example.com/schulgesetz"
assert doc.state == "NW"
assert doc.law_name == "SchulG NRW"
assert len(doc.paragraphs) == 1
def test_document_without_optional_fields(self):
"""Test CrawledDocument ohne optionale Felder."""
doc = CrawledDocument(
url="https://example.com/info",
canonical_url=None,
title="Info Page",
content="Some content",
content_hash="def456",
category="legal",
doc_type="info",
state=None,
law_name=None,
paragraphs=None,
trust_score=0.5,
)
assert doc.state is None
assert doc.paragraphs is None
class TestParagraphExtraction:
"""Tests für die Paragraphen-Extraktion."""
def test_extract_paragraphs_from_html(self):
"""Test Paragraphen werden aus HTML extrahiert."""
crawler = LegalCrawler()
html_content = """
§ 1 Bildungsauftrag
Die Schule hat den Auftrag...
§ 2 Erziehungsauftrag
Die Schule erzieht...
§ 42 Pflichten der Eltern
Die Eltern sind verpflichtet...
"""
from bs4 import BeautifulSoup
soup = BeautifulSoup("<body></body>", "html.parser")
paragraphs = crawler._extract_paragraphs(soup, html_content)
assert paragraphs is not None
assert len(paragraphs) >= 3
# Prüfe dass § 42 gefunden wurde
para_numbers = [p["nr"] for p in paragraphs]
assert any("42" in nr for nr in para_numbers)
def test_extract_paragraphs_empty_content(self):
"""Test keine Paragraphen bei leerem Content."""
crawler = LegalCrawler()
from bs4 import BeautifulSoup
soup = BeautifulSoup("<body></body>", "html.parser")
paragraphs = crawler._extract_paragraphs(soup, "")
assert paragraphs is None or len(paragraphs) == 0
def test_extract_paragraphs_no_pattern_match(self):
"""Test keine Paragraphen wenn kein Pattern matched."""
crawler = LegalCrawler()
from bs4 import BeautifulSoup
soup = BeautifulSoup("<body></body>", "html.parser")
paragraphs = crawler._extract_paragraphs(soup, "Just some text without paragraphs")
assert paragraphs is None or len(paragraphs) == 0
class TestCrawlUrl:
"""Tests für das URL-Crawling."""
@pytest.mark.asyncio
async def test_crawl_url_html_success(self):
"""Test erfolgreiches Crawlen einer HTML-URL."""
crawler = LegalCrawler()
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.headers = {"content-type": "text/html; charset=utf-8"}
mock_response.text = """
<html>
<head><title>Schulgesetz NRW</title></head>
<body>
<main>
§ 1 Bildungsauftrag
Die Schule hat den Auftrag...
</main>
</body>
</html>
"""
mock_response.url = "https://example.com/schulgesetz"
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
seed_info = {"name": "SchulG NRW", "state": "NW", "trust_boost": 0.95}
doc = await crawler.crawl_url("https://example.com/schulgesetz", seed_info)
assert doc is not None
assert doc.title == "Schulgesetz NRW"
assert doc.state == "NW"
assert doc.trust_score == 0.95
@pytest.mark.asyncio
async def test_crawl_url_404_returns_none(self):
"""Test 404 Error gibt None zurück."""
crawler = LegalCrawler()
mock_response = MagicMock()
mock_response.status_code = 404
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
doc = await crawler.crawl_url("https://example.com/notfound", {})
assert doc is None
@pytest.mark.asyncio
async def test_crawl_url_network_error_returns_none(self):
"""Test Netzwerkfehler gibt None zurück."""
crawler = LegalCrawler()
with patch("httpx.AsyncClient") as mock_client:
mock_instance = AsyncMock()
mock_instance.get.side_effect = httpx.ConnectError("Network error")
mock_instance.__aenter__.return_value = mock_instance
mock_instance.__aexit__.return_value = None
mock_client.return_value = mock_instance
doc = await crawler.crawl_url("https://example.com/error", {})
assert doc is None
@pytest.mark.asyncio
async def test_crawl_url_pdf_returns_none(self):
"""Test PDF URLs werden aktuell übersprungen (not implemented)."""
crawler = LegalCrawler()
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.headers = {"content-type": "application/pdf"}
mock_response.content = b"%PDF-1.4..."
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
doc = await crawler.crawl_url("https://example.com/doc.pdf", {})
# PDF extraction ist noch nicht implementiert
assert doc is None
class TestGetLegalCrawler:
"""Tests für Singleton-Pattern."""
def test_get_legal_crawler_singleton(self):
"""Test dass get_legal_crawler immer dieselbe Instanz zurückgibt."""
crawler1 = get_legal_crawler()
crawler2 = get_legal_crawler()
assert crawler1 is crawler2
def test_get_legal_crawler_returns_crawler(self):
"""Test dass get_legal_crawler einen LegalCrawler zurückgibt."""
crawler = get_legal_crawler()
assert isinstance(crawler, LegalCrawler)

View File

@@ -0,0 +1,204 @@
"""
Tests für LLM Gateway Pydantic Models.
"""
import pytest
from llm_gateway.models.chat import (
ChatMessage,
ChatCompletionRequest,
ChatCompletionResponse,
ChatCompletionChunk,
ChatChoice,
StreamChoice,
ChatChoiceDelta,
Usage,
ModelInfo,
ModelListResponse,
RequestMetadata,
)
class TestChatMessage:
"""Tests für ChatMessage Model."""
def test_user_message(self):
"""Test User Message erstellen."""
msg = ChatMessage(role="user", content="Hello")
assert msg.role == "user"
assert msg.content == "Hello"
assert msg.name is None
def test_assistant_message(self):
"""Test Assistant Message erstellen."""
msg = ChatMessage(role="assistant", content="Hi there!")
assert msg.role == "assistant"
assert msg.content == "Hi there!"
def test_system_message(self):
"""Test System Message erstellen."""
msg = ChatMessage(role="system", content="You are a helpful assistant.")
assert msg.role == "system"
def test_tool_message(self):
"""Test Tool Message erstellen."""
msg = ChatMessage(role="tool", content='{"result": "success"}', tool_call_id="call_123")
assert msg.role == "tool"
assert msg.tool_call_id == "call_123"
class TestChatCompletionRequest:
"""Tests für ChatCompletionRequest Model."""
def test_minimal_request(self):
"""Test minimale Request."""
req = ChatCompletionRequest(
model="breakpilot-teacher-8b",
messages=[ChatMessage(role="user", content="Hello")],
)
assert req.model == "breakpilot-teacher-8b"
assert len(req.messages) == 1
assert req.stream is False
assert req.temperature == 0.7
def test_full_request(self):
"""Test vollständige Request."""
req = ChatCompletionRequest(
model="breakpilot-teacher-70b",
messages=[
ChatMessage(role="system", content="Du bist ein Assistent."),
ChatMessage(role="user", content="Schreibe einen Brief."),
],
stream=True,
temperature=0.5,
max_tokens=1000,
metadata=RequestMetadata(playbook_id="pb_elternbrief"),
)
assert req.stream is True
assert req.temperature == 0.5
assert req.max_tokens == 1000
assert req.metadata.playbook_id == "pb_elternbrief"
def test_temperature_bounds(self):
"""Test Temperature Grenzen."""
# Gültige Werte
req = ChatCompletionRequest(
model="test",
messages=[ChatMessage(role="user", content="test")],
temperature=0.0,
)
assert req.temperature == 0.0
req = ChatCompletionRequest(
model="test",
messages=[ChatMessage(role="user", content="test")],
temperature=2.0,
)
assert req.temperature == 2.0
# Ungültige Werte
with pytest.raises(ValueError):
ChatCompletionRequest(
model="test",
messages=[ChatMessage(role="user", content="test")],
temperature=2.5,
)
class TestChatCompletionResponse:
"""Tests für ChatCompletionResponse Model."""
def test_response_creation(self):
"""Test Response erstellen."""
response = ChatCompletionResponse(
model="breakpilot-teacher-8b",
choices=[
ChatChoice(
index=0,
message=ChatMessage(role="assistant", content="Hello!"),
finish_reason="stop",
)
],
usage=Usage(prompt_tokens=10, completion_tokens=5, total_tokens=15),
)
assert response.object == "chat.completion"
assert response.model == "breakpilot-teacher-8b"
assert len(response.choices) == 1
assert response.choices[0].message.content == "Hello!"
assert response.usage.total_tokens == 15
def test_response_has_id(self):
"""Test dass Response eine ID hat."""
response = ChatCompletionResponse(
model="test",
choices=[
ChatChoice(
message=ChatMessage(role="assistant", content="test"),
)
],
)
assert response.id.startswith("chatcmpl-")
assert len(response.id) > 10
class TestChatCompletionChunk:
"""Tests für Streaming Chunks."""
def test_chunk_creation(self):
"""Test Chunk erstellen."""
chunk = ChatCompletionChunk(
model="breakpilot-teacher-8b",
choices=[
StreamChoice(
index=0,
delta=ChatChoiceDelta(content="Hello"),
finish_reason=None,
)
],
)
assert chunk.object == "chat.completion.chunk"
assert chunk.choices[0].delta.content == "Hello"
def test_final_chunk(self):
"""Test Final Chunk mit finish_reason."""
chunk = ChatCompletionChunk(
model="test",
choices=[
StreamChoice(
index=0,
delta=ChatChoiceDelta(),
finish_reason="stop",
)
],
)
assert chunk.choices[0].finish_reason == "stop"
class TestModelInfo:
"""Tests für ModelInfo."""
def test_model_info(self):
"""Test ModelInfo erstellen."""
model = ModelInfo(
id="breakpilot-teacher-8b",
owned_by="breakpilot",
description="Test model",
context_length=8192,
)
assert model.id == "breakpilot-teacher-8b"
assert model.object == "model"
assert model.context_length == 8192
class TestModelListResponse:
"""Tests für ModelListResponse."""
def test_model_list(self):
"""Test Model List erstellen."""
response = ModelListResponse(
data=[
ModelInfo(id="model-1", owned_by="test"),
ModelInfo(id="model-2", owned_by="test"),
]
)
assert response.object == "list"
assert len(response.data) == 2

View File

@@ -0,0 +1,296 @@
"""
Tests für PII Detector Service.
"""
import pytest
from llm_gateway.services.pii_detector import (
PIIDetector,
PIIType,
PIIMatch,
RedactionResult,
get_pii_detector,
)
class TestPIIDetectorPatterns:
"""Tests für PII-Erkennung."""
def setup_method(self):
"""Setup für jeden Test."""
self.detector = PIIDetector()
def test_detect_email(self):
"""Test E-Mail-Erkennung."""
text = "Kontakt: max.mustermann@example.com für Rückfragen"
matches = self.detector.detect(text)
assert len(matches) == 1
assert matches[0].type == PIIType.EMAIL
assert matches[0].value == "max.mustermann@example.com"
def test_detect_multiple_emails(self):
"""Test mehrerer E-Mail-Adressen."""
text = "Von: a@b.de An: c@d.com CC: e@f.org"
matches = self.detector.detect(text)
assert len(matches) == 3
assert all(m.type == PIIType.EMAIL for m in matches)
def test_detect_german_phone(self):
"""Test deutsche Telefonnummer."""
text = "Erreichbar unter 089 12345678"
matches = self.detector.detect(text)
assert len(matches) == 1
assert matches[0].type == PIIType.PHONE
def test_detect_phone_with_country_code(self):
"""Test Telefonnummer mit Landesvorwahl."""
text = "Tel: +49 30 1234567"
matches = self.detector.detect(text)
assert len(matches) == 1
assert matches[0].type == PIIType.PHONE
def test_detect_iban(self):
"""Test IBAN-Erkennung."""
text = "IBAN: DE89370400440532013000"
matches = self.detector.detect(text)
# IBAN sollte erkannt werden (evtl. auch als Telefon, aber IBAN hat Priorität)
iban_matches = [m for m in matches if m.type == PIIType.IBAN]
assert len(iban_matches) >= 1
def test_detect_iban_with_spaces(self):
"""Test IBAN mit Leerzeichen."""
text = "Konto: DE89 3704 0044 0532 0130 00"
matches = self.detector.detect(text)
# Bei überlappenden Matches gewinnt IBAN wegen höherer Priorität
iban_matches = [m for m in matches if m.type == PIIType.IBAN]
assert len(iban_matches) >= 1
def test_detect_credit_card_visa(self):
"""Test Visa Kreditkarte."""
text = "Karte: 4111 1111 1111 1111"
matches = self.detector.detect(text)
assert len(matches) == 1
assert matches[0].type == PIIType.CREDIT_CARD
def test_detect_credit_card_mastercard(self):
"""Test Mastercard."""
text = "MC: 5500 0000 0000 0004"
matches = self.detector.detect(text)
# Kreditkarte hat höhere Priorität als Telefon
cc_matches = [m for m in matches if m.type == PIIType.CREDIT_CARD]
assert len(cc_matches) >= 1
def test_detect_ip_address(self):
"""Test IP-Adresse."""
text = "Server IP: 192.168.1.100"
matches = self.detector.detect(text)
assert len(matches) == 1
assert matches[0].type == PIIType.IP_ADDRESS
def test_detect_date_of_birth(self):
"""Test Geburtsdatum."""
text = "Geboren am 15.03.1985"
matches = self.detector.detect(text)
assert len(matches) == 1
assert matches[0].type == PIIType.DATE_OF_BIRTH
def test_detect_date_single_digit(self):
"""Test Datum mit einstelligem Tag/Monat."""
text = "DOB: 1.5.1990"
matches = self.detector.detect(text)
assert len(matches) == 1
assert matches[0].type == PIIType.DATE_OF_BIRTH
def test_no_false_positive_year(self):
"""Test dass Jahre allein nicht erkannt werden."""
text = "Im Jahr 2024 wurde das System eingeführt"
matches = self.detector.detect(text)
# Sollte kein DOB sein
assert all(m.type != PIIType.DATE_OF_BIRTH for m in matches)
class TestPIIDetectorRedaction:
"""Tests für PII-Redaktion."""
def setup_method(self):
"""Setup für jeden Test."""
self.detector = PIIDetector()
def test_redact_email(self):
"""Test E-Mail Redaktion."""
text = "Mail an test@example.com senden"
result = self.detector.redact(text)
assert result.pii_found is True
assert "test@example.com" not in result.redacted_text
assert "[EMAIL_REDACTED]" in result.redacted_text
assert result.original_text == text
def test_redact_multiple_pii(self):
"""Test Redaktion mehrerer PII-Typen."""
text = "Kontakt: max@test.de, Tel: 089 123456"
result = self.detector.redact(text)
assert result.pii_found is True
assert len(result.matches) == 2
assert "[EMAIL_REDACTED]" in result.redacted_text
assert "[PHONE_REDACTED]" in result.redacted_text
def test_redact_preserves_structure(self):
"""Test dass Textstruktur erhalten bleibt."""
text = "Von: a@b.de\nAn: c@d.de"
result = self.detector.redact(text)
assert "\n" in result.redacted_text
assert "Von:" in result.redacted_text
assert "An:" in result.redacted_text
def test_no_pii_returns_original(self):
"""Test ohne PII gibt Original zurück."""
text = "Keine personenbezogenen Daten hier"
result = self.detector.redact(text)
assert result.pii_found is False
assert result.redacted_text == text
assert len(result.matches) == 0
class TestPIIDetectorContainsPII:
"""Tests für schnelle PII-Prüfung."""
def setup_method(self):
"""Setup für jeden Test."""
self.detector = PIIDetector()
def test_contains_pii_with_email(self):
"""Test contains_pii mit E-Mail."""
assert self.detector.contains_pii("test@example.com") is True
def test_contains_pii_with_phone(self):
"""Test contains_pii mit Telefon."""
assert self.detector.contains_pii("+49 89 123456") is True
def test_contains_pii_without_pii(self):
"""Test contains_pii ohne PII."""
assert self.detector.contains_pii("Schulrecht Bayern") is False
class TestPIIDetectorConfiguration:
"""Tests für Detector-Konfiguration."""
def test_custom_enabled_types(self):
"""Test mit eingeschränkten PII-Typen."""
detector = PIIDetector(enabled_types=[PIIType.EMAIL])
# E-Mail wird erkannt
assert len(detector.detect("test@example.com")) == 1
# Telefon wird nicht erkannt
assert len(detector.detect("+49 89 123456")) == 0
def test_empty_enabled_types(self):
"""Test mit leerer Typen-Liste."""
detector = PIIDetector(enabled_types=[])
# Nichts wird erkannt
assert len(detector.detect("test@example.com +49 89 123456")) == 0
class TestPIIMatch:
"""Tests für PIIMatch Dataclass."""
def test_pii_match_creation(self):
"""Test PIIMatch erstellen."""
match = PIIMatch(
type=PIIType.EMAIL,
value="test@example.com",
start=0,
end=16,
replacement="[EMAIL_REDACTED]",
)
assert match.type == PIIType.EMAIL
assert match.value == "test@example.com"
assert match.start == 0
assert match.end == 16
class TestRedactionResult:
"""Tests für RedactionResult Dataclass."""
def test_redaction_result_creation(self):
"""Test RedactionResult erstellen."""
result = RedactionResult(
original_text="test@example.com",
redacted_text="[EMAIL_REDACTED]",
matches=[],
pii_found=True,
)
assert result.pii_found is True
assert result.original_text == "test@example.com"
assert result.redacted_text == "[EMAIL_REDACTED]"
class TestGetPIIDetectorSingleton:
"""Tests für Singleton Pattern."""
def test_singleton_returns_same_instance(self):
"""Test dass get_pii_detector Singleton zurückgibt."""
detector1 = get_pii_detector()
detector2 = get_pii_detector()
assert detector1 is detector2
class TestPIIRealWorldExamples:
"""Tests mit realistischen Beispielen."""
def setup_method(self):
"""Setup für jeden Test."""
self.detector = PIIDetector()
def test_school_query_without_pii(self):
"""Test Schulanfrage ohne PII."""
query = "Welche Regeln gelten für Datenschutz an Schulen in Bayern?"
result = self.detector.redact(query)
assert result.pii_found is False
assert result.redacted_text == query
def test_school_query_with_email(self):
"""Test Schulanfrage mit E-Mail."""
query = "Wie kontaktiere ich lehrer.mueller@schule.de wegen Datenschutz?"
result = self.detector.redact(query)
assert result.pii_found is True
assert "lehrer.mueller@schule.de" not in result.redacted_text
assert "[EMAIL_REDACTED]" in result.redacted_text
def test_parent_letter_with_multiple_pii(self):
"""Test Elternbrief mit mehreren PII."""
text = """
Sehr geehrte Familie Müller,
bitte rufen Sie unter 089 12345678 an oder
schreiben Sie an eltern@schule.de.
IBAN für Klassenfahrt: DE89370400440532013000
"""
result = self.detector.redact(text)
assert result.pii_found is True
# Mindestens 3 Matches (Telefon, E-Mail, IBAN)
# Überlappende werden gefiltert (IBAN hat Priorität über Telefon)
assert len(result.matches) >= 3
assert "[PHONE_REDACTED]" in result.redacted_text
assert "[EMAIL_REDACTED]" in result.redacted_text
assert "[IBAN_REDACTED]" in result.redacted_text

View File

@@ -0,0 +1,199 @@
"""
Tests für Playbook Service.
"""
import pytest
from datetime import datetime
from llm_gateway.services.playbook_service import (
PlaybookService,
Playbook,
get_playbook_service,
)
class TestPlaybookService:
"""Tests für PlaybookService."""
def setup_method(self):
"""Setup für jeden Test."""
self.service = PlaybookService()
def test_list_playbooks_returns_default_playbooks(self):
"""Test dass Default-Playbooks geladen werden."""
playbooks = self.service.list_playbooks()
assert len(playbooks) > 0
# Prüfe dass bekannte Playbooks existieren
ids = [p.id for p in playbooks]
assert "pb_default" in ids
assert "pb_elternbrief" in ids
assert "pb_arbeitsblatt" in ids
def test_list_playbooks_filter_by_status(self):
"""Test Status-Filter."""
# Alle Default-Playbooks sind published
published = self.service.list_playbooks(status="published")
assert len(published) > 0
# Keine Draft-Playbooks
drafts = self.service.list_playbooks(status="draft")
assert len(drafts) == 0
def test_get_playbook_existing(self):
"""Test Playbook abrufen."""
playbook = self.service.get_playbook("pb_default")
assert playbook is not None
assert playbook.id == "pb_default"
assert playbook.name == "Standard-Assistent"
assert len(playbook.system_prompt) > 0
def test_get_playbook_not_found(self):
"""Test nicht existierendes Playbook."""
playbook = self.service.get_playbook("non_existent")
assert playbook is None
def test_get_system_prompt(self):
"""Test System Prompt abrufen."""
prompt = self.service.get_system_prompt("pb_elternbrief")
assert prompt is not None
assert "Elternbrief" in prompt or "Elternkommunikation" in prompt
def test_get_system_prompt_not_found(self):
"""Test System Prompt für nicht existierendes Playbook."""
prompt = self.service.get_system_prompt("non_existent")
assert prompt is None
def test_create_playbook(self):
"""Test neues Playbook erstellen."""
new_playbook = Playbook(
id="pb_test_new",
name="Test Playbook",
description="Ein Test Playbook",
system_prompt="Du bist ein Test-Assistent.",
prompt_version="1.0.0",
)
created = self.service.create_playbook(new_playbook)
assert created.id == "pb_test_new"
# Prüfe dass es abrufbar ist
retrieved = self.service.get_playbook("pb_test_new")
assert retrieved is not None
assert retrieved.name == "Test Playbook"
def test_create_playbook_duplicate_id(self):
"""Test Playbook mit existierender ID erstellen."""
duplicate = Playbook(
id="pb_default", # Existiert bereits
name="Duplicate",
description="Test",
system_prompt="Test",
prompt_version="1.0.0",
)
with pytest.raises(ValueError):
self.service.create_playbook(duplicate)
def test_update_playbook(self):
"""Test Playbook aktualisieren."""
original = self.service.get_playbook("pb_default")
original_name = original.name
updated = self.service.update_playbook(
"pb_default",
name="Aktualisierter Name",
)
assert updated is not None
assert updated.name == "Aktualisierter Name"
# Reset
self.service.update_playbook("pb_default", name=original_name)
def test_update_playbook_not_found(self):
"""Test nicht existierendes Playbook aktualisieren."""
result = self.service.update_playbook("non_existent", name="Test")
assert result is None
def test_delete_playbook(self):
"""Test Playbook löschen."""
# Erst erstellen
new_playbook = Playbook(
id="pb_to_delete",
name="To Delete",
description="Test",
system_prompt="Test",
prompt_version="1.0.0",
)
self.service.create_playbook(new_playbook)
# Dann löschen
result = self.service.delete_playbook("pb_to_delete")
assert result is True
# Prüfen dass gelöscht
assert self.service.get_playbook("pb_to_delete") is None
def test_delete_playbook_not_found(self):
"""Test nicht existierendes Playbook löschen."""
result = self.service.delete_playbook("non_existent")
assert result is False
class TestPlaybookContent:
"""Tests für Playbook-Inhalte."""
def setup_method(self):
"""Setup für jeden Test."""
self.service = PlaybookService()
def test_elternbrief_playbook_has_guidelines(self):
"""Test dass Elternbrief-Playbook Richtlinien enthält."""
playbook = self.service.get_playbook("pb_elternbrief")
assert playbook is not None
prompt = playbook.system_prompt.lower()
# Sollte wichtige Richtlinien enthalten
assert "ton" in prompt or "sprache" in prompt
assert "brief" in prompt or "eltern" in prompt
def test_rechtlich_playbook_has_disclaimer(self):
"""Test dass Rechtlich-Playbook Disclaimer enthält."""
playbook = self.service.get_playbook("pb_rechtlich")
assert playbook is not None
prompt = playbook.system_prompt.lower()
# Sollte Hinweis auf keine Rechtsberatung enthalten
assert "rechtsberatung" in prompt or "fachanwalt" in prompt
def test_foerderplan_playbook_mentions_privacy(self):
"""Test dass Förderplan-Playbook Datenschutz erwähnt."""
playbook = self.service.get_playbook("pb_foerderplan")
assert playbook is not None
prompt = playbook.system_prompt.lower()
# Sollte sensible Daten erwähnen
assert "sensib" in prompt or "vertraulich" in prompt or "daten" in prompt
def test_all_playbooks_have_required_fields(self):
"""Test dass alle Playbooks Pflichtfelder haben."""
playbooks = self.service.list_playbooks(status=None)
for playbook in playbooks:
assert playbook.id is not None
assert len(playbook.id) > 0
assert playbook.name is not None
assert len(playbook.name) > 0
assert playbook.system_prompt is not None
assert len(playbook.system_prompt) > 0
assert playbook.prompt_version is not None
def test_playbooks_have_tool_policy(self):
"""Test dass Playbooks Tool Policy haben."""
playbooks = self.service.list_playbooks()
for playbook in playbooks:
assert hasattr(playbook, "tool_policy")
# no_pii_in_output sollte standardmäßig true sein
assert playbook.tool_policy.get("no_pii_in_output") is True
class TestGetPlaybookServiceSingleton:
"""Tests für Singleton Pattern."""
def test_singleton_returns_same_instance(self):
"""Test dass get_playbook_service Singleton zurückgibt."""
service1 = get_playbook_service()
service2 = get_playbook_service()
assert service1 is service2

View File

@@ -0,0 +1,369 @@
"""
Tests für Tool Gateway Service.
"""
import pytest
from unittest.mock import AsyncMock, patch, MagicMock
import httpx
from llm_gateway.services.tool_gateway import (
ToolGateway,
ToolGatewayConfig,
SearchDepth,
SearchResult,
SearchResponse,
TavilyError,
ToolGatewayError,
get_tool_gateway,
)
from llm_gateway.services.pii_detector import PIIDetector, RedactionResult
class TestToolGatewayConfig:
"""Tests für ToolGatewayConfig."""
def test_default_config(self):
"""Test Standardkonfiguration."""
config = ToolGatewayConfig()
assert config.tavily_api_key is None
assert config.tavily_base_url == "https://api.tavily.com"
assert config.timeout == 30
assert config.max_results == 5
assert config.search_depth == SearchDepth.BASIC
assert config.include_answer is True
assert config.pii_redaction_enabled is True
def test_config_from_env(self):
"""Test Konfiguration aus Umgebungsvariablen."""
with patch.dict("os.environ", {
"TAVILY_API_KEY": "test-key",
"TAVILY_BASE_URL": "https://custom.tavily.com",
"TAVILY_TIMEOUT": "60",
"TAVILY_MAX_RESULTS": "10",
"TAVILY_SEARCH_DEPTH": "advanced",
"PII_REDACTION_ENABLED": "false",
}):
config = ToolGatewayConfig.from_env()
assert config.tavily_api_key == "test-key"
assert config.tavily_base_url == "https://custom.tavily.com"
assert config.timeout == 60
assert config.max_results == 10
assert config.search_depth == SearchDepth.ADVANCED
assert config.pii_redaction_enabled is False
class TestToolGatewayAvailability:
"""Tests für Gateway-Verfügbarkeit."""
def test_tavily_not_available_without_key(self):
"""Test Tavily nicht verfügbar ohne API Key."""
config = ToolGatewayConfig(tavily_api_key=None)
gateway = ToolGateway(config=config)
assert gateway.tavily_available is False
def test_tavily_available_with_key(self):
"""Test Tavily verfügbar mit API Key."""
config = ToolGatewayConfig(tavily_api_key="test-key")
gateway = ToolGateway(config=config)
assert gateway.tavily_available is True
class TestToolGatewayPIIRedaction:
"""Tests für PII-Redaktion im Gateway."""
def test_redact_query_with_email(self):
"""Test Redaktion von E-Mail in Query."""
config = ToolGatewayConfig(pii_redaction_enabled=True)
gateway = ToolGateway(config=config)
result = gateway._redact_query("Kontakt test@example.com Datenschutz")
assert result.pii_found is True
assert "test@example.com" not in result.redacted_text
assert "[EMAIL_REDACTED]" in result.redacted_text
def test_no_redaction_when_disabled(self):
"""Test keine Redaktion wenn deaktiviert."""
config = ToolGatewayConfig(pii_redaction_enabled=False)
gateway = ToolGateway(config=config)
result = gateway._redact_query("test@example.com")
assert result.pii_found is False
assert result.redacted_text == "test@example.com"
class TestToolGatewaySearch:
"""Tests für Suche mit Gateway."""
@pytest.mark.asyncio
async def test_search_raises_error_without_key(self):
"""Test Fehler bei Suche ohne API Key."""
config = ToolGatewayConfig(tavily_api_key=None)
gateway = ToolGateway(config=config)
with pytest.raises(ToolGatewayError, match="not configured"):
await gateway.search("test query")
@pytest.mark.asyncio
async def test_search_success(self):
"""Test erfolgreiche Suche."""
config = ToolGatewayConfig(tavily_api_key="test-key")
gateway = ToolGateway(config=config)
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"results": [
{
"title": "Test Result",
"url": "https://example.com",
"content": "Test content",
"score": 0.95,
}
],
"answer": "Test answer",
}
with patch.object(gateway, "_get_client") as mock_client:
mock_http = AsyncMock()
mock_http.post.return_value = mock_response
mock_client.return_value = mock_http
result = await gateway.search("Schulrecht Bayern")
assert result.query == "Schulrecht Bayern"
assert len(result.results) == 1
assert result.results[0].title == "Test Result"
assert result.answer == "Test answer"
@pytest.mark.asyncio
async def test_search_with_pii_redaction(self):
"""Test Suche mit PII-Redaktion."""
config = ToolGatewayConfig(
tavily_api_key="test-key",
pii_redaction_enabled=True,
)
gateway = ToolGateway(config=config)
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"results": [],
"answer": None,
}
with patch.object(gateway, "_get_client") as mock_client:
mock_http = AsyncMock()
mock_http.post.return_value = mock_response
mock_client.return_value = mock_http
result = await gateway.search("Kontakt test@example.com Datenschutz")
assert result.pii_detected is True
assert "email" in result.pii_types
assert result.redacted_query is not None
assert "test@example.com" not in result.redacted_query
# Prüfen dass redaktierte Query an Tavily gesendet wurde
call_args = mock_http.post.call_args
sent_query = call_args.kwargs["json"]["query"]
assert "test@example.com" not in sent_query
@pytest.mark.asyncio
async def test_search_http_error(self):
"""Test HTTP-Fehler bei Suche."""
config = ToolGatewayConfig(tavily_api_key="test-key")
gateway = ToolGateway(config=config)
mock_response = MagicMock()
mock_response.status_code = 429
mock_response.text = "Rate limit exceeded"
mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
"Rate limit",
request=MagicMock(),
response=mock_response,
)
with patch.object(gateway, "_get_client") as mock_client:
mock_http = AsyncMock()
mock_http.post.return_value = mock_response
mock_client.return_value = mock_http
with pytest.raises(TavilyError, match="429"):
await gateway.search("test")
@pytest.mark.asyncio
async def test_search_with_domain_filters(self):
"""Test Suche mit Domain-Filtern."""
config = ToolGatewayConfig(tavily_api_key="test-key")
gateway = ToolGateway(config=config)
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"results": [], "answer": None}
with patch.object(gateway, "_get_client") as mock_client:
mock_http = AsyncMock()
mock_http.post.return_value = mock_response
mock_client.return_value = mock_http
await gateway.search(
"test",
include_domains=["gov.de", "schule.de"],
exclude_domains=["wikipedia.org"],
)
call_args = mock_http.post.call_args
payload = call_args.kwargs["json"]
assert payload["include_domains"] == ["gov.de", "schule.de"]
assert payload["exclude_domains"] == ["wikipedia.org"]
class TestSearchResult:
"""Tests für SearchResult Dataclass."""
def test_search_result_creation(self):
"""Test SearchResult erstellen."""
result = SearchResult(
title="Test",
url="https://example.com",
content="Content",
score=0.9,
published_date="2024-01-15",
)
assert result.title == "Test"
assert result.url == "https://example.com"
assert result.score == 0.9
def test_search_result_defaults(self):
"""Test SearchResult Standardwerte."""
result = SearchResult(
title="Test",
url="https://example.com",
content="Content",
)
assert result.score == 0.0
assert result.published_date is None
class TestSearchResponse:
"""Tests für SearchResponse Dataclass."""
def test_search_response_creation(self):
"""Test SearchResponse erstellen."""
response = SearchResponse(
query="test query",
results=[],
pii_detected=False,
)
assert response.query == "test query"
assert len(response.results) == 0
assert response.pii_detected is False
def test_search_response_with_pii(self):
"""Test SearchResponse mit PII."""
response = SearchResponse(
query="original query",
redacted_query="redacted query",
results=[],
pii_detected=True,
pii_types=["email", "phone"],
)
assert response.pii_detected is True
assert "email" in response.pii_types
class TestSearchDepthEnum:
"""Tests für SearchDepth Enum."""
def test_search_depth_values(self):
"""Test SearchDepth Werte."""
assert SearchDepth.BASIC.value == "basic"
assert SearchDepth.ADVANCED.value == "advanced"
class TestGetToolGatewaySingleton:
"""Tests für Singleton Pattern."""
def test_singleton_returns_same_instance(self):
"""Test dass get_tool_gateway Singleton zurückgibt."""
gateway1 = get_tool_gateway()
gateway2 = get_tool_gateway()
assert gateway1 is gateway2
class TestToolGatewayHealthCheck:
"""Tests für Health Check."""
@pytest.mark.asyncio
async def test_health_check_without_tavily(self):
"""Test Health Check ohne Tavily."""
config = ToolGatewayConfig(tavily_api_key=None)
gateway = ToolGateway(config=config)
status = await gateway.health_check()
assert status["tavily"]["configured"] is False
assert status["tavily"]["healthy"] is False
@pytest.mark.asyncio
async def test_health_check_with_tavily_success(self):
"""Test Health Check mit erfolgreichem Tavily."""
config = ToolGatewayConfig(tavily_api_key="test-key")
gateway = ToolGateway(config=config)
with patch.object(gateway, "search") as mock_search:
mock_search.return_value = SearchResponse(
query="test",
results=[],
response_time_ms=100,
)
status = await gateway.health_check()
assert status["tavily"]["configured"] is True
assert status["tavily"]["healthy"] is True
assert status["tavily"]["response_time_ms"] == 100
@pytest.mark.asyncio
async def test_health_check_with_tavily_failure(self):
"""Test Health Check mit Tavily-Fehler."""
config = ToolGatewayConfig(tavily_api_key="test-key")
gateway = ToolGateway(config=config)
with patch.object(gateway, "search") as mock_search:
mock_search.side_effect = TavilyError("Connection failed")
status = await gateway.health_check()
assert status["tavily"]["configured"] is True
assert status["tavily"]["healthy"] is False
assert "error" in status["tavily"]
class TestToolGatewayClose:
"""Tests für Gateway-Cleanup."""
@pytest.mark.asyncio
async def test_close_client(self):
"""Test Client-Cleanup."""
config = ToolGatewayConfig(tavily_api_key="test-key")
gateway = ToolGateway(config=config)
# Simuliere dass Client erstellt wurde
mock_client = AsyncMock()
gateway._client = mock_client
await gateway.close()
mock_client.aclose.assert_called_once()
assert gateway._client is None

View File

@@ -0,0 +1,366 @@
"""
Integration Tests für Tools Routes.
Testet die API-Endpoints /llm/tools/search und /llm/tools/health.
"""
import pytest
from unittest.mock import AsyncMock, patch, MagicMock
from fastapi.testclient import TestClient
from fastapi import FastAPI
from llm_gateway.routes.tools import router
from llm_gateway.middleware.auth import verify_api_key
from llm_gateway.services.tool_gateway import (
ToolGateway,
SearchResponse,
SearchResult,
TavilyError,
ToolGatewayError,
get_tool_gateway,
)
# Test App erstellen mit Auth-Override
app = FastAPI()
app.include_router(router, prefix="/tools")
# Mock für Auth-Dependency
def mock_verify_api_key():
return "test-user"
class TestSearchEndpoint:
"""Tests für POST /tools/search."""
def setup_method(self):
"""Setup für jeden Test."""
# Auth-Dependency überschreiben für Tests
app.dependency_overrides[verify_api_key] = mock_verify_api_key
self.client = TestClient(app)
def teardown_method(self):
"""Cleanup nach jedem Test."""
app.dependency_overrides.clear()
def test_search_requires_auth(self):
"""Test dass Auth erforderlich ist."""
# Auth-Override entfernen für diesen Test
app.dependency_overrides.clear()
client = TestClient(app)
response = client.post(
"/tools/search",
json={"query": "test"},
)
# Ohne API-Key sollte 401/403 kommen
assert response.status_code in [401, 403]
def test_search_invalid_query_too_short(self):
"""Test Validierung: Query zu kurz."""
response = self.client.post(
"/tools/search",
json={"query": ""},
)
assert response.status_code == 422 # Validation Error
def test_search_invalid_max_results(self):
"""Test Validierung: max_results außerhalb Grenzen."""
response = self.client.post(
"/tools/search",
json={"query": "test", "max_results": 100}, # > 20
)
assert response.status_code == 422
def test_search_success(self):
"""Test erfolgreiche Suche."""
mock_gateway = MagicMock(spec=ToolGateway)
mock_gateway.search = AsyncMock(return_value=SearchResponse(
query="Datenschutz Schule",
results=[
SearchResult(
title="Datenschutz an Schulen",
url="https://example.com",
content="Informationen...",
score=0.9,
)
],
answer="Zusammenfassung",
pii_detected=False,
response_time_ms=1500,
))
app.dependency_overrides[get_tool_gateway] = lambda: mock_gateway
response = self.client.post(
"/tools/search",
json={"query": "Datenschutz Schule"},
)
assert response.status_code == 200
data = response.json()
assert data["query"] == "Datenschutz Schule"
assert len(data["results"]) == 1
assert data["results"][0]["title"] == "Datenschutz an Schulen"
assert data["pii_detected"] is False
def test_search_with_pii_redaction(self):
"""Test Suche mit PII-Erkennung."""
mock_gateway = MagicMock(spec=ToolGateway)
mock_gateway.search = AsyncMock(return_value=SearchResponse(
query="Kontakt test@example.com",
redacted_query="Kontakt [EMAIL_REDACTED]",
results=[],
pii_detected=True,
pii_types=["email"],
response_time_ms=1000,
))
app.dependency_overrides[get_tool_gateway] = lambda: mock_gateway
response = self.client.post(
"/tools/search",
json={"query": "Kontakt test@example.com"},
)
assert response.status_code == 200
data = response.json()
assert data["pii_detected"] is True
assert "email" in data["pii_types"]
assert data["redacted_query"] == "Kontakt [EMAIL_REDACTED]"
def test_search_with_domain_filters(self):
"""Test Suche mit Domain-Filtern."""
mock_gateway = MagicMock(spec=ToolGateway)
mock_gateway.search = AsyncMock(return_value=SearchResponse(
query="test",
results=[],
pii_detected=False,
response_time_ms=500,
))
app.dependency_overrides[get_tool_gateway] = lambda: mock_gateway
response = self.client.post(
"/tools/search",
json={
"query": "test",
"include_domains": ["bayern.de"],
"exclude_domains": ["wikipedia.org"],
},
)
assert response.status_code == 200
# Prüfen dass Filter an Gateway übergeben wurden
mock_gateway.search.assert_called_once()
call_kwargs = mock_gateway.search.call_args.kwargs
assert call_kwargs["include_domains"] == ["bayern.de"]
assert call_kwargs["exclude_domains"] == ["wikipedia.org"]
def test_search_gateway_error(self):
"""Test Fehlerbehandlung bei Gateway-Fehler."""
mock_gateway = MagicMock(spec=ToolGateway)
mock_gateway.search = AsyncMock(side_effect=ToolGatewayError("Not configured"))
app.dependency_overrides[get_tool_gateway] = lambda: mock_gateway
response = self.client.post(
"/tools/search",
json={"query": "test"},
)
assert response.status_code == 503
assert "unavailable" in response.json()["detail"].lower()
def test_search_tavily_error(self):
"""Test Fehlerbehandlung bei Tavily-Fehler."""
mock_gateway = MagicMock(spec=ToolGateway)
mock_gateway.search = AsyncMock(side_effect=TavilyError("Rate limit"))
app.dependency_overrides[get_tool_gateway] = lambda: mock_gateway
response = self.client.post(
"/tools/search",
json={"query": "test"},
)
assert response.status_code == 502
assert "search service error" in response.json()["detail"].lower()
class TestHealthEndpoint:
"""Tests für GET /tools/health."""
def setup_method(self):
"""Setup für jeden Test."""
app.dependency_overrides[verify_api_key] = mock_verify_api_key
self.client = TestClient(app)
def teardown_method(self):
"""Cleanup nach jedem Test."""
app.dependency_overrides.clear()
def test_health_requires_auth(self):
"""Test dass Auth erforderlich ist."""
app.dependency_overrides.clear()
client = TestClient(app)
response = client.get("/tools/health")
assert response.status_code in [401, 403]
def test_health_success(self):
"""Test erfolgreicher Health Check."""
mock_gateway = MagicMock(spec=ToolGateway)
mock_gateway.health_check = AsyncMock(return_value={
"tavily": {
"configured": True,
"healthy": True,
"response_time_ms": 1500,
},
"pii_redaction": {
"enabled": True,
},
})
app.dependency_overrides[get_tool_gateway] = lambda: mock_gateway
response = self.client.get("/tools/health")
assert response.status_code == 200
data = response.json()
assert data["tavily"]["configured"] is True
assert data["tavily"]["healthy"] is True
assert data["pii_redaction"]["enabled"] is True
def test_health_tavily_not_configured(self):
"""Test Health Check ohne Tavily-Konfiguration."""
mock_gateway = MagicMock(spec=ToolGateway)
mock_gateway.health_check = AsyncMock(return_value={
"tavily": {
"configured": False,
"healthy": False,
},
"pii_redaction": {
"enabled": True,
},
})
app.dependency_overrides[get_tool_gateway] = lambda: mock_gateway
response = self.client.get("/tools/health")
assert response.status_code == 200
data = response.json()
assert data["tavily"]["configured"] is False
class TestSearchRequestValidation:
"""Tests für Request-Validierung."""
def setup_method(self):
"""Setup für jeden Test."""
app.dependency_overrides[verify_api_key] = mock_verify_api_key
self.client = TestClient(app)
def teardown_method(self):
"""Cleanup nach jedem Test."""
app.dependency_overrides.clear()
def test_query_max_length(self):
"""Test Query max length Validierung."""
# Query mit > 1000 Zeichen
response = self.client.post(
"/tools/search",
json={"query": "x" * 1001},
)
assert response.status_code == 422
def test_search_depth_enum(self):
"""Test search_depth Enum Validierung."""
mock_gateway = MagicMock(spec=ToolGateway)
mock_gateway.search = AsyncMock(return_value=SearchResponse(
query="test",
results=[],
pii_detected=False,
response_time_ms=100,
))
app.dependency_overrides[get_tool_gateway] = lambda: mock_gateway
# Gültiger Wert
response = self.client.post(
"/tools/search",
json={"query": "test", "search_depth": "advanced"},
)
assert response.status_code == 200
def test_search_depth_invalid(self):
"""Test ungültiger search_depth Wert."""
response = self.client.post(
"/tools/search",
json={"query": "test", "search_depth": "invalid"},
)
assert response.status_code == 422
class TestSearchResponseFormat:
"""Tests für Response-Format."""
def setup_method(self):
"""Setup für jeden Test."""
app.dependency_overrides[verify_api_key] = mock_verify_api_key
self.client = TestClient(app)
def teardown_method(self):
"""Cleanup nach jedem Test."""
app.dependency_overrides.clear()
def test_response_has_all_fields(self):
"""Test dass Response alle erforderlichen Felder hat."""
mock_gateway = MagicMock(spec=ToolGateway)
mock_gateway.search = AsyncMock(return_value=SearchResponse(
query="test query",
redacted_query=None,
results=[
SearchResult(
title="Result 1",
url="https://example.com/1",
content="Content 1",
score=0.95,
published_date="2024-01-15",
),
],
answer="AI Summary",
pii_detected=False,
pii_types=[],
response_time_ms=2000,
))
app.dependency_overrides[get_tool_gateway] = lambda: mock_gateway
response = self.client.post(
"/tools/search",
json={"query": "test query"},
)
assert response.status_code == 200
data = response.json()
# Pflichtfelder
assert "query" in data
assert "results" in data
assert "pii_detected" in data
assert "pii_types" in data
assert "response_time_ms" in data
# Optionale Felder
assert "redacted_query" in data
assert "answer" in data
# Result-Felder
result = data["results"][0]
assert "title" in result
assert "url" in result
assert "content" in result
assert "score" in result
assert "published_date" in result