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,265 @@
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
import struct
|
||||
import sqlite3
|
||||
import sys
|
||||
import threading
|
||||
from types import SimpleNamespace
|
||||
import uuid
|
||||
import wave
|
||||
|
||||
from fastapi import HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
import service
|
||||
|
||||
|
||||
def wav():
|
||||
output = BytesIO()
|
||||
with wave.open(output, "wb") as audio:
|
||||
audio.setnchannels(1)
|
||||
audio.setsampwidth(2)
|
||||
audio.setframerate(16000)
|
||||
audio.writeframes(struct.pack("<h", 100) * 1600)
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
def upload(jobs, key=None, data=None, language="de"):
|
||||
return jobs.upload(BytesIO(wav() if data is None else data), "call.wav",
|
||||
key or "swyxweb:" + str(uuid.uuid4()), language, "meeting")
|
||||
|
||||
|
||||
def test_job_and_audio_survive_restart_and_complete_without_summary(tmp_path):
|
||||
calls = []
|
||||
|
||||
def transcribe(path, language):
|
||||
calls.append((path.read_bytes(), language))
|
||||
return "Bitte morgen zurückrufen."
|
||||
|
||||
jobs = service.Jobs(tmp_path, transcribe)
|
||||
job = upload(jobs)
|
||||
assert job["status"] == "queued"
|
||||
# Simulate interruption after acknowledgement and during inference.
|
||||
with jobs.db() as db:
|
||||
db.execute("UPDATE jobs SET status='transcribing' WHERE id=?", (job["id"],))
|
||||
restarted = service.Jobs(tmp_path, transcribe)
|
||||
assert restarted.get(job["id"])["status"] == "queued"
|
||||
assert restarted.process_next()
|
||||
result = restarted.get(job["id"])
|
||||
assert result["status"] == "done"
|
||||
assert result["transcript"] == "Bitte morgen zurückrufen."
|
||||
assert result["summary"] == ""
|
||||
assert calls == [(wav(), "de")]
|
||||
assert not restarted.process_next()
|
||||
assert service.Jobs(tmp_path, transcribe).get(job["id"]) == result
|
||||
|
||||
|
||||
def test_concurrent_duplicate_uploads_create_one_durable_job(tmp_path):
|
||||
jobs = service.Jobs(tmp_path, lambda *_: "Text")
|
||||
key = "swyxweb:" + str(uuid.uuid4())
|
||||
with ThreadPoolExecutor(max_workers=4) as pool:
|
||||
results = list(pool.map(lambda _: upload(jobs, key), range(8)))
|
||||
assert len({job["id"] for job in results}) == 1
|
||||
assert len(list(tmp_path.glob("*.wav"))) == 1
|
||||
with pytest.raises(HTTPException) as conflict:
|
||||
upload(jobs, key, language="en")
|
||||
assert conflict.value.status_code == 409
|
||||
with pytest.raises(HTTPException) as conflict:
|
||||
upload(jobs, key, data=wav() + b"changed")
|
||||
assert conflict.value.status_code == 409
|
||||
assert len(list(tmp_path.glob("*.wav"))) == 1
|
||||
|
||||
|
||||
def test_failed_job_can_be_retried_without_reuploading(tmp_path):
|
||||
def failure(*_):
|
||||
raise ValueError("Unreadable audio")
|
||||
|
||||
jobs = service.Jobs(tmp_path, failure)
|
||||
job = upload(jobs)
|
||||
assert jobs.process_next()
|
||||
assert jobs.get(job["id"])["status"] == "failed"
|
||||
assert jobs.get(job["id"])["error"]
|
||||
jobs.transcribe = lambda *_: "Erneut transkribiert."
|
||||
assert jobs.retry(job["id"])["status"] == "queued"
|
||||
with pytest.raises(HTTPException) as conflict:
|
||||
jobs.retry(job["id"])
|
||||
assert conflict.value.status_code == 409
|
||||
jobs.process_next()
|
||||
assert jobs.get(job["id"])["transcript"] == "Erneut transkribiert."
|
||||
assert jobs.get(job["id"])["error"] == ""
|
||||
|
||||
|
||||
@pytest.mark.parametrize("data", [b"", b"not a WAV recording"])
|
||||
def test_invalid_upload_does_not_leave_files_or_jobs(tmp_path, data):
|
||||
jobs = service.Jobs(tmp_path, lambda *_: "")
|
||||
with pytest.raises(HTTPException) as error:
|
||||
upload(jobs, data=data)
|
||||
assert error.value.status_code == 400
|
||||
assert not list(tmp_path.glob("*.wav"))
|
||||
assert not jobs.process_next()
|
||||
|
||||
|
||||
def test_size_limit_cleans_up_partial_file(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(service, "MAX_BYTES", 100)
|
||||
jobs = service.Jobs(tmp_path, lambda *_: "")
|
||||
with pytest.raises(HTTPException) as error:
|
||||
upload(jobs)
|
||||
assert error.value.status_code == 413
|
||||
assert not list(tmp_path.glob("*.wav"))
|
||||
|
||||
|
||||
def test_http_contract_remains_responsive_during_transcription(tmp_path):
|
||||
started, release = threading.Event(), threading.Event()
|
||||
|
||||
def transcribe(*_):
|
||||
started.set()
|
||||
assert release.wait(5)
|
||||
return "Lokales Transkript"
|
||||
|
||||
with TestClient(service.create_app(tmp_path, transcribe), base_url="http://127.0.0.1") as client:
|
||||
try:
|
||||
response = client.post("/api/jobs", files={"file": ("call.wav", wav(), "audio/wav")},
|
||||
data={"language": "de", "style": "meeting"}, headers={"Idempotency-Key": "swyxweb:" + str(uuid.uuid4())})
|
||||
assert response.status_code == 202
|
||||
job = response.json()
|
||||
assert set(job) == set(service.PUBLIC_FIELDS)
|
||||
assert started.wait(5)
|
||||
assert client.get("/health").status_code == 200
|
||||
assert client.get("/api/jobs/" + job["id"]).json()["status"] == "transcribing"
|
||||
assert client.post("/api/jobs/" + job["id"] + "/retry").status_code == 409
|
||||
assert client.get("/api/jobs/" + "0" * 32).status_code == 404
|
||||
assert client.get("/api/jobs/invalid").status_code == 400
|
||||
assert client.get("/health", headers={"Host": "untrusted.example"}).status_code == 400
|
||||
finally:
|
||||
release.set()
|
||||
|
||||
|
||||
def test_whisper_runs_cpu_offline_and_consumes_all_segments(monkeypatch):
|
||||
consumed = []
|
||||
|
||||
class Model:
|
||||
def __init__(self, path, **options):
|
||||
assert path == "/models/test"
|
||||
assert options == {"device": "cpu", "compute_type": "int8", "local_files_only": True,
|
||||
"cpu_threads": 2, "num_workers": 1}
|
||||
|
||||
def transcribe(self, path, **options):
|
||||
assert path == "call.wav"
|
||||
assert options["language"] is None
|
||||
assert options["task"] == "transcribe"
|
||||
|
||||
def segments():
|
||||
for text in [" Guten Tag.", " Bitte zurückrufen."]:
|
||||
consumed.append(text)
|
||||
yield SimpleNamespace(text=text)
|
||||
|
||||
return segments(), SimpleNamespace(language="de")
|
||||
|
||||
monkeypatch.setenv("WHISPER_MODEL_PATH", "/models/test")
|
||||
monkeypatch.setenv("WHISPER_THREADS", "2")
|
||||
monkeypatch.setitem(sys.modules, "faster_whisper", SimpleNamespace(WhisperModel=Model))
|
||||
assert service.load_whisper()(Path("call.wav"), "auto") == "Guten Tag. Bitte zurückrufen."
|
||||
assert len(consumed) == 2
|
||||
|
||||
|
||||
def test_real_audio_decoder_is_compatible_with_installed_pyav():
|
||||
audio = pytest.importorskip("faster_whisper.audio")
|
||||
decoded = audio.decode_audio(BytesIO(wav()), sampling_rate=16000)
|
||||
assert decoded.shape == (1600,)
|
||||
assert decoded[0] == pytest.approx(100 / 32768)
|
||||
|
||||
|
||||
def test_summary_failure_preserves_transcript_and_retry_skips_whisper(tmp_path):
|
||||
calls = []
|
||||
|
||||
def transcribe(*_):
|
||||
calls.append("whisper")
|
||||
return "Rückruf am Freitag."
|
||||
|
||||
def fail_summary(text, style):
|
||||
assert text == "Rückruf am Freitag."
|
||||
assert style == "meeting"
|
||||
raise service.SummaryError("LLM vorübergehend nicht erreichbar.")
|
||||
|
||||
jobs = service.Jobs(tmp_path, transcribe, fail_summary)
|
||||
job = upload(jobs)
|
||||
jobs.process_next()
|
||||
result = jobs.get(job["id"])
|
||||
assert result["status"] == "failed"
|
||||
assert result["stage"] == "Zusammenfassung fehlgeschlagen"
|
||||
assert result["transcript"] == "Rückruf am Freitag."
|
||||
assert "LLM" in result["error"]
|
||||
jobs.summarize = lambda text, style: "Am Freitag zurückrufen."
|
||||
jobs.retry(job["id"])
|
||||
jobs.process_next()
|
||||
result = jobs.get(job["id"])
|
||||
assert result["status"] == "done"
|
||||
assert result["summary"] == "Am Freitag zurückrufen."
|
||||
assert result["error"] == ""
|
||||
assert calls == ["whisper"]
|
||||
|
||||
|
||||
def test_transcript_is_available_while_summary_is_still_running(tmp_path):
|
||||
started, release = threading.Event(), threading.Event()
|
||||
|
||||
def summarize(*_):
|
||||
started.set()
|
||||
assert release.wait(5)
|
||||
return "Zusammenfassung"
|
||||
|
||||
with TestClient(service.create_app(tmp_path, lambda *_: "Gesprächstext", summarize), base_url="http://127.0.0.1") as client:
|
||||
try:
|
||||
response = client.post("/api/jobs", files={"file": ("call.wav", wav(), "audio/wav")},
|
||||
headers={"Idempotency-Key": "swyxweb:" + str(uuid.uuid4())})
|
||||
assert response.status_code == 202
|
||||
assert started.wait(5)
|
||||
job = client.get("/api/jobs/" + response.json()["id"]).json()
|
||||
assert job["status"] == "summarizing"
|
||||
assert job["transcript"] == "Gesprächstext"
|
||||
assert job["summary"] == ""
|
||||
assert client.get("/health").status_code == 200
|
||||
finally:
|
||||
release.set()
|
||||
|
||||
|
||||
def test_restart_resumes_summary_without_retranscribing(tmp_path):
|
||||
jobs = service.Jobs(tmp_path, lambda *_: "Gesprächstext")
|
||||
job = upload(jobs)
|
||||
jobs.process_next()
|
||||
with jobs.db() as db:
|
||||
db.execute("UPDATE jobs SET status='summarizing' WHERE id=?", (job["id"],))
|
||||
|
||||
def unexpected_transcription(*_):
|
||||
pytest.fail("An existing transcript must be reused")
|
||||
|
||||
restarted = service.Jobs(tmp_path, unexpected_transcription, lambda text, _: "Ergebnis: " + text)
|
||||
assert restarted.get(job["id"])["status"] == "queued"
|
||||
restarted.process_next()
|
||||
assert restarted.get(job["id"])["summary"] == "Ergebnis: Gesprächstext"
|
||||
|
||||
|
||||
def test_silent_recording_does_not_call_llm(tmp_path):
|
||||
def unexpected_summary(*_):
|
||||
pytest.fail("Silence must not be sent to the LLM")
|
||||
|
||||
jobs = service.Jobs(tmp_path, lambda *_: "", unexpected_summary)
|
||||
job = upload(jobs)
|
||||
jobs.process_next()
|
||||
assert jobs.get(job["id"])["status"] == "done"
|
||||
assert jobs.get(job["id"])["stage"] == "Keine Sprache erkannt"
|
||||
|
||||
|
||||
def test_existing_database_is_migrated_without_losing_results(tmp_path):
|
||||
jobs = service.Jobs(tmp_path, lambda *_: "Vorhandenes Transkript")
|
||||
job = upload(jobs)
|
||||
jobs.process_next()
|
||||
with sqlite3.connect(tmp_path / "jobs.sqlite3") as db:
|
||||
db.execute("ALTER TABLE jobs DROP COLUMN transcribed")
|
||||
migrated = service.Jobs(tmp_path, lambda *_: "")
|
||||
assert migrated.get(job["id"])["transcript"] == "Vorhandenes Transkript"
|
||||
assert migrated.get(job["id"])["status"] == "done"
|
||||
with migrated.db() as db:
|
||||
assert db.execute("SELECT transcribed FROM jobs WHERE id=?", (job["id"],)).fetchone()[0] == 1
|
||||
@@ -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