my_voice_assistant_v3_jamulix/BEDIENUNGSANLEITUNG.md
dschlueter 9eb5c31eb6 release(v0.2.0): Marke „Alexis" + sichtbare Versionsnummer
- Anzeigename Voice Assistant -> Alexis (UI-Titel/Kopfzeile mit Untertitel
  „Ihr Sprachbegleiter", FastAPI-Titel, README/Handbuch). Technische Identifier
  (Dienst, Pfade, Paketname, Konfig-/DB-Dateinamen) bleiben unveraendert.
- Version als EINE Quelle: pyproject.toml = 0.2.0; app.__version__ liest sie
  (pyproject zuerst, Paket-Metadaten als Fallback). Sichtbar in der UI-Kopfzeile,
  unter /api/config (version) und in OpenAPI.
- CHANGELOG.md mit 0.2.0-Eintrag; Tests fuer /api/config.version + Branding.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 07:02:02 +02:00

2598 lines
100 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.

# Alexis — Handbuch
> **Zielgruppen:** 👤 Endnutzer · 🔧 Admin/Betreiber · 💻 Entwickler
>
> Technische Tiefe: [Architektur-Dokument](Docs/voice-assistant-architecture.md) ·
> Remote-Deployment: [deploy/README.md](deploy/README.md) ·
> Kurzübersicht: [README.md](README.md)
### Lesehilfe: `$URL` und `| jq`
In allen Shell-Beispielen dieses Handbuchs steht `$URL` als Platzhalter für die
Gateway-Adresse. Einmal setzen, dann überall einsetzbar:
```bash
export URL=http://localhost:8003
```
*(Port aus deiner `.env` — Standard ist `8080`, in dieser Installation `8003`.)*
Danach kann man z. B. schreiben:
```bash
curl -s $URL/health
# entspricht: curl -s http://localhost:8003/health
```
Befehle, die JSON zurückgeben, enden auf `| jq` — das formatiert die Ausgabe lesbar.
Installieren: `sudo apt install jq`. Ohne `jq` einfach weglassen; der Befehl
funktioniert trotzdem, die Ausgabe ist dann unformatiert.
---
## Inhaltsverzeichnis
**Grundlagen**
1. [Was ist dieses System?](#1-was-ist-dieses-system)
2. [Installation und Einrichtung](#2-installation-und-einrichtung)
3. [Betriebsprofile wählen](#3-betriebsprofile-wählen)
4. [Starten und Stoppen](#4-starten-und-stoppen) — [4.0 Schnellbefehle](#40-schnellbefehle-überblick) · [4.5 llama.cpp](#45-llamacpp-server-für-profil-hybridlocal-dev) · [4.6 Ollama](#46-ollama-alternative-zu-llamacpp-kein-docker-nötig) · [4.7 Wechseln](#47-zwischen-llamacpp-und-ollama-wechseln) · [4.8 Stoppen](#48-alles-stoppen) · [4.9 Neustart](#49-komplett-neustart)
**Bedienung**
5. [Das System benutzen](#5-das-system-benutzen)
**Konfiguration**
6. [Einstellungen und Konfiguration](#6-einstellungen-und-konfiguration)
**Administration**
7. [Nutzerverwaltung und Authentifizierung](#7-nutzerverwaltung-und-authentifizierung) · [7.5 Admin-Web-Panel](#75-admin-web-panel) · [7.6 Anmeldung & Admin-Zugang](#76-anmeldung--admin-zugang-link-passwort-recovery)
8. [Gedächtnis und Erinnerungen](#8-gedächtnis-und-erinnerungen)
9. [Resilienz, Fallbacks und Metriken](#9-resilienz-fallbacks-und-metriken)
10. [Notfall-Erkennung und Eskalation](#10-notfall-erkennung-und-eskalation)
11. [Remote-Zugang und Deployment](#11-remote-zugang-und-deployment)
**Qualitätssicherung**
12. [Tests und Reaktionszeiten](#12-tests-und-reaktionszeiten)
**Problemlösung**
13. [Fehlerbehebung](#13-fehlerbehebung)
**Referenz**
- [Anhang A — Alle Umgebungsvariablen](#anhang-a--alle-umgebungsvariablen)
- [Anhang B — API-Endpunkte](#anhang-b--api-endpunkte)
- [Anhang C — Provider-Übersicht](#anhang-c--provider-übersicht)
- [Anhang D — Sachregister](#anhang-d--sachregister)
---
## 1. Was ist dieses System?
### 1.1 Überblick
**Alexis** ist ein modulares Sprachassistenten-System. Es nimmt
gesprochene oder getippte Eingaben entgegen, lässt sie von einer KI beantworten und
liest die Antwort vor. Die drei KI-Stufen — **Spracherkennung (STT)**, **Sprachmodell (LLM)**
und **Sprachsynthese (TTS)** — sind einzeln austauschbar: lokal oder in der Cloud,
je nach Bedarf.
Das System läuft als HTTP-/WebSocket-Server (FastAPI). Darauf greift man zu per:
- **Browser** (Web-Interface, mobiltauglich)
- **Kommandozeile** (Sprech-Loop, Chat-Client)
- **eigene Apps** (REST-API, WebSocket)
### 1.2 Leseanleitung nach Zielgruppe
| Du bist … | Lies zuerst … | Dann … |
|-----------|--------------|--------|
| 👤 **Endnutzer** (nutzt den Assistenten) | § 5 Bedienung | § 8 Gedächtnis |
| 🔧 **Admin/Betreiber** (installiert, verwaltet) | § 24 Installation + Profile | § 7, 9, 10, 11 |
| 💻 **Entwickler** (erweitert den Code) | § 2 Installation | [Architektur-Dokument](Docs/voice-assistant-architecture.md) |
### 1.3 Architektur auf einen Blick
```
Eingabe (Sprache/Text)
[ STT-Provider ] Sprache → Text (Whisper lokal oder Cloud)
[ Input Cleaner ] Füllwörter, Whitespace bereinigen
[ LLM-Provider ] Text → Antwort-Text (lokal oder Cloud)
[ Spoken-Response-Adapter ] Markdown raus, vorlesbar machen
[ TTS-Normalizer ] Aussprache (Ordinalzahlen, Einheiten, Abkürzungen)
[ TTS-Provider ] Text → Audio (piper lokal / Cloud)
Ausgabe (Audio-Stream)
```
Jeder Provider ist über die Registry austauschbar — ohne Code-Änderung.
Technische Details: [Architektur-Dokument § 36](Docs/voice-assistant-architecture.md).
---
## 2. Installation und Einrichtung
> 🔧 Admin / 💻 Entwickler
### 2.1 Voraussetzungen
| Bedarf | Details |
|--------|---------|
| **Python 3.11+** | `python3 --version` |
| **jq** | `sudo apt install jq` — für lesbare JSON-Ausgabe |
| **Audio-Tools** | `sudo apt install alsa-utils ffmpeg` — für CLI-Sprech-Loop |
| **OpenRouter-Key** | für Profile `cloud` und `hybrid` (→ [openrouter.ai](https://openrouter.ai)) |
| **Docker + NVIDIA-GPU** | nur für lokalen llama.cpp-Server (Profil `local-dev` / `hybrid`) |
| **piper + Stimmmodell** | nur für lokales TTS (→ § 6.5.2) |
Für lokales STT und TTS zusätzlich:
```bash
pip install -e .[local] # installiert faster-whisper + piper-tts
```
### 2.2 Installation
```bash
cd my_voice_assistant_v3
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
```
Fehlt `.env`, legt `make run` sie automatisch aus `.env.example` an.
### 2.3 API-Key hinterlegen (für Cloud/Hybrid)
Der Key gehört **ausschließlich in die Umgebung** — nie in `.env` oder eine Config-Datei
(Leakage-Risiko):
```bash
echo 'export OPENROUTER_API_KEY=sk-or-v1-DEIN_KEY' >> ~/.bashrc
chmod 600 ~/.bashrc
source ~/.bashrc
echo ${OPENROUTER_API_KEY:0:8} # nur Anfang anzeigen zur Kontrolle
```
Bei Leak: im OpenRouter-Dashboard löschen (= sofort widerrufen) und neu erstellen.
### 2.4 Konfigurationsdatei
Die Datei `config/voice-assistant.toml` enthält Profile und Modellnamen (kein Secret).
Die Vorlage `config/voice-assistant.example.toml` zeigt alle möglichen Einträge.
Präzedenz (höhere Ebene gewinnt): → § 6.1.
---
## 3. Betriebsprofile wählen
> 🔧 Admin
Das Gateway kennt **drei Betriebsprofile**. Sie legen fest, welche der drei KI-Stufen
lokal oder in der Cloud laufen. Einzelne Stufen lassen sich danach noch weiter
übersteuern (→ § 6.2).
### 3.1 Profil `cloud` — alles über OpenRouter *(Empfehlung für den Einstieg)*
Alle drei Stufen laufen remote bei OpenRouter. Nichts lokal zu starten außer dem Gateway.
| Stufe | Läuft | Standard-Modell |
|-------|-------|-----------------|
| STT | OpenRouter | `openai/whisper-large-v3` |
| LLM | OpenRouter | `openai/gpt-4.1-mini` |
| TTS | OpenRouter | `openai/gpt-4o-mini-tts` |
**Was muss laufen?** Nur das Gateway (`make run`).
**Hardware:** Beliebiger Rechner mit Internetzugang. Keine GPU.
**Software:** Nur die Basisinstallation (`pip install -e .[test]`).
**API-Key:** `OPENROUTER_API_KEY` erforderlich.
**Kosten:** ca. 12 ¢ pro Sprech-Runde. STT und TTS sind die Kostentreiber; LLM ist
nahezu kostenlos. Grob ~2040 ¢ pro 10-Minuten-Gespräch.
Genaue Werte: OpenRouter-Dashboard → Activity/Usage.
**Antwortgeschwindigkeit:** ~4 s Round-Trip (STT ~1,2 s + LLM ~0,7 s + TTS ~1,9 s).
Mit Streaming (`audio_stream=true`) kommt die erste Silbe früher — subjektiv schneller.
→ Messung: § 12.2.
**Bewährte Modell-Kombination** (inkl. Plattdeutsch, Stand 2026-06-17):
```bash
# in .env:
VA_PROFILE=cloud
OPENROUTER_STT_MODEL=openai/whisper-large-v3
OPENROUTER_LLM_MODEL=google/gemini-3.1-flash-lite
OPENROUTER_TTS_MODEL=google/gemini-3.1-flash-tts-preview
OPENROUTER_TTS_VOICE=Zephyr
```
**Einrichten:**
```bash
VA_PROFILE=cloud make run
```
---
### 3.2 Profil `hybrid` — STT/TTS Cloud, LLM lokal
STT und TTS laufen remote (OpenRouter), die KI (LLM) läuft lokal. Datenschutzvorteil:
Sprachverständnis verlässt den Rechner nicht. Der finanzielle Vorteil ist gering
(nur ~1015 % günstiger als `cloud`), weil TTS der eigentliche Kostentreiber ist
und remote bleibt.
| Stufe | Läuft | Provider |
|-------|-------|----------|
| STT | OpenRouter | `openrouter` |
| LLM | eigener Rechner | `local-openai-compatible` |
| TTS | OpenRouter | `openrouter` |
**Was muss laufen?** Gateway + lokaler LLM-Server (llama.cpp oder Ollama).
**Hardware:** NVIDIA-GPU empfohlen (llama.cpp mit >7B-Modellen braucht VRAM). Mit
Ollama + kleinen Modellen (7B) auch ohne GPU möglich, aber langsamer.
**API-Key:** `OPENROUTER_API_KEY` erforderlich (für STT + TTS).
**Kosten:** ~0,51,5 ¢/Runde. Nur TTS bleibt remote; STT war ohnehin günstig.
**Antwortgeschwindigkeit:** STT/TTS wie `cloud`. LLM-Latenz vom lokalen Modell
abhängig — Qwen3-35B auf RTX 3090 mit `LOCAL_LLM_DISABLE_REASONING=true`: ~0,7 s.
**Einrichten (llama.cpp):**
```bash
make llm-up # Docker-Container starten (GPU 1, Port 8001)
make llm-status # warten bis "HTTP OK" erscheint
VA_PROFILE=hybrid make run
```
**Einrichten (Ollama):**
```bash
# in .env:
LOCAL_LLM_BASE_URL=http://127.0.0.1:11434/v1
LOCAL_LLM_API_KEY=ollama
LOCAL_LLM_MODEL=qwen3:30b-a3b # exakter Name aus 'ollama list'
VA_PROFILE=hybrid make run
```
> ⚠️ Das Gateway startet auch ohne laufenden LLM-Server fehlerfrei hoch. Der Fehler
> „All connection attempts failed" erscheint erst beim ersten Request. Deshalb: immer
> erst auf den LLM-Server warten, dann Gateway starten.
---
### 3.3 Profil `local-dev` — alles lokal
Alle drei Stufen laufen auf dem eigenen Rechner. Kein Internet nötig, keine API-Kosten.
Maximaler Datenschutz.
| Stufe | Läuft | Provider |
|-------|-------|----------|
| STT | eigener Rechner | `faster-whisper` |
| LLM | eigener Rechner | `local-openai-compatible` |
| TTS | eigener Rechner | `piper` |
**Was muss laufen?** Gateway + lokaler LLM-Server. STT (faster-whisper) und TTS (piper)
laufen direkt im Gateway-Prozess — kein eigener Dienst nötig.
**Hardware:**
- NVIDIA-GPU für llama.cpp (35B-Modell: ~20 GB VRAM)
- Mit Ollama + 7B-Modell auch ohne GPU möglich (langsam)
- Kein Internetzugang nötig
**API-Key:** keiner.
**Kosten:** keine API-Kosten. Nur Stromkosten (GPU).
**Antwortgeschwindigkeit:** STT (`faster-whisper base` auf CPU) ~13 s; LLM wie
`hybrid`; TTS (`piper`, in-process) ~0,30,5 s/Satz. Erste Antwort nach Start ist
schnell, weil Modelle beim Serverstart vorgeladen werden (Warm-up).
**Sprachqualität:** piper klingt synthetischer als Cloud-TTS. Whisper `base` ist
bei Dialekten schwächer als `large-v3`. Für bessere Qualität:
`FASTER_WHISPER_MODEL=large-v3` + `FASTER_WHISPER_DEVICE=cuda`.
**Einrichten (llama.cpp):**
```bash
pip install -e .[local] # faster-whisper + piper-tts installieren
# Piper-Stimmmodell bereitstellen (einmalig, → § 6.5.2)
make llm-up # warten bis make llm-status "HTTP OK" zeigt
VA_PROFILE=local-dev make run
```
**Einrichten (Ollama als LLM-Backend):**
Ollama bietet eine OpenAI-kompatible API und verwaltet seinen Server selbst — kein
Docker, kein Start-Skript nötig.
```bash
# Voraussetzung: ollama installiert und Modell geladen
ollama pull qwen3:30b-a3b
# in .env:
LOCAL_LLM_BASE_URL=http://127.0.0.1:11434/v1
LOCAL_LLM_API_KEY=ollama
LOCAL_LLM_MODEL=qwen3:30b-a3b
VA_PROFILE=local-dev make run
```
Hinweis: `LOCAL_LLM_DISABLE_REASONING=true` (Standard) schickt
`chat_template_kwargs: {enable_thinking: false}` — Ollama ignoriert dieses Feld.
Reasoning muss über den Modell-Tag abgeschaltet werden (`qwen3:30b-a3b` statt
`qwen3:30b-a3b:thinking`) oder bleibt an.
---
### 3.4 Vergleich auf einen Blick
| | `cloud` | `hybrid` | `local-dev` |
|---|---------|----------|-------------|
| **STT** | remote | remote | lokal |
| **LLM** | remote | **lokal** | **lokal** |
| **TTS** | remote | remote | **lokal** |
| **API-Key nötig** | ja | ja | nein |
| **GPU nötig** | nein | empfohlen | empfohlen |
| **Internetverbindung** | ja | ja | nein |
| **API-Kosten/Runde** | ~12 ¢ | ~0,51,5 ¢ | ~0 (nur Strom) |
| **Round-Trip** | ~4 s | ~35 s | ~36 s |
| **TTS-Qualität** | hoch | hoch | mittel (piper) |
| **Datenschutz** | gering | hoch | maximal |
| **Empfohlen für** | Einstieg, Senioren | Datenschutz + gutes TTS | Offline, kein API-Key |
### 3.5 Profil wechseln
```bash
# dauerhaft in .env:
VA_PROFILE=cloud
# einmalig für einen Start:
VA_PROFILE=hybrid make run
# aktive Konfiguration prüfen:
curl -s $URL/api/config | jq '{profile, default_route}'
```
> **Falle:** Sind `DEFAULT_STT_PROVIDER`, `DEFAULT_LLM_PROVIDER` oder
> `DEFAULT_TTS_PROVIDER` in `.env` gesetzt, überschreiben sie das Profil.
> Diese Zeilen auskommentieren, wenn profilbasiert umgeschaltet werden soll.
---
## 4. Starten und Stoppen
> 🔧 Admin
### 4.0 Schnellbefehle (Überblick)
Die wichtigsten Kommandos auf einen Blick — Details in den Abschnitten darunter.
**Starten:**
| Situation | Kommando |
|-----------|----------|
| Profil `cloud` — nur Gateway | `make run` |
| Profil `hybrid` / `local-dev` — llama.cpp + Gateway | `make start` |
| Profil `hybrid` / `local-dev` — Ollama + Gateway | `sudo systemctl start ollama && make run` |
| LLM-Backend → Ollama wechseln | `make llm-ollama` (→ § 4.7) |
| LLM-Backend → llama.cpp wechseln | `make llm-llamacpp` (→ § 4.7) |
| systemd-Dienst starten | `systemctl --user start voice-assistant` |
**Stoppen:**
| Situation | Kommando |
|-----------|----------|
| Gateway im Vordergrund | **Strg + C** |
| Gateway im Hintergrund / systemd | `make stop` |
| Alles (Gateway + llama.cpp) | `make stop` |
| llama.cpp allein | `make llm-down` |
| Ollama allein | `sudo systemctl stop ollama` |
**Neu starten (alles):**
```bash
make restart # make stop + make start (llama.cpp + Gateway)
# Nur Gateway neu starten (llama.cpp läuft weiter):
systemctl --user restart voice-assistant # systemd
# oder: Strg+C und make run # Vordergrund
```
---
### 4.1 Vordergrund (Entwicklung/Test)
```bash
source .venv/bin/activate
make run # Gateway startet auf dem in .env gesetzten PORT
```
Beenden mit **Strg + C**. Schnelltest:
```bash
curl -s $URL/health | jq
curl -s $URL/api/config | jq
```
### 4.2 Hintergrund
```bash
nohup make run > server.log 2>&1 & # starten, Logs nach server.log
pkill -f "uvicorn app.main:app" # stoppen
tail -f server.log # Logs beobachten
```
Mehr Log-Details: `LOG_LEVEL=debug` in `.env` setzen.
### 4.3 Als systemd-Dienst (Dauer-Betrieb, ohne root)
```bash
cp deploy/voice-assistant.user.service ~/.config/systemd/user/voice-assistant.service
loginctl enable-linger "$USER" # überlebt Logout und Reboot
systemctl --user daemon-reload
systemctl --user enable --now voice-assistant
systemctl --user status voice-assistant
journalctl --user -u voice-assistant -f # Logs live verfolgen
```
Konfiguration: `deploy/voice-assistant.env.example` → anpassen, dann als
`/etc/voice-assistant/voice-assistant.env` ablegen (Pfad in der Unit).
### 4.4 Docker
```bash
export OPENROUTER_API_KEY=...
docker compose up --build
```
Port ändern: `PORT=8005 make run` (einmalig) oder `PORT=8005` in `.env` (dauerhaft).
### 4.5 llama.cpp-Server (für Profil `hybrid`/`local-dev`)
**Voraussetzungen:** Docker mit NVIDIA-Container-Toolkit, GPU mit ausreichend VRAM
(Qwen3-35B-Q4: ~22 GB; Qwen3-8B-Q4: ~5 GB).
```bash
# Starten (Default: GPU 1, Port 8001, Modell qwen3-35B-Uncensored):
make llm-up
# Status prüfen (warten bis „Modell bereit" und HTTP 200 erscheinen):
make llm-status
# Logs live beobachten:
docker logs -f va_llm
# Stoppen:
make llm-down
```
**Mit anderen Parametern** — ENV-Variable vor dem Befehl setzen:
```bash
# Andere GPU:
GPU_DEVICE=0 make llm-up
# Anderen Port:
HOST_PORT=8101 make llm-up
# Anderes Modell auf anderer GPU:
GPU_DEVICE=2 HOST_PORT=8102 MODEL_REL_PATH="models/qwen3/anderes-modell.gguf" make llm-up
# Direkt (ohne make — identisch, aber zeigt alle Parameter):
bash scripts/llm-server/start-llm-server.sh
GPU_DEVICE=0 bash scripts/llm-server/start-llm-server.sh
GPU_DEVICE=2 HOST_PORT=8102 MODEL_REL_PATH="models/qwen3/anderes-modell.gguf" \
bash scripts/llm-server/start-llm-server.sh
```
Alle überschreibbaren ENV-Variablen:
| Variable | Default | Bedeutung |
|----------|---------|-----------|
| `GPU_DEVICE` | `1` | GPU-Index (0-basiert, `nvidia-smi` zeigt verfügbare GPUs) |
| `HOST_PORT` | `8001` | Host-Port des LLM-Servers |
| `MODEL_REL_PATH` | `models/qwen3/Qwen3.6-35B-A3B-Uncensored-...Q4_K_M.gguf` | Modellpfad relativ zu `HF_HOME` |
| `HF_HOME` | `~/nvme2n1p7_home/huggingface` | Modell-Basisverzeichnis (als Volume eingebunden) |
| `MODEL_ALIAS` | `va_llm` | Modellname in der OpenAI-API (→ `LOCAL_LLM_MODEL` in `.env`) |
| `CONTAINER_NAME` | `va_llm` | Docker-Containername |
| `IMAGE` | `ghcr.io/ggml-org/llama.cpp:server-cuda` | Docker-Image |
> ⚠️ Wird `HOST_PORT` oder `MODEL_ALIAS` geändert, müssen `LOCAL_LLM_BASE_URL`
> und `LOCAL_LLM_MODEL` in `.env` entsprechend angepasst werden.
Das Skript wartet bis zu 300 Sekunden auf einen HTTP-200-Response und bricht mit
Fehler ab, wenn das Modell nicht startet — kein stilles Fehlschlagen.
---
### 4.6 Ollama (Alternative zu llama.cpp, kein Docker nötig)
Ollama verwaltet seinen Serverprozess selbst und braucht kein Docker. Es eignet sich
besonders für schnellen Einstieg, CPU-Betrieb und kleinere Modelle.
**Installation** (falls noch nicht installiert):
```bash
curl -fsSL https://ollama.com/install.sh | sh
```
**Dienst starten:**
```bash
# empfohlen — systemd verwaltet den Prozess:
sudo systemctl start ollama
sudo systemctl enable ollama # automatisch bei Boot starten
sudo systemctl status ollama # Status prüfen
# alternativ — manuell im Vordergrund (Strg+C stoppt):
ollama serve
# mit anderem Port (Default: 11434):
OLLAMA_HOST=0.0.0.0:11435 ollama serve
```
**Modell herunterladen** (einmalig):
```bash
ollama pull qwen3:30b-a3b # ~20 GB, Thinking deaktiviert (empfohlen für Voice)
ollama pull qwen3:8b # ~5 GB, CPU-tauglich, weniger Qualität
ollama pull qwen3:14b # ~9 GB, guter Kompromiss
```
**Status prüfen:**
```bash
ollama list # installierte Modelle mit Größe und Änderungsdatum
ollama ps # gerade aktive Modelle mit VRAM-Verbrauch
```
**Modell entfernen** (Speicher freigeben):
```bash
ollama rm qwen3:8b
```
**Gateway für Ollama konfigurieren** (in `.env`):
```bash
LOCAL_LLM_BASE_URL=http://127.0.0.1:11434/v1
LOCAL_LLM_API_KEY=ollama
LOCAL_LLM_MODEL=qwen3:30b-a3b # exakter Name aus 'ollama list'
```
**Gateway starten:**
```bash
VA_PROFILE=hybrid make run # STT/TTS cloud, LLM via Ollama
VA_PROFILE=local-dev make run # alles lokal (STT/TTS in-process, LLM via Ollama)
```
> **Hinweis Reasoning:** `LOCAL_LLM_DISABLE_REASONING=true` (Gateway-Standard) schickt
> `enable_thinking: false` an den Server — Ollama ignoriert dieses Feld. Um Reasoning
> zu deaktivieren, den Modell-Tag ohne Thinking-Suffix wählen (`qwen3:30b-a3b` statt
> `qwen3:30b-a3b:thinking`).
---
### 4.7 Zwischen llama.cpp und Ollama wechseln
Beide nutzen denselben Gateway-Provider `local-openai-compatible` (OpenAI-kompatible
API). `DEFAULT_LLM_PROVIDER` bleibt beim Wechsel unverändert.
#### Schnellster Weg: Make-Targets (empfohlen)
```bash
make llm-ollama # -> Ollama (Default-Modell gemma3:latest)
make llm-llamacpp # -> llama.cpp (Alias va_llm)
# Anderes Ollama-Modell:
OLLAMA_MODEL=qwen2.5:latest make llm-ollama
```
Das Target erledigt automatisch alle Schritte: es gibt den GPU-Speicher des anderen
Backends frei (llama.cpp-Container stoppen bzw. geladene Ollama-Modelle entladen — der
Ollama-*Dienst* bleibt für andere Nutzungen laufen), startet das gewünschte Backend,
passt die `LOCAL_LLM_*`-Zeilen in `.env` an und startet das Gateway neu (als Dienst)
bzw. weist auf den manuellen Neustart hin. Skript: `scripts/llm-server/switch-llm.sh`.
> **Warum der Gateway-Neustart nötig ist:** Das `Makefile` exportiert die `.env`-Werte
> als echte Umgebungsvariablen an `uvicorn` — und Env-Variablen haben **Vorrang vor der
> `.env`-Datei**. Eine reine `.env`-Änderung wirkt daher erst, wenn das Gateway neu
> gestartet wird (uvicorn `--reload` reagiert nur auf Code-, nicht auf `.env`-Änderungen).
> Starte es **in einer frischen Shell** neu (`make run`) bzw. als Dienst:
> `systemctl --user restart voice-assistant.service`.
#### Manuell (was die Targets im Hintergrund tun)
**Merkhilfe:**
- llama.cpp = Docker-Container `va_llm``make llm-up` / `make llm-down` (Port 8001)
- Ollama = systemd-Dienst → `sudo systemctl start/stop ollama` (Port 11434)
GPU freigeben ohne Dienst-Stopp: `ollama stop <modell>`
#### Von llama.cpp → Ollama wechseln
```bash
# 1) llama.cpp stoppen (GPU freigeben)
make llm-down # alternativ: docker rm -f va_llm
# 2) Ollama starten und Modell sicherstellen
sudo systemctl start ollama
ollama list # exakten Modellnamen ablesen (z. B. gemma4:12b)
ollama pull gemma4:12b # nur falls noch nicht vorhanden
# 3) .env umstellen:
# LOCAL_LLM_BASE_URL=http://127.0.0.1:11434/v1
# LOCAL_LLM_API_KEY=ollama
# LOCAL_LLM_MODEL=gemma4:12b # exakter Name aus 'ollama list'
# 4) Gateway neu starten
VA_PROFILE=hybrid make run # oder: systemctl --user restart voice-assistant.service
```
#### Von Ollama → llama.cpp wechseln
```bash
# 1) Ollama stoppen (GPU freigeben)
sudo systemctl stop ollama
pkill -f "ollama serve" 2>/dev/null || true # falls manuell im Vordergrund gestartet
# 2) llama.cpp starten und warten
make llm-up
make llm-status # warten bis „Modell bereit" + HTTP OK
# 3) .env umstellen:
# LOCAL_LLM_BASE_URL=http://127.0.0.1:8001/v1
# LOCAL_LLM_API_KEY=dummy
# LOCAL_LLM_MODEL=va_llm
# 4) Gateway neu starten
VA_PROFILE=hybrid make run # oder: systemctl --user restart voice-assistant.service
```
**Prüfen** (egal welche Richtung):
```bash
curl -s http://127.0.0.1:11434/v1/models | jq # Ollama (bzw. :8001 für llama.cpp)
# danach im Admin → Status den LLM-Provider/das Modell kontrollieren oder kurz testen
```
> **Reasoning/Latenz:** Das Gateway sendet `enable_thinking:false`; **Ollama ignoriert**
> das. Modelle mit eingebautem „Thinking" (z. B. `gemma4:12b`) liefern die Antwort sauber
> im `content`, denken aber intern mit → höhere Latenz. Für reinen Smalltalk ggf. ein
> kleineres/nicht-reasonendes Modell wählen.
---
### 4.8 Alles stoppen
```bash
make stop
```
Ein Befehl stoppt alle Voice-Assistant-Komponenten:
- Gateway (ob im Vordergrund gestartet, im Hintergrund oder als systemd-Dienst)
- llama.cpp-Docker-Container (`va_llm`)
Ollama ist ein systemd-Dienst und muss separat gestoppt werden:
```bash
sudo systemctl stop ollama
```
**Einzelne Komponenten stoppen:**
```bash
# Nur Gateway (Vordergrund):
Strg + C
# Nur Gateway (Hintergrund):
pkill -f "uvicorn app.main:app"
# Nur Gateway (systemd):
systemctl --user stop voice-assistant
# Nur llama.cpp:
make llm-down
# oder direkt:
docker rm -f va_llm
# Nur Ollama:
sudo systemctl stop ollama
```
### 4.9 Komplett-Neustart
```bash
make restart
```
Entspricht `make stop` gefolgt von `make start` (llama.cpp + Gateway). Sinnvoll nach
Konfigurationsänderungen, die einen Neustart erfordern (z. B. neue `.env`-Werte).
**Nur Gateway neu starten** (llama.cpp läuft weiter — schneller):
```bash
systemctl --user restart voice-assistant # systemd-Betrieb
# oder: Strg+C → make run # Vordergrund-Betrieb
```
> ⚠️ `make restart` startet llama.cpp neu (Modell lädt ~5 Min.). Wenn nur der Gateway-
> Code oder die Konfiguration geändert wurde, ist `systemctl --user restart voice-assistant`
> deutlich schneller.
---
### 4.10 Dauerbetrieb als Dienst + GPU automatisch frei
Damit der Gateway beim Booten automatisch startet und nicht im Vordergrund hängt,
läuft er als **systemd-User-Dienst** (Unit: `deploy/voice-assistant.user.service`).
```bash
cp deploy/voice-assistant.user.service ~/.config/systemd/user/voice-assistant.service
loginctl enable-linger "$USER" # sudo -> Dienst läuft auch ohne Login / nach Reboot
systemctl --user daemon-reload
systemctl --user enable --now voice-assistant
```
**Wichtig — der Gateway blockiert die GPU NICHT.** Der Gateway-Prozess läuft auf der
CPU. Die GPU 1 wird allein vom **LLM-Backend** belegt:
- **llama.cpp** (Docker-Container) ist *immer resident* → belegt die GPU dauerhaft, solange er läuft. Daher **nicht** automatisch mitstarten; nur bei Bedarf (`make llm-llamacpp`).
- **Ollama** lädt das Modell erst beim ersten Request in die GPU und gibt sie nach
Leerlauf wieder frei — **wenn** `OLLAMA_KEEP_ALIVE` ein Timeout ist (Default `-1` = nie).
→ Für „GPU im Leerlauf frei" das Drop-in `deploy/ollama-keepalive.conf` installieren
(setzt `OLLAMA_KEEP_ALIVE=5m`):
```bash
sudo mkdir -p /etc/systemd/system/ollama.service.d
sudo cp deploy/ollama-keepalive.conf /etc/systemd/system/ollama.service.d/keepalive.conf
sudo systemctl daemon-reload && sudo systemctl restart ollama
```
So ist GPU 1 standardmäßig frei: Der Gateway läuft (Boot), Ollama hält die GPU nur
während aktiver Nutzung. Du musst nichts mehr manuell stoppen.
> **Hinweis:** Erst im Dienst-Betrieb funktioniert der **Log-Tab** des Admin-Panels
> (er streamt das Journal der Unit). Im Vordergrund-Betrieb (`make run`) landen die
> Logs nur im Terminal.
---
## 5. Das System benutzen
> 👤 Endnutzer
### 5.1 Web-Interface im Browser *(einfachster Einstieg)*
Das Gateway liefert unter `/` eine fertige Web-Oberfläche aus — kein zusätzliches
Programm nötig.
#### 5.1.1 URL aufrufen
```
http://localhost:8003/ ← am Server selbst (Mikrofon funktioniert)
http://<server-lan-ip>:8003/ ← aus dem LAN (nur Text-Chat; Mikrofon braucht HTTPS)
https://va.beispiel.de/ ← remote über Reverse-Proxy (alles, inkl. Mikrofon)
```
> Mikrofon im Browser geht nur über `localhost` oder HTTPS. Für Sprachaufnahme von
> einem anderen Gerät im Heimnetz: HTTPS-Zugang einrichten (→ § 11.2).
#### 5.1.2 Oberfläche auf einen Blick
```
┌─────────────────────────────────────────────────────────┐
│ 👄 Voice Assistant ............... [🇩🇪 ▾] [ ⋮ ] │
├─────────────────────────────────────────────────────────┤ ┌─ Menü (⋮) ──────────────┐
│ Angemeldet als <user> ....................... Abmelden │ │ VORLESEN │
├─────────────────────────────────────────────────────────┤ │ ▣ 📱 Im Gerät │
│ Nachrichtenverlauf │ │ ▢ ⚡ Schnell │
│ (eigene Nachrichten: blaue Blase rechts) │ │ ▢ ✨ Hohe Qualität │
│ (Assistent: graue Blase links) │ │ ▢ ☁ Cloud │
│ │ │ ────────────────────── │
├─────────────────────────────┬───────────────────────────┤ │ ✎ Neues Gespräch │
│ Texteingabe … [Senden] │ [🎤] │ │ 🌙 Tag-/Nachtmodus │
└─────────────────────────────┴───────────────────────────┘ │ ⚙ Admin-Bereich │
Statuszeile: „denkt …" / „verarbeite Sprache …" / leer └──────────────────────────┘
```
**Kopfzeile (immer schlank):**
| Element | Funktion |
|---------|----------|
| **👄 / Titel** | Marke; Mund-Symbol als Wiedererkennung |
| **Sprache ▾** | Antwortsprache (fest, → § 6.6). Diktat in einer Fremdsprache wird automatisch in die Zielsprache übersetzt. Welche Sprachen erscheinen, legt der Admin pro Nutzer fest. |
| **⋮ Menü** | Öffnet die Einstellungen (unten) |
**Identitätsleiste (direkt unter der Kopfzeile):** links „Angemeldet als …" (SSO-Identität;
„Gast" ohne SSO), rechts **Abmelden** (Link zum SSO-Logout).
**Im ⋮-Menü:**
| Element | Funktion |
|---------|----------|
| **Vorlesen** | Wie wird die Antwort vorgelesen? `📱 Im Gerät` = das Handy liest selbst vor (kein Server-Audio → spart Daten; fällt auf Server zurück, wenn der Browser keine Stimmen hat) · `⚡ Schnell` = piper (lokal) · `✨ Hohe Qualität` = chatterbox (natürliche Stimme) · `☁ Cloud` = OpenRouter (Internet nötig). „Im Gerät" erscheint nur, wo der Browser es unterstützt. |
| **♂ Männlich / ♀ Weiblich** | Geschlechtspräferenz der Stimme. Radiobuttons — immer genau einer aktiv. Standard: ♀. Wirkt bei Piper (wo ♂+♀-Varianten vorhanden: DE, EN, FR, ES, IT, RU, PL) und bei Cartesia (DE, EN, FR, ES, ZH, PT). Sprachen ohne Geschlechtspaar (AR, ZH, PT bei Piper) ignorieren die Einstellung. Präferenz wird dauerhaft gespeichert. |
| **✎ Neues Gespräch** | Frische Sitzung (Verlauf zurücksetzen) |
| **🌙 Tag-/Nachtmodus** | Heller/dunkler Modus; folgt sonst dem Betriebssystem |
| **⚙ Admin-Bereich** | Öffnet das Admin-Panel — nur für Admin-Nutzer sichtbar (→ § 7.5) |
**Unten:**
| Element | Funktion |
|---------|----------|
| **🎤 Mikrofon** | **grün:** tippen → Aufnahme · **rot:** tippen → stoppt & sendet · **amber ⏹:** tippen → KI unterbrechen (Barge-in). STT läuft serverseitig (Whisper). |
| **Texteingabe + Senden** | Text tippen, dann Enter oder „Senden" |
#### 5.1.3 Typischer Ablauf — Textchat
1. Seite aufrufen → Eingabefeld ist aktiv.
2. Text tippen (z. B. „Wie wird das Wetter morgen?") → **Enter** oder **Senden**.
3. Eigene Nachricht erscheint als blaue Blase; Assistent antwortet grau und liest vor.
4. Nächste Frage — der Verlauf bleibt (solange die Seite offen ist).
#### 5.1.4 Typischer Ablauf — Sprachaufnahme
1. **🎤** tippen → Button wird rot, Statuszeile: „Aufnahme …".
2. Sprechen.
3. **🎤** erneut tippen → Statuszeile: „verarbeite Sprache …" → „denkt …".
4. Transkription erscheint blau, Antwort grau — und wird vorgelesen.
**Antwort unterbrechen (Barge-in):** Während der Button amber / ⏹ zeigt (KI spricht),
einfach erneut tippen → Wiedergabe stoppt sofort, Generierung auf dem Server bricht ab.
Der Button kehrt zu grün zurück, sobald die Verbindung sauber geschlossen ist.
#### 5.1.5 Fehlermeldungen im Chat
| Meldung | Ursache | Abhilfe |
|---------|---------|---------|
| „Verbindungsfehler" | WebSocket-Verbindung gescheitert | Seite neu laden; Gateway läuft? (`make run`) |
| „Fehler: All connection attempts failed" | LLM-/STT-/TTS-Dienst nicht erreichbar | Dienst starten (z. B. `make llm-up`) |
| „Mikrofon-Zugriff fehlgeschlagen" | Browser hat Mikrofon verweigert | Browser-Einstellungen → Mikrofon erlauben; oder HTTPS nutzen |
| „Aufnahme nicht unterstützt" | Browser zu alt (iOS < 14.3) | Browser/iOS aktualisieren |
---
### 5.2 Sprech-Loop (Kommandozeile) *(empfohlen für Desktop)*
Nimmt vom Mikrofon auf, schickt die Aufnahme ans Gateway, spielt die Antwort ab —
fortlaufend, mit Gedächtnis:
```bash
source .venv/bin/activate
python scripts/voice_loop.py --session mein-gespraech
```
**Ablauf je Runde:**
1. **[Enter]** → sprechen
2. **[Enter]** → Aufnahme stoppt, Assistent antwortet hörbar
3. **[Enter]** während der KI antwortet (Text streamt *oder* Audio spielt) → **Barge-in:** Antwort sofort unterbrechen
4. **[Enter]** → nächste Runde beginnen
5. **Strg + C** → beenden
**Nützliche Optionen:**
| Option | Wirkung |
|--------|---------|
| `--stream-text` | Antworttext live anzeigen, während die KI generiert |
| `--no-stream-audio` | satzweises Vorlesen abschalten (erst komplett, dann abspielen) |
| `--recorder arecord --device plughw:6,0` | bestimmtes Mikrofon erzwingen |
| `--stt-provider faster-whisper` | STT-Provider für diese Sitzung |
| `--llm-provider local-openai-compatible` | LLM-Provider für diese Sitzung |
| `--tts-provider openrouter` | TTS-Provider für diese Sitzung |
| `--voice Zephyr` | TTS-Stimme für diese Sitzung |
| `--token "$TOKEN"` | Bearer-Token (wenn `AUTH_ENABLED=true`) |
| `--file frage.wav` | WAV-Datei statt Mikrofon senden (Test) |
**Mikrofon-Auswahl:** Ohne `--device` folgt der Loop dem **System-Standard-Mikrofon**
(umstellbar unter *Ubuntu → Einstellungen → Ton*, → § 6.7). `--recorder auto` (Standard)
wählt selbsttätig ein Aufnahmewerkzeug, das wirklich Audio liefert
(`ffmpeg``parecord``arecord``pw-record`).
**Audio-Ausgabe:** Der Loop spielt über das **System-Standard-Ausgabegerät**. Ist die
Bluetooth-Box dort als Standard gesetzt, kommt die Antwort automatisch über sie.
---
### 5.3 Chat-Client (Kommandozeile, nur Text)
```bash
python chat_client.py "Erzähl mir bitte einen guten Morgen-Spruch"
```
Schickt Text ans Gateway und spielt die gesprochene Antwort ab (Port aus `.env`, hier 8003).
---
### 5.4 Pipeline manuell verstehen (Einzelschritte)
Gut für Tests und um die Stufen separat zu messen:
```bash
# 1) Aufnehmen (Strg+C zum Stoppen):
arecord -f S16_LE -r 16000 -c 1 frage.wav
# 2) Transkribieren (Audio → Text):
curl -s -X POST $URL/api/transcribe \
-F "file=@frage.wav" -F "language=de" | jq
# 3) Antwort erzeugen (Text → Audio) und abspielen:
curl -s -X POST "$URL/api/chat?session_id=loop" \
-H 'Content-Type: application/json' \
-d '{"text":"Guten Tag, wie heißt du?"}' --output antwort.pcm
ffplay -loglevel quiet -nodisp -autoexit -f s16le -ar 24000 -ac 1 antwort.pcm
# alternativ: aplay -f S16_LE -r 24000 -c 1 antwort.pcm
# Nur Sprachausgabe (Text → Audio):
curl -s -X POST $URL/api/speak \
-H 'Content-Type: application/json' \
-d '{"text":"Guten Morgen!"}' --output gruss.pcm
# Chat als Text-Trace (ohne Audio), lesbar:
curl -s -X POST "$URL/api/chat?debug=true" \
-H 'Content-Type: application/json' \
-d '{"text":"Wie wird das Wetter?"}' | jq
```
---
## 6. Einstellungen und Konfiguration
> 🔧 Admin / 👤 Endnutzer (je nach Abschnitt)
### 6.1 Konfigurationsebenen und Priorität
Niedrigere Ebene wird von höherer überschrieben:
```
eingebaute Defaults
↓ überschrieben von
config/voice-assistant.toml (inkl. aktivem Profil)
ENV / .env
Nutzer-Präferenzen (PUT /api/me/prefs)
Session-Route (POST /api/sessions/{id}/route)
Request-Body (Felder im POST /api/chat etc.)
```
Dies bedeutet: Was im Request-Body steht, gilt nur für diesen einen Aufruf.
Was in `.env` steht, gilt global — aber nur wenn die darüber liegenden Ebenen nicht übersteuern.
```bash
# Aktiv aufgelöste Konfiguration ansehen:
curl -s $URL/api/config | jq
```
### 6.2 KI-Provider wechseln (STT / LLM / TTS)
Verfügbare Provider (→ vollständige Liste: Anhang C):
| Kategorie | Provider-Name | Beschreibung |
|-----------|--------------|--------------|
| STT | `openrouter` | Cloud (Whisper via OpenRouter) |
| STT | `faster-whisper` | Lokal (braucht `pip install -e .[local]`) |
| LLM | `openrouter` | Cloud (GPT-4.1-mini, Gemini, …) |
| LLM | `local-openai-compatible` | Lokal (llama.cpp oder Ollama) |
| TTS | `openrouter` | Cloud (GPT-4o-mini-TTS, Gemini-TTS, …) |
| TTS | `piper` | Lokal, schnell (braucht `pip install -e .[local]` + Stimmmodell) |
| TTS | `chatterbox` | Lokal, hohe Qualität + Voice-Cloning (eigener HTTP-Dienst) |
**Global (dauerhaft in `.env`):**
```bash
# Profil wählen — empfohlen statt einzelne Provider zu setzen:
VA_PROFILE=hybrid
# Alternativ: einzelne Provider direkt setzen (überschreibt das Profil!):
DEFAULT_STT_PROVIDER=faster-whisper
DEFAULT_LLM_PROVIDER=local-openai-compatible
DEFAULT_TTS_PROVIDER=piper
```
**Pro Nutzer** (dauerhaft für diesen User, bis er es ändert):
```bash
curl -s -X PUT $URL/api/me/prefs \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"llm_provider":"openrouter","tts_provider":"piper"}' | jq
```
**Pro Session** (gilt für alle Aufrufe mit dieser `session_id`):
```bash
curl -s -X POST $URL/api/sessions/oma-anna/route \
-H 'Content-Type: application/json' \
-d '{"tts_provider":"openrouter","language":"de"}' | jq
```
**Pro Aufruf** (gilt nur für diesen einen Request):
```bash
curl -s -X POST "$URL/api/chat?debug=true" \
-H 'Content-Type: application/json' \
-d '{"text":"Test","llm_provider":"openrouter","tts_provider":"piper"}' | jq '.route'
```
Im Sprech-Loop per Flag:
```bash
python scripts/voice_loop.py \
--stt-provider faster-whisper \
--llm-provider local-openai-compatible \
--tts-provider openrouter
```
---
### 6.3 STT-Einstellungen (Spracherkennung)
| Variable | Default | Bedeutung |
|----------|---------|-----------|
| `OPENROUTER_STT_MODEL` | `openai/whisper-large-v3` | Cloud-Modell |
| `FASTER_WHISPER_MODEL` | `base` | Lokales Modell: `tiny\|base\|small\|medium\|large-v3` |
| `FASTER_WHISPER_DEVICE` | `auto` | Gerät: `auto\|cpu\|cuda` |
| `FASTER_WHISPER_COMPUTE_TYPE` | `default` | Precision: `default\|int8\|float16\|int8_float16` |
Für bessere Qualität bei Dialekt (braucht viel VRAM):
```bash
FASTER_WHISPER_MODEL=large-v3
FASTER_WHISPER_DEVICE=cuda
FASTER_WHISPER_COMPUTE_TYPE=float16
```
> **Hinweis:** Die Spracherkennung läuft auf **allen** Geräten serverseitig (Whisper) —
> einheitlich und zuverlässig. (Ein früher erprobtes Geräte-STT über die Web Speech API
> wurde wieder entfernt: Auf Android-Chrome lief es nur über die Google-Cloud, und die
> Erkennungsqualität war Whisper unterlegen. Das **Vorlesen** im Gerät (§ 6.5.0) bleibt.)
---
### 6.4 LLM-Einstellungen (Sprachmodell, lokal)
Diese Settings gelten nur für den Provider `local-openai-compatible`.
| Variable | Default | Bedeutung |
|----------|---------|-----------|
| `LOCAL_LLM_BASE_URL` | `http://127.0.0.1:8001/v1` | URL des lokalen LLM-Servers |
| `LOCAL_LLM_API_KEY` | `dummy` | Beliebiger Wert (bei Ollama: `ollama`) |
| `LOCAL_LLM_MODEL` | `va_llm` | Modellname / Alias |
| `LOCAL_LLM_DISABLE_REASONING` | `true` | Qwen3-Denkphase abschalten (~9× schneller) |
| `LOCAL_LLM_SYSTEM_PROMPT` | Sprach-Prompt | Kurze, vorlesbare Antworten |
| `LOCAL_LLM_MAX_TOKENS` | `0` (Server-Limit) | Optionaler Deckel, z. B. `256` |
| `LOCAL_LLM_TEMPERATURE` | `0.3` | Sampling-Temperatur |
| `LOCAL_LLM_TOP_P` | `0.9` | Nucleus-Sampling (0.01.0) |
> Temperatur, Top-p und Max-Tokens sind **Live-Parameter**: im Admin-Panel →
> Einstellungen änderbar und **ohne Neustart** sofort wirksam (pro Anfrage gesendet).
> Das **Kontextfenster** ist dagegen ein Startup-Wert (Ollama: `OLLAMA_CONTEXT_LENGTH`,
> llama.cpp: `-c`) und erfordert einen Backend-Neustart.
Messung (Qwen3-35B, `va_llm`): Reasoning an → **5,5 s / 1433 Zeichen**;
Reasoning aus + Sprach-Prompt → **0,7 s / ~190 Zeichen**.
System-Prompt leeren (für „freie" Gespräche ohne inhaltliche Einschränkung):
```bash
LOCAL_LLM_SYSTEM_PROMPT=
```
---
### 6.5 TTS-Einstellungen (Sprachsynthese)
Die Vorlese-Quelle wählt der Nutzer im Kopfzeilen-Menü **Qualität** (→ § 5.1.2). Es gibt
vier Optionen: das **Gerät** selbst (§ 6.5.0) oder einen der drei Server-Provider
**piper** (§ 6.5.2), **chatterbox** (§ 6.5.3) bzw. **OpenRouter** (§ 6.5.1).
#### 6.5.0 Geräte-TTS (Web Speech API) — „📱 Gerät"
Wählt der Nutzer **„📱 Gerät"**, liest das Endgerät die Antwort **selbst** vor (iPhone:
Safari/Siri-Stimmen, Android: System-TTS). Der Server erzeugt und überträgt dann **kein
Audio** — er schickt nur den Text. Das spart Mobilfunk-Daten und TTS-Rechenzeit/Kosten.
- **Vorteile:** keine Audio-Bytes, geringere Latenz, funktioniert auch ohne laufenden
TTS-Dienst.
- **Grenzen:** Stimme/Qualität hängen vom Gerät ab; deine eigenen Stimmen (Klon,
FLEURS-Referenzen) und der Aussprache-Normalizer/das Wörterbuch (§ 6.5.4) greifen **nicht**.
- **Verhalten:** Auf Mobilgeräten ist „Gerät" voreingestellt (solange nichts gewählt wurde).
Die Wahl ist **geräte-lokal** gespeichert (kein Server-Pref), da On-Device-Stimmen pro
Gerät verschieden sind. Browser ohne Web Speech API blenden die Option aus.
- **Technik:** Das Frontend sendet `text_only:true`; die Antwortsprache je Bubble steuert
`SpeechSynthesisUtterance.lang`. iOS erlaubt Sprachausgabe erst nach einer Nutzergeste —
das Frontend schaltet sie beim ersten Tippen/Senden frei.
#### 6.5.1 Cloud-TTS (OpenRouter) — Stimmen wählen
Das Gateway reicht den Stimmennamen unverändert an OpenRouter weiter. Welche
Namen gültig sind, bestimmt das gewählte TTS-Modell:
**Gemini-TTS** (`google/gemini-3.1-flash-tts-preview`, empfohlen):
Verfügbare Stimmen (live verifiziert 2026-06-17): `Zephyr`, `Puck`, `Charon`, `Kore`,
`Fenrir`, `Leda`, `Orus`, `Aoede`, `Callirrhoe`, `Enceladus`, `Iapetus`, `Umbriel`,
`Algieba`, `Despina`, `Erinome`, `Algenib`, `Achernar`, `Schedar`, `Gacrux`, `Sulafat`.
**OpenAI-TTS** (`openai/gpt-4o-mini-tts`, TOML-Default):
Stimmen: `alloy`, `ash`, `ballad`, `coral`, `echo`, `fable`, `nova`, `onyx`, `sage`,
`shimmer`, `verse`.
> Preview-Modelle liefern gelegentlich leer (HTTP 200, kein Audio) — das Gateway
> wiederholt den Aufruf automatisch bis zu 3 Mal. Eine einzelne
> „empty audio content"-Meldung war meist ein Aussetzer; einfach erneut versuchen.
Stimme dauerhaft setzen (in `.env`, dann Server neu starten):
```bash
OPENROUTER_TTS_MODEL=google/gemini-3.1-flash-tts-preview
OPENROUTER_TTS_VOICE=Zephyr
```
Stimme pro Aufruf:
```bash
curl -s -X POST $URL/api/speak \
-H 'Content-Type: application/json' \
-d '{"text":"Probe","voice":"Kore","tts_provider":"openrouter"}' --output probe.pcm
```
Im Sprech-Loop:
```bash
python scripts/voice_loop.py --tts-provider openrouter --voice Puck
```
#### 6.5.2 Lokales TTS (piper) — Stimmen und Modelle
piper läuft in-process — das Stimmmodell wird **einmal** beim Server-Start geladen
und gecacht. Kein Subprozess pro Satz, kein Kaltstart beim ersten Turn.
Stimmmodell-Dateien (`<name>.onnx` + `<name>.onnx.json`) liegen im `PIPER_VOICES_DIR`
(Default: `~/.local/share/piper/voices`). Neue Stimmen von HuggingFace:
`rhasspy/piper-voices` → die zwei Dateien in das Verzeichnis kopieren, dann
`PIPER_VOICE` setzen und Server neu starten.
Aktuell installierte Stimmen (Stand 2026-06-24):
Die Geschlechtspräferenz (♂/♀-Buttons in der Sidebar) wählt automatisch aus den
verfügbaren Varianten. Sprachen mit nur einer Stimme ignorieren die Einstellung.
| `PIPER_VOICE` | Sprache | Qualität | Geschlecht | Hinweis |
|---|---|---|---|---|
| `de_DE-thorsten-high` | Deutsch | **high** | ♂ | **Default** |
| `de_DE-kerstin-low` | Deutsch | low | ♀ | |
| `en_US-lessac-high` | Englisch (US) | **high** | ♂ | |
| `en_US-amy-medium` | Englisch (US) | medium* | ♀ | |
| `en_US-ryan-high` | Englisch (US) | **high** | ♂ | alternativ |
| `en_GB-cori-high` | Englisch (GB) | **high** | ♀ | britischer Akzent |
| `fr_FR-siwis-medium` | Französisch | medium* | ♀ | korrekte Nasalvokale |
| `fr_FR-tom-medium` | Französisch | medium* | ♂ | |
| `es_ES-sharvard-medium` | Spanisch | medium* | ♂+♀ | Multi-Speaker-Modell: `#0`=♂, `#1`=♀ |
| `it_IT-paola-medium` | Italienisch | medium* | ♀ | |
| `it_IT-riccardo-x_low` | Italienisch | x_low | ♂ | |
| `pt_BR-faber-medium` | Portugiesisch (BR) | medium* | ♂ | |
| `pl_PL-gosia-medium` | Polnisch | medium* | ♀ | |
| `pl_PL-darkman-medium` | Polnisch | medium* | ♂ | |
| `ar_JO-kareem-medium` | Arabisch (JO) | medium* | ♂ | jordanischer Dialekt |
| `ru_RU-irina-medium` | Russisch | medium* | ♀ | |
| `ru_RU-ruslan-medium` | Russisch | medium* | ♂ | |
| `zh_CN-huayan-medium` | Chinesisch (Mandarin) | medium* | ♀ | |
\* Für diese Sprachen existiert keine `high`-Variante in piper — `medium` ist das Maximum.
Stimme wechseln (in `.env`):
```bash
PIPER_VOICE=de_DE-kerstin-low
```
Liefert ein Modell nicht 24000 Hz (z. B. `de_DE-thorsten-high` = 22050 Hz), resampelt
das Gateway automatisch per `ffmpeg`.
Im Sprech-Loop:
```bash
python scripts/voice_loop.py --tts-provider piper --voice de_DE-kerstin-low
```
#### 6.5.3 Chatterbox TTS (Voice-Cloning, hohe Qualität)
Chatterbox ist ein eigener HTTP-Dienst auf der GPU (Resemble AI, Port 9999). Er ist
deutlich langsamer als piper (~Echtzeit), aber deutlich natürlicher. Unterstützt
**Voice-Cloning** über eine Referenz-WAV. Setup: [deploy/README.md § 6](deploy/README.md).
Aktivieren pro Request/Session:
```bash
curl -s -X POST $URL/api/chat \
-H 'Content-Type: application/json' \
-d '{"text":"Hallo!","tts_provider":"chatterbox"}' --output antwort.pcm
```
Konfiguration in `.env`:
```bash
CHATTERBOX_BASE_URL=http://127.0.0.1:9999
CHATTERBOX_VOICE=/pfad/zu/referenz_stimme.wav # leer = Standardstimme
CHATTERBOX_LANG=de # Fallback-Sprache (Gesprächssprache gewinnt)
CHATTERBOX_SPEED=1.0
CHATTERBOX_VOICES_DIR=config/voices # native Referenz-Stimmen je Sprache
```
**Mehrsprachig + native Stimme je Sprache.** Chatterbox ist mehrsprachig (de, en, fr,
es, it, nl, ru, zh u. a.) und klont **cross-lingual**: Die Antwortsprache (→ § 6.6)
wird automatisch an den Dienst übergeben, und die passende Referenz-Stimme wird aus
`CHATTERBOX_VOICES_DIR` nach Konvention `<lang>.wav` gewählt (z. B. `fr.wav`, `zh.wav`).
So spricht jede Sprache mit einer muttersprachlichen Stimme statt deutsch-akzentuiert.
- Reihenfolge der Stimm-Auswahl: explizit angefragte `voice` (WAV-Pfad) → `config/voices/<lang>.wav``CHATTERBOX_VOICE` (persönlicher Klon) → Standardstimme des Dienstes.
- Deutsch nutzt bewusst keine Datei in `config/voices/`, sondern `CHATTERBOX_VOICE`.
- Die mitgelieferten Referenz-Clips stammen aus dem FLEURS-Datensatz (CC-BY 4.0) —
Quelle/Lizenz/Austausch siehe `config/voices/README.md`.
#### 6.5.4 Aussprache verbessern (TTS-Normalizer)
Vor dem TTS läuft ein Normalizer, der Ausspracheprobleme des Phonemizers behebt:
- **Ordinalzahlen:** „1. Mai" → „erster Mai", „1. 2. 3." → „erstens, zweitens, drittens"
- **Einheiten nach Zahl:** „10 kg" → „zehn Kilogramm", „km/h" → „Kilometer pro Stunde"
- **Abkürzungen:** „Dr." → „Doktor", „z. B." → „zum Beispiel"
- **YAML-Lexikon:** eigene Begriffe — für jede Sprache eine eigene Datei
Stärke: `TTS_NORMALIZE_LEVEL=auto|full|light|off`
`auto` = piper bekommt `full`, Cloud-TTS bekommt `light` (Cloud kann Zahlen selbst).
##### Eigene Aussprache hinzufügen (Deutsch / Englisch)
**Web-UI (empfohlen):** Admin-Panel → Tab „🔤 Wörterbuch" (→ § 7.5). Kein Neustart nötig.
**Kommandozeile:**
```bash
python scripts/add_pronunciation.py "strömt:ströhmt" # Wort:Aussprache
python scripts/add_pronunciation.py Mond Mohnd --verify # mit Phonem-Check
python scripts/add_pronunciation.py kWh "Kilowattstunden" --section units
```
Danach Server neu starten (damit der Cache geleert wird).
##### Aussprache für alle Sprachen — YAML-Lexika
Für jede aktive Sprache gibt es eine separate YAML-Datei im Verzeichnis `config/`:
```
config/
pronunciation.de.yaml # Deutsch
pronunciation.en.yaml # Englisch
pronunciation.fr.yaml # Französisch
pronunciation.es.yaml # Spanisch
pronunciation.it.yaml # Italienisch
pronunciation.pt.yaml # Portugiesisch
pronunciation.pl.yaml # Polnisch
pronunciation.ar.yaml # Arabisch
pronunciation.ru.yaml # Russisch
pronunciation.zh.yaml # Chinesisch
```
Fehlende Dateien werden stillschweigend übersprungen (keine Pflicht für jede Sprache).
Jede Datei hat drei Sektionen:
```yaml
# config/pronunciation.de.yaml (Beispiel)
abbreviations: # Abkürzungen — ganze Token, wortgrenzen-sicher
"ggf.": "gegebenenfalls"
"inkl.": "inklusive"
units: # Einheiten — nur DIREKT nach einer Zahl ersetzt
"kWh": "Kilowattstunden"
terms: # Eigennamen / Begriffe — Groß-/Kleinschreibung egal
"Linux": "Linuks"
"Mond": "Mohnd"
```
| Sektion | Trifft | Beispiel |
|---------|--------|---------|
| `abbreviations` | ganze Wörter / Token mit Wortgrenze | `"z.B."``"zum Beispiel"` |
| `units` | nur nach einer Zahl (`\d\s*Einheit`) | `"kg"``"Kilogramm"` (nur nach Zahl!) |
| `terms` | beliebiger Teiltext, Groß/Klein egal | `"Linux"``"Linuks"` |
**Längerer Eintrag gewinnt**`"z. B."` wird vor `"B."` geprüft. Reihenfolge im YAML spielt keine Rolle.
##### Eigennamen in Fremdsprachen korrekt aussprechen
Das Lexikon arbeitet mit **Textersetzung** — kein IPA nötig. Der eingetragene Text
wird von espeak-ng (in Piper) nach den Phonemregeln der **Zielsprache** gelesen.
Das Ziel ist also: den Namen so schreiben, wie ihn ein Muttersprachler der Zielsprache
schreiben würde, damit er richtig klingt.
**Grundprinzip:**
```
Original: "Schlüter"
DE: kein Eintrag nötig (nativ)
FR: "Chluteur" → ch=/ʃ/ u=/y/ (= ü!) eur=/œʁ/ → /ʃlytœʁ/ ≈ /ʃlyːtɐ/
EN: "Schlueter" → espeak-en liest "ue" als /uː/ → /ˈʃluːtər/ ✓
PT: "Schluter" → pt "u"=/u/ "e"=/ɨ/ → /ʃlu.tɨɾ/ (EP) oder /ʃlu.tɛɾ/ (BP)
RU: "Шлютер" → Kyrillisch für exakte Phoneme (Latein wird schlecht gelesen)
ZH: "施吕特" → 施=Shī=/ʃɨ/ 吕=lǚ=/ly/ (≈ lü!) 特=tè=/tɛ/
```
**Praktische Anleitung für einen neuen Eigennamen:**
1. Überlege, welche Laute der Name enthält.
2. Finde in der Zielsprache Buchstaben/Buchstabenkombinationen, die diese Laute erzeugen.
3. Trage den Ersatztext in `terms:` der passenden Sprachdatei ein.
4. Teste (→ unten).
**Häufige Klangäquivalente je Sprache:**
| Laut | DE | EN | FR | ES/PT | RU | ZH |
|------|----|----|----|----|----|----|
| /ʃ/ | sch | sh | ch | sh | Ш | sh → 施/书 |
| /y/ (= ü) | ü | — | u | — | Ю | ü → 吕/绿 |
| /x/ (= ch) | ch | kh | — | j | Х | h → 哈 |
| /ts/ | z | ts | ts | ts | Ц | ts → 茨 |
> **Arabisch (AR):** espeak-ng liest arabischen Text direkt in korrekter Aussprache —
> Einträge in `pronunciation.ar.yaml` daher immer in **arabischer Schrift** (كذلك).
> Lateinische Umschriften werden schlecht gelesen.
**Sonderfall Russisch und Chinesisch:** espeak-ng liest lateinische Buchstaben
in russischem / chinesischem Modus schlecht. Immer Kyrillisch (RU) bzw. Hanzi (ZH) verwenden:
```yaml
# config/pronunciation.ru.yaml
terms:
"Schlüter": "Шлютер" # Ш=/ʃ/ лю=/lʲu/ тер=/tʲɛr/
"Dieter": "Дитер"
# config/pronunciation.zh.yaml
terms:
"Schlüter": "施吕特" # 施=Shī=/ʃɨ/ 吕=lǚ=/ly/ 特=tè=/tɛ/
"Dieter": "迪特"
```
##### Einträge hinzufügen — alle Wege im Überblick
**Weg 1 — Admin-Web-UI** (de/en, sofort wirksam):
Admin-Panel → Tab „🔤 Wörterbuch" → Sprache und Sektion wählen → Eintrag hinzufügen.
Der Cache wird automatisch geleert.
**Weg 2 — REST-API** (alle Sprachen, sofort wirksam):
```bash
# Französischen Eintrag hinzufügen (kein Neustart nötig):
curl -s -X POST $URL/api/admin/pronunciation/fr \
-H "X-Admin-Key: $ADMIN_API_KEY" \
-H "Content-Type: application/json" \
-d '{"section":"terms","key":"Schlüter","value":"Chluteur"}' | jq
# Eintrag löschen:
curl -s -X DELETE $URL/api/admin/pronunciation/fr/terms/Schlüter \
-H "X-Admin-Key: $ADMIN_API_KEY" | jq
# Alle Einträge einer Sprache anzeigen:
curl -s $URL/api/admin/pronunciation/ru \
-H "X-Admin-Key: $ADMIN_API_KEY" | jq
```
**Weg 3 — YAML-Datei direkt editieren** (alle Sprachen):
```bash
nano config/pronunciation.fr.yaml # oder vim, gedit …
```
Danach **Server neu starten**, damit der In-Memory-Cache geleert wird:
```bash
make restart # oder: systemctl --user restart voice-assistant
```
##### Aussprache testen
Nach dem Hinzufügen eines Eintrags kannst du den Effekt sofort prüfen:
**Admin-Panel → Tab „⚙ Einstellungen" → Feld „Piper-Stimme" → Test-Button:**
Spricht den Testsatz mit der aktuell eingestellten Stimme und Sprache.
**Oder via curl:**
```bash
curl -s -X POST $URL/api/speak \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"text":"Hallo, ich bin Dieter Schlüter.","tts_provider":"piper","language":"fr"}' \
--output /tmp/test.wav && aplay /tmp/test.wav
```
**Oder mit dem Normalizer-Skript allein** (kein Server nötig):
```bash
source .venv/bin/activate
python3 -c "
import asyncio
from app.pipeline.tts_normalizer import TTSNormalizer
t = TTSNormalizer()
result = asyncio.run(t.run('Schlüter kommt.', language='fr', level='full'))
print(result) # → 'Chluteur kommt.'
"
```
---
### 6.6 Sprache (fest, pro Nutzer)
Die Sprache ist **immer fest** — den früheren „Flex"-Modus gibt es nicht mehr. Im Dropdown
„Sprache" wählt man eine konkrete Sprache; **Antwort und vorlesende Stimme sind immer in
dieser Sprache.**
**Fremdsprache diktieren → Übersetzung:** Spricht man in einer **anderen** als der
eingestellten Sprache, erkennt das System die gesprochene Sprache automatisch und
**übersetzt die Anfrage in die Zielsprache**. Sie wird dann auch in der Zielsprache
angezeigt und beantwortet — hilfreich beim Sprachenlernen und über Sprachgrenzen hinweg.
Beispiel (Zielsprache 🇩🇪 DE, auf Französisch gesprochen): Die Blase zeigt die **deutsche**
Übersetzung; Antwort + Stimme sind Deutsch.
Die vorlesende Piper-Stimme folgt der Sprache automatisch; die ♂/♀-Buttons bestimmen die
Variante (→ `LANG_TO_PIPER_VOICE`, `PIPER_VOICE_GENDERED`).
**Erlaubte Sprachen pro Nutzer (Admin):** Im Admin → **Nutzer** legt der Admin per
Sprach-Chips fest, welche Sprache(n) ein Nutzer wählen darf. Die App zeigt dann nur diese;
bei **genau einer** erlaubten Sprache verschwindet das Sprachmenü ganz (kein Stress).
**Konfiguration außerhalb der Web-UI:**
```bash
DEFAULT_LANGUAGE=de # globale Standardsprache (Admin: „⚙ Konfiguration")
```
Pro Nutzer (dauerhaft):
```bash
# Standardsprache des Nutzers:
curl -s -X PUT $URL/api/me/prefs -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' -d '{"language":"en"}' | jq
# Erlaubte Sprachen setzt der Admin (CSV, leer = alle):
curl -s -X PUT $URL/api/admin/users/$USER_ID/prefs -H "X-Admin-Key: $ADMIN_KEY" \
-H 'Content-Type: application/json' -d '{"allowed_languages":"de,en"}' | jq
```
---
### 6.7 Audio-Geräte (Mikrofon, Lautsprecher, Bluetooth)
> Hinweis: Die Geräte-Endpunkte im Gateway (`input_endpoint`/`output_endpoint`) sind
> vorbereitet (Routing-Ebene, `GET /api/devices`), aber die Hardware-Treiber sind noch
> Platzhalter — kein echtes I/O durch das Gateway. Geräteauswahl erfolgt heute auf
> **Betriebssystem-Ebene**.
**Empfohlen: Ubuntu-Systemeinstellungen (grafisch)**
1. *Einstellungen → Ton* öffnen.
2. **Ausgabe:** gewünschtes Gerät wählen (z. B. Bluetooth-Box).
3. **Eingabe:** Mikrofon wählen (z. B. reSpeaker). Pegelbalken zeigt Schall.
Bluetooth-Box koppeln (einmalig): *Einstellungen → Bluetooth → Box in Kopplungsmodus →
Verbinden*. Danach erscheint sie unter Ton → Ausgabe.
**Per Kommandozeile:**
```bash
arecord -l # Karten-/Gerätennummern (hw:X,Y)
pactl list short sources # Mikrofone
pactl list short sinks # Ausgaben (inkl. Bluetooth)
# Standard-Gerät setzen:
pactl set-default-source <SOURCE_NAME>
pactl set-default-sink <SINK_NAME>
```
Der Sprech-Loop folgt mit `--recorder auto` automatisch dem System-Standard.
Bestimmtes Mikrofon erzwingen:
```bash
python scripts/voice_loop.py --recorder arecord --device plughw:6,0
```
(`arecord -l` zeigt Kartennummer; auf diesem Rechner: M2-Mic = Karte 5, reSpeaker = Karte 6)
---
### 6.8 Streaming-Verhalten
| Variable | Default | Wirkung |
|----------|---------|---------|
| `AUDIO_STREAM_DEFAULT` | `true` | Satzweises Audio-Streaming als Standard |
Mit `audio_stream=true` (Standard bei WebSocket) beginnt die Ausgabe nach dem ersten
Satz — spürbar kürzere wahrgenommene Latenz. Abschalten:
```bash
# global:
AUDIO_STREAM_DEFAULT=false
# pro Aufruf im Sprech-Loop:
python scripts/voice_loop.py --no-stream-audio
```
**Barge-in** (laufende Antwort unterbrechen):
- **Web-Interface:** Mic-Button ⏹ (amber) während der KI-Antwort tippen → Wiedergabe stoppt sofort, Server bricht Generierung ab.
- **Terminal:** `[Enter]` drücken, sobald die KI antwortet — egal ob Text noch streamt oder Audio bereits läuft → dasselbe Ergebnis.
- **API/eigene Clients:** WebSocket-Event `{"type":"interrupt"}` senden → Server stoppt Streaming und meldet `{"type":"interrupted"}`.
**VAD** (automatische Sprechpausen-Erkennung): Im Start-Frame von `/ws/voice`
`{"type":"start","vad":true,"format":"pcm","sample_rate":16000}` → kein manuelles Ende nötig.
Optional: `vad_silence_ms`, `vad_threshold`.
---
## 7. Nutzerverwaltung und Authentifizierung
> 🔧 Admin
### 7.1 Auth aktivieren/deaktivieren
```bash
AUTH_ENABLED=true # Standard: geschützte Endpunkte brauchen Bearer-Token
AUTH_ENABLED=false # Lokal/Entwicklung: anonymer Standardnutzer, kein Token nötig
```
Geschützte Endpunkte: `chat`, `speak`, `transcribe`, `sessions`, `me`.
### 7.2 SSO-Nutzer (va.linix.de) — automatische Registrierung
Nutzer, die über den YunoHost-SSO kommen (`https://va.linix.de/`), werden **beim ersten
Besuch automatisch registriert** — kein manuelles Anlegen nötig.
Der genaue Ablauf:
1. YunoHost-SSO authentifiziert den Nutzer (nur eingeloggte YunoHost-Nutzer durch)
2. Der Benutzername aus dem JWT-Cookie (`yunohost.portal`) wird ans Gateway weitergegeben
3. Gateway ruft intern `get_or_create_user_by_external_id(username)` auf:
- Erster Besuch → neuer Datenbankdatensatz (UUID-ID, `external_id = YunoHost-Username`)
- Folgender Besuch → selber Datensatz
4. Jeder Nutzer hat ab sofort **eigene** Sessions, Erinnerungen und Präferenzen
**Kein Bearer-Token** — SSO-Nutzer authentifizieren sich ausschließlich über den YunoHost-Cookie.
**Persönlichkeit und Kontextwissen der KI:**
Das Sprachmodell kennt den Nutzer über zwei Kanäle:
- `display_name` wird bei jeder Anfrage als `"Du sprichst mit <Name>."` ins System-Prompt injiziert
- Erinnerungen (automatisch extrahiert + manuell angelegt) folgen darunter
#### Anzeigenamen setzen (nach erstem SSO-Login)
Nach dem ersten Besuch steht im Datensatz als `display_name` der YunoHost-Username
(z. B. `"dschlueter"`). Die KI würde den Nutzer mit diesem Systemnamen ansprechen.
Ein Admin setzt einen echten Namen:
```bash
# user_id aus der Nutzerliste holen:
curl -s $URL/api/admin/users -H "X-Admin-Key: $ADMIN_API_KEY" | jq '.[].user_id'
USER_ID=a3f8c1d2e4b7... # user_id des betroffenen Nutzers
curl -s -X PUT $URL/api/admin/users/$USER_ID \
-H "X-Admin-Key: $ADMIN_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"display_name":"Oma Anna"}' | jq
```
Antwort:
```json
{ "user_id": "a3f8c1d2e4b7...", "display_name": "Oma Anna" }
```
Ab dem nächsten Gespräch sagt die KI „Guten Tag, Anna" statt „Guten Tag, dschlueter".
#### Initiale Erinnerungen vorbelegen
Ohne vorher gespeicherte Erinnerungen beginnt die KI jedes Gespräch mit Neuem.
Ein Admin kann Kontext vorab anlegen, damit die KI von Anfang an personalisiert reagiert:
```bash
curl -s -X POST $URL/api/admin/users/$USER_ID/memories \
-H "X-Admin-Key: $ADMIN_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"content":"Anna ist 78 Jahre alt, wohnt allein in Hamburg und mag klassische Musik."}' | jq
```
Antwort:
```json
{ "id": 1, "content": "Anna ist 78 Jahre alt...", "created_at": "2026-06-18T10:00:00+00:00" }
```
Mehrere Erinnerungen sind möglich — einfach den Aufruf wiederholen. Beim nächsten Gespräch
bekommt das LLM als System-Nachricht:
```
Du sprichst mit Oma Anna.
Was du über den Nutzer weisst:
- Anna ist 78 Jahre alt, wohnt allein in Hamburg und mag klassische Musik.
```
#### Empfohlener Workflow für neue SSO-Nutzer
```
1. Nutzer loggt sich einmal bei https://va.linix.de/ ein
→ Datensatz wird automatisch angelegt
2. Admin: GET /api/admin/users → user_id notieren
3. Admin: PUT /api/admin/users/{id}
→ {"display_name": "Oma Anna"}
4. Optional: POST /api/admin/users/{id}/memories
→ 1-3 Sätze über die Person
5. Ab dem nächsten Gespräch ist die KI sofort personalisiert.
```
> **Hinweis:** Nutzer, die per `POST /api/admin/users` mit Bearer-Token angelegt werden,
> und SSO-Nutzer sind **getrennte Identitäten**. Es gibt keine Verknüpfung. Für
> va.linix.de-Nutzer daher **nicht** manuell vorab anlegen — das würde zu zwei getrennten
> Datensätzen führen.
---
### 7.3 Nutzer anlegen, anzeigen und löschen (Bearer-Token-Nutzer)
> Für Nutzer **ohne** SSO-Zugang (z. B. lokale Nutzung, API-Clients, curl/CLI).
**Voraussetzung:** `ADMIN_API_KEY` muss beim Gateway-Start als Umgebungsvariable gesetzt sein.
Einmal setzen (gilt für alle folgenden Befehle im Terminal):
```bash
export ADMIN_API_KEY=mein-langes-geheimnis
```
#### Nutzer anlegen
```bash
curl -s -X POST $URL/api/admin/users \
-H "X-Admin-Key: $ADMIN_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"display_name":"Oma Anna"}' | jq
```
Beispiel-Antwort:
```json
{
"user_id": "a3f8c1d2e4b7...",
"display_name": "Oma Anna",
"token": "va-tok-AbCdEfGh12345..."
}
```
> ⚠️ Das Token erscheint **nur einmal** — sofort sicher aufbewahren (z. B. in einem
> Passwort-Manager oder `~/.bashrc`). Es wird nur als SHA256-Hash in der Datenbank
> gespeichert — der Klartext ist danach **nicht mehr abrufbar**.
>
> Token verloren? → Neues Token ausstellen (s. u.) oder Nutzer löschen und neu anlegen.
Das Token dem Nutzer mitteilen. Er gibt es bei jedem Aufruf im `Authorization`-Header an:
```bash
TOKEN=va-tok-AbCdEfGh12345... # einmal setzen, z.B. in ~/.bashrc:
# export TOKEN=va-tok-AbCdEfGh12345...
curl -s $URL/api/me -H "Authorization: Bearer $TOKEN" | jq
# → {"user_id":"a3f8c1d2e4b7…","display_name":"Oma Anna","prefs":{}}
```
#### Token neu ausstellen (bei Verlust oder Rotation)
Falls das Token verloren gegangen ist oder aus Sicherheitsgründen gewechselt werden soll:
```bash
curl -s -X POST $URL/api/admin/users/$USER_ID/token \
-H "X-Admin-Key: $ADMIN_API_KEY" | jq
```
Beispiel-Antwort:
```json
{
"user_id": "a3f8c1d2e4b7...",
"display_name": "Oma Anna",
"token": "va-tok-NeuErKlArTeXt..."
}
```
Der **alte Token wird sofort ungültig**. Der neue Token erscheint ebenfalls nur einmal —
alle bisherigen Daten (Erinnerungen, Gesprächsverlauf) bleiben erhalten.
> **Woher kommt `$ADMIN_API_KEY`?** Dieser Key ist in der Datei `.env` auf dem Server
> hinterlegt. Nachschauen mit:
> ```bash
> grep ADMIN_API_KEY .env
> # ADMIN_API_KEY=mein-geheimes-admin-passwort
> ```
> Du hast ihn beim Setup selbst gewählt. Er ist **kein** auto-generierter Hash —
> du kannst ihn jederzeit in `.env` lesen und bei Bedarf ändern (Gateway neu starten).
#### Alle Nutzer anzeigen
```bash
curl -s $URL/api/admin/users -H "X-Admin-Key: $ADMIN_API_KEY" | jq
```
Beispiel-Antwort:
```json
[
{
"user_id": "a3f8c1d2e4b7...",
"display_name": "Oma Anna",
"external_id": null,
"created_at": "2026-06-18T10:00:00+00:00"
},
{
"user_id": "b9e2f5a1c6d3...",
"display_name": "Herr Müller",
"external_id": null,
"created_at": "2026-06-18T11:30:00+00:00"
}
]
```
#### Nutzer löschen
Löscht den Nutzer **und alle seine Daten** (Sessions, Gesprächsverlauf, Erinnerungen,
Nutzungsstatistik) unwiderruflich.
```bash
USER_ID=a3f8c1d2e4b7... # user_id aus der Liste oben
curl -s -X DELETE $URL/api/admin/users/$USER_ID \
-H "X-Admin-Key: $ADMIN_API_KEY" | jq
```
Beispiel-Antwort bei Erfolg:
```json
{ "deleted": "a3f8c1d2e4b7..." }
```
Nutzer nicht gefunden → HTTP 404:
```json
{ "detail": "Nutzer 'xyz' nicht gefunden." }
```
#### Dauerhafte Nutzerpräferenzen setzen
```bash
curl -s -X PUT $URL/api/me/prefs \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"tts_provider":"piper","language":"de","daily_request_limit":100}' | jq
```
Fremde Sessions → HTTP 403.
### 7.4 SSO / YunoHost-Integration (Remote-Betrieb)
Der Gateway akzeptiert Identitäten von einem Reverse-Proxy per Cookie oder Header —
ausschließlich von vertrauenswürdigen Proxy-IPs (`TRUSTED_PROXY_IPS`):
```bash
# YunoHost-Cookie (empfohlen):
TRUSTED_AUTH_COOKIE=yunohost.portal
TRUSTED_AUTH_COOKIE_CLAIM=user
TRUSTED_PROXY_IPS=192.168.179.10
ADMIN_USERS=atoor,dieterschlueter,dschlueter
SSO_LOGOUT_URL=https://linix.de/yunohost/sso/?action=logout
SSO_LOGIN_URL=https://linix.de/yunohost/sso/ # unauth. Seitenaufrufe -> hierhin umleiten
# Optional: Signaturprüfung des JWT-Cookies
TRUSTED_AUTH_JWT_SECRET=<hs256-secret aus /etc/yunohost/.ssowat_cookie_secret>
# Alternativ: Header-basiert (andere SSO-Systeme):
TRUSTED_AUTH_HEADER=X-Remote-User
```
Vollständige Anleitung: [deploy/README.md](deploy/README.md).
**Zugriffsschutz der Web-UI (mehrstufig):**
1. **YunoHost SSOwat (primär):** Die App-Berechtigung darf die Gruppe „visitors" nicht
enthalten → unauthentifizierte Besucher werden zum Portal umgeleitet, bevor sie das
Gateway erreichen: `yunohost user permission update <APP>.main --remove visitors --add all_users`.
2. **Gateway, API/WS:** Anfragen über den Proxy ohne gültiges `yunohost.portal`-Cookie
bekommen 401 (auch bei `AUTH_ENABLED=false` — das betrifft nur den LAN-Direktzugriff).
3. **Gateway, statische Seite:** Unauthentifizierte Seitenaufrufe werden auf `SSO_LOGIN_URL`
umgeleitet (bzw. 401, falls nicht gesetzt) — Defense-in-Depth, falls SSOwat umgangen wird.
4. **Port:** Das Gateway sollte nicht offen im Netz lauschen (`HOST=127.0.0.1` bzw. Firewall
auf die Proxy-IP), damit der SSO-Weg nicht per Direktzugriff umgangen werden kann.
### 7.5 Admin-Web-Panel
> 🔧 Admin — erreichbar über den **⚙️-Button** im Web-Interface (nur für Admin-Nutzer sichtbar)
Das Admin-Panel öffnet sich als Vollbild-Overlay über dem Chat und gliedert sich in
**fünf Bereiche**. Bereiche mit mehreren Ansichten zeigen darunter eine Sub-Navigation:
| Bereich | Inhalt |
|---------|--------|
| **📊 Übersicht** | Start-Dashboard: Kennzahlen (Nutzer, Anfragen gesamt, Notfälle) + LLM-Backend/GPU; Direktsprünge |
| **👥 Nutzer** | Sub-Tabs *Verwalten* (anlegen/umbenennen/Token/löschen, Erinnerungen) und *Gespräche* (Transkripte) |
| **🚨 Notfälle** | protokollierte Notfall-Ereignisse |
| **🖥 System** | Sub-Tabs *Status* (inkl. LLM-Steuerung), *Metriken*, *Log* |
| **⚙ Konfiguration** | Sub-Tabs *Einstellungen* (Laufzeit-Config) und *Wörterbuch* (Aussprache) |
#### Übersicht
Beim Öffnen sichtbar: Kacheln mit Nutzerzahl, Anfragen gesamt, Notfall-Anzahl und
aktivem LLM-Backend/Modell, dazu die GPU-Auslastung und Schnell-Sprünge in die Bereiche.
#### Nutzer Verwalten
Nutzer anlegen (Name eingeben → „Anlegen" → Token erscheint **einmalig** — sofort kopieren!),
umbenennen, Token zurücksetzen und löschen. Erinnerungen je Nutzer auf- und zuklappen,
neue Erinnerungen hinzufügen oder vorhandene löschen.
Im **Notfall-Profil** jedes Nutzers (aufklappbar) stehen Kontaktdaten, med. Hinweise,
„Standort im Notfall mitsenden" und **„Pause bis zum erneuten Alarm"** (Minuten): nach
einem ausgelösten Notruf ist ein erneuter Alarm so lange blockiert (Schutz vor
Mehrfach-Alarmen). **Leer** = der globale Standard greift (Tab *Konfiguration
Einstellungen* → „Notruf-Sperre (Minuten)", Default 5), **0** = aus. Präzedenz:
Nutzer-Wert > globale Einstellung > 5.
#### Nutzer Gespräche
Nutzerliste links → Session auswählen → Gesprächs-Transkript als Chat-Bubbles ansehen.
#### Notfälle
Tabellarische Übersicht aller protokollierten Notfall-Ereignisse (Zeitpunkt, Nutzer,
Kategorie, Textausschnitt).
#### System Status
Zeigt aktives Profil, Provider-Konfiguration, Laufzeit-Metriken und verfügbare Provider.
Zusätzlich eine **LLM-Backend-Karte**: aktives Backend (Ollama/llama.cpp), Modell, ob
die Backends laufen, geladene Ollama-Modelle und die **GPU-Auslastung** je Karte als
Balken. Darunter eine **Steuerung**: Backend wählen (+ Ollama-Modell), **„Backend
wechseln"** und **„Gateway neu starten"**.
> Sicherheit: Backend ist auf `ollama|llamacpp` beschränkt, Modellnamen werden gegen
> `ollama list` und ein striktes Format geprüft (kein Shell-Zugriff, kein sudo). Der
> Wechsel läuft losgelöst; die Seite pollt, bis das Gateway wieder antwortet.
> **Wirkt vollständig nur, wenn das Gateway als systemd-Dienst läuft** (→ § 4.10) —
> im Vordergrund-Betrieb werden `.env`/Backend umgestellt, der Gateway muss aber manuell
> neu gestartet werden.
Am Ende: **⬇ voice-assistant.db herunterladen** — lädt die SQLite-Datenbank als Backup.
#### System Metriken
Nutzungsstatistik je Nutzer (Anfragen, Einheiten, letzte Aktivität) als Tabelle
und CSS-Balkendiagramm.
#### Konfiguration Wörterbuch
Aussprache-Lexikon direkt im Browser bearbeiten — kein Kommandozeilen-Skript nötig:
1. Sprache wählen (alle 8 Sprachen: de, en, fr, es, it, nl, ru, zh).
2. Sektion wählen: **Abkürzungen**, **Einheiten**, **Begriffe / Aussprache**.
3. Eintrag bearbeiten: Zeile anklicken → lädt in die Felder unten (Button wird zu „Speichern").
4. Eintrag löschen: Maus drüber → **✕**.
5. Neuer Eintrag: Schlüssel + Ersetzung → **+ Hinzufügen**.
Die Liste wird nach jedem Speichern alphabetisch sortiert; Änderungen greifen sofort
(Server-Cache wird automatisch geleert). Vollständige Anleitung → § 6.5.4.
#### System Log
Zeigt den systemd-Journal-Log des `voice-assistant.service` live im Browser:
1. **▶ Verbinden** → letzte 100 Zeilen + laufende Ausgabe erscheinen im Terminal-Fenster.
2. **■ Trennen** → Stream stoppen.
3. **Leeren** → Anzeige leeren (Log auf dem Server bleibt erhalten).
Der Log hilft, Fehler zu diagnostizieren ohne SSH-Zugang.
**Audit:** Schreibende Admin-Aktionen werden mit Auslöser protokolliert und erscheinen
hier live, z. B.:
```
ADMIN action=config_set user=dschlueter key='local_llm_top_p' value='0.7'
ADMIN action=llm_backend_switch user=dschlueter backend='ollama' model='gemma3:latest'
ADMIN action=gateway_restart user=admin-key
```
Protokolliert werden u. a. `config_set` / `config_reset` (Laufzeit-Einstellungen),
`llm_backend_switch` (+ `_rejected` bei Allowlist-Verstoß) und `gateway_restart`.
`user` ist der SSO-Name bzw. `admin-key` bei Zugriff per `ADMIN_API_KEY`.
> Sichtbar im Log-Tab nur im **Dienst-Betrieb** (Journal). Im Vordergrund-Betrieb
> (`make run`) erscheinen die Audit-Zeilen im Terminal.
### 7.6 Anmeldung & Admin-Zugang (Link, Passwort, Recovery)
> 🔧 Admin
Die App-Oberfläche (`/`) erkennt Dich an einem `va_token`-Cookie. Es gibt drei Wege hinein:
1. **Persönlicher Zugangslink** (`?k=<token>`) — der Standardweg für Senioren (keine
Login-Maske); ein gültiges Cookie loggt automatisch ein.
2. **Benutzername & Passwort (1FA, ohne 2FA)** — für Betreuer/Admins (§ 7.6.1).
3. **Recovery-Wege**, falls ein Admin ausgesperrt ist (§ 7.6.3 / 7.6.4).
Ohne gültiges Cookie zeigt `/` die Seite „Persönlicher Zugang nötig" mit einem Knopf
**„Mit Benutzername & Passwort anmelden"** (→ `/login`). Senioren nutzen weiter ihren Link;
`/` leitet bewusst **nicht** automatisch zur Login-Maske um (sonst säßen Senioren ohne
Authelia-Konto fest).
#### 7.6.1 Anmeldung mit Benutzername & Passwort (1FA, ohne 2FA)
Rufe auf (oder klicke den Knopf auf der Zugangs-Seite):
```
https://voice.jamulix.de/login
```
Ablauf: Der Reverse-Proxy schickt Dich zu **Authelia** (`auth.jamulix.de`) → Du gibst
**nur Benutzername + Passwort** ein (Zugriffsregel `voice.jamulix.de → one_factor`, also
**kein 2FA**) → das Gateway erkennt Dich am `Remote-User`, setzt ein frisches
`va_token`-Cookie und leitet in die App (`/`). Gilt für **Admins und normale
Authelia-Nutzer**; die Konten liegen im Authelia-Datei-Backend
(`/etc/authelia/users_database.yml`).
- Endpunkt dahinter: `GET /api/login` (nginx-Location `/login`).
- **Jeder Login rotiert Dein Token** — ein zuvor gesetztes Cookie / ein alter Link
*dieses Nutzers* wird ungültig (ein Token pro Nutzer).
- Andere `*.jamulix.de`-Dienste bleiben bei **2FA**; nur `voice.jamulix.de` ist 1FA.
Sicherheits-Hinweis: 1FA für den Admin-Zugang ist schwächer als 2FA — bewusste Abwägung.
#### 7.6.2 Admin-Selbstanmeldung (`/admin-login`)
`https://voice.jamulix.de/admin-login` ist die auf **Admins** (`ADMIN_USERS`) beschränkte
Variante von `/login` — gleicher Ablauf, aber Nicht-Admins bekommen 403. Praktisch als
Bookmark „Admin-Login". Endpunkt: `GET /api/admin/login` (nginx-Alias `/admin-login`).
#### 7.6.3 Recovery per `ADMIN_API_KEY` (SSO-unabhängig)
Funktioniert auch, wenn Authelia gerade nicht erreichbar ist — vom Server/localhost aus.
Den Key liest Du als root aus `/etc/voice-assistant/voice-assistant.env`.
```bash
# 1) Deine user_id finden:
curl -s $URL/api/admin/users -H "X-Admin-Key: $ADMIN_API_KEY" \
| jq -r '.[] | select(.external_id=="dschlueter") | .user_id'
USER_ID=<deine user_id>
# 2) Neuen Token ausstellen (wird EINMALIG zurückgegeben):
TOKEN=$(curl -s -X POST $URL/api/admin/users/$USER_ID/token \
-H "X-Admin-Key: $ADMIN_API_KEY" | jq -r '.token')
# 3) Daraus den Zugangslink bauen:
echo "https://voice.jamulix.de/?k=$TOKEN"
```
> Der `ADMIN_API_KEY` ist sehr mächtig (voller Admin-Zugriff). Nicht in Proxy-Logs
> geraten lassen (kein `?key=` in URLs verwenden, außer beim einmaligen Bootstrap über
> `GET /api/admin/request-headers?key=…`), und nach solchem Gebrauch rotieren.
#### 7.6.4 Härtung gegen Total-Aussperrung
Eine echte Vollsperre bräuchte: Link weg **und** Authelia-Konto weg **und**
`ADMIN_API_KEY` weg **und** kein SSH. Um auch das abzusichern:
- **Zweiter Admin:** einen weiteren Authelia-Namen in `ADMIN_USERS` aufnehmen (CSV),
damit ein einzelnes verlorenes Konto nicht alles blockiert:
```bash
# in /etc/voice-assistant/voice-assistant.env, dann Gateway neu starten:
ADMIN_USERS=dschlueter,<zweiter-admin>
```
- **`ADMIN_API_KEY` sichern:** im Passwortmanager hinterlegen (der Wert steht in
`/etc/voice-assistant/voice-assistant.env`).
---
## 8. Gedächtnis und Erinnerungen
> 👤 Endnutzer / 🔧 Admin
**Woher kommt `$TOKEN`?** Das Token erscheint einmalig beim Anlegen eines Nutzers
(→ § 7.3). Im Terminal einmal setzen:
```bash
TOKEN=va-tok-AbCdEfGh12345...
```
Bei `AUTH_ENABLED=false` (lokale Entwicklung) ist kein Token nötig —
`-H "Authorization: Bearer $TOKEN"` dann einfach weglassen.
### 8.1 Sitzungsgedächtnis (Kurzzeit)
Mit `?session_id=name` merkt sich der Assistent den Gesprächsverlauf der aktuellen
Sitzung. Die letzten `HISTORY_MAX_MESSAGES` (Standard: 10) Nachrichten fließen als
Kontext ins LLM. Ohne `session_id` ist jeder Aufruf zustandslos.
```bash
curl -s -X POST "$URL/api/chat?session_id=oma-anna" \
-H 'Content-Type: application/json' -d '{"text":"Ich heiße Anna."}' --output /dev/null
curl -s -X POST "$URL/api/chat?session_id=oma-anna&debug=true" \
-H 'Content-Type: application/json' -d '{"text":"Wie heiße ich?"}' | jq '.trace'
```
### 8.2 Langzeit-Erinnerungen (manuell)
Dauerhafte Fakten und Vorlieben, die bei jedem Chat als Kontext ans LLM gehen —
unabhängig von der Session.
```bash
# Erinnerung hinzufügen:
curl -s -X POST $URL/api/me/memories \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"content":"Mag morgens Kamillentee."}' | jq
# Alle Erinnerungen anzeigen:
curl -s $URL/api/me/memories -H "Authorization: Bearer $TOKEN" | jq
# Erinnerung löschen:
curl -s -X DELETE $URL/api/me/memories/<id> -H "Authorization: Bearer $TOKEN" | jq
```
In der Datenbank direkt ansehen/löschen (nötig z. B. wenn eine falsch extrahierte
Erinnerung stört):
```bash
python3 -c "
import sqlite3; conn = sqlite3.connect('data/voice-assistant.db')
for r in conn.execute('SELECT id, content FROM memories'): print(r)
"
# Löschen:
python3 -c "
import sqlite3; conn = sqlite3.connect('data/voice-assistant.db')
conn.execute('DELETE FROM memories WHERE content LIKE \"%Stichwort%\"'); conn.commit()
"
```
### 8.3 Automatische Erinnerungsextraktion
Nach je N Turns (Standard: 3) destilliert ein LLM dauerhaft wirkende Fakten und
Vorlieben aus dem Gespräch und legt sie als Erinnerungen ab. Der Prozess läuft als
**Hintergrund-Task** — kein Einfluss auf die Antwortlatenz.
| Variable | Default | Bedeutung |
|----------|---------|-----------|
| `MEMORY_EXTRACTION_ENABLED` | `true` | Extraktion ein/aus |
| `MEMORY_EXTRACTION_EVERY_N_TURNS` | `3` | Wie oft extrahiert wird |
| `MEMORY_EXTRACTION_MAX` | `50` | Maximale Anzahl gespeicherter Erinnerungen |
| `MEMORY_EXTRACTION_PROVIDER` | (leer = Default-LLM) | Welcher Provider extrahiert |
Besonders nützlich mit lokalem LLM (kostenloser Zusatzaufruf). Bei Cloud-LLM entstehen
geringe Zusatzkosten pro Extraktion.
---
## 9. Resilienz, Fallbacks und Metriken
> 🔧 Admin
### 9.1 Fallback-Ketten
Fällt der primäre Provider aus (Timeout, HTTP-Fehler), übernimmt transparent der nächste:
```bash
# in .env (kommasepariert, mehrere möglich):
STT_FALLBACK=faster-whisper
LLM_FALLBACK=local-openai-compatible
TTS_FALLBACK=piper
```
Erfolgreiche Fallbacks und Fehler werden in den Metriken gezählt.
### 9.2 Metriken und Monitoring
```bash
curl -s $URL/api/metrics | jq # JSON (alle Zähler + Latenzen)
curl -s "$URL/api/metrics?format=prometheus" # Prometheus-Text
# Nur Pipeline-Latenzen:
curl -s $URL/api/metrics | jq '
.timers | to_entries
| map(select(.key|test("stage_duration")))
| map({stufe:.key, avg_s:.value.avg})'
```
In-Memory pro Prozess — kein externer Dienst nötig. Bei Neustart auf null.
**Gemessene Richtwerte** (Profil `cloud`, gegen OpenRouter, Stand 2026-06-17):
| Stufe | Modell | ~Zeit |
|-------|--------|-------|
| STT | `whisper-large-v3` | ~1,2 s |
| LLM | `gemini-3.1-flash-lite` | ~0,7 s |
| TTS | `gemini-3.1-flash-tts` | ~1,9 s |
| **Sprach-Round-Trip** | — | **~4 s** |
### 9.3 Tageskontingent (Kostenbremse)
```bash
DAILY_REQUEST_LIMIT=200 # Anfragen/Nutzer/Tag; 0 = unbegrenzt
```
Überschreitung → HTTP 429 (auch als `error`-Event im WebSocket). Notfall-Eingaben
werden **nie** blockiert, auch bei Limit.
Pro Nutzer übersteuern: `daily_request_limit` in `PUT /api/me/prefs`.
---
## 10. Notruf (vom Nutzer ausgelöst)
> 🔧 Admin
Es gibt **keine automatische** Notfallerkennung mehr (früher Stichwörter + LLM) — die war
unzuverlässig (Fehlalarme **wie** verpasste Notfälle) und intransparent. Ein Notruf entsteht
**nur durch eine bewusste Nutzeraktion**:
- In der App oben links ein dezenter roter **🆘-Knopf** („Hilfe rufen").
- Tippen → Rückfrage „Wirklich Hilfe rufen?" → **erst nach Bestätigung** wird ausgelöst.
- Der Notruf wird protokolliert (Admin → System → **Notrufe**); der Nutzer erhält einen
klaren Hinweis in seiner Sprache.
**Benachrichtigung — zwei unabhängige Kanäle (best effort):**
1. **E-Mail (SMTP):** Ist `SMTP_HOST` gesetzt, geht eine E-Mail an die Kontaktperson(en)
(`EMERGENCY_CONTACT_EMAIL`; pro Nutzer übersteuerbar via Pref `emergency_contacts`).
2. **SMS/Anruf (provider-agnostischer Webhook):** Ist `EMERGENCY_WEBHOOK_URL` gesetzt, geht
zusätzlich ein JSON-POST dorthin — mit den angefragten Kanälen, den Telefonnummern
(`EMERGENCY_CONTACT_PHONE`; pro Nutzer via Pref `emergency_phones`) und **fertigen
SMS-/Anruf-Texten**. Den konkreten Gateway (Twilio, seven.io, sipgate, eigener Dienst …)
verdrahtet man hinter dem Webhook; `EMERGENCY_WEBHOOK_TOKEN` schützt ihn optional per
`Authorization: Bearer <token>`.
Geht über **mindestens einen** Kanal etwas raus, sieht der Nutzer „Hilfe wurde verständigt …",
sonst „ACHTUNG: Es ist noch keine Benachrichtigung eingebaut.".
```bash
EMERGENCY_CONTACT_EMAIL=dieter.schlueter@linix.de # Default-Kontaktperson (E-Mail)
SMTP_HOST= # ohne Host kein E-Mail-Versand — KEINE Inline-Kommentare hinter Werten!
SMTP_PORT=587
SMTP_USER=
SMTP_PASSWORD=
SMTP_FROM= # muss dem SMTP_USER gehören (sonst 553 Sender rejected)
SMTP_STARTTLS=true
EMERGENCY_WEBHOOK_URL= # SMS/Anruf-Eskalation: JSON-POST hierhin (leer = aus)
EMERGENCY_WEBHOOK_TOKEN= # optional -> Authorization: Bearer <token>
EMERGENCY_CONTACT_PHONE= # Default-Telefonnummer(n), CSV, E.164 (+49…)
EMERGENCY_WEBHOOK_CHANNELS=sms,call # angefragte Kanäle im Payload
```
Das POST-Payload, das der Gateway-Empfänger bekommt:
```json
{
"event": "emergency", "category": "manual", "source": "voice-assistant",
"timestamp": "2026-06-25T…Z", "language": "de",
"user": {"id": "…", "name": "…"},
"channels": ["sms", "call"],
"phones": ["+4915112345678"],
"message": {"sms": "NOTRUF: …", "call": "Achtung. …"}
}
```
Auslösen per API (z. B. zum Testen):
```bash
curl -s -X POST $URL/api/emergency -H 'Content-Type: application/json' \
-d '{"language":"de"}' | jq
# → {"category":"manual","notice":"…","email_sent":…,"webhook_sent":…,"channels":[…]}
```
> ⚠️ **Wichtig:** Der Notruf ist **kein Ersatz** für einen echten Rettungsdienst. Die
> Zustellung ist best effort und hängt vom Mailserver bzw. dem hinter dem Webhook
> verdrahteten SMS/Anruf-Gateway ab.
---
## 11. Remote-Zugang und Deployment
> 🔧 Admin
### 11.1 Zugriff aus dem lokalen Netz (LAN)
Der Gateway lauscht standardmäßig auf `0.0.0.0` (alle Interfaces). Firewall öffnen:
```bash
sudo ufw allow from 192.168.179.0/24 to any port 8003 proto tcp comment 'voice-assistant LAN'
```
Browser: `http://<server-lan-ip>:8003/` — **Text-Chat** funktioniert. **Mikrofon-Button**
nicht: Browser geben das Mikrofon nur über HTTPS oder `localhost` frei.
> ⚠️ Bei `AUTH_ENABLED=false` kann jeder im LAN den Dienst anonym nutzen. Für Produktiv-
> betrieb: Auth aktivieren oder SSO-Weg nutzen.
### 11.2 Remote + HTTPS + SSO (YunoHost)
Für Handy/Browser von unterwegs über HTTPS mit YunoHost-SSO:
```
https://va.linix.de → nginx@YunoHost (TLS + SSO) → LAN → http://GPU-Box:8003
```
Vollständige Anleitung mit nginx-Konfiguration, Firewall, systemd und Chatterbox:
**[deploy/README.md](deploy/README.md)**
Kern-Einstellungen auf der GPU-Box (`/etc/voice-assistant/voice-assistant.env`):
```bash
HOST=<LAN-IP der GPU-Box> # nicht 0.0.0.0
PORT=8003
AUTH_ENABLED=true
TRUSTED_AUTH_COOKIE=yunohost.portal
TRUSTED_AUTH_COOKIE_CLAIM=user
TRUSTED_PROXY_IPS=<LAN-IP des YunoHost-Servers>
ADMIN_USERS=atoor,dieterschlueter,dschlueter
SSO_LOGOUT_URL=https://linix.de/yunohost/sso/?action=logout
```
WebSocket-Upgrade im nginx nicht vergessen — sonst kein Mikrofon und kein Streaming.
### 11.3 Docker
```bash
export OPENROUTER_API_KEY=...
docker compose up --build
```
### 11.4 systemd-Dienst
→ § 4.3 (Dauer-Betrieb ohne root)
---
## 12. Tests und Reaktionszeiten
> 💻 Entwickler / 🔧 Admin
### 12.1 Automatisierte Tests
```bash
make test # offline (mit Platzhaltern) — schnell, kostenlos
# oder: pytest -q
```
Abgedeckt: Config-Profile + Präzedenz, Route-Auflösung, Device Router,
Auth/Mandanten, Gedächtnis, Streaming, Resilienz, Quota, Notfall.
### 12.2 Reaktionszeiten messen
```bash
# TTS (Text → Audio):
curl -s -o gruss.pcm -w "TTS: %{time_total}s, %{size_download} Bytes\n" \
-X POST $URL/api/speak -H 'Content-Type: application/json' \
-d '{"text":"Guten Tag, wie kann ich Ihnen helfen?"}'
# STT (Audio → Text):
curl -s -o /dev/null -w "STT: %{time_total}s\n" \
-X POST $URL/api/transcribe -F "file=@frage.wav" -F "language=de"
# LLM (Text → Text, TTS auf Stub isoliert):
curl -s -o /dev/null -w "LLM: %{time_total}s\n" \
-X POST "$URL/api/chat?debug=true" -H 'Content-Type: application/json' \
-d '{"text":"Sag einen kurzen Gruß.","tts_provider":"piper"}'
# Durchschnitt der Pipeline-Stufen serverseitig:
curl -s $URL/api/metrics | jq '
.timers | to_entries
| map(select(.key|test("stage_duration")))
| map({stufe:.key, avg_s:.value.avg})'
```
### 12.3 Live-Smoke-Test (echter Netz-Aufruf)
```bash
make smoke # oder: python scripts/smoke_e2e.py
```
Prüft LLM, TTS und STT live gegen OpenRouter (geringe Kosten). Braucht `OPENROUTER_API_KEY`.
Meldet pro Modul `[OK]` / `[FAIL]`, inkl. TTS→STT-Round-Trip.
---
## 13. Fehlerbehebung
> alle Zielgruppen
| Symptom | Ursache | Lösung |
|---------|---------|--------|
| `OPENROUTER_API_KEY is empty` | Key fehlt im Service-Environment | `OPENROUTER_API_KEY=sk-or-…` in `.env` eintragen — systemd sourct kein `.bashrc` |
| HTTP **401** „Bearer/Invalid token" | Auth an, Token fehlt/falsch | Token im Header; oder `AUTH_ENABLED=false` für Dev |
| HTTP **401** bei `/api/admin/users` | falscher/fehlender Admin-Key | `X-Admin-Key` = `ADMIN_API_KEY` |
| HTTP **403** bei `?session_id=…` | Session gehört anderem Nutzer | eigene `session_id` wählen |
| HTTP **429** | Tageskontingent erreicht | `DAILY_REQUEST_LIMIT` erhöhen; oder nächster Tag |
| HTTP **422** „Unbekannter Provider" | Tippfehler im Provider-Namen | gültige Namen: `curl -s $URL/api/config \| jq '.available'` |
| HTTP **502** bei STT/TTS | Cloud-Fehler oder falsches Modell | `make smoke`; Modellnamen in `.env` prüfen |
| `VA_PROFILE` wirkt nicht | `DEFAULT_*_PROVIDER` in `.env` überschreibt das Profil | diese Zeilen auskommentieren |
| `Address already in use` | Port belegt | anderen `PORT` setzen; `ss -tlnp \| grep 8003` |
| „All connection attempts failed" (im Web-Chat) | LLM-/STT-/TTS-Dienst nicht erreichbar | Dienst starten; bei `local-dev`: `make llm-up` und warten bis HTTP OK |
| Kein Mikrofon im Browser | Kein HTTPS / kein `localhost` | HTTPS-Zugang einrichten (§ 11.2) oder lokal auf `localhost` zugreifen |
| `pw_context_connect() failed` | PipeWire-Pfad gestört | `--recorder auto` überspringt gestörte Tools; notfalls `--recorder arecord --device plughw:6,0` |
| Keine Aufnahme/Wiedergabe | Tool/Gerät fehlt | `arecord -L`; Pakete `alsa-utils`, `ffmpeg`, `pipewire` prüfen |
| Profil greift nicht | `config/voice-assistant.toml` fehlt | aus `*.example.toml` kopieren (→ § 2.2) |
| Erste Antwort sehr langsam | lokale Modelle noch nicht vorgeladen | Warm-up passiert im Hintergrund; 12 Minuten warten |
Logs: Terminal von `make run`. Mehr Details: `LOG_LEVEL=debug` in `.env`.
---
---
# Anhang A — Alle Umgebungsvariablen
> Vollständige Referenz. Alle Werte gehören in `.env` oder die Systemumgebung.
> Secrets (API-Keys, JWT-Secret) **nur** in die Umgebung, nie in `config/*.toml`.
## A.1 Betrieb und Server
| Variable | Default | Bedeutung |
|----------|---------|-----------|
| `APP_ENV` | `dev` | Umgebungskennung (z. B. `prod`) |
| `HOST` | `0.0.0.0` | Bind-Adresse (für LAN-only: LAN-IP setzen) |
| `PORT` | `8080` | Gateway-Port |
| `LOG_LEVEL` | `info` | `debug\|info\|warning\|error` |
| `VA_PROFILE` | (leer) | Aktives Profil: `local-dev\|hybrid\|cloud` |
| `VA_CONFIG_FILE` | `config/voice-assistant.toml` | Pfad zur TOML-Konfiguration |
| `DB_PATH` | `data/voice-assistant.db` | SQLite-Datenbankpfad |
## A.2 API-Keys und Authentifizierung
| Variable | Default | Bedeutung |
|----------|---------|-----------|
| `OPENROUTER_API_KEY` | (leer) | OpenRouter-API-Key — **nur als Umgebungsvariable** |
| `ADMIN_API_KEY` | (leer) | Admin-Key für `/api/admin/*` — **nur als Umgebungsvariable** |
| `AUTH_ENABLED` | `true` | Bearer-Token-Auth ein/aus |
| `TRUSTED_AUTH_HEADER` | (leer) | Header mit SSO-Usernamen (z. B. `X-Remote-User`) |
| `TRUSTED_AUTH_COOKIE` | (leer) | Cookie-Name (z. B. `yunohost.portal`) |
| `TRUSTED_AUTH_COOKIE_CLAIM` | `user` | JWT-Claim im Cookie |
| `TRUSTED_AUTH_JWT_SECRET` | (leer) | HS256-Secret für Cookie-Signaturprüfung |
| `TRUSTED_PROXY_IPS` | (leer) | Kommaseparierte IPs der vertrauenswürdigen Proxys |
| `ADMIN_USERS` | (leer) | Kommaseparierte SSO-Usernamen mit Admin-Rechten |
| `SSO_LOGOUT_URL` | (leer) | Logout-Link fürs Frontend |
## A.3 Profil und Provider-Auswahl
| Variable | Default | Bedeutung |
|----------|---------|-----------|
| `DEFAULT_LANGUAGE` | `de` | Standardsprache |
| `DEFAULT_STT_PROVIDER` | (Profil) | Überschreibt Profil; leer lassen für profilbasiert |
| `DEFAULT_LLM_PROVIDER` | (Profil) | Überschreibt Profil |
| `DEFAULT_TTS_PROVIDER` | (Profil) | Überschreibt Profil |
| `DEFAULT_INPUT_ENDPOINT` | `local-default` | Standard-Audio-Eingang |
| `DEFAULT_OUTPUT_ENDPOINT` | `local-default` | Standard-Audio-Ausgang |
| `STT_FALLBACK` | (leer) | Kommaseparierte Fallback-Provider für STT |
| `LLM_FALLBACK` | (leer) | Fallback-Provider für LLM |
| `TTS_FALLBACK` | (leer) | Fallback-Provider für TTS |
## A.4 Cloud-STT/LLM/TTS (OpenRouter)
| Variable | Default | Bedeutung |
|----------|---------|-----------|
| `OPENROUTER_STT_MODEL` | `openai/whisper-large-v3` | STT-Modell |
| `OPENROUTER_LLM_MODEL` | `openai/gpt-4.1-mini` | LLM-Modell |
| `OPENROUTER_TTS_MODEL` | `openai/gpt-4o-mini-tts` | TTS-Modell |
| `OPENROUTER_TTS_VOICE` | `alloy` | TTS-Stimme |
## A.5 Lokales LLM (llama.cpp / Ollama)
| Variable | Default | Bedeutung |
|----------|---------|-----------|
| `LOCAL_LLM_BASE_URL` | `http://127.0.0.1:8001/v1` | API-URL des LLM-Servers |
| `LOCAL_LLM_API_KEY` | `dummy` | Beliebiger Wert (Ollama: `ollama`) |
| `LOCAL_LLM_MODEL` | `va_llm` | Modellname / Alias |
| `LOCAL_LLM_DISABLE_REASONING` | `true` | Qwen3-Denkphase abschalten |
| `LOCAL_LLM_SYSTEM_PROMPT` | Sprach-Prompt | System-Prompt für gesprochene Antworten |
| `LOCAL_LLM_MAX_TOKENS` | `0` | Maximale Antwort-Tokens (0 = Server-Limit) |
| `LOCAL_LLM_TEMPERATURE` | `0.3` | Sampling-Temperatur |
## A.6 Lokales STT (faster-whisper)
| Variable | Default | Bedeutung |
|----------|---------|-----------|
| `FASTER_WHISPER_MODEL` | `base` | Modell: `tiny\|base\|small\|medium\|large-v3` |
| `FASTER_WHISPER_DEVICE` | `auto` | `auto\|cpu\|cuda` |
| `FASTER_WHISPER_COMPUTE_TYPE` | `default` | `default\|int8\|float16\|int8_float16` |
## A.7 Lokales TTS (piper)
| Variable | Default | Bedeutung |
|----------|---------|-----------|
| `PIPER_BIN` | `piper` | Pfad/Name des piper-Binaries |
| `PIPER_VOICES_DIR` | `~/.local/share/piper/voices` | Verzeichnis der `.onnx`-Stimmen |
| `PIPER_VOICE` | `de_DE-thorsten-high` | Stimmmodell (ohne `.onnx`) |
| `TTS_SAMPLE_RATE` | `24000` | Ziel-Sample-Rate (Gateway resampelt bei Bedarf) |
| `TTS_NORMALIZE_LEVEL` | `auto` | `auto\|full\|light\|off` |
## A.8 Chatterbox TTS
| Variable | Default | Bedeutung |
|----------|---------|-----------|
| `CHATTERBOX_BASE_URL` | `http://127.0.0.1:9999` | URL des Chatterbox-Dienstes |
| `CHATTERBOX_VOICE` | (leer) | Pfad zu Referenz-WAV (Voice-Cloning) |
| `CHATTERBOX_LANG` | `de` | Synthesesprache |
| `CHATTERBOX_SPEED` | `1.0` | Sprechgeschwindigkeit |
| `CHATTERBOX_TIMEOUT` | `180` | Timeout in Sekunden |
## A.9 Gedächtnis und Erinnerungen
| Variable | Default | Bedeutung |
|----------|---------|-----------|
| `HISTORY_MAX_MESSAGES` | `10` | Gesprächsverlauf pro Session (Turns) |
| `MEMORY_EXTRACTION_ENABLED` | `true` | Automatische Erinnerungsextraktion |
| `MEMORY_EXTRACTION_EVERY_N_TURNS` | `3` | Extraktion alle N Turns |
| `MEMORY_EXTRACTION_MAX` | `50` | Maximale Anzahl gespeicherter Erinnerungen |
| `MEMORY_EXTRACTION_PROVIDER` | (leer = Default-LLM) | Provider für Extraktion |
## A.10 Streaming und Audio
| Variable | Default | Bedeutung |
|----------|---------|-----------|
| `AUDIO_STREAM_DEFAULT` | `true` | Satzweises Audio-Streaming als Standard |
## A.11 Betrieb, Kontingent und Notfall
| Variable | Default | Bedeutung |
|----------|---------|-----------|
| `DAILY_REQUEST_LIMIT` | `0` | Anfragen/Nutzer/Tag (0 = unbegrenzt) |
| `EMERGENCY_CONTACT_EMAIL` | `dieter.schlueter@linix.de` | Kontaktperson für die Notruf-E-Mail |
| `SMTP_HOST` | (leer) | Mailserver für Notruf-E-Mail (leer = kein Versand) |
| `SMTP_PORT` | `587` | SMTP-Port |
| `SMTP_USER` / `SMTP_PASSWORD` | (leer) | SMTP-Zugangsdaten |
| `SMTP_FROM` | (leer → `SMTP_USER`) | Absender (muss dem SMTP_USER gehören) |
| `SMTP_STARTTLS` | `true` | STARTTLS verwenden |
| `EMERGENCY_WEBHOOK_URL` | (leer) | SMS/Anruf-Eskalation: JSON-POST hierhin (leer = aus) |
| `EMERGENCY_WEBHOOK_TOKEN` | (leer) | optional → `Authorization: Bearer <token>` für den Webhook |
| `EMERGENCY_CONTACT_PHONE` | (leer) | Default-Telefonnummer(n) für SMS/Anruf, CSV, E.164 |
| `EMERGENCY_WEBHOOK_CHANNELS` | `sms,call` | im Payload angefragte Kanäle (CSV) |
---
# Anhang B — API-Endpunkte
> Vollständige Referenz aller HTTP- und WebSocket-Endpunkte.
## B.1 System
| Methode | Pfad | Beschreibung |
|---------|------|--------------|
| `GET` | `/health` | Liveness-Check → `{"status":"ok"}` |
| `GET` | `/api/config` | Aktives Profil + aufgelöste Route (ohne Secrets) |
| `GET` | `/api/devices` | Verfügbare Audio-Endpunkte + Capabilities |
| `GET` | `/api/metrics` | Metriken (JSON oder `?format=prometheus`) |
## B.2 Konversation
| Methode | Pfad | Beschreibung |
|---------|------|--------------|
| `POST` | `/api/chat` | Text rein → Audio raus (PCM). `?debug=true` → JSON-Trace. `?session_id=…` → Gedächtnis |
| `POST` | `/api/speak` | Text rein → TTS-Audio raus (PCM) |
| `POST` | `/api/transcribe` | Audio-Upload (multipart) → Transkript-JSON |
Wichtige Body-Felder für `/api/chat` und `/api/speak`:
| Feld | Typ | Bedeutung |
|------|-----|-----------|
| `text` | string | Eingabe-Text (Pflicht) |
| `language` | string | Zielsprache, z. B. `de`, `en` — Antwort + Stimme (→ § 6.6) |
| `stt_provider` | string | Provider für diese Anfrage |
| `llm_provider` | string | Provider für diese Anfrage |
| `tts_provider` | string | Provider für diese Anfrage |
| `voice` | string | TTS-Stimme für diese Anfrage |
| `stream` | bool | LLM-Token-Streaming (nur WebSocket) |
| `audio_stream` | bool | Satzweises Audio-Streaming (nur WebSocket) |
| `text_only` | bool | Kein Server-Audio erzeugen/senden (nur Text) — fürs Geräte-TTS (→ § 6.5.0) |
## B.3 Sessions und Routing
| Methode | Pfad | Beschreibung |
|---------|------|--------------|
| `POST` | `/api/sessions/{id}/route` | Provider/Sprache/Geräte für Session festlegen |
Body-Felder: `input_endpoint`, `output_endpoint`, `stt_provider`, `llm_provider`,
`tts_provider`, `language`.
## B.4 Nutzer und Präferenzen
| Methode | Pfad | Beschreibung |
|---------|------|--------------|
| `GET` | `/api/me` | Aktueller Nutzer + Präferenzen |
| `PUT` | `/api/me/prefs` | Dauerhafte Nutzer-Präferenzen setzen (Merge; Felder u. a. `language`, `tts_provider` — → § 6.6) |
| `GET` | `/api/me/memories` | Alle Langzeit-Erinnerungen |
| `POST` | `/api/me/memories` | Erinnerung hinzufügen |
| `DELETE` | `/api/me/memories/{id}` | Erinnerung löschen |
## B.5 Administration
| Methode | Pfad | Auth | Beschreibung |
|---------|------|------|--------------|
| `POST` | `/api/admin/users` | Admin | Nutzer anlegen → Token einmalig |
| `GET` | `/api/admin/users` | Admin | Alle Nutzer auflisten |
| `PUT` | `/api/admin/users/{user_id}` | Admin | Anzeigenamen aktualisieren (`{"display_name":"…"}`) |
| `DELETE` | `/api/admin/users/{user_id}` | Admin | Nutzer + alle Daten löschen |
| `POST` | `/api/admin/users/{user_id}/token` | Admin | Neues Token ausstellen (alter Token sofort ungültig) |
| `POST` | `/api/admin/users/{user_id}/memories` | Admin | Erinnerung für Nutzer vorbelegen (`{"content":"…"}`) |
| `GET` | `/api/admin/users/{user_id}/memories` | Admin | Alle Erinnerungen eines Nutzers |
| `DELETE` | `/api/admin/users/{user_id}/memories/{id}` | Admin | Eine Erinnerung löschen |
| `GET` | `/api/admin/users/{user_id}/sessions` | Admin | Sessions eines Nutzers (neueste zuerst) |
| `GET` | `/api/admin/sessions/{session_id}/messages` | Admin | Nachrichten einer Session (`?limit=200`) |
| `GET` | `/api/admin/emergency-events` | Admin | Notfall-Ereignisse (`?limit=50`) |
| `GET` | `/api/admin/users/{user_id}/usage` | Admin | Nutzungsstatistik eines Nutzers |
| `GET` | `/api/admin/usage` | Admin | Aggregierte Nutzungsstatistik aller Nutzer |
| `GET` | `/api/admin/db-export` | Admin | SQLite-Datenbank als Datei-Download (Backup) |
| `GET` | `/api/admin/pronunciation/{lang}` | Admin | Aussprache-Lexikon lesen (`lang`: `de`, `en`, `fr`, `es`, `it`, `nl`, `ru`, `zh`, …) |
| `POST` | `/api/admin/pronunciation/{lang}` | Admin | Eintrag hinzufügen/überschreiben (`{"section":"terms","key":"Schlüter","value":"Chluteur"}`) |
| `DELETE` | `/api/admin/pronunciation/{lang}/{section}/{key}` | Admin | Eintrag löschen |
| `WS` | `/api/admin/log` | Admin | Live-Log via WebSocket (journalctl stream) |
**Auth:** `X-Admin-Key`-Header oder SSO-Admin-Cookie (→ § 7.4).
## B.6 WebSocket
| Pfad | Beschreibung |
|------|--------------|
| `/ws/chat` | Echtzeit-Chat. Client sendet JSON mit `text`; Server streamt `ack` → `token`* → `semantic` → Audio (binär) → `done`. Auth: `?token=…`, Gedächtnis: `?session_id=…` |
| `/ws/voice` | Echtzeit-Sprache. Client sendet Start-JSON (`{"type":"start","format":"webm"}`), dann Audio-Bytes, dann `{"type":"end"}`. Server antwortet mit `transcript` → dann wie `/ws/chat` |
**WebSocket-Events (Server → Client):**
| Event-Typ | Inhalt | Wann |
|-----------|--------|------|
| `ack` | `{}` | Verbindung aufgebaut |
| `transcript` | `{"text":"…"}` | STT-Ergebnis (bei `/ws/voice`) |
| `token` | `{"text":"…"}` | LLM-Token (bei `stream:true`) |
| `semantic` | `{"text":"…"}` | Vollständige Antwort |
| `audio` | `{"seq":N}` + binärer Frame | Satz-Audio (bei `audio_stream:true`) |
| `done` | `{"sample_rate":24000}` | Antwort fertig |
| `error` | `{"detail":"…"}` | Fehler |
| `emergency` | `{"category":"…","source":"keyword\|llm"}` | Notfall erkannt |
| `interrupted` | `{}` | Barge-in bestätigt |
**Barge-in:** `{"type":"interrupt"}` senden → laufende Antwort bricht ab.
**VAD:** Im Start-Frame `{"type":"start","vad":true,"format":"pcm","sample_rate":16000}` → Server erkennt Sprechpausen selbst.
---
# Anhang C — Provider-Übersicht
| Provider-Name | Kategorie | Typ | Abhängigkeit | Bemerkung |
|---------------|-----------|-----|-------------|-----------|
| `openrouter` | STT | Cloud | `OPENROUTER_API_KEY` | Whisper-large-v3, andere |
| `faster-whisper` | STT | Lokal | `pip install -e .[local]` | In-Process, GPU-fähig |
| `openrouter` | LLM | Cloud | `OPENROUTER_API_KEY` | GPT-4.1-mini, Gemini, … |
| `local-openai-compatible` | LLM | Lokal | llama.cpp oder Ollama | OpenAI-kompatibler Server |
| `openrouter` | TTS | Cloud | `OPENROUTER_API_KEY` | GPT-4o-mini-TTS, Gemini-TTS |
| `piper` | TTS | Lokal | `pip install -e .[local]` + Stimmmodell | In-Process, schnell |
| `chatterbox` | TTS | Lokal | Eigener HTTP-Dienst (Port 9999) | Langsam, hohe Qualität, Voice-Cloning |
Neuen Provider hinzufügen: Eintrag in `STT_REGISTRY`/`LLM_REGISTRY`/`TTS_REGISTRY`
in `app/dependencies.py` + Implementierung in `app/providers/`. → [Architektur-Dokument § 3.3](Docs/voice-assistant-architecture.md).
---
# Anhang D — Sachregister
| Begriff | Abschnitt |
|---------|-----------|
| Admin-Web-Panel | § 7.5 |
| API-Key (OpenRouter) | § 2.3, Anhang A.2 |
| Authentifizierung / Bearer-Token | § 7.1, § 7.3, Anhang B.4 |
| Audio-Geräte / Mikrofon / Lautsprecher | § 6.7 |
| Aussprache verbessern | § 6.5.4 |
| Aussprache — Eigennamen in Fremdsprachen | § 6.5.4 |
| Aussprache — YAML-Lexika (alle Sprachen) | § 6.5.4 |
| Automatische Erinnerungen | § 8.3 |
| Barge-in (Unterbrechung) | § 6.8, Anhang B.6 |
| Bluetooth | § 6.7 |
| Chatterbox TTS | § 6.5.3, Anhang C |
| Cloud-Profil | § 3.1 |
| Deployment (systemd, Docker) | § 4.3, § 4.4, § 11 |
| Erinnerungen (Langzeit) | § 8.2, § 8.3 |
| Fallback-Ketten | § 9.1, Anhang A.3 |
| faster-whisper | § 6.3, Anhang C |
| Fehlerbehebung | § 13 |
| Fix / Flex (Sprachmodus) | § 6.6 |
| Gedächtnis (Sitzung) | § 8.1 |
| Geräte-TTS (Web Speech API) | § 6.5.0 |
| Hybrid-Profil | § 3.2 |
| Installation | § 2 |
| Konfigurationsebenen / Priorität | § 6.1 |
| Kontingent (Kosten-Bremse) | § 9.3 |
| llama.cpp | § 4.5, § 3.2, § 3.3 |
| local-dev-Profil | § 3.3 |
| Ollama starten | § 4.6 |
| Ollama ↔ llama.cpp wechseln | § 4.7 |
| Stoppen (alle Varianten) | § 4.8 |
| Neustart | § 4.9 |
| Metriken / Monitoring | § 9.2, Anhang B.1 |
| Mikrofon → Audio-Geräte | § 6.7 |
| Notfall-Erkennung | § 10 |
| Ollama | § 3.2, § 3.3, **§ 4.6** |
| piper (TTS) | § 6.5.2, Anhang C |
| Pipeline (Architektur) | § 1.3 |
| Profile (cloud/hybrid/local-dev) | § 3 |
| Provider wechseln | § 6.2 |
| Remote-Zugang / HTTPS / SSO | § 11 |
| Sachregister | Anhang D |
| Sitzungsgedächtnis | § 8.1 |
| Sprache wechseln (Fix/Flex) | § 6.6 |
| Sprech-Loop | § 5.2 |
| Stimmen (TTS) | § 6.5.1, § 6.5.2 |
| Stimme folgt Sprache (Flex) | § 6.6 |
| STT-Einstellungen | § 6.3 |
| Streaming (Audio/Token/VAD) | § 6.8, Anhang B.6 |
| Tests | § 12 |
| TTS-Einstellungen | § 6.5 |
| Umgebungsvariablen (alle) | Anhang A |
| VAD (Sprechpausen-Erkennung) | § 6.8, Anhang B.6 |
| Voice-Cloning (Chatterbox) | § 6.5.3 |
| Web-Interface | § 5.1 |
| WebSocket | Anhang B.6 |
| YunoHost / SSO | § 7.2, § 7.4, § 11.2 |