# Voice Assistant Gateway Modulares FastAPI-Gateway für einen **seniorengerechten Sprachassistenten** — cloud-first, aber hybrid/lokal betreibbar, mit austauschbaren Audio-Endpunkten und STT-/LLM-/TTS-Providern. Jede Achse — **Hardware** (Audio In/Out), **Betrieb** (lokal/cloud) und **Software** (lokale/remote KI) — ist frei konfigurierbar, ohne Code zu ändern. Konzept und Details: [`Docs/voice-assistant-architecture.md`](Docs/voice-assistant-architecture.md). Praktische Bedienung: [`BEDIENUNGSANLEITUNG.md`](BEDIENUNGSANLEITUNG.md). ## Features - **Pipeline mit getrennter Semantik/Sprache:** STT → Input-Cleaner → LLM → Spoken-Adapter → TTS-Normalizer → TTS - **Provider austauschbar** über Registry (OpenRouter remote; lokales STT via faster-whisper `.[local]`; **lokales TTS via piper**; chatterbox-TTS noch Stub) - **Aussprache-Normalisierung** vor dem TTS (Ordinalia/Einheiten/Abkürzungen + YAML-Lexikon, provider-abhängig `TTS_NORMALIZE_LEVEL`); Pflege per `scripts/add_pronunciation.py` - **Geschichtete Konfiguration** mit Profilen (`local-dev` / `hybrid` / `cloud`) - **Routing auf jeder Ebene:** Default → Profil → Nutzer → Session → Request - **Authentifizierung** (Bearer-Token) + persistente Nutzer/Sessions (SQLite) - **Resilienz:** Fallback-Ketten je Modul (Provider fällt aus → nächster) + Metriken - **Betrieb:** Tageskontingent pro Nutzer (`429`) + heuristische Notfall-Eskalation - **Gesprächsgedächtnis pro Session:** Verlauf wird gespeichert und fließt ins LLM - **Langzeit-Erinnerungen pro Nutzer:** dauerhafte Fakten/Vorlieben als LLM-Kontext - **WebSocket-Streaming-Chat** (`/ws/chat`) als Echtzeit-Transport - **REST-API** für Chat, Transkription, Sprachausgabe, Geräte, Sessions, Config - **Ohne Secrets im Code** — API-Keys nur über die Umgebung ## Schnellstart ```bash python3 -m venv .venv source .venv/bin/activate pip install -U pip pip install -e .[test] cp config/voice-assistant.example.toml config/voice-assistant.toml export OPENROUTER_API_KEY=sk-or-v1-... # nur für Cloud-/Hybrid-Profile nötig make run ``` Fehlt `.env`, wird sie beim ersten `make run` aus `.env.example` erzeugt. Die App läuft dann auf `http://localhost:8080` (bzw. dem in `.env` gesetzten `PORT`). Kurztest: ```bash curl http://localhost:8080/health curl http://localhost:8080/api/config ``` **Sprechen → Antwort hören → erneut sprechen** (Mikrofon-Loop): ```bash python scripts/voice_loop.py --session mein-gespraech ``` Vollständige, copy-&-paste-fertige Schritt-für-Schritt-Anleitung (Bedienung, Einstellungen wechseln, Praxis-Tests & Reaktionszeiten): **[BEDIENUNGSANLEITUNG.md](BEDIENUNGSANLEITUNG.md)**. ## Konfiguration & Profile Höhere Ebene gewinnt: ``` eingebaute Defaults < config/voice-assistant.toml (inkl. aktivem Profil) < ENV / .env < Session-Route < Request ``` **Profile** umschalten per Umgebungsvariable (oder dauerhaft in `.env`): ```bash VA_PROFILE=local-dev make run # alles lokal (faster-whisper / lokales LLM / piper) VA_PROFILE=hybrid make run # STT/TTS remote, LLM lokal VA_PROFILE=cloud make run # alles über OpenRouter ``` > Secrets gehören **nicht** in `config/*.toml` — nur in die Umgebung > (`export OPENROUTER_API_KEY=…`). Gesetzte `DEFAULT_*_PROVIDER`-Werte in `.env` > überschreiben ein `VA_PROFILE`. **Pro Session:** `POST /api/sessions/{id}/route` (`input_endpoint`, `output_endpoint`, `stt_provider`, `llm_provider`, `tts_provider`, `language`), dann Aufrufe mit `?session_id=…`. **Pro Request:** dieselben Felder im Body von `/api/chat` bzw. `/api/speak`. Aktive Konfiguration prüfen: `curl http://localhost:8080/api/config`. ## Lokales LLM (llama.cpp, unzensiert) Die zentrale KI kann statt OpenRouter ein **lokales, unzensiertes Modell** über einen llama.cpp-Server (OpenAI-kompatibel) sein. Der Provider `local-openai-compatible` spricht direkt dagegen — kein Code, nur Server starten + Profil wählen. ```bash make llm-up # startet den llama.cpp-Container (Default: Port 8001, GPU 1) make llm-status # Container- + HTTP-Status make llm-down # stoppt den Container ``` Das Modell wird über das `--alias va_llm` angesprochen; die Defaults zeigen bereits auf `http://127.0.0.1:8001/v1` mit Modell `va_llm`. Danach genügt ein lokales Profil: ```bash VA_PROFILE=hybrid make run # STT/TTS remote, Haupt-LLM lokal (unzensiert) VA_PROFILE=local-dev make run # komplett lokal (faster-whisper / llama.cpp / piper) ``` Alle Server-Parameter sind per ENV überschreibbar (Defaults in Klammern): | Variable | Bedeutung | Default | |------------------|---------------------------------------------|---------| | `HOST_PORT` | Host-Port des Servers | `8001` | | `GPU_DEVICE` | GPU-Index (von 3 GPUs) | `1` | | `MODEL_REL_PATH` | Modellpfad relativ zu `HF_HOME` | `models/qwen3/Qwen3.6-35B-A3B-Uncensored-HauhauCS-Aggressive-Q4_K_M.gguf` | | `HF_HOME` | Wurzel der Modell-Sammlung | `~/nvme2n1p7_home/huggingface` | | `MODEL_ALIAS` | API-Modellname (= `LOCAL_LLM_MODEL`) | `va_llm` | | `CONTAINER_NAME` | Docker-Containername | `va_llm` | Beispiel (andere GPU/Port/Modell): ```bash GPU_DEVICE=2 HOST_PORT=8101 MODEL_REL_PATH=models/qwen3/Qwopus3.6-35B-A3B-v1-Q4_K_M.gguf \ bash scripts/llm-server/start-llm-server.sh ``` > Wird `HOST_PORT`/`MODEL_ALIAS` geändert, müssen `LOCAL_LLM_BASE_URL`/`LOCAL_LLM_MODEL` > im Gateway (`.env`) entsprechend angepasst werden. ### Tempo im Sprach-Loop Ein Reasoning-Modell (Qwen3) „denkt" per Default lang und antwortet ausführlich mit Markdown/Emojis — schlecht zum Vorlesen und spürbar träge. Der Provider `local-openai-compatible` stellt daher für **gesprochene** Antworten um: | Setting | Default | Wirkung | |---------|---------|---------| | `LOCAL_LLM_DISABLE_REASONING` | `true` | schaltet die Qwen3-Denkphase ab (Time-to-first-word ~9× schneller) | | `LOCAL_LLM_SYSTEM_PROMPT` | knapper Sprach-Prompt | kurze, vorlesbare Antworten in Fließtext (kein Markdown/Emoji) | | `LOCAL_LLM_MAX_TOKENS` | `0` (Server-Limit) | optionaler harter Deckel, z. B. `256` | | `LOCAL_LLM_TEMPERATURE` | `0.3` | Sampling-Temperatur | > Gemessen am Modell `va_llm`: dieselbe Frage fällt von **5,5 s / 1433 Zeichen** > (Reasoning an, ausführlich) auf **0,7 s / ~190 Zeichen** (Reasoning aus + Sprach-Prompt). > Für unzensierte „freie" Gespräche bleibt der Prompt rein formal (nur Kürze/Format, > keine inhaltlichen Einschränkungen); per `LOCAL_LLM_SYSTEM_PROMPT=` leerbar. **Zweiter Hebel — STT:** `faster-whisper` läuft per Default auf `auto` (oft CPU) mit Modell `base`. Auf einer RTX 3090 lohnt `FASTER_WHISPER_DEVICE=cuda` + `FASTER_WHISPER_COMPUTE_TYPE=float16`; das verkürzt die Transkriptionszeit pro Turn. ### Komplett lokal: Profil `local-dev` `VA_PROFILE=local-dev` betreibt **alle** KI-Module ohne Cloud. Die Route löst auf zu: | Modul | Provider | Quelle | |-------|----------|--------| | STT | `faster-whisper` | lokales Whisper-Modell | | **LLM** | `local-openai-compatible` | llama.cpp-Server `http://127.0.0.1:8001/v1`, Modell `va_llm` | | TTS | `piper` | lokales Stimmmodell | **Voraussetzungen:** - **LLM:** llama.cpp-Container läuft (`make llm-up`) - **STT:** faster-whisper installiert (`pip install -e .[local]`) - **TTS:** piper-Binary + Stimme vorhanden (siehe `PIPER_*` in `.env.example`) **Start (Reihenfolge):** ```bash make llm-up # 35B-Modell laden; mit make llm-status auf "HTTP OK" warten make run # Gateway nutzt jetzt das lokale, unzensierte Modell als zentrale KI ``` > `VA_PROFILE` ist in `.env` dauerhaft setzbar (aktuell `local-dev`) oder pro Lauf > voranstellbar (`VA_PROFILE=hybrid make run`). Prüfen: `curl http://localhost:8080/api/config`. ## API-Überblick | Methode & Pfad | Zweck | |---------------------------------|-------| | `GET /health` | Liveness-Check | | `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` | Geräte/Provider/Sprache je Session setzen | | `GET /api/config` | aktives Profil + aufgelöste Route (ohne Secrets) | | `POST /api/admin/users` | Nutzer anlegen (Admin-Key) → Token einmalig | | `GET /api/me` | aktueller Nutzer + Präferenzen | | `PUT /api/me/prefs` | dauerhafte Routing-Präferenzen des Nutzers setzen | | `GET/POST/DELETE /api/me/memories` | Langzeit-Erinnerungen des Nutzers verwalten | | `WS /ws/chat` | Echtzeit-Chat über WebSocket (Text rein, Streaming-Events) | | `WS /ws/voice` | Echtzeit-Sprache (Audio rein → Transkript → Antwort) | | `GET /api/metrics` | Metriken (JSON, oder `?format=prometheus`) | Beispiel (Sprachausgabe an den Test-Loopback; `piper` = lokales TTS): ```bash curl -X POST http://localhost:8080/api/speak \ -H 'Content-Type: application/json' \ -d '{"text":"Guten Morgen!","tts_provider":"piper","output_endpoint":"loopback"}' ``` Unbekannter Endpunkt/Provider → `HTTP 422` mit Klartext-Hinweis. ## Gesprächsgedächtnis Wird bei `/api/chat` eine `session_id` mitgegeben, merkt sich der Assistent den Verlauf: vergangene Turns werden gespeichert und beim nächsten Aufruf ans LLM gegeben (begrenzt auf die letzten `HISTORY_MAX_MESSAGES` Nachrichten, Default 10). Ohne `session_id` bleibt der Aufruf zustandslos. ```bash curl -X POST "http://localhost:8080/api/chat?session_id=oma-anna&debug=true" \ -H 'Content-Type: application/json' -d '{"text":"Ich heiße Anna."}' curl -X POST "http://localhost:8080/api/chat?session_id=oma-anna&debug=true" \ -H 'Content-Type: application/json' -d '{"text":"Wie war noch mein Name?"}' ``` **Langzeit-Erinnerungen** (über Sessions hinweg, pro Nutzer) werden über `/api/me/memories` gepflegt und bei jedem Chat als Kontext ans LLM gegeben — auch ohne `session_id`: ```bash curl -X POST http://localhost:8080/api/me/memories \ -H "Authorization: Bearer $TOKEN" \ -H 'Content-Type: application/json' -d '{"content":"Mag morgens Kamillentee."}' ``` **Automatische Erinnerungen:** Zusätzlich zur manuellen Pflege destilliert das LLM nach je N Turns (Default 3) dauerhafte Fakten/Vorlieben aus dem Gespräch und legt sie dedupliziert als Erinnerungen ab — best-effort und **nicht-blockierend** (Hintergrund-Task, erhöht die Antwortlatenz nicht). Steuerung über `MEMORY_EXTRACTION_*` (siehe `.env.example`); `MEMORY_EXTRACTION_ENABLED=false` schaltet es ab. Sinnvoll mit einem lokalen LLM, da pro Turn ein zusätzlicher (kostenloser) Modellaufruf anfällt. ## Echtzeit-Chat (WebSocket) `/ws/chat` bietet einen dauerhaften, bidirektionalen Kanal. Der Client sendet pro Turn eine JSON-Nachricht (`{"text": "..."}`, optional Provider/Endpunkt-Overrides), der Server streamt strukturierte Events zurück: `ack` → `semantic` → Audio (binär) → `done`. Auth (Token-Query `?token=…`), Session-Gedächtnis (`?session_id=…`) und Erinnerungen gelten wie bei `POST /api/chat`. **Token-Streaming:** Mit `{"text": "...", "stream": true}` schickt der Server die LLM-Antwort schon während der Generierung als `token`-Events — spürbar geringere wahrgenommene Latenz. OpenRouter und der lokale OpenAI-kompatible Provider streamen via SSE; Provider ohne Streaming liefern die komplette Antwort als ein `token`-Event. **Audio-Streaming:** Mit `{"text": "...", "audio_stream": true}` wird das Audio **satzweise** erzeugt (chunked TTS) und pro fertigem Satz als `audio`-Event (JSON mit `seq` + binärer Frame) gesendet — die Ausgabe beginnt, bevor die Antwort fertig ist. `stream` und `audio_stream` lassen sich kombinieren. **Sprach-Eingang (`/ws/voice`):** Der Client streamt Mikrofon-Audio als binäre Frames; ein `{"type":"end"}`-Control-Frame schließt die Äußerung ab. Der Server transkribiert (STT), sendet ein `transcript`-Event und durchläuft dann dieselbe Antwort-Pipeline wie `/ws/chat` (inkl. `stream`/`audio_stream`). Damit ist Sprach-zu-Sprach-Konversation über einen Kanal möglich. **VAD (automatische Äußerungserkennung):** Mit `{"type":"start","vad":true, "sample_rate":16000,"format":"pcm"}` segmentiert der Server Äußerungen selbst anhand von Stille (energie-basiert, reines Python) — ohne explizites `end`. Optional: `vad_silence_ms`, `vad_threshold`. **Barge-in:** Eine laufende Antwort lässt sich mit `{"type":"interrupt"}` (oder durch eine neue Eingabe) abbrechen — der Server stoppt das Streaming und meldet `{"type":"interrupted"}`. Wichtig für natürliche Gespräche. > Echte **partielle Live-Transkripte** (Streaming-STT-Dienst, wortweise während des > Sprechens) und **WebRTC** sind als nächste Increments vorgesehen (siehe > Architektur-Dokument). Heute läuft STT pro Äußerung. ## Resilienz & Metriken **Fallback-Ketten:** Pro Modul lässt sich eine Ersatz-Provider-Liste setzen. Fällt der primäre Provider aus (Timeout/Fehler), übernimmt transparent der nächste: ```bash # z. B. Cloud-LLM mit lokalem Fallback LLM_FALLBACK=local-openai-compatible STT_FALLBACK=faster-whisper TTS_FALLBACK=piper ``` Die Kette ist `Route-Provider` + `*_FALLBACK` (dedupliziert). Erfolgreiche Fallbacks und Provider-Fehler werden gezählt. **Metriken** (`GET /api/metrics`): Request-Counts/-Latenzen pro Pfad, Pipeline-Stufen (`stt`/`llm`/`tts`), Fallback-/Fehlerzähler — als JSON oder Prometheus-Text (`?format=prometheus`). In-Memory pro Prozess (keine externe Dependency). ```bash curl http://localhost:8080/api/metrics curl http://localhost:8080/api/metrics?format=prometheus ``` ## Kontingent & Notfall-Eskalation **Tageskontingent** pro Nutzer begrenzt die Kosten (Cloud-LLM/TTS). Bei Überschreitung `HTTP 429` (bzw. `error`-Event über WebSocket): ```bash DAILY_REQUEST_LIMIT=200 # 0 = unbegrenzt; pro Nutzer/Tag ``` Pro Nutzer übersteuerbar via `prefs.daily_request_limit` (siehe `PUT /api/me/prefs`). **Notfall-Eskalation:** `/api/chat` und `/ws/chat` prüfen die Nutzereingabe heuristisch auf Notlagen-Signale (medizinisch, Selbstgefährdung, Hilferuf — de/en). Bei Treffer wird der Vorfall protokolliert, optional ein Webhook ausgelöst und das Signal sichtbar gemacht (`X-Emergency`-Header / `emergency`-Feld / WebSocket-`emergency`-Event). Eine Notfall-Eingabe umgeht das Kontingent (wird nie geblockt). ```bash EMERGENCY_WEBHOOK_URL=https://example.org/alert # optional, Benachrichtigung ``` > ⚠️ Die Erkennung ist eine **Schlüsselwort-Heuristik** — kein verlässlicher > Lebensretter und kein Ersatz für einen echten Notruf. Sie kann Notlagen verpassen > oder Fehlalarme auslösen. Erkannte Texte sind hochsensibel (DSGVO: Einwilligung, > Aufbewahrung, Zugriff beachten). ## Authentifizierung Standardmäßig (`AUTH_ENABLED=true`) sind `chat`/`speak`/`transcribe`/`sessions`/`me` durch ein **Bearer-Token pro Nutzer** geschützt. Nutzer/Sessions werden in SQLite persistiert (`DB_PATH`, Default `data/voice-assistant.db`). ```bash # 1) Nutzer anlegen (Admin-Key aus der Umgebung) — Token erscheint EINMALIG export ADMIN_API_KEY=ein-langes-geheimnis curl -X POST http://localhost:8080/api/admin/users \ -H "X-Admin-Key: $ADMIN_API_KEY" \ -H 'Content-Type: application/json' \ -d '{"display_name":"Oma Anna"}' # -> {"user_id":"…","display_name":"Oma Anna","token":"…"} # 2) Mit dem Token aufrufen curl http://localhost:8080/api/me -H "Authorization: Bearer " ``` Dauerhafte Präferenzen pro Nutzer (`PUT /api/me/prefs`) fließen in die Route-Auflösung ein (Ebene zwischen Profil und Session). Fremde Sessions → `HTTP 403`. > **Lokale Entwicklung:** `AUTH_ENABLED=false` setzen — dann gilt ein anonymer > Standardnutzer und es ist kein Token nötig. ## Tests ```bash make test # oder: pytest -q (offline, mit Stubs) ``` Abgedeckt: Config-Profile & Präzedenz, Route-Auflösung, Device Router, Auth/Mandanten, Gedächtnis, Streaming, Resilienz, Quota/Notfall. **Echter End-to-End-Test gegen OpenRouter** (Netz-Aufrufe, geringe Kosten — prüft LLM, TTS und STT live, inkl. TTS→STT-Round-Trip): ```bash make smoke # oder: python scripts/smoke_e2e.py ``` ## Port ändern ```bash PORT=8003 make run # einmalig sed -i 's/^PORT=.*/PORT=8003/' .env # dauerhaft PORT=8003 docker compose up # mit Docker ``` ## Deployment - **Docker:** `docker compose up --build` (reicht `OPENROUTER_API_KEY` aus der Shell durch) - **systemd:** Vorlagen unter `deploy/` (`voice-assistant.service`, `voice-assistant.env.example`) ## Projektstruktur (Kurzform) ```text app/ Gateway: config, dependencies, api/, core/, audio/, pipeline/, providers/ config/ voice-assistant.example.toml (lokale .toml ist gitignored) deploy/ systemd-Unit + env-Beispiel tests/ Pytest-Suite Docs/ Architektur-Dokument ``` ## Lizenz / Status Frühes, aktiv entwickeltes Projektgerüst. Audio-Hardware-/Streaming-Anbindung, Authentifizierung, Persistenz und Gedächtnis sind als nächste Schritte vorgesehen (siehe Roadmap im Architektur-Dokument).