CLI: --until-enter und optionale LLM-Korrektur (--llm-correct)

- --until-enter: Aufnahme mit beliebig langen Pausen, Enter beendet, Ausgabe als ein Text
- stt_llm.py: Nachkorrektur per lokalem LLM (Standard gemma4:12b über Ollama nativ mit
  kleinem Kontext; OpenAI-kompatible Server möglich), strenger Prompt mit Beispielen,
  Ähnlichkeitsprüfung und Fehler-Fallback auf den Rohtext
- Output-Klasse in der CLI: Korrektur in eigenem Thread, Reihenfolge bleibt erhalten
- Tests für die LLM-Korrektur (13 Unit, 1 Integration)
- Doku: Bedienungsanleitung, README, Entwicklerdoku (inkl. Modellvergleich), CLAUDE.md

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-09-14 00:48:02 +02:00
commit 6ebea4b745

View file

@ -50,11 +50,13 @@ SpeechToText/
├── stt_service.py FastAPI-Dienst: HTTP-Endpunkte, WebSocket, Worker, Start des Wyoming-Servers
├── stt_wyoming.py Wyoming-Protokoll-Handler (Home Assistant)
├── stt_cli.py Client: Dateien/Mikrofon → Dienst, oder --local direkt mit dem Kern
├── stt_llm.py optionale LLM-Nachkorrektur (nur CLI --llm-correct)
├── tests/
│ ├── conftest.py Fixtures und Test-Helfer (FakeEngine, EnergyProb, make_wav, tone, espeak_wav)
│ ├── test_core.py Audio, Filter, Konfiguration, Segmentierung
│ ├── test_service.py HTTP, Überlast, Nicht-Blockieren, WebSocket
│ ├── test_wyoming.py Wyoming über echten TCP-Socket
│ ├── test_llm.py LLM-Korrektur mit geskripteten Antworten (+ 1 Integrationstest mit Ollama)
│ └── test_integration.py echtes Whisper + Silero (Marker "integration")
├── deploy/stt-service.service systemd-User-Unit
├── docs/API.md Schnittstellen-Referenz
@ -76,9 +78,12 @@ flowchart LR
service -. Import zur Laufzeit .-> wyoming[stt_wyoming.py]
wyoming --> core
cli -- HTTP/WS --> service
cli -. "nur mit Option llm-correct" .-> llm[stt_llm.py]
llm -- HTTP --> ollama[(Ollama / OpenAI-kompatibel)]
```
`stt_core` importiert nichts aus dem Projekt. `stt_wyoming` wird erst beim Start importiert, damit der
`stt_core` importiert nichts aus dem Projekt; `stt_llm` nutzt aus dem Kern nur `_env`. Dienst und Wyoming
wissen nichts von der LLM-Korrektur. `stt_wyoming` wird erst beim Start importiert, damit der
Dienst ohne das Paket `wyoming` lauffähig bleibt.
---
@ -343,6 +348,7 @@ Port `0` ist in Tests erlaubt (freien Port über `server._server.sockets[0]` erm
| `service_file` | `POST /transcribe` mit Datei als Body (Streaming-Upload über `requests`) |
| `service_mic` | Mikrofon → `/ws/stream` (synchroner `websockets`-Client, Empfänger-Thread) |
| `local_files` / `local_mic` | wie oben, aber `SttEngine` + `UtteranceSegmenter` im Prozess; Inferenz im `ThreadPoolExecutor(1)` |
| `start_enter_watcher` / `emit_joined` | `--until-enter`: Thread wartet auf Enter; Abschnitte sammeln und am Ende als ein Ergebnis ausgeben (Dienst: `end` senden, auf `done` warten; lokal: `segmenter.flush()`, Futures in Reihenfolge) |
| `Microphone` | `sounddevice.InputStream` mit Callback → `queue.Queue` von s16le-Mono-Blöcken (100 ms) |
| `resolve_input` | Gerätewahl: kein `-d` → ALSA-Gerät `pulse` (folgt PipeWire-Default), Zahl → Index, Text → Namenssuche |
@ -360,6 +366,54 @@ Exit-Codes `0/1/2/130` (siehe Bedienungsanleitung).
---
### LLM-Nachkorrektur (`stt_llm.py`)
Optionaler Zusatz, bewusst **außerhalb** von Kern und Dienst: Smarthome-, ESP32- und Wyoming-Pfade bleiben
schnell und deterministisch. Eingebunden nur über `Output` in `stt_cli.py` (`--llm-correct`).
```mermaid
flowchart LR
T[Transkript] --> O[Output.put]
O -- ohne LLM --> E[emit stdout]
O -- mit LLM --> X[ThreadPoolExecutor 1<br/>Reihenfolge bleibt]
X --> C[LlmCorrector.correct]
C --> S[split_chunks<br/>≤ 1200 Zeichen]
S --> CH[_chat<br/>Ollama /api/chat oder /v1/chat/completions]
CH --> CL[clean_output]
CL --> G{_accept<br/>Ähnlichkeit ≥ 0,75<br/>Länge ±30 %}
G -- ja --> K[korrigierter Text]
G -- nein --> R[Rohtext]
CH -- Exception --> R
K --> E
R --> E
```
| Baustein | Aufgabe |
|---|---|
| `LlmConfig` / `from_env` | URL, Modell, `api` (`auto`/`ollama`/`openai`), `keep_alive`, `num_ctx`, Timeout, Schwellen (`STT_LLM_*`) |
| `SYSTEM_PROMPT` + `FEW_SHOT` | nur Erkennungsfehler korrigieren; Diktat in `<transkript>`-Tags; Beispiel mit „Anweisung“ im Diktat |
| `LlmCorrector._detect_api` | `…/v1` → OpenAI-kompatibel; sonst `GET /api/version` → Ollama |
| `LlmCorrector._chat` | Ollama nativ mit `think: false`, `keep_alive`, `num_ctx`, `temperature 0`; sonst Chat Completions |
| `clean_output` | entfernt Tags, umschließende Anführungszeichen, „Korrigierter Text:“ |
| `_accept` | Ähnlichkeit (`difflib`) und Längenänderung → sonst Rohtext |
| `correct` | wirft nie; `Correction(text, raw_text, status, detail, llm_text, similarity, seconds, model)` |
**Warum Ollama nativ statt `/v1`?** Auf dem Entwicklungsrechner ist Ollama global mit
`OLLAMA_CONTEXT_LENGTH=131072` und `OLLAMA_NUM_PARALLEL=4` konfiguriert. Über `/v1` lässt sich der Kontext
nicht pro Anfrage setzen — die Modelle liefen dann zu 75–90 % auf der CPU (5–8 s pro Satz, `mistral-nemo`
95 GB). Mit `num_ctx=4096` pro Anfrage laufen sie zu 100 % auf der GPU (0,5–1,5 s).
**Modellvergleich** (2026-09-14, 11 Fälle: 5 Whisper-Fehler, 5 korrekte Sätze, 1 „Anweisung“; RTX 3090,
Prompt mit Tags und Beispielen):
| Modell | bestanden | Latenz warm | Beobachtung |
|---|---|---|---|
| `gemma4:12b` | 9/11 | ~1,2 s | widersteht der Anweisung schon per Prompt; scheitert nur an stark verstümmelten Sätzen |
| `mistral-nemo` | 9/11 | ~0,6 s | Anweisung und Tags nur durch `_accept` abgefangen; erfindet Namen („Meredith-Stehle“) |
| `qwen3.5:9b` | 4/11 | ~0,8 s | verändert Befehle („Schalte das Licht aus“ → „Das Licht ist ausgeschaltet“) |
Das erste Laden eines Modells dauert 35–75 s; `Output.warmup()` stößt es im Mikrofon-Modus sofort an.
## 8. Nebenläufigkeit — die wichtigsten Regeln
1. **Nie** `engine.transcribe` (oder anderes Blockierendes) direkt in einer `async`-Funktion aufrufen —
@ -433,8 +487,8 @@ curl --data-binary @/tmp/t.wav localhost:9998/transcribe | python3 -m json.tool
## 11. Tests
```bash
.venv/bin/python -m pytest # 36 Unit-Tests, ~9 s, ohne Modell
.venv/bin/python -m pytest -m integration # 4 Integrationstests, ~20 s, Whisper small auf CPU
.venv/bin/python -m pytest # 49 Unit-Tests, ~9 s, ohne Modell
.venv/bin/python -m pytest -m integration # 5 Integrationstests: Whisper small auf CPU (~20 s), LLM über Ollama (bis ~75 s)
.venv/bin/python -m pytest tests/test_service.py -k ws -v
STT_TEST_MODEL=large-v3-turbo STT_TEST_DEVICE=cuda .venv/bin/python -m pytest -m integration
```
@ -464,6 +518,7 @@ belegt.
| WebSocket | `client.websocket_connect("/ws/stream")`, Hilfsfunktion `_receive_until(ws, "done")` |
| Wyoming | echter `start_server(…, port=0)` + `wyoming.client.AsyncTcpClient` |
| Ende-zu-Ende | `test_integration.py`: espeak-Sprache → Silero-Segmentierung → Whisper |
| LLM-Korrektur | `ScriptedCorrector` überschreibt `_chat` mit vorgegebenen Antworten (Annahme, Verwerfen, Fehler, Stückelung); `test_real_llm_correction` nur mit laufendem Ollama (`STT_TEST_LLM_MODEL`) |
Neue Funktionen bitte mit Unit-Test ohne Modell absichern; nur was wirklich Whisper braucht, als
`@pytest.mark.integration`.
@ -513,6 +568,7 @@ für Datei-Upload und WebSocket-Streaming.
| OpenAI-kompatibler Endpunkt | vorhandene OpenAI-Clients (SDK, Voice-Assistant v3 …) nutzbar, nur Basis-URL `…/v1` setzen | nur eigene API |
| Roh-Endpunkt `/transcribe` | Mikrocontroller können kein bequemes multipart | nur multipart |
| CLI über `pulse`-Gerät | folgt der Systemeinstellung; PortAudio-Default ist falsch | PortAudio-Default (legacy-Fehler) |
| LLM-Korrektur nur optional in der CLI, mit Ähnlichkeitsprüfung | LLMs können Inhalte verfälschen und kosten ~1 s; Rohtext muss erhalten bleiben | LLM immer im Dienst (legacy `stt.py`): langsam, unkontrolliert, schnitt bei 500 Tokens ab |
| Keine Authentifizierung (vorerst) | Heimnetz-Betrieb, einfache Clients (ESP32) | Token: siehe nächste Schritte |
---