my_voice_assistant_v3/README.md
Dieter Schlüter 434a154af2 docs: LAN-Zugriff auf die Web-UI dokumentieren (Firewall, IP, Mic-Caveat)
Hinweis, dass der Server bereits auf 0.0.0.0 lauscht und fuer LAN-Zugriff nur die
Firewall geoeffnet werden muss; Mikrofon braucht HTTPS/localhost (secure context).

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

20 KiB
Raw Blame History

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. Praktische Bedienung: 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) + zweistufige Notfall-Eskalation (Stichwörter + LLM)
  • 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

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:

curl http://localhost:8080/health
curl http://localhost:8080/api/config

Sprechen → Antwort hören → erneut sprechen (Mikrofon-Loop):

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.

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):

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.

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:

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):

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):

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):

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.

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:

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: acksemantic → 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:

# 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).

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):

DAILY_REQUEST_LIMIT=200        # 0 = unbegrenzt; pro Nutzer/Tag

Pro Nutzer übersteuerbar via prefs.daily_request_limit (siehe PUT /api/me/prefs).

Notfall-Eskalation (zweistufig): /api/chat und /ws/chat prüfen die Nutzereingabe 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).

  1. Stichwort-Heuristik im Hot-Path — sofort, ohne Latenz.
  2. LLM-Klassifikation als Hintergrund-Task, der nur läuft, wenn die Stichwörter nichts fanden. Fängt verpasste Formulierungen (z. B. metaphorisch geäußerte Suizidalität oder Schlaganfall-Symptome ohne Schlüsselwort) mit Konfidenz-Schwelle — ohne die Antwortlatenz zu erhöhen. Eskaliert genauso (Log/Webhook), beim WebSocket zusätzlich ein nachgelagertes emergency-Event (source: "llm").
EMERGENCY_WEBHOOK_URL=https://example.org/alert   # optional, Benachrichtigung
EMERGENCY_LLM_ENABLED=true                         # Stufe 2 (Default an); false = nur Stichwörter
EMERGENCY_LLM_MIN_CONFIDENCE=0.6                   # Schwelle gegen Fehlalarme

⚠️ Die Erkennung (Heuristik und LLM) ist 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).

# 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 <TOKEN>"

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

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):

make smoke          # oder: python scripts/smoke_e2e.py

Port ändern

PORT=8003 make run                 # einmalig
sed -i 's/^PORT=.*/PORT=8003/' .env  # dauerhaft
PORT=8003 docker compose up         # mit Docker

Web-UI & Remote-Zugang

Das Gateway liefert unter / eine minimale Web-Oberfläche aus (app/web/, vanilla, kein Build): Text-Eingabe + Mikrofon-Button (Aufnahme im Browser → /ws/voice → Antwort wird vorgelesen) und links ein Menü mit Identität/Logout/Admin.

Zugriff aus dem lokalen Netz (LAN)

Der Server lauscht standardmäßig auf 0.0.0.0 (alle Interfaces). Für den Zugriff von anderen Rechnern/Handys im LAN reichen zwei Dinge:

  1. Firewall öffnen für den Port (Beispiel ufw, auf die eigenen LAN-Subnetze beschränkt):
    sudo ufw allow from 192.168.179.0/24 to any port 8003 proto tcp comment 'voice-assistant LAN'
    
  2. Im Browser des anderen Geräts die LAN-IP des Servers aufrufen: http://<server-lan-ip>:8003/.

⚠️ Mikrofon nur über HTTPS/localhost: Browser geben das Mikrofon nur in einem „secure context" frei. Über http://<lan-ip>:8003 funktioniert daher der Text-Chat, aber nicht der Mic-Button. Für Sprache von anderen Geräten den HTTPS-Weg nutzen (siehe unten) — am lokalen Rechner via http://localhost:8003 geht das Mikrofon.

⚠️ Bei AUTH_ENABLED=false kann jeder im LAN den Dienst anonym nutzen. Für mehr als vertrautes Testen Auth aktivieren bzw. den SSO-Weg wählen.

Remote von unterwegs (HTTPS + SSO)

Für den Remote-Betrieb (Handy/Browser von unterwegs) hinter einem Reverse-Proxy mit HTTPS + SSO (z. B. YunoHost): siehe deploy/README.md. Kernpunkte:

  • HTTPS ist Pflicht — Browser geben das Mikrofon nur im „secure context" frei.
  • Forward-/Trusted-Header-Auth: der Proxy/SSO authentifiziert, reicht die Identität per Header durch (TRUSTED_AUTH_HEADER); das Gateway legt Nutzer automatisch an. Akzeptiert wird der Header nur von der Proxy-Quell-IP (TRUSTED_PROXY_IPS).
  • WebSocket-Upgrade im nginx nicht vergessen (sonst kein Mikrofon).

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)
  • Remote über YunoHost/Reverse-Proxy: deploy/README.md (HTTPS, SSO, nginx, Firewall)

Projektstruktur (Kurzform)

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).