Gesprächsaufnahmen mit lokalem Whisper und LLM verarbeiten
This commit is contained in:
1 parent
373081d33d
commit
55e5d34e83
39 files changed
+2931
-74
No files matched your search
@@ -0,0 +1,150 @@
|
||||
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": "<think>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": "<think>Interner Gedankengang</think>\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)
|
||||
Reference in new issue
Block a user