my_voice_assistant_v3/Docs/voice-assistant-architecture.md
Dieter Schlüter 1899663308 docs: lokales TTS (piper in-process) + Start-Warm-up dokumentieren
README/BEDIENUNGSANLEITUNG/Architektur: piper laeuft in-process (gecachtes
Stimmmodell, kein Subprozess pro Satz), In-Process-Resampling, lokale Modelle
werden beim Start vorgeladen. .[local] installiert jetzt faster-whisper UND
piper-tts. Latenz-Hinweis erster Ton 5,8 s -> ~1,6 s.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 08:32:05 +02:00

256 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Architektur: Modularer Voice-Assistent
> Stand: aktueller Implementierungsstand des Gateways. Dieses Dokument beschreibt
> das Konzept, den umgesetzten Stand und die geplanten nächsten Schritte.
## 1. Ziel & Leitidee
Ein modularer, **cloud-first, aber hybrid betreibbarer** Sprachassistent für
Senioren — ein „digitaler Vertrauter", erreichbar von zuhause und unterwegs.
Der Hauptbetrieb läuft in der Cloud / auf einem vHost (Senioren sollen keinen
teuren KI-Rechner zuhause brauchen). Gleichzeitig muss jede Achse frei wählbar
bleiben, je nach Installation und Entwicklungs-/Testbedarf:
- **Hardware** — Audio-Eingabe und -Ausgabe (lokal, Bluetooth, Handy, Netzwerk)
- **Betrieb** — lokal, Cloud oder hybrid
- **Software** — lokale oder entfernte KI (STT / LLM / TTS), ganz oder teilweise
Designziele: geringe Latenz, robuste Fallbacks, gute Debugbarkeit, klare Trennung
von **semantischer** und **gesprochener** Antwort, und vor allem **Austauschbarkeit**:
einzelne Module gegen andere tauschen, ohne das Programm umzuschreiben.
## 2. Architekturprinzip — fünf Ebenen
1. **Audio Endpoints** konkrete Quellen/Ziele (lokal, Bluetooth, Handy, WebRTC)
2. **Device Router** wählt passende Input-/Output-Endpunkte
3. **Speech Pipeline** STT, Input-Cleaner, Dialog-LLM, Spoken-Response-Adapter, TTS-Normalizer, TTS
4. **Transport Router** lokale vs. entfernte Ausführung einzelner Module
5. **Orchestrator** Session, Turn-Taking, Fallbacks, Metrik
Jede Ebene kommuniziert über wohldefinierte Interfaces (ABCs + Pydantic-Schemas),
nicht über konkrete Bibliotheken.
## 3. Konfigurations- & Routing-Modell (umgesetzt)
Das Herzstück der Austauschbarkeit. Jede Achse ist auf mehreren Ebenen
einstellbar; höhere Ebene gewinnt:
```
eingebaute Defaults < config/voice-assistant.toml (inkl. aktivem Profil)
< ENV / .env < Nutzer-Prefs < Session-Route < Request
```
### 3.1 Zentrale Config + Profile
Eine geschichtete zentrale Datei (`config/voice-assistant.toml`, Vorlage
`config/voice-assistant.example.toml`), gelesen über die stdlib (`tomllib`).
**Profile** bündeln Betriebsarten und werden per ENV `VA_PROFILE` aktiviert:
| Profil | STT | LLM | TTS |
|-------------|----------------|--------------------------|------------|
| `local-dev` | faster-whisper | local-openai-compatible | piper |
| `hybrid` | openrouter | local-openai-compatible | openrouter |
| `cloud` | openrouter | openrouter | openrouter |
Umgesetzt in `app/config.py`: `load_profile_config()` merged `[defaults]` +
`[profiles.<aktiv>]`; `TomlProfileSource` hängt diese Werte als Settings-Quelle
**unter** ENV ein (`settings_customise_sources`). `VA_PROFILE`/`VA_CONFIG_FILE`
werden aus echter Umgebung **oder** `.env` gelesen.
> **Secrets gehören nie in die Config-Datei** — nur in die Umgebung
> (z. B. `OPENROUTER_API_KEY`). Die TOML-Datei darf versioniert/geteilt werden.
### 3.2 Einheitliche Route-Auflösung
`app/dependencies.py` löst pro Aufruf eine `ResolvedRoute` auf
(`resolve_route(user, session_id, overrides)`): Defaults < Nutzer-Prefs <
Session-Route < Request. Die Route umfasst `input_endpoint`, `output_endpoint`,
`stt_provider`, `llm_provider`, `tts_provider`, `language`. Gehört eine Session
einem anderen Nutzer, wird `SessionOwnershipError` (→ 403) ausgelöst.
### 3.3 Registry-Pattern (Provider austauschbar)
`STT_REGISTRY` / `LLM_REGISTRY` / `TTS_REGISTRY` bilden `name -> factory(settings)`.
Ein neuer Provider = ein Eintrag, ohne Kern-Code zu ändern. Unbekannter Name →
`UnknownComponentError` → HTTP 422.
### 3.4 Device Router
`app/audio/router.py` wählt Endpunkte per `id`- oder `kind`-Match. Ein angefragter,
aber unbekannter Endpunkt wirft `UnknownEndpointError` (→ 422) — **kein** stiller
Default-Fallback. Ohne Wunsch greift der als `default` markierte Endpunkt. Der
Router ist ein Singleton (stabiler Zustand, z. B. `LoopbackOutput`).
## 4. Datenmodelle & Interfaces
Schemas in `app/schemas.py`, Interfaces als ABCs in den jeweiligen `base.py`.
```python
class EndpointCapabilities(BaseModel):
id: str; kind: str
direction: Literal["input", "output"]
sample_rate: int = 16000; channels: int = 1
latency_class: Literal["low", "medium", "high"] = "medium"
supports_aec: bool = False; supports_barge_in: bool = False
networked: bool = False; bluetooth: bool = False
mobile: bool = False; default: bool = False
class AudioChunk(BaseModel):
data: bytes; sample_rate: int = 16000; channels: int = 1
format: str = "wav"; timestamp_ms: int = 0
class PipelineTrace(BaseModel):
raw_transcript: str | None = None
cleaned_transcript: str | None = None
semantic_response: str | None = None
spoken_response: str | None = None
tts_ready_text: str | None = None
```
Provider-Interfaces:
```python
class STTProvider(ABC):
async def transcribe(self, audio_bytes: bytes, fmt: str, language: str | None = None) -> str: ...
class LLMProvider(ABC):
async def complete(self, text: str, session_id: str | None = None) -> str: ...
class TTSProvider(ABC):
async def synthesize(self, text: str, voice: str | None = None, audio_format: str = "pcm") -> bytes: ...
```
Audio-Endpunkt-Interfaces: `AudioInputEndpoint` (`capabilities/open/read_chunk/close`),
`AudioOutputEndpoint` (`capabilities/open/write_chunk/flush/close`) — alle async.
## 5. Speech Pipeline
Trennung von **semantischer** und **gesprochener** Antwort ist zentral: eine
inhaltlich gute Antwort ist nicht automatisch gut hörbar.
Stufen: `raw_transcript → cleaned_transcript → semantic_response → spoken_response → tts_ready_text`.
- **Input Cleaner** (`pipeline/input_cleaner.py`) konservative Bereinigung des STT-Texts (Füllwörter, Whitespace). Verändert die Nutzerintention nicht.
- **Dialog-LLM** semantische Antwort; Persona/Sicherheitsregeln im System-Prompt (`providers/llm/openrouter.py`).
- **Spoken Response Adapter** (`pipeline/spoken_response_adapter.py`) macht die Antwort sprechbar/seniorengerecht (Markdown raus, Aufzählungspunkte weg, **nummerierte Listen → Ordinalwörter** „1." → „erstens", Uhrzeiten/Verhältnisse erhalten).
- **Sentence Chunker** (`pipeline/sentence_chunker.py`) inkrementelle Satzsegmentierung für satzweises Streaming-TTS. Trennt bewusst **nicht** nach Ziffer+Punkt („1. Mai"), Einzelbuchstabe+Punkt („z. B.", Initialen) oder bekannten Abkürzungen.
- **TTS Normalizer** (`pipeline/tts_normalizer.py`) füllt gezielt die Lücken des Phonemizers (espeak-ng in piper), **ohne** zu duplizieren, was der schon gut kann (Kardinal-/Dezimalzahlen bleiben unangetastet): **Ordinalia** (Datum „1. Mai" → „erster Mai", Folgen „1. 2. 3." → „erstens, zweitens …"), **Einheiten nach Zahl** (kg/km/km-h/…), **Abkürzungen** (Dr./z. B./usw.) und ein **YAML-Aussprache-Lexikon** (`config/pronunciation.<lang>.yaml`, erweitert die eingebauten Defaults; case-insensitive Wort-Umschreibungen wie „strömt" → „ströhmt"). Stufe **provider-abhängig** (`TTS_NORMALIZE_LEVEL=auto|full|light|off`): piper → `full`, Cloud-TTS → `light` (Cloud spricht Zahlen/Abkürzungen selbst gut). Ordinalzahlen 1.31. in `pipeline/german_numbers.py`.
Empfehlung: nicht jede Zwischenstufe braucht ein großes LLM — Cleaner, Chunker und
Normalizer überwiegend regelbasiert (so heute umgesetzt), Adapter promptbasiert.
Lexikon pflegen: `python scripts/add_pronunciation.py "wort:aussprache" [--verify]`.
## 6. Orchestrator
`app/core/orchestrator.py` verbindet Provider, Pipeline und Output-Endpunkt.
- `chat_text(text, language, voice, output, history)``(trace, audio_bytes)`
- `speak_only(text, voice, language, output)``audio_bytes`
- `transcribe_only(audio_bytes, fmt, language, input)``trace`
Bei `/api/chat` mit `session_id` lädt die API-Schicht den letzten Gesprächsverlauf
aus dem Store (`messages`-Tabelle, letzte `HISTORY_MAX_MESSAGES`), gibt ihn als
`history` an das LLM und speichert nach der Antwort User- und Assistant-Turn.
Ohne `session_id` bleibt der Aufruf zustandslos.
Das synthetisierte Audio wird **zusätzlich** durch den gewählten Output-Endpunkt
geschrieben (`open → write_chunk → flush → close`) und **gleichzeitig** als
HTTP-Stream zurückgegeben (additiv). Bei lokalen Geräten ist `write_chunk` heute
ein No-op; `LoopbackOutput` sammelt die Chunks (testbar ohne Hardware).
## 7. FastAPI-Endpunkte (umgesetzt)
| Methode & Pfad | Zweck |
|--------------------------------------|-------|
| `GET /health` | Liveness |
| `POST /api/chat` | Text rein → Audio raus (`?debug=true` → JSON-Trace) |
| `POST /api/speak` | Text rein → TTS-Audio raus |
| `POST /api/transcribe` | Audio-Upload → Transkript |
| `GET /api/devices` | verfügbare Audio-Endpunkte + Capabilities |
| `POST /api/sessions/{id}/route` | bevorzugte Geräte/Provider/Sprache je Session |
| `GET /api/config` | aktives Profil + aufgelöste Route (ohne Secrets) |
| `POST /api/admin/users` | Nutzer anlegen (Admin-Key) → Token einmalig |
| `GET /api/me` · `PUT /api/me/prefs` | aktueller Nutzer + dauerhafte Präferenzen |
| `GET/POST/DELETE /api/me/memories` | Langzeit-Erinnerungen des Nutzers |
| `GET /api/metrics` | Metriken (JSON / Prometheus) |
| `WS /ws/chat` | Echtzeit-Chat (Text rein, Streaming-Events) |
| `WS /ws/voice` | Echtzeit-Sprache (Audio rein → STT → Antwort) |
Endpunkt-/Provider-Auswahl ist über **Request-Body** (pro Aufruf), **Session**
(`?session_id=…`) und **Defaults/Profil** steuerbar. Verwendete Route erscheint als
`X-*`-Header bzw. im `?debug`-JSON.
## 8. Stand der Implementierung
**Umgesetzt:** FastAPI-Gateway, alle o. g. REST-Endpunkte; OpenRouter-Adapter für
STT (JSON/base64), LLM und TTS; lokaler OpenAI-kompatibler LLM-Adapter; regelbasierte
Pipeline; geschichtete Config + Profile; Registry + einheitliche Route-Auflösung;
Device Router (strikt, Singleton); Output-Lifecycle; **Authentifizierung
(Bearer-Token) + persistenter SQLite-Store für Nutzer/Sessions + Mandanten-Trennung
+ dauerhafte Nutzer-Präferenzen**; **Gesprächsgedächtnis pro Session (Verlauf im
Store, fließt ins LLM)**; **Langzeit-Erinnerungen pro Nutzer (als LLM-Kontext)**;
**WebSocket-Streaming-Chat (`/ws/chat`) inkl. Token-Level-LLM-Streaming (SSE,
`stream:true`) und satzweisem Audio-Streaming (chunked TTS, `audio_stream:true`)**; **Sprach-Eingang
über WebSocket (`/ws/voice`: Audio rein → STT → Antwort-Pipeline) mit VAD-Aeusserungs-
erkennung und Barge-in (`interrupt`)**; **Resilienz (Fallback-Ketten je Modul,
In-Memory-Metriken `/api/metrics`), Tageskontingent pro Nutzer und heuristische
Notfall-Eskalation**; automatisierte Tests.
**Echtes lokales STT & TTS:** `faster-whisper` (optionale Dependency `.[local]`,
CTranslate2) transkribiert real; `piper` (in-process via piper-Python-API, Stimmmodell
einmal geladen + gecacht, In-Process-Resampling auf 24000 Hz) synthetisiert real. Lokale
Modelle werden beim Serverstart vorgeladen (Warm-up). Damit ist sowohl ein Hybrid „STT+LLM lokal, TTS
remote" als auch eine **voll-lokale** Konstellation möglich (live verifiziert).
**Platzhalter (Gerüst):** Audio-Endpunkte (`local-default`, `bluetooth`,
`mobile-ws`, `mobile-webrtc`) liefern leere Chunks — nur Auswahl/Lifecycle sind
verdrahtet, kein echtes Hardware-I/O. Der TTS-Provider `chatterbox` ist noch ein
Stub. `transport_router.py` (Ebene 4) existiert, ist aber noch nicht aktiv
(lokal/remote trägt vorerst der Provider-Name).
## 9. Roadmap / bewusste nächste Schritte
Reihenfolge der Weiterentwicklung:
1. **(erledigt)** Konfig- & Routing-Fundament: Profile, Device Router, Registry, Pro-Request-Override.
2. **(erledigt)** Cloud-Fundament: Bearer-Token-Auth, Mehrbenutzer, persistenter SQLite-Store, Mandanten-Trennung, dauerhafte Nutzer-Präferenzen. Offen: Skalierung auf gemeinsamen Store (Postgres/Redis) für mehrere Instanzen.
3. **(erledigt)** Konversationsgedächtnis: Kurzzeit-Gesprächsverlauf pro Session + Langzeit-Erinnerungen pro Nutzer (manuell **und automatisch** gepflegt, als LLM-Kontext). **Automatische Extraktion** (`app/core/memory_extractor.py`): nach je N Turns destilliert ein LLM dauerhafte Fakten/Vorlieben aus dem Verlauf und legt sie dedupliziert als Erinnerungen ab — best-effort, nicht-blockierend (Hintergrund-Task), konfigurierbar (`MEMORY_EXTRACTION_*`). Offen: periodische Verdichtung/Zusammenfassung wachsender Erinnerungslisten.
4. **(weitgehend erledigt)** Echtzeit: WebSocket-Streaming-Chat (`/ws/chat`), **Token-Level-LLM-Streaming (SSE, `stream:true`)**, **Audio-Streaming (chunked TTS satzweise, `audio_stream:true`)**, **Audio-Eingang (`/ws/voice`)**, **Barge-in/Turn-Manager (`interrupt` bricht laufende Antwort ab)** und **VAD-Aeusserungserkennung (energie-basiert, opt-in)** sind umgesetzt. Offen: **echte partielle Live-Transkripte (Streaming-STT-Dienst, wortweise)** und **WebRTC (aiortc)** — beide brauchen schwere Abhaengigkeiten/Dienste. Heute laeuft STT pro Aeusserung.
5. **(weitgehend erledigt)** Resilienz: Fallback-Ketten je Modul (`*_FALLBACK`, Provider faellt aus → naechster) und In-Memory-Metriken (`/api/metrics`: Request/Latenz, Pipeline-Stufen, Fallback/Fehler; JSON + Prometheus). Offen: verteiltes Tracing, Alerting.
6. **(weitgehend erledigt)** Betrieb: Tageskontingent pro Nutzer (`DAILY_REQUEST_LIMIT`, 429) und **zweistufige** Notfall-Eskalation: (1) schnelle Stichwort-Heuristik im Hot-Path (0 Latenz) + (2) **LLM-Klassifikation** (`app/safety/llm_classifier.py`) als Hintergrund-Task, der laeuft, wenn die Heuristik nichts fand — faengt verpasste Formulierungen (z. B. metaphorisch geaeusserte Suizidalitaet, Schlaganfall-Symptome ohne Stichwort) mit Konfidenz-Schwelle, ohne die Antwortlatenz zu erhoehen. Eskalation jeweils -> Log + Metrik (`source`: keyword/llm) + optionaler Webhook + Event. Offen: Telefon-/Angehoerigen-Integration, Abrechnung.
7. **(weitgehend erledigt)** Lokale Provider: STT via `faster-whisper` (`.[local]`) und **TTS via `piper`** (lokales Neural-TTS, **in-process** mit gecachtem Stimmmodell, In-Process-Resampling auf 24000 Hz; lokale Modelle werden beim Start vorgeladen) sind echt — eine **voll-lokale Konstellation** (STT+LLM+TTS lokal, keine API-Kosten, max. Datenschutz) ist damit möglich. Offen: `chatterbox`-TTS (noch Stub), höhere Sprachqualität als piper.
8. **TransportRouter** als eigene lokal/remote-Achse aktivieren; echte Geräte-Endpunkte (PipeWire/Bluetooth) — heute OS-Ebene.
**Datenschutz (querschnittlich, ab sofort mitdenken):** Senioren-Sprachdaten sind
hochsensibel (oft gesundheitsbezogen → DSGVO Art. 9). EU-Datenresidenz,
Verschlüsselung at-rest/in-transit, Löschkonzept, Einwilligung — „privacy by design".
## 10. Verzeichnisstruktur (Ist)
```text
voice-assistant-scaffold/
├── app/
│ ├── main.py # FastAPI-App + Router-Registrierung
│ ├── config.py # Settings, TOML-Profile, Präzedenz
│ ├── dependencies.py # Registries, ResolvedRoute, resolve_route, Store-/Router-Singleton
│ ├── store.py # Persistenz: Store-Interface + SQLiteStore (Nutzer/Sessions/Verlauf)
│ ├── auth.py # Bearer-Token-Auth (require_user) + Admin-Schutz
│ ├── metrics.py # In-Memory-Metriken (Counter/Timer, JSON + Prometheus)
│ ├── quota.py # Tageskontingent pro Nutzer (Kostenkontrolle)
│ ├── safety/ # emergency.py: heuristische Notfall-Erkennung/-Eskalation
│ ├── errors.py # RoutingError -> HTTP 422
│ ├── schemas.py # Pydantic-Modelle
│ ├── api/ # health, chat, speak, transcribe, devices, sessions, config, admin, me, ws
│ ├── core/ # orchestrator
│ ├── audio/ # router, transport_router, vad, endpoints/input|output/*
│ ├── pipeline/ # input_cleaner, spoken_response_adapter, tts_normalizer, sentence_chunker, german_numbers
│ └── providers/ # stt/ llm/ tts/ (openrouter + lokale Stubs) + fallback.py
├── config/ # voice-assistant.example.toml (+ lokale .toml, gitignored)
├── data/ # SQLite-DB (gitignored)
├── deploy/ # systemd unit + env-Beispiel
├── tests/ # config-profile, routing, audio-router, e2e
├── Docs/ # dieses Dokument
├── Dockerfile, docker-compose.yml, Makefile, pyproject.toml
└── README.md, BEDIENUNGSANLEITUNG.md
```