from contextlib import contextmanager from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer import json from pathlib import Path import sys import threading import time import pytest sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from summary import Summarizer, SummaryError, load_summarizer @contextmanager def llm_server(body=None, status=200, delay=0, redirect=None): requests = [] response = body if body is not None else {"choices": [{"message": {"content": "Rückruf morgen um zehn Uhr."}, "finish_reason": "stop"}]} class Handler(BaseHTTPRequestHandler): def do_POST(self): requests.append({"path": self.path, "authorization": self.headers.get("Authorization"), "body": json.loads(self.rfile.read(int(self.headers["Content-Length"])))}) time.sleep(delay) self.send_response(status) self.send_header("Content-Type", "application/json") if redirect: self.send_header("Location", redirect) self.end_headers() try: self.wfile.write(json.dumps(response).encode()) except BrokenPipeError: pass def log_message(self, *_): pass server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() try: yield f"http://127.0.0.1:{server.server_port}/v1", requests finally: server.shutdown() server.server_close() thread.join() def test_openai_compatible_request_contains_text_model_and_optional_bearer(): with llm_server() as (base_url, requests): summarize = Summarizer(base_url + "/", "local-model", "test-api-key") assert summarize("Bitte morgen um zehn Uhr zurückrufen.", "meeting") == "Rückruf morgen um zehn Uhr." assert requests[0]["path"] == "/v1/chat/completions" assert requests[0]["authorization"] == "Bearer test-api-key" payload = requests[0]["body"] assert payload["model"] == "local-model" assert payload["stream"] is False assert payload["messages"][0]["role"] == "system" assert "Aufgaben" in payload["messages"][0]["content"] assert payload["messages"][1] == {"role": "user", "content": "Bitte morgen um zehn Uhr zurückrufen."} assert "test-api-key" not in json.dumps(payload) Summarizer(base_url, "model-without-key")("Text", "short") assert requests[1]["authorization"] is None assert "fünf" in requests[1]["body"]["messages"][0]["content"] @pytest.mark.parametrize("body", [ {}, {"choices": []}, {"choices": [{"message": {"content": ""}}]}, {"choices": [{"message": {"content": None}}]}, {"choices": [{"message": {"content": "Halbe Antwort"}, "finish_reason": "length"}]}, {"choices": [{"message": {"content": "Unfertiger Gedankengang"}}]}, ]) def test_rejects_missing_empty_truncated_and_unfinished_responses(body): with llm_server(body) as (base_url, _): with pytest.raises(SummaryError, match="keine vollständige Zusammenfassung"): Summarizer(base_url, "local-model")("Text", "standard") def test_reasoning_is_not_exposed_as_summary(): body = {"choices": [{"message": {"content": "Interner Gedankengang\nFertige Zusammenfassung."}}]} with llm_server(body) as (base_url, _): assert Summarizer(base_url, "local-model")("Text", "standard") == "Fertige Zusammenfassung." def test_http_errors_do_not_expose_provider_body_or_api_key(): with llm_server({"error": "secret-api-key private transcript"}, status=401) as (base_url, _): with pytest.raises(SummaryError, match="HTTP 401") as error: Summarizer(base_url, "local-model", "secret-api-key")("private transcript", "standard") assert "secret-api-key" not in str(error.value) assert "private transcript" not in str(error.value) def test_no_redirect_can_forward_transcript_or_credentials(): with llm_server() as (target, target_requests): with llm_server(status=307, redirect=target + "/chat/completions") as (base_url, _): with pytest.raises(SummaryError, match="HTTP 307"): Summarizer(base_url, "local-model", "test-key")("Text", "standard") assert not target_requests def test_timeout_is_retryable_summary_error(): with llm_server(delay=.1) as (base_url, _): with pytest.raises(SummaryError, match="nicht rechtzeitig"): Summarizer(base_url, "local-model", timeout=.01)("Text", "standard") def test_long_transcript_is_fully_processed_before_merging(monkeypatch): summarizer = Summarizer("http://localhost/v1", "model", chunk_chars=1000) transcript = " ".join(f"Wort{number}" for number in range(600)) seen = [] def complete(text, style, partial=False): seen.append((text, style, partial)) return f"Teil {len(seen)}" if partial else "Gesamtergebnis" monkeypatch.setattr(summarizer, "complete", complete) assert summarizer(transcript, "meeting") == "Gesamtergebnis" chunks = [text for text, _, partial in seen if partial] assert " ".join(chunks) == transcript assert all(len(part) <= 1000 for part in chunks) assert seen[-1] == ("\n\n".join(f"Teil {n}" for n in range(1, len(chunks) + 1)), "meeting", False) def test_non_reducing_model_does_not_loop_forever(monkeypatch): summarizer = Summarizer("http://localhost/v1", "model", chunk_chars=1000) monkeypatch.setattr(summarizer, "complete", lambda text, *args, **kwargs: text) with pytest.raises(SummaryError, match="nicht ausreichend gekürzt"): summarizer("wort " * 1000, "meeting") @pytest.mark.parametrize("url", ["file:///tmp/model", "http://user:secret@localhost/v1", "http://localhost/v1?key=secret", "http://localhost/v1#section"]) def test_invalid_base_urls_are_rejected(url): with pytest.raises(ValueError, match="LLM_BASE_URL"): Summarizer(url, "model") def test_env_config_is_optional_and_passed_to_client(monkeypatch): for name in ["LLM_BASE_URL", "LLM_MODEL", "LLM_API_KEY", "LLM_TIMEOUT_SECONDS", "LLM_CHUNK_CHARS"]: monkeypatch.delenv(name, raising=False) assert load_summarizer() is None monkeypatch.setenv("LLM_BASE_URL", "http://localhost:1234/v1") with pytest.raises(SummaryError, match="LLM_MODEL"): load_summarizer()("Transkript", "meeting") monkeypatch.setenv("LLM_MODEL", "configured-model") monkeypatch.setenv("LLM_API_KEY", "test-key") monkeypatch.setenv("LLM_TIMEOUT_SECONDS", "45") monkeypatch.setenv("LLM_CHUNK_CHARS", "8000") client = load_summarizer() assert (client.base_url, client.model, client.api_key, client.timeout, client.chunk_chars) == ( "http://localhost:1234/v1", "configured-model", "test-key", 45, 8000)