2026-06-17 01:48:56 +02:00
# 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
feat(tts): Chatterbox-Provider (hohe Qualitaet + Voice-Cloning) anbinden
Loest #7. Der chatterbox-Stub wird durch eine echte Anbindung an den lokalen
Chatterbox-HTTP-Dienst ersetzt (POST /speak -> /status pollen -> GET /audio, WAV).
no_playback=true -> der Dienst spielt nicht lokal ab, liefert nur Bytes. WAV->PCM
(24 kHz, Resampling bei Bedarf). Waehlbar via tts_provider=chatterbox; piper bleibt
der schnelle Default (chatterbox ist ~echtzeit-langsam, dafuer klonbare Stimme).
- config: CHATTERBOX_BASE_URL/_VOICE/_LANG/_SPEED/_TIMEOUT; Registry verdrahtet
- Tests: gemockter httpx (Synthese, WAV/Resample, Job-Fehler, Stimmenwahl)
- Doku: README, Architektur (#7), deploy/README (GPU-Pinning per UUID, no_playback)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 09:44:31 +02:00
- **Provider austauschbar** über Registry (OpenRouter remote; lokales STT via faster-whisper `.[local]` ; **lokales TTS via piper ** (schnell) **und chatterbox ** (hohe Qualität + Voice-Cloning, eigener Dienst))
2026-06-17 23:28:52 +02:00
- **Aussprache-Normalisierung** vor dem TTS (Ordinalia/Einheiten/Abkürzungen + YAML-Lexikon, provider-abhängig `TTS_NORMALIZE_LEVEL` ); Pflege per `scripts/add_pronunciation.py`
2026-06-17 01:48:56 +02:00
- **Geschichtete Konfiguration** mit Profilen (`local-dev` / `hybrid` / `cloud` )
2026-06-17 02:14:25 +02:00
- **Routing auf jeder Ebene:** Default → Profil → Nutzer → Session → Request
- **Authentifizierung** (Bearer-Token) + persistente Nutzer/Sessions (SQLite)
2026-06-17 05:19:07 +02:00
- **Resilienz:** Fallback-Ketten je Modul (Provider fällt aus → nächster) + Metriken
2026-06-18 03:24:25 +02:00
- **Betrieb:** Tageskontingent pro Nutzer (`429` ) + zweistufige Notfall-Eskalation (Stichwörter + LLM)
2026-06-17 04:16:35 +02:00
- **Gesprächsgedächtnis pro Session:** Verlauf wird gespeichert und fließt ins LLM
2026-06-17 04:26:55 +02:00
- **Langzeit-Erinnerungen pro Nutzer:** dauerhafte Fakten/Vorlieben als LLM-Kontext
- **WebSocket-Streaming-Chat** (`/ws/chat` ) als Echtzeit-Transport
2026-06-17 01:48:56 +02:00
- **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
```
docs: Bedienungsanleitung (Sprech-Loop, Einstellungen, Praxis-Tests) + voice_loop.py
- NEU scripts/voice_loop.py: Mikrofon -> /ws/voice -> Wiedergabe im Loop, mit
Gedaechtnis (--session), Geraete-/Provider-Optionen, --file fuer Test ohne Mikrofon
- BEDIENUNGSANLEITUNG.md neu strukturiert:
Teil A (sprechen->hoeren->sprechen: voice_loop + manueller Loop + chat_client),
Teil B (Einstellungen: KI/Provider auf allen Ebenen real; Sound-Quelle/-Ausgabe
ehrlich auf OS-Ebene, Gateway-Endpunkte als vorbereitete Routing-Ebene),
Teil C (Praxis-Tests + gemessene Reaktionszeiten: STT ~1,2s / LLM ~0,7s / TTS ~1,9s
/ Round-Trip ~4s; Konstellations-Empfehlung)
- Alle JSON-Befehle mit '| jq'; Audio-Befehle in Datei + Player
- README: kurzer Verweis auf den Sprech-Loop
- live verifiziert (make smoke, Timings, voice_loop --file); 64 Tests gruen
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 10:31:35 +02:00
**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 )**.
2026-06-17 01:48:56 +02:00
## 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` .
2026-06-18 02:57:57 +02:00
## 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.
2026-06-18 08:32:05 +02:00
**Dritter Hebel — lokales TTS (piper):** piper läuft **in-process ** über die piper-Python-API
(im `.[local]` -Extra). Das Stimmmodell wird **einmal ** geladen und prozessweit gecacht —
früher startete piper als Subprozess **pro Satz ** und zahlte jedes Mal ~2 s Modell-Ladezeit.
Zusätzlich werden lokale Modelle **beim Serverstart vorgeladen ** (Warm-up), sodass auch der
erste Nutzer keinen Kaltstart spürt. Messung (lokales Setup): erster Ton **5,8 s → ~1,6 s ** .
feat(tts): Chatterbox-Provider (hohe Qualitaet + Voice-Cloning) anbinden
Loest #7. Der chatterbox-Stub wird durch eine echte Anbindung an den lokalen
Chatterbox-HTTP-Dienst ersetzt (POST /speak -> /status pollen -> GET /audio, WAV).
no_playback=true -> der Dienst spielt nicht lokal ab, liefert nur Bytes. WAV->PCM
(24 kHz, Resampling bei Bedarf). Waehlbar via tts_provider=chatterbox; piper bleibt
der schnelle Default (chatterbox ist ~echtzeit-langsam, dafuer klonbare Stimme).
- config: CHATTERBOX_BASE_URL/_VOICE/_LANG/_SPEED/_TIMEOUT; Registry verdrahtet
- Tests: gemockter httpx (Synthese, WAV/Resample, Job-Fehler, Stimmenwahl)
- Doku: README, Architektur (#7), deploy/README (GPU-Pinning per UUID, no_playback)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 09:44:31 +02:00
**Höhere Sprachqualität — chatterbox (optional):** Für deutlich natürlichere, **klonbare **
Stimmen gibt es den Provider `chatterbox` (Resemble AI, eigener HTTP-Dienst auf GPU, siehe
`deploy/README.md` ). Wählbar pro Request/Session via `tts_provider=chatterbox` (`piper` bleibt
der schnelle Default). Chatterbox ist neural und ~echtzeit-langsam → besser für Qualität als
für minimale Latenz. Konfig: `CHATTERBOX_BASE_URL` , `CHATTERBOX_VOICE` (Referenz-WAV fürs
Cloning), `CHATTERBOX_SPEED` .
2026-06-18 02:57:57 +02:00
### 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` )
2026-06-18 08:32:05 +02:00
- **STT + TTS:** `pip install -e .[local]` (installiert faster-whisper **und ** piper-tts);
ein piper-Stimmmodell (`<name>.onnx` + `.onnx.json` ) im `PIPER_VOICES_DIR` (siehe `PIPER_*` )
2026-06-18 02:57:57 +02:00
**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`.
2026-06-17 01:48:56 +02:00
## 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) |
2026-06-17 02:14:25 +02:00
| `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 |
2026-06-17 04:26:55 +02:00
| `GET/POST/DELETE /api/me/memories` | Langzeit-Erinnerungen des Nutzers verwalten |
2026-06-17 04:51:49 +02:00
| `WS /ws/chat` | Echtzeit-Chat über WebSocket (Text rein, Streaming-Events) |
| `WS /ws/voice` | Echtzeit-Sprache (Audio rein → Transkript → Antwort) |
2026-06-17 05:19:07 +02:00
| `GET /api/metrics` | Metriken (JSON, oder `?format=prometheus` ) |
2026-06-17 01:48:56 +02:00
2026-06-17 22:11:20 +02:00
Beispiel (Sprachausgabe an den Test-Loopback; `piper` = lokales TTS):
2026-06-17 01:48:56 +02:00
```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.
2026-06-17 04:16:35 +02:00
## 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?"}'
```
2026-06-17 04:26:55 +02:00
**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."}'
```
feat(memory): automatische Erinnerungs-Extraktion aus Gespraechen
- app/core/memory_extractor.py: LLM destilliert nach je N Turns dauerhafte
Fakten/Vorlieben aus dem Verlauf, dedupliziert gegen vorhandene Erinnerungen
und legt sie ab - best-effort, nicht-blockierend (Hintergrund-Task), eigener
Extraktions-Prompt (JSON, Reasoning aus), Cap-Begrenzung
- Trigger in /api/chat und /ws/voice nach dem Persistieren des Turns
- Konfig: MEMORY_EXTRACTION_ENABLED/_EVERY_N_TURNS/_MAX/_PROVIDER
- Tests: Extraktion, Dedup, kaputtes JSON, Cap, leeres Gespraech, Scheduling
- Doku: README + Architektur-Roadmap (Punkt 3 erledigt)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 03:15:08 +02:00
**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.
2026-06-17 04:26:55 +02:00
## 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` .
2026-06-17 04:37:37 +02:00
**Token-Streaming:** Mit `{"text": "...", "stream": true}` schickt der Server die
2026-06-17 04:43:50 +02:00
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.
2026-06-17 04:37:37 +02:00
2026-06-17 04:43:50 +02:00
**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.
2026-06-17 04:51:49 +02:00
**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.
2026-06-17 05:06:58 +02:00
**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.
2026-06-17 04:26:55 +02:00
2026-06-17 05:19:07 +02:00
## 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
```
2026-06-17 05:29:30 +02:00
## 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` ).
2026-06-18 03:24:25 +02:00
**Notfall-Eskalation (zweistufig):** `/api/chat` und `/ws/chat` prüfen die Nutzereingabe
2026-06-17 05:29:30 +02:00
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).
2026-06-18 03:24:25 +02:00
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"` ).
2026-06-17 05:29:30 +02:00
```bash
EMERGENCY_WEBHOOK_URL=https://example.org/alert # optional, Benachrichtigung
2026-06-18 03:24:25 +02:00
EMERGENCY_LLM_ENABLED=true # Stufe 2 (Default an); false = nur Stichwörter
EMERGENCY_LLM_MIN_CONFIDENCE=0.6 # Schwelle gegen Fehlalarme
2026-06-17 05:29:30 +02:00
```
2026-06-18 03:24:25 +02:00
> ⚠️ 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).
2026-06-17 05:29:30 +02:00
2026-06-17 02:14:25 +02:00
## 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 <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.
2026-06-17 01:48:56 +02:00
## Tests
```bash
2026-06-17 09:49:04 +02:00
make test # oder: pytest -q (offline, mit Stubs)
2026-06-17 01:48:56 +02:00
```
Abgedeckt: Config-Profile & Präzedenz, Route-Auflösung, Device Router,
2026-06-17 09:49:04 +02:00
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
```
2026-06-17 01:48:56 +02:00
## Port ändern
```bash
PORT=8003 make run # einmalig
sed -i 's/^PORT=.*/PORT=8003/' .env # dauerhaft
PORT=8003 docker compose up # mit Docker
```
feat(web): Remote-Web-UI mit Mikrofon + Forward-Auth (YunoHost-SSO)
- Minimale Web-UI (app/web/, vanilla, same-origin -> kein CORS): Text-Prompt +
Mikrofon-Button (Aufnahme im Browser -> /ws/voice -> Antwort wird vorgelesen),
Token-Streaming, PCM-Wiedergabe, Identitaet/Logout/Admin im Menue
- Forward-/Trusted-Header-Auth (app/auth.py): Identitaet aus SSO-Header, nur von
TRUSTED_PROXY_IPS akzeptiert; sonst Token/Anonymous-Fallback. Auto-Provisioning
via store.get_or_create_user_by_external_id (+ external_id-Spalte/Migration)
- /api/me um is_admin + sso_logout_url erweitert; GET /api/admin/users (Liste) und
GET /api/admin/request-headers (SSO-Header-Discovery), Admin-gated
- StaticFiles-Mount; Config: TRUSTED_AUTH_HEADER/_PROXY_IPS, ADMIN_USERS, SSO_LOGOUT_URL
- WS-Auth liest Identitaet aus dem Handshake-Header
- Deploy: nginx-Vorlage (WS-Upgrade!) + deploy/README.md (HTTPS/SSO/Firewall/Discovery)
- Tests: Forward-Auth (Provisioning, Admin-Flag, Proxy-IP-Trust, 401/403, Static)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 04:37:16 +02:00
## Web-UI & Remote-Zugang
2026-06-18 10:20:24 +02:00
Das Gateway liefert unter `/` eine **Web-Oberfläche ** aus (`app/web/` , Tailwind via CDN,
kein Build): responsives, modernes Layout mit **Tag-/Nacht-Umschalter ** (folgt automatisch
dem System), Text-Eingabe + **Mikrofon-Button ** (Aufnahme im Browser → `/ws/voice` → Antwort
wird vorgelesen), **Stimmen-Auswahl ** (piper/chatterbox/cloud) sowie Identität/Logout und
(für Admins) eine Nutzerliste.
feat(web): Remote-Web-UI mit Mikrofon + Forward-Auth (YunoHost-SSO)
- Minimale Web-UI (app/web/, vanilla, same-origin -> kein CORS): Text-Prompt +
Mikrofon-Button (Aufnahme im Browser -> /ws/voice -> Antwort wird vorgelesen),
Token-Streaming, PCM-Wiedergabe, Identitaet/Logout/Admin im Menue
- Forward-/Trusted-Header-Auth (app/auth.py): Identitaet aus SSO-Header, nur von
TRUSTED_PROXY_IPS akzeptiert; sonst Token/Anonymous-Fallback. Auto-Provisioning
via store.get_or_create_user_by_external_id (+ external_id-Spalte/Migration)
- /api/me um is_admin + sso_logout_url erweitert; GET /api/admin/users (Liste) und
GET /api/admin/request-headers (SSO-Header-Discovery), Admin-gated
- StaticFiles-Mount; Config: TRUSTED_AUTH_HEADER/_PROXY_IPS, ADMIN_USERS, SSO_LOGOUT_URL
- WS-Auth liest Identitaet aus dem Handshake-Header
- Deploy: nginx-Vorlage (WS-Upgrade!) + deploy/README.md (HTTPS/SSO/Firewall/Discovery)
- Tests: Forward-Auth (Provisioning, Admin-Flag, Proxy-IP-Trust, 401/403, Static)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 04:37:16 +02:00
2026-06-18 05:12:14 +02:00
### 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):
```bash
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)
feat(web): Remote-Web-UI mit Mikrofon + Forward-Auth (YunoHost-SSO)
- Minimale Web-UI (app/web/, vanilla, same-origin -> kein CORS): Text-Prompt +
Mikrofon-Button (Aufnahme im Browser -> /ws/voice -> Antwort wird vorgelesen),
Token-Streaming, PCM-Wiedergabe, Identitaet/Logout/Admin im Menue
- Forward-/Trusted-Header-Auth (app/auth.py): Identitaet aus SSO-Header, nur von
TRUSTED_PROXY_IPS akzeptiert; sonst Token/Anonymous-Fallback. Auto-Provisioning
via store.get_or_create_user_by_external_id (+ external_id-Spalte/Migration)
- /api/me um is_admin + sso_logout_url erweitert; GET /api/admin/users (Liste) und
GET /api/admin/request-headers (SSO-Header-Discovery), Admin-gated
- StaticFiles-Mount; Config: TRUSTED_AUTH_HEADER/_PROXY_IPS, ADMIN_USERS, SSO_LOGOUT_URL
- WS-Auth liest Identitaet aus dem Handshake-Header
- Deploy: nginx-Vorlage (WS-Upgrade!) + deploy/README.md (HTTPS/SSO/Firewall/Discovery)
- Tests: Forward-Auth (Provisioning, Admin-Flag, Proxy-IP-Trust, 401/403, Static)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 04:37:16 +02:00
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).
2026-06-17 01:48:56 +02:00
## 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` )
feat(web): Remote-Web-UI mit Mikrofon + Forward-Auth (YunoHost-SSO)
- Minimale Web-UI (app/web/, vanilla, same-origin -> kein CORS): Text-Prompt +
Mikrofon-Button (Aufnahme im Browser -> /ws/voice -> Antwort wird vorgelesen),
Token-Streaming, PCM-Wiedergabe, Identitaet/Logout/Admin im Menue
- Forward-/Trusted-Header-Auth (app/auth.py): Identitaet aus SSO-Header, nur von
TRUSTED_PROXY_IPS akzeptiert; sonst Token/Anonymous-Fallback. Auto-Provisioning
via store.get_or_create_user_by_external_id (+ external_id-Spalte/Migration)
- /api/me um is_admin + sso_logout_url erweitert; GET /api/admin/users (Liste) und
GET /api/admin/request-headers (SSO-Header-Discovery), Admin-gated
- StaticFiles-Mount; Config: TRUSTED_AUTH_HEADER/_PROXY_IPS, ADMIN_USERS, SSO_LOGOUT_URL
- WS-Auth liest Identitaet aus dem Handshake-Header
- Deploy: nginx-Vorlage (WS-Upgrade!) + deploy/README.md (HTTPS/SSO/Firewall/Discovery)
- Tests: Forward-Auth (Provisioning, Admin-Flag, Proxy-IP-Trust, 401/403, Static)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 04:37:16 +02:00
- **Remote über YunoHost/Reverse-Proxy:** `deploy/README.md` (HTTPS, SSO, nginx, Firewall)
2026-06-17 01:48:56 +02:00
## 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
2026-06-18 11:03:15 +02:00
Lizenz: **proprietär – alle Rechte vorbehalten ** (siehe [LICENSE.md ](LICENSE.md )).
2026-06-18 10:59:59 +02:00
2026-06-17 01:48:56 +02:00
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).