diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ce07d4d --- /dev/null +++ b/.env.example @@ -0,0 +1,112 @@ +# Port bei Bedarf anpassen +APP_ENV=dev +HOST=0.0.0.0 +PORT=8080 +LOG_LEVEL=info + +# Secret nur ueber die Umgebung setzen (nicht hier eintragen), z. B. export in ~/.bashrc +OPENROUTER_API_KEY= + +# Satzweises Vorlesen (Audio-Streaming) als Default fuer WebSocket-Turns. +# false = Antwort erst komplett synthetisieren, dann abspielen. +AUDIO_STREAM_DEFAULT=true + +# Text-Normalisierung vor dem TTS (Aussprache): auto|full|light|off. +# auto = piper bekommt 'full' (Ordinalia/Einheiten/Abk./Lexikon), Cloud-TTS 'light' +# (nur Glaettung; Cloud spricht Zahlen/Abkuerzungen selbst gut). Lexikon: +# config/pronunciation.de.yaml (erweitert die eingebauten Defaults). +TTS_NORMALIZE_LEVEL=auto + +# --- Authentifizierung ----------------------------------------------------- +# AUTH_ENABLED=true (Standard) schuetzt chat/speak/transcribe/sessions per Bearer-Token. +# Fuer lokale Entwicklung/Tests auf false setzen (dann gilt ein anonymer Nutzer). +AUTH_ENABLED=true +# Schluessel fuer die Nutzerverwaltung (POST /api/admin/users). Nur ueber die Umgebung. +ADMIN_API_KEY= + +# Forward-Auth via Reverse-Proxy/SSO (z. B. YunoHost). Nur fuer Remote-Betrieb - +# siehe deploy/README.md. Lokal leer lassen. Identitaet per Header ODER Cookie: +# TRUSTED_AUTH_HEADER=X-Remote-User # falls der Proxy einen Header setzt +# TRUSTED_AUTH_COOKIE=yunohost.portal # YunoHost: Username im JWT-Cookie +# TRUSTED_AUTH_COOKIE_CLAIM=user +# TRUSTED_AUTH_JWT_SECRET= # optional: HS256-Signatur pruefen +# TRUSTED_PROXY_IPS=192.168.0.10 +# ADMIN_USERS=atoor,dieterschlueter,dschlueter +# SSO_LOGOUT_URL=https://linix.de/yunohost/sso/?action=logout + +# --- Zentrale Konfiguration / Profile ------------------------------------- +# Aktives Profil aus config/voice-assistant.toml waehlen: local-dev | hybrid | cloud +# (leer lassen = nur Defaults/ENV). Eigener Pfad via VA_CONFIG_FILE. +VA_PROFILE= +# VA_CONFIG_FILE=config/voice-assistant.toml + +# Hinweis zur Praezedenz: ENV gewinnt ueber die TOML-Datei. Die DEFAULT_*_PROVIDER- +# Zeilen unten ueberschreiben daher ein gesetztes VA_PROFILE. Wer profilbasiert +# umschalten will, sollte sie auskommentiert lassen. +OPENROUTER_STT_MODEL=openai/whisper-large-v3 +# Stimme haengt vom Modell ab (Gemini: Zephyr/Puck/Kore/...; OpenAI: alloy/echo/nova/...). +# Stimmenliste + Umstellen pro Aufruf: siehe BEDIENUNGSANLEITUNG ("Stimme des Cloud-TTS"). +OPENROUTER_TTS_MODEL=openai/gpt-4o-mini-tts +OPENROUTER_TTS_VOICE=alloy +OPENROUTER_LLM_MODEL=openai/gpt-4.1-mini +DEFAULT_LANGUAGE=de +DEFAULT_INPUT_ENDPOINT=local-default +DEFAULT_OUTPUT_ENDPOINT=local-default +# DEFAULT_STT_PROVIDER=openrouter +# DEFAULT_LLM_PROVIDER=local-openai-compatible +# DEFAULT_TTS_PROVIDER=openrouter +# Lokaler llama.cpp-Server (zentrale, unzensierte KI). Start: scripts/llm-server/start-llm-server.sh +# LOCAL_LLM_MODEL muss dem --alias des Servers entsprechen (Default: va_llm). +LOCAL_LLM_BASE_URL=http://127.0.0.1:8001/v1 +LOCAL_LLM_API_KEY=dummy +LOCAL_LLM_MODEL=va_llm +# Tempo-Hebel fuer den Sprach-Loop: Reasoning aus + knappe, vorlesbare Antworten. +# LOCAL_LLM_DISABLE_REASONING=true # Qwen3-Denkphase abschalten (deutlich schneller) +# LOCAL_LLM_MAX_TOKENS=0 # 0 = serverseitiges Limit (-n); z. B. 256 kappt lange Antworten +# LOCAL_LLM_TEMPERATURE=0.3 +# LOCAL_LLM_SYSTEM_PROMPT=Du bist ein gesprochener Sprachassistent. Antworte kurz ... + +# --- Lokales STT (faster-whisper; nur mit pip install -e .[local] ) --------- +FASTER_WHISPER_MODEL=base # tiny|base|small|medium|large-v3 +FASTER_WHISPER_DEVICE=auto # auto|cpu|cuda +FASTER_WHISPER_COMPUTE_TYPE=default # default|int8|float16|int8_float16 + +# --- Lokales TTS (piper; Binary + Stimmmodell noetig) ------------------------ +# Aktivieren z. B. mit DEFAULT_TTS_PROVIDER=piper (oder --tts-provider piper). +# Stimmen liegen als .onnx (+ .onnx.json) im Voices-Verzeichnis. +# Installierte Stimmen anzeigen: ls ~/.local/share/piper/voices/*.onnx +# Weitere laden: von huggingface 'rhasspy/piper-voices' nach PIPER_VOICES_DIR kopieren. +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) oder voller Pfad +TTS_SAMPLE_RATE=24000 # Ziel-Sample-Rate (ffmpeg resampelt bei Bedarf) + +# --- Chatterbox-TTS (hohe Qualitaet + Voice-Cloning; eigener Dienst) --------- +# Wählbar via tts_provider=chatterbox. Dienst: deploy/README.md. Langsamer als piper. +# CHATTERBOX_BASE_URL=http://127.0.0.1:9999 +# CHATTERBOX_VOICE=/pfad/zu/referenz_stimme.wav # leer = Chatterbox-Standardstimme +# CHATTERBOX_LANG=de +# CHATTERBOX_SPEED=1.0 + +# --- Resilienz: Fallback-Ketten (kommaseparierte Provider-Namen) ------------ +# Faellt der primaere Provider aus, uebernimmt der naechste. +# STT_FALLBACK=faster-whisper +# LLM_FALLBACK=local-openai-compatible +# TTS_FALLBACK=piper + +# --- Automatische Erinnerungs-Extraktion ----------------------------------- +# Das LLM destilliert nach je N Turns dauerhafte Fakten/Vorlieben aus dem Gespraech +# und legt sie als Nutzer-Erinnerungen ab (best-effort, nicht-blockierend). +# MEMORY_EXTRACTION_ENABLED=true +# MEMORY_EXTRACTION_EVERY_N_TURNS=3 # wie oft extrahiert wird +# MEMORY_EXTRACTION_MAX=50 # Obergrenze gespeicherter Erinnerungen +# MEMORY_EXTRACTION_PROVIDER= # leer = Default-LLM; sonst Registry-Name + +# --- Betrieb: Kontingent & Notfall ----------------------------------------- +DAILY_REQUEST_LIMIT=0 # Anfragen pro Nutzer/Tag (0 = unbegrenzt) +# EMERGENCY_WEBHOOK_URL=https://example.org/alert # optionale Eskalation +# LLM-Notfall-Klassifikation (Stufe 2): faengt im Hintergrund Notlagen, die die +# Stichwort-Heuristik verpasst -> keine zusaetzliche Antwortlatenz. +# EMERGENCY_LLM_ENABLED=true +# EMERGENCY_LLM_PROVIDER= # leer = Default-LLM; sonst Registry-Name +# EMERGENCY_LLM_MIN_CONFIDENCE=0.6 # Schwelle gegen Fehlalarme diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e00fe4c --- /dev/null +++ b/.gitignore @@ -0,0 +1,34 @@ +# Secrets / lokale Konfiguration +.env + +# Lokale/instanzspezifische Konfiguration (nur die *.example.toml wird versioniert) +config/voice-assistant.toml + +# Persistente Daten (SQLite-DB etc.) +data/ + +# Generierte Audio-Ausgaben (z. B. chat_client.py) +*.wav +# Ausnahme: native Referenz-Stimmen je Sprache (versioniert, Feature-Asset) +!config/voices/*.wav + +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +.venv/ +venv/ +.pytest_cache/ + +# Lokale Tool-/Editor-Konfiguration +.claude/ + +# Editor-/Backup-Reste +*.bak +*.patch +*.orig + +# Dateien +3 +TODO.md + diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md new file mode 100644 index 0000000..2a0aa62 --- /dev/null +++ b/BEDIENUNGSANLEITUNG.md @@ -0,0 +1,2455 @@ +# Voice Assistant Gateway — 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) +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 + +Der **Voice Assistant Gateway** ist ein modulares Sprachassistenten-System. Er 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) | § 2–4 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 § 3–6](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. 1–2 ¢ pro Sprech-Runde. STT und TTS sind die Kostentreiber; LLM ist +nahezu kostenlos. Grob ~20–40 ¢ 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 ~10–15 % 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,5–1,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) ~1–3 s; LLM wie +`hybrid`; TTS (`piper`, in-process) ~0,3–0,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** | ~1–2 ¢ | ~0,5–1,5 ¢ | ~0 (nur Strom) | +| **Round-Trip** | ~4 s | ~3–5 s | ~3–6 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 ` + +#### 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 http://127.0.0.1:11434/v1/models # 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://: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 ....................... 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 (→ § 6.6): `🔄 Flex` = folgt der gesprochenen Sprache · feste Sprache (🇩🇪/🇬🇧/…) = Antwort + Stimme in dieser Sprache | +| **⋮ 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. | +| **✎ 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.0–1.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 (`.onnx` + `.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-19): + +| `PIPER_VOICE` | Sprache | Qualität | Phoneme | Hinweis | +|---|---|---|---|---| +| `de_DE-thorsten-high` | Deutsch | **high** | 154 | **Default**, männlich | +| `en_US-ryan-high` | Englisch (US) | **high** | 130 | männlich | +| `en_US-lessac-high` | Englisch (US) | **high** | 154 | weiblich | +| `en_GB-cori-high` | Englisch (GB) | **high** | 157 | weiblich, britischer Akzent | +| `es_ES-sharvard-medium` | Spanisch | medium* | 154 | männlich | +| `fr_FR-siwis-medium` | Französisch | medium* | 154 | weiblich, korrekte Nasalvokale | +| `it_IT-paola-medium` | Italienisch | medium* | 154 | weiblich | +| `nl_NL-mls-medium` | Niederländisch | medium* | 159 | mehrere Sprecher | +| `ru_RU-irina-medium` | Russisch | medium* | 151 | weiblich | +| `zh_CN-huayan-medium` | Chinesisch (Mandarin) | medium* | 152 | weiblich | + +\* 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 `.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/.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.nl.yaml # Niederländisch + 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/ ✓ +NL: "Schluuter" → nl "uu"=/yː/ (= ü) sch=/sx/ +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 | NL | RU | ZH | +|------|----|----|----|----|----|----| +| /ʃ/ | sch | sh | ch | sch (≈) | Ш | sh → 施/书 | +| /y/ (= ü) | ü | — | u | uu | Ю/Ю | ü → 吕/绿 | +| /x/ (= ch) | ch | kh | — | g/ch | Х | h → 哈 | +| /ts/ | z | ts | ts | ts | Ц | ts → 茨 | + +**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 -X POST http://localhost:8080/api/admin/pronunciation/fr \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"section":"terms","key":"Schlüter","value":"Chluteur"}' + +# Eintrag löschen: +curl -X DELETE http://localhost:8080/api/admin/pronunciation/fr/terms/Schlüter \ + -H "Authorization: Bearer " + +# Alle Einträge einer Sprache anzeigen: +curl http://localhost:8080/api/admin/pronunciation/ru \ + -H "Authorization: Bearer " +``` + +**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 http://localhost:8080/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 wechseln (Fix / Flex) + +Die Antwortsprache wird in der Web-Oberfläche über **ein einziges Dropdown** („Sprache") +gesteuert. Es kennt zwei Arten von Werten: + +| Auswahl | Bedeutung | Whisper (STT) | LLM-Antwort + Stimme | +|---------|-----------|---------------|----------------------| +| **🔄 Flex** | keine feste Sprache — folgt automatisch der gesprochenen | erkennt die Sprache, **übersetzt nicht** | in der **erkannten** Sprache (blaue Blase zeigt das Original) | +| **🇩🇪 / 🇬🇧 / … (feste Sprache)** | „Fix": System bleibt bei dieser Sprache | bekommt die feste Sprache → **übersetzt** die Eingabe | immer in der **festen** Sprache, egal worin gefragt wurde | + +**Beispiele** (Eingabe auf Französisch gesprochen): + +- **Flex** → blaue Blase: französischer Originaltext · Antwort + Stimme: Französisch. +- **🇩🇪 DE** → blaue Blase: deutsche Übersetzung · Antwort + Stimme: Deutsch. + +Die vorlesende Piper-Stimme folgt immer der Antwortsprache automatisch (z. B. `thorsten` +für Deutsch, `siwis` für Französisch — Zuordnung → `LANG_TO_PIPER_VOICE`). Bei Text-Chat +im Flex-Modus (keine Audio-Erkennung möglich) bekommt das LLM **keine** Sprachvorgabe und +antwortet von selbst in der Sprache der Eingabe; als Fallback gilt `DEFAULT_LANGUAGE`. + +> **Hinweis:** Es gibt kein separates „Fix/Flex"-Menü mehr — eine konkrete Sprache zu +> wählen *ist* der Fix-Modus, „🔄 Flex" der flexible. Intern bleiben zwei Felder +> erhalten: `language` (ISO-Code) und `language_mode` (`fix` | `flex`). + +**Konfiguration außerhalb der Web-UI:** + +```bash +DEFAULT_LANGUAGE=de # global in .env (Fallback-/Standardsprache) +DEFAULT_LANGUAGE_MODE=fix # global: fix | flex (Admin: Tab „⚙ Einstellungen") +``` + +Pro Nutzer (dauerhaft): +```bash +# Feste Sprache (Fix): +curl -X PUT $URL/api/me/prefs -H "Authorization: Bearer $TOKEN" \ + -H 'Content-Type: application/json' -d '{"language":"en","language_mode":"fix"}' + +# Flex (Sprache folgt automatisch): +curl -X PUT $URL/api/me/prefs -H "Authorization: Bearer $TOKEN" \ + -H 'Content-Type: application/json' -d '{"language_mode":"flex"}' +``` + +Pro Aufruf: `{"text":"…","language":"en","language_mode":"fix"}` im Body. + +--- + +### 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 +pactl set-default-sink +``` + +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 ."` 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= + +# 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 .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. + +#### 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. + +--- + +## 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/ -H "Authorization: Bearer $TOKEN" +``` + +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. Notfall-Erkennung und Eskalation + +> 🔧 Admin + +Das System erkennt Notlagen-Signale (medizinisch, Sturz, Hilferuf) in der Nutzereingabe. +Die Erkennung ist **zweistufig**: + +1. **Stichwort-Heuristik** — im Hot-Path, sofort, ohne Latenz. +2. **LLM-Klassifikation** — läuft als Hintergrund-Task, **nur** wenn Stufe 1 nichts fand. + Erkennt verpasste Formulierungen (metaphorische Suizidalität, Schlaganfall-Symptome + ohne Schlüsselwort) mit Konfidenz-Schwelle. + +Bei Treffer: Protokolleintrag + Metrik + optionaler Webhook + Signal im Response +(`X-Emergency`-Header, `emergency`-Feld, WebSocket-`emergency`-Event). + +```bash +EMERGENCY_WEBHOOK_URL=https://example.org/alert # optional +EMERGENCY_LLM_ENABLED=true # Stufe 2 (Standard: an) +EMERGENCY_LLM_MIN_CONFIDENCE=0.6 # Schwelle gegen Fehlalarme +EMERGENCY_LLM_PROVIDER= # leer = Default-LLM +``` + +> ⚠️ **Wichtig:** Die Erkennung ist **keine verlässliche Lebensrettung** und kein Ersatz +> für einen echten Notruf. Sie kann Notlagen verpassen oder Fehlalarme auslösen. +> Erkannte Texte sind hochsensibel (DSGVO Art. 9: Einwilligung, Aufbewahrung, Zugriff). + +--- + +## 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://: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= # nicht 0.0.0.0 +PORT=8003 +AUTH_ENABLED=true +TRUSTED_AUTH_COOKIE=yunohost.portal +TRUSTED_AUTH_COOKIE_CLAIM=user +TRUSTED_PROXY_IPS= +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; 1–2 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_WEBHOOK_URL` | (leer) | Webhook-URL für Notfall-Eskalation | +| `EMERGENCY_LLM_ENABLED` | `true` | LLM-Klassifikation (Stufe 2) ein/aus | +| `EMERGENCY_LLM_PROVIDER` | (leer = Default-LLM) | Provider für Klassifikation | +| `EMERGENCY_LLM_MIN_CONFIDENCE` | `0.6` | Konfidenz-Schwelle gegen Fehlalarme | + +--- + +# 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 | Sprache, z. B. `de`, `en` (im Fix-Modus maßgeblich) | +| `language_mode` | string | `fix` (feste Sprache, Eingabe wird übersetzt) oder `flex` (folgt der erkannten Sprache) — → § 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 Routing-Präferenzen setzen (Merge; Felder u. a. `language`, `language_mode`, `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 | diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b8729a8 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.12-slim +WORKDIR /app +COPY pyproject.toml README.md ./ +COPY app ./app +RUN pip install --no-cache-dir -U pip && pip install --no-cache-dir .[test] +EXPOSE 8080 +CMD ["sh", "-c", "uvicorn app.main:app --host ${HOST:-0.0.0.0} --port ${PORT:-8080}"] diff --git a/Docs/Neues_Konzept_mit_TTS_auf_Mobil_Geraet.md b/Docs/Neues_Konzept_mit_TTS_auf_Mobil_Geraet.md new file mode 100644 index 0000000..134f411 --- /dev/null +++ b/Docs/Neues_Konzept_mit_TTS_auf_Mobil_Geraet.md @@ -0,0 +1,554 @@ +# Voice Assistant, Ubuntu 24.04, Python, iPhone, Android-Handy: + +Ich habe mit Python unter Linux einen Voice Assistenten Programmiert, mit dem sich der User unterhalten kann. Das System soll für Senioren stundenlangen Smalltalk mit KI ermöglichen (gegen Einsamkeit, multiuserfähig, mit Gedächtnis). Es funktioniert vereinfacht so: Das System wandelt Sprache in Text um (STT), gibt den Text an ein LLM-KI-Tool weiter. Das LLM formuliert eine adäquate Text-Antwort. Die wird angezeigt und in Sprache umgewandelt (TTS) und abgespielt. + +Das funktioniert am Desktop und auf Handys sowie auf Tablets im Browser, am Server auch im Terminal. Aber die Umwandlung von Text to Speech (TTS) in sehr guter Sprachqualität (also nicht mit Piper oder chatterbox via Python, die auch implementiert sind) ist einerseits teuer (Google-TTS-Tool via OpenRouter kostet ca. 2 Cent pro Minute Sound) , andererseits kommt der Ton verzögert auf dem Handy an, weil im Vergleich zum reinen Text große Sounddateien übers Netz zum Handy geschickt werden müssen. + +Nun stellt sich die Frage, ob es für IOS- und Android-Geräte die Möglichkeit gibt, den gesendeten Text erst auf dem Handy in Sprache mit professioneller Qualität umwandeln zu lassen. Das könnte unmittelbar passieren (Siri oder Ängliches) oder über Flat-Rate-Apps, die Cloud-Anbindung haben. + +1. Geht das? +2. Wie teuer wäre das? +3. Wie schnell sind die Tools? +4. Könnte man eventuell auch sogar die TTS-Umwandlung auf dem Handy laufen lassen? + +Ja, **das geht** sehr gut – und für Ihr Szenario ist „Text vom Server ans Handy schicken, TTS lokal auf dem Handy ausführen“ wahrscheinlich die beste Architektur, zumindest als Standardpfad für iPhone und viele Android-Geräte. iPhone und Android bringen eigene TTS-Engines mit, die direkt auf dem Gerät sprechen können; dadurch entfallen große Audiodateien über das Netz, die Latenz sinkt stark, und die laufenden Kosten für TTS können auf nahezu null fallen.[^1_1][^1_2] + +## Machbarkeit + +Auf dem iPhone kann eine App oder eine Weboberfläche Text an die systemeigene Sprachausgabe übergeben; Apple stellt dafür `AVSpeechSynthesizer` bereit, und Nutzer können in den Bedienungshilfen Stimmen auswählen, inklusive herunterladbarer „Enhanced Quality“-Stimmen. Diese erweiterten Stimmen sind lokal auf dem Gerät nutzbar und laut Apple oft 100 MB oder größer, was klar darauf hindeutet, dass die hochwertige Ausgabe zumindest nach dem Download lokal erfolgt.[^1_3][^1_1] + +Auf Android gibt es ebenfalls eine eingebaute `TextToSpeech`-API mit `speak()`, also genau den Mechanismus, den man für eine lokale oder systemnahe Ausgabe braucht. In der Praxis hängt die Qualität dort stärker vom installierten TTS-Engine-Provider ab, etwa Google Speech Services oder Samsung, aber grundsätzlich ist On-Device-TTS auf Android ein Standard-Use-Case.[^1_4][^1_5][^1_2] + +## Qualität und Geschwindigkeit + +Für **Geschwindigkeit** ist lokales TTS auf dem Handy fast immer besser als serverseitig erzeugtes Audio, weil Sie nur Text übertragen statt MP3/Opus/WAV-Dateien. Das spart Netzlast und vermeidet den zusätzlichen Schritt „Audio generieren → speichern → übertragen → puffern → abspielen“; bei lokaler TTS beginnt die Wiedergabe oft quasi sofort nach Erhalt des Textes.[^1_2][^1_3] + +Bei der **Qualität** ist iPhone derzeit meist der dankbarere Pfad, weil Apple hochwertige Systemstimmen und Enhanced-Varianten anbietet. Android kann ebenfalls gut klingen, aber dort ist die Qualität weniger homogen, weil sie vom Hersteller, der Android-Version und der installierten Engine abhängt.[^1_5][^1_1][^1_2] + +## Kosten + +Wenn Sie die System-TTS des Handys nutzen, sind die direkten TTS-Kosten typischerweise **null**, abgesehen von der einmaligen Datenmenge für den Download hochwertiger Stimmen und eventuell App-Entwicklung. Das ist für ein Senioren-Smalltalk-System wirtschaftlich sehr attraktiv, weil lange Gespräche nicht linear TTS-Kosten erzeugen.[^1_1][^1_2] + +Zum Vergleich: Cloud-TTS kostet je nach Anbieter und Qualitätsstufe weiter spürbar Geld. Google Cloud verlangt zum Beispiel für Neural2-Stimmen 16 US-Dollar pro 1 Mio. Zeichen, für WaveNet 4 US-Dollar pro 1 Mio. Zeichen und für HD-Stimmen 30 US-Dollar pro 1 Mio. Zeichen; OpenAI listet für GPT-Realtime-Translate 0,034 US-Dollar pro Minute und für Realtime-Whisper 0,017 US-Dollar pro Minute, während die Realtime-Audioausgabe tokenbasiert abgerechnet wird. Ihr genannter Wert von etwa 2 Cent pro Minute liegt also durchaus in einer realistischen Größenordnung für hochwertige Cloud-Audio-Pipelines, besonders wenn noch ein Vermittler wie OpenRouter dazwischenliegt.[^1_6][^1_7] + +## Sinnvolle Architektur + +Für Ihr Projekt würde ich drei Betriebsmodi unterscheiden: + + +| Modus | Technik | Kosten | Latenz | Qualität | Eignung | +| :-- | :-- | --: | --: | :-- | :-- | +| Browser/App mit lokaler System-TTS | Text an iPhone/Android, dort sprechen | Nahe 0 [^1_1][^1_2] | Sehr gut [^1_2][^1_3] | iPhone meist gut bis sehr gut, Android geräteabhängig [^1_1][^1_2] | **Standardfall** | +| Cloud-TTS als Premium-Fallback | Server erzeugt Audio | Laufend nutzungsabhängig [^1_6][^1_7] | Mittel bis schwankend | Sehr hoch | Für Spezialstimmen | +| Komplett lokal auf Handy | Native App mit OS-TTS | Nahe 0 [^1_1][^1_2] | Sehr gut [^1_2][^1_3] | Gut, aber nicht völlig frei wählbar [^1_1][^1_2] | Beste mobile UX | + +Der Knackpunkt ist: Im **Browser** ist das auf Android meist einfacher, weil Web Speech / systemnahe TTS dort oft besser nutzbar ist; auf iOS ist Browser-TTS funktional möglich, aber Apple ist restriktiver, und für eine robuste Produktlösung ist eine kleine native App oder ein WebView-Wrapper oft verlässlicher. Wenn Sie Multiuser, Gedächtnis und lange Sessions sauber betreiben wollen, wäre daher ein Server für STT/LLM/State plus mobile App für Playback eine sehr saubere Trennung.[^1_2][^1_3] + +## Konkrete Antwort auf Ihre 4 Fragen + +1. **Geht das?** +Ja. Sowohl iPhone als auch Android können empfangenen Text lokal in Sprache umwandeln; iPhone über Apples Sprachsynthese und Android über `TextToSpeech`.[^1_3][^1_1][^1_2] +2. **Wie teuer wäre das?** +Mit lokaler Geräte-TTS meist praktisch kostenlos im Betrieb. Cloud-TTS dagegen kostet je nach Stimme und Anbieter etwa von wenigen Dollar bis deutlich höheren Beträgen pro 1 Mio. Zeichen oder minutenbasiert bei Realtime-Angeboten.[^1_7][^1_6] +3. **Wie schnell sind die Tools?** +Lokale TTS ist in der Regel schneller wahrnehmbar, weil nur Text übertragen wird und keine Audiodatei gepuffert werden muss. Für Ihr Problem „Ton kommt verzögert aufs Handy“ ist das genau der Hauptvorteil.[^1_2][^1_3] +4. **Kann die TTS-Umwandlung auf dem Handy laufen?** +Ja, und genau das würde ich empfehlen. Auf iPhone ist das mit den Apple-Stimmen besonders interessant, auf Android ebenfalls gut machbar, aber mit etwas mehr Varianz bei der Stimmqualität.[^1_1][^1_2] + +Für Ihr Seniorensystem würde ich praktisch so bauen: Server liefert nur Text, Metadaten und Steuerkommandos; die mobile App spricht lokal mit System-TTS; Cloud-TTS nur optional für „Premium-Stimme“ oder wenn eine ganz bestimmte Persona gebraucht wird. Soll ich Ihnen als Nächstes eine konkrete Zielarchitektur skizzieren – einmal als reine Browserlösung und einmal als robuste native iPhone/Android-App?[^1_7][^1_1][^1_2] +[^1_10][^1_11][^1_12][^1_13][^1_14][^1_15][^1_16][^1_17][^1_18][^1_19][^1_20][^1_21][^1_22][^1_23][^1_24][^1_25][^1_26][^1_27][^1_28][^1_29][^1_8][^1_9] + +
+ +[^1_1]: https://support.apple.com/en-lb/111798 + +[^1_2]: https://developer.android.com/reference/android/speech/tts/TextToSpeech + +[^1_3]: https://a11y-guidelines.orange.com/en/mobile/ios/wwdc/2018/236/ + +[^1_4]: https://support.google.com/accessibility/android/answer/6006983?hl=en + +[^1_5]: https://play.google.com/store/apps/details?id=com.google.android.tts + +[^1_6]: https://medium.com/@john.goodstadt/artificial-intelligence-from-an-ios-app-1-a880f3dd4323 + +[^1_7]: https://www.oreateai.com/blog/unlocking-androids-voice-a-deep-dive-into-texttospeech-api/7b5fcae54518ca663c26d61df106c6df + +[^1_8]: https://www.finout.io/blog/openai-pricing-in-2026 + +[^1_9]: https://www.youtube.com/watch?v=_UD_dhuUozs + +[^1_10]: https://cloud.google.com/text-to-speech/pricing + +[^1_11]: https://www.pcmag.com/how-to/how-to-use-the-iphone-text-to-speech-feature + +[^1_12]: https://android-developers.googleblog.com/2024/03/introducing-new-text-to-speech-engine-wear-os.html + +[^1_13]: https://openai.com/api/pricing/ + +[^1_14]: https://www.youtube.com/watch?v=SXuTWmmTQwU + +[^1_15]: https://medium.com/@mrizqi070502/speak-up-implement-text-to-speech-in-android-3ad0f7f2580 + +[^1_16]: https://crazyrouter.com/en/blog/text-to-speech-api-comparison-2026 + +[^1_17]: https://medium.com/google-cloud/how-to-integrate-google-cloud-text-to-speech-api-into-your-ios-app-140ab7be42ae + +[^1_18]: https://the-rogue-marketing.github.io/google-gemini-tts-speech-audio-api-pricing-may-2026/ + +[^1_19]: https://devstreaming-cdn.apple.com/videos/wwdc/2018/236mwbxbxjfsvns4jan/236/236_avspeechsynthesizer_making_ios_talk.pdf?dl=1 + +[^1_20]: https://wildandfreetools.com/blog/text-to-speech-iphone-ios-complete-guide-free/ + +[^1_21]: https://android-developers.googleblog.com/2009/09/introduction-to-text-to-speech-in.html + +[^1_22]: https://stackoverflow.com/questions/60116322/availability-of-installed-voices-for-use-by-avspeechsynthesis-in-ios + +[^1_23]: https://sustainablecomputing.net/2024/01/29/speak-text-on-the-screen/ + +[^1_24]: https://android.googlesource.com/platform/frameworks/base/+/master/core/java/android/speech/tts/TextToSpeech.java + +[^1_25]: https://github.com/NSHipster/articles/blob/master/2014-03-31-avspeechsynthesizer.md + +[^1_26]: https://discussions.apple.com/thread/255707687 + +[^1_27]: https://stackoverflow.com/questions/22189778/can-we-give-a-custom-voice-to-avspeechsynthesizer + +[^1_28]: https://www.youtube.com/watch?v=22mdFf4aLmI + +[^1_29]: http://opensource.hcltechsw.com/volt-mx-native-function-docs/Android/android.speech.tts-Android-10.0/ + + +--- + +# ja + +Dann skizziere ich dir zwei Zielarchitekturen: + +1) „Nur Browser“ (ohne App), +2) „saubere“ native iOS-/Android-App, die dein bestehendes Python-Backend nutzt. + +*** + +## 1. Reine Browser‑Lösung (Web Speech / System‑TTS) + +### Grundidee + +- Dein Python‑Backend liefert nur Text (LLM‑Antwort + Metadaten). +- Im Browser rufst du per JavaScript die **Web Speech / SpeechSynthesis API** auf, die wiederum die **jeweilige Geräte‑TTS** nutzt (auf Android typischerweise Google TTS, auf iOS die lokalen Apple‑Stimmen).[^2_1][^2_2] +- Du überträgst also keinen Audiostream mehr, sondern nur Text → minimale Latenz, praktisch keine TTS‑Kosten.[^2_2][^2_1] + + +### Browser‑Support und Stolpersteine + +- Die SpeechSynthesis‑API ist in modernen Desktop‑Browsern und auf iOS Safari, Chrome/Firefox Android weitgehend unterstützt.[^2_3][^2_4][^2_1] +- Android: + - Chrome für Android hat volle SpeechSynthesis‑Unterstützung, Firefox Android ebenfalls.[^2_4][^2_3] + - Die Stimmen kommen i.d.R. vom Google‑TTS‑Dienst bzw. vom OS‑TTS; Qualität ist je nach Gerät und Sprache ziemlich ordentlich.[^2_5][^2_6][^2_7] +- iOS: + - Safari auf iOS unterstützt SpeechSynthesis, aber mit Besonderheiten: erste Ausgabe muss aus einem echten User‑Event (Button‑Klick o.ä.) kommen, Hintergrundwiedergabe ist eingeschränkt und es gibt Bugs beim Voice‑Wechsel.[^2_8][^2_9][^2_10] + - Praktisch heißt das: Du brauchst am Anfang der Session einen expliziten „Audio aktivieren“-Button, der einmalig eine Dummy‑Utterance abspielt, danach kannst du programmatisch sprechen.[^2_11][^2_10] + + +### Bewertung für dein Use Case + +**Vorteile** + +- Kein App‑Store‑Deployment nötig. +- Minimaler Netzwerk‑Traffic, TTS praktisch kostenlos.[^2_1][^2_2] +- Für Android‑Tablets/Phones mit Chrome läuft das erstaunlich robust und performant.[^2_3][^2_4] + +**Nachteile** + +- iOS Safari ist launisch: kein Background‑Audio, TTS stoppt teils beim Wechsel in andere Apps und erfordert Workarounds.[^2_12][^2_10][^2_8] +- Du hast relativ wenig Kontrolle über Voice‑Auswahl, Lautstärke, Audio‑Routing etc., alles ist vom Browser/OS abhängig.[^2_9][^2_1] + +Wenn du Senior:innen ein **„immer an, immer verfügbar“‑Gefühl** geben willst, das auch beim Display‑Lock oder App‑Wechsel noch halbwegs stabil reagiert, kommst du auf iOS mit einer **App** deutlich entspannter ans Ziel. + +*** + +## 2. Architektur mit nativer iOS‑ und Android‑App + +Hier nutzt du dein Linux‑/Python‑Backend weiter wie bisher (STT→LLM→Text), verschiebst aber TTS vollständig ins Handy. + +### Backend (Python / Ubuntu / Server) + +- Bleibt weitgehend wie heute: + - WebSocket oder HTTP(s) für Text‑Nachrichten. + - Session‑Management (User‑ID, Gesprächskontext, Gedächtnis). + - STT entweder serverseitig (z.B. Whisper) oder auch schon lokal, falls du später native STT am Handy nutzen willst. +- Erweiterung: + - Statt Audio sendest du **nur Text + Steuerinfos**: z.B. `{ text, speakerId, emotion, priority }`. + - Optional: „SSML‑Marken“ für Pausen, Betonung, etc. (einige mobile TTS‑Engines verstehen das).[^2_7][^2_13] + + +### iOS‑App + +- Spricht Text über **AVSpeechSynthesizer**: + - `AVSpeechUtterance(string:)` + `AVSpeechSynthesizer().speak(utterance)`.[^2_14][^2_15] + - Nutzer:innen können im System „Sprachausgabe“/„Spoken Content“ die Stimme (auch „Enhanced Quality“) und Sprache auswählen.[^2_16] + - Diese Stimmen sind nach Download lokal verfügbar und bieten sehr hohe Qualität.[^2_16] +- Vorteile: + - Zuverlässige Audioausgabe, Hintergrund‑Audio lässt sich sauber konfigurieren (AVAudioSession, Background Modes).[^2_12][^2_14] + - Du umgehst alle Safari/Web‑Speech‑Eigenheiten komplett – die App kontrolliert Wiedergabe, Lautstärke, Unterbrechung durch Telefonate etc. + + +### Android‑App + +- Nutzt die `TextToSpeech`‑API: + - `TextToSpeech(context, OnInitListener)`, danach `tts.speak(text, QUEUE_ADD, params, utteranceId)`.[^2_13][^2_7] + - TTS‑Engine: Google Speech Services (Standard) oder andere Engines, die Nutzer installieren.[^2_6][^2_5] + - Ab Android‑Seite kannst du Geschwindigkeit, Tonhöhe und ggf. Voice auswählen.[^2_7] +- Vorteile: + - Gute Latenz, da komplett lokal.[^2_7] + - Hintergrund‑Speech ist in nativen Apps deutlich besser handhabbar als im Browser.[^2_7] + + +### Netzwerk‑Protokoll zwischen Handy und Backend + +- Idealerweise **WebSocket**, weil du eh schon längere Sessions fährst. +Einfaches Schema: + - Client → Server: + - `user_audio_chunk` (falls STT serverseitig), + - `user_text` (falls STT in der App), + - `control` (Pause, Stop, etc.). + - Server → Client: + - `assistant_text` (LLM‑Antwort), + - optional `assistant_meta` (Emotion, Sprechtempo), + - keine Audiodaten mehr. +- Auf dem Client: + - Für jede eingehende Antwort wird direkt ein `utterance` erzeugt und in die lokale TTS‑Queue gestellt. + + +### Kosten, Geschwindigkeit, Qualität im Vergleich + +| Variante | TTS‑Ort | Laufende TTS‑Kosten | Latenz bis Ton | Qualitäts‑Kontrolle | Bemerkung | +| :-- | :-- | :-- | :-- | :-- | :-- | +| Heute (Cloud‑TTS über OpenRouter) | Server | Cents pro Minute, summiert sich [^2_17][^2_18] | Audio über Netz → spürbare Verzögerung | Sehr hoch, aber Provider‑abhängig [^2_17][^2_18] | Problem: Kosten + Latenz | +| Web‑only mit SpeechSynthesis | Handy / Browser | Praktisch 0 [^2_1][^2_2] | Sehr gut, nur Text übertragen [^2_1][^2_2] | gut, aber Browser‑abhängig [^2_3][^2_4] | Android sehr gut, iOS okay mit Workarounds | +| Native App + System‑TTS | Handy / OS | Praktisch 0 [^2_16][^2_7] | Sehr gut, offlinefähig [^2_7][^2_14] | iOS sehr gut, Android gut [^2_16][^2_7] | Beste Kontrolle und UX | + + +*** + +## 3. Lokale TTS‑Engines auf dem Handy (ohne Cloud) + +Deine Frage 4 zielte noch einmal extra darauf ab, ob die gesamte TTS‑Umwandlung direkt auf dem Handy laufen kann – also ohne Cloud. + +- iOS: + - Die herunterladbaren „hochwertigen Stimmen“ in Spoken Content/VoiceOver sind genau dafür gedacht: hochwertige Sprachausgabe, lokal, nachdem die Stimme einmal geladen wurde.[^2_16] + - Du nutzt sie transparent über AVSpeechSynthesizer; du musst keinen separaten TTS‑Dienst ansprechen.[^2_14] +- Android: + - Google Speech Services („Speech Recognition \& Synthesis“) liefern lokale Text‑zu‑Sprache‑Funktionalität für Apps.[^2_5][^2_6] + - Die `TextToSpeech`‑API arbeitet i.d.R. auch offline, solange die entsprechende Sprach‑Engine und Sprachpakete installiert sind.[^2_13][^2_7] + +Damit hast du eine **vollständig lokale TTS‑Pipeline auf dem Endgerät**, mit sehr wenig Netzbedarf und in vielen Fällen guter Qualität. + +*** + +## Was ich dir konkret vorschlagen würde + +Für deinen Senior:innen‑Assistenten würde ich: + +1. **Kurzfristig**: + - In der bestehenden Web‑UI Web Speech / SpeechSynthesis integrieren (also im Browser TTS starten statt Audio vom Server zu spielen). + - Sicheren „Audio aktivieren“-Button implementieren (wegen iOS‑Restrictions).[^2_10][^2_8] + - Logging/Telemetry einbauen, um zu sehen, bei welchen Geräten/Browsern es hakt. +2. **Mittelfristig**: + - Eine sehr schlanke native iOS‑ und Android‑App bauen: + - UI im Wesentlichen ein WebView/eine im Browser bereits bestehende Oberfläche, + - aber TTS und ggf. STT per nativen APIs (`AVSpeechSynthesizer` / `TextToSpeech`). + - Dein Python‑Backend bleibt unverändert, liefert nur Text. + +Wenn du mir sagst, ob du eher **Web‑Only** bleiben willst oder mittelfristig **echte Apps** für iOS/Android planst, kann ich dir im nächsten Schritt sehr konkret Pseudocode/Architektur‑Snippets (inkl. Beispiel‑WebSocket‑Protokoll und Swift/Kotlin‑Skizzen) formulieren. +[^2_19][^2_20][^2_21][^2_22][^2_23][^2_24] + +
+ +[^2_1]: https://wiki.selfhtml.org/wiki/Web_Speech + +[^2_2]: https://developer.mozilla.org/en-US/docs/Web/API/Web_Speech_API + +[^2_3]: https://www.lambdatest.com/web-technologies/speech-synthesis + +[^2_4]: https://caniuse.com/speech-synthesis + +[^2_5]: https://support.google.com/accessibility/android/answer/6006983?hl=en + +[^2_6]: https://play.google.com/store/apps/details?id=com.google.android.tts + +[^2_7]: https://developer.android.com/reference/android/speech/tts/TextToSpeech + +[^2_8]: https://weboutloud.io/bulletin/speech_synthesis_in_safari/ + +[^2_9]: https://elliotbentley.com/blog/html5-speech-synthesis-api/ + +[^2_10]: https://stackoverflow.com/questions/67655133/when-will-speechsynthesis-speak-work-on-ios-safari/72199291 + +[^2_11]: https://www.reddit.com/r/learnjavascript/comments/1smimrx/help_mobile_safari_web_speech_api_silent_failure/ + +[^2_12]: https://developer.apple.com/forums/thread/771040 + +[^2_13]: https://android-developers.googleblog.com/2009/09/introduction-to-text-to-speech-in.html + +[^2_14]: https://a11y-guidelines.orange.com/en/mobile/ios/wwdc/2018/236/ + +[^2_15]: https://github.com/NSHipster/articles/blob/master/2014-03-31-avspeechsynthesizer.md + +[^2_16]: https://support.apple.com/en-lb/111798 + +[^2_17]: https://medium.com/@john.goodstadt/artificial-intelligence-from-an-ios-app-1-a880f3dd4323 + +[^2_18]: https://www.oreateai.com/blog/unlocking-androids-voice-a-deep-dive-into-texttospeech-api/7b5fcae54518ca663c26d61df106c6df + +[^2_19]: https://developer.mozilla.org/fr/docs/Web/API/Web_Speech_API/Using_the_Web_Speech_API + +[^2_20]: https://developer.chrome.com/blog/web-apps-that-talk-introduction-to-the-speech-synthesis-api + +[^2_21]: https://dzone.com/articles/the-developers-guide-to-web-speech-api-what-is-it + +[^2_22]: http://devdoc.net/web/developer.mozilla.org/en-US/docs/Web/API/Web_Speech_API/Using_the_Web_Speech_API.html + +[^2_23]: https://developer.chrome.com/blog/voice-driven-web-apps-introduction-to-the-web-speech-api + +[^2_24]: https://blog.addpipe.com/a-deep-dive-into-the-web-speech-api/ + + +--- + +# Könnte man eventuell auch sogar die STT-Umwandlung auf dem Handy laufen lassen? + +Ja – **STT auf dem Handy** laufen zu lassen ist grundsätzlich möglich, und technisch sogar sehr attraktiv für dein System. Sowohl iPhone als auch Android bieten Wege für lokale bzw. gerätenahe Spracherkennung; im Browser ist das aber deutlich unzuverlässiger als in nativen Apps.[^3_1][^3_2][^3_3] + +## Native Apps + +Auf iPhone unterstützt Apples Speech-Framework On-Device-Erkennung; seit iOS 13 gibt es dafür die Option `requiresOnDeviceRecognition`, also echte lokale Transkription ohne Serverpflicht, sofern Sprache und Gerät das unterstützen. Zusätzlich kann Apple auch Diktat- und Siri-nahe Funktionen teilweise offline ausführen, was zeigt, dass Sprachverarbeitung auf dem Gerät inzwischen ein normaler Pfad ist.[^3_4][^3_5][^3_6][^3_1] + +Auf Android gibt es mit `SpeechRecognizer` die systemeigene API für Spracherkennung. Offline-Erkennung ist dort grundsätzlich möglich, wenn die passende Engine und die Offline-Sprachpakete installiert sind; die praktische Qualität hängt aber stärker als bei iOS von Hersteller, Android-Version und installierter Sprach-Engine ab.[^3_7][^3_8][^3_3] + +## Browser + +Im **mobilen Browser** ist STT die deutlich schwierigere Baustelle als TTS. Die Web Speech API unterstützt Spracherkennung zwar grundsätzlich, aber der Support ist laut aktuellen Kompatibilitätsübersichten nur partiell: Chrome auf Android und Safari auf iOS teilweise, Firefox Android gar nicht. Für ein Seniorenprodukt mit langen Gesprächen und Multiuser-Betrieb ist das zu fragil, wenn du eine verlässliche Hauptlösung willst.[^3_9][^3_10] + +Es gibt zwar inzwischen in der Web Speech API auch einen Pfad für **On-Device-Speech-Recognition** über `processLocally = true`, inklusive Sprachpaket-Download via `SpeechRecognition.available()` und `SpeechRecognition.install()`. Aber das ist browserabhängig und noch weit davon entfernt, plattformübergreifend so robust zu sein wie native APIs.[^3_2][^3_9] + +## Architekturfolgen + +Wenn du STT aufs Handy verlagerst, sendet das Gerät nur noch **Text** oder kleine Transkriptions-Events an deinen Python-Server, statt kontinuierlich Audiodaten hochzuladen. Das senkt Bandbreite, reduziert Datenschutzrisiken und kann die wahrgenommene Reaktionszeit verbessern, weil das erste erkannte Textstück früher beim Backend ankommt.[^3_3][^3_1][^3_2] + +Für deine Architektur ergeben sich damit drei sinnvolle Varianten: + +- **STT lokal, TTS lokal, LLM am Server** – wahrscheinlich der beste Zielzustand für mobile Apps.[^3_5][^3_1][^3_3] +- **STT serverseitig, TTS lokal** – guter Zwischenschritt, wenn du am Backend wenig ändern willst.[^3_11][^3_12] +- **STT und TTS im Browser lokal** – machbar als Experiment oder Android-first-Weblösung, aber nicht robust genug als alleinige Senioren-Produktionslösung.[^3_2][^3_9] + + +## Empfehlung + +Für dein Szenario würde ich STT auf dem Handy **ja, aber vorzugsweise nativ** einplanen. iPhone ist dafür besonders interessant, weil Apple On-Device-Recognition offiziell unterstützt. Android geht ebenfalls gut, braucht aber mehr Tests pro Gerätekategorie und Sprachpaket-Setup.[^3_1][^3_7][^3_5][^3_3] + +Die pragmatische Reihenfolge wäre: + +- Erst **TTS lokal aufs Handy** verlagern, weil das sofort Kosten und Latenz spart.[^3_12][^3_11] +- Danach **STT nativ mobil** ergänzen. +- Browser-STT nur als optionalen Fallback oder Prototyp betrachten.[^3_9][^3_2] + +Für deine Produktidee wäre also eine sehr plausible Endarchitektur: +**Mobiles Gerät:** STT lokal + TTS lokal.[^3_3][^3_1] +**Server unter Ubuntu/Python:** Session-Logik, Memory, Benutzerverwaltung, LLM-Orchestrierung.[^3_1][^3_3] + +Soll ich dir als Nächstes eine konkrete **Soll-Architektur mit Komponentenliste** skizzieren – getrennt nach Web-only, Android-App und iPhone-App? +[^3_13][^3_14][^3_15][^3_16][^3_17] + +
+ +[^3_1]: https://stackoverflow.com/questions/42900254/is-there-a-way-to-use-ios-speech-recognition-in-offline-mode/57047665 + +[^3_2]: https://developer.mozilla.org/en-US/docs/Web/API/Web_Speech_API/Using_the_Web_Speech_API + +[^3_3]: https://developer.android.com/reference/android/speech/SpeechRecognizer + +[^3_4]: https://plymouth.libguides.com/tech/voicerecognition + +[^3_5]: https://9to5mac.com/2019/11/07/new-dictation-app-brings-advanced-offline-transcription-capabilities-to-iphone-and-ipad/ + +[^3_6]: https://www.idownloadblog.com/2021/06/08/ios-15-siri-offline-features/ + +[^3_7]: https://stackoverflow.com/questions/31562229/offline-speech-recognition-in-android + +[^3_8]: https://www.oreateai.com/blog/whispers-in-your-pocket-making-android-speechtotext-work-offline/7a0284926c7c184dd6483638a755f963 + +[^3_9]: https://www.lambdatest.com/web-technologies/speech-recognition + +[^3_10]: https://blog.addpipe.com/a-deep-dive-into-the-web-speech-api/ + +[^3_11]: https://support.apple.com/en-lb/111798 + +[^3_12]: https://developer.android.com/reference/android/speech/tts/TextToSpeech + +[^3_13]: https://soniqo.audio/getting-started/android + +[^3_14]: https://devdoc.net/web/developer.mozilla.org/en-US/docs/Web/API/Web_Speech_API/Using_the_Web_Speech_API.html + +[^3_15]: https://apps.apple.com/us/app/speech-to-text-offline/id6756486902 + +[^3_16]: https://www.reddit.com/r/androiddev/comments/w3lm92/offline_streaming_speech_to_text/ + +[^3_17]: https://towardsdatascience.com/web-speech-api-what-works-what-doesnt-and-how-to-improve-it-by-linking-it-to-a-gpt-language-dc1afde54ced/ + + +--- + +# ja + +Ja. Für dein System würde ich drei Soll-Architekturen unterscheiden: **Web-only**, **Android nativ zuerst**, und **iPhone nativ**. Die technische Kernidee ist in allen Fällen gleich: STT und TTS möglichst nahe ans Endgerät, während dein Ubuntu/Python-Server nur noch Orchestrierung, Gedächtnis, Benutzerverwaltung und LLM-Antworten übernimmt.[^4_1][^4_2][^4_3] + +## Web-only + +Bei einer reinen Weblösung läuft TTS über `speechSynthesis`, und STT über die Web Speech API. Neuere Web-API-Dokumentation beschreibt dafür sogar `processLocally = true` sowie Sprachpaket-Prüfung per `SpeechRecognition.available()` und Installation per `SpeechRecognition.install()`, also grundsätzlich einen Pfad zu lokaler Erkennung im Browser.[^4_2][^4_4][^4_5] + +Für Produktion wäre das aber nur als **Best-Effort**-Variante sinnvoll, weil Browser-Support und Verhalten je nach Plattform stark schwanken. Deshalb sollte dein Server in dieser Variante immer auch einen Fallback haben: Browser-STT lokal, sonst Browser-/Server-STT; TTS lokal im Browser, sonst notfalls Audio vom Server.[^4_4][^4_5][^4_2] + +### Komponenten + +- Browser-UI: Aufnahme, Push-to-talk oder VAD, Textanzeige, lokale TTS/STT.[^4_2][^4_4] +- Python-Backend: Session-State, Multiuser, Gedächtnis, LLM, WebSocket-Transport. +- Fallback-Logik: erkennt, ob lokales STT/TTS verfügbar ist, und schaltet sonst auf Serverpfade um.[^4_5] + + +### Datenfluss + +1. Browser startet lokale Erkennung, wenn verfügbar.[^4_2] +2. Browser sendet nur Text-Teilresultate oder Final-Text zum Server. +3. Server antwortet mit Text. +4. Browser spricht den Text lokal aus. + +## Android nativ + +Android ist als erster nativer Schritt besonders sinnvoll, weil `SpeechRecognizer` offiziell für App-seitige Spracherkennung vorgesehen ist und Offline-Betrieb mit installierten Sprachpaketen möglich ist. Für vollständig lokale Alternativen gibt es zudem erprobte Bibliotheken wie Vosk für Android, falls du dich nicht an die jeweilige System-Engine binden willst.[^4_6][^4_7][^4_3] + +Damit könntest du auf Android **STT lokal + TTS lokal + LLM am Server** umsetzen. Das reduziert Netztraffic stark, verbessert Privatsphäre und senkt die laufenden Sprachkosten praktisch auf null.[^4_7][^4_3][^4_6] + +### Komponenten + +- Android-App: + - `SpeechRecognizer` für STT, primär lokal/offline wenn Sprachpakete vorhanden sind.[^4_3][^4_6] + - `TextToSpeech` für TTS. + - WebSocket-Client für Serveranbindung. + - Lokale Audio-/Sessionsteuerung. +- Python-Backend: + - Auth, Multiuser, Gedächtnis, Gesprächslogik, LLM. + + +### Datenfluss + +1. Mikrofon geht an, Android-App transkribiert lokal.[^4_3] +2. Partials und Final-Text gehen per WebSocket an den Server. +3. Server generiert Antworttext. +4. Android-App spielt Antwort über lokale TTS. + +### Praktische Empfehlung + +- **Phase 1:** `SpeechRecognizer` + System-TTS. +- **Phase 2:** optional Vosk/ähnlich für vollständige Unabhängigkeit von Google-Services.[^4_7] + + +## iPhone nativ + +Auf iPhone ist native STT ebenfalls möglich, und Apple dokumentiert dafür ausdrücklich `requiresOnDeviceRecognition`. Wenn diese Eigenschaft auf `true` gesetzt wird, verhindert das Senden der Audiodaten übers Netz; Apple weist aber darauf hin, dass On-Device-Erkennung weniger genau sein kann als Servererkennung. Apple empfiehlt außerdem, vorab zu prüfen, ob `supportsOnDeviceRecognition` vorhanden ist, und dann gezielt On-Device zu aktivieren.[^4_8][^4_9][^4_1] + +Damit ist die iPhone-Zielarchitektur sehr klar: **Speech Framework lokal**, **AVSpeechSynthesizer lokal**, **LLM und Memory am Server**. Für dein Senioren-Szenario ist das attraktiv, weil du eine kontrollierte UX bekommst, ohne Safari-Eigenheiten im Browser.[^4_9][^4_1][^4_8] + +### Komponenten + +- iOS-App: + - `SFSpeechRecognizer` + Request mit `requiresOnDeviceRecognition = true`.[^4_1] + - Prüfung auf `supportsOnDeviceRecognition` vor Sessionstart.[^4_8][^4_9] + - `AVSpeechSynthesizer` für lokale Sprachausgabe. + - WebSocket-Client für Texttransport. +- Python-Backend: + - wie bei Android. + + +### Datenfluss + +1. App nimmt Audio auf und transkribiert lokal, wenn unterstützt.[^4_9][^4_1] +2. Nur Text geht an den Server. +3. Server antwortet mit Text. +4. iPhone spricht lokal. + +## Gemeinsame Serverarchitektur + +Dein Ubuntu-24.04-/Python-Backend kann in allen drei Varianten nahezu gleich bleiben. Es sollte nur noch diese Kernrollen übernehmen: + +- Benutzer- und Sitzungsverwaltung. +- Gedächtnis / Langzeitkontext. +- LLM-Orchestrierung. +- WebSocket- oder SSE-Schnittstelle für Text-Events. +- Optionaler Fallback-STT/TTS, wenn ein Client lokales Speech nicht kann.[^4_1][^4_3][^4_2] + +Ein sinnvolles Nachrichtenmodell wäre: + +- Client → Server: + - `session_start` + - `partial_transcript` + - `final_transcript` + - `barge_in` + - `tts_stop` +- Server → Client: + - `assistant_text_partial` + - `assistant_text_final` + - `memory_hint` + - `fallback_required` + +Das passt gut zu langen Smalltalk-Sessions, weil du Unterbrechungen, Zwischenresultate und Streaming-Antworten sauber behandeln kannst. + +## Empfehlung + +Für dein Projekt würde ich die Reihenfolge so setzen: + + +| Stufe | Ziel | Warum | +| :-- | :-- | :-- | +| 1 | TTS lokal auf Handy | Sofort weniger Kosten und weniger Audio-Latenz. [^4_1][^4_3] | +| 2 | Android nativ mit lokalem STT | Technisch pragmatisch, gute Testbasis. [^4_3][^4_6] | +| 3 | iPhone nativ mit `requiresOnDeviceRecognition` | Sehr sauberer Apple-Pfad, gute UX. [^4_1][^4_9] | +| 4 | Browser-STT nur ergänzend | Nützlich als Fallback, aber nicht deine Hauptplattform. [^4_2][^4_5] | + +Wenn du möglichst effizient vorgehen willst, wäre mein Rat: **Android nativ zuerst**, mit lokalem STT/TTS und deinem bestehenden Python-Server dahinter. Danach iPhone nativ mit Apples On-Device-Speech-Framework.[^4_7][^4_3][^4_9][^4_1] + +Soll ich dir im nächsten Schritt eine **konkrete Referenzarchitektur mit Modulen, WebSocket-Events und Zustandsautomat** für dein Python-Backend plus Android-Client aufschreiben? +[^4_10][^4_11][^4_12][^4_13][^4_14][^4_15] + +
+ +[^4_1]: https://developer.apple.com/documentation/speech/sfspeechrecognitionrequest/requiresondevicerecognition + +[^4_2]: https://developer.mozilla.org/en-US/docs/Web/API/SpeechRecognition/processLocally + +[^4_3]: https://developer.android.com/reference/android/speech/SpeechRecognizer + +[^4_4]: https://developer.mozilla.org/en-US/docs/Web/API/SpeechRecognition + +[^4_5]: https://developer.mozilla.org/ja/docs/Web/API/SpeechRecognition/available_static + +[^4_6]: https://stackoverflow.com/questions/31562229/offline-speech-recognition-in-android + +[^4_7]: https://github.com/alphacep/vosk-android-demo + +[^4_8]: https://devstreaming-cdn.apple.com/videos/wwdc/2019/256p7m9z4yst71ai/256/256_advances_in_speech_recognition.pdf + +[^4_9]: https://developer.apple.com/videos/play/wwdc2019/256/ + +[^4_10]: https://stackoverflow.com/questions/17003620/can-i-make-android-offline-voice-recognition-api-public + +[^4_11]: https://developer.mozilla.org/ja/docs/Web/API/SpeechRecognition/processLocally + +[^4_12]: https://stackoverflow.com/questions/38729105/how-to-make-ios-speech-to-text-persistent/38729106 + +[^4_13]: https://qiita.com/m-naoki-m/items/38eb7199a07e844d3f49 + +[^4_14]: https://www.geeksforgeeks.org/android/offline-speech-to-text-without-any-popup-dialog-in-android/ + +[^4_15]: https://chromium.googlesource.com/external/github.com/web-platform-tests/wpt/+/refs/tags/merge_pr_53592/speech-api/SpeechRecognition-phrases-manual.https.html + diff --git a/Docs/voice-assistant-architecture.md b/Docs/voice-assistant-architecture.md new file mode 100644 index 0000000..15b3e49 --- /dev/null +++ b/Docs/voice-assistant-architecture.md @@ -0,0 +1,293 @@ +# Architektur: Modularer Voice-Assistent + +> Stand: aktueller Implementierungsstand des Gateways. Dieses Dokument beschreibt +> das Konzept, den umgesetzten Stand und die geplanten nächsten Schritte. + +## 1. Ziel & Leitidee + +Ein modularer, **cloud-first, aber hybrid betreibbarer** Sprachassistent für +Senioren — ein „digitaler Vertrauter", erreichbar von zuhause und unterwegs. + +Der Hauptbetrieb läuft in der Cloud / auf einem vHost (Senioren sollen keinen +teuren KI-Rechner zuhause brauchen). Gleichzeitig muss jede Achse frei wählbar +bleiben, je nach Installation und Entwicklungs-/Testbedarf: + +- **Hardware** — Audio-Eingabe und -Ausgabe (lokal, Bluetooth, Handy, Netzwerk) +- **Betrieb** — lokal, Cloud oder hybrid +- **Software** — lokale oder entfernte KI (STT / LLM / TTS), ganz oder teilweise + +Designziele: geringe Latenz, robuste Fallbacks, gute Debugbarkeit, klare Trennung +von **semantischer** und **gesprochener** Antwort, und vor allem **Austauschbarkeit**: +einzelne Module gegen andere tauschen, ohne das Programm umzuschreiben. + +## 2. Architekturprinzip — fünf Ebenen + +1. **Audio Endpoints** – konkrete Quellen/Ziele (lokal, Bluetooth, Handy, WebRTC) +2. **Device Router** – wählt passende Input-/Output-Endpunkte +3. **Speech Pipeline** – STT, Input-Cleaner, Dialog-LLM, Spoken-Response-Adapter, TTS-Normalizer, TTS +4. **Transport Router** – lokale vs. entfernte Ausführung einzelner Module +5. **Orchestrator** – Session, Turn-Taking, Fallbacks, Metrik + +Jede Ebene kommuniziert über wohldefinierte Interfaces (ABCs + Pydantic-Schemas), +nicht über konkrete Bibliotheken. + +## 3. Konfigurations- & Routing-Modell (umgesetzt) + +Das Herzstück der Austauschbarkeit. Jede Achse ist auf mehreren Ebenen +einstellbar; höhere Ebene gewinnt: + +``` +eingebaute Defaults < config/voice-assistant.toml (inkl. aktivem Profil) + < ENV / .env < Nutzer-Prefs < Session-Route < Request +``` + +### 3.1 Zentrale Config + Profile + +Eine geschichtete zentrale Datei (`config/voice-assistant.toml`, Vorlage +`config/voice-assistant.example.toml`), gelesen über die stdlib (`tomllib`). +**Profile** bündeln Betriebsarten und werden per ENV `VA_PROFILE` aktiviert: + +| Profil | STT | LLM | TTS | +|-------------|----------------|--------------------------|------------| +| `local-dev` | faster-whisper | local-openai-compatible | piper | +| `hybrid` | openrouter | local-openai-compatible | openrouter | +| `cloud` | openrouter | openrouter | openrouter | + +Umgesetzt in `app/config.py`: `load_profile_config()` merged `[defaults]` + +`[profiles.]`; `TomlProfileSource` hängt diese Werte als Settings-Quelle +**unter** ENV ein (`settings_customise_sources`). `VA_PROFILE`/`VA_CONFIG_FILE` +werden aus echter Umgebung **oder** `.env` gelesen. + +> **Secrets gehören nie in die Config-Datei** — nur in die Umgebung +> (z. B. `OPENROUTER_API_KEY`). Die TOML-Datei darf versioniert/geteilt werden. + +### 3.2 Einheitliche Route-Auflösung + +`app/dependencies.py` löst pro Aufruf eine `ResolvedRoute` auf +(`resolve_route(user, session_id, overrides)`): Defaults < Nutzer-Prefs < +Session-Route < Request. Die Route umfasst `input_endpoint`, `output_endpoint`, +`stt_provider`, `llm_provider`, `tts_provider`, `language`. Gehört eine Session +einem anderen Nutzer, wird `SessionOwnershipError` (→ 403) ausgelöst. + +### 3.3 Registry-Pattern (Provider austauschbar) + +`STT_REGISTRY` / `LLM_REGISTRY` / `TTS_REGISTRY` bilden `name -> factory(settings)`. +Ein neuer Provider = ein Eintrag, ohne Kern-Code zu ändern. Unbekannter Name → +`UnknownComponentError` → HTTP 422. + +### 3.4 Device Router + +`app/audio/router.py` wählt Endpunkte per `id`- oder `kind`-Match. Ein angefragter, +aber unbekannter Endpunkt wirft `UnknownEndpointError` (→ 422) — **kein** stiller +Default-Fallback. Ohne Wunsch greift der als `default` markierte Endpunkt. Der +Router ist ein Singleton (stabiler Zustand, z. B. `LoopbackOutput`). + +## 4. Datenmodelle & Interfaces + +Schemas in `app/schemas.py`, Interfaces als ABCs in den jeweiligen `base.py`. + +```python +class EndpointCapabilities(BaseModel): + id: str; kind: str + direction: Literal["input", "output"] + sample_rate: int = 16000; channels: int = 1 + latency_class: Literal["low", "medium", "high"] = "medium" + supports_aec: bool = False; supports_barge_in: bool = False + networked: bool = False; bluetooth: bool = False + mobile: bool = False; default: bool = False + +class AudioChunk(BaseModel): + data: bytes; sample_rate: int = 16000; channels: int = 1 + format: str = "wav"; timestamp_ms: int = 0 + +class PipelineTrace(BaseModel): + raw_transcript: str | None = None + cleaned_transcript: str | None = None + semantic_response: str | None = None + spoken_response: str | None = None + tts_ready_text: str | None = None +``` + +Provider-Interfaces: + +```python +class STTProvider(ABC): + async def transcribe(self, audio_bytes: bytes, fmt: str, language: str | None = None) -> str: ... + +class LLMProvider(ABC): + async def complete(self, text: str, session_id: str | None = None) -> str: ... + +class TTSProvider(ABC): + async def synthesize(self, text: str, voice: str | None = None, audio_format: str = "pcm") -> bytes: ... +``` + +Audio-Endpunkt-Interfaces: `AudioInputEndpoint` (`capabilities/open/read_chunk/close`), +`AudioOutputEndpoint` (`capabilities/open/write_chunk/flush/close`) — alle async. + +## 5. Speech Pipeline + +Trennung von **semantischer** und **gesprochener** Antwort ist zentral: eine +inhaltlich gute Antwort ist nicht automatisch gut hörbar. + +Stufen: `raw_transcript → cleaned_transcript → semantic_response → spoken_response → tts_ready_text`. + +- **Input Cleaner** (`pipeline/input_cleaner.py`) – konservative Bereinigung des STT-Texts (Füllwörter, Whitespace). Verändert die Nutzerintention nicht. +- **Dialog-LLM** – semantische Antwort; Persona/Sicherheitsregeln im System-Prompt (`providers/llm/openrouter.py`). +- **Spoken Response Adapter** (`pipeline/spoken_response_adapter.py`) – macht die Antwort sprechbar/seniorengerecht (Markdown raus, Aufzählungspunkte weg, **nummerierte Listen → Ordinalwörter** „1." → „erstens", Uhrzeiten/Verhältnisse erhalten). +- **Sentence Chunker** (`pipeline/sentence_chunker.py`) – inkrementelle Satzsegmentierung für satzweises Streaming-TTS. Trennt bewusst **nicht** nach Ziffer+Punkt („1. Mai"), Einzelbuchstabe+Punkt („z. B.", Initialen) oder bekannten Abkürzungen. +- **TTS Normalizer** (`pipeline/tts_normalizer.py`) – füllt gezielt die Lücken des Phonemizers (espeak-ng in piper), **ohne** zu duplizieren, was der schon gut kann (Kardinal-/Dezimalzahlen bleiben unangetastet): **Ordinalia** (Datum „1. Mai" → „erster Mai", Folgen „1. 2. 3." → „erstens, zweitens …"), **Einheiten nach Zahl** (kg/km/km-h/…), **Abkürzungen** (Dr./z. B./usw.) und ein **YAML-Aussprache-Lexikon** (`config/pronunciation..yaml`, erweitert die eingebauten Defaults; case-insensitive Wort-Umschreibungen wie „strömt" → „ströhmt"). Stufe **provider-abhängig** (`TTS_NORMALIZE_LEVEL=auto|full|light|off`): piper → `full`, Cloud-TTS → `light` (Cloud spricht Zahlen/Abkürzungen selbst gut). Ordinalzahlen 1.–31. in `pipeline/german_numbers.py`. + +Empfehlung: nicht jede Zwischenstufe braucht ein großes LLM — Cleaner, Chunker und +Normalizer überwiegend regelbasiert (so heute umgesetzt), Adapter promptbasiert. +Lexikon pflegen: `python scripts/add_pronunciation.py "wort:aussprache" [--verify]`. + +## 6. Orchestrator + +`app/core/orchestrator.py` verbindet Provider, Pipeline und Output-Endpunkt. + +- `chat_text(text, language, voice, output, history)` → `(trace, audio_bytes)` +- `speak_only(text, voice, language, output)` → `audio_bytes` +- `transcribe_only(audio_bytes, fmt, language, input)` → `trace` + +Bei `/api/chat` mit `session_id` lädt die API-Schicht den letzten Gesprächsverlauf +aus dem Store (`messages`-Tabelle, letzte `HISTORY_MAX_MESSAGES`), gibt ihn als +`history` an das LLM und speichert nach der Antwort User- und Assistant-Turn. +Ohne `session_id` bleibt der Aufruf zustandslos. + +Das synthetisierte Audio wird **zusätzlich** durch den gewählten Output-Endpunkt +geschrieben (`open → write_chunk → flush → close`) und **gleichzeitig** als +HTTP-Stream zurückgegeben (additiv). Bei lokalen Geräten ist `write_chunk` heute +ein No-op; `LoopbackOutput` sammelt die Chunks (testbar ohne Hardware). + +## 7. FastAPI-Endpunkte (umgesetzt) + +| Methode & Pfad | Zweck | +|--------------------------------------|-------| +| `GET /health` | Liveness | +| `POST /api/chat` | Text rein → Audio raus (`?debug=true` → JSON-Trace) | +| `POST /api/speak` | Text rein → TTS-Audio raus | +| `POST /api/transcribe` | Audio-Upload → Transkript | +| `GET /api/devices` | verfügbare Audio-Endpunkte + Capabilities | +| `POST /api/sessions/{id}/route` | bevorzugte Geräte/Provider/Sprache je Session | +| `GET /api/config` | aktives Profil + aufgelöste Route (ohne Secrets) | +| `GET /api/me` · `PUT /api/me/prefs` | aktueller Nutzer + dauerhafte Präferenzen | +| `GET/POST/DELETE /api/me/memories` | Langzeit-Erinnerungen des Nutzers | +| `GET /api/metrics` | Metriken (JSON / Prometheus) | +| `WS /ws/chat` | Echtzeit-Chat (Text rein, Streaming-Events) | +| `WS /ws/voice` | Echtzeit-Sprache (Audio rein → STT → Antwort) | + +Endpunkt-/Provider-Auswahl ist über **Request-Body** (pro Aufruf), **Session** +(`?session_id=…`) und **Defaults/Profil** steuerbar. Verwendete Route erscheint als +`X-*`-Header bzw. im `?debug`-JSON. + +### 7.1 Admin-API (alle hinter `require_admin`, Audit-geloggt) + +Trägt das Admin-Web-Panel (5 Bereiche + Übersicht-Dashboard). Schreibende Aktionen +werden ins Audit-Log geschrieben. + +| Methode & Pfad | Zweck | +|--------------------------------------------------|-------| +| `POST /api/admin/users` | Nutzer anlegen → Token einmalig | +| `GET /api/admin/users` | Nutzerliste | +| `PUT/DELETE /api/admin/users/{id}` | Nutzer ändern/löschen | +| `POST /api/admin/users/{id}/token` | Token neu ausstellen | +| `GET/POST/DELETE /api/admin/users/{id}/memories` | Erinnerungen je Nutzer | +| `GET /api/admin/users/{id}/sessions`·`/usage` | Sessions / Kontingent-Nutzung | +| `GET /api/admin/sessions/{id}/messages` | Gesprächsverlauf einsehen | +| `GET/PUT/DELETE /api/admin/config[/{key}]` | Live-Config lesen/setzen (z. B. `top_p`) | +| `GET /api/admin/llm/status` | LLM-/GPU-Status (read-only) | +| `POST /api/admin/llm/backend` | Backend wechseln (Ollama ↔ llama.cpp) | +| `POST /api/admin/gateway/restart` | Gateway aus dem Panel neu starten | +| `GET/POST/DELETE /api/admin/pronunciation/{lang}`| Aussprache-Lexika pflegen | +| `GET /api/admin/usage`·`/emergency-events` | Gesamt-Nutzung / Notfall-Ereignisse | +| `GET /api/admin/db-export` | SQLite-Export | +| `WS /api/admin/log` | Live-Log-Stream | + +Config-Änderungen sind als **Live** (sofort wirksam) oder **Restart** (Neustart nötig) +gekennzeichnet; der Backend-Wechsel und Live-Parameter wie `top_p` laufen ohne Neustart. + +## 8. Stand der Implementierung + +**Umgesetzt:** FastAPI-Gateway, alle o. g. REST-Endpunkte; OpenRouter-Adapter für +STT (JSON/base64), LLM und TTS; lokaler OpenAI-kompatibler LLM-Adapter; regelbasierte +Pipeline; geschichtete Config + Profile; Registry + einheitliche Route-Auflösung; +Device Router (strikt, Singleton); Output-Lifecycle; **Authentifizierung +(Bearer-Token) + persistenter SQLite-Store für Nutzer/Sessions + Mandanten-Trennung ++ dauerhafte Nutzer-Präferenzen**; **Gesprächsgedächtnis pro Session (Verlauf im +Store, fließt ins LLM)**; **Langzeit-Erinnerungen pro Nutzer (als LLM-Kontext)**; +**WebSocket-Streaming-Chat (`/ws/chat`) inkl. Token-Level-LLM-Streaming (SSE, +`stream:true`) und satzweisem Audio-Streaming (chunked TTS, `audio_stream:true`)**; **Sprach-Eingang +über WebSocket (`/ws/voice`: Audio rein → STT → Antwort-Pipeline) mit VAD-Aeusserungs- +erkennung und Barge-in (`interrupt`)**; **Resilienz (Fallback-Ketten je Modul, +In-Memory-Metriken `/api/metrics`), Tageskontingent pro Nutzer und heuristische +Notfall-Eskalation**; **Admin-Web-Panel (5 Bereiche + Übersicht-Dashboard) über die +Admin-API (§7.1) inkl. Backend-Wechsel, Gateway-Neustart, Live-Config-Parameter, +LLM-/GPU-Status, Aussprache-Lexika, Live-Log und Audit-Logging schreibender +Aktionen**; automatisierte Tests. + +**Web-/Mobil-Frontend:** schlankes Web-Interface unter `/` (Tailwind, kein Build), +mit **Geräte-TTS** (Browser-SpeechSynthesis auf Mobilgeräten; fällt auf Server-Audio +zurück, wenn keine lokalen Stimmen vorhanden) und **Ton-Presets** (Schnell → piper, +Hohe Qualität → chatterbox, Cloud → openrouter). Auth-Gate (Bearer-Token). + +**Echtes lokales STT & TTS:** `faster-whisper` (optionale Dependency `.[local]`, +CTranslate2) transkribiert real; `piper` (in-process via piper-Python-API, Stimmmodell +einmal geladen + gecacht, In-Process-Resampling auf 24000 Hz) synthetisiert real. Lokale +Modelle werden beim Serverstart vorgeladen (Warm-up). Damit ist sowohl ein Hybrid „STT+LLM lokal, TTS +remote" als auch eine **voll-lokale** Konstellation möglich (live verifiziert). + +**Platzhalter (Gerüst):** Audio-Endpunkte (`local-default`, `bluetooth`, +`mobile-ws`, `mobile-webrtc`) liefern leere Chunks — nur Auswahl/Lifecycle sind +verdrahtet, kein echtes Hardware-I/O. `transport_router.py` (Ebene 4) existiert, ist aber noch nicht aktiv +(lokal/remote trägt vorerst der Provider-Name). + +## 9. Roadmap / bewusste nächste Schritte + +Reihenfolge der Weiterentwicklung: + +1. **(erledigt)** Konfig- & Routing-Fundament: Profile, Device Router, Registry, Pro-Request-Override. +2. **(erledigt)** Cloud-Fundament: Bearer-Token-Auth, Mehrbenutzer, persistenter SQLite-Store, Mandanten-Trennung, dauerhafte Nutzer-Präferenzen. Offen: Skalierung auf gemeinsamen Store (Postgres/Redis) für mehrere Instanzen. +3. **(erledigt)** Konversationsgedächtnis: Kurzzeit-Gesprächsverlauf pro Session + Langzeit-Erinnerungen pro Nutzer (manuell **und automatisch** gepflegt, als LLM-Kontext). **Automatische Extraktion** (`app/core/memory_extractor.py`): nach je N Turns destilliert ein LLM dauerhafte Fakten/Vorlieben aus dem Verlauf und legt sie dedupliziert als Erinnerungen ab — best-effort, nicht-blockierend (Hintergrund-Task), konfigurierbar (`MEMORY_EXTRACTION_*`). Offen: periodische Verdichtung/Zusammenfassung wachsender Erinnerungslisten. +4. **(weitgehend erledigt)** Echtzeit: WebSocket-Streaming-Chat (`/ws/chat`), **Token-Level-LLM-Streaming (SSE, `stream:true`)**, **Audio-Streaming (chunked TTS satzweise, `audio_stream:true`)**, **Audio-Eingang (`/ws/voice`)**, **Barge-in/Turn-Manager (`interrupt` bricht laufende Antwort ab)** und **VAD-Aeusserungserkennung (energie-basiert, opt-in)** sind umgesetzt. Offen: **echte partielle Live-Transkripte (Streaming-STT-Dienst, wortweise)** und **WebRTC (aiortc)** — beide brauchen schwere Abhaengigkeiten/Dienste. Heute laeuft STT pro Aeusserung. +5. **(weitgehend erledigt)** Resilienz: Fallback-Ketten je Modul (`*_FALLBACK`, Provider faellt aus → naechster) und In-Memory-Metriken (`/api/metrics`: Request/Latenz, Pipeline-Stufen, Fallback/Fehler; JSON + Prometheus). Offen: verteiltes Tracing, Alerting. +6. **(weitgehend erledigt)** Betrieb: Tageskontingent pro Nutzer (`DAILY_REQUEST_LIMIT`, 429) und **zweistufige** Notfall-Eskalation: (1) schnelle Stichwort-Heuristik im Hot-Path (0 Latenz) + (2) **LLM-Klassifikation** (`app/safety/llm_classifier.py`) als Hintergrund-Task, der laeuft, wenn die Heuristik nichts fand — faengt verpasste Formulierungen (z. B. metaphorisch geaeusserte Suizidalitaet, Schlaganfall-Symptome ohne Stichwort) mit Konfidenz-Schwelle, ohne die Antwortlatenz zu erhoehen. Eskalation jeweils -> Log + Metrik (`source`: keyword/llm) + optionaler Webhook + Event. Offen: Telefon-/Angehoerigen-Integration, Abrechnung. +7. **(weitgehend erledigt)** Lokale Provider: STT via `faster-whisper` (`.[local]`) und **TTS via `piper`** (lokales Neural-TTS, **in-process** mit gecachtem Stimmmodell, In-Process-Resampling auf 24000 Hz; lokale Modelle werden beim Start vorgeladen) sind echt — eine **voll-lokale Konstellation** (STT+LLM+TTS lokal, keine API-Kosten, max. Datenschutz) ist damit möglich. **`chatterbox`-TTS** (Resemble AI, eigener GPU-HTTP-Dienst, hohe Qualität + Voice-Cloning) ist als **wählbarer** Provider angebunden (job-basiert: `/speak`→`/status`→`/audio`, `no_playback`-Modus liefert nur Bytes). Offen: Streaming-Synthese für niedrigere Latenz. +8. **TransportRouter** als eigene lokal/remote-Achse aktivieren; echte Geräte-Endpunkte (PipeWire/Bluetooth) — heute OS-Ebene. + +**Datenschutz (querschnittlich, ab sofort mitdenken):** Senioren-Sprachdaten sind +hochsensibel (oft gesundheitsbezogen → DSGVO Art. 9). EU-Datenresidenz, +Verschlüsselung at-rest/in-transit, Löschkonzept, Einwilligung — „privacy by design". + +## 10. Verzeichnisstruktur (Ist) + +```text +my_voice_assistant_v3/ +├── app/ +│ ├── main.py # FastAPI-App + Router-Registrierung +│ ├── config.py # Settings, TOML-Profile, Präzedenz +│ ├── dependencies.py # Registries, ResolvedRoute, resolve_route, Store-/Router-Singleton +│ ├── store.py # Persistenz: Store-Interface + SQLiteStore (Nutzer/Sessions/Verlauf) +│ ├── auth.py # Bearer-Token-Auth (require_user) + Admin-Schutz +│ ├── audit.py # Audit-Log schreibender Admin-Aktionen +│ ├── admin_llm.py # LLM-/GPU-Status + Backend-Wechsel (Ollama ↔ llama.cpp) +│ ├── runtime_config.py # Live-Config (zur Laufzeit setzbare Parameter) +│ ├── metrics.py # In-Memory-Metriken (Counter/Timer, JSON + Prometheus) +│ ├── quota.py # Tageskontingent pro Nutzer (Kostenkontrolle) +│ ├── safety/ # emergency.py (Heuristik) + llm_classifier.py (Notfall-Klassifikation) +│ ├── errors.py # RoutingError -> HTTP 422 +│ ├── schemas.py # Pydantic-Modelle +│ ├── api/ # health, chat, speak, transcribe, devices, sessions, config, admin, me, ws +│ ├── core/ # orchestrator, memory_extractor (Auto-Erinnerungen), warmup (Modell-Vorladen) +│ ├── audio/ # router, transport_router, vad, endpoints/input|output/* +│ ├── pipeline/ # input_cleaner, spoken_response_adapter, tts_normalizer, sentence_chunker, german_numbers +│ ├── providers/ # stt/ llm/ tts/ (openrouter + lokale + chatterbox) + fallback.py +│ ├── utils/ # Hilfsfunktionen +│ └── web/ # Web-/Admin-Frontend (Tailwind, kein Build) +├── config/ # voice-assistant.example.toml (+ lokale .toml, gitignored) +├── data/ # SQLite-DB (gitignored) +├── deploy/ # systemd unit + env-Beispiel +├── tests/ # config-profile, routing, audio-router, e2e +├── Docs/ # dieses Dokument +├── Dockerfile, docker-compose.yml, Makefile, pyproject.toml +└── README.md, BEDIENUNGSANLEITUNG.md +``` diff --git a/LICENSE b/LICENSE deleted file mode 100644 index f692e41..0000000 --- a/LICENSE +++ /dev/null @@ -1,18 +0,0 @@ -MIT License - -Copyright (c) 2026 dschlueter - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and -associated documentation files (the "Software"), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the -following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial -portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT -LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO -EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER -IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE -USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..ea09a2c --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,28 @@ +# Proprietäre Lizenz – Alle Rechte vorbehalten + +Copyright (c) 2026 Dieter Schlüter. Alle Rechte vorbehalten. + +Diese Software und die zugehörige Dokumentation (das „Werk") sind urheberrechtlich +geschützt und vertraulich. Sie sind **kein** Open-Source- oder freies Werk. + +Ohne vorherige ausdrückliche schriftliche Genehmigung des Rechteinhabers ist es +untersagt, das Werk ganz oder teilweise zu: + +- nutzen, ausführen oder bereitzustellen (außer durch den Rechteinhaber oder + ausdrücklich autorisierte Personen), +- kopieren, vervielfältigen oder speichern (außer technisch notwendige Kopien für + eine autorisierte Nutzung), +- verändern, übersetzen, bearbeiten oder davon abgeleitete Werke zu erstellen, +- weiterzugeben, zu veröffentlichen, zu verbreiten, zu vermieten, zu verkaufen, + zu unterlizenzieren oder anderweitig Dritten zugänglich zu machen, +- zu dekompilieren, zu disassemblieren oder zurückzuentwickeln, soweit nicht + zwingendes Recht dies ausdrücklich erlaubt. + +Es werden keine Lizenz- oder sonstigen Rechte stillschweigend oder anderweitig +eingeräumt. Alle nicht ausdrücklich gewährten Rechte verbleiben beim Rechteinhaber. + +DAS WERK WIRD „WIE BESEHEN" OHNE JEGLICHE GEWÄHRLEISTUNG ODER GARANTIE +BEREITGESTELLT. DER RECHTEINHABER HAFTET NICHT FÜR SCHÄDEN, DIE AUS DER NUTZUNG +ODER UNMÖGLICHKEIT DER NUTZUNG DES WERKS ENTSTEHEN, SOWEIT GESETZLICH ZULÄSSIG. + +Anfragen zur Lizenzierung oder Nutzung: Dieter Schlüter . diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..4d6a25f --- /dev/null +++ b/Makefile @@ -0,0 +1,75 @@ +ifneq (,$(wildcard ./.env)) +include .env +export +endif + +PORT ?= 8080 +HOST ?= 0.0.0.0 + +CONTAINER_NAME ?= va_llm + +.PHONY: ensure-env install run start stop restart test smoke docker-build llm-up llm-down llm-status llm-ollama llm-llamacpp + +ensure-env: + @if [ ! -f .env ] && [ -f .env.example ]; then \ + cp .env.example .env; \ + echo "Created .env from .env.example"; \ + fi + +install: ensure-env + python3 -m venv .venv + . .venv/bin/activate && pip install -U pip && pip install -e .[test] + +run: ensure-env + . .venv/bin/activate && uvicorn app.main:app --host $(HOST) --port $(PORT) --reload + +test: ensure-env + . .venv/bin/activate && pytest tests/ + +# Echter End-to-End-Test gegen OpenRouter (macht Netz-Aufrufe, kostet wenig). +smoke: ensure-env + . .venv/bin/activate && python scripts/smoke_e2e.py + +docker-build: + docker build -t voice-assistant-gateway . + +# --- Komplett-Befehle (Start / Stop / Restart) -------------------------------- + +# Stoppt alle Voice-Assistant-Komponenten: Gateway (Vordergrund + systemd) und +# llama.cpp-Container. Ollama (systemd) separat: sudo systemctl stop ollama +stop: + -pkill -f "uvicorn app.main:app" 2>/dev/null; true + -systemctl --user stop voice-assistant 2>/dev/null; true + -docker rm -f "$(CONTAINER_NAME)" 2>/dev/null; true + @echo "[*] Voice-Assistant gestoppt (Gateway + llama.cpp)." + +# Startet llama.cpp-LLM-Server und danach das Gateway (Profil hybrid/local-dev). +# Fuer Profil cloud (kein lokales LLM): einfach 'make run'. +start: ensure-env + $(MAKE) llm-up + $(MAKE) run + +# Faehrt alles herunter und startet neu (llama.cpp + Gateway). +restart: stop + $(MAKE) start + +# --- Lokales LLM (llama.cpp-Server, zentrale unzensierte KI) ----------------- +# Konfig per ENV ueberschreibbar, z. B.: GPU_DEVICE=2 HOST_PORT=8101 make llm-up +llm-up: + bash scripts/llm-server/start-llm-server.sh + +llm-down: + bash scripts/llm-server/stop-llm-server.sh + +llm-status: + bash scripts/llm-server/status-llm-server.sh + +# --- LLM-Backend wechseln (Ollama <-> llama.cpp) ----------------------------- +# Passt die LOCAL_LLM_*-Zeilen in .env an, gibt den GPU-Speicher des anderen +# Backends frei, startet das gewuenschte und startet das Gateway (falls Dienst) neu. +# Modell ueberschreibbar: OLLAMA_MODEL=qwen2.5:latest make llm-ollama +llm-ollama: + bash scripts/llm-server/switch-llm.sh ollama + +llm-llamacpp: + bash scripts/llm-server/switch-llm.sh llamacpp diff --git a/README.md b/README.md index bedcd7f..d28ed79 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,210 @@ -# my_voice_assistant_v3 +# Voice Assistant Gateway -Voice Assistant Gateway --- Modulares FastAPI-Gateway für einen **seniorengerechten Sprachassistenten** — -cloud-first, aber hybrid/lokal betreibbar, mit austauschbaren STT-/LLM-/TTS-Providern. -Jede Achse — Hardware, Betrieb, Software — ist frei konfigurierbar, ohne Code zu ändern. \ No newline at end of file +Modulares FastAPI-Gateway für einen **seniorengerechten Sprachassistenten** — +cloud-first, aber hybrid/lokal betreibbar, mit austauschbaren STT-/LLM-/TTS-Providern. +Jede Achse — Hardware, Betrieb, Software — ist frei konfigurierbar, ohne Code zu ändern. + +--- + +## Features + +- **Sprach-Pipeline:** STT → Input-Cleaner → LLM → Spoken-Adapter → TTS-Normalizer → TTS +- **Provider austauschbar** über Registry: OpenRouter (Cloud), faster-whisper (STT lokal), piper (TTS lokal, schnell), chatterbox (TTS lokal, hohe Qualität + Voice-Cloning) +- **Geschichtete Konfiguration** mit Profilen (`cloud` / `hybrid` / `local-dev`) +- **Routing auf jeder Ebene:** Global → Profil → Nutzer → Session → Request +- **Authentifizierung** (Bearer-Token) + persistente Nutzer/Sessions (SQLite) +- **Resilienz:** Fallback-Ketten je Modul + In-Memory-Metriken (JSON + Prometheus) +- **Gesprächsgedächtnis** pro Session (Verlauf) + Langzeit-Erinnerungen pro Nutzer (manuell + automatisch) +- **WebSocket-Streaming:** Token-Streaming (LLM), Audio-Streaming (satzweises TTS), Sprach-Eingang, VAD, Barge-in +- **Notfall-Eskalation:** zweistufig (Stichwörter + LLM-Klassifikation im Hintergrund) +- **Web-Interface** unter `/` (Tailwind, kein Build, mobiltauglich) mit Geräte-TTS (Browser-Sprachausgabe) und Ton-Presets (Schnell/Hohe Qualität/Cloud) +- **Admin-Panel** (5 Bereiche + Übersicht-Dashboard): Nutzer/Sessions, LLM-/GPU-Status, Backend-Wechsel + Gateway-Neustart, Live-Config, Aussprache-Lexika, Live-Log, Audit-Logging +- **Keine Secrets im Code** — API-Keys nur über die Umgebung + +--- + +## Schnellstart (30 Sekunden) + +```bash +python3 -m venv .venv && source .venv/bin/activate +pip install -U pip && pip install -e .[test] +cp config/voice-assistant.example.toml config/voice-assistant.toml +export OPENROUTER_API_KEY=sk-or-v1-... # für Cloud/Hybrid; bei local-dev nicht nötig +make run +``` + +Fehlt `.env`, wird sie aus `.env.example` erzeugt. Gateway läuft auf `http://localhost:8080` +(oder dem in `.env` gesetzten `PORT`). + +```bash +curl http://localhost:8080/health # {"status":"ok"} +curl http://localhost:8080/api/config # aktives Profil + aufgelöste Provider +``` + +Sprechen → Antwort hören (CLI-Loop): +```bash +python scripts/voice_loop.py --session mein-gespraech +``` + +Web-Interface: Browser → `http://localhost:8080/` + +--- + +## Starten — alle Szenarien + +### Profil `cloud` (nur Gateway, alles via OpenRouter) + +```bash +make run # Gateway auf PORT aus .env (Standard: 8080) +VA_PROFILE=cloud make run # Profil explizit setzen (überschreibt .env) +PORT=8003 make run # anderen Port für diesen Start +LOG_LEVEL=debug make run # ausführlichere Logs +``` + +### Profil `hybrid` / `local-dev` mit llama.cpp (Docker + GPU) + +```bash +# 1) LLM-Server starten +make llm-up # Default: GPU 1, Port 8001, Modell qwen3-35B-Uncensored +make llm-status # warten bis „Modell bereit" + HTTP 200 erscheint +make llm-down # stoppen + +# Mit anderen Parametern (via ENV): +GPU_DEVICE=0 make llm-up +HOST_PORT=8101 GPU_DEVICE=2 make llm-up +GPU_DEVICE=0 MODEL_REL_PATH="models/qwen3/anderes-modell.gguf" make llm-up + +# Direkt (ohne make): +bash scripts/llm-server/start-llm-server.sh +GPU_DEVICE=0 bash scripts/llm-server/start-llm-server.sh + +# 2) Gateway starten +VA_PROFILE=hybrid make run # STT/TTS cloud, LLM lokal +VA_PROFILE=local-dev make run # alles lokal (STT/TTS in-process) +``` + +ENV-Optionen für `make llm-up` / `start-llm-server.sh`: + +| Variable | Default | Bedeutung | +|----------|---------|-----------| +| `GPU_DEVICE` | `1` | GPU-Index (0-basiert) | +| `HOST_PORT` | `8001` | Host-Port des LLM-Servers | +| `MODEL_REL_PATH` | `models/qwen3/Qwen3.6-35B-...Q4_K_M.gguf` | Modellpfad relativ zu `HF_HOME` | +| `HF_HOME` | `~/nvme2n1p7_home/huggingface` | Modell-Basisverzeichnis | +| `MODEL_ALIAS` | `va_llm` | OpenAI-API-Modellname (→ `LOCAL_LLM_MODEL` in `.env`) | +| `CONTAINER_NAME` | `va_llm` | Docker-Containername | + +> Wird `HOST_PORT` oder `MODEL_ALIAS` geändert, müssen `LOCAL_LLM_BASE_URL` und +> `LOCAL_LLM_MODEL` in `.env` entsprechend angepasst werden. + +### Profil `hybrid` / `local-dev` mit Ollama + +```bash +# 1) Ollama-Dienst starten +sudo systemctl start ollama # empfohlen (bei systemd-Installation) +# oder im Vordergrund: +ollama serve + +# 2) Modell herunterladen (einmalig, ~20 GB): +ollama pull qwen3:30b-a3b # Thinking deaktiviert (empfohlen) +# kleinere Alternative (CPU-tauglich, ~5 GB): +ollama pull qwen3:8b + +# Status prüfen: +ollama list # installierte Modelle +ollama ps # gerade aktive Modelle (mit VRAM-Verbrauch) + +# 3) .env anpassen (einmalig): +# 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' + +# 4) Gateway starten: +VA_PROFILE=hybrid make run +VA_PROFILE=local-dev make run +``` + +### Stoppen + +```bash +make stop # Gateway (alle Varianten) + llama.cpp-Container +# Einzeln: +Strg + C # Gateway im Vordergrund +pkill -f "uvicorn app.main:app" # Gateway im Hintergrund +systemctl --user stop voice-assistant # Gateway als systemd-Dienst +make llm-down # nur llama.cpp +sudo systemctl stop ollama # nur Ollama +``` + +### Neu starten (alles) + +```bash +make restart # make stop + make start (llama.cpp + Gateway) +# Nur Gateway neu (llama.cpp läuft weiter): +systemctl --user restart voice-assistant +``` + +### Backend wechseln (llama.cpp ↔ Ollama) + +Beide Server können nicht gleichzeitig laufen (geteilter GPU-Speicher). + +```bash +# → zu llama.cpp wechseln (Ollama vorher killen): +sudo systemctl stop ollama && pkill -f "ollama serve" 2>/dev/null || true +make llm-up && make llm-status + +# → zu Ollama wechseln (llama.cpp vorher killen): +make llm-down # oder: docker rm -f va_llm +sudo systemctl start ollama +``` + +### Alle `make`-Targets im Überblick + +```bash +make install # venv anlegen + Abhängigkeiten installieren +make run # Gateway starten (uvicorn --reload, Port aus .env) +make start # llama.cpp + Gateway starten (hybrid/local-dev) +make stop # alles stoppen (Gateway + llama.cpp) +make restart # make stop + make start +make test # Pytest-Suite (offline, kostenlos) +make smoke # Live-End-to-End-Test gegen OpenRouter (geringe Kosten) +make llm-up # llama.cpp-Docker-Container starten +make llm-down # llama.cpp-Container stoppen +make llm-status # Container- und HTTP-Status prüfen +``` + +--- + +## Dokumentation + +| Dokument | Zielgruppe | Inhalt | +|----------|-----------|--------| +| **[BEDIENUNGSANLEITUNG.md](BEDIENUNGSANLEITUNG.md)** | alle | Installation, Betriebsprofile, Bedienung, Konfiguration, Admin, Deployment, Fehlerbehebung, Referenz | +| **[Docs/voice-assistant-architecture.md](Docs/voice-assistant-architecture.md)** | Entwickler | Architekturprinzipien, Interfaces, Pipeline, Roadmap, Verzeichnisstruktur | +| **[deploy/README.md](deploy/README.md)** | Admin | Remote-Betrieb: nginx, YunoHost-SSO, systemd, Firewall, Chatterbox | + +**Einstiegspunkte je Zielgruppe:** + +- 👤 **Endnutzer** → BEDIENUNGSANLEITUNG § 5 (Bedienung) +- 🔧 **Admin/Betreiber** → BEDIENUNGSANLEITUNG § 2–4 (Installation + Profile), § 7–11 (Betrieb) +- 💻 **Entwickler** → BEDIENUNGSANLEITUNG § 2 + Architektur-Dokument + +--- + +## Projektstruktur (Kurzform) + +```text +app/ Gateway: config, api/, core/, audio/, pipeline/, providers/, web/ (Frontend + Admin) +config/ voice-assistant.example.toml (lokale .toml ist gitignored) +deploy/ systemd-Unit, nginx-Vorlage, env-Beispiel +scripts/ voice_loop.py, chat_client.py, smoke_e2e.py, add_pronunciation.py +scripts/llm-server/ start/stop/status-llm-server.sh (llama.cpp-Docker) +tests/ Pytest-Suite (offline + smoke) +Docs/ Architektur-Dokument +``` + +--- + +## Lizenz + +**Proprietär — alle Rechte vorbehalten.** Siehe [LICENSE.md](LICENSE.md). diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/admin_llm.py b/app/admin_llm.py new file mode 100644 index 0000000..8cb21cc --- /dev/null +++ b/app/admin_llm.py @@ -0,0 +1,189 @@ +"""Read-only Statusabfragen rund um das lokale LLM-Backend (fuer das Admin-Panel). + +Alles nur lesend, ohne sudo: `docker ps`, `ollama ps`, `nvidia-smi`, `systemctl --user`. +Fehlende Tools oder Fehler fuehren zu sicheren Defaults (None/[]), nie zu Exceptions. +""" + +from __future__ import annotations + +import asyncio +import os +import re +import shutil +import subprocess +from pathlib import Path + +from app.config import settings + +ALLOWED_BACKENDS = {"ollama", "llamacpp"} +_SCRIPT = Path(__file__).resolve().parent.parent / "scripts" / "llm-server" / "switch-llm.sh" +# Erlaubtes Format fuer Ollama-Modellnamen (zusaetzlich zur Pruefung gegen 'ollama list'). +_MODEL_RE = re.compile(r"^[A-Za-z0-9._:/-]{1,100}$") + + +async def _run(cmd: list[str], timeout: float = 6.0) -> str | None: + """Fuehrt ein Kommando aus (shell=False) und liefert stdout, oder None bei Fehler.""" + if not shutil.which(cmd[0]): + return None + try: + proc = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.DEVNULL, + env={**os.environ, "XDG_RUNTIME_DIR": os.environ.get( + "XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}")}, + ) + out, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout) + if proc.returncode != 0: + return None + return out.decode("utf-8", "replace") + except (asyncio.TimeoutError, OSError): + return None + + +def _detect_backend(base_url: str) -> str: + if ":11434" in base_url: + return "ollama" + if ":8001" in base_url: + return "llamacpp" + return "unknown" + + +async def _llamacpp_running(container: str = "va_llm") -> bool: + out = await _run(["docker", "ps", "--filter", f"name=^{container}$", "--format", "{{.Names}}"]) + return bool(out and container in out) + + +async def _ollama_loaded() -> tuple[bool, list[dict]]: + """(dienst_erreichbar, [geladene Modelle]).""" + out = await _run(["ollama", "ps"]) + if out is None: + return False, [] + models: list[dict] = [] + lines = [ln for ln in out.splitlines() if ln.strip()] + for ln in lines[1:]: # Kopfzeile ueberspringen + # Spalten sind durch 2+ Leerzeichen getrennt: NAME ID SIZE PROCESSOR CONTEXT UNTIL + import re + cols = re.split(r"\s{2,}", ln.strip()) + if cols: + models.append({ + "name": cols[0], + "size": cols[2] if len(cols) > 2 else "", + "processor": cols[3] if len(cols) > 3 else "", + }) + return True, models + + +async def _gpus() -> list[dict]: + out = await _run([ + "nvidia-smi", + "--query-gpu=index,memory.used,memory.total", + "--format=csv,noheader,nounits", + ]) + if out is None: + return [] + gpus: list[dict] = [] + for ln in out.splitlines(): + parts = [p.strip() for p in ln.split(",")] + if len(parts) == 3 and parts[0].isdigit(): + used, total = int(parts[1]), int(parts[2]) + gpus.append({ + "index": int(parts[0]), + "used_mib": used, + "total_mib": total, + "percent": round(used / total * 100) if total else 0, + }) + return gpus + + +async def _gateway_service_active() -> bool: + out = await _run(["systemctl", "--user", "is-active", "voice-assistant.service"], timeout=4.0) + return bool(out and out.strip() == "active") + + +async def llm_status() -> dict: + """Aggregierter, read-only LLM-/System-Status fuer das Admin-Panel.""" + base_url = settings.local_llm_base_url + backend = _detect_backend(base_url) + llamacpp, (ollama_reachable, ollama_models), gpus, gw = await asyncio.gather( + _llamacpp_running(), + _ollama_loaded(), + _gpus(), + _gateway_service_active(), + ) + return { + "backend": backend, + "model": settings.local_llm_model, + "base_url": base_url, + "llamacpp_running": llamacpp, + "ollama_reachable": ollama_reachable, + "ollama_loaded": ollama_models, + "gpus": gpus, + "gateway_service_active": gw, + } + + +# ── Schreibende Steuerung (Backend-Wechsel / Gateway-Neustart) ─────────────── + +class LlmControlError(ValueError): + """Validierungs-/Steuerfehler -> wird vom Endpoint als 400/422 gemeldet.""" + + +async def available_ollama_models() -> list[str]: + """Modellnamen aus `ollama list` (erste Spalte), oder [].""" + out = await _run(["ollama", "list"]) + if out is None: + return [] + names: list[str] = [] + for ln in out.splitlines()[1:]: + ln = ln.strip() + if ln: + names.append(ln.split()[0]) + return names + + +async def switch_backend(backend: str, model: str | None = None) -> dict: + """Validiert streng und startet den Backend-Wechsel als losgelösten Prozess. + + Allowlist: backend ∈ {ollama, llamacpp}. Bei ollama muss `model` (falls gesetzt) + exakt in `ollama list` vorkommen. Nie freie Strings an die Shell — Aufruf mit + fester Argumentliste (shell=False); das Modell geht ausschließlich als Env-Var. + Der Wechsel läuft detached weiter (er startet ggf. das Gateway neu). + """ + if backend not in ALLOWED_BACKENDS: + raise LlmControlError(f"Unbekanntes Backend: {backend!r}") + if not _SCRIPT.exists(): + raise LlmControlError("switch-llm.sh nicht gefunden") + + env = {**os.environ} + if backend == "ollama" and model: + if not _MODEL_RE.match(model): + raise LlmControlError("Ungültiger Modellname") + available = await available_ollama_models() + if available and model not in available: + raise LlmControlError(f"Modell nicht in 'ollama list': {model!r}") + env["OLLAMA_MODEL"] = model + + # Detached starten: der Wechsel kann das Gateway neu starten -> wir würden uns + # sonst selbst killen, bevor die HTTP-Antwort raus ist. + subprocess.Popen( + ["bash", str(_SCRIPT), backend], + env=env, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + return {"status": "switching", "backend": backend, "model": model} + + +def restart_gateway_detached() -> dict: + """Startet das Gateway als systemd-User-Dienst neu (losgelöst, Self-Restart-sicher).""" + xdg = os.environ.get("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}") + subprocess.Popen( + ["bash", "-c", + f"sleep 1; XDG_RUNTIME_DIR={xdg} systemctl --user restart voice-assistant.service"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + return {"status": "restarting"} diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/admin.py b/app/api/admin.py new file mode 100644 index 0000000..2aa500e --- /dev/null +++ b/app/api/admin.py @@ -0,0 +1,404 @@ +import asyncio +from pathlib import Path + +import yaml +from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSocketDisconnect +from fastapi.responses import FileResponse +from pydantic import BaseModel + +from app.admin_llm import ( + LlmControlError, + llm_status, + restart_gateway_detached, + switch_backend, +) +from app.audit import log_admin_action +from app.auth import is_admin_user, require_admin, require_admin_or_user +from app.config import settings +from app.dependencies import get_store +from app.runtime_config import RUNTIME_SETTABLE, invalidate_cache, runtime_settings +from app.schemas import MemoryCreate, MemoryOut, UserCreate, UserCreated, UserUpdate + +router = APIRouter() + +_CONFIG_DIR = Path(__file__).resolve().parents[2] / "config" +_ALLOWED_SECTIONS = {"abbreviations", "units", "terms"} +_ALLOWED_LANGS = {"de", "en", "fr", "es", "it", "nl", "ru", "zh"} + + +class PronunciationEntry(BaseModel): + section: str + key: str + value: str + + +@router.get("/admin/request-headers") +async def request_headers(request: Request, key: str | None = None): + """Discovery: zeigt die eingehenden HTTP-Header + Quell-IP. + + Hilft, hinter dem Reverse-Proxy/SSO den richtigen Identitaets-Header + (TRUSTED_AUTH_HEADER) festzustellen. + + Henne-Ei: Beim Einrichten kennt das Gateway den SSO-Admin noch nicht. Daher + ist der Endpoint auch per Admin-Key aufrufbar - als Query (`?key=...`, im Browser + durchs SSO bequem) oder X-Admin-Key-Header. Nach dem Setup wieder meiden bzw. + den Key rotieren (er landet sonst in Proxy-Logs). + """ + expected = settings.admin_api_key.strip() + provided = key or request.headers.get("x-admin-key") + ok = bool(expected) and provided is not None and provided.strip() == expected + if not ok and not is_admin_user(_current_user_or_none(request)): + raise HTTPException(status_code=403, detail="Admin privileges required") + return { + "client": request.client.host if request.client else None, + "headers": dict(request.headers), + } + + +def _current_user_or_none(request: Request): + from app.auth import authenticate, _bearer_token + + client_host = request.client.host if request.client else "" + token = _bearer_token(request.headers.get("authorization")) + return authenticate(request.headers, client_host, token) + + +@router.post("/admin/users", response_model=UserCreated, dependencies=[Depends(require_admin)]) +async def create_user(payload: UserCreate): + """Legt einen Nutzer an und gibt das Bearer-Token EINMALIG zurueck.""" + user, token = get_store().create_user(payload.display_name) + return UserCreated(user_id=user.id, display_name=user.display_name, token=token) + + +@router.delete("/admin/users/{user_id}", dependencies=[Depends(require_admin)]) +async def delete_user(user_id: str): + """Loescht einen Nutzer und alle seine Daten (Sessions, Nachrichten, Erinnerungen, + Nutzungsdaten). Der anonyme Nutzer kann nicht geloescht werden.""" + try: + deleted = get_store().delete_user(user_id) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + if not deleted: + raise HTTPException(status_code=404, detail=f"Nutzer {user_id!r} nicht gefunden.") + return {"deleted": user_id} + + +@router.post("/admin/users/{user_id}/token", response_model=UserCreated, dependencies=[Depends(require_admin)]) +async def reset_token(user_id: str): + """Stellt einen neuen Bearer-Token aus; der alte wird sofort ungueltig. + Der neue Token wird EINMALIG zurueckgegeben und danach nicht mehr angezeigt.""" + result = get_store().reset_token(user_id) + if result is None: + raise HTTPException(status_code=404, detail=f"Nutzer {user_id!r} nicht gefunden.") + user, token = result + return UserCreated(user_id=user.id, display_name=user.display_name, token=token) + + +@router.put("/admin/users/{user_id}", dependencies=[Depends(require_admin)]) +async def update_user(user_id: str, payload: UserUpdate): + """Aktualisiert den Anzeigenamen eines Nutzers (z. B. nach erstem SSO-Login).""" + user = get_store().update_display_name(user_id, payload.display_name) + if user is None: + raise HTTPException(status_code=404, detail=f"Nutzer {user_id!r} nicht gefunden.") + return {"user_id": user.id, "display_name": user.display_name} + + +@router.post("/admin/users/{user_id}/memories", response_model=MemoryOut, dependencies=[Depends(require_admin)]) +async def add_user_memory(user_id: str, payload: MemoryCreate): + """Legt eine Erinnerung fuer einen Nutzer an (Admin kann Kontext vorbelegen).""" + store = get_store() + if store.get_user(user_id) is None: + raise HTTPException(status_code=404, detail=f"Nutzer {user_id!r} nicht gefunden.") + memory = store.add_memory(user_id, payload.content) + return MemoryOut(id=memory.id, content=memory.content, created_at=memory.created_at) + + +@router.get("/admin/users", dependencies=[Depends(require_admin_or_user)]) +async def list_users(): + """Listet die Nutzer (ohne Secrets). Fuer Admins (SSO/ADMIN_USERS) oder ADMIN_API_KEY.""" + return [ + { + "user_id": u.id, + "display_name": u.display_name, + "external_id": u.external_id, + "created_at": u.created_at, + } + for u in get_store().list_users() + ] + + +@router.get("/admin/users/{user_id}/memories", dependencies=[Depends(require_admin)]) +async def get_user_memories(user_id: str): + """Gibt alle Erinnerungen eines Nutzers zurueck.""" + store = get_store() + if store.get_user(user_id) is None: + raise HTTPException(status_code=404, detail=f"Nutzer {user_id!r} nicht gefunden.") + return [ + {"id": m.id, "content": m.content, "created_at": m.created_at} + for m in store.get_memories(user_id) + ] + + +@router.delete("/admin/users/{user_id}/memories/{memory_id}", dependencies=[Depends(require_admin)]) +async def delete_user_memory(user_id: str, memory_id: int): + """Loescht eine einzelne Erinnerung eines Nutzers.""" + deleted = get_store().delete_memory(user_id, memory_id) + if not deleted: + raise HTTPException(status_code=404, detail="Erinnerung nicht gefunden.") + return {"deleted": memory_id} + + +@router.get("/admin/users/{user_id}/sessions", dependencies=[Depends(require_admin)]) +async def list_user_sessions(user_id: str): + """Listet alle Sessions eines Nutzers (neueste zuerst).""" + return get_store().list_sessions_for_user(user_id) + + +@router.get("/admin/sessions/{session_id}/messages", dependencies=[Depends(require_admin)]) +async def get_session_messages(session_id: str, limit: int = 200): + """Gibt alle Nachrichten einer Session zurueck (Gespraechs-Browser).""" + return get_store().get_messages_for_session(session_id, limit) + + +@router.get("/admin/emergency-events", dependencies=[Depends(require_admin)]) +async def list_emergency_events(limit: int = 50): + """Listet alle protokollierten Notfall-Ereignisse (neueste zuerst).""" + return get_store().list_emergency_events(limit) + + +@router.get("/admin/users/{user_id}/usage", dependencies=[Depends(require_admin)]) +async def get_user_usage(user_id: str): + """Nutzungsstatistik eines Nutzers (letzte 30 Tage).""" + return get_store().get_usage_for_user(user_id) + + +@router.get("/admin/usage", dependencies=[Depends(require_admin)]) +async def get_all_usage(): + """Aggregierte Nutzungsstatistik aller Nutzer.""" + return get_store().get_all_usage() + + +# ── Datenbank-Export ──────────────────────────────────────────────────────── + +@router.get("/admin/db-export", dependencies=[Depends(require_admin)]) +async def export_db(): + """Laed die SQLite-Datenbank als Datei herunter (Backup).""" + path = Path(settings.db_path) + if not path.exists(): + raise HTTPException(status_code=404, detail="Datenbank nicht gefunden.") + return FileResponse( + path, + media_type="application/octet-stream", + filename="voice-assistant.db", + headers={"Content-Disposition": 'attachment; filename="voice-assistant.db"'}, + ) + + +# ── LLM-/System-Status (read-only) ────────────────────────────────────────── + +@router.get("/admin/llm/status", dependencies=[Depends(require_admin)]) +async def get_llm_status(): + """Read-only Status: aktives Backend, Modell, GPU-Auslastung, Dienste.""" + return await llm_status() + + +class BackendSwitch(BaseModel): + backend: str + model: str | None = None + + +@router.post("/admin/llm/backend", dependencies=[Depends(require_admin)]) +async def post_llm_backend(payload: BackendSwitch, request: Request): + """Wechselt das LLM-Backend (Allowlist-validiert, detached). Greift voll erst, + wenn das Gateway als systemd-Dienst läuft; sonst muss es manuell neu starten.""" + try: + result = await switch_backend(payload.backend, payload.model) + except LlmControlError as exc: + log_admin_action(request, "llm_backend_switch_rejected", + backend=payload.backend, model=payload.model, error=str(exc)) + raise HTTPException(status_code=422, detail=str(exc)) + log_admin_action(request, "llm_backend_switch", + backend=payload.backend, model=payload.model) + return result + + +@router.post("/admin/gateway/restart", dependencies=[Depends(require_admin)]) +async def post_gateway_restart(request: Request): + """Startet das Gateway (systemd-User-Dienst) neu — losgelöst, Self-Restart-sicher.""" + log_admin_action(request, "gateway_restart") + return restart_gateway_detached() + + +# ── Aussprache-Lexikon CRUD ───────────────────────────────────────────────── + +def _read_pronunciation(lang: str) -> dict: + path = _CONFIG_DIR / f"pronunciation.{lang}.yaml" + if not path.exists(): + return {"abbreviations": {}, "units": {}, "terms": {}} + data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + return { + "abbreviations": dict(data.get("abbreviations") or {}), + "units": dict(data.get("units") or {}), + "terms": dict(data.get("terms") or {}), + } + + +def _write_pronunciation(lang: str, data: dict) -> None: + path = _CONFIG_DIR / f"pronunciation.{lang}.yaml" + path.write_text( + yaml.dump(data, allow_unicode=True, default_flow_style=False, sort_keys=False), + encoding="utf-8", + ) + # LRU-Cache des Normalizers invalidieren, damit die Aenderung sofort greift. + from app.pipeline.tts_normalizer import _load_lexicon + _load_lexicon.cache_clear() + + +@router.get("/admin/pronunciation/{lang}", dependencies=[Depends(require_admin)]) +async def get_pronunciation(lang: str): + """Gibt alle Eintraege des Aussprache-Lexikons zurueck.""" + if lang not in _ALLOWED_LANGS: + raise HTTPException(status_code=400, detail=f"Sprache muss eine von {_ALLOWED_LANGS} sein.") + return _read_pronunciation(lang) + + +@router.post("/admin/pronunciation/{lang}", dependencies=[Depends(require_admin)]) +async def add_pronunciation(lang: str, entry: PronunciationEntry): + """Fuegt einen Eintrag zum Aussprache-Lexikon hinzu oder ueberschreibt ihn.""" + if lang not in _ALLOWED_LANGS: + raise HTTPException(status_code=400, detail=f"Sprache muss eine von {_ALLOWED_LANGS} sein.") + if entry.section not in _ALLOWED_SECTIONS: + raise HTTPException(status_code=400, detail=f"Section muss eine von {_ALLOWED_SECTIONS} sein.") + if not entry.key.strip() or not entry.value.strip(): + raise HTTPException(status_code=422, detail="key und value duerfen nicht leer sein.") + data = _read_pronunciation(lang) + data[entry.section][entry.key.strip()] = entry.value.strip() + # Nach jedem Einfügen: Sektion alphabetisch aufsteigend nach Schlüssel sortieren. + data[entry.section] = dict( + sorted(data[entry.section].items(), key=lambda kv: kv[0].lower()) + ) + _write_pronunciation(lang, data) + return {"section": entry.section, "key": entry.key.strip(), "value": entry.value.strip()} + + +@router.delete("/admin/pronunciation/{lang}/{section}/{key:path}", dependencies=[Depends(require_admin)]) +async def delete_pronunciation(lang: str, section: str, key: str): + """Loescht einen Eintrag aus dem Aussprache-Lexikon.""" + if lang not in _ALLOWED_LANGS: + raise HTTPException(status_code=400, detail="Unbekannte Sprache.") + if section not in _ALLOWED_SECTIONS: + raise HTTPException(status_code=400, detail="Unbekannte Section.") + data = _read_pronunciation(lang) + if key not in data[section]: + raise HTTPException(status_code=404, detail=f"Eintrag '{key}' nicht gefunden.") + del data[section][key] + _write_pronunciation(lang, data) + return {"deleted": key} + + +# ── Laufzeit-Konfiguration ───────────────────────────────────────────────── + +class ConfigValue(BaseModel): + value: str + + +@router.get("/admin/config", dependencies=[Depends(require_admin)]) +async def get_runtime_config(): + """Gibt alle überschreibbaren Einstellungen mit aktuellem Wert zurück.""" + overrides = get_store().get_config_overrides() + result = [] + for key, (label, type_str, hint) in RUNTIME_SETTABLE.items(): + base_val = getattr(settings, key, None) + effective_val = getattr(runtime_settings, key, base_val) + result.append({ + "key": key, + "label": label, + "type": type_str, + "hint": hint, + "base_value": str(base_val) if base_val is not None else "", + "override_value": overrides.get(key), + "effective_value": str(effective_val) if effective_val is not None else "", + "is_overridden": key in overrides, + }) + return result + + +@router.put("/admin/config/{key}", dependencies=[Depends(require_admin)]) +async def set_runtime_config(key: str, body: ConfigValue, request: Request): + """Setzt eine Laufzeit-Einstellung (wirkt sofort, kein Neustart nötig).""" + if key not in RUNTIME_SETTABLE: + raise HTTPException(status_code=400, detail=f"Nicht überschreibbar: {key!r}") + value = body.value.strip() + get_store().set_config_override(key, value) + invalidate_cache() + log_admin_action(request, "config_set", key=key, value=value) + return {"key": key, "value": value} + + +@router.delete("/admin/config/{key}", dependencies=[Depends(require_admin)]) +async def delete_runtime_config(key: str, request: Request): + """Entfernt eine Laufzeit-Einstellung (fällt auf .env-Wert zurück).""" + if key not in RUNTIME_SETTABLE: + raise HTTPException(status_code=400, detail=f"Nicht überschreibbar: {key!r}") + deleted = get_store().delete_config_override(key) + invalidate_cache() + if not deleted: + raise HTTPException(status_code=404, detail=f"Kein Override für {key!r} gesetzt.") + log_admin_action(request, "config_reset", key=key) + return {"deleted": key} + + +# ── Live-Log (journalctl → WebSocket) ────────────────────────────────────── + +@router.websocket("/admin/log") +async def admin_log_ws(websocket: WebSocket, key: str | None = None): + """Streamt den systemd-Journal-Log des Voice-Assistant-Service live.""" + # Auth: Admin-Key als Query-Param ODER SSO-Identitaet via Cookie/Header. + from app.auth import authenticate, _bearer_token + client_host = websocket.client.host if websocket.client else "" + token = _bearer_token(websocket.headers.get("authorization")) or key + user = authenticate(websocket.headers, client_host, token) + if not is_admin_user(user): + await websocket.close(code=1008) + return + + await websocket.accept() + + # Hinweis, falls die laufende Instanz NICHT der systemd-Dienst ist (z. B. manueller + # `uvicorn --reload`-Start). Dann hat das Journal dieser Unit keine aktuellen Zeilen, + # und der Log-Tab bliebe sonst kommentarlos leer. + try: + check = await asyncio.create_subprocess_exec( + "systemctl", "--user", "is-active", "voice-assistant.service", + stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL, + ) + out, _ = await check.communicate() + if out.decode().strip() != "active": + await websocket.send_text( + "⚠ Der systemd-Dienst 'voice-assistant.service' ist nicht aktiv — " + "die laufende Instanz wurde vermutlich manuell gestartet (uvicorn --reload). " + "Live-Logs erscheinen hier nur, wenn das Gateway als Dienst läuft " + "(systemctl --user start voice-assistant.service). Manuelle Starts loggen ins Terminal." + ) + except Exception: + pass + + proc = await asyncio.create_subprocess_exec( + "journalctl", "--user", "-f", "-u", "voice-assistant.service", + "-n", "100", "--no-pager", "-o", "short", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + ) + try: + while True: + line = await proc.stdout.readline() + if not line: + break + await websocket.send_text(line.decode("utf-8", "replace").rstrip()) + except (WebSocketDisconnect, Exception): + pass + finally: + try: + proc.terminate() + except Exception: + pass diff --git a/app/api/chat.py b/app/api/chat.py new file mode 100644 index 0000000..f56a0db --- /dev/null +++ b/app/api/chat.py @@ -0,0 +1,151 @@ +from io import BytesIO + +from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi.responses import JSONResponse, StreamingResponse + +from app.config import settings +from app.errors import RoutingError +from app.auth import require_user +from app.store import ANONYMOUS_USER_ID, User, SessionOwnershipError +from app.dependencies import ( + resolve_route, + build_orchestrator, + resolve_output_endpoint, + get_store, + piper_voice_for_language, +) +from app.core.memory_extractor import maybe_schedule_extraction +from app.quota import enforce_quota, record_usage, QuotaExceededError +from app.safety.emergency import handle_emergency, schedule_llm_emergency_check +from app.schemas import ChatRequest + +router = APIRouter() + + +def _route_headers(route) -> dict: + return { + "X-Input-Endpoint": route.input_endpoint, + "X-Output-Endpoint": route.output_endpoint, + "X-STT-Provider": route.stt_provider, + "X-LLM-Provider": route.llm_provider, + "X-TTS-Provider": route.tts_provider, + } + + +@router.post("/chat") +async def chat( + payload: ChatRequest, + debug: bool = Query( + default=False, + description="Return JSON trace instead of audio response", + ), + session_id: str | None = Query( + default=None, + description="Optional session id to apply a stored route", + ), + user: User = Depends(require_user), +): + overrides = { + "input_endpoint": payload.input_endpoint, + "output_endpoint": payload.output_endpoint, + "language": payload.language, + "language_mode": payload.language_mode, + "stt_provider": payload.stt_provider, + "llm_provider": payload.llm_provider, + "tts_provider": payload.tts_provider, + } + store = get_store() + + try: + route = resolve_route(user, session_id, overrides) + orchestrator = build_orchestrator(route) + output = await resolve_output_endpoint(route) + # Gespraechsverlauf laden (nur bei gesetzter session_id -> sonst zustandslos). + conversation = ( + store.get_recent_messages(session_id, settings.history_max_messages) + if session_id + else [] + ) + # Langzeit-Erinnerungen sind nutzerbezogen und gelten auch ohne Session. + memories = store.get_memories(user.id) + except SessionOwnershipError as exc: + raise HTTPException(status_code=403, detail=str(exc)) + except RoutingError as exc: + raise HTTPException(status_code=422, detail=str(exc)) + + llm_context = list(conversation) + user_context_parts = [] + if user.id != ANONYMOUS_USER_ID: + user_context_parts.append(f"Du sprichst mit {user.display_name}.") + if memories: + user_context_parts.append( + "Was du ueber den Nutzer weisst:\n" + "\n".join(f"- {m.content}" for m in memories) + ) + if user_context_parts: + llm_context = [{"role": "system", "content": "\n".join(user_context_parts)}] + llm_context + + # Notfall-Erkennung zuerst (immer eskalieren, auch bei Quota-Limit). + emergency = handle_emergency(user, payload.text, store) + # Stufe 2: LLM-Klassifikation als Hintergrund-Task (nur wenn Stichwoerter nichts fanden). + schedule_llm_emergency_check(user, payload.text, store, emergency) + + if emergency is None: + try: + enforce_quota(user, store) + except QuotaExceededError as exc: + raise HTTPException(status_code=429, detail=str(exc)) + + # Explizit angeforderte Stimme gewinnt; sonst folgt sie der Routensprache. + voice = payload.voice or piper_voice_for_language(route.tts_provider, route.language) + + try: + trace, audio = await orchestrator.chat_text( + payload.text, + language=route.language, + voice=voice, + output=output, + history=llm_context, + language_mode=route.language_mode, + text_only=bool(payload.text_only), + ) + except Exception as exc: + raise HTTPException(status_code=502, detail=str(exc)) + + record_usage(user, store, len(payload.text) + len(trace.semantic_response or "")) + + # Turn persistieren (User-Eingabe + semantische Antwort) fuer das Gedaechtnis. + if session_id: + store.append_message(session_id, user.id, "user", payload.text) + store.append_message(session_id, user.id, "assistant", trace.semantic_response) + maybe_schedule_extraction(store, user.id, session_id) + + if debug: + return JSONResponse( + content={ + "ok": True, + "voice": voice, + "route": route.as_dict(), + "history_len": len(conversation), + "memories_len": len(memories), + "emergency": emergency, + "trace": { + "raw_transcript": trace.raw_transcript, + "cleaned_transcript": trace.cleaned_transcript, + "semantic_response": trace.semantic_response, + "spoken_response": trace.spoken_response, + "tts_ready_text": trace.tts_ready_text, + }, + } + ) + + headers = { + "Content-Language": route.language, + "X-Audio-Format": "pcm", + "X-Audio-Sample-Rate": "24000", + "X-Audio-Channels": "1", + "X-Audio-Sample-Width": "16", + **_route_headers(route), + } + if emergency: + headers["X-Emergency"] = emergency["category"] + return StreamingResponse(BytesIO(audio), media_type="audio/pcm", headers=headers) diff --git a/app/api/config.py b/app/api/config.py new file mode 100644 index 0000000..6b1f1e5 --- /dev/null +++ b/app/api/config.py @@ -0,0 +1,37 @@ +from fastapi import APIRouter + +from app.config import settings, active_profile +from app.dependencies import ( + resolve_route, + get_audio_router, + STT_REGISTRY, + LLM_REGISTRY, + TTS_REGISTRY, +) + +router = APIRouter() + + +@router.get("/config") +async def get_config(): + """Zeigt aktives Profil, aufgeloeste Default-Route und verfuegbare Bausteine. + + Bewusst OHNE Secrets - API-Keys werden nur als 'gesetzt/nicht gesetzt' gemeldet. + """ + route = resolve_route() + audio_router = get_audio_router() + return { + "profile": active_profile(), + "app_env": settings.app_env, + "default_route": route.as_dict(), + "available": { + "stt_providers": sorted(STT_REGISTRY), + "llm_providers": sorted(LLM_REGISTRY), + "tts_providers": sorted(TTS_REGISTRY), + "input_endpoints": [c.model_dump() for c in await audio_router.list_inputs()], + "output_endpoints": [c.model_dump() for c in await audio_router.list_outputs()], + }, + "secrets": { + "openrouter_api_key_set": bool(settings.openrouter_api_key.strip()), + }, + } diff --git a/app/api/devices.py b/app/api/devices.py new file mode 100644 index 0000000..931740b --- /dev/null +++ b/app/api/devices.py @@ -0,0 +1,12 @@ +from fastapi import APIRouter +from app.dependencies import get_audio_router + +router = APIRouter() + +@router.get("/devices") +async def list_devices(): + audio_router = get_audio_router() + return { + "inputs": [item.model_dump() for item in await audio_router.list_inputs()], + "outputs": [item.model_dump() for item in await audio_router.list_outputs()], + } diff --git a/app/api/health.py b/app/api/health.py new file mode 100644 index 0000000..60aeed4 --- /dev/null +++ b/app/api/health.py @@ -0,0 +1,7 @@ +from fastapi import APIRouter + +router = APIRouter() + +@router.get("/health") +async def health(): + return {"status": "ok"} diff --git a/app/api/me.py b/app/api/me.py new file mode 100644 index 0000000..f270820 --- /dev/null +++ b/app/api/me.py @@ -0,0 +1,48 @@ +from fastapi import APIRouter, Depends, HTTPException + +from app.auth import require_user, is_admin_user +from app.config import settings +from app.dependencies import get_store +from app.schemas import UserPrefs, MemoryCreate, MemoryOut +from app.store import User + +router = APIRouter() + + +@router.get("/me") +async def get_me(user: User = Depends(require_user)): + return { + "user_id": user.id, + "display_name": user.display_name, + "external_id": user.external_id, + "is_admin": user.is_admin or is_admin_user(user), + "prefs": user.prefs, + "sso_logout_url": settings.sso_logout_url, + } + + +@router.put("/me/prefs") +async def set_my_prefs(payload: UserPrefs, user: User = Depends(require_user)): + """Setzt dauerhafter Routing-Präferenzen des Nutzers. Merge mit bestehenden Prefs.""" + new = {k: v for k, v in payload.model_dump().items() if v is not None} + merged = {**user.prefs, **new} + updated = get_store().set_user_prefs(user.id, merged) + return {"user_id": updated.id, "prefs": updated.prefs} + + +@router.get("/me/memories", response_model=list[MemoryOut]) +async def list_memories(user: User = Depends(require_user)): + return get_store().get_memories(user.id) + + +@router.post("/me/memories", response_model=MemoryOut) +async def add_memory(payload: MemoryCreate, user: User = Depends(require_user)): + """Speichert einen dauerhaften Fakt/eine Vorliebe ueber den Nutzer.""" + return get_store().add_memory(user.id, payload.content.strip()) + + +@router.delete("/me/memories/{memory_id}") +async def delete_memory(memory_id: int, user: User = Depends(require_user)): + if not get_store().delete_memory(user.id, memory_id): + raise HTTPException(status_code=404, detail="Memory not found") + return {"ok": True, "deleted": memory_id} diff --git a/app/api/metrics.py b/app/api/metrics.py new file mode 100644 index 0000000..42d09a7 --- /dev/null +++ b/app/api/metrics.py @@ -0,0 +1,13 @@ +from fastapi import APIRouter, Query +from fastapi.responses import PlainTextResponse + +from app.metrics import metrics + +router = APIRouter() + + +@router.get("/metrics") +async def get_metrics(format: str = Query(default="json", description="json | prometheus")): + if format == "prometheus": + return PlainTextResponse(metrics.prometheus(), media_type="text/plain; version=0.0.4") + return metrics.snapshot() diff --git a/app/api/sessions.py b/app/api/sessions.py new file mode 100644 index 0000000..c5ac2e1 --- /dev/null +++ b/app/api/sessions.py @@ -0,0 +1,21 @@ +from fastapi import APIRouter, Depends, HTTPException + +from app.schemas import SessionRouteRequest +from app.auth import require_user +from app.dependencies import get_store +from app.store import User, SessionOwnershipError + +router = APIRouter() + + +@router.post("/sessions/{session_id}/route") +async def set_session_route( + session_id: str, + payload: SessionRouteRequest, + user: User = Depends(require_user), +): + try: + session = get_store().update_session(session_id, user.id, payload.model_dump()) + except SessionOwnershipError as exc: + raise HTTPException(status_code=403, detail=str(exc)) + return {"session_id": session_id, "route": session.data} diff --git a/app/api/speak.py b/app/api/speak.py new file mode 100644 index 0000000..a39e8d1 --- /dev/null +++ b/app/api/speak.py @@ -0,0 +1,74 @@ +from io import BytesIO + +from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi.responses import StreamingResponse + +from app.errors import RoutingError +from app.auth import require_user +from app.store import User, SessionOwnershipError +from app.dependencies import ( + resolve_route, + build_orchestrator, + resolve_output_endpoint, + get_store, + piper_voice_for_language, +) +from app.quota import enforce_quota, record_usage, QuotaExceededError +from app.schemas import SpeakRequest + +router = APIRouter() + + +@router.post("/speak") +async def speak( + payload: SpeakRequest, + session_id: str | None = Query( + default=None, + description="Optional session id to apply a stored route", + ), + user: User = Depends(require_user), +): + overrides = { + "output_endpoint": payload.output_endpoint, + "language": payload.language, + "tts_provider": payload.tts_provider, + } + try: + route = resolve_route(user, session_id, overrides) + # Explizit angefragte Stimme gewinnt; sonst folgt sie der Sprache (Piper-Parität zu /chat). + voice = payload.voice or piper_voice_for_language(route.tts_provider, route.language) + orchestrator = build_orchestrator(route) + output = await resolve_output_endpoint(route) + except SessionOwnershipError as exc: + raise HTTPException(status_code=403, detail=str(exc)) + except RoutingError as exc: + raise HTTPException(status_code=422, detail=str(exc)) + + store = get_store() + try: + enforce_quota(user, store) + except QuotaExceededError as exc: + raise HTTPException(status_code=429, detail=str(exc)) + + try: + audio = await orchestrator.speak_only( + payload.text, + voice=voice, + language=route.language, + output=output, + ) + record_usage(user, store, len(payload.text)) + + headers = { + "Content-Language": route.language, + "X-Audio-Format": "pcm", + "X-Audio-Sample-Rate": "24000", + "X-Audio-Channels": "1", + "X-Audio-Sample-Width": "16", + "X-Output-Endpoint": route.output_endpoint, + "X-TTS-Provider": route.tts_provider, + } + return StreamingResponse(BytesIO(audio), media_type="audio/pcm", headers=headers) + + except Exception as exc: + raise HTTPException(status_code=502, detail=str(exc)) diff --git a/app/api/transcribe.py b/app/api/transcribe.py new file mode 100644 index 0000000..2c86446 --- /dev/null +++ b/app/api/transcribe.py @@ -0,0 +1,65 @@ +from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile + +from app.errors import RoutingError +from app.auth import require_user +from app.store import User, SessionOwnershipError +from app.dependencies import ( + resolve_route, + build_orchestrator, + resolve_input_endpoint, + get_store, +) +from app.quota import enforce_quota, record_usage, QuotaExceededError + +router = APIRouter() + + +@router.post("/transcribe") +async def transcribe( + file: UploadFile = File(...), + language: str | None = Form(default=None), + input_endpoint: str | None = Form(default=None), + stt_provider: str | None = Form(default=None), + session_id: str | None = Query( + default=None, + description="Optional session id to apply a stored route", + ), + user: User = Depends(require_user), +): + overrides = { + "input_endpoint": input_endpoint, + "language": language, + "stt_provider": stt_provider, + } + + try: + route = resolve_route(user, session_id, overrides) + orchestrator = build_orchestrator(route) + source = await resolve_input_endpoint(route) + except SessionOwnershipError as exc: + raise HTTPException(status_code=403, detail=str(exc)) + except RoutingError as exc: + raise HTTPException(status_code=422, detail=str(exc)) + + store = get_store() + try: + enforce_quota(user, store) + except QuotaExceededError as exc: + raise HTTPException(status_code=429, detail=str(exc)) + + content = await file.read() + suffix = (file.filename or "audio.wav").rsplit(".", 1)[-1].lower() + + try: + trace = await orchestrator.transcribe_only( + content, + fmt=suffix, + language=route.language, + input=source, + ) + except Exception as exc: + raise HTTPException(status_code=502, detail=str(exc)) + + record_usage(user, store, len(trace.raw_transcript or "")) + + return {"route": route.as_dict(), "trace": trace.model_dump()} diff --git a/app/api/ws.py b/app/api/ws.py new file mode 100644 index 0000000..18d03ad --- /dev/null +++ b/app/api/ws.py @@ -0,0 +1,380 @@ +"""WebSocket-Echtzeit-Chat und -Sprache. + +- /ws/chat : Text rein (JSON pro Turn), Antwort als Event-Folge zurueck. +- /ws/voice: Audio rein (binaere Chunks + Control), Transkription -> selbe Pipeline. + +Antwort-Events: ack -> [token*] -> [audio*] -> semantic -> done. +Mit {"stream":true} kommen LLM-Token live, mit {"audio_stream":true} das Audio +satzweise (chunked TTS). /ws/voice sendet zuvor ein transcript-Event. + +Barge-in: Ein {"type":"interrupt"}-Frame oder eine neue Eingabe bricht eine laufende +Antwort ab (-> interrupted-Event). Der Antwort-Turn laeuft als abbrechbarer Task. + +Spaeter (eigene Increments): echte partielle Live-Transkripte (Streaming-STT-Dienst), +WebRTC. +""" + +import asyncio +import json + +from fastapi import APIRouter, WebSocket, WebSocketDisconnect + +from app.config import settings +from app.errors import RoutingError +from app.dependencies import ( + get_store, + resolve_route, + build_orchestrator, + resolve_output_endpoint, + piper_voice_for_language, +) +from app.store import ANONYMOUS_USER_ID, SessionOwnershipError +from app.audio.vad import EnergyVAD +from app.core.memory_extractor import maybe_schedule_extraction +from app.quota import enforce_quota, record_usage, QuotaExceededError +from app.auth import authenticate +from app.safety.emergency import handle_emergency, schedule_llm_emergency_check + +router = APIRouter() + +_OVERRIDE_KEYS = ( + "input_endpoint", + "output_endpoint", + "language", + "language_mode", + "stt_provider", + "llm_provider", + "tts_provider", +) + + +def _authenticate(websocket: WebSocket, token: str | None): + # Forward-Auth (SSO) greift auch beim WS-Handshake: SSOwat injiziert den + # Identitaets-Header in den Upgrade-Request -> aus websocket.headers lesbar. + client_host = websocket.client.host if websocket.client else "" + return authenticate(websocket.headers, client_host, token) + + +async def _resolve(user, session_id, options): + """Loest Route + Orchestrator + Output-Endpunkt auf (kann RoutingError/Ownership werfen).""" + overrides = {key: options.get(key) for key in _OVERRIDE_KEYS} + route = resolve_route(user, session_id, overrides) + orchestrator = build_orchestrator(route) + output = await resolve_output_endpoint(route) + return route, orchestrator, output + + +async def _run_turn( + websocket, store, user, session_id, route, orchestrator, output, text, options, + detected_language: str | None = None, effective_voice: str | None = None, +): + """Faehrt einen Antwort-Turn und streamt die Events an den Client.""" + conversation = ( + store.get_recent_messages(session_id, settings.history_max_messages) + if session_id + else [] + ) + memories = store.get_memories(user.id) + llm_context = list(conversation) + user_context_parts = [] + if user.id != ANONYMOUS_USER_ID: + user_context_parts.append(f"Du sprichst mit {user.display_name}.") + if memories: + user_context_parts.append( + "Was du ueber den Nutzer weisst:\n" + "\n".join(f"- {m.content}" for m in memories) + ) + if user_context_parts: + llm_context = [{"role": "system", "content": "\n".join(user_context_parts)}] + llm_context + + # Notfall-Erkennung zuerst (immer eskalieren, auch bei Quota-Limit). + emergency = handle_emergency(user, text, store) + + async def _on_llm_emergency(category): + try: + await websocket.send_json( + {"type": "emergency", "category": category, "source": "llm"} + ) + except Exception: # Socket evtl. geschlossen -> ignorieren + pass + + # Stufe 2: LLM-Klassifikation als Hintergrund-Task (nur wenn Stichwoerter nichts fanden). + schedule_llm_emergency_check( + user, text, store, emergency, on_emergency=_on_llm_emergency + ) + + if emergency: + await websocket.send_json({"type": "emergency", "category": emergency["category"]}) + else: + try: + enforce_quota(user, store) + except QuotaExceededError as exc: + await websocket.send_json({"type": "error", "status": 429, "detail": str(exc)}) + return + + await websocket.send_json({"type": "ack", "route": route.as_dict()}) + + # Stimme: explizit angeforderte gewinnt, sonst die vom Caller (Sprach-Turn) + # vorberechnete sprachpassende Stimme, sonst folgt sie der Routensprache + # (greift v. a. beim Text-Chat ohne Spracherkennung). + explicit_voice = options.get("voice") + if explicit_voice: + voice = explicit_voice + elif effective_voice is not None: + voice = effective_voice + else: + voice = piper_voice_for_language(route.tts_provider, route.language) + stream = bool(options.get("stream")) + # text_only: Geräte-TTS (Web Speech API) spricht selbst -> kein Server-Audio erzeugen/senden. + text_only = bool(options.get("text_only")) + # audio_stream: explizite Anfrage gewinnt, sonst der serverseitige Default (Admin). + audio_stream = False if text_only else ( + bool(options["audio_stream"]) if "audio_stream" in options + else settings.audio_stream_default + ) + + on_token = None + if stream: + async def on_token(delta): + await websocket.send_json({"type": "token", "text": delta}) + + on_audio = None + if audio_stream: + audio_seq = 0 + + async def on_audio(chunk): + nonlocal audio_seq + await websocket.send_json({"type": "audio", "seq": audio_seq}) + audio_seq += 1 + await websocket.send_bytes(chunk) + + try: + if stream or audio_stream: + trace, audio = await orchestrator.chat_stream( + text, + language=route.language, + voice=voice, + output=output, + history=llm_context, + on_token=on_token, + on_audio=on_audio, + language_mode=route.language_mode, + detected_language=detected_language, + text_only=text_only, + ) + else: + trace, audio = await orchestrator.chat_text( + text, + language=route.language, + voice=voice, + output=output, + history=llm_context, + language_mode=route.language_mode, + detected_language=detected_language, + text_only=text_only, + ) + except Exception as exc: + await websocket.send_json({"type": "error", "status": 502, "detail": str(exc)}) + return + + record_usage(user, store, len(text) + len(trace.semantic_response or "")) + + if session_id: + store.append_message(session_id, user.id, "user", text) + store.append_message(session_id, user.id, "assistant", trace.semantic_response) + maybe_schedule_extraction(store, user.id, session_id) + + await websocket.send_json( + {"type": "semantic", "text": trace.semantic_response, "spoken": trace.spoken_response} + ) + # Im text_only-Modus kommt kein Audio (Gerät spricht selbst). + if not audio_stream and not text_only: + await websocket.send_bytes(audio) + await websocket.send_json({"type": "done", "audio_format": "pcm", "sample_rate": 24000}) + + +async def _cancel_active(task, websocket) -> None: + """Bricht einen laufenden Antwort-Turn ab (Barge-in) und meldet 'interrupted'.""" + if task is None or task.done(): + return + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + await websocket.send_json({"type": "interrupted"}) + + +async def _chat_turn(websocket, store, user, session_id, text, options): + try: + route, orchestrator, output = await _resolve(user, session_id, options) + except SessionOwnershipError as exc: + await websocket.send_json({"type": "error", "status": 403, "detail": str(exc)}) + return + except RoutingError as exc: + await websocket.send_json({"type": "error", "status": 422, "detail": str(exc)}) + return + await _run_turn(websocket, store, user, session_id, route, orchestrator, output, text, options) + + +async def _voice_turn(websocket, store, user, session_id, audio, fmt, options): + try: + route, orchestrator, output = await _resolve(user, session_id, options) + except SessionOwnershipError as exc: + await websocket.send_json({"type": "error", "status": 403, "detail": str(exc)}) + return + except RoutingError as exc: + await websocket.send_json({"type": "error", "status": 422, "detail": str(exc)}) + return + + try: + if route.language_mode == "flex": + # Flex: Sprache auto-erkennen, kein Übersetzen durch Whisper. + transcript, detected_language = await orchestrator.stt.transcribe_detect( + audio, fmt=fmt, language=None + ) + # Stimme folgt der erkannten Sprache (Fallback: Routensprache). + effective_voice = piper_voice_for_language( + route.tts_provider, detected_language or route.language + ) + else: + # Fix: konfigurierte Sprache an Whisper → Whisper übersetzt automatisch. + transcript = await orchestrator.stt.transcribe(audio, fmt=fmt, language=route.language) + detected_language = None + # Stimme folgt der konfigurierten Sprache. + effective_voice = piper_voice_for_language(route.tts_provider, route.language) + except Exception as exc: + await websocket.send_json({"type": "error", "status": 502, "detail": str(exc)}) + return + + await websocket.send_json({ + "type": "transcript", + "text": transcript, + "detected_language": detected_language, + }) + if not transcript or not transcript.strip(): + await websocket.send_json({ + "type": "error", + "detail": "Keine Sprache erkannt — bitte erneut sprechen.", + }) + return + await _run_turn( + websocket, store, user, session_id, route, orchestrator, output, transcript, options, + detected_language=detected_language, effective_voice=effective_voice, + ) + + +@router.websocket("/ws/chat") +async def ws_chat(websocket: WebSocket, session_id: str | None = None, token: str | None = None): + user = _authenticate(websocket, token) + if user is None: + await websocket.close(code=1008) + return + await websocket.accept() + store = get_store() + active = None + + try: + while True: + msg = await websocket.receive_json() + if msg.get("type") == "interrupt": + await _cancel_active(active, websocket) + active = None + continue + text = (msg.get("text") or "").strip() + if not text: + await websocket.send_json({"type": "error", "detail": "empty text"}) + continue + await _cancel_active(active, websocket) # Barge-in bei neuer Eingabe + active = asyncio.create_task( + _chat_turn(websocket, store, user, session_id, text, msg) + ) + except WebSocketDisconnect: + if active and not active.done(): + active.cancel() + return + + +@router.websocket("/ws/voice") +async def ws_voice(websocket: WebSocket, session_id: str | None = None, token: str | None = None): + user = _authenticate(websocket, token) + if user is None: + await websocket.close(code=1008) + return + await websocket.accept() + store = get_store() + + audio_buffer = bytearray() + fmt = "wav" + active = None + vad = None + start_options: dict = {} # Konfig aus dem start-Frame (Provider, audio_stream, language) + + async def _start_voice(audio: bytes, options: dict): + nonlocal active + await _cancel_active(active, websocket) # Barge-in bei neuer Aeusserung + active = asyncio.create_task( + _voice_turn(websocket, store, user, session_id, audio, fmt, options) + ) + + try: + while True: + message = await websocket.receive() + if message["type"] == "websocket.disconnect": + if active and not active.done(): + active.cancel() + return + + if message.get("bytes") is not None: + audio_buffer.extend(message["bytes"]) + # VAD: Aeusserungsende automatisch erkennen (opt-in via start-Frame). + if vad is not None and vad.feed(message["bytes"]): + audio = bytes(audio_buffer) + audio_buffer.clear() + vad.reset() + await _start_voice(audio, start_options) + continue + + raw = message.get("text") + if raw is None: + continue + try: + control = json.loads(raw) + except ValueError: + await websocket.send_json({"type": "error", "detail": "invalid control frame"}) + continue + + ctype = control.get("type") + if ctype == "interrupt": + await _cancel_active(active, websocket) + active = None + continue + if ctype == "start": + audio_buffer.clear() + start_options = control # Provider/audio_stream/language fuer den Turn merken + fmt = control.get("format", "wav") + if control.get("vad"): + vad = EnergyVAD( + sample_rate=control.get("sample_rate", 16000), + threshold=control.get("vad_threshold", 500.0), + silence_ms=control.get("vad_silence_ms", 700.0), + ) + else: + vad = None + continue + if ctype != "end": + continue + + if not audio_buffer: + await websocket.send_json({"type": "error", "detail": "no audio received"}) + continue + + audio = bytes(audio_buffer) + audio_buffer.clear() + if vad is not None: + vad.reset() + # start-Frame-Konfig + end-Frame zusammenfuehren (end kann ueberschreiben) + await _start_voice(audio, {**start_options, **control}) + except WebSocketDisconnect: + if active and not active.done(): + active.cancel() + return diff --git a/app/audio/__init__.py b/app/audio/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/audio/endpoints/__init__.py b/app/audio/endpoints/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/audio/endpoints/input/__init__.py b/app/audio/endpoints/input/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/audio/endpoints/input/base.py b/app/audio/endpoints/input/base.py new file mode 100644 index 0000000..8d21d0e --- /dev/null +++ b/app/audio/endpoints/input/base.py @@ -0,0 +1,17 @@ +from abc import ABC, abstractmethod +from app.schemas import AudioChunk, EndpointCapabilities + +class AudioInputEndpoint(ABC): + endpoint_id: str + + @abstractmethod + async def capabilities(self) -> EndpointCapabilities: ... + + @abstractmethod + async def open(self) -> None: ... + + @abstractmethod + async def read_chunk(self) -> AudioChunk: ... + + @abstractmethod + async def close(self) -> None: ... diff --git a/app/audio/endpoints/input/bluetooth.py b/app/audio/endpoints/input/bluetooth.py new file mode 100644 index 0000000..135b838 --- /dev/null +++ b/app/audio/endpoints/input/bluetooth.py @@ -0,0 +1,23 @@ +from app.audio.endpoints.input.base import AudioInputEndpoint +from app.schemas import AudioChunk, EndpointCapabilities + +class BluetoothInput(AudioInputEndpoint): + endpoint_id = "bt-headset-01" + + async def capabilities(self) -> EndpointCapabilities: + return EndpointCapabilities( + id=self.endpoint_id, + kind="bluetooth", + direction="input", + latency_class="medium", + bluetooth=True, + ) + + async def open(self) -> None: + return None + + async def read_chunk(self) -> AudioChunk: + return AudioChunk(data=b"", format="wav", timestamp_ms=0) + + async def close(self) -> None: + return None diff --git a/app/audio/endpoints/input/local_default.py b/app/audio/endpoints/input/local_default.py new file mode 100644 index 0000000..c8a418c --- /dev/null +++ b/app/audio/endpoints/input/local_default.py @@ -0,0 +1,24 @@ +from app.audio.endpoints.input.base import AudioInputEndpoint +from app.schemas import AudioChunk, EndpointCapabilities + +class LocalDefaultInput(AudioInputEndpoint): + endpoint_id = "local-default-mic" + + async def capabilities(self) -> EndpointCapabilities: + return EndpointCapabilities( + id=self.endpoint_id, + kind="local-default", + direction="input", + latency_class="low", + supports_barge_in=True, + default=True, + ) + + async def open(self) -> None: + return None + + async def read_chunk(self) -> AudioChunk: + return AudioChunk(data=b"", format="wav", timestamp_ms=0) + + async def close(self) -> None: + return None diff --git a/app/audio/endpoints/input/mobile_webrtc.py b/app/audio/endpoints/input/mobile_webrtc.py new file mode 100644 index 0000000..42b419e --- /dev/null +++ b/app/audio/endpoints/input/mobile_webrtc.py @@ -0,0 +1,25 @@ +from app.audio.endpoints.input.base import AudioInputEndpoint +from app.schemas import AudioChunk, EndpointCapabilities + +class MobileWebRTCInput(AudioInputEndpoint): + endpoint_id = "mobile-webrtc-client" + + async def capabilities(self) -> EndpointCapabilities: + return EndpointCapabilities( + id=self.endpoint_id, + kind="mobile-webrtc", + direction="input", + latency_class="low", + networked=True, + mobile=True, + supports_barge_in=True, + ) + + async def open(self) -> None: + return None + + async def read_chunk(self) -> AudioChunk: + return AudioChunk(data=b"", format="wav", timestamp_ms=0) + + async def close(self) -> None: + return None diff --git a/app/audio/endpoints/input/mobile_ws.py b/app/audio/endpoints/input/mobile_ws.py new file mode 100644 index 0000000..eae733c --- /dev/null +++ b/app/audio/endpoints/input/mobile_ws.py @@ -0,0 +1,24 @@ +from app.audio.endpoints.input.base import AudioInputEndpoint +from app.schemas import AudioChunk, EndpointCapabilities + +class MobileWebSocketInput(AudioInputEndpoint): + endpoint_id = "mobile-ws-client" + + async def capabilities(self) -> EndpointCapabilities: + return EndpointCapabilities( + id=self.endpoint_id, + kind="mobile-ws", + direction="input", + latency_class="medium", + networked=True, + mobile=True, + ) + + async def open(self) -> None: + return None + + async def read_chunk(self) -> AudioChunk: + return AudioChunk(data=b"", format="wav", timestamp_ms=0) + + async def close(self) -> None: + return None diff --git a/app/audio/endpoints/output/__init__.py b/app/audio/endpoints/output/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/audio/endpoints/output/base.py b/app/audio/endpoints/output/base.py new file mode 100644 index 0000000..31cd2d9 --- /dev/null +++ b/app/audio/endpoints/output/base.py @@ -0,0 +1,20 @@ +from abc import ABC, abstractmethod +from app.schemas import AudioChunk, EndpointCapabilities + +class AudioOutputEndpoint(ABC): + endpoint_id: str + + @abstractmethod + async def capabilities(self) -> EndpointCapabilities: ... + + @abstractmethod + async def open(self) -> None: ... + + @abstractmethod + async def write_chunk(self, chunk: AudioChunk) -> None: ... + + @abstractmethod + async def flush(self) -> None: ... + + @abstractmethod + async def close(self) -> None: ... diff --git a/app/audio/endpoints/output/bluetooth.py b/app/audio/endpoints/output/bluetooth.py new file mode 100644 index 0000000..d275045 --- /dev/null +++ b/app/audio/endpoints/output/bluetooth.py @@ -0,0 +1,26 @@ +from app.audio.endpoints.output.base import AudioOutputEndpoint +from app.schemas import AudioChunk, EndpointCapabilities + +class BluetoothOutput(AudioOutputEndpoint): + endpoint_id = "bt-speaker-01" + + async def capabilities(self) -> EndpointCapabilities: + return EndpointCapabilities( + id=self.endpoint_id, + kind="bluetooth", + direction="output", + latency_class="medium", + bluetooth=True, + ) + + async def open(self) -> None: + return None + + async def write_chunk(self, chunk: AudioChunk) -> None: + return None + + async def flush(self) -> None: + return None + + async def close(self) -> None: + return None diff --git a/app/audio/endpoints/output/local_default.py b/app/audio/endpoints/output/local_default.py new file mode 100644 index 0000000..692ecbd --- /dev/null +++ b/app/audio/endpoints/output/local_default.py @@ -0,0 +1,26 @@ +from app.audio.endpoints.output.base import AudioOutputEndpoint +from app.schemas import AudioChunk, EndpointCapabilities + +class LocalDefaultOutput(AudioOutputEndpoint): + endpoint_id = "local-default-speaker" + + async def capabilities(self) -> EndpointCapabilities: + return EndpointCapabilities( + id=self.endpoint_id, + kind="local-default", + direction="output", + latency_class="low", + default=True, + ) + + async def open(self) -> None: + return None + + async def write_chunk(self, chunk: AudioChunk) -> None: + return None + + async def flush(self) -> None: + return None + + async def close(self) -> None: + return None diff --git a/app/audio/endpoints/output/loopback.py b/app/audio/endpoints/output/loopback.py new file mode 100644 index 0000000..d8cdbd4 --- /dev/null +++ b/app/audio/endpoints/output/loopback.py @@ -0,0 +1,28 @@ +from app.audio.endpoints.output.base import AudioOutputEndpoint +from app.schemas import AudioChunk, EndpointCapabilities + +class LoopbackOutput(AudioOutputEndpoint): + endpoint_id = "loopback-output" + + def __init__(self): + self.chunks = [] + + async def capabilities(self) -> EndpointCapabilities: + return EndpointCapabilities( + id=self.endpoint_id, + kind="loopback", + direction="output", + latency_class="low", + ) + + async def open(self) -> None: + return None + + async def write_chunk(self, chunk: AudioChunk) -> None: + self.chunks.append(chunk) + + async def flush(self) -> None: + return None + + async def close(self) -> None: + return None diff --git a/app/audio/endpoints/output/mobile_webrtc.py b/app/audio/endpoints/output/mobile_webrtc.py new file mode 100644 index 0000000..d75feca --- /dev/null +++ b/app/audio/endpoints/output/mobile_webrtc.py @@ -0,0 +1,27 @@ +from app.audio.endpoints.output.base import AudioOutputEndpoint +from app.schemas import AudioChunk, EndpointCapabilities + +class MobileWebRTCOutput(AudioOutputEndpoint): + endpoint_id = "mobile-webrtc-client" + + async def capabilities(self) -> EndpointCapabilities: + return EndpointCapabilities( + id=self.endpoint_id, + kind="mobile-webrtc", + direction="output", + latency_class="low", + networked=True, + mobile=True, + ) + + async def open(self) -> None: + return None + + async def write_chunk(self, chunk: AudioChunk) -> None: + return None + + async def flush(self) -> None: + return None + + async def close(self) -> None: + return None diff --git a/app/audio/endpoints/output/mobile_ws.py b/app/audio/endpoints/output/mobile_ws.py new file mode 100644 index 0000000..302920d --- /dev/null +++ b/app/audio/endpoints/output/mobile_ws.py @@ -0,0 +1,27 @@ +from app.audio.endpoints.output.base import AudioOutputEndpoint +from app.schemas import AudioChunk, EndpointCapabilities + +class MobileWebSocketOutput(AudioOutputEndpoint): + endpoint_id = "mobile-ws-client" + + async def capabilities(self) -> EndpointCapabilities: + return EndpointCapabilities( + id=self.endpoint_id, + kind="mobile-ws", + direction="output", + latency_class="medium", + networked=True, + mobile=True, + ) + + async def open(self) -> None: + return None + + async def write_chunk(self, chunk: AudioChunk) -> None: + return None + + async def flush(self) -> None: + return None + + async def close(self) -> None: + return None diff --git a/app/audio/router.py b/app/audio/router.py new file mode 100644 index 0000000..0c43247 --- /dev/null +++ b/app/audio/router.py @@ -0,0 +1,38 @@ +from app.errors import UnknownEndpointError + + +class AudioRouter: + def __init__(self, inputs, outputs): + self.inputs = inputs + self.outputs = outputs + + async def list_inputs(self): + return [await endpoint.capabilities() for endpoint in self.inputs] + + async def list_outputs(self): + return [await endpoint.capabilities() for endpoint in self.outputs] + + async def select_input(self, preferred: str | None = None): + return await self._select(self.inputs, preferred, direction="input") + + async def select_output(self, preferred: str | None = None): + return await self._select(self.outputs, preferred, direction="output") + + async def _select(self, endpoints, preferred: str | None, direction: str): + # Capabilities einmal sammeln (Basis fuer spaetere capability-basierte Auswahl). + pairs = [(endpoint, await endpoint.capabilities()) for endpoint in endpoints] + + if preferred: + for endpoint, caps in pairs: + if caps.id == preferred or caps.kind == preferred: + return endpoint + # Angefragter Endpunkt existiert nicht -> KEIN stiller Default-Fallback. + available = sorted({caps.id for _, caps in pairs} | {caps.kind for _, caps in pairs}) + raise UnknownEndpointError( + f"Unbekannter {direction}-Endpunkt {preferred!r}. Verfuegbar: {available}" + ) + + for endpoint, caps in pairs: + if caps.default: + return endpoint + raise RuntimeError(f"Kein {direction}-Standardendpunkt verfuegbar") diff --git a/app/audio/transport_router.py b/app/audio/transport_router.py new file mode 100644 index 0000000..061ed48 --- /dev/null +++ b/app/audio/transport_router.py @@ -0,0 +1,11 @@ +class TransportRouter: + def __init__(self, local_registry: dict, remote_registry: dict): + self.local_registry = local_registry + self.remote_registry = remote_registry + + def resolve(self, module_type: str, provider_name: str): + if provider_name in self.local_registry.get(module_type, {}): + return self.local_registry[module_type][provider_name] + if provider_name in self.remote_registry.get(module_type, {}): + return self.remote_registry[module_type][provider_name] + raise KeyError(f"Unknown provider: {module_type}/{provider_name}") diff --git a/app/audio/vad.py b/app/audio/vad.py new file mode 100644 index 0000000..c48484a --- /dev/null +++ b/app/audio/vad.py @@ -0,0 +1,59 @@ +"""Einfache energie-basierte Sprachaktivitaetserkennung (VAD). + +Reines Python (stdlib `array`), arbeitet auf s16le-PCM (mono). Erkennt das Ende +einer Aeusserung anhand andauernder Stille nach erkannter Sprache. Damit kann der +Server in /ws/voice Aeusserungen automatisch segmentieren, ohne dass der Client +ein explizites Ende-Signal schickt. + +Hinweis: Das ersetzt keinen echten Streaming-STT-Dienst (keine wortweisen +Teil-Transkripte) - es bestimmt nur die Aeusserungsgrenzen. +""" + +import array +import math + + +def rms(pcm: bytes) -> float: + """Lautstaerke (RMS) eines s16le-PCM-Puffers; 0.0 bei leerem Puffer.""" + usable = len(pcm) - (len(pcm) % 2) + if usable <= 0: + return 0.0 + samples = array.array("h") + samples.frombytes(pcm[:usable]) + if not samples: + return 0.0 + return math.sqrt(sum(s * s for s in samples) / len(samples)) + + +class EnergyVAD: + def __init__(self, sample_rate: int = 16000, threshold: float = 500.0, silence_ms: float = 700.0): + self.sample_rate = sample_rate + self.threshold = threshold + self.silence_ms = silence_ms + self._speech_started = False + self._silence_ms = 0.0 + + def feed(self, pcm: bytes) -> bool: + """Verarbeitet einen Audio-Chunk. + + Liefert True, sobald nach erkannter Sprache genug Stille (silence_ms) + vergangen ist - die Aeusserung gilt dann als beendet. + """ + level = rms(pcm) + n_samples = len(pcm) // 2 + chunk_ms = (n_samples / self.sample_rate) * 1000.0 if self.sample_rate else 0.0 + + if level >= self.threshold: + self._speech_started = True + self._silence_ms = 0.0 + return False + + if self._speech_started: + self._silence_ms += chunk_ms + if self._silence_ms >= self.silence_ms: + return True + return False + + def reset(self) -> None: + self._speech_started = False + self._silence_ms = 0.0 diff --git a/app/audit.py b/app/audit.py new file mode 100644 index 0000000..fa361ce --- /dev/null +++ b/app/audit.py @@ -0,0 +1,44 @@ +"""Strukturiertes Audit-Logging für Admin-Aktionen. + +Schreibt eine Zeile pro schreibender Admin-Aktion (Backend-Wechsel, Config-Änderung, +Gateway-Neustart …) mit der Identität des Auslösers. Die Zeilen landen über stdout im +systemd-Journal und sind damit live im Admin-Log-Tab sichtbar. +""" + +from __future__ import annotations + +import logging +import sys + +# Eigener Logger mit eigenem Handler -> unabhängig von der uvicorn-Logging-Config, +# erscheint zuverlässig auf stdout (= Journal im Dienst-Betrieb). +logger = logging.getLogger("va.audit") +if not logger.handlers: + _h = logging.StreamHandler(sys.stdout) + _h.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s")) + logger.addHandler(_h) + logger.setLevel(logging.INFO) + logger.propagate = False + + +def admin_identity(request) -> str: + """Ermittelt, wer die Admin-Aktion ausführt (SSO-Username oder 'admin-key').""" + try: + from app.auth import authenticate, _bearer_token + client_host = request.client.host if request.client else "" + token = _bearer_token(request.headers.get("authorization")) + user = authenticate(request.headers, client_host, token) + if user is not None and getattr(user, "external_id", None): + return user.external_id + except Exception: # pragma: no cover - Auth-Fehler nie fatal fürs Logging + pass + if request.headers.get("x-admin-key"): + return "admin-key" + return "unknown" + + +def log_admin_action(request, action: str, **fields) -> None: + """Loggt eine Admin-Aktion strukturiert: action, user + freie Felder.""" + who = admin_identity(request) + extra = " ".join(f"{k}={v!r}" for k, v in fields.items() if v is not None) + logger.info("ADMIN action=%s user=%s %s", action, who, extra) diff --git a/app/auth.py b/app/auth.py new file mode 100644 index 0000000..8b7cad7 --- /dev/null +++ b/app/auth.py @@ -0,0 +1,154 @@ +import base64 +import hashlib +import hmac +import json + +from fastapi import Header, HTTPException, Request + +from app.config import settings, Settings +from app.dependencies import get_store +from app.store import User + + +def _csv_set(value: str) -> set[str]: + return {item.strip() for item in (value or "").split(",") if item.strip()} + + +def _b64url_decode(data: str) -> bytes: + return base64.urlsafe_b64decode(data + "=" * (-len(data) % 4)) + + +def _cookie_value(cookie_header: str | None, name: str) -> str | None: + """Liest einen Cookie-Wert robust aus dem Cookie-Header (ohne SimpleCookie).""" + if not cookie_header or not name: + return None + for part in cookie_header.split(";"): + part = part.strip() + if part.startswith(name + "="): + return part[len(name) + 1:] + return None + + +def _username_from_cookie(headers, cfg: Settings) -> str | None: + """Extrahiert den Usernamen aus einem JWT-Cookie (z. B. YunoHost 'yunohost.portal'). + + Mit gesetztem `trusted_auth_jwt_secret` wird die HS256-Signatur geprueft. Ohne + Secret wird die Payload ungeprueft gelesen - das ist nur sicher, weil (a) nur die + Proxy-Quell-IP akzeptiert wird und (b) das SSO unauthentifizierte Anfragen gar nicht + erst durchlaesst (also nur vom SSO validierte Cookies hier ankommen). + """ + token = _cookie_value(headers.get("cookie"), cfg.trusted_auth_cookie) + if not token or token.count(".") != 2: + return None + header_b64, payload_b64, sig_b64 = token.split(".") + secret = cfg.trusted_auth_jwt_secret.strip() + if secret: + expected = hmac.new( + secret.encode(), f"{header_b64}.{payload_b64}".encode(), hashlib.sha256 + ).digest() + try: + if not hmac.compare_digest(expected, _b64url_decode(sig_b64)): + return None + except (ValueError, TypeError): + return None + try: + payload = json.loads(_b64url_decode(payload_b64)) + except (ValueError, TypeError): + return None + value = payload.get(cfg.trusted_auth_cookie_claim) + return value.strip() if isinstance(value, str) and value.strip() else None + + +def is_admin_user(user: User | None, cfg: Settings = settings) -> bool: + """True, wenn der Nutzer (per SSO-Identitaet) in ADMIN_USERS steht.""" + if user is None or not user.external_id: + return False + return user.external_id in _csv_set(cfg.admin_users) + + +def authenticate(headers, client_host: str, token: str | None, + cfg: Settings = settings) -> User | None: + """Gemeinsame Auth-Logik fuer HTTP und WebSocket. + + Praezedenz: + 1. Forward-Auth: trusted_auth_header gesetzt UND Request von einer Proxy-Quell-IP + -> Identitaet aus dem Header, interner Nutzer wird ggf. angelegt. + 2. AUTH_ENABLED=false -> anonymer Standardnutzer (dev/Test). + 3. Bearer-Token. + Liefert den Nutzer oder None (nicht authentifiziert). + """ + store = get_store() + + # 1. Forward-Auth: Identitaet aus Header ODER (signiertem) Cookie - nur von der + # Proxy-Quell-IP akzeptiert. + if cfg.trusted_auth_header or cfg.trusted_auth_cookie: + ips = _csv_set(cfg.trusted_proxy_ips) + if ips and client_host in ips: + external = None + if cfg.trusted_auth_header: + external = (headers.get(cfg.trusted_auth_header) or "").strip() or None + if not external and cfg.trusted_auth_cookie: + external = _username_from_cookie(headers, cfg) + if not external: + return None # SSO sollte die Identitaet immer liefern -> 401 + user = store.get_or_create_user_by_external_id(external, display_name=external) + user.is_admin = is_admin_user(user, cfg) + return user + # Nicht von der Proxy-IP -> ignorieren, normale Auth unten. + + # 2. Auth abgeschaltet (dev/Test). + if not cfg.auth_enabled: + return store.ensure_anonymous_user() + + # 3. Bearer-Token. + if not token: + return None + return store.get_user_by_token(token) + + +def _bearer_token(authorization: str | None) -> str | None: + if authorization and authorization.lower().startswith("bearer "): + return authorization.split(" ", 1)[1].strip() + return None + + +def require_user(request: Request) -> User: + """FastAPI-Dependency: liefert den authentifizierten Nutzer (sonst 401).""" + client_host = request.client.host if request.client else "" + token = _bearer_token(request.headers.get("authorization")) + user = authenticate(request.headers, client_host, token) + if user is None: + raise HTTPException(status_code=401, detail="Authentication required") + return user + + +def require_admin( + request: Request, x_admin_key: str | None = Header(default=None) +) -> None: + """Schuetzt Admin-Endpunkte: ADMIN_API_KEY-Header ODER SSO-Admin-Nutzer.""" + expected = settings.admin_api_key.strip() + if expected and x_admin_key and x_admin_key.strip() == expected: + return + client_host = request.client.host if request.client else "" + token = _bearer_token(request.headers.get("authorization")) + user = authenticate(request.headers, client_host, token) + if is_admin_user(user): + return + if not expected: + raise HTTPException(status_code=503, detail="Admin API not configured (ADMIN_API_KEY unset)") + raise HTTPException(status_code=401, detail="Admin privileges required") + + +def require_admin_or_user( + request: Request, x_admin_key: str | None = Header(default=None) +) -> User | None: + """Erlaubt Zugriff fuer Admin-Nutzer (SSO/ADMIN_USERS) ODER gueltigen ADMIN_API_KEY.""" + expected = settings.admin_api_key.strip() + if expected and x_admin_key and x_admin_key.strip() == expected: + return None + client_host = request.client.host if request.client else "" + token = _bearer_token(request.headers.get("authorization")) + user = authenticate(request.headers, client_host, token) + if user is not None and is_admin_user(user): + return user + raise HTTPException(status_code=403, detail="Admin privileges required") diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..14ce23f --- /dev/null +++ b/app/config.py @@ -0,0 +1,217 @@ +import os +from pathlib import Path + +try: + import tomllib # Python >= 3.11 (stdlib) +except ModuleNotFoundError: # pragma: no cover - Fallback fuer aeltere Interpreter + import tomli as tomllib # type: ignore + +from pydantic.fields import FieldInfo +from pydantic_settings import ( + BaseSettings, + PydanticBaseSettingsSource, + SettingsConfigDict, +) + +BASE_DIR = Path(__file__).resolve().parent.parent +ENV_FILE = BASE_DIR / ".env" +DEFAULT_CONFIG_FILE = BASE_DIR / "config" / "voice-assistant.toml" + + +def _setting_lookup(key: str) -> str | None: + """Liest einen Steuer-Schluessel: echte Umgebung zuerst, dann die .env-Datei. + + Noetig fuer VA_PROFILE/VA_CONFIG_FILE, weil diese gebraucht werden, BEVOR + pydantic-settings die .env laedt - und .env-Werte sonst nicht in os.environ stehen. + """ + value = os.getenv(key) + if value is not None: + return value + try: + from dotenv import dotenv_values + except ModuleNotFoundError: # pragma: no cover + return None + if ENV_FILE.is_file(): + return dotenv_values(ENV_FILE).get(key) + return None + + +def _config_file_path() -> Path: + return Path(_setting_lookup("VA_CONFIG_FILE") or str(DEFAULT_CONFIG_FILE)) + + +def active_profile() -> str | None: + """Name des aktiven Profils (VA_PROFILE) aus Umgebung oder .env, falls gesetzt.""" + profile = _setting_lookup("VA_PROFILE") + return profile.strip() or None if profile else None + + +def load_profile_config() -> dict: + """Liest die zentrale TOML-Config und merged [defaults] + [profiles.]. + + - Fehlt die Datei, gilt ein leeres dict (nur ENV/Defaults greifen) - kein Fehler, + damit reine Cloud-Deployments ohne Datei (nur ENV) funktionieren. + - Ein gesetztes, aber unbekanntes VA_PROFILE ist ein Konfigurationsfehler. + """ + path = _config_file_path() + if not path.is_file(): + return {} + + with path.open("rb") as handle: + data = tomllib.load(handle) + + merged: dict = dict(data.get("defaults", {})) + + profile = active_profile() + if profile: + profiles = data.get("profiles", {}) + if profile not in profiles: + raise ValueError( + f"Unbekanntes VA_PROFILE {profile!r}. " + f"Verfuegbar: {sorted(profiles)}" + ) + merged.update(profiles[profile]) + + return merged + + +class TomlProfileSource(PydanticBaseSettingsSource): + """Settings-Quelle aus der zentralen TOML-Config (inkl. aktivem Profil). + + Liegt in der Praezedenz unter ENV/.env, aber ueber den eingebauten Defaults. + Es werden nur Schluessel durchgereicht, die auch als Settings-Feld existieren. + """ + + def __init__(self, settings_cls): + super().__init__(settings_cls) + raw = load_profile_config() + known = set(settings_cls.model_fields) + self._values = { + key.lower(): value + for key, value in raw.items() + if key.lower() in known + } + + def get_field_value(self, field: FieldInfo, field_name: str): + if field_name in self._values: + return self._values[field_name], field_name, False + return None, field_name, False + + def __call__(self) -> dict: + return dict(self._values) + + +class Settings(BaseSettings): + app_env: str = "dev" + host: str = "0.0.0.0" + port: int = 8080 + log_level: str = "info" + openrouter_api_key: str = "" + openrouter_stt_model: str = "openai/whisper-large-v3" + openrouter_tts_model: str = "openai/gpt-4o-mini-tts" + openrouter_tts_voice: str = "alloy" + openrouter_llm_model: str = "openai/gpt-4.1-mini" + default_language: str = "de" + default_language_mode: str = "fix" + default_input_endpoint: str = "local-default" + default_output_endpoint: str = "local-default" + default_stt_provider: str = "openrouter" + default_llm_provider: str = "local-openai-compatible" + default_tts_provider: str = "openrouter" + # Lokaler llama.cpp-Server (OpenAI-kompatibel), siehe scripts/llm-server/. + local_llm_base_url: str = "http://127.0.0.1:8001/v1" + local_llm_api_key: str = "dummy" + local_llm_model: str = "va_llm" # = --alias des llama.cpp-Servers + # Sprach-Assistent: knappe, vorlesbare Antworten + Reasoning aus = deutlich schneller. + local_llm_system_prompt: str = ( + "Du bist ein gesprochener Sprachassistent. Antworte kurz und natuerlich " + "(in der Regel 1-3 Saetze), in reinem Fliesstext ohne Markdown, ohne " + "Aufzaehlungen, ohne Emojis. Formuliere so, wie man es laut vorliest." + ) + local_llm_disable_reasoning: bool = True # Qwen3 /no_think: spart die Denkphase + local_llm_max_tokens: int = 0 # 0 = serverseitiges Limit (-n) + local_llm_temperature: float = 0.3 + local_llm_top_p: float = 0.9 # Nucleus-Sampling (0.0–1.0) + faster_whisper_model: str = "base" # tiny|base|small|medium|large-v3 + faster_whisper_device: str = "auto" # auto|cpu|cuda + faster_whisper_compute_type: str = "default" # default|int8|float16|int8_float16 + # --- Lokales TTS (piper) ------------------------------------------------- + piper_bin: str = "piper" # Pfad/Name des piper-Binaries + piper_voices_dir: str = str(Path.home() / ".local" / "share" / "piper" / "voices") + piper_voice: str = "de_DE-thorsten-high" # Stimmmodell-Name (ohne .onnx) oder voller Pfad + tts_sample_rate: int = 24000 # Ziel-Sample-Rate (das Gateway erwartet 24000 Hz) + # --- Chatterbox-TTS (hohe Qualitaet + Voice-Cloning, eigener HTTP-Dienst) - + chatterbox_base_url: str = "http://127.0.0.1:9999" + chatterbox_voice: str = "" # Pfad zu Referenz-WAV (Voice-Cloning) oder leer + chatterbox_lang: str = "de" + chatterbox_speed: float = 1.0 + chatterbox_timeout: int = 180 + # Verzeichnis mit nativen Referenz-WAVs je Sprache (Konvention .wav, z. B. fr.wav). + # Greift cross-lingual: pro Sprache eine muttersprachliche Stimme. Leer -> nur chatterbox_voice. + chatterbox_voices_dir: str = str(BASE_DIR / "config" / "voices") + db_path: str = str(BASE_DIR / "data" / "voice-assistant.db") + admin_api_key: str = "" + auth_enabled: bool = True + # --- Forward-/Trusted-Header-Auth (Reverse-Proxy / YunoHost-SSO) --------- + # Ist trusted_auth_header gesetzt UND die Quell-IP in trusted_proxy_ips, wird die + # Identitaet aus diesem Header gelesen (SSO-User) und ein interner Nutzer + # automatisch angelegt. Sonst gilt die normale Token-/Anonymous-Auth. + trusted_auth_header: str = "" + # Alternativ zur Header-Variante: Identitaet aus einem (signierten) JWT-Cookie lesen. + # YunoHost reicht den Usernamen nicht als Header durch, sondern im Cookie + # "yunohost.portal" (JWT, Claim "user"). Nur von der Proxy-IP akzeptiert. + trusted_auth_cookie: str = "" # Cookie-Name (z. B. yunohost.portal) + trusted_auth_cookie_claim: str = "user" # JWT-Claim mit dem Usernamen + trusted_auth_jwt_secret: str = "" # optional: HS256-Secret -> Signatur pruefen + trusted_proxy_ips: str = "" # kommasepariert; IP(s) des Reverse-Proxys + admin_users: str = "" # kommaseparierte SSO-Usernamen mit Admin-Rechten + sso_logout_url: str = "" # Logout-Link fuers Frontend (SSO-Portal) + # Login-/Portal-URL: unauthentifizierte Seitenaufrufe werden hierhin umgeleitet + # (Defense-in-Depth zusaetzlich zu SSOwat). Leer -> stattdessen HTTP 401. + sso_login_url: str = "" + history_max_messages: int = 10 + # Automatische Erinnerungs-Extraktion: das LLM destilliert dauerhafte Fakten + # aus dem Gespraech und legt sie als Nutzer-Erinnerungen ab (best-effort, + # nicht-blockierend). Leerer Provider = Default-LLM-Provider. + memory_extraction_enabled: bool = True + memory_extraction_every_n_turns: int = 3 + memory_extraction_max: int = 50 + memory_extraction_provider: str = "" + audio_stream_default: bool = True # satzweises TTS als Default (Admin kann abschalten) + # TTS-Text-Normalisierung: auto|full|light|off. "auto" = piper -> full, Cloud -> light. + tts_normalize_level: str = "auto" + stt_fallback: str = "" # kommaseparierte Provider-Namen (Fallback-Kette) + llm_fallback: str = "" + tts_fallback: str = "" + daily_request_limit: int = 0 # 0 = unbegrenzt; Anfragen pro Nutzer pro Tag + emergency_webhook_url: str = "" # optionaler Eskalations-Webhook + # LLM-Notfall-Klassifikation (zweite Stufe, faengt was die Stichwoerter verpassen). + # Laeuft als Hintergrund-Task NUR wenn der Keyword-Filter nichts fand -> keine + # zusaetzliche Antwortlatenz. Leerer Provider = Default-LLM-Provider. + emergency_llm_enabled: bool = True + emergency_llm_provider: str = "" + emergency_llm_min_confidence: float = 0.6 + model_config = SettingsConfigDict( + env_file=ENV_FILE, case_sensitive=False, extra="ignore" + ) + + @classmethod + def settings_customise_sources( + cls, + settings_cls, + init_settings, + env_settings, + dotenv_settings, + file_secret_settings, + ): + # Praezedenz (frueher = hoeher): init > ENV > .env > TOML/Profil > Defaults + return ( + init_settings, + env_settings, + dotenv_settings, + TomlProfileSource(settings_cls), + file_secret_settings, + ) + + +settings = Settings() diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/core/memory_extractor.py b/app/core/memory_extractor.py new file mode 100644 index 0000000..c84101c --- /dev/null +++ b/app/core/memory_extractor.py @@ -0,0 +1,164 @@ +"""Automatische Erinnerungs-Extraktion. + +Nach einigen Gespraechsturns destilliert ein LLM dauerhafte Fakten/Vorlieben +ueber den Nutzer aus dem Verlauf und legt sie als Nutzer-Erinnerungen ab. + +Bewusst **best-effort und nicht-blockierend**: Die Extraktion laeuft als +Hintergrund-Task und darf die Antwortlatenz nie erhoehen. Schlaegt sie fehl +(LLM-Fehler, kaputtes JSON), ist die Folge nur "kein neuer Fakt" - niemals ein +Fehler im Antwort-Turn. +""" + +import asyncio +import json +import logging +import re + +from app.config import Settings, settings + +logger = logging.getLogger(__name__) + +# Turn-Zaehler pro Session (in-memory, bewusst kein DB-Schema-Eingriff). +_turn_counts: dict[str, int] = {} +# Referenzen auf laufende Tasks halten, damit sie nicht vorzeitig vom GC kassiert werden. +_pending: set[asyncio.Task] = set() + +_EXTRACTION_SYSTEM_PROMPT = ( + "Du extrahierst dauerhafte, langfristig relevante Fakten und Vorlieben ueber den " + "Nutzer aus einem Gespraech (z. B. Name, Wohnort, Familie, Gesundheit, Hobbys, " + "Vorlieben, Abneigungen, feste Routinen). Gib AUSSCHLIESSLICH ein JSON-Array " + "kurzer deutscher Strings zurueck, ohne Erklaerung und ohne Markdown. Nimm nur " + "NEUE Fakten auf, die nicht bereits bekannt sind. Ignoriere fluechtige oder rein " + "situative Aussagen. Gibt es nichts Neues, antworte mit []." +) + + +def _build_extractor_llm(cfg: Settings): + """Baut eine eigene LLM-Instanz fuer die Extraktion (nicht der Sprach-Provider). + + Fuer den lokalen Provider wird der Extraktions-System-Prompt direkt gesetzt + (der Chat-Provider ist auf kurze, vorlesbare Saetze getrimmt und taugt nicht + fuer JSON). Fuer andere Provider wird der generische Provider verwendet; die + Anweisung steckt dann zusaetzlich in der Nachricht selbst. + """ + provider = cfg.memory_extraction_provider or cfg.default_llm_provider + if provider == "local-openai-compatible": + from app.providers.llm.local_openai_compatible import LocalOpenAICompatibleLLM + + return LocalOpenAICompatibleLLM( + cfg.local_llm_base_url, + cfg.local_llm_api_key, + cfg.local_llm_model, + system_prompt=_EXTRACTION_SYSTEM_PROMPT, + disable_reasoning=True, + max_tokens=512, + temperature=0.1, + ) + + from app.dependencies import get_llm_provider + + return get_llm_provider(provider, cfg) + + +def _format_conversation(messages: list[dict]) -> str: + lines = [] + for msg in messages: + role = "Nutzer" if msg.get("role") == "user" else "Assistent" + content = (msg.get("content") or "").strip() + if content: + lines.append(f"{role}: {content}") + return "\n".join(lines) + + +def parse_facts(raw: str) -> list[str]: + """Liest ein JSON-Array von Fakt-Strings aus der (evtl. verrauschten) LLM-Antwort.""" + if not raw: + return [] + match = re.search(r"\[.*\]", raw, re.DOTALL) + if not match: + return [] + try: + data = json.loads(match.group(0)) + except ValueError: + return [] + if not isinstance(data, list): + return [] + facts = [] + for item in data: + if isinstance(item, str): + fact = item.strip() + if fact: + facts.append(fact) + return facts + + +def _norm(text: str) -> str: + return " ".join(text.lower().split()) + + +async def extract_and_store(store, user_id: str, session_id: str, cfg: Settings = settings) -> int: + """Extrahiert neue Fakten und speichert sie. Liefert die Anzahl neu gespeicherter.""" + messages = store.get_recent_messages(session_id, cfg.history_max_messages) + conversation = _format_conversation(messages) + if not conversation: + return 0 + + existing = store.get_memories(user_id) + if len(existing) >= cfg.memory_extraction_max: + return 0 + + known = [m.content for m in existing] + known_text = "\n".join(f"- {k}" for k in known) if known else "(noch nichts bekannt)" + user_prompt = ( + f"Bereits bekannt:\n{known_text}\n\n" + f"Gespraech:\n{conversation}\n\n" + "Neue Fakten als JSON-Array:" + ) + + llm = _build_extractor_llm(cfg) + raw = await llm.complete(user_prompt) + facts = parse_facts(raw) + if not facts: + return 0 + + seen = {_norm(k) for k in known} + added = 0 + for fact in facts: + if len(existing) + added >= cfg.memory_extraction_max: + break + key = _norm(fact) + if key in seen: + continue + seen.add(key) + store.add_memory(user_id, fact) + added += 1 + if added: + logger.info("memory-extraction: %d neue Erinnerung(en) fuer %s", added, user_id) + return added + + +async def _run_safe(store, user_id: str, session_id: str, cfg: Settings) -> None: + try: + await extract_and_store(store, user_id, session_id, cfg) + except Exception: # best-effort: niemals den Turn beeintraechtigen + logger.exception("memory-extraction fehlgeschlagen (ignoriert)") + + +def maybe_schedule_extraction(store, user_id: str, session_id: str | None, + cfg: Settings = settings) -> asyncio.Task | None: + """Plant die Extraktion als Hintergrund-Task, sofern aktiviert und N Turns erreicht. + + Gibt den geplanten Task zurueck (oder None) - blockiert nie. + """ + if not cfg.memory_extraction_enabled or not session_id: + return None + every = max(1, cfg.memory_extraction_every_n_turns) + count = _turn_counts.get(session_id, 0) + 1 + _turn_counts[session_id] = count + if count % every != 0: + return None + + task = asyncio.create_task(_run_safe(store, user_id, session_id, cfg)) + _pending.add(task) + task.add_done_callback(_pending.discard) + return task diff --git a/app/core/orchestrator.py b/app/core/orchestrator.py new file mode 100644 index 0000000..19cd11c --- /dev/null +++ b/app/core/orchestrator.py @@ -0,0 +1,227 @@ +from app.schemas import AudioChunk, PipelineTrace +from app.pipeline.sentence_chunker import SentenceChunker +from app.metrics import timer, metrics + + +def _stage(name: str): + return timer("stage_duration_seconds", {"stage": name}) + +# Festes Ausgabeformat der TTS-Stufe (s16le PCM, 24 kHz, mono). +TTS_AUDIO_FORMAT = "pcm" +TTS_SAMPLE_RATE = 24000 +TTS_CHANNELS = 1 + + +class Orchestrator: + def __init__(self, stt, llm, tts, input_cleaner, spoken_adapter, tts_normalizer, + normalize_level: str = "full"): + self.stt = stt + self.llm = llm + self.tts = tts + self.input_cleaner = input_cleaner + self.spoken_adapter = spoken_adapter + self.tts_normalizer = tts_normalizer + self.normalize_level = normalize_level + + async def _emit_to_output(self, audio: bytes, output) -> None: + """Schreibt das synthetisierte Audio durch den gewaehlten Output-Endpunkt. + + Der HTTP-Stream bleibt davon unberuehrt (additiv). Bei lokalen Geraeten + ist write_chunk heute ein no-op; LoopbackOutput sammelt die Chunks. + """ + if output is None: + return + chunk = AudioChunk( + data=audio, + sample_rate=TTS_SAMPLE_RATE, + channels=TTS_CHANNELS, + format=TTS_AUDIO_FORMAT, + ) + await output.open() + try: + await output.write_chunk(chunk) + await output.flush() + finally: + await output.close() + + async def transcribe_only( + self, + audio_bytes: bytes, + fmt: str, + language: str | None = None, + input=None, + ): + trace = PipelineTrace() + # input dient hier nur der Validierung/Metadaten; das Audio kommt per Upload. + if input is not None: + await input.capabilities() + with _stage("stt"): + trace.raw_transcript = await self.stt.transcribe( + audio_bytes, + fmt=fmt, + language=language, + ) + trace.cleaned_transcript = await self.input_cleaner.run( + trace.raw_transcript or "" + ) + return trace + + async def speak_only( + self, + text: str, + voice: str | None = None, + language: str | None = None, + output=None, + ): + spoken = await self.spoken_adapter.run(text, language=language) + normalized = await self.tts_normalizer.run( + spoken, language=language, level=self.normalize_level + ) + with _stage("tts"): + audio = await self.tts.synthesize(normalized, voice=voice, language=language) + await self._emit_to_output(audio, output) + return audio + + async def chat_text( + self, + text: str, + language: str | None = None, + voice: str | None = None, + output=None, + history: list[dict] | None = None, + language_mode: str = "fix", + detected_language: str | None = None, + text_only: bool = False, + ): + trace = PipelineTrace() + + trace.raw_transcript = text + trace.cleaned_transcript = await self.input_cleaner.run(text or "") + + # Flex: die erkannte Sprache bestimmt die Ausgabe. Gibt es keine (z. B. Text-Chat + # ohne Audio), bleibt sie None -> das LLM antwortet von selbst in der Eingabesprache. + # Fix: immer die konfigurierte Sprache (Whisper hat die Eingabe übersetzt). + effective_language = detected_language if language_mode == "flex" else language + + with _stage("llm"): + trace.semantic_response = await self.llm.complete( + trace.cleaned_transcript or "", + history=history, + language=effective_language, + ) + if not trace.semantic_response: + raise RuntimeError("LLM returned an empty response") + + trace.spoken_response = await self.spoken_adapter.run( + trace.semantic_response, + language=effective_language, + ) + trace.tts_ready_text = await self.tts_normalizer.run( + trace.spoken_response, + language=effective_language, + level=self.normalize_level, + ) + + # text_only: Geräte-TTS (Web Speech API) übernimmt das Sprechen -> kein Server-Audio. + if text_only: + return trace, b"" + + with _stage("tts"): + audio = await self.tts.synthesize( + trace.tts_ready_text, + voice=voice, + language=effective_language, + ) + await self._emit_to_output(audio, output) + return trace, audio + + async def chat_stream( + self, + text: str, + language: str | None = None, + voice: str | None = None, + output=None, + history: list[dict] | None = None, + on_token=None, + on_audio=None, + language_mode: str = "fix", + detected_language: str | None = None, + text_only: bool = False, + ): + """Wie chat_text, aber gestreamt. + + `on_token(delta)` (async) wird pro LLM-Token-Delta aufgerufen. + Ist `on_audio(chunk)` gesetzt, wird das Audio satzweise erzeugt (chunked TTS) + und pro fertigem Satz ausgeliefert, statt erst am Ende komplett. + """ + trace = PipelineTrace() + trace.raw_transcript = text + trace.cleaned_transcript = await self.input_cleaner.run(text or "") + + # Siehe chat_text: Flex folgt der erkannten Sprache (None -> LLM spiegelt Eingabe), + # Fix nutzt die konfigurierte Sprache. + effective_language = detected_language if language_mode == "flex" else language + + # text_only: kein Server-Audio (Geräte-TTS spricht selbst) -> kein Chunked-TTS. + chunker = SentenceChunker() if (on_audio and not text_only) else None + audio_parts: list[bytes] = [] + + async def _emit_sentence(sentence: str) -> None: + spoken = await self.spoken_adapter.run(sentence, language=effective_language) + ready = await self.tts_normalizer.run( + spoken, language=effective_language, level=self.normalize_level + ) + if not ready.strip(): + return + chunk = await self.tts.synthesize(ready, voice=voice, language=effective_language) + audio_parts.append(chunk) + await on_audio(chunk) + + parts: list[str] = [] + stream_fn = getattr(self.llm, "stream", None) + if stream_fn is not None: + async for delta in stream_fn(trace.cleaned_transcript or "", history=history, language=effective_language): + parts.append(delta) + if on_token: + await on_token(delta) + if chunker: + for sentence in chunker.feed(delta): + await _emit_sentence(sentence) + else: + result = await self.llm.complete(trace.cleaned_transcript or "", history=history, language=effective_language) + parts.append(result) + if on_token: + await on_token(result) + if chunker: + for sentence in chunker.feed(result): + await _emit_sentence(sentence) + + trace.semantic_response = "".join(parts) + if not trace.semantic_response: + raise RuntimeError("LLM returned an empty response") + + trace.spoken_response = await self.spoken_adapter.run( + trace.semantic_response, + language=effective_language, + ) + trace.tts_ready_text = await self.tts_normalizer.run( + trace.spoken_response, + language=effective_language, + level=self.normalize_level, + ) + + if text_only: + return trace, b"" + + if chunker: + tail = chunker.flush() + if tail: + await _emit_sentence(tail) + audio = b"".join(audio_parts) + else: + audio = await self.tts.synthesize( + trace.tts_ready_text, voice=voice, language=effective_language + ) + + await self._emit_to_output(audio, output) + return trace, audio diff --git a/app/core/warmup.py b/app/core/warmup.py new file mode 100644 index 0000000..b2f8d7e --- /dev/null +++ b/app/core/warmup.py @@ -0,0 +1,45 @@ +"""Laedt lokale KI-Modelle beim Start vor. + +So zahlt nicht der erste Nutzer den Kaltstart (piper ~2 s Modell-Load, faster-whisper +Modell-Load). Es wird nur vorgeladen, was die aktive Konfiguration tatsaechlich nutzt +(Default-Provider) - bei reinen Cloud-Profilen passiert nichts. Best-effort: Fehler +werden geloggt, brechen den Start nie ab. +""" + +import io +import logging +import wave + +from app.config import settings + +logger = logging.getLogger(__name__) + + +def _silence_wav(seconds: float = 0.1, rate: int = 16000) -> bytes: + buf = io.BytesIO() + with wave.open(buf, "wb") as w: + w.setnchannels(1) + w.setsampwidth(2) + w.setframerate(rate) + w.writeframes(b"\x00\x00" * int(rate * seconds)) + return buf.getvalue() + + +async def warmup_local_models() -> None: + from app.dependencies import get_stt_provider, get_tts_provider + + try: + tts = get_tts_provider() + if type(tts).__name__ == "PiperTTSProvider": + await tts.synthesize("Hallo.", audio_format="pcm") + logger.info("warmup: piper-Stimmmodell geladen") + except Exception: + logger.exception("warmup: TTS-Vorladen fehlgeschlagen (ignoriert)") + + try: + stt = get_stt_provider() + if type(stt).__name__ == "FasterWhisperProvider": + await stt.transcribe(_silence_wav(), fmt="wav", language=settings.default_language) + logger.info("warmup: faster-whisper-Modell geladen") + except Exception: + logger.exception("warmup: STT-Vorladen fehlgeschlagen (ignoriert)") diff --git a/app/dependencies.py b/app/dependencies.py new file mode 100644 index 0000000..9179ae5 --- /dev/null +++ b/app/dependencies.py @@ -0,0 +1,305 @@ +from dataclasses import dataclass + +from app.config import Settings, settings +from app.runtime_config import runtime_settings +from app.errors import UnknownComponentError +from app.audio.router import AudioRouter +from app.audio.endpoints.input.local_default import LocalDefaultInput +from app.audio.endpoints.input.bluetooth import BluetoothInput +from app.audio.endpoints.input.mobile_ws import MobileWebSocketInput +from app.audio.endpoints.input.mobile_webrtc import MobileWebRTCInput +from app.audio.endpoints.output.local_default import LocalDefaultOutput +from app.audio.endpoints.output.bluetooth import BluetoothOutput +from app.audio.endpoints.output.mobile_ws import MobileWebSocketOutput +from app.audio.endpoints.output.mobile_webrtc import MobileWebRTCOutput +from app.audio.endpoints.output.loopback import LoopbackOutput +from app.providers.stt.openrouter import OpenRouterSTTProvider +from app.providers.stt.faster_whisper import FasterWhisperProvider +from app.providers.llm.local_openai_compatible import LocalOpenAICompatibleLLM +from app.providers.llm.openrouter import OpenRouterLLMProvider +from app.providers.tts.openrouter import OpenRouterTTSProvider +from app.providers.tts.chatterbox import ChatterboxTTSProvider +from app.providers.tts.piper import PiperTTSProvider +from app.providers.fallback import ( + FallbackSTTProvider, + FallbackLLMProvider, + FallbackTTSProvider, +) +from app.pipeline.input_cleaner import InputCleaner +from app.pipeline.spoken_response_adapter import SpokenResponseAdapter +from app.pipeline.tts_normalizer import TTSNormalizer +from app.core.orchestrator import Orchestrator +from app.store import SQLiteStore, Store, User + +# --------------------------------------------------------------------------- +# Persistenz-Store: Modul-Singleton (SQLite). Spaetere Backends implementieren +# dasselbe Store-Interface, ohne die App zu aendern. +# --------------------------------------------------------------------------- +_store: Store | None = None + + +def get_store() -> Store: + global _store + if _store is None: + _store = SQLiteStore(settings.db_path) + return _store + +# --------------------------------------------------------------------------- +# Provider-Registries: Modul austauschbar via Name, ohne Kern-Code zu aendern. +# Ein neuer Provider = ein Eintrag. Unbekannter Name -> UnknownComponentError. +# --------------------------------------------------------------------------- +STT_REGISTRY = { + "openrouter": lambda s: OpenRouterSTTProvider(s.openrouter_api_key, s.openrouter_stt_model), + "faster-whisper": lambda s: FasterWhisperProvider(), +} + +LLM_REGISTRY = { + "openrouter": lambda s: OpenRouterLLMProvider(s.openrouter_api_key, s.openrouter_llm_model), + "local-openai-compatible": lambda s: LocalOpenAICompatibleLLM( + s.local_llm_base_url, + s.local_llm_api_key, + s.local_llm_model, + system_prompt=s.local_llm_system_prompt, + disable_reasoning=s.local_llm_disable_reasoning, + max_tokens=s.local_llm_max_tokens, + temperature=s.local_llm_temperature, + top_p=s.local_llm_top_p, + ), +} + +TTS_REGISTRY = { + "openrouter": lambda s: OpenRouterTTSProvider( + s.openrouter_api_key, s.openrouter_tts_model, s.openrouter_tts_voice + ), + "chatterbox": lambda s: ChatterboxTTSProvider( + s.chatterbox_base_url, + s.chatterbox_voice, + s.chatterbox_lang, + s.chatterbox_speed, + s.tts_sample_rate, + s.chatterbox_timeout, + voices_dir=s.chatterbox_voices_dir, + ), + "piper": lambda s: PiperTTSProvider( + s.piper_bin, s.piper_voices_dir, s.piper_voice, s.tts_sample_rate + ), +} + + +def _from_registry(registry: dict, name: str, kind: str, cfg: Settings): + try: + factory = registry[name] + except KeyError as exc: + raise UnknownComponentError( + f"Unbekannter {kind}-Provider {name!r}. Verfuegbar: {sorted(registry)}" + ) from exc + return factory(cfg) + + +def get_stt_provider(name: str | None = None, cfg=None): + cfg = cfg or runtime_settings + return _from_registry(STT_REGISTRY, name or cfg.default_stt_provider, "STT", cfg) + + +def get_llm_provider(name: str | None = None, cfg=None): + cfg = cfg or runtime_settings + return _from_registry(LLM_REGISTRY, name or cfg.default_llm_provider, "LLM", cfg) + + +def get_tts_provider(name: str | None = None, cfg=None): + cfg = cfg or runtime_settings + return _from_registry(TTS_REGISTRY, name or cfg.default_tts_provider, "TTS", cfg) + + +# --------------------------------------------------------------------------- +# Audio-Router: Modul-Singleton, damit zustandsbehaftete Endpunkte +# (z. B. LoopbackOutput.chunks) ueber Requests hinweg stabil bleiben. +# --------------------------------------------------------------------------- +_audio_router: AudioRouter | None = None + + +def get_audio_router() -> AudioRouter: + global _audio_router + if _audio_router is None: + _audio_router = AudioRouter( + inputs=[ + LocalDefaultInput(), + BluetoothInput(), + MobileWebSocketInput(), + MobileWebRTCInput(), + ], + outputs=[ + LocalDefaultOutput(), + BluetoothOutput(), + MobileWebSocketOutput(), + MobileWebRTCOutput(), + LoopbackOutput(), + ], + ) + return _audio_router + + +# --------------------------------------------------------------------------- +# Session-Routing und einheitliche Route-Aufloesung ueber alle Achsen. +# Praezedenz: Settings-Defaults < Session-Route < Request-Overrides. +# --------------------------------------------------------------------------- +ROUTE_KEYS = ( + "input_endpoint", + "output_endpoint", + "stt_provider", + "llm_provider", + "tts_provider", + "language", + "language_mode", +) + +# Stimm-Auswahl nach Sprache: (effektive) Sprache → beste Piper-Stimme. +# Gilt in BEIDEN Modi — die gesprochene Stimme folgt immer der Antwortsprache. +LANG_TO_PIPER_VOICE: dict[str, str] = { + "de": "de_DE-thorsten-high", + "en": "en_US-lessac-high", + "fr": "fr_FR-siwis-medium", + "es": "es_ES-sharvard-medium", + "it": "it_IT-paola-medium", + "nl": "nl_NL-mls-medium", + "ru": "ru_RU-irina-medium", + "zh": "zh_CN-huayan-medium", + "cmn": "zh_CN-huayan-medium", +} + + +def piper_voice_for_language(tts_provider: str, language: str | None) -> str | None: + """Liefert die zur Sprache passende Piper-Stimme (sonst None → Provider-Default). + + Nur für Piper sinnvoll: Cloud-TTS (OpenRouter) und Chatterbox haben eigene + Stimmnamen (z. B. "Zephyr") und steuern die Sprache anders. Bei None nimmt der + jeweilige Provider seine konfigurierte Default-Stimme. + """ + if tts_provider == "piper" and language: + return LANG_TO_PIPER_VOICE.get(language) + return None + + +@dataclass +class ResolvedRoute: + input_endpoint: str + output_endpoint: str + stt_provider: str + llm_provider: str + tts_provider: str + language: str + language_mode: str = "fix" + + def as_dict(self) -> dict: + return { + "input_endpoint": self.input_endpoint, + "output_endpoint": self.output_endpoint, + "stt_provider": self.stt_provider, + "llm_provider": self.llm_provider, + "tts_provider": self.tts_provider, + "language": self.language, + "language_mode": self.language_mode, + } + + +def get_session_route(session_id: str | None, user: User | None = None) -> dict: + """Liefert die gespeicherte Route einer Session des Nutzers (leeres dict sonst). + + Gehoert die Session einem anderen Nutzer, wird SessionOwnershipError ausgeloest. + """ + if not session_id: + return {} + session = get_store().get_session(session_id) + if session is None: + return {} + if user is not None and session.user_id != user.id: + from app.store import SessionOwnershipError + + raise SessionOwnershipError( + f"Session {session_id!r} gehoert einem anderen Nutzer" + ) + return session.data + + +def resolve_route( + user: User | None = None, + session_id: str | None = None, + overrides: dict | None = None, + cfg=None, +) -> ResolvedRoute: + cfg = cfg or runtime_settings + """Loest die effektive Route auf. + + Praezedenz (hoeher gewinnt): Defaults < Nutzer-Prefs < Session-Route < Request. + """ + resolved = { + "input_endpoint": cfg.default_input_endpoint, + "output_endpoint": cfg.default_output_endpoint, + "stt_provider": cfg.default_stt_provider, + "llm_provider": cfg.default_llm_provider, + "tts_provider": cfg.default_tts_provider, + "language": cfg.default_language, + "language_mode": cfg.default_language_mode, + } + + user_prefs = user.prefs if user is not None else {} + session_route = get_session_route(session_id, user) + request_overrides = overrides or {} + + for layer in (user_prefs, session_route, request_overrides): + for key in ROUTE_KEYS: + value = layer.get(key) + if value is not None: + resolved[key] = value + + return ResolvedRoute(**resolved) + + +_FALLBACK_CLASS = { + "stt": FallbackSTTProvider, + "llm": FallbackLLMProvider, + "tts": FallbackTTSProvider, +} + + +def _provider_chain(registry, primary: str, fallback_csv: str, module: str, cfg: Settings): + """Baut primaeren Provider + optionale Fallback-Kette (dedupliziert, Reihenfolge erhalten).""" + names = [primary] + [n.strip() for n in (fallback_csv or "").split(",") if n.strip()] + seen, ordered = set(), [] + for name in names: + if name not in seen: + seen.add(name) + ordered.append(name) + entries = [(name, _from_registry(registry, name, module.upper(), cfg)) for name in ordered] + if len(entries) == 1: + return entries[0][1] + return _FALLBACK_CLASS[module](module, entries) + + +def _resolve_normalize_level(tts_provider: str, cfg: Settings) -> str: + """auto -> piper bekommt 'full', Cloud-TTS 'light' (macht Zahlen/Abk. selbst gut).""" + level = (cfg.tts_normalize_level or "auto").lower() + if level == "auto": + return "full" if tts_provider == "piper" else "light" + return level + + +def build_orchestrator(route: ResolvedRoute, cfg=None) -> Orchestrator: + cfg = cfg or runtime_settings + return Orchestrator( + stt=_provider_chain(STT_REGISTRY, route.stt_provider, cfg.stt_fallback, "stt", cfg), + llm=_provider_chain(LLM_REGISTRY, route.llm_provider, cfg.llm_fallback, "llm", cfg), + tts=_provider_chain(TTS_REGISTRY, route.tts_provider, cfg.tts_fallback, "tts", cfg), + input_cleaner=InputCleaner(), + spoken_adapter=SpokenResponseAdapter(), + tts_normalizer=TTSNormalizer(), + normalize_level=_resolve_normalize_level(route.tts_provider, cfg), + ) + + +async def resolve_output_endpoint(route: ResolvedRoute): + return await get_audio_router().select_output(route.output_endpoint) + + +async def resolve_input_endpoint(route: ResolvedRoute): + return await get_audio_router().select_input(route.input_endpoint) diff --git a/app/errors.py b/app/errors.py new file mode 100644 index 0000000..b56f1c9 --- /dev/null +++ b/app/errors.py @@ -0,0 +1,13 @@ +class RoutingError(Exception): + """Basis fuer Fehler bei der Routing-/Komponentenauswahl. + + Wird in der API-Schicht zu HTTP 422 uebersetzt (Client-Konfigurationsfehler). + """ + + +class UnknownComponentError(RoutingError): + """Unbekannter Provider-Name fuer STT, LLM oder TTS.""" + + +class UnknownEndpointError(RoutingError): + """Ein angefragter Audio-Endpunkt (input/output) existiert nicht.""" diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..02da98b --- /dev/null +++ b/app/main.py @@ -0,0 +1,105 @@ +import asyncio +import time +from contextlib import asynccontextmanager +from pathlib import Path + +from fastapi import FastAPI, Request +from fastapi.staticfiles import StaticFiles +from starlette.responses import RedirectResponse, JSONResponse + +from app.config import settings +from app.auth import authenticate, _bearer_token +from app.core.warmup import warmup_local_models +from app.metrics import metrics +from app.api.health import router as health_router +from app.api.chat import router as chat_router +from app.api.transcribe import router as transcribe_router +from app.api.speak import router as speak_router +from app.api.devices import router as devices_router +from app.api.sessions import router as sessions_router +from app.api.config import router as config_router +from app.api.admin import router as admin_router +from app.api.me import router as me_router +from app.api.metrics import router as metrics_router +from app.api.ws import router as ws_router + +@asynccontextmanager +async def _lifespan(app: FastAPI): + # Lokale Modelle im Hintergrund vorladen -> Server ist sofort verfuegbar, + # der erste Nutzer zahlt nicht den Kaltstart. + task = asyncio.create_task(warmup_local_models()) + yield + if not task.done(): + task.cancel() + + +app = FastAPI(title="Voice Assistant Gateway", lifespan=_lifespan) + + +@app.middleware("http") +async def record_metrics(request: Request, call_next): + start = time.perf_counter() + response = await call_next(request) + duration = time.perf_counter() - start + # Route-Template (z. B. /api/sessions/{session_id}/route) statt konkreter URL, + # um die Label-Kardinalitaet niedrig zu halten. + route = request.scope.get("route") + path = getattr(route, "path", request.url.path) + labels = {"method": request.method, "path": path} + metrics.inc("http_requests_total", {**labels, "status": response.status_code}) + metrics.observe("http_request_duration_seconds", duration, labels) + return response + + +# Pfade mit eigener Auth, Health-Checks und Favicons: nicht gaten. +_PUBLIC_PREFIXES = ("/api", "/ws", "/health", "/favicon", "/apple-touch-icon") + + +@app.middleware("http") +async def gate_web_ui(request: Request, call_next): + """Schuetzt die statische Web-UI: unauthentifizierte Seitenaufrufe -> SSO-Login (sonst 401). + + Defense-in-Depth zusaetzlich zu SSOwat: Selbst wenn der Reverse-Proxy einen + unauthentifizierten Request durchliesse (oder jemand den Port direkt trifft), + bekommt er die Seite nicht ausgeliefert. API/WS haben ihre eigene Auth (require_user + -> 401/JSON), Health-Checks bleiben offen. Bei abgeschalteter Auth (LAN-Dev) liefert + authenticate() einen anonymen Nutzer -> die Seite wird wie bisher ausgeliefert. + """ + path = request.url.path + if not path.startswith(_PUBLIC_PREFIXES): + client_host = request.client.host if request.client else "" + token = _bearer_token(request.headers.get("authorization")) + if authenticate(request.headers, client_host, token) is None: + login = settings.sso_login_url.strip() + if login: + return RedirectResponse(login, status_code=302) + return JSONResponse({"detail": "Authentication required"}, status_code=401) + return await call_next(request) + + +app.include_router(health_router) +app.include_router(chat_router, prefix="/api") +app.include_router(transcribe_router, prefix="/api") +app.include_router(speak_router, prefix="/api") +app.include_router(devices_router, prefix="/api") +app.include_router(sessions_router, prefix="/api") +app.include_router(config_router, prefix="/api") +app.include_router(admin_router, prefix="/api") +app.include_router(me_router, prefix="/api") +app.include_router(metrics_router, prefix="/api") +app.include_router(ws_router) + +# Statische Web-UI (same-origin -> kein CORS). Muss NACH allen API-/WS-Routen +# gemountet werden, damit "/" nur die uebrigen Pfade abfaengt. +class _NoCacheStaticFiles(StaticFiles): + """Liefert die UI ohne Caching aus -> Aenderungen sind sofort sichtbar (kein Safari-Cache).""" + + def file_response(self, *args, **kwargs): + response = super().file_response(*args, **kwargs) + response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate" + return response + + +_WEB_DIR = Path(__file__).resolve().parent / "web" +if _WEB_DIR.is_dir(): + app.mount("/", _NoCacheStaticFiles(directory=str(_WEB_DIR), html=True), name="web") diff --git a/app/metrics.py b/app/metrics.py new file mode 100644 index 0000000..4fe21a7 --- /dev/null +++ b/app/metrics.py @@ -0,0 +1,82 @@ +"""Schlanke In-Memory-Metriken (Counter + Timer) fuer einen Prozess. + +Bewusst ohne externe Dependency. Fuer mehrere Instanzen/Prozesse spaeter durch +einen gemeinsamen Backend (z. B. Prometheus-Exporter) ersetzbar. +""" + +import threading +import time +from collections import defaultdict + + +class Metrics: + def __init__(self): + self._lock = threading.Lock() + self._counters: dict[str, float] = defaultdict(float) + self._timers: dict[str, list] = defaultdict(lambda: [0.0, 0]) # [sum, count] + + @staticmethod + def _key(name: str, labels: dict | None) -> str: + if not labels: + return name + rendered = ",".join(f'{k}="{v}"' for k, v in sorted(labels.items())) + return f"{name}{{{rendered}}}" + + def inc(self, name: str, labels: dict | None = None, value: float = 1.0) -> None: + with self._lock: + self._counters[self._key(name, labels)] += value + + def observe(self, name: str, seconds: float, labels: dict | None = None) -> None: + with self._lock: + agg = self._timers[self._key(name, labels)] + agg[0] += seconds + agg[1] += 1 + + def snapshot(self) -> dict: + with self._lock: + counters = dict(self._counters) + timers = { + key: { + "sum": agg[0], + "count": agg[1], + "avg": (agg[0] / agg[1] if agg[1] else 0.0), + } + for key, agg in self._timers.items() + } + return {"counters": counters, "timers": timers} + + def prometheus(self) -> str: + snap = self.snapshot() + lines = [] + for key, value in sorted(snap["counters"].items()): + lines.append(f"{key} {value}") + for key, agg in sorted(snap["timers"].items()): + base, _, labels = key.partition("{") + suffix = ("{" + labels) if labels else "" + lines.append(f"{base}_sum{suffix} {agg['sum']}") + lines.append(f"{base}_count{suffix} {agg['count']}") + return "\n".join(lines) + "\n" + + def reset(self) -> None: + with self._lock: + self._counters.clear() + self._timers.clear() + + +metrics = Metrics() + + +class timer: + """Context-Manager: misst die Dauer und schreibt sie als Timer-Beobachtung.""" + + def __init__(self, name: str, labels: dict | None = None): + self.name = name + self.labels = labels + + def __enter__(self): + self._start = time.perf_counter() + return self + + def __exit__(self, *exc): + metrics.observe(self.name, time.perf_counter() - self._start, self.labels) + return False diff --git a/app/pipeline/__init__.py b/app/pipeline/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/pipeline/german_numbers.py b/app/pipeline/german_numbers.py new file mode 100644 index 0000000..a45bf9d --- /dev/null +++ b/app/pipeline/german_numbers.py @@ -0,0 +1,37 @@ +"""Deutsche Ordinalzahlen 1.–31. (für Datums- und Aufzählungs-Aussprache). + +Bewusst eine kleine handgepflegte Tabelle statt `num2words`: die deutsche +Ordinal-Flexion ist mit num2words nicht sauber abbildbar, und der Bereich 1–31 +deckt Datumsangaben und Listenpositionen vollständig ab. +""" + +from __future__ import annotations + +# Stamm der Ordinalzahl (ohne Endung). attributiv = Stamm + "er" ("erster"), +# adverbial = Stamm + "ens" ("erstens"). +_STEMS: dict[int, str] = { + 1: "erst", 2: "zweit", 3: "dritt", 4: "viert", 5: "fünft", + 6: "sechst", 7: "siebt", 8: "acht", 9: "neunt", 10: "zehnt", + 11: "elft", 12: "zwölft", 13: "dreizehnt", 14: "vierzehnt", 15: "fünfzehnt", + 16: "sechzehnt", 17: "siebzehnt", 18: "achtzehnt", 19: "neunzehnt", 20: "zwanzigst", + 21: "einundzwanzigst", 22: "zweiundzwanzigst", 23: "dreiundzwanzigst", + 24: "vierundzwanzigst", 25: "fünfundzwanzigst", 26: "sechsundzwanzigst", + 27: "siebenundzwanzigst", 28: "achtundzwanzigst", 29: "neunundzwanzigst", + 30: "dreißigst", 31: "einunddreißigst", +} + +MIN, MAX = 1, 31 + + +def has_ordinal(n: int) -> bool: + return n in _STEMS + + +def ordinal_attributive(n: int) -> str: + """'1' -> 'erster' (z. B. 'erster Mai').""" + return _STEMS[n] + "er" + + +def ordinal_adverbial(n: int) -> str: + """'1' -> 'erstens' (Aufzählungen).""" + return _STEMS[n] + "ens" diff --git a/app/pipeline/input_cleaner.py b/app/pipeline/input_cleaner.py new file mode 100644 index 0000000..6d06fb9 --- /dev/null +++ b/app/pipeline/input_cleaner.py @@ -0,0 +1,4 @@ +class InputCleaner: + async def run(self, text: str) -> str: + cleaned = " ".join(text.strip().split()) + return cleaned.replace(" äh ", " ").replace(" hm ", " ") diff --git a/app/pipeline/sentence_chunker.py b/app/pipeline/sentence_chunker.py new file mode 100644 index 0000000..634e711 --- /dev/null +++ b/app/pipeline/sentence_chunker.py @@ -0,0 +1,75 @@ +import re + +# Satzende: . ! ? … gefolgt von Whitespace (oder Stringende beim flush). +_SENTENCE_END = re.compile(r"[.!?…]+(?=\s)") + +# Abkürzungen, nach denen NICHT getrennt werden darf (sonst zerschneidet der +# Streaming-Chunker mitten in "z. | B." und die Normalisierung greift nicht mehr). +_ABBREVS = ( + "z.b.", "z. b.", "d.h.", "d. h.", "u.a.", "u. a.", "bzw.", "ca.", "usw.", + "etc.", "dr.", "prof.", "nr.", "str.", "evtl.", "inkl.", "ggf.", "max.", + "min.", "vgl.", "sog.", "u.ä.", "o.ä.", "bspw.", +) +_ABBR_END = re.compile( + r"(?:^|[\s(„\"'])(" + "|".join(re.escape(a) for a in _ABBREVS) + r")$", + re.IGNORECASE, +) + + +class SentenceChunker: + """Inkrementelle Satzsegmentierung fuer gestreamte LLM-Token. + + `feed(delta)` liefert die seit dem letzten Aufruf fertig gewordenen Saetze, + `flush()` den verbleibenden Rest (z. B. der letzte Satz ohne abschliessendes + Leerzeichen). Damit kann pro Satz schon TTS erzeugt werden, waehrend das LLM + noch weiterschreibt. + + Es wird NICHT getrennt, wenn der Punkt zu einer Ordinal-/Datumszahl + ("1. Mai") oder einer bekannten Abkuerzung ("z. B.") gehoert. + """ + + def __init__(self): + self._buffer = "" + + def _is_real_end(self, match) -> bool: + start, end = match.start(), match.end() + punct = match.group() + dot_only = set(punct) <= {".", "…"} + if dot_only and start > 0: + prev = self._buffer[start - 1] + # Ziffer + Punkt ("1.") = Ordinal-/Listenmarker, kein Satzende. + if prev.isdigit(): + return False + # Einzelner Buchstabe + Punkt ("z. B.", Initialen "A.") -> kein Satzende. + if prev.isalpha(): + before = self._buffer[start - 2] if start >= 2 else "" + if before == "" or not before.isalpha(): + return False + # Bekannte (mehrbuchstabige) Abkuerzung vor dem Punkt -> kein Satzende. + if _ABBR_END.search(self._buffer[:end]): + return False + return True + + def feed(self, text: str) -> list[str]: + self._buffer += text + sentences: list[str] = [] + search_start = 0 + while True: + match = _SENTENCE_END.search(self._buffer, search_start) + if not match: + break + end = match.end() + if not self._is_real_end(match): + search_start = end # diese Stelle nicht trennen, weitersuchen + continue + sentence = self._buffer[:end].strip() + self._buffer = self._buffer[end:] + search_start = 0 + if sentence: + sentences.append(sentence) + return sentences + + def flush(self) -> str: + rest = self._buffer.strip() + self._buffer = "" + return rest diff --git a/app/pipeline/spoken_response_adapter.py b/app/pipeline/spoken_response_adapter.py new file mode 100644 index 0000000..fe1a705 --- /dev/null +++ b/app/pipeline/spoken_response_adapter.py @@ -0,0 +1,62 @@ +import re + +from app.pipeline import german_numbers as gn + +# Emojis/Piktogramme/Symbole: stoeren das Vorlesen (TTS spricht sie aus oder verschluckt +# sich). Deckt die gaengigen Unicode-Bloecke ab (Emoticons, Symbole, Transport, Flaggen, +# Dingbats, Pfeile, Variationsselektoren, ZWJ). +_EMOJI_RE = re.compile( + "[" + "\U0001F300-\U0001FAFF" # Symbole/Emoticons/Transport/Erweiterungen + "\U00002600-\U000027BF" # Diverse Symbole + Dingbats (☀ ⚠ ✅ ❤ …) + "\U0001F1E6-\U0001F1FF" # Regional-Indikatoren (Flaggen) + "\U00002B00-\U00002BFF" # Symbole/Pfeile (⭐ …) + "\U00002190-\U000021FF" # Pfeile (→ ← …) + "\U00002300-\U000023FF" # Technische Symbole (⌚ ⏰ …) + "\U0000FE00-\U0000FE0F" # Variationsselektoren + "\U0000200D" # Zero-Width-Joiner + "]+" +) + + +class SpokenResponseAdapter: + async def run(self, text: str, language: str = "de") -> str: + if not text: + return "" + + text = text.strip() + + # Markdown / Formatierung entfernen + text = re.sub(r"```[\s\S]*?```", " ", text) # code blocks + text = re.sub(r"`([^`]*)`", r"\1", text) # inline code + text = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", text) # markdown links + text = re.sub(r"[*_~#>]+", " ", text) # markdown symbols + text = _EMOJI_RE.sub("", text) # Emojis/Symbole + + # Listen entschärfen: Aufzählungspunkte weg, nummerierte Listen zu + # Ordinalwörtern ("1. " -> "erstens, "), damit Piper nicht "eins" sagt. + text = re.sub(r"(?m)^\s*[-•]\s+", "", text) + + def _numbered(m): + n = int(m.group(1)) + return f"{gn.ordinal_adverbial(n)}, " if gn.has_ordinal(n) else "" + + text = re.sub(r"(?m)^\s*(\d{1,2})\.\s+", _numbered, text) + + # Mehrfache Leerzeichen / Zeilenumbrüche glätten + text = re.sub(r"\s+", " ", text).strip() + + # Für Voice natürlicher machen: Doppelpunkte/Semikolons etwas beruhigen, + # aber Uhrzeiten/Verhältnisse (10:30) nicht zerstören -> nur am Wortende ersetzen. + text = re.sub(r"[:;](?=\s|$)", ",", text) + + # Klammern meist nicht gut für TTS + text = text.replace("(", ", ") + text = text.replace(")", " ") + + # Abschlusspunktion sicherstellen + if text and not text.endswith((".", "!", "?")): + text += "." + + return text + diff --git a/app/pipeline/tts_normalizer.py b/app/pipeline/tts_normalizer.py new file mode 100644 index 0000000..124b361 --- /dev/null +++ b/app/pipeline/tts_normalizer.py @@ -0,0 +1,165 @@ +"""Text-Normalisierung vor dem TTS. + +Leitprinzip: NICHT duplizieren, was espeak-ng (in piper) bereits gut kann +(Kardinal-/Dezimalzahlen). Nur die belegten Lücken füllen: Ordinalzahlen, +Einheiten-Abkürzungen, gängige Abkürzungen und ein optionales YAML-Lexikon. + +Stufen (`level`): + - "off": keine Änderung (Text unverändert durchreichen). + - "light": nur harmlose Glättung (URLs/E-Mails, Whitespace, Abschlusspunkt) – + für Cloud-TTS, das Zahlen/Abkürzungen selbst gut spricht. + - "full": zusätzlich Ordinalia, Einheiten, Abkürzungen, Lexikon, Symbole – + für lokales TTS (piper). +""" + +from __future__ import annotations + +import re +from functools import lru_cache +from pathlib import Path + +from app.pipeline import german_numbers as gn + +try: + import yaml +except ModuleNotFoundError: # pragma: no cover - YAML optional + yaml = None + +_CONFIG_DIR = Path(__file__).resolve().parents[2] / "config" + +_MONTHS = ( + "Januar|Februar|März|April|Mai|Juni|Juli|August|" + "September|Oktober|November|Dezember" +) +_MONTH_ORDINAL = re.compile(rf"\b(\d{{1,2}})\.\s+(?=(?:{_MONTHS})\b)") +# Aufzählungsmarker wie "1)" oder "1.)" -> adverbiale Ordinalzahl. +_ENUM_MARKER = re.compile(r"\b(\d{1,2})\.?\)") +# Folge von >=2 Zahl-Punkt-Markern ("1. 2. 3.") -> jeweils adverbial. +_ENUM_SEQ = re.compile(r"(?:(? tuple: + """Lädt config/pronunciation..yaml; gibt (abbrevs, units, terms) zurück.""" + abbrevs, units, terms = {}, {}, {} + path = _CONFIG_DIR / f"pronunciation.{language}.yaml" + if yaml is not None and path.exists(): + try: + data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + abbrevs = {str(k): str(v) for k, v in (data.get("abbreviations") or {}).items()} + units = {str(k): str(v) for k, v in (data.get("units") or {}).items()} + terms = {str(k): str(v) for k, v in (data.get("terms") or {}).items()} + except (OSError, ValueError, AttributeError): + pass # defektes YAML -> nur Defaults + return abbrevs, units, terms + + +def _apply_ordinals(text: str) -> str: + # Datum: "1. Mai" -> "erster Mai" + def _date(m): + n = int(m.group(1)) + return f"{gn.ordinal_attributive(n)} " if gn.has_ordinal(n) else m.group(0) + + text = _MONTH_ORDINAL.sub(_date, text) + + # Aufzählungs-Folge "1. 2. 3." -> "erstens zweitens drittens" (alle in der Folge). + def _num(m): + n = int(m.group(1)) + return f"{gn.ordinal_adverbial(n)} " if gn.has_ordinal(n) else m.group(0) + + text = _ENUM_SEQ.sub(lambda m: _NUM_DOT.sub(_num, m.group(0)), text) + + # Marker "1)" / "1.)" -> "erstens" + def _marker(m): + n = int(m.group(1)) + return gn.ordinal_adverbial(n) if gn.has_ordinal(n) else m.group(0) + + text = _ENUM_MARKER.sub(_marker, text) + return text + + +def _apply_units(text: str, units: dict) -> str: + # Nur DIREKT nach einer Zahl ersetzen, damit "Meter" nicht jedes "m" trifft. + # Längere Einheitenkürzel zuerst (km/h vor km, mm vor m). + for unit in sorted(units, key=len, reverse=True): + spoken = units[unit] + pattern = rf"(?<=\d)\s*{re.escape(unit)}\b" + text = re.sub(pattern, f" {spoken}", text) + return text + + +def _apply_dict(text: str, mapping: dict, ignore_case: bool = False) -> str: + # Längste Schlüssel zuerst, wortgrenzen-bewusst (Abkürzungen enden oft auf "."). + flags = re.IGNORECASE if ignore_case else 0 + for key in sorted(mapping, key=len, reverse=True): + repl = mapping[key] + if key.isalnum(): # reines Wort/Akronym -> mit Wortgrenzen + text = re.sub(rf"\b{re.escape(key)}\b", repl, text, flags=flags) + elif ignore_case: + text = re.sub(re.escape(key), repl, text, flags=re.IGNORECASE) + else: # enthält Punkte/Sonderzeichen -> direkte Ersetzung + text = text.replace(key, repl) + return text + + +class TTSNormalizer: + async def run(self, text: str, language: str = "de", level: str = "full") -> str: + if not text: + return "" + if level == "off": + return text + + t = text + + # --- immer (light + full): harmlose Glättung --- + t = re.sub(r"https?://\S+", "Link", t) + t = re.sub(r"\b[\w\.-]+@[\w\.-]+\.\w+\b", "E-Mail-Adresse", t) + + if level == "full": + abbrevs, units, terms = _load_lexicon(language) + + if language == "de": + t = _apply_ordinals(t) + t = _apply_units(t, {**_DEFAULT_UNITS_DE, **units}) + t = _apply_dict(t, {**_DEFAULT_ABBREVS_DE, **abbrevs}) + t = _apply_dict(t, terms, ignore_case=True) + t = _apply_dict(t, _DEFAULT_SYMBOLS_DE) + else: + t = _apply_units(t, units) + t = _apply_dict(t, abbrevs) + t = _apply_dict(t, terms, ignore_case=True) + t = _apply_dict(t, _DEFAULT_SYMBOLS_EN) + + # Slash zwischen Wörtern/Zahlen sprachfreundlich, Striche entschärfen. + t = re.sub(r"(\w)/(\w)", r"\1 oder \2", t) + t = t.replace("–", " bis ").replace("—", ", ").replace(" - ", ", ") + + # Mehrfache Leerzeichen glätten, Abschlusspunkt sicherstellen. + t = re.sub(r"\s+", " ", t).strip() + if t and not t.endswith((".", "!", "?")): + t += "." + return t diff --git a/app/providers/__init__.py b/app/providers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/providers/fallback.py b/app/providers/fallback.py new file mode 100644 index 0000000..5682f2d --- /dev/null +++ b/app/providers/fallback.py @@ -0,0 +1,104 @@ +"""Fallback-Ketten: versuchen mehrere Provider der Reihe nach. + +Faellt der primaere Provider aus (Timeout/Fehler), wird transparent der naechste +versucht. Erfolgreicher Fallback und Provider-Fehler werden als Metrik erfasst. +""" + +from collections.abc import AsyncIterator + +from app.metrics import metrics + + +class _Chain: + def __init__(self, module: str, entries: list[tuple[str, object]]): + self.module = module + self.entries = entries # [(provider_name, provider), ...] + + def _on_error(self, name: str) -> None: + metrics.inc("provider_error_total", {"module": self.module, "provider": name}) + + def _on_fallback(self) -> None: + metrics.inc("provider_fallback_total", {"module": self.module}) + + +class FallbackSTTProvider(_Chain): + async def transcribe(self, audio_bytes, fmt, language=None) -> str: + last_exc = None + for index, (name, provider) in enumerate(self.entries): + try: + result = await provider.transcribe(audio_bytes, fmt, language=language) + if index > 0: + self._on_fallback() + return result + except Exception as exc: # noqa: BLE001 - bewusst breit fuer Resilienz + last_exc = exc + self._on_error(name) + raise last_exc + + async def transcribe_detect(self, audio_bytes, fmt, language=None) -> tuple[str, str | None]: + last_exc = None + for index, (name, provider) in enumerate(self.entries): + try: + result = await provider.transcribe_detect(audio_bytes, fmt, language=language) + if index > 0: + self._on_fallback() + return result + except Exception as exc: # noqa: BLE001 + last_exc = exc + self._on_error(name) + raise last_exc + + +class FallbackLLMProvider(_Chain): + async def complete(self, text, history=None, session_id=None, language=None) -> str: + last_exc = None + for index, (name, provider) in enumerate(self.entries): + try: + result = await provider.complete( + text, history=history, session_id=session_id, language=language + ) + if index > 0: + self._on_fallback() + return result + except Exception as exc: # noqa: BLE001 + last_exc = exc + self._on_error(name) + raise last_exc + + async def stream(self, text, history=None, session_id=None, language=None) -> AsyncIterator[str]: + last_exc = None + for index, (name, provider) in enumerate(self.entries): + produced = False + try: + async for delta in provider.stream( + text, history=history, session_id=session_id, language=language + ): + produced = True + yield delta + if index > 0: + self._on_fallback() + return + except Exception as exc: # noqa: BLE001 + last_exc = exc + self._on_error(name) + if produced: + # Schon Token gesendet -> kein Fallback mehr moeglich. + raise + raise last_exc + + +class FallbackTTSProvider(_Chain): + async def synthesize(self, text, voice=None, audio_format="pcm", language=None) -> bytes: + last_exc = None + for index, (name, provider) in enumerate(self.entries): + try: + result = await provider.synthesize( + text, voice=voice, audio_format=audio_format, language=language + ) + if index > 0: + self._on_fallback() + return result + except Exception as exc: # noqa: BLE001 + last_exc = exc + self._on_error(name) + raise last_exc diff --git a/app/providers/llm/__init__.py b/app/providers/llm/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/providers/llm/base.py b/app/providers/llm/base.py new file mode 100644 index 0000000..a72a1e7 --- /dev/null +++ b/app/providers/llm/base.py @@ -0,0 +1,78 @@ +import json +from abc import ABC, abstractmethod +from collections.abc import AsyncIterator + +_LANG_NAMES: dict[str, str] = { + "de": "Deutsch", + "en": "English", + "fr": "Français", + "es": "Español", + "it": "Italiano", + "nl": "Nederlands", + "ru": "Русский", + "zh": "中文", + "cmn": "中文", +} + + +def lang_instruction(language: str | None) -> str | None: + """Sprach-Anweisung für den ISO-Code, oder None. + + Bewusst nachdrücklich formuliert: Eine lange anderssprachige History (z. B. der + Nutzer hat zwischendurch englisch gesprochen) soll die gewünschte Sprache NICHT + überstimmen. Wird zusätzlich nah an die Generierung gesetzt (siehe with_lang_reminder). + """ + if not language: + return None + name = _LANG_NAMES.get(language, language) + return f"Respond ONLY in {name}, regardless of the language used earlier in this conversation." + + +def with_lang_reminder(text: str, language: str | None) -> str: + """Hängt eine knappe Sprach-Erinnerung an die letzte Nutzer-Nachricht. + + Direkt vor der Generierung platziert wirkt sie stärker als der (weit zurückliegende) + System-Prompt, wenn die bisherige Unterhaltung in einer anderen Sprache lief. + """ + if not language: + return text + name = _LANG_NAMES.get(language, language) + return f"{text}\n\n[Bitte ausschließlich auf {name} antworten.]" + + +def sse_delta(line: str) -> str | None: + """Extrahiert das Token-Delta aus einer OpenAI-kompatiblen SSE-Zeile (oder None).""" + if not line.startswith("data:"): + return None + data = line[len("data:"):].strip() + if not data or data == "[DONE]": + return None + try: + obj = json.loads(data) + return obj["choices"][0]["delta"].get("content") + except (ValueError, KeyError, IndexError, TypeError): + return None + + +class LLMProvider(ABC): + @abstractmethod + async def complete( + self, + text: str, + history: list[dict] | None = None, + session_id: str | None = None, + language: str | None = None, + ) -> str: ... + + async def stream( + self, + text: str, + history: list[dict] | None = None, + session_id: str | None = None, + language: str | None = None, + ) -> AsyncIterator[str]: + """Token-Stream. Default: kein echtes Streaming -> komplette Antwort als ein Chunk. + + Provider mit SSE-Unterstuetzung ueberschreiben diese Methode. + """ + yield await self.complete(text, history=history, session_id=session_id, language=language) diff --git a/app/providers/llm/local_openai_compatible.py b/app/providers/llm/local_openai_compatible.py new file mode 100644 index 0000000..bb6e15d --- /dev/null +++ b/app/providers/llm/local_openai_compatible.py @@ -0,0 +1,122 @@ +from collections.abc import AsyncIterator + +import httpx + +from app.providers.llm.base import ( + LLMProvider, + lang_instruction, + with_lang_reminder, + sse_delta, +) + + +class LocalOpenAICompatibleLLM(LLMProvider): + def __init__( + self, + base_url: str, + api_key: str, + model: str, + system_prompt: str = "", + disable_reasoning: bool = True, + max_tokens: int = 0, + temperature: float = 0.3, + top_p: float = 0.9, + ): + self.base_url = base_url.rstrip("/") + self.api_key = api_key + self.model = model + self.system_prompt = (system_prompt or "").strip() + self.disable_reasoning = disable_reasoning + self.max_tokens = max_tokens + self.temperature = temperature + self.top_p = top_p + + def _build_messages( + self, text: str, history: list[dict] | None, language: str | None = None + ) -> list[dict]: + # Manche Chat-Templates (z. B. Qwen3) erlauben nur EINE System-Nachricht, + # ganz am Anfang. Daher den Sprach-System-Prompt und etwaige System- + # Nachrichten aus der History (z. B. Nutzer-Erinnerungen) zu einer einzigen + # fuehrenden System-Nachricht zusammenfuehren. + system_parts: list[str] = [] + if self.system_prompt: + system_parts.append(self.system_prompt) + rest: list[dict] = [] + for msg in history or []: + if msg.get("role") == "system": + content = (msg.get("content") or "").strip() + if content: + system_parts.append(content) + else: + rest.append(msg) + instr = lang_instruction(language) + if instr: + system_parts.append(instr) + + messages: list[dict] = [] + if system_parts: + messages.append({"role": "system", "content": "\n\n".join(system_parts)}) + messages.extend(rest) + # Sprach-Erinnerung direkt an der letzten Nutzer-Nachricht (schlägt History-Trägheit). + messages.append({"role": "user", "content": with_lang_reminder(text, language)}) + return messages + + def _payload( + self, text: str, history: list[dict] | None, stream: bool, language: str | None = None + ) -> dict: + payload: dict = { + "model": self.model, + "messages": self._build_messages(text, history, language=language), + "temperature": self.temperature, + "top_p": self.top_p, + } + if stream: + payload["stream"] = True + if self.max_tokens > 0: + payload["max_tokens"] = self.max_tokens + if self.disable_reasoning: + # Qwen3/llama.cpp: Denkphase abschalten -> schnellere erste Antwort. + payload["chat_template_kwargs"] = {"enable_thinking": False} + return payload + + async def complete( + self, + text: str, + history: list[dict] | None = None, + session_id: str | None = None, + language: str | None = None, + ) -> str: + async with httpx.AsyncClient(timeout=120) as client: + response = await client.post( + f"{self.base_url}/chat/completions", + headers={"Authorization": f"Bearer {self.api_key}"}, + json=self._payload(text, history, stream=False, language=language), + ) + response.raise_for_status() + data = response.json() + return data["choices"][0]["message"]["content"] + + async def stream( + self, + text: str, + history: list[dict] | None = None, + session_id: str | None = None, + language: str | None = None, + ) -> AsyncIterator[str]: + async with httpx.AsyncClient(timeout=120) as client: + async with client.stream( + "POST", + f"{self.base_url}/chat/completions", + headers={"Authorization": f"Bearer {self.api_key}"}, + json=self._payload(text, history, stream=True, language=language), + ) as response: + if response.status_code >= 400: + body = await response.aread() + raise RuntimeError( + f"Local LLM error {response.status_code}: " + f"{body.decode(errors='replace')}" + ) + async for line in response.aiter_lines(): + delta = sse_delta(line) + if delta: + yield delta diff --git a/app/providers/llm/openrouter.py b/app/providers/llm/openrouter.py new file mode 100644 index 0000000..408afa7 --- /dev/null +++ b/app/providers/llm/openrouter.py @@ -0,0 +1,166 @@ +from collections.abc import AsyncIterator + +import httpx + +from app.providers.llm.base import ( + LLMProvider, + lang_instruction, + with_lang_reminder, + sse_delta, +) + + +SYSTEM_PROMPT = """ +You are a voice assistant for spoken conversations with older adults. + +Speak naturally, clearly, and calmly. +Use short, simple sentences. +Prefer plain everyday language over technical wording. +Answer in the same language as the user, unless the user asks to switch languages. + +Important response rules: +- Output plain text only. +- No markdown. +- No bullet points. +- No numbered lists. +- No tables. +- No code. +- No emojis. +- No URLs unless the user explicitly asks for one. +- Do not use asterisks, hashtags, or formatting symbols. +- Do not write headings. +- Do not use long disclaimers. + +Voice style rules: +- Sound helpful, warm, and patient. +- Keep answers brief by default: 1 to 3 short sentences. +- If more detail is needed, explain step by step in natural spoken sentences. +- Ask at most one follow-up question at a time. +- If the answer contains several items, present them as natural speech, not as a list. +- Use wording that sounds good when spoken aloud. +- Avoid abbreviations when possible. +- Avoid symbols when words are better. +- Prefer complete spoken forms for dates, times, and numbers when useful. + +Safety and honesty rules: +- If you are unsure, say so briefly and clearly. +- Do not invent facts. +- If current real-world information is needed and unavailable, say that clearly. + +Always optimize your answer for listening, not for reading. +""".strip() + + +class OpenRouterLLMProvider(LLMProvider): + def __init__(self, api_key: str, model: str): + self.api_key = (api_key or "").strip() + self.model = (model or "").strip() + + def _build_messages( + self, text: str, history: list[dict] | None, language: str | None = None + ) -> list[dict]: + if not self.api_key: + raise ValueError("OPENROUTER_API_KEY is empty") + if not self.model: + raise ValueError("OPENROUTER_LLM_MODEL is empty") + if not text or not text.strip(): + raise ValueError("LLM input text is empty") + + system_content = SYSTEM_PROMPT + instr = lang_instruction(language) + if instr: + system_content = f"{SYSTEM_PROMPT}\n\n{instr}" + + messages = [{"role": "system", "content": system_content}] + if history: + messages.extend(history) + # Sprach-Erinnerung direkt an der letzten Nutzer-Nachricht (schlägt History-Trägheit). + messages.append({"role": "user", "content": with_lang_reminder(text.strip(), language)}) + return messages + + async def complete( + self, + text: str, + history: list[dict] | None = None, + session_id: str | None = None, + language: str | None = None, + ) -> str: + payload = { + "model": self.model, + "messages": self._build_messages(text, history, language=language), + } + + timeout = httpx.Timeout(connect=10.0, read=120.0, write=30.0, pool=10.0) + + async with httpx.AsyncClient(timeout=timeout) as client: + try: + response = await client.post( + "https://openrouter.ai/api/v1/chat/completions", + headers={ + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + }, + json=payload, + ) + response.raise_for_status() + except httpx.HTTPStatusError as exc: + raise RuntimeError( + f"OpenRouter LLM error {exc.response.status_code}: {exc.response.text}" + ) from exc + except httpx.TimeoutException as exc: + raise RuntimeError("OpenRouter LLM timeout") from exc + except httpx.HTTPError as exc: + raise RuntimeError(f"OpenRouter LLM transport error: {exc}") from exc + + data = response.json() + + try: + content = data["choices"][0]["message"]["content"] + except (KeyError, IndexError, TypeError) as exc: + raise RuntimeError(f"Unexpected OpenRouter LLM response: {data}") from exc + + if not content or not str(content).strip(): + raise RuntimeError("OpenRouter LLM returned empty content") + + return str(content).strip() + + async def stream( + self, + text: str, + history: list[dict] | None = None, + session_id: str | None = None, + language: str | None = None, + ) -> AsyncIterator[str]: + payload = { + "model": self.model, + "messages": self._build_messages(text, history, language=language), + "stream": True, + } + timeout = httpx.Timeout(connect=10.0, read=120.0, write=30.0, pool=10.0) + + async with httpx.AsyncClient(timeout=timeout) as client: + try: + async with client.stream( + "POST", + "https://openrouter.ai/api/v1/chat/completions", + headers={ + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + }, + json=payload, + ) as response: + if response.status_code >= 400: + body = await response.aread() + raise RuntimeError( + f"OpenRouter LLM error {response.status_code}: " + f"{body.decode(errors='replace')}" + ) + async for line in response.aiter_lines(): + delta = sse_delta(line) + if delta: + yield delta + except httpx.TimeoutException as exc: + raise RuntimeError("OpenRouter LLM timeout") from exc + except httpx.HTTPError as exc: + raise RuntimeError(f"OpenRouter LLM transport error: {exc}") from exc + diff --git a/app/providers/stt/__init__.py b/app/providers/stt/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/providers/stt/base.py b/app/providers/stt/base.py new file mode 100644 index 0000000..420e0b3 --- /dev/null +++ b/app/providers/stt/base.py @@ -0,0 +1,17 @@ +from abc import ABC, abstractmethod + + +class STTProvider(ABC): + @abstractmethod + async def transcribe(self, audio_bytes: bytes, fmt: str, language: str | None = None) -> str: ... + + async def transcribe_detect( + self, audio_bytes: bytes, fmt: str, language: str | None = None + ) -> tuple[str, str | None]: + """Transkribiert und liefert (text, detected_language). + + Standardimplementierung delegiert an transcribe(); detected_language=None. + Provider mit Spracherkennung überschreiben diese Methode. + """ + text = await self.transcribe(audio_bytes, fmt, language=language) + return text, None diff --git a/app/providers/stt/faster_whisper.py b/app/providers/stt/faster_whisper.py new file mode 100644 index 0000000..6cd4399 --- /dev/null +++ b/app/providers/stt/faster_whisper.py @@ -0,0 +1,61 @@ +"""Lokaler STT-Provider auf Basis von faster-whisper (CTranslate2). + +Optionale Dependency: `pip install -e .[local]`. Das Whisper-Modell wird beim +ersten Aufruf geladen (und ggf. heruntergeladen) und prozessweit zwischengespeichert. +Die Transkription ist CPU/GPU-lastig und laeuft daher in einem Thread, damit der +Event-Loop frei bleibt. +""" + +import asyncio +import io +from functools import lru_cache + +from app.config import settings +from app.providers.stt.base import STTProvider + + +@lru_cache(maxsize=2) +def _load_model(model_size: str, device: str, compute_type: str): + try: + from faster_whisper import WhisperModel + except ModuleNotFoundError as exc: # pragma: no cover - haengt von Installation ab + raise RuntimeError( + "faster-whisper ist nicht installiert. Installieren mit: pip install -e .[local]" + ) from exc + try: + return WhisperModel(model_size, device=device, compute_type=compute_type) + except Exception: + # GPU/Compute-Type nicht verfuegbar -> robuster CPU-Fallback (int8). + if device != "cpu": + return WhisperModel(model_size, device="cpu", compute_type="int8") + raise + + +class FasterWhisperProvider(STTProvider): + def __init__(self, model_size: str | None = None, device: str | None = None, + compute_type: str | None = None): + self.model_size = model_size or settings.faster_whisper_model + self.device = device or settings.faster_whisper_device + self.compute_type = compute_type or settings.faster_whisper_compute_type + + def _transcribe_sync( + self, audio_bytes: bytes, language: str | None + ) -> tuple[str, str | None]: + model = _load_model(self.model_size, self.device, self.compute_type) + segments, info = model.transcribe(io.BytesIO(audio_bytes), language=language) + text = "".join(segment.text for segment in segments).strip() + detected = getattr(info, "language", None) + return text, detected + + async def transcribe(self, audio_bytes: bytes, fmt: str, language: str | None = None) -> str: + if not audio_bytes: + raise ValueError("STT input audio is empty") + text, _ = await asyncio.to_thread(self._transcribe_sync, audio_bytes, language) + return text + + async def transcribe_detect( + self, audio_bytes: bytes, fmt: str, language: str | None = None + ) -> tuple[str, str | None]: + if not audio_bytes: + raise ValueError("STT input audio is empty") + return await asyncio.to_thread(self._transcribe_sync, audio_bytes, language) diff --git a/app/providers/stt/openrouter.py b/app/providers/stt/openrouter.py new file mode 100644 index 0000000..0b359d9 --- /dev/null +++ b/app/providers/stt/openrouter.py @@ -0,0 +1,99 @@ +import base64 + +import httpx + +from app.providers.stt.base import STTProvider + + +class OpenRouterSTTProvider(STTProvider): + def __init__(self, api_key: str, model: str): + self.api_key = (api_key or "").strip() + self.model = (model or "").strip() + + async def transcribe(self, audio_bytes: bytes, fmt: str, language: str | None = None) -> str: + if not self.api_key: + raise ValueError("OPENROUTER_API_KEY is empty") + if not self.model: + raise ValueError("OPENROUTER_STT_MODEL is empty") + if not audio_bytes: + raise ValueError("STT input audio is empty") + + # OpenRouter /audio/transcriptions erwartet JSON mit base64-Audio + # (NICHT multipart/form-data). Quelle: OpenRouter-Doku (STT). + payload = { + "model": self.model, + "input_audio": { + "data": base64.b64encode(audio_bytes).decode("utf-8"), + "format": fmt, + }, + } + if language: + payload["language"] = language + + timeout = httpx.Timeout(connect=10.0, read=120.0, write=30.0, pool=10.0) + + async with httpx.AsyncClient(timeout=timeout) as client: + try: + response = await client.post( + "https://openrouter.ai/api/v1/audio/transcriptions", + headers={ + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + }, + json=payload, + ) + response.raise_for_status() + except httpx.HTTPStatusError as exc: + raise RuntimeError( + f"OpenRouter STT error {exc.response.status_code}: {exc.response.text}" + ) from exc + except httpx.TimeoutException as exc: + raise RuntimeError("OpenRouter STT timeout") from exc + except httpx.HTTPError as exc: + raise RuntimeError(f"OpenRouter STT transport error: {exc}") from exc + + data = response.json() + return data.get("text", "") + + async def transcribe_detect( + self, audio_bytes: bytes, fmt: str, language: str | None = None + ) -> tuple[str, str | None]: + if not self.api_key: + raise ValueError("OPENROUTER_API_KEY is empty") + if not self.model: + raise ValueError("OPENROUTER_STT_MODEL is empty") + if not audio_bytes: + raise ValueError("STT input audio is empty") + + payload = { + "model": self.model, + "input_audio": { + "data": base64.b64encode(audio_bytes).decode("utf-8"), + "format": fmt, + }, + } + if language: + payload["language"] = language + + timeout = httpx.Timeout(connect=10.0, read=120.0, write=30.0, pool=10.0) + async with httpx.AsyncClient(timeout=timeout) as client: + try: + response = await client.post( + "https://openrouter.ai/api/v1/audio/transcriptions", + headers={ + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + }, + json=payload, + ) + response.raise_for_status() + except httpx.HTTPStatusError as exc: + raise RuntimeError( + f"OpenRouter STT error {exc.response.status_code}: {exc.response.text}" + ) from exc + except httpx.TimeoutException as exc: + raise RuntimeError("OpenRouter STT timeout") from exc + except httpx.HTTPError as exc: + raise RuntimeError(f"OpenRouter STT transport error: {exc}") from exc + data = response.json() + return data.get("text", ""), data.get("language") diff --git a/app/providers/tts/__init__.py b/app/providers/tts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/providers/tts/base.py b/app/providers/tts/base.py new file mode 100644 index 0000000..c7c6cba --- /dev/null +++ b/app/providers/tts/base.py @@ -0,0 +1,11 @@ +from abc import ABC, abstractmethod + +class TTSProvider(ABC): + @abstractmethod + async def synthesize( + self, + text: str, + voice: str | None = None, + audio_format: str = "pcm", + language: str | None = None, + ) -> bytes: ... diff --git a/app/providers/tts/chatterbox.py b/app/providers/tts/chatterbox.py new file mode 100644 index 0000000..0711aff --- /dev/null +++ b/app/providers/tts/chatterbox.py @@ -0,0 +1,134 @@ +"""TTS über den lokalen Chatterbox-HTTP-Service (Resemble AI, hohe Qualitaet + Voice-Cloning). + +Architektur wie beim llama.cpp-LLM: ein separater Dienst (eigene Conda-Env, GPU) haelt +das Modell geladen; dieser Provider ruft ihn per HTTP auf. Der Dienst ist job-basiert: +POST /speak -> /status pollen -> GET /audio/{id} (WAV). + +Wichtig: `no_playback=true` -> der Dienst spielt NICHT lokal ab, sondern liefert nur die +WAV (fuer Remote-/Gateway-Nutzung). Chatterbox ist neural und ~Echtzeit langsam -> als +QUALITAETS-Provider gedacht (piper bleibt der schnelle Default). +""" + +from __future__ import annotations + +import asyncio +import io +import time +import wave +from pathlib import Path + +import httpx + +from app.providers.tts.base import TTSProvider +from app.providers.tts.piper import _resample, _wrap_wav + + +class ChatterboxTTSProvider(TTSProvider): + def __init__( + self, + base_url: str = "http://127.0.0.1:9999", + voice: str = "", + lang: str = "de", + speed: float = 1.0, + target_rate: int = 24000, + timeout: int = 180, + voices_dir: str = "", + ): + self.base_url = base_url.rstrip("/") + self.voice = (voice or "").strip() # Pfad zu Referenz-WAV (Voice-Cloning) oder leer + self.lang = lang + self.speed = speed + self.target_rate = int(target_rate) + self.timeout = timeout + # Absolut auflösen: der Chatterbox-Dienst liest den Pfad mit eigenem CWD. + self.voices_dir = Path(voices_dir).expanduser().resolve() if voices_dir else None + + def _lang_ref(self, lang: str) -> str | None: + """Native Referenz-WAV der Sprache (Konvention /.wav), falls vorhanden.""" + if not self.voices_dir: + return None + path = self.voices_dir / f"{lang}.wav" + return str(path) if path.exists() else None + + def _ref_voice(self, voice: str | None, lang: str) -> str | None: + # 1. Explizit angefragte Stimme nur, wenn sie wie ein WAV-Pfad aussieht + # (Cloud-Stimmennamen wie "Zephyr"/"alloy" ignorieren). + cand = (voice or "").strip() + if cand.endswith(".wav"): + return cand + # 2. Native Stimme der jeweiligen Sprache, sonst die konfigurierte Default-Stimme. + return self._lang_ref(lang) or self.voice or None + + # Chatterbox ist mehrsprachig und klont die Stimme cross-lingual: die Referenz-WAV + # liefert das Timbre, `lang` die Aussprache. So spricht die Klon-Stimme jede Sprache. + _LANG_ALIASES = {"cmn": "zh"} + + async def synthesize( + self, + text: str, + voice: str | None = None, + audio_format: str = "pcm", + language: str | None = None, + ) -> bytes: + if not text or not text.strip(): + raise ValueError("TTS input text is empty") + + # Gesprächssprache bevorzugen; ohne Angabe der konfigurierte Default. + lang = (language or self.lang or "de").strip() + lang = self._LANG_ALIASES.get(lang, lang) + + payload = { + "text": text.strip(), + "lang": lang, + "speed": self.speed, + "keep_audio": True, + "no_playback": True, + } + ref = self._ref_voice(voice, lang) + if ref: + payload["voice"] = ref + + async with httpx.AsyncClient(timeout=self.timeout) as client: + resp = await client.post(f"{self.base_url}/speak", json=payload) + resp.raise_for_status() + job_id = resp.json()["job_id"] + wav_bytes = await self._await_audio(client, job_id) + + pcm, rate = self._wav_to_pcm(wav_bytes) + if rate != self.target_rate: + pcm = await asyncio.to_thread(_resample, pcm, rate, self.target_rate) + if audio_format == "wav": + return _wrap_wav(pcm, self.target_rate) + return pcm + + async def _await_audio(self, client: httpx.AsyncClient, job_id: str) -> bytes: + """Wartet (via /status) bis der Job fertig ist und laedt dann die WAV.""" + deadline = time.monotonic() + self.timeout + while True: + status = await client.get(f"{self.base_url}/status") + status.raise_for_status() + match = next( + (j for j in status.json().get("recent_jobs", []) if j["id"] == job_id), + None, + ) + if match: + if match["status"] == "done": + break + raise RuntimeError( + f"Chatterbox-Job {match['status']}: {match.get('error') or ''}" + ) + if time.monotonic() > deadline: + raise RuntimeError("Chatterbox-Timeout (Job nicht rechtzeitig fertig)") + await asyncio.sleep(0.3) + + audio = await client.get(f"{self.base_url}/audio/{job_id}") + if audio.status_code != 200 or not audio.content: + raise RuntimeError(f"Chatterbox-Audio {audio.status_code}: {audio.text[:200]}") + return audio.content + + @staticmethod + def _wav_to_pcm(wav_bytes: bytes) -> tuple[bytes, int]: + with wave.open(io.BytesIO(wav_bytes)) as w: + rate = w.getframerate() + frames = w.readframes(w.getnframes()) + return frames, rate diff --git a/app/providers/tts/openrouter.py b/app/providers/tts/openrouter.py new file mode 100644 index 0000000..dda8e5f --- /dev/null +++ b/app/providers/tts/openrouter.py @@ -0,0 +1,80 @@ +import asyncio + +import httpx + +from app.providers.tts.base import TTSProvider + +# Preview-TTS-Modelle liefern gelegentlich HTTP 200 mit LEEREM Body oder ein +# transientes 5xx. Solche Aussetzer kurz wiederholen, statt die Runde abzubrechen. +_MAX_ATTEMPTS = 3 +_RETRY_BACKOFF = 0.6 # Sekunden, linear ansteigend + + +class OpenRouterTTSProvider(TTSProvider): + def __init__(self, api_key: str, model: str, voice: str): + self.api_key = (api_key or "").strip() + self.model = (model or "").strip() + self.voice = (voice or "").strip() + + async def synthesize( + self, + text: str, + voice: str | None = None, + audio_format: str = "pcm", + language: str | None = None, # Cloud-TTS steuert Sprache über Stimme/Modell + ) -> bytes: + if not self.api_key: + raise ValueError("OPENROUTER_API_KEY is empty") + if not self.model: + raise ValueError("OPENROUTER_TTS_MODEL is empty") + if not text or not text.strip(): + raise ValueError("TTS input text is empty") + + effective_voice = (voice or self.voice).strip() + if not effective_voice: + raise ValueError("TTS voice is required for OpenRouter TTS") + + payload = { + "model": self.model, + "input": text.strip(), + "voice": effective_voice, + "response_format": audio_format, + } + + timeout = httpx.Timeout(connect=10.0, read=120.0, write=30.0, pool=10.0) + + async with httpx.AsyncClient(timeout=timeout) as client: + last_error = "OpenRouter TTS returned empty audio content" + for attempt in range(_MAX_ATTEMPTS): + try: + response = await client.post( + "https://openrouter.ai/api/v1/audio/speech", + headers={ + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + }, + json=payload, + ) + response.raise_for_status() + except httpx.HTTPStatusError as exc: + status = exc.response.status_code + # 4xx (z. B. ungueltige Stimme) ist nicht transient -> sofort melden. + if status < 500: + raise RuntimeError( + f"OpenRouter TTS error {status}: {exc.response.text}" + ) from exc + last_error = f"OpenRouter TTS error {status}: {exc.response.text}" + except httpx.TimeoutException: + last_error = "OpenRouter TTS timeout" + except httpx.HTTPError as exc: + raise RuntimeError(f"OpenRouter TTS transport error: {exc}") from exc + else: + if response.content: + return response.content + # HTTP 200 mit leerem Body -> transienter Aussetzer, erneut versuchen. + + if attempt < _MAX_ATTEMPTS - 1: + await asyncio.sleep(_RETRY_BACKOFF * (attempt + 1)) + + raise RuntimeError(last_error) + diff --git a/app/providers/tts/piper.py b/app/providers/tts/piper.py new file mode 100644 index 0000000..e1d102c --- /dev/null +++ b/app/providers/tts/piper.py @@ -0,0 +1,154 @@ +"""Lokales TTS über piper (CPU-freundlich, offline). + +Bevorzugt die **in-process** piper-Python-API: Das Stimmmodell wird EINMAL geladen +und prozessweit zwischengespeichert (lru_cache) - so entfaellt der teure Modell-Start +pro Satz (~2 s), der bei einem Subprozess-pro-Aufruf anfiel. Fehlt das Paket +(`pip install -e .[local]`), wird automatisch auf das piper-Binary zurueckgefallen. + +piper liefert s16le-Mono-PCM in der Sample-Rate des Modells; das Gateway erwartet +24000 Hz -> bei Abweichung wird resampelt (in-process via audioop, sonst ffmpeg). +""" + +from __future__ import annotations + +import asyncio +import io +import json +import shutil +import wave +from functools import lru_cache +from pathlib import Path + +from app.providers.tts.base import TTSProvider + +try: # bevorzugter Pfad: in-process, Modell bleibt geladen + from piper import PiperVoice # type: ignore + + _PIPER_LIB = True +except Exception: # pragma: no cover - Paket optional + PiperVoice = None # type: ignore + _PIPER_LIB = False + + +def _wrap_wav(pcm: bytes, sample_rate: int) -> bytes: + buf = io.BytesIO() + with wave.open(buf, "wb") as w: + w.setnchannels(1) + w.setsampwidth(2) + w.setframerate(sample_rate) + w.writeframes(pcm) + return buf.getvalue() + + +@lru_cache(maxsize=4) +def _load_voice(model_path: str): + """Laedt ein piper-Stimmmodell einmalig (prozessweit gecacht).""" + return PiperVoice.load(model_path) + + +def _resample(pcm: bytes, src_rate: int, dst_rate: int) -> bytes: + """Resampelt s16le-Mono in-process. audioop (stdlib) bevorzugt, sonst numpy.""" + try: + import audioop # in Python 3.13 entfernt -> Fallback unten + + converted, _ = audioop.ratecv(pcm, 2, 1, src_rate, dst_rate, None) + return converted + except Exception: + import numpy as np + + src = np.frombuffer(pcm, dtype=" tuple[Path, Path]: + # Angeforderte Stimme zuerst; passt sie nicht (z. B. eine Cloud-Stimme wie + # "Zephyr"/"alloy" aus der Route), auf die konfigurierte Default-Stimme zurueckfallen. + if not (voice or self.voice or "").strip(): + raise ValueError("Piper-Stimme ist nicht gesetzt (PIPER_VOICE)") + tried: list[str] = [] + for candidate in (voice, self.voice): + name = (candidate or "").strip() + if not name or name in tried: + continue + tried.append(name) + model = Path(name).expanduser() + if not model.suffix: # nur ein Name -> im Voices-Verzeichnis suchen + model = self.voices_dir / f"{name}.onnx" + if model.exists(): + return model, Path(f"{model}.json") + raise RuntimeError( + f"Piper-Stimmmodell nicht gefunden (versucht: {', '.join(tried)}) in {self.voices_dir}" + ) + + def _native_rate(self, config: Path) -> int: + try: + with open(config) as fh: + return int(json.load(fh)["audio"]["sample_rate"]) + except (OSError, KeyError, ValueError, TypeError): + return self.target_rate # Konfig unlesbar -> Resampling überspringen + + async def synthesize( + self, + text: str, + voice: str | None = None, + audio_format: str = "pcm", + language: str | None = None, # ignoriert: die Piper-Stimme kodiert die Sprache + ) -> bytes: + if not text or not text.strip(): + raise ValueError("TTS input text is empty") + + model, config = self._model_paths(voice) + pcm, native_rate = await asyncio.to_thread( + self._synthesize_sync, str(model), str(config), text.strip() + ) + if native_rate != self.target_rate: + pcm = await asyncio.to_thread(_resample, pcm, native_rate, self.target_rate) + + if audio_format == "wav": + return _wrap_wav(pcm, self.target_rate) + return pcm + + def _synthesize_sync(self, model: str, config: str, text: str) -> tuple[bytes, int]: + if _PIPER_LIB: + voice = _load_voice(model) + pcm = bytearray() + rate = self.target_rate + for chunk in voice.synthesize(text): + pcm += chunk.audio_int16_bytes + rate = chunk.sample_rate + if not pcm: + raise RuntimeError("Piper lieferte kein Audio") + return bytes(pcm), rate + # Fallback: piper-Binary (Subprozess pro Aufruf, langsamer). + return self._run_piper_binary(model, config, text) + + def _run_piper_binary(self, model: str, config: str, text: str) -> tuple[bytes, int]: + import subprocess + + if not (shutil.which(self.bin_path) or Path(self.bin_path).exists()): + raise RuntimeError(f"Piper-Binary nicht gefunden: {self.bin_path}") + cmd = [self.bin_path, "-m", model, "-c", config, "--output-raw"] + proc = subprocess.run(cmd, input=text.encode("utf-8"), capture_output=True) + if proc.returncode != 0: + detail = proc.stderr.decode("utf-8", "replace").strip()[:300] + raise RuntimeError(f"Piper-Fehler ({proc.returncode}): {detail}") + if not proc.stdout: + raise RuntimeError("Piper lieferte kein Audio") + return proc.stdout, self._native_rate(Path(config)) diff --git a/app/quota.py b/app/quota.py new file mode 100644 index 0000000..720ff0d --- /dev/null +++ b/app/quota.py @@ -0,0 +1,42 @@ +"""Pro-Nutzer-Tageskontingent (Kostenkontrolle). + +Limit aus Settings (`daily_request_limit`), pro Nutzer ueber `prefs.daily_request_limit` +ueberschreibbar. 0 bedeutet unbegrenzt. +""" + +from app.metrics import metrics + + +class QuotaExceededError(Exception): + def __init__(self, limit: int, count: int): + self.limit = limit + self.count = count + super().__init__(f"Daily request limit reached ({count}/{limit})") + + +def effective_limit(user, cfg=None) -> int: + if cfg is None: + from app.runtime_config import runtime_settings + cfg = runtime_settings + pref = user.prefs.get("daily_request_limit") if user and user.prefs else None + if pref is not None: + try: + return int(pref) + except (TypeError, ValueError): + pass + return cfg.daily_request_limit + + +def enforce_quota(user, store, cfg=None) -> None: + """Wirft QuotaExceededError, wenn das Tageslimit erreicht ist.""" + limit = effective_limit(user, cfg) + if limit and limit > 0: + count = store.get_request_count(user.id) + if count >= limit: + metrics.inc("quota_exceeded_total") + raise QuotaExceededError(limit, count) + + +def record_usage(user, store, units: int = 0) -> None: + store.add_usage(user.id, units) + metrics.inc("turns_total") diff --git a/app/runtime_config.py b/app/runtime_config.py new file mode 100644 index 0000000..231e413 --- /dev/null +++ b/app/runtime_config.py @@ -0,0 +1,99 @@ +"""Laufzeit-Konfigurationsüberschreibungen aus der Datenbank. + +Einzelne Settings-Felder können zur Laufzeit via Admin-UI geändert werden, +ohne den Server neu zu starten. Die Werte liegen in der Tabelle +`config_overrides` und werden mit 30s TTL gecacht. + +Nur Felder aus RUNTIME_SETTABLE sind überschreibbar — alle anderen +kommen weiterhin aus .env / TOML / pydantic-settings. +""" + +import threading +import time +from typing import Any + +from app.config import Settings, settings as _base + +# (label, type_str, hint) +RUNTIME_SETTABLE: dict[str, tuple[str, str, str]] = { + "default_stt_provider": ("STT-Provider (Standard)", "str", "openrouter | faster-whisper"), + "default_llm_provider": ("LLM-Provider (Standard)", "str", "openrouter | local-openai-compatible"), + "default_tts_provider": ("TTS-Provider (Standard)", "str", "openrouter | piper | chatterbox — Standard, pro Nutzer überschreibbar"), + "default_language": ("Sprache (Standard)", "str", "de | en | … — Standard, pro Nutzer überschreibbar"), + "default_language_mode": ("Sprachmodus (Standard)", "str", "fix | flex — Standard, pro Nutzer überschreibbar"), + "openrouter_llm_model": ("LLM-Modell (OpenRouter)", "str", "z.B. google/gemini-3.1-flash-lite"), + "openrouter_tts_model": ("TTS-Modell (OpenRouter)", "str", "z.B. google/gemini-3.1-flash-tts-preview"), + "openrouter_tts_voice": ("TTS-Stimme (OpenRouter)", "str", "z.B. Zephyr, Puck, Kore"), + "piper_voice": ("TTS-Stimme (piper)", "str", "z.B. de_DE-thorsten-high"), + "local_llm_system_prompt": ("Systemprompt (lokal)", "str", "Freier Text"), + "local_llm_temperature": ("Temperatur (lokal)", "float", "0.0–2.0 — wirkt sofort (kein Neustart)"), + "local_llm_top_p": ("Top-p (lokal)", "float", "0.0–1.0 — wirkt sofort (kein Neustart)"), + "local_llm_max_tokens": ("Max. Tokens (lokal)", "int", "0 = kein Limit"), + "tts_normalize_level": ("TTS-Normalisierung", "str", "auto | full | light | off"), + "audio_stream_default": ("Audio-Streaming Standard", "bool", "true | false"), + "memory_extraction_enabled": ("Erinnerungs-Extraktion", "bool", "true | false"), + "memory_extraction_every_n_turns": ("Extraktion alle N Turns", "int", "z.B. 3"), + "daily_request_limit": ("Tageskontingent (global)", "int", "0 = unbegrenzt"), +} + +_TTL = 30.0 +_cache: dict[str, str] = {} +_cache_time: float = 0.0 +_lock = threading.Lock() + + +def _coerce(key: str, raw: str) -> Any: + _, type_str, _ = RUNTIME_SETTABLE[key] + try: + if type_str == "bool": + return raw.strip().lower() in ("1", "true", "yes") + if type_str == "int": + return int(raw) + if type_str == "float": + return float(raw) + except (ValueError, AttributeError): + pass + return raw + + +def _get_cache() -> dict[str, str]: + global _cache, _cache_time + now = time.monotonic() + with _lock: + if now - _cache_time < _TTL: + return _cache + try: + from app.dependencies import get_store + _cache = get_store().get_config_overrides() + _cache_time = now + except Exception: + pass + return _cache + + +def invalidate_cache() -> None: + global _cache_time + with _lock: + _cache_time = 0.0 + + +class RuntimeSettings: + """Wraps Settings; liest überschreibbare Felder aus der DB (30s TTL).""" + + def __init__(self, base: Settings): + object.__setattr__(self, "_base", base) + + def __getattr__(self, name: str) -> Any: + if name in RUNTIME_SETTABLE: + overrides = _get_cache() + if name in overrides: + return _coerce(name, overrides[name]) + return getattr(object.__getattribute__(self, "_base"), name) + + # Delegiere Pydantic-Metadaten ans Basis-Objekt. + @property + def model_fields(self): + return self._base.model_fields + + +runtime_settings = RuntimeSettings(_base) diff --git a/app/safety/__init__.py b/app/safety/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/safety/emergency.py b/app/safety/emergency.py new file mode 100644 index 0000000..2862df0 --- /dev/null +++ b/app/safety/emergency.py @@ -0,0 +1,140 @@ +"""Notfall-Erkennung und Eskalation (Senioren-Kontext). + +Zweistufig: +1. Schnelle Stichwort-Heuristik (`detect` / `handle_emergency`) im Hot-Path -> 0 Latenz. +2. LLM-Klassifikation (`schedule_llm_emergency_check`) als Hintergrund-Task, der NUR + laeuft, wenn die Heuristik nichts fand -> faengt verpasste Formulierungen, ohne die + Antwortlatenz zu erhoehen. + +Die erkannten Textauszuege sind hochsensibel und werden bewusst protokolliert +(DSGVO beachten: Einwilligung, Aufbewahrung, Zugriff). +""" + +import asyncio +import logging + +import httpx + +from app.config import settings, Settings +from app.metrics import metrics + +logger = logging.getLogger(__name__) + +# Referenzen auf laufende Hintergrund-Tasks halten (sonst GC-gefaehrdet). +_pending: set[asyncio.Task] = set() + +# Phrasen je Kategorie (de/en), bewusst eher spezifisch gegen Fehlalarme. +_PATTERNS: dict[str, list[str]] = { + "medical": [ + "brustschmerz", "schmerzen in der brust", "kann nicht atmen", "keine luft", + "atemnot", "herzinfarkt", "schlaganfall", "bewusstlos", "gestuerzt", "gestürzt", + "gefallen und komme nicht hoch", "starke blutung", + "chest pain", "can't breathe", "cannot breathe", "heart attack", "stroke", + "i fell and can't", "bleeding badly", + ], + "self_harm": [ + "nicht mehr leben", "mich umbringen", "selbstmord", "suizid", "will sterben", + "kill myself", "end my life", "suicide", "want to die", + ], + "help": [ + "notruf", "notarzt", "krankenwagen", "ruf einen arzt", "es brennt", + "call an ambulance", "call 911", "call 112", + ], +} + + +def detect(text: str): + """Liefert (category, matched_phrase) oder None.""" + if not text: + return None + low = text.lower() + for category, phrases in _PATTERNS.items(): + for phrase in phrases: + if phrase in low: + return category, phrase + return None + + +async def _fire_webhook(url: str, user, category: str, snippet: str) -> None: + try: + async with httpx.AsyncClient(timeout=5) as client: + await client.post( + url, + json={ + "user_id": user.id, + "display_name": user.display_name, + "category": category, + "text": snippet, + }, + ) + except Exception: # noqa: BLE001 - best effort, darf den Chat nicht brechen + metrics.inc("emergency_webhook_error_total") + + +def _escalate(user, category: str, snippet: str, store, cfg: Settings, source: str) -> None: + """Protokolliert + eskaliert einen erkannten Notfall (Log, Metrik, Webhook).""" + store.log_emergency(user.id, category, snippet) + metrics.inc("emergency_total", {"category": category, "source": source}) + if cfg.emergency_webhook_url: + try: + asyncio.get_running_loop().create_task( + _fire_webhook(cfg.emergency_webhook_url, user, category, snippet) + ) + except RuntimeError: + pass # kein laufender Event-Loop (z. B. im Test) -> Webhook ueberspringen + + +def handle_emergency(user, text: str, store, cfg: Settings = settings): + """Stufe 1: Stichwort-Heuristik. Erkennt, protokolliert und eskaliert sofort. + + Gibt {"category", "matched"} zurueck, wenn etwas erkannt wurde, sonst None. + Der Webhook (falls konfiguriert) wird nicht-blockierend ausgeloest. + """ + match = detect(text) + if not match: + return None + category, phrase = match + _escalate(user, category, text[:500], store, cfg, source="keyword") + return {"category": category, "matched": phrase} + + +async def _llm_check(user, text: str, store, cfg: Settings, on_emergency) -> None: + """Hintergrund: LLM-Klassifikation + Eskalation (best-effort).""" + try: + from app.safety.llm_classifier import classify_emergency + + result = await classify_emergency(text, cfg) + if not result: + return + category = result["category"] + _escalate(user, category, text[:500], store, cfg, source="llm") + logger.info( + "llm-emergency: %s (conf=%.2f) fuer %s", category, result["confidence"], user.id + ) + if on_emergency is not None: + await on_emergency(category) + except Exception: # best-effort: darf den Turn nie brechen + logger.exception("llm-emergency-check fehlgeschlagen (ignoriert)") + + +def schedule_llm_emergency_check(user, text: str, store, keyword_hit, + cfg: Settings = settings, on_emergency=None): + """Stufe 2: plant die LLM-Klassifikation als Hintergrund-Task. + + Laeuft NUR, wenn die Stichwort-Heuristik nichts fand (`keyword_hit` ist None) und + die LLM-Stufe aktiviert ist. `on_emergency(category)` (async) wird bei Treffer + aufgerufen (z. B. WS-Event). Gibt den Task zurueck oder None - blockiert nie. + """ + if keyword_hit is not None or not cfg.emergency_llm_enabled: + return None + if not text or not text.strip(): + return None + try: + task = asyncio.get_running_loop().create_task( + _llm_check(user, text, store, cfg, on_emergency) + ) + except RuntimeError: + return None # kein laufender Event-Loop (z. B. Test) -> ueberspringen + _pending.add(task) + task.add_done_callback(_pending.discard) + return task diff --git a/app/safety/llm_classifier.py b/app/safety/llm_classifier.py new file mode 100644 index 0000000..6e54c2a --- /dev/null +++ b/app/safety/llm_classifier.py @@ -0,0 +1,96 @@ +"""LLM-Notfall-Klassifikation (zweite Stufe der Notfall-Erkennung). + +Ergaenzt die schnelle Stichwort-Heuristik (`app.safety.emergency.detect`) um einen +LLM-Klassifikator, der Formulierungen erkennt, die keine Stichwoerter treffen. + +Bewusst **best-effort** und mit Konfidenz-Schwelle (sensibler Senioren-Kontext): +Ein LLM-Fehler oder kaputtes JSON fuehrt nie zu einem Alarm und nie zu einem Fehler +im Antwort-Turn. +""" + +import json +import logging +import re + +from app.config import Settings, settings + +logger = logging.getLogger(__name__) + +# Gueltige Notfall-Kategorien (deckungsgleich mit der Stichwort-Heuristik). +VALID_CATEGORIES = {"medical", "self_harm", "help"} + +_SYSTEM_PROMPT = ( + "Du bist ein Sicherheits-Klassifikator fuer einen Senioren-Sprachassistenten. " + "Beurteile, ob die Nutzeraeusserung einen akuten Notfall beschreibt. Kategorien: " + "'medical' (akute medizinische Notlage, z. B. Brustschmerz, Atemnot, Sturz, " + "Schlaganfall), 'self_harm' (Suizidalitaet/Selbstgefaehrdung), 'help' (akuter " + "Hilferuf, z. B. Feuer, Notruf), 'none' (kein Notfall). Antworte AUSSCHLIESSLICH " + "mit JSON: {\"category\": \"medical|self_harm|help|none\", \"confidence\": 0.0-1.0, " + "\"reason\": \"kurze Begruendung\"}. Sei zurueckhaltend: nur echte, akute Notlagen " + "sind ein Notfall, keine beilaeufigen Erwaehnungen oder Vergangenes." +) + + +def _build_classifier_llm(cfg: Settings): + """Baut eine eigene LLM-Instanz fuer die Klassifikation (eigener JSON-Prompt).""" + provider = cfg.emergency_llm_provider or cfg.default_llm_provider + if provider == "local-openai-compatible": + from app.providers.llm.local_openai_compatible import LocalOpenAICompatibleLLM + + return LocalOpenAICompatibleLLM( + cfg.local_llm_base_url, + cfg.local_llm_api_key, + cfg.local_llm_model, + system_prompt=_SYSTEM_PROMPT, + disable_reasoning=True, + max_tokens=128, + temperature=0.0, + ) + + from app.dependencies import get_llm_provider + + return get_llm_provider(provider, cfg) + + +def parse_classification(raw: str) -> dict | None: + """Liest {category, confidence, reason} aus der (evtl. verrauschten) LLM-Antwort.""" + if not raw: + return None + match = re.search(r"\{.*\}", raw, re.DOTALL) + if not match: + return None + try: + data = json.loads(match.group(0)) + except ValueError: + return None + if not isinstance(data, dict): + return None + category = data.get("category") + if category not in VALID_CATEGORIES: + return None + try: + confidence = float(data.get("confidence", 0.0)) + except (TypeError, ValueError): + confidence = 0.0 + return { + "category": category, + "confidence": confidence, + "reason": str(data.get("reason", "")), + } + + +async def classify_emergency(text: str, cfg: Settings = settings) -> dict | None: + """Klassifiziert eine Aeusserung. Liefert {category, confidence, reason} oder None. + + None bedeutet: kein Notfall (bzw. unter der Konfidenz-Schwelle / nicht parsebar). + """ + if not text or not text.strip(): + return None + llm = _build_classifier_llm(cfg) + raw = await llm.complete(text) + result = parse_classification(raw) + if result is None: + return None + if result["confidence"] < cfg.emergency_llm_min_confidence: + return None + return result diff --git a/app/schemas.py b/app/schemas.py new file mode 100644 index 0000000..8fb6e6e --- /dev/null +++ b/app/schemas.py @@ -0,0 +1,107 @@ +from typing import Literal +from pydantic import BaseModel, Field + + +class EndpointCapabilities(BaseModel): + id: str + kind: str + direction: Literal["input", "output"] + sample_rate: int = 16000 + channels: int = 1 + latency_class: Literal["low", "medium", "high"] = "medium" + supports_aec: bool = False + supports_barge_in: bool = False + networked: bool = False + bluetooth: bool = False + mobile: bool = False + default: bool = False + + +class AudioChunk(BaseModel): + data: bytes + sample_rate: int = 16000 + channels: int = 1 + format: str = "wav" + timestamp_ms: int = 0 + + +class PipelineTrace(BaseModel): + raw_transcript: str | None = None + cleaned_transcript: str | None = None + semantic_response: str | None = None + spoken_response: str | None = None + tts_ready_text: str | None = None + + +class SpeakRequest(BaseModel): + text: str = Field(min_length=1) + voice: str | None = None + language: str | None = None + output_endpoint: str | None = None + tts_provider: str | None = None + + +class ChatRequest(BaseModel): + text: str = Field(min_length=1) + input_endpoint: str | None = None + output_endpoint: str | None = None + language: str | None = None + language_mode: Literal["fix", "flex"] | None = None + voice: str | None = None + stt_provider: str | None = None + llm_provider: str | None = None + tts_provider: str | None = None + text_only: bool | None = None # True -> kein Server-Audio (Geräte-TTS spricht selbst) + + +class SessionRouteRequest(BaseModel): + input_endpoint: str | None = None + output_endpoint: str | None = None + stt_provider: str | None = None + llm_provider: str | None = None + tts_provider: str | None = None + language: str | None = None + + +class RouteInfo(BaseModel): + input_endpoint: str + output_endpoint: str + stt_provider: str + llm_provider: str + tts_provider: str + language: str + + +class UserCreate(BaseModel): + display_name: str = Field(min_length=1) + + +class UserCreated(BaseModel): + user_id: str + display_name: str + token: str # nur bei Erstellung sichtbar + + +class UserUpdate(BaseModel): + display_name: str = Field(min_length=1) + + +class UserPrefs(BaseModel): + input_endpoint: str | None = None + output_endpoint: str | None = None + stt_provider: str | None = None + llm_provider: str | None = None + tts_provider: str | None = None + language: str | None = None + language_mode: Literal["fix", "flex"] | None = None + + +class MemoryCreate(BaseModel): + content: str = Field(min_length=1) + + +class MemoryOut(BaseModel): + id: int + content: str + created_at: str + diff --git a/app/store.py b/app/store.py new file mode 100644 index 0000000..d7624ba --- /dev/null +++ b/app/store.py @@ -0,0 +1,585 @@ +"""Persistenzschicht: Nutzer und Sessions. + +Ein abstraktes Store-Interface mit SQLite-Default (stdlib). Spaetere Backends +(Postgres/Redis) koennen dasselbe Interface implementieren, ohne die App zu aendern. +""" + +from __future__ import annotations + +import json +import hashlib +import secrets +import sqlite3 +import uuid +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path + +ANONYMOUS_USER_ID = "anonymous" + + +def hash_token(raw_token: str) -> str: + return hashlib.sha256(raw_token.encode("utf-8")).hexdigest() + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat() + + +@dataclass +class User: + id: str + display_name: str + prefs: dict = field(default_factory=dict) + created_at: str = "" + external_id: str | None = None # SSO-/Proxy-Identitaet (Forward-Auth) + is_admin: bool = False # transient, aus ADMIN_USERS abgeleitet + + +@dataclass +class Session: + id: str + user_id: str + data: dict = field(default_factory=dict) + + +@dataclass +class Memory: + id: int + content: str + created_at: str = "" + + +class SessionOwnershipError(Exception): + """Eine Session gehoert einem anderen Nutzer (-> HTTP 403).""" + + +class Store(ABC): + @abstractmethod + def create_user(self, display_name: str) -> tuple[User, str]: + """Legt einen Nutzer an und liefert (User, Klartext-Token). Token nur hier sichtbar.""" + + @abstractmethod + def get_user_by_token(self, raw_token: str) -> User | None: ... + + @abstractmethod + def get_user(self, user_id: str) -> User | None: ... + + @abstractmethod + def set_user_prefs(self, user_id: str, prefs: dict) -> User: ... + + @abstractmethod + def ensure_anonymous_user(self) -> User: ... + + @abstractmethod + def list_users(self) -> list[User]: ... + + @abstractmethod + def get_user_by_external_id(self, external_id: str) -> User | None: ... + + @abstractmethod + def get_or_create_user_by_external_id( + self, external_id: str, display_name: str | None = None + ) -> User: ... + + @abstractmethod + def get_session(self, session_id: str) -> Session | None: ... + + @abstractmethod + def update_session(self, session_id: str, user_id: str, values: dict) -> Session: + """Erstellt/aktualisiert eine Session des Nutzers. Fremde Session -> SessionOwnershipError.""" + + @abstractmethod + def append_message(self, session_id: str, user_id: str, role: str, content: str) -> None: + """Haengt eine Nachricht an die Session an. Fremde Session -> SessionOwnershipError.""" + + @abstractmethod + def get_recent_messages(self, session_id: str, limit: int) -> list[dict]: + """Liefert die letzten `limit` Nachrichten chronologisch ([{'role','content'}, ...]).""" + + @abstractmethod + def add_memory(self, user_id: str, content: str) -> Memory: + """Speichert eine dauerhafte Erinnerung (Fakt/Vorliebe) zum Nutzer.""" + + @abstractmethod + def get_memories(self, user_id: str) -> list[Memory]: + """Liefert alle Erinnerungen des Nutzers (chronologisch).""" + + @abstractmethod + def delete_memory(self, user_id: str, memory_id: int) -> bool: + """Loescht eine Erinnerung des Nutzers. True, wenn etwas geloescht wurde.""" + + @abstractmethod + def get_request_count(self, user_id: str, day: str | None = None) -> int: + """Anzahl der Anfragen des Nutzers am angegebenen Tag (Default: heute, UTC).""" + + @abstractmethod + def add_usage(self, user_id: str, units: int = 0, day: str | None = None) -> int: + """Zaehlt eine Anfrage (+units) und liefert die neue Tages-Anfragezahl.""" + + @abstractmethod + def delete_user(self, user_id: str) -> bool: + """Loescht einen Nutzer und alle seine Daten (Sessions, Nachrichten, Erinnerungen, + Nutzungsdaten). Anonymer Nutzer kann nicht geloescht werden. + Liefert True, wenn der Nutzer existierte und geloescht wurde.""" + + @abstractmethod + def reset_token(self, user_id: str) -> tuple[User, str] | None: + """Generiert einen neuen Token fuer den Nutzer; der alte wird sofort ungueltig. + Liefert (User, Klartext-Token) oder None, wenn der Nutzer nicht existiert.""" + + @abstractmethod + def update_display_name(self, user_id: str, display_name: str) -> User | None: + """Aktualisiert den Anzeigenamen. None wenn nicht gefunden.""" + + @abstractmethod + def log_emergency(self, user_id: str, category: str, snippet: str) -> None: + """Protokolliert ein erkanntes Notfall-Signal (sensibel!).""" + + @abstractmethod + def list_sessions_for_user(self, user_id: str) -> list[dict]: ... + + @abstractmethod + def get_messages_for_session(self, session_id: str, limit: int = 200) -> list[dict]: ... + + @abstractmethod + def list_emergency_events(self, limit: int = 50) -> list[dict]: ... + + @abstractmethod + def get_usage_for_user(self, user_id: str) -> list[dict]: ... + + @abstractmethod + def get_all_usage(self) -> list[dict]: ... + + @abstractmethod + def get_config_overrides(self) -> dict[str, str]: ... + + @abstractmethod + def set_config_override(self, key: str, value: str) -> None: ... + + @abstractmethod + def delete_config_override(self, key: str) -> bool: ... + + +class SQLiteStore(Store): + def __init__(self, db_path: str): + self.db_path = db_path + Path(db_path).parent.mkdir(parents=True, exist_ok=True) + self._init_schema() + + def _connect(self) -> sqlite3.Connection: + conn = sqlite3.connect(self.db_path) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA foreign_keys=ON") + return conn + + def _init_schema(self) -> None: + with self._connect() as conn: + conn.executescript( + """ + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + display_name TEXT NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + prefs_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL, + external_id TEXT + ); + CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + data_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + role TEXT NOT NULL, + content TEXT NOT NULL, + created_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_messages_session + ON messages(session_id, id); + CREATE TABLE IF NOT EXISTS memories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + content TEXT NOT NULL, + created_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_memories_user + ON memories(user_id, id); + CREATE TABLE IF NOT EXISTS usage ( + user_id TEXT NOT NULL, + day TEXT NOT NULL, + requests INTEGER NOT NULL DEFAULT 0, + units INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, day) + ); + CREATE TABLE IF NOT EXISTS emergency_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + category TEXT NOT NULL, + snippet TEXT NOT NULL, + created_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS config_overrides ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + """ + ) + # Migration fuer bestehende DBs: external_id ergaenzen (falls noch nicht da). + cols = {row["name"] for row in conn.execute("PRAGMA table_info(users)")} + if "external_id" not in cols: + conn.execute("ALTER TABLE users ADD COLUMN external_id TEXT") + # NULLs gelten in SQLite als verschieden -> Alt-Nutzer ohne external_id ok. + conn.execute( + "CREATE UNIQUE INDEX IF NOT EXISTS idx_users_external" + " ON users(external_id)" + ) + + # ----- Nutzer ----------------------------------------------------------- + def _row_to_user(self, row: sqlite3.Row) -> User: + keys = row.keys() + return User( + id=row["id"], + display_name=row["display_name"], + prefs=json.loads(row["prefs_json"] or "{}"), + created_at=row["created_at"], + external_id=row["external_id"] if "external_id" in keys else None, + ) + + def create_user(self, display_name: str) -> tuple[User, str]: + raw_token = secrets.token_urlsafe(32) + user = User(id=uuid.uuid4().hex, display_name=display_name, prefs={}, created_at=_now()) + with self._connect() as conn: + conn.execute( + "INSERT INTO users (id, display_name, token_hash, prefs_json, created_at)" + " VALUES (?, ?, ?, ?, ?)", + (user.id, user.display_name, hash_token(raw_token), "{}", user.created_at), + ) + return user, raw_token + + def get_user_by_token(self, raw_token: str) -> User | None: + with self._connect() as conn: + row = conn.execute( + "SELECT * FROM users WHERE token_hash = ?", (hash_token(raw_token),) + ).fetchone() + return self._row_to_user(row) if row else None + + def get_user(self, user_id: str) -> User | None: + with self._connect() as conn: + row = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone() + return self._row_to_user(row) if row else None + + def set_user_prefs(self, user_id: str, prefs: dict) -> User: + with self._connect() as conn: + conn.execute( + "UPDATE users SET prefs_json = ? WHERE id = ?", + (json.dumps(prefs), user_id), + ) + row = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone() + if row is None: + raise KeyError(f"Unbekannter Nutzer: {user_id}") + return self._row_to_user(row) + + def ensure_anonymous_user(self) -> User: + existing = self.get_user(ANONYMOUS_USER_ID) + if existing: + return existing + with self._connect() as conn: + conn.execute( + "INSERT OR IGNORE INTO users (id, display_name, token_hash, prefs_json, created_at)" + " VALUES (?, ?, ?, ?, ?)", + (ANONYMOUS_USER_ID, "Anonymous", f"anon-{ANONYMOUS_USER_ID}", "{}", _now()), + ) + return self.get_user(ANONYMOUS_USER_ID) + + def list_users(self) -> list[User]: + with self._connect() as conn: + rows = conn.execute( + "SELECT * FROM users WHERE id != ? ORDER BY created_at", + (ANONYMOUS_USER_ID,), + ).fetchall() + return [self._row_to_user(row) for row in rows] + + def get_user_by_external_id(self, external_id: str) -> User | None: + with self._connect() as conn: + row = conn.execute( + "SELECT * FROM users WHERE external_id = ?", (external_id,) + ).fetchone() + return self._row_to_user(row) if row else None + + def get_or_create_user_by_external_id( + self, external_id: str, display_name: str | None = None + ) -> User: + """Findet den Nutzer zur SSO-/Proxy-Identitaet oder legt ihn an (Forward-Auth).""" + existing = self.get_user_by_external_id(external_id) + if existing: + return existing + user = User( + id=uuid.uuid4().hex, + display_name=display_name or external_id, + prefs={}, + created_at=_now(), + external_id=external_id, + ) + with self._connect() as conn: + conn.execute( + "INSERT INTO users (id, display_name, token_hash, prefs_json, created_at," + " external_id) VALUES (?, ?, ?, ?, ?, ?)", + # token_hash ist NOT NULL UNIQUE -> synthetischer, kollisionsfreier Platzhalter + # (SSO-Nutzer authentifizieren sich nicht ueber ein Token). + (user.id, user.display_name, f"ext:{external_id}", "{}", + user.created_at, external_id), + ) + return user + + # ----- Sessions --------------------------------------------------------- + def get_session(self, session_id: str) -> Session | None: + with self._connect() as conn: + row = conn.execute( + "SELECT * FROM sessions WHERE id = ?", (session_id,) + ).fetchone() + if row is None: + return None + return Session(id=row["id"], user_id=row["user_id"], data=json.loads(row["data_json"] or "{}")) + + def update_session(self, session_id: str, user_id: str, values: dict) -> Session: + existing = self.get_session(session_id) + if existing and existing.user_id != user_id: + raise SessionOwnershipError( + f"Session {session_id!r} gehoert einem anderen Nutzer" + ) + + data = dict(existing.data) if existing else {} + data.update({k: v for k, v in values.items() if v is not None}) + payload = json.dumps(data) + now = _now() + + with self._connect() as conn: + if existing: + conn.execute( + "UPDATE sessions SET data_json = ?, updated_at = ? WHERE id = ?", + (payload, now, session_id), + ) + else: + conn.execute( + "INSERT INTO sessions (id, user_id, data_json, created_at, updated_at)" + " VALUES (?, ?, ?, ?, ?)", + (session_id, user_id, payload, now, now), + ) + return Session(id=session_id, user_id=user_id, data=data) + + # ----- Nachrichten / Gespraechsverlauf ---------------------------------- + def append_message(self, session_id: str, user_id: str, role: str, content: str) -> None: + existing = self.get_session(session_id) + if existing and existing.user_id != user_id: + raise SessionOwnershipError( + f"Session {session_id!r} gehoert einem anderen Nutzer" + ) + now = _now() + with self._connect() as conn: + if not existing: + conn.execute( + "INSERT INTO sessions (id, user_id, data_json, created_at, updated_at)" + " VALUES (?, ?, ?, ?, ?)", + (session_id, user_id, "{}", now, now), + ) + conn.execute( + "INSERT INTO messages (session_id, role, content, created_at)" + " VALUES (?, ?, ?, ?)", + (session_id, role, content, now), + ) + + def get_recent_messages(self, session_id: str, limit: int) -> list[dict]: + if limit <= 0: + return [] + with self._connect() as conn: + rows = conn.execute( + "SELECT role, content FROM messages WHERE session_id = ?" + " ORDER BY id DESC LIMIT ?", + (session_id, limit), + ).fetchall() + return [{"role": row["role"], "content": row["content"]} for row in reversed(rows)] + + # ----- Langzeit-Erinnerungen -------------------------------------------- + def add_memory(self, user_id: str, content: str) -> Memory: + now = _now() + with self._connect() as conn: + cur = conn.execute( + "INSERT INTO memories (user_id, content, created_at) VALUES (?, ?, ?)", + (user_id, content, now), + ) + memory_id = cur.lastrowid + return Memory(id=memory_id, content=content, created_at=now) + + def get_memories(self, user_id: str) -> list[Memory]: + with self._connect() as conn: + rows = conn.execute( + "SELECT id, content, created_at FROM memories WHERE user_id = ? ORDER BY id", + (user_id,), + ).fetchall() + return [ + Memory(id=row["id"], content=row["content"], created_at=row["created_at"]) + for row in rows + ] + + def delete_memory(self, user_id: str, memory_id: int) -> bool: + with self._connect() as conn: + cur = conn.execute( + "DELETE FROM memories WHERE id = ? AND user_id = ?", + (memory_id, user_id), + ) + return cur.rowcount > 0 + + # ----- Nutzung / Quota -------------------------------------------------- + @staticmethod + def _today() -> str: + return datetime.now(timezone.utc).date().isoformat() + + def get_request_count(self, user_id: str, day: str | None = None) -> int: + day = day or self._today() + with self._connect() as conn: + row = conn.execute( + "SELECT requests FROM usage WHERE user_id = ? AND day = ?", + (user_id, day), + ).fetchone() + return int(row["requests"]) if row else 0 + + def add_usage(self, user_id: str, units: int = 0, day: str | None = None) -> int: + day = day or self._today() + with self._connect() as conn: + conn.execute( + "INSERT INTO usage (user_id, day, requests, units) VALUES (?, ?, 1, ?)" + " ON CONFLICT(user_id, day) DO UPDATE SET" + " requests = requests + 1, units = units + excluded.units", + (user_id, day, units), + ) + row = conn.execute( + "SELECT requests FROM usage WHERE user_id = ? AND day = ?", + (user_id, day), + ).fetchone() + return int(row["requests"]) + + def delete_user(self, user_id: str) -> bool: + if user_id == ANONYMOUS_USER_ID: + raise ValueError("Der anonyme Nutzer kann nicht geloescht werden.") + with self._connect() as conn: + if not conn.execute("SELECT 1 FROM users WHERE id = ?", (user_id,)).fetchone(): + return False + conn.execute( + "DELETE FROM messages WHERE session_id IN" + " (SELECT id FROM sessions WHERE user_id = ?)", + (user_id,), + ) + conn.execute("DELETE FROM sessions WHERE user_id = ?", (user_id,)) + conn.execute("DELETE FROM memories WHERE user_id = ?", (user_id,)) + conn.execute("DELETE FROM usage WHERE user_id = ?", (user_id,)) + conn.execute("DELETE FROM users WHERE id = ?", (user_id,)) + return True + + def reset_token(self, user_id: str) -> tuple[User, str] | None: + with self._connect() as conn: + row = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone() + if not row: + return None + raw_token = secrets.token_urlsafe(32) + conn.execute( + "UPDATE users SET token_hash = ? WHERE id = ?", + (hash_token(raw_token), user_id), + ) + user = self._row_to_user(conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone()) + return user, raw_token + + def update_display_name(self, user_id: str, display_name: str) -> User | None: + with self._connect() as conn: + conn.execute( + "UPDATE users SET display_name = ? WHERE id = ?", + (display_name, user_id), + ) + row = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone() + return self._row_to_user(row) if row else None + + # ----- Notfall-Protokoll ------------------------------------------------ + def log_emergency(self, user_id: str, category: str, snippet: str) -> None: + with self._connect() as conn: + conn.execute( + "INSERT INTO emergency_events (user_id, category, snippet, created_at)" + " VALUES (?, ?, ?, ?)", + (user_id, category, snippet, _now()), + ) + + # ----- Admin-Abfragen --------------------------------------------------- + def list_sessions_for_user(self, user_id: str) -> list[dict]: + with self._connect() as conn: + rows = conn.execute( + "SELECT s.id, s.created_at, s.updated_at," + " (SELECT COUNT(*) FROM messages m WHERE m.session_id = s.id) AS msg_count" + " FROM sessions s WHERE s.user_id = ?" + " ORDER BY s.updated_at DESC LIMIT 50", + (user_id,), + ).fetchall() + return [dict(r) for r in rows] + + def get_messages_for_session(self, session_id: str, limit: int = 200) -> list[dict]: + with self._connect() as conn: + rows = conn.execute( + "SELECT role, content, created_at FROM messages" + " WHERE session_id = ? ORDER BY id LIMIT ?", + (session_id, limit), + ).fetchall() + return [dict(r) for r in rows] + + def list_emergency_events(self, limit: int = 50) -> list[dict]: + with self._connect() as conn: + rows = conn.execute( + "SELECT e.id, e.user_id, e.category, e.snippet, e.created_at," + " COALESCE(u.display_name, e.user_id) AS display_name" + " FROM emergency_events e LEFT JOIN users u ON u.id = e.user_id" + " ORDER BY e.id DESC LIMIT ?", + (limit,), + ).fetchall() + return [dict(r) for r in rows] + + def get_usage_for_user(self, user_id: str) -> list[dict]: + with self._connect() as conn: + rows = conn.execute( + "SELECT day, requests, units FROM usage" + " WHERE user_id = ? ORDER BY day DESC LIMIT 30", + (user_id,), + ).fetchall() + return [dict(r) for r in rows] + + def get_all_usage(self) -> list[dict]: + with self._connect() as conn: + rows = conn.execute( + "SELECT u.user_id, COALESCE(usr.display_name, u.user_id) AS display_name," + " SUM(u.requests) AS total_requests, SUM(u.units) AS total_units," + " MAX(u.day) AS last_active" + " FROM usage u LEFT JOIN users usr ON usr.id = u.user_id" + " GROUP BY u.user_id ORDER BY total_requests DESC", + ).fetchall() + return [dict(r) for r in rows] + + def get_config_overrides(self) -> dict[str, str]: + with self._connect() as conn: + rows = conn.execute("SELECT key, value FROM config_overrides").fetchall() + return {r["key"]: r["value"] for r in rows} + + def set_config_override(self, key: str, value: str) -> None: + with self._connect() as conn: + conn.execute( + "INSERT INTO config_overrides(key, value, updated_at) VALUES(?,?,?)" + " ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at", + (key, value, _now()), + ) + + def delete_config_override(self, key: str) -> bool: + with self._connect() as conn: + cur = conn.execute("DELETE FROM config_overrides WHERE key=?", (key,)) + return cur.rowcount > 0 diff --git a/app/utils/__init__.py b/app/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/web/app.js b/app/web/app.js new file mode 100644 index 0000000..a7ce676 --- /dev/null +++ b/app/web/app.js @@ -0,0 +1,1845 @@ +"use strict"; + +const $ = (sel) => document.querySelector(sel); + +const messagesEl = $("#messages"); +const statusEl = $("#status"); +const promptEl = $("#prompt"); +const formEl = $("#prompt-form"); +const micBtn = $("#mic"); +const ttsSel = $("#tts"); +const langSel = $("#lang-sel"); + +// "Flex" ist im Sprachmenü nur ein weiterer Wert: keine feste Sprache, sondern +// automatische Erkennung. Eine konkrete Sprache = Fix-Modus (Eingabe wird übersetzt). +// Beide internen Felder (language, language_mode) werden aus dieser einen Auswahl abgeleitet. +function langOverrides() { + const sel = langSel && langSel.value; + if (!sel || sel === "flex") return { language_mode: "flex" }; + return { language: sel, language_mode: "fix" }; +} + +// Nutzer-Präferenzen als Request-Overrides für jeden Turn. +function overrides() { + const out = { ...langOverrides() }; + if (isDeviceMode()) { + out.text_only = true; // kein Server-Audio -> Gerät spricht selbst + } else if (ttsSel && ttsSel.value) { + out.tts_provider = ttsSel.value; + } + return out; +} + +async function savePrefs(obj) { + try { + await fetch("/api/me/prefs", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(obj), + }); + } catch (e) { console.error("savePrefs", e); } +} + +// ---------- Geräte-TTS (Web Speech API) ---------- +// "Gerät" liest die Antwort lokal vor -> der Server schickt kein Audio (spart Bytes/Kosten). +const TTS_SUPPORTED = typeof window !== "undefined" && "speechSynthesis" in window; +const LANG_BCP47 = { + de: "de-DE", en: "en-US", fr: "fr-FR", es: "es-ES", + it: "it-IT", nl: "nl-NL", ru: "ru-RU", zh: "zh-CN", +}; + +function isMobile() { + const ua = navigator.userAgent || ""; + return /Android|iPhone|iPad|iPod/i.test(ua) || + (navigator.maxTouchPoints > 1 && /Macintosh/.test(ua)); // iPad ab iOS 13 +} + +function isDeviceMode() { return !!ttsSel && ttsSel.value === "device"; } + +let _voices = []; +function loadVoices() { _voices = TTS_SUPPORTED ? speechSynthesis.getVoices() : []; } +if (TTS_SUPPORTED) { + loadVoices(); + // Stimmen laden asynchron -> bei Eintreffen Preset-Zustand neu bewerten (entgrauen). + speechSynthesis.addEventListener("voiceschanged", () => { loadVoices(); refreshDevicePreset(); }); + setTimeout(() => refreshDevicePreset(), 1500); // Backstop, falls kein voiceschanged kommt +} + +function pickVoice(bcp) { + if (!_voices.length) loadVoices(); + const lc = bcp.toLowerCase(); + const two = lc.slice(0, 2); + return _voices.find((v) => v.lang && v.lang.toLowerCase() === lc) || + _voices.find((v) => v.lang && v.lang.toLowerCase().startsWith(two)) || null; +} + +function _makeUtterance(text, lang) { + const u = new SpeechSynthesisUtterance(text); + u.lang = LANG_BCP47[lang] || lang || "de-DE"; + const v = pickVoice(u.lang); + if (v) u.voice = v; + return u; +} + +// Hat der Browser nutzbare Sprachstimmen? (Linux-Desktops haben oft KEINE -> stumm.) +function deviceVoicesReady() { + if (!TTS_SUPPORTED) return false; + if (!_voices.length) loadVoices(); + return _voices.length > 0; +} + +// Server-Fallback: holt Audio via /api/speak (ohne tts_provider -> Server-Default, +// niemals "device") und spielt es ab. Greift, wenn Geräte-TTS keine Stimme hat. +async function serverSpeakFallback(text, lang, btn) { + try { + const body = { text }; + if (lang) body.language = lang; + const r = await fetch("/api/speak", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!r.ok) throw new Error("HTTP " + r.status); + startReplay([await r.arrayBuffer()], btn); + } catch (e) { + setReplayPlaying(btn, false); + statusEl.textContent = "Vorlesen fehlgeschlagen"; + } +} + +// Vorlesen ohne Button-Status (automatisches Vorlesen der Antwort). +function deviceSpeak(text, lang) { + if (!text) return; + if (deviceVoicesReady()) { + speechSynthesis.cancel(); + speechSynthesis.speak(_makeUtterance(text, lang)); + } else { + serverSpeakFallback(text, lang, null); // keine Stimmen -> Server-Audio + } +} + +// Vorlesen mit Button-Status (Replay): Icon -> ⏹ während des Sprechens, danach zurück. +function deviceSpeakBtn(text, lang, btn) { + if (!text) { setReplayPlaying(btn, false); return; } + if (!deviceVoicesReady()) { serverSpeakFallback(text, lang, btn); return; } + speechSynthesis.cancel(); + const u = _makeUtterance(text, lang); + u.onend = u.onerror = () => setReplayPlaying(btn, false); + setReplayPlaying(btn, true); + speechSynthesis.speak(u); +} + +// iOS: speechSynthesis darf nur nach einer Nutzergeste starten -> beim ersten Tap freischalten. +let _ttsUnlocked = false; +function unlockTTS() { + if (_ttsUnlocked || !TTS_SUPPORTED) return; + try { speechSynthesis.speak(new SpeechSynthesisUtterance("")); _ttsUnlocked = true; } catch (e) {} +} + +const PLAYBACK_KEY = "va-playback"; // geräte-lokal: "device" wenn Geräte-TTS gewählt + +// Setzt das verborgene Quell-Select (#tts) beim Laden + spiegelt es in die Presets. +// Vorrang: 1. lokale Wahl (localStorage), 2. Server-Pref (tts_provider), 3. Mobil-Default = Gerät. +function applyPlaybackChoice(serverProvider) { + if (!ttsSel) return; + const local = localStorage.getItem(PLAYBACK_KEY); + if (local === "device" && TTS_SUPPORTED) { + ttsSel.value = "device"; + } else if (serverProvider) { + ttsSel.value = serverProvider; + } else if (TTS_SUPPORTED && isMobile() && local === null) { + ttsSel.value = "device"; // Mobil-Default, solange nichts gewählt wurde + localStorage.setItem(PLAYBACK_KEY, "device"); + } else if (!ttsSel.value) { + ttsSel.value = "piper"; // Fallback: Server + } + refreshDevicePreset(); // graut „Im Gerät" aus, wenn keine Stimmen + syncPresetUI +} + +// Graut „Im Gerät" aus, wenn keine Sprachstimmen vorhanden sind; entgraut automatisch, +// sobald welche da sind. Kein Aufzwingen: device wird nur (wieder) gewählt, wenn es die +// gespeicherte Präferenz ist. Ohne Stimmen NICHT auf der deaktivierten Option sitzen bleiben. +function refreshDevicePreset() { + const btn = document.querySelector('.tts-preset[data-tts="device"]'); + if (!btn || !ttsSel) return; + const ready = deviceVoicesReady(); + btn.disabled = !ready; + btn.classList.toggle("opacity-50", !ready); + btn.classList.toggle("cursor-not-allowed", !ready); + const sub = btn.querySelector(".tts-sub"); + if (sub) sub.textContent = ready + ? "Das Gerät liest vor · spart Daten" + : "keine Sprachstimmen im Browser"; + if (ready) { + if (localStorage.getItem(PLAYBACK_KEY) === "device" && ttsSel.value !== "device") { + ttsSel.value = "device"; // gespeicherte Präferenz wieder aktivieren + } + } else if (ttsSel.value === "device") { + ttsSel.value = "piper"; // Server statt deaktivierter Option (Präferenz bleibt) + } + syncPresetUI(); +} + +// Markiert das aktive Ton-Preset (Häkchen + Rahmen) passend zu #tts. +function syncPresetUI() { + const val = ttsSel ? ttsSel.value : ""; + document.querySelectorAll(".tts-preset").forEach((btn) => { + const active = btn.dataset.tts === val; + btn.classList.toggle("ring-2", active); + btn.classList.toggle("ring-blue-500", active); + btn.classList.toggle("border-blue-500", active); + const chk = btn.querySelector(".check"); + if (chk) chk.style.opacity = active ? "1" : "0"; + }); +} + +// ---------- Einstellungs-Menü (⋮) ---------- +function openMenu() { + $("#settings-sheet").classList.remove("hidden"); + $("#settings-backdrop").classList.remove("hidden"); +} +function closeMenu() { + $("#settings-sheet").classList.add("hidden"); + $("#settings-backdrop").classList.add("hidden"); +} + +let busy = false; +let activeWs = null; // aktive WS-Verbindung (fuer Barge-in von aussen erreichbar) +let activeSources = []; // laufende AudioBufferSourceNodes (stoppbar per Barge-in) + +// Session-ID pro Nutzer (sonst "gehoert einem anderen Nutzer"-Konflikt). Wird aus +// /api/me abgeleitet; bis dahin null -> ensureSession() wartet auf loadMe(). +let sessionId = null; +let mePromise = null; +let currentUserId = null; +const sessionKey = (uid) => "va-session-" + uid; + +// ---------- Identitaet / Menue ---------- +function loadMe() { + mePromise = (async () => { + try { + const res = await fetch("/api/me", { headers: { Accept: "application/json" } }); + if (!res.ok) { + $("#identity").textContent = "Nicht angemeldet"; + return null; + } + const me = await res.json(); + currentUserId = me.user_id; + // Zuletzt aktive (ggf. frische) Session bevorzugen, sonst die Basis-Session. + sessionId = localStorage.getItem(sessionKey(me.user_id)) || ("web-" + me.user_id); + $("#identity").textContent = "Angemeldet als " + (me.display_name || me.external_id || "Gast"); + if (me.sso_logout_url) { + const logout = $("#logout"); + logout.href = me.sso_logout_url; + logout.classList.remove("hidden"); + } + if (me.is_admin) { + $("#admin-toggle").classList.remove("hidden"); + } + // Gespeicherte Nutzer-Präferenzen in die Dropdowns laden. + const prefs = me.prefs || {}; + if (langSel) { + // Flex-Modus -> "flex", sonst die feste Sprache (Fallback: Flex). + langSel.value = prefs.language_mode === "flex" + ? "flex" + : (prefs.language || "flex"); + } + applyPlaybackChoice(prefs.tts_provider); + return me; + } catch (e) { + $("#identity").textContent = "Verbindung fehlgeschlagen"; + return null; + } + })(); + return mePromise; +} + +// Stellt sicher, dass eine nutzerspezifische Session-ID feststeht, bevor ein Turn startet. +async function ensureSession() { + if (!sessionId && mePromise) { + try { await mePromise; } catch (e) { /* ignore */ } + } + if (!sessionId) sessionId = "web-anon"; +} + +// loadUsers() ersetzt durch Admin-Panel (siehe unten) + +// ---------- Nachrichten-UI ---------- +// Nach dem naechsten Layout ans Ende scrollen -> die neueste Antwort ist sichtbar. +function scrollToBottom() { + requestAnimationFrame(() => { messagesEl.scrollTop = messagesEl.scrollHeight; }); +} + +const BUBBLE = { + user: "ml-auto max-w-[80%] rounded-2xl rounded-br-sm bg-blue-600 px-4 py-2 text-white whitespace-pre-wrap", + assistant: "mr-auto max-w-[80%] rounded-2xl rounded-bl-sm border border-slate-200 bg-white px-4 py-2 whitespace-pre-wrap dark:border-slate-700 dark:bg-slate-800", + system: "mx-auto max-w-[90%] rounded-lg bg-amber-100 px-3 py-1.5 text-xs text-amber-800 dark:bg-amber-900/40 dark:text-amber-200", + emergency: "mx-auto max-w-[90%] rounded-lg bg-red-100 px-3 py-1.5 text-sm font-semibold text-red-800 dark:bg-red-900/40 dark:text-red-200", +}; + +// Lautsprecher-/Stop-Icons als SVG (currentColor → per CSS einfärbbar, anders als Emoji). +const ICON_SPEAKER = + ''; +const ICON_STOP = + '' + + ''; + +// meta.lang: Sprache, in der diese Bubble vorgelesen werden soll (für Replay). +function addMessage(role, text, meta = {}) { + const div = document.createElement("div"); + div.className = BUBBLE[role] || BUBBLE.assistant; + const span = document.createElement("span"); + span.className = "bubble-text"; + span.textContent = text; + div.appendChild(span); + div._textEl = span; + // Vorlese-Symbol nur an Nutzer-/Assistenten-Bubbles (nicht System/Notfall). + if (role === "user" || role === "assistant") { + div.classList.add("relative", "pr-7"); + div._replay = { lang: meta.lang || null, chunks: null }; // chunks: gecachtes PCM (Assistent) + const btn = document.createElement("button"); + btn.type = "button"; + btn.innerHTML = ICON_SPEAKER; + btn.title = "Vorlesen"; + btn.setAttribute("aria-label", "Vorlesen"); + // Kontrastreiche Farbe je Bubble: weiß auf Blau; im Dark-Mode hell auf Dunkelgrau. + const replayColor = role === "user" + ? "text-white/80 hover:text-white" + : "text-slate-500 hover:text-slate-700 dark:text-slate-300 dark:hover:text-white"; + btn.className = "replay-btn absolute top-1.5 right-1.5 transition-colors " + replayColor; + btn.addEventListener("click", () => replayBubble(div)); + div._replayBtn = btn; + div.appendChild(btn); + } + messagesEl.appendChild(div); + scrollToBottom(); + return div; +} + +// ---------- Vorlesen / Replay ---------- +function setReplayPlaying(btn, on) { + if (!btn) return; + btn.innerHTML = on ? ICON_STOP : ICON_SPEAKER; + btn.dataset.playing = on ? "1" : ""; +} + +function startReplay(chunks, btn) { + stopAudio(); // Barge-in: laufendes Audio (auch andere Replays) stoppen + setReplayPlaying(btn, true); + playPcm(chunks, 24000, () => setReplayPlaying(btn, false)); +} + +async function speakBubble(text, lang, btn) { + try { + const body = { text }; + if (lang) body.language = lang; + if (ttsSel && ttsSel.value) body.tts_provider = ttsSel.value; + const r = await fetch("/api/speak", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!r.ok) throw new Error("HTTP " + r.status); + startReplay([await r.arrayBuffer()], btn); + } catch (e) { + setReplayPlaying(btn, false); + statusEl.textContent = "Vorlesen fehlgeschlagen"; + } +} + +function replayBubble(div) { + const btn = div._replayBtn; + if (btn && btn.dataset.playing) { stopAudio(); setReplayPlaying(btn, false); return; } // Toggle + const info = div._replay || {}; + const text = (div._textEl && div._textEl.textContent) || ""; + if (info.chunks && info.chunks.length) { + startReplay(info.chunks, btn); // Assistent: gecachtes PCM, sofort & gratis + } else if (isDeviceMode()) { + stopAudio(); + // Geräte-TTS: bereinigtes "spoken" bevorzugen (ohne Markdown/Emojis), sonst Rohtext. + deviceSpeakBtn(info.spoken || text, info.lang, btn); + } else { + speakBubble(text, info.lang, btn); // Server-TTS via /api/speak (bereinigt selbst) + } +} + +// ---------- Barge-in-Hilfsfunktionen ---------- +function stopAudio() { + activeSources.forEach((s) => { try { s.stop(); } catch (e) {} }); + activeSources = []; + if (TTS_SUPPORTED) { try { speechSynthesis.cancel(); } catch (e) {} } // Geräte-TTS stoppen +} + +function setMicIdle() { + micBtn.textContent = "🎤"; + micBtn.title = "Mikrofon"; + micBtn.classList.remove("bg-red-600", "animate-pulse", "bg-amber-500"); + micBtn.classList.add("bg-emerald-600"); +} + +function setMicRecording() { + micBtn.classList.remove("bg-emerald-600", "bg-amber-500"); + micBtn.classList.add("bg-red-600", "animate-pulse"); + micBtn.title = "Aufnahme stoppen"; +} + +function setMicBusy() { + micBtn.textContent = "⏹"; + micBtn.title = "KI unterbrechen (Barge-in)"; + micBtn.classList.remove("bg-emerald-600", "bg-red-600", "animate-pulse"); + micBtn.classList.add("bg-amber-500"); +} + +// ---------- Audio-Wiedergabe (PCM s16le) ---------- +let audioCtx = null; +function playPcm(chunks, sampleRate, onended) { + if (!chunks.length) { if (onended) onended(); return; } + const total = chunks.reduce((n, c) => n + c.byteLength, 0); + const merged = new Uint8Array(total); + let off = 0; + for (const c of chunks) { merged.set(new Uint8Array(c), off); off += c.byteLength; } + const view = new DataView(merged.buffer); + const n = Math.floor(merged.byteLength / 2); + audioCtx = audioCtx || new (window.AudioContext || window.webkitAudioContext)(); + if (audioCtx.state === "suspended") audioCtx.resume(); + const buf = audioCtx.createBuffer(1, n, sampleRate || 24000); + const ch = buf.getChannelData(0); + for (let i = 0; i < n; i++) ch[i] = view.getInt16(i * 2, true) / 32768; + const src = audioCtx.createBufferSource(); + src.buffer = buf; + src.connect(audioCtx.destination); + activeSources.push(src); + src.onended = () => { + activeSources = activeSources.filter((s) => s !== src); + if (onended) onended(); + }; + src.start(); +} + +// ---------- WS-Turn (Text und Sprache teilen die Event-Logik) ---------- +function wsUrl(path) { + const proto = location.protocol === "https:" ? "wss" : "ws"; + return `${proto}://${location.host}${path}?session_id=${encodeURIComponent(sessionId)}`; +} + +// onopen: (ws) => sendet die Eingabe. Liefert ein Promise, das beim done-Event endet. +function runTurn(path, onopen) { + return new Promise((resolve) => { + const ws = new WebSocket(wsUrl(path)); + activeWs = ws; + ws.binaryType = "arraybuffer"; + const pcm = []; + let answerEl = null; + let sampleRate = 24000; + // Sprache dieses Turns (für den Replay je Bubble): Flex folgt erkannter Sprache. + let routeLang = null, routeMode = null, detectedLang = null; + const turnLang = () => + (routeMode === "flex" ? (detectedLang || routeLang) : routeLang) || null; + + ws.onopen = () => onopen(ws); + ws.onerror = () => { statusEl.textContent = "Verbindungsfehler"; resolve(); }; + ws.onclose = () => { activeWs = null; resolve(); }; + + ws.onmessage = (event) => { + if (typeof event.data !== "string") { pcm.push(event.data); return; } + let msg; + try { msg = JSON.parse(event.data); } catch { return; } + switch (msg.type) { + case "ack": + routeLang = (msg.route && msg.route.language) || null; + routeMode = (msg.route && msg.route.language_mode) || null; + break; + case "transcript": + detectedLang = msg.detected_language || null; + if (msg.text) addMessage("user", msg.text, { lang: turnLang() }); + break; + case "token": + if (!answerEl) answerEl = addMessage("assistant", "", { lang: turnLang() }); + answerEl._textEl.textContent += msg.text || ""; + scrollToBottom(); + break; + case "semantic": + if (!answerEl) answerEl = addMessage("assistant", "", { lang: turnLang() }); + if (msg.text) answerEl._textEl.textContent = msg.text; + // Sprache final festhalten (für Replay) und im Geräte-Modus lokal vorlesen. + // Für TTS das bereinigte "spoken" (ohne Markdown/Emojis) bevorzugen, anzeigen + // bleibt der Originaltext. + if (answerEl._replay) { + answerEl._replay.lang = turnLang(); + answerEl._replay.spoken = msg.spoken || msg.text || ""; + } + if (isDeviceMode()) { + const spoken = msg.spoken || msg.text; + if (spoken) deviceSpeak(spoken, turnLang()); + } + scrollToBottom(); + break; + case "emergency": + addMessage("emergency", "⚠ Notfall erkannt (" + (msg.category || "?") + ")"); + break; + case "done": + sampleRate = msg.sample_rate || 24000; + // Geräte-Modus: kein Server-Audio -> nichts abspielen/cachen (Gerät hat schon gesprochen). + if (!isDeviceMode()) { + if (answerEl && answerEl._replay) answerEl._replay.chunks = pcm.slice(); // Replay-Cache + playPcm(pcm, sampleRate); + } + statusEl.textContent = ""; + scrollToBottom(); + ws.close(); + break; + case "error": + addMessage("system", "Fehler: " + (msg.detail || msg.status || "unbekannt")); + ws.close(); + break; + case "interrupted": + statusEl.textContent = ""; + ws.close(); // → ws.onclose → resolve() → busy=false → setMicIdle() + break; + } + }; + }); +} + +// ---------- Text senden ---------- +async function sendText(text) { + if (busy || !text.trim()) return; + busy = true; + setMicBusy(); + await ensureSession(); + // Bei fester Sprache wird der eigene Text in dieser Sprache vorgelesen; bei Flex offen. + const lo = langOverrides(); + addMessage("user", text, { lang: lo.language_mode === "fix" ? lo.language : null }); + statusEl.textContent = "denkt …"; + const audioStream = !isDeviceMode(); // Geräte-Modus: kein Server-Audio anfordern + await runTurn("/ws/chat", (ws) => { + ws.send(JSON.stringify({ text, stream: true, audio_stream: audioStream, ...overrides() })); + }); + busy = false; + setMicIdle(); +} + +formEl.addEventListener("submit", (e) => { + e.preventDefault(); + unlockTTS(); // iOS: Geräte-TTS bei der Nutzergeste freischalten + const text = promptEl.value; + promptEl.value = ""; + sendText(text); +}); + +// ---------- Mikrofon (Push-to-Talk) ---------- +let mediaRecorder = null; +let recChunks = []; + +async function startRecording(statusText) { + if (busy) return; + const md = navigator.mediaDevices; + if (!md || !md.getUserMedia) { + addMessage("system", "Mikrofon nicht freigegeben: Bitte die Seite direkt in Safari öffnen (nicht im In-App-Browser) und keinen Privat-Modus verwenden."); + return; + } + if (typeof MediaRecorder === "undefined") { + addMessage("system", "Aufnahme nicht unterstützt (iOS < 14.3?). Bitte iOS aktualisieren."); + return; + } + let stream; + try { + stream = await md.getUserMedia({ audio: true }); + } catch (e) { + addMessage("system", "Mikrofon-Zugriff fehlgeschlagen: " + (e.name || "") + + ". Tipp: iPhone → Einstellungen → Safari → Mikrofon = Erlauben/Fragen."); + return; + } + recChunks = []; + try { + mediaRecorder = new MediaRecorder(stream); + } catch (e) { + stream.getTracks().forEach((t) => t.stop()); + addMessage("system", "Aufnahme nicht möglich: " + (e.name || e.message)); + return; + } + mediaRecorder.ondataavailable = (e) => { if (e.data.size) recChunks.push(e.data); }; + mediaRecorder.onstop = async () => { + stream.getTracks().forEach((t) => t.stop()); + const mime = mediaRecorder.mimeType || "audio/mp4"; + const blob = new Blob(recChunks, { type: mime }); + const bytes = await blob.arrayBuffer(); + // Safari -> audio/mp4, andere -> webm; der Server erkennt das Format am Inhalt. + sendVoice(bytes, mime.includes("mp4") || mime.includes("mpeg") ? "mp4" : "webm"); + }; + mediaRecorder.start(); + setMicRecording(); + statusEl.textContent = statusText || "Aufnahme … (zum Stoppen erneut tippen)"; +} + +function stopRecording() { + if (mediaRecorder && mediaRecorder.state !== "inactive") mediaRecorder.stop(); + setMicIdle(); +} + +async function sendVoice(bytes, fmt = "webm") { + if (busy) return; + busy = true; + setMicBusy(); + await ensureSession(); + statusEl.textContent = "verarbeite Sprache …"; + const audioStream = !isDeviceMode(); // Geräte-Modus: kein Server-Audio anfordern + await runTurn("/ws/voice", (ws) => { + ws.send(JSON.stringify({ type: "start", format: fmt, stream: true, audio_stream: audioStream, ...overrides() })); + ws.send(bytes); + ws.send(JSON.stringify({ type: "end" })); + }); + busy = false; + setMicIdle(); +} + +micBtn.addEventListener("click", () => { + unlockTTS(); // iOS: Geräte-TTS bei der Nutzergeste freischalten + if (busy) { + // Barge-in: laufende KI-Antwort sofort unterbrechen + if (activeWs) activeWs.send(JSON.stringify({ type: "interrupt" })); + stopAudio(); + statusEl.textContent = "Unterbrochen"; + // busy + Button-Reset erfolgen sobald die WS schliesst (-> runTurn resolve -> sendVoice/sendText) + } else if (mediaRecorder && mediaRecorder.state === "recording") { + stopRecording(); + } else { + startRecording(); // Server-STT (Upload) — überall gleich + } +}); + +$("#admin-toggle").addEventListener("click", () => { closeMenu(); openAdminPanel(); }); + +// ---------- Neues Gespräch ---------- +// Startet eine frische Session (neuer session_id) -> der bisherige Verlauf wird nicht +// mehr als LLM-Kontext genutzt. Behebt "Sprache springt nicht um", wenn die History +// in einer anderen Sprache dominiert. Nicht-destruktiv: alte Session bleibt in der DB. +function newConversation() { + const base = currentUserId || "anon"; + sessionId = "web-" + base + "-" + Date.now(); + if (currentUserId) localStorage.setItem(sessionKey(currentUserId), sessionId); + stopAudio(); + messagesEl.innerHTML = ""; + statusEl.textContent = "Neues Gespräch begonnen"; + setTimeout(() => { + if (statusEl.textContent === "Neues Gespräch begonnen") statusEl.textContent = ""; + }, 2500); +} +$("#new-chat").addEventListener("click", () => { closeMenu(); newConversation(); }); + +// ---------- Menü (⋮) verdrahten ---------- +$("#menu-toggle").addEventListener("click", () => + $("#settings-sheet").classList.contains("hidden") ? openMenu() : closeMenu()); +$("#settings-backdrop").addEventListener("click", closeMenu); +document.addEventListener("keydown", (e) => { if (e.key === "Escape") closeMenu(); }); +// Ton-Presets: setzen das verborgene #tts + lösen die bestehende Persistenz-Logik aus. +document.querySelectorAll(".tts-preset").forEach((btn) => + btn.addEventListener("click", () => { + unlockTTS(); + ttsSel.value = btn.dataset.tts; + ttsSel.dispatchEvent(new Event("change")); + syncPresetUI(); + }) +); + +// Tag/Nacht: Umschalter (manuell) + automatisch dem System folgen, solange nichts gewählt. +const themeBtn = $("#theme-toggle"); +const setThemeIcon = () => { + const icon = $("#theme-icon"); + if (icon) icon.textContent = document.documentElement.classList.contains("dark") ? "☀️" : "🌙"; +}; +themeBtn.addEventListener("click", () => { + const dark = document.documentElement.classList.toggle("dark"); + localStorage.setItem("theme", dark ? "dark" : "light"); + setThemeIcon(); +}); +matchMedia("(prefers-color-scheme: dark)").addEventListener("change", (e) => { + if (!localStorage.getItem("theme")) { + document.documentElement.classList.toggle("dark", e.matches); + setThemeIcon(); + } +}); +setThemeIcon(); + +// Wenn die Bildschirmtastatur die sichtbare Hoehe aendert (iOS), unten bleiben. +if (window.visualViewport) { + window.visualViewport.addEventListener("resize", scrollToBottom); +} +window.addEventListener("resize", scrollToBottom); +promptEl.addEventListener("focus", () => setTimeout(scrollToBottom, 300)); + +// Dropdown-Änderungen dauerhaft als Nutzer-Präferenz speichern. +if (langSel) langSel.addEventListener("change", () => savePrefs(langOverrides())); +if (ttsSel) { + ttsSel.addEventListener("change", () => { + unlockTTS(); // Auswahl ist eine Nutzergeste -> Geräte-TTS gleich freischalten + if (ttsSel.value === "device") { + // Geräte-TTS ist geräte-lokal (nicht server-seitig) -> nur lokal merken. + localStorage.setItem(PLAYBACK_KEY, "device"); + } else { + localStorage.removeItem(PLAYBACK_KEY); + savePrefs({ tts_provider: ttsSel.value || null }); // echter Provider -> Server-Pref + } + }); +} + +loadMe(); + +// ═══════════════════════════════════════════════════════════════ +// ADMIN PANEL +// ═══════════════════════════════════════════════════════════════ + +// ---------- Hilfsfunktionen ---------- +function escHtml(str) { + if (str === null || str === undefined || str === "") return ""; + return String(str).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +} + +function fmtDate(iso) { + if (!iso) return "—"; + try { + return new Date(iso).toLocaleString("de-DE", { + day: "2-digit", month: "2-digit", year: "2-digit", + hour: "2-digit", minute: "2-digit", + }); + } catch { return iso; } +} + +async function adminFetch(url, method = "GET", body = null) { + try { + const opts = { method, headers: { "Content-Type": "application/json" } }; + if (body) opts.body = JSON.stringify(body); + const res = await fetch(url, opts); + if (method === "DELETE" && res.status === 204) return {}; + const data = await res.json().catch(() => null); + if (!res.ok) { console.error("Admin API", res.status, data); return null; } + return data; + } catch (e) { console.error("adminFetch", e); return null; } +} + +// ---------- Panel öffnen / schließen ---------- +function openAdminPanel() { + $("#admin-panel").classList.remove("hidden"); + switchAdminTab("overview"); +} + +$("#admin-close").addEventListener("click", () => $("#admin-panel").classList.add("hidden")); + +// ---------- Tab-Struktur: 5 Bereiche, je mit 1+ Abschnitten ---------- +// section = vorhandener Content-Block (#admin-tab-
); Bereiche mit mehreren +// Abschnitten bekommen eine Sub-Navigation. +const ADMIN_TABS = { + overview: [["overview", "Übersicht"]], + users: [["users", "Verwalten"], ["chat", "Gespräche"]], + emergency: [["emergency", "Notfälle"]], + system: [["status", "Status"], ["metrics", "Metriken"], ["log", "Log"]], + config: [["settings", "Einstellungen"], ["words", "Wörterbuch"]], +}; +const SECTION_LOADER = { + overview: () => loadOverview(), + users: () => loadAdminUsers(), + chat: () => loadChatUsers(), + emergency: () => loadEmergencyEvents(), + status: () => loadStatus(), + metrics: () => loadMetrics(), + words: () => loadWoerterbuch(), + settings: () => loadSettings(), + log: () => {}, // Log: Nutzer verbindet manuell +}; + +function _setTabActive(btn, active) { + btn.classList.toggle("border-blue-600", active); + btn.classList.toggle("text-blue-600", active); + btn.classList.toggle("dark:border-blue-400", active); + btn.classList.toggle("dark:text-blue-400", active); + btn.classList.toggle("border-transparent", !active); + btn.classList.toggle("text-slate-500", !active); +} + +function showAdminSection(section, top) { + document.querySelectorAll('[id^="admin-tab-"]').forEach((el) => el.classList.add("hidden")); + const panel = $("#admin-tab-" + section); + if (panel) panel.classList.remove("hidden"); + // Sub-Tab markieren + document.querySelectorAll(".admin-subtab").forEach((b) => { + const active = b.dataset.sec === section; + b.classList.toggle("bg-blue-600", active); + b.classList.toggle("text-white", active); + b.classList.toggle("border-blue-600", active); + b.classList.toggle("text-slate-600", !active); + b.classList.toggle("dark:text-slate-300", !active); + b.classList.toggle("border-slate-300", !active); + b.classList.toggle("dark:border-slate-600", !active); + }); + const loader = SECTION_LOADER[section]; + if (loader) loader(); +} + +function switchAdminTab(top) { + const sections = ADMIN_TABS[top] || [[top, top]]; + document.querySelectorAll(".admin-tab").forEach((btn) => _setTabActive(btn, btn.dataset.tab === top)); + + const subnav = $("#admin-subnav"); + if (sections.length <= 1) { + subnav.classList.add("hidden"); + subnav.innerHTML = ""; + showAdminSection(sections[0][0], top); + } else { + subnav.classList.remove("hidden"); + subnav.innerHTML = sections.map(([sec, label]) => + `` + ).join(""); + subnav.querySelectorAll(".admin-subtab").forEach((b) => + b.addEventListener("click", () => showAdminSection(b.dataset.sec, top)) + ); + showAdminSection(sections[0][0], top); + } +} + +document.querySelectorAll(".admin-tab").forEach((btn) => + btn.addEventListener("click", () => switchAdminTab(btn.dataset.tab)) +); +$("#load-overview").addEventListener("click", loadOverview); +$("#load-emergency").addEventListener("click", loadEmergencyEvents); +$("#load-status").addEventListener("click", loadStatus); +$("#load-metrics").addEventListener("click", loadMetrics); + +// ════════════════════════════════════════ +// TAB: ÜBERSICHT (Dashboard) +// ════════════════════════════════════════ +async function loadOverview() { + const c = $("#overview-content"); + c.innerHTML = '

lade …

'; + const [users, usage, emergencies, llm] = await Promise.all([ + adminFetch("/api/admin/users"), + adminFetch("/api/admin/usage"), + adminFetch("/api/admin/emergency-events"), + adminFetch("/api/admin/llm/status"), + ]); + const userCount = users ? users.length : "—"; + const reqTotal = usage ? usage.reduce((s, u) => s + (u.total_requests || 0), 0) : "—"; + const emCount = emergencies ? emergencies.length : "—"; + const backend = llm ? ({ ollama: "Ollama", llamacpp: "llama.cpp" }[llm.backend] || llm.backend) : "—"; + const gpu = (llm && llm.gpus && llm.gpus.length) + ? llm.gpus.map((g) => `GPU ${g.index}: ${g.percent}%`).join(" · ") : "—"; + + const hasEmergencies = typeof emCount === "number" && emCount > 0; + // Kachel; mit data-jump wird sie anklickbar (springt in den Bereich). + const stat = (label, value, sub, opts = {}) => ` + <${opts.jump ? `button data-jump="${opts.jump}"` : "div"} + class="ov-stat text-left bg-white dark:bg-slate-800 rounded-xl border ${opts.border || "border-slate-200 dark:border-slate-700"} p-4 ${opts.jump ? "hover:bg-slate-50 dark:hover:bg-slate-700/50 transition-colors" : ""}"> +

${escHtml(label)}

+

${escHtml(value)}

+ ${sub ? `

${escHtml(sub)}

` : ""} + `; + + const jump = (to, label) => + ``; + + c.innerHTML = ` +
+ ${stat("Nutzer", userCount, "", { jump: "users" })} + ${stat("Anfragen gesamt", reqTotal, "", { jump: "system" })} + ${stat("Notfälle", emCount, hasEmergencies ? "ansehen" : "keine", + { jump: "emergency", + accent: hasEmergencies ? "text-red-600 dark:text-red-400" : "", + border: hasEmergencies ? "border-red-300 dark:border-red-800" : "" })} + ${stat("LLM-Backend", backend, llm ? llm.model : "", { jump: "system" })} +
+
+

GPU-Auslastung

+

${escHtml(gpu)}

+
+
+ ${jump("users", "→ Nutzer")} ${jump("system", "→ System")} ${jump("emergency", "→ Notfälle")} +
`; + c.querySelectorAll(".ov-jump, .ov-stat[data-jump]").forEach((b) => + b.addEventListener("click", () => switchAdminTab(b.dataset.jump))); +} + +// ════════════════════════════════════════ +// TAB: NUTZER +// ════════════════════════════════════════ +async function loadAdminUsers() { + const container = $("#admin-user-cards"); + container.innerHTML = '

lade …

'; + const users = await adminFetch("/api/admin/users"); + if (!users) { container.innerHTML = '

Fehler beim Laden.

'; return; } + if (!users.length) { container.innerHTML = '

Noch keine Nutzer.

'; return; } + container.innerHTML = ""; + users.forEach((u) => container.appendChild(buildUserCard(u))); +} + +$("#create-user-form").addEventListener("submit", async (e) => { + e.preventDefault(); + const nameEl = $("#new-user-name"); + const name = nameEl.value.trim(); + if (!name) return; + const data = await adminFetch("/api/admin/users", "POST", { display_name: name }); + if (data?.token) { + $("#new-token-value").textContent = data.token; + $("#new-token-box").classList.remove("hidden"); + nameEl.value = ""; + loadAdminUsers(); + } +}); + +function buildUserCard(u) { + const card = document.createElement("div"); + card.className = "bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4"; + + const badgeSSO = u.external_id + ? `SSO` : ""; + + card.innerHTML = ` +
+
+
+ ${escHtml(u.display_name)} + ${badgeSSO} +
+

${u.user_id.slice(0, 8)}…${u.external_id ? " · " + escHtml(u.external_id) : ""}

+

seit ${fmtDate(u.created_at)}

+
+
+ + + +
+
+ + + + + + + + +
+ + +
+ `; + + const uid = u.user_id; + + // Umbenennen + card.querySelector(".btn-rename").addEventListener("click", () => + card.querySelector(".rename-box").classList.toggle("hidden") + ); + card.querySelector(".rename-cancel").addEventListener("click", () => + card.querySelector(".rename-box").classList.add("hidden") + ); + card.querySelector(".rename-box form").addEventListener("submit", async (e) => { + e.preventDefault(); + const name = card.querySelector(".rename-inp").value.trim(); + if (!name) return; + const res = await adminFetch(`/api/admin/users/${uid}`, "PUT", { display_name: name }); + if (res) { + card.querySelector(".user-dn").textContent = name; + card.querySelector(".rename-box").classList.add("hidden"); + } + }); + + // Token-Reset + card.querySelector(".btn-token").addEventListener("click", async () => { + if (!confirm(`Token für „${u.display_name}" zurücksetzen?\nDer alte Token wird sofort ungültig.`)) return; + const data = await adminFetch(`/api/admin/users/${uid}/token`, "POST"); + if (data?.token) { + card.querySelector(".token-val").textContent = data.token; + card.querySelector(".token-box").classList.remove("hidden"); + } + }); + + // Löschen + card.querySelector(".btn-delete").addEventListener("click", async () => { + if (!confirm(`Nutzer „${u.display_name}" und alle Daten unwiderruflich löschen?`)) return; + const res = await adminFetch(`/api/admin/users/${uid}`, "DELETE"); + if (res !== null) card.remove(); + }); + + // Erinnerungen auf-/zuklappen + const memBtn = card.querySelector(".btn-mem"); + const memSection = card.querySelector(".mem-section"); + const memArrow = card.querySelector(".mem-arrow"); + memBtn.addEventListener("click", async () => { + const open = !memSection.classList.contains("hidden"); + memSection.classList.toggle("hidden", open); + memArrow.textContent = open ? "▶" : "▼"; + if (!open) await renderMemories(uid, card.querySelector(".mem-list")); + }); + + // Erinnerung hinzufügen + card.querySelector(".mem-add-form").addEventListener("submit", async (e) => { + e.preventDefault(); + const inp = e.target.querySelector("input"); + const content = inp.value.trim(); + if (!content) return; + const res = await adminFetch(`/api/admin/users/${uid}/memories`, "POST", { content }); + if (res) { inp.value = ""; await renderMemories(uid, card.querySelector(".mem-list")); } + }); + + return card; +} + +async function renderMemories(uid, container) { + container.innerHTML = '

lade …

'; + const mems = await adminFetch(`/api/admin/users/${uid}/memories`); + if (!mems?.length) { + container.innerHTML = '

Keine Erinnerungen vorhanden.

'; + return; + } + container.innerHTML = ""; + mems.forEach((m) => { + const row = document.createElement("div"); + row.className = "flex items-start gap-2 rounded-lg bg-slate-50 dark:bg-slate-700/50 px-3 py-2 text-xs"; + row.innerHTML = ` + ${escHtml(m.content)} + + `; + row.querySelector("button").addEventListener("click", async () => { + await adminFetch(`/api/admin/users/${uid}/memories/${m.id}`, "DELETE"); + row.remove(); + }); + container.appendChild(row); + }); +} + +// ════════════════════════════════════════ +// TAB: GESPRÄCHE +// ════════════════════════════════════════ +let _chatUser = null; + +async function loadChatUsers() { + const list = $("#chat-user-list"); + list.innerHTML = '
  • lade …
  • '; + const users = await adminFetch("/api/admin/users"); + if (!users) return; + list.innerHTML = ""; + users.forEach((u) => { + const li = document.createElement("li"); + li.className = "px-3 py-2.5 text-sm cursor-pointer hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors flex flex-col"; + li.innerHTML = `${escHtml(u.display_name)} + ${u.user_id.slice(0,8)}…`; + li.addEventListener("click", () => { + document.querySelectorAll("#chat-user-list li").forEach((el) => + el.classList.remove("bg-blue-50", "dark:bg-blue-900/20", "text-blue-700", "dark:text-blue-300") + ); + li.classList.add("bg-blue-50", "dark:bg-blue-900/20"); + _chatUser = u; + loadChatSessions(u); + }); + list.appendChild(li); + }); +} + +async function loadChatSessions(u) { + const panel = $("#chat-right-panel"); + panel.innerHTML = `

    lade Sessions für ${escHtml(u.display_name)} …

    `; + const sessions = await adminFetch(`/api/admin/users/${u.user_id}/sessions`); + if (!sessions?.length) { + panel.innerHTML = '

    Keine Sessions gefunden.

    '; + return; + } + panel.innerHTML = ` +
    +

    Sessions · ${escHtml(u.display_name)}

    +
    +
    + `; + const list = panel.querySelector("#session-list"); + sessions.forEach((s) => { + const row = document.createElement("div"); + row.className = "px-4 py-3 cursor-pointer hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"; + row.innerHTML = ` +

    ${escHtml(s.id)}

    +

    ${fmtDate(s.updated_at)} · ${s.msg_count} Nachrichten

    + `; + row.addEventListener("click", () => loadTranscript(s.id)); + list.appendChild(row); + }); +} + +async function loadTranscript(sessionId) { + const panel = $("#chat-right-panel"); + panel.innerHTML = `

    lade Transkript …

    `; + const msgs = await adminFetch(`/api/admin/sessions/${sessionId}/messages`); + if (!msgs?.length) { + panel.innerHTML = `
    + +

    Keine Nachrichten.

    `; + panel.querySelector(".back-btn").addEventListener("click", () => _chatUser && loadChatSessions(_chatUser)); + return; + } + + const bubbles = msgs.map((m) => { + const isUser = m.role === "user"; + return `
    +
    + ${escHtml(m.content)}
    `; + }).join(""); + + panel.innerHTML = ` +
    + + ${escHtml(sessionId)} +
    +
    ${bubbles}
    + `; + panel.querySelector(".back-btn").addEventListener("click", () => _chatUser && loadChatSessions(_chatUser)); +} + +// ════════════════════════════════════════ +// TAB: NOTFÄLLE +// ════════════════════════════════════════ +async function loadEmergencyEvents() { + const container = $("#emergency-content"); + container.innerHTML = '

    lade …

    '; + const events = await adminFetch("/api/admin/emergency-events"); + if (!events) { container.innerHTML = '

    Fehler beim Laden.

    '; return; } + if (!events.length) { container.innerHTML = '

    Keine Notfall-Ereignisse protokolliert.

    '; return; } + container.innerHTML = ` +
    + + + + + + + + + + + ${events.map((ev) => ` + + + + + + `).join("")} + +
    ZeitpunktNutzerKategorieTextausschnitt
    ${fmtDate(ev.created_at)}${escHtml(ev.display_name)} + ${escHtml(ev.category)} + ${escHtml(ev.snippet)}
    +
    `; +} + +// ════════════════════════════════════════ +// TAB: STATUS +// ════════════════════════════════════════ +// LLM-/GPU-Status-Karte (read-only) für den Status-Tab. +function renderLlmCard(llm) { + if (!llm) return ""; + const backendLabel = { ollama: "Ollama", llamacpp: "llama.cpp", unknown: "—" }[llm.backend] || llm.backend; + const dot = (on) => ``; + const row = (label, val) => + `
    ${label}
    +
    ${escHtml(val ?? "—")}
    `; + + const loaded = (llm.ollama_loaded || []).map((m) => + `${escHtml(m.name)} (${escHtml(m.processor || "")})` + ).join(", ") || "—"; + + const gpuBars = (llm.gpus || []).map((g) => ` +
    + GPU ${g.index} +
    +
    +
    + ${g.used_mib} / ${g.total_mib} MiB +
    `).join("") || '

    keine GPU-Daten (nvidia-smi)

    '; + + return ` +
    +
    + ${dot(true)}

    LLM-Backend

    + ${escHtml(backendLabel)} +
    +
    + ${row("Modell", llm.model)} + ${row("URL", llm.base_url)} + ${row("Ollama erreichbar", llm.ollama_reachable ? "ja" : "nein")} + ${row("llama.cpp läuft", llm.llamacpp_running ? "ja" : "nein")} + ${row("Geladene Modelle", loaded)} + ${row("Gateway-Dienst", llm.gateway_service_active ? "aktiv (systemd)" : "Vordergrund/aus")} +
    +
    + ${gpuBars} +
    + + +
    + + + + + +
    +

    Hinweis: Wirkt vollständig nur, wenn das Gateway als systemd-Dienst läuft (sonst .env/Backend umgestellt, aber Gateway manuell neu starten).

    +
    `; +} + +// Wartet, bis das Gateway nach einem Neustart wieder antwortet, dann Status neu laden. +async function pollGatewayBack(msgEl, label) { + for (let i = 0; i < 40; i++) { + await new Promise((r) => setTimeout(r, 1500)); + try { + const r = await fetch("/api/config", { cache: "no-store" }); + if (r.ok) { if (msgEl) msgEl.textContent = label + " ✓"; loadStatus(); return; } + } catch (e) { /* noch nicht oben */ } + if (msgEl) msgEl.textContent = label + " … (" + (i + 1) + ")"; + } + if (msgEl) msgEl.textContent = label + " — Zeitüberschreitung, bitte Status manuell prüfen."; +} + +function wireLlmControls() { + const sel = $("#llm-backend-sel"); + const switchBtn = $("#llm-switch-btn"); + const restartBtn = $("#gw-restart-btn"); + const msg = $("#llm-ctrl-msg"); + if (!switchBtn) return; + + switchBtn.addEventListener("click", async () => { + const backend = sel.value; + const model = ($("#llm-model-inp").value || "").trim(); + if (!confirm(`Backend auf „${backend}"${backend === "ollama" && model ? " (" + model + ")" : ""} umstellen?\nDie GPU des anderen Backends wird freigegeben.`)) return; + switchBtn.disabled = true; + msg.textContent = "stelle um …"; + const body = { backend }; + if (backend === "ollama" && model) body.model = model; + const res = await adminFetch("/api/admin/llm/backend", "POST", body); + if (res === null) { msg.textContent = "Fehler (Modell/Backend ungültig?)"; switchBtn.disabled = false; return; } + pollGatewayBack(msg, "Backend gewechselt"); + }); + + restartBtn.addEventListener("click", async () => { + if (!confirm("Gateway-Dienst jetzt neu starten?")) return; + restartBtn.disabled = true; + msg.textContent = "starte neu …"; + const res = await adminFetch("/api/admin/gateway/restart", "POST"); + if (res === null) { msg.textContent = "Fehler beim Neustart"; restartBtn.disabled = false; return; } + pollGatewayBack(msg, "Neu gestartet"); + }); +} + +async function loadStatus() { + const container = $("#status-content"); + container.innerHTML = '

    lade …

    '; + try { + const [cfg, met, llm] = await Promise.all([ + fetch("/api/config").then((r) => r.json()), + fetch("/api/metrics").then((r) => r.json()), + adminFetch("/api/admin/llm/status"), + ]); + const route = cfg.default_route || {}; + const counters = met.counters || {}; + const turnsTotal = counters["turns_total"] || 0; + const httpTotal = Object.entries(counters) + .filter(([k]) => k.startsWith("http_requests_total")) + .reduce((s, [, v]) => s + v, 0); + + const providerRow = (label, val) => + `
    ${label}
    +
    ${escHtml(val || "—")}
    `; + + container.innerHTML = ` + +
    +
    + +

    Gateway

    + läuft +
    +
    + ${providerRow("Profil", cfg.profile)} + ${providerRow("Umgebung", cfg.app_env)} +
    +
    + + +
    +

    Provider (Standard-Route)

    +
    + ${providerRow("STT", route.stt_provider)} + ${providerRow("LLM", route.llm_provider)} + ${providerRow("TTS", route.tts_provider)} + ${providerRow("Sprache", route.language)} +
    +
    + + ${renderLlmCard(llm)} + + +
    +

    Laufzeit-Metriken

    +
    + ${providerRow("Turns gesamt", turnsTotal)} + ${providerRow("HTTP-Anfragen", httpTotal)} +
    +
    + + +
    +

    Verfügbare Provider

    +
    + ${providerRow("STT", (cfg.available?.stt_providers || []).join(", "))} + ${providerRow("LLM", (cfg.available?.llm_providers || []).join(", "))} + ${providerRow("TTS", (cfg.available?.tts_providers || []).join(", "))} +
    +
    + + +
    +

    Datenbank-Backup

    +

    SQLite-Datenbank als Datei herunterladen (Backup / Migration).

    + + ⬇ voice-assistant.db herunterladen + +
    + `; + wireLlmControls(); // Buttons der LLM-Karte verdrahten (nach dem Rendern) + } catch (e) { + console.error("loadStatus error:", e); + container.innerHTML = '

    Fehler beim Laden.

    '; + } +} + +// ════════════════════════════════════════ +// TAB: METRIKEN +// ════════════════════════════════════════ +async function loadMetrics() { + const container = $("#metrics-content"); + container.innerHTML = '

    lade …

    '; + const data = await adminFetch("/api/admin/usage"); + if (!data) { container.innerHTML = '

    Fehler beim Laden.

    '; return; } + if (!data.length) { container.innerHTML = '

    Noch keine Nutzungsdaten vorhanden.

    '; return; } + + const totalReq = data.reduce((s, u) => s + (u.total_requests || 0), 0); + const maxReq = Math.max(...data.map((u) => u.total_requests || 0), 1); + + const bars = data.map((u) => { + const pct = Math.round(((u.total_requests || 0) / maxReq) * 100); + return ` +
    + ${escHtml(u.display_name)} +
    +
    +
    + ${u.total_requests ?? 0} +
    `; + }).join(""); + + container.innerHTML = ` +
    + + + + + + + + + + + ${data.map((u) => ` + + + + + + `).join("")} + + + + + + + + + +
    NutzerAnfragen gesamtEinheitenLetzte Aktivität
    ${escHtml(u.display_name)}${u.total_requests ?? 0}${u.total_units ?? 0}${u.last_active || "—"}
    Gesamt${totalReq}
    +
    + +
    +

    Anfragen je Nutzer

    +
    ${bars}
    +
    + `; +} + +// ════════════════════════════════════════ +// TAB: WÖRTERBUCH +// ════════════════════════════════════════ +async function loadWoerterbuch() { + const lang = ($("#words-lang") || {}).value || "de"; + const container = $("#words-content"); + container.innerHTML = '

    lade …

    '; + const data = await adminFetch(`/api/admin/pronunciation/${lang}`); + if (!data) { container.innerHTML = '

    Fehler beim Laden.

    '; return; } + container.innerHTML = ""; + const sections = [ + ["abbreviations", "Abkürzungen"], + ["units", "Einheiten"], + ["terms", "Begriffe / Aussprache"], + ]; + for (const [section, label] of sections) { + container.appendChild(buildWortSection(label, section, data[section] || {}, lang)); + } +} + +function buildWortSection(title, section, entries, lang) { + const wrap = document.createElement("div"); + wrap.className = "bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4"; + + const rows = Object.entries(entries).map(([k, v]) => ` + + ${escHtml(k)} + → + ${escHtml(v)} + + + + `).join(""); + + wrap.innerHTML = ` +

    + ${escHtml(title)} + ${Object.keys(entries).length} Einträge +

    + ${rows ? ` +
    + + ${rows} +
    +
    ` : '

    Keine Einträge.

    '} +
    + + + +
    + `; + + const form = wrap.querySelector(".add-form"); + const [keyInp, valInp] = form.querySelectorAll("input"); + const submitBtn = form.querySelector('button[type="submit"]'); + + // Zeile anklicken -> Begriffspaar in die Editier-Felder laden, Button wird zu „Speichern". + wrap.querySelectorAll("tr.row-edit").forEach((tr) => + tr.addEventListener("click", () => { + keyInp.value = tr.dataset.key; + valInp.value = tr.dataset.val; + form.dataset.editKey = tr.dataset.key; // Originalschlüssel merken (für Umbenennen) + submitBtn.textContent = "Speichern"; + valInp.focus(); + }) + ); + + wrap.querySelectorAll(".del-btn").forEach((btn) => + btn.addEventListener("click", async (e) => { + e.stopPropagation(); // nicht zugleich die Zeile zum Bearbeiten laden + const key = btn.dataset.key; + if (!confirm(`Eintrag „${key}" löschen?`)) return; + const res = await adminFetch( + `/api/admin/pronunciation/${lang}/${section}/${encodeURIComponent(key)}`, "DELETE" + ); + if (res !== null) btn.closest("tr").remove(); + }) + ); + + form.addEventListener("submit", async (e) => { + e.preventDefault(); + const key = keyInp.value.trim(); + const value = valInp.value.trim(); + if (!key || !value) return; + const editKey = form.dataset.editKey || ""; + // Hinzufügen ODER Aktualisieren (gleicher Schlüssel wird überschrieben; Backend sortiert). + const res = await adminFetch(`/api/admin/pronunciation/${lang}`, "POST", { section, key, value }); + if (res === null) return; + // Umbenannt -> alten Schlüssel entfernen (neuer wurde oben bereits angelegt). + if (editKey && editKey !== key) { + await adminFetch( + `/api/admin/pronunciation/${lang}/${section}/${encodeURIComponent(editKey)}`, "DELETE" + ); + } + loadWoerterbuch(); // neu laden -> sortierte Liste, Formular zurück auf „+ Hinzufügen" + }); + + return wrap; +} + +$("#load-words").addEventListener("click", loadWoerterbuch); +$("#words-lang").addEventListener("change", loadWoerterbuch); + +// ════════════════════════════════════════ +// TAB: LOG +// ════════════════════════════════════════ +let _logWs = null; + +function connectLog() { + if (_logWs) return; + const proto = location.protocol === "https:" ? "wss" : "ws"; + _logWs = new WebSocket(`${proto}://${location.host}/api/admin/log`); + const output = $("#log-output"); + const logStat = $("#log-status"); + const btnConn = $("#log-connect"); + const btnDisc = $("#log-disconnect"); + + btnConn.disabled = true; + btnDisc.disabled = false; + btnDisc.classList.remove("opacity-40"); + logStat.textContent = "verbinde …"; + logStat.className = "text-xs text-amber-400"; + + _logWs.onopen = () => { + logStat.textContent = "verbunden"; + logStat.className = "text-xs text-emerald-400"; + output.innerHTML = ""; + }; + + _logWs.onmessage = (e) => { + const line = document.createElement("div"); + line.textContent = e.data; + output.appendChild(line); + output.scrollTop = output.scrollHeight; + }; + + _logWs.onerror = () => { + logStat.textContent = "Verbindungsfehler"; + logStat.className = "text-xs text-red-400"; + }; + + _logWs.onclose = () => { + _logWs = null; + btnConn.disabled = false; + btnDisc.disabled = true; + btnDisc.classList.add("opacity-40"); + if (!logStat.className.includes("red")) { + logStat.textContent = "getrennt"; + logStat.className = "text-xs text-slate-400"; + } + }; +} + +function disconnectLog() { + if (_logWs) { _logWs.close(); } +} + +$("#log-connect").addEventListener("click", connectLog); +$("#log-disconnect").addEventListener("click", disconnectLog); +$("#log-clear").addEventListener("click", () => { $("#log-output").innerHTML = ""; }); + +// ════════════════════════════════════════ +// TAB: EINSTELLUNGEN +// ════════════════════════════════════════ + +// Metadaten für jedes Feld: UI-Typ, Optionen, Test-Typ +const FIELD_META = { + default_stt_provider: { ui: "combo", opts: ["openrouter","faster-whisper"], test: "stt" }, + default_llm_provider: { ui: "combo", opts: ["openrouter","local-openai-compatible"], test: "llm" }, + default_tts_provider: { ui: "combo", opts: ["openrouter","piper","chatterbox"], test: "tts" }, + default_language: { ui: "combo", opts: ["de","en","fr","es","it","nl"], + labels: { de:"Deutsch",en:"Englisch",fr:"Französisch",es:"Spanisch",it:"Italienisch",nl:"Niederländisch" } }, + openrouter_llm_model: { ui: "combo", opts: ["google/gemini-3.1-flash-lite","openai/gpt-4.1-mini","google/gemini-2.5-flash","anthropic/claude-haiku-4-5-20251001","meta-llama/llama-3.3-70b-instruct"], test: "llm" }, + openrouter_tts_model: { ui: "combo", opts: ["google/gemini-3.1-flash-tts-preview","openai/gpt-4o-mini-tts"], test: "tts", testProvider: "openrouter" }, + openrouter_tts_voice: { ui: "combo", opts: ["Zephyr","Puck","Charon","Kore","Fenrir","Leda","Orus","Aoede","Callirrhoe","Enceladus","Algieba","Despina","Achernar"], test: "tts", testProvider: "openrouter" }, + piper_voice: { ui: "combo", opts: ["de_DE-thorsten-high","en_US-ryan-high","en_US-lessac-high","en_GB-cori-high","es_ES-sharvard-medium","fr_FR-siwis-medium","it_IT-paola-medium","nl_NL-mls-medium","ru_RU-irina-medium","zh_CN-huayan-medium"], test: "tts", testProvider: "piper" }, + local_llm_system_prompt: { ui: "textarea", test: "llm" }, + local_llm_temperature: { ui: "range", min: 0, max: 2, step: 0.1, test: "llm" }, + local_llm_top_p: { ui: "range", min: 0, max: 1, step: 0.05, test: "llm" }, + local_llm_max_tokens: { ui: "number", min: 0, test: "llm" }, + tts_normalize_level: { ui: "select", opts: ["auto","full","light","off"], test: "tts" }, + audio_stream_default: { ui: "select", opts: ["true","false"], + labels: { "true":"Aktiviert (satzweises Streaming)","false":"Deaktiviert (alles auf einmal)" }, test: "tts" }, + memory_extraction_enabled: { ui: "select", opts: ["true","false"], + labels: { "true":"Aktiviert","false":"Deaktiviert" } }, + memory_extraction_every_n_turns: { ui: "number", min: 1, max: 20 }, + daily_request_limit: { ui: "number", min: 0 }, +}; + +const FIELD_GROUPS = [ + { label: "Provider & Sprache", keys: ["default_stt_provider","default_llm_provider","default_tts_provider","default_language"] }, + { label: "OpenRouter-Modelle & Stimmen", keys: ["openrouter_llm_model","openrouter_tts_model","openrouter_tts_voice"] }, + { label: "Piper TTS", keys: ["piper_voice"] }, + { label: "Lokales LLM", keys: ["local_llm_system_prompt","local_llm_temperature","local_llm_top_p","local_llm_max_tokens"] }, + { label: "TTS-Verarbeitung", keys: ["tts_normalize_level","audio_stream_default"] }, + { label: "Gedächtnis", keys: ["memory_extraction_enabled","memory_extraction_every_n_turns"] }, + { label: "Limits", keys: ["daily_request_limit"] }, +]; + +let _settingsData = []; + +async function loadSettings() { + const container = $("#settings-content"); + container.innerHTML = '

    lade …

    '; + const data = await adminFetch("/api/admin/config"); + if (!data) { container.innerHTML = '

    Fehler beim Laden.

    '; return; } + _settingsData = data; + container.innerHTML = ""; + const byKey = Object.fromEntries(data.map((d) => [d.key, d])); + FIELD_GROUPS.forEach((group) => { + const items = group.keys.map((k) => byKey[k]).filter(Boolean); + if (!items.length) return; + const section = document.createElement("div"); + section.className = "bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 overflow-hidden"; + const header = document.createElement("div"); + header.className = "px-4 py-2.5 border-b border-slate-100 dark:border-slate-700 bg-slate-50/80 dark:bg-slate-800/60"; + header.innerHTML = `

    ${escHtml(group.label)}

    `; + section.appendChild(header); + items.forEach((item, idx) => { + const row = buildSettingRow(item); + if (idx > 0) row.classList.add("border-t", "border-slate-100", "dark:border-slate-700/60"); + section.appendChild(row); + }); + container.appendChild(section); + }); +} + +function _buildControl(item, meta) { + const val = item.effective_value; + const id = "cfg-" + item.key; + const cls = "rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-3 py-2 text-xs font-mono focus:outline-none focus:ring-2 focus:ring-blue-500"; + + if (meta.ui === "select") { + const labels = meta.labels || {}; + const options = meta.opts.map((o) => + `` + ).join(""); + return ``; + } + + if (meta.ui === "combo") { + const inList = meta.opts.includes(val); + const labels = meta.labels || {}; + const options = meta.opts.map((o) => + `` + ).join(""); + const customSel = !inList ? 'selected' : ''; + return ` +
    + + +
    `; + } + + if (meta.ui === "range") { + return ` +
    + + ${escHtml(val)} +
    `; + } + + if (meta.ui === "number") { + const extra = meta.max !== undefined ? `max="${meta.max}"` : ""; + return ``; + } + + if (meta.ui === "textarea") { + return ``; + } + + return ``; +} + +function buildSettingRow(item) { + const meta = FIELD_META[item.key] || { ui: "text" }; + const overridden = item.is_overridden; + const envName = item.key.toUpperCase(); + const testType = meta.test; + + const wrap = document.createElement("div"); + wrap.className = "px-4 py-3" + (overridden ? " bg-amber-50/40 dark:bg-amber-900/10" : ""); + + const testBtn = testType + ? `` + : ""; + + const resetBtn = overridden + ? `` + : ""; + + wrap.innerHTML = ` +
    + ${escHtml(item.label)} + ${overridden ? 'überschrieben' : ""} + ${resetBtn} +
    +
    + ${_buildControl(item, meta)} +
    + + ${testBtn} +
    +
    +
    + ${envName} + · + .env: ${escHtml(item.base_value) || "—"} + · + ${escHtml(item.hint)} +
    + + + `; + + // Combo-Synchronisation: Dropdown ↔ Textfeld + const comboSel = wrap.querySelector(".combo-sel"); + const comboInp = wrap.querySelector(".combo-inp"); + if (comboSel && comboInp) { + comboSel.addEventListener("change", () => { + if (comboSel.value === "__custom__") { + comboInp.classList.remove("hidden"); + comboInp.focus(); + } else { + comboInp.value = comboSel.value; + comboInp.classList.add("hidden"); + } + }); + comboInp.addEventListener("input", () => { + const match = Array.from(comboSel.options).find((o) => o.value === comboInp.value); + comboSel.value = match ? match.value : "__custom__"; + }); + } + + // Range-Slider: Wert live anzeigen + const rangeInp = wrap.querySelector('input[type="range"]'); + if (rangeInp) { + rangeInp.addEventListener("input", () => { + wrap.querySelector(".range-val").textContent = rangeInp.value; + }); + } + + // Aktuellen Wert holen (aus Combo-Textfeld oder normalem Input) + function getCurrentValue() { + if (comboInp) return comboInp.value.trim(); + const inp = wrap.querySelector(`#cfg-${item.key}`); + return inp ? (inp.value ?? inp.textContent ?? "").trim() : ""; + } + + // Feedback-Anzeige + function showFeedback(ok, msg) { + const fb = wrap.querySelector(".feedback-msg"); + fb.textContent = msg; + fb.className = "feedback-msg text-xs mt-1 " + (ok ? "text-emerald-600 dark:text-emerald-400" : "text-red-500"); + setTimeout(() => { fb.className = "feedback-msg text-xs mt-1 hidden"; }, 3000); + } + + // Speichern + wrap.querySelector(".save-btn").addEventListener("click", async () => { + const val = getCurrentValue(); + const res = await adminFetch(`/api/admin/config/${item.key}`, "PUT", { value: val }); + if (res !== null) { + showFeedback(true, "✓ Gespeichert"); + item.is_overridden = true; + item.effective_value = val; + // Overrides-Badge nachrüsten falls noch nicht da + if (!wrap.querySelector(".reset-btn")) { + wrap.querySelector(".flex.items-center.gap-2").insertAdjacentHTML("beforeend", + ''); + wrap.querySelector(".reset-btn").addEventListener("click", doReset); + } + } else { + showFeedback(false, "✗ Fehler beim Speichern"); + } + }); + + // Reset + async function doReset() { + if (!confirm(`Override für „${item.label}" entfernen?\nDer .env-Wert gilt danach wieder.`)) return; + const res = await adminFetch(`/api/admin/config/${item.key}`, "DELETE"); + if (res !== null) loadSettings(); + } + wrap.querySelector(".reset-btn")?.addEventListener("click", doReset); + + // Test + const testBtnEl = wrap.querySelector(".test-btn"); + if (testBtnEl) { + testBtnEl.addEventListener("click", async () => { + const resultEl = wrap.querySelector(".test-result"); + resultEl.textContent = "…"; + resultEl.classList.remove("hidden"); + testBtnEl.disabled = true; + try { + if (testType === "tts") { + const body = { text: "Hallo, ich bin der Sprachassistent." }; + if (meta.testProvider) body.tts_provider = meta.testProvider; + const r = await fetch("/api/speak", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!r.ok) throw new Error("HTTP " + r.status); + const buf = await r.arrayBuffer(); + const view = new DataView(buf); + const n = Math.floor(buf.byteLength / 2); + audioCtx = audioCtx || new (window.AudioContext || window.webkitAudioContext)(); + const abuf = audioCtx.createBuffer(1, n, 24000); + const ch = abuf.getChannelData(0); + for (let i = 0; i < n; i++) ch[i] = view.getInt16(i * 2, true) / 32768; + const src = audioCtx.createBufferSource(); + src.buffer = abuf; src.connect(audioCtx.destination); src.start(); + resultEl.textContent = "▶ Audio spielt …"; + src.onended = () => { resultEl.classList.add("hidden"); }; + } else if (testType === "llm") { + const r = await fetch("/api/chat?debug=true", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ text: "Sag kurz Hallo.", tts_provider: "piper" }), + }); + const d = await r.json(); + resultEl.textContent = d.response || d.semantic || d.trace?.response || JSON.stringify(d).slice(0, 200); + } else if (testType === "stt") { + const cfg = await fetch("/api/config").then((r) => r.json()); + const available = cfg.available?.stt_providers || []; + const current = item.effective_value; + resultEl.textContent = available.includes(current) + ? `✓ Provider „${current}" ist verfügbar.` + : `✗ Provider „${current}" nicht in verfügbaren: ${available.join(", ")}`; + } + } catch (e) { + resultEl.textContent = "Fehler: " + e.message; + } + testBtnEl.disabled = false; + }); + } + + return wrap; +} + +$("#load-settings").addEventListener("click", loadSettings); diff --git a/app/web/apple-touch-icon.png b/app/web/apple-touch-icon.png new file mode 100644 index 0000000..7cb4f37 Binary files /dev/null and b/app/web/apple-touch-icon.png differ diff --git a/app/web/favicon-32.png b/app/web/favicon-32.png new file mode 100644 index 0000000..70955e7 Binary files /dev/null and b/app/web/favicon-32.png differ diff --git a/app/web/favicon.svg b/app/web/favicon.svg new file mode 100644 index 0000000..751dfb9 --- /dev/null +++ b/app/web/favicon.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/web/index.html b/app/web/index.html new file mode 100644 index 0000000..fafb5b0 --- /dev/null +++ b/app/web/index.html @@ -0,0 +1,331 @@ + + + + + + Voice Assistant + + + + + + + + + + + + + + + +
    + +

    Voice Assistant

    + + +
    + + +
    + lade … + +
    + + + + + + + + +
    + +
    +
    + + + +
    +
    +
    + + + + diff --git a/chat_client.py b/chat_client.py new file mode 100644 index 0000000..20ebb9e --- /dev/null +++ b/chat_client.py @@ -0,0 +1,76 @@ +import io +import wave +import requests +import soundfile as sf +import numpy as np +import subprocess +import sys + +GATEWAY_URL = "http://localhost:8003" +CHAT_ENDPOINT = f"{GATEWAY_URL}/api/chat" + +# feste Annahmen für Gemini 3.1 Flash TTS über OpenRouter +SAMPLE_RATE = 24000 +CHANNELS = 1 +SAMPLE_WIDTH = 2 # 16-bit PCM + + +def pcm_to_wav(pcm_bytes: bytes, wav_path: str) -> None: + """Rohes s16le-PCM in eine WAV-Datei schreiben.""" + with wave.open(wav_path, "wb") as wf: + wf.setnchannels(CHANNELS) + wf.setsampwidth(SAMPLE_WIDTH) + wf.setframerate(SAMPLE_RATE) + wf.writeframes(pcm_bytes) + + +def play_wav(wav_path: str) -> None: + """WAV-Datei abspielen (ffplay oder aplay/mpv, je nach System).""" + for cmd in ( + ["ffplay", "-nodisp", "-autoexit", wav_path], + ["aplay", wav_path], + ["mpv", wav_path], + ): + try: + subprocess.run(cmd, check=True) + return + except (FileNotFoundError, subprocess.CalledProcessError): + continue + print(f"Konnte keine geeignete Player-CLI finden für {wav_path}", file=sys.stderr) + + +def chat_and_play(text: str, language: str = "de") -> None: + payload = {"text": text, "language": language} + + resp = requests.post( + CHAT_ENDPOINT, + json=payload, + stream=True, + ) + + if not resp.ok: + print("HTTP", resp.status_code) + print(resp.text) + return + + pcm_bytes = b"".join(resp.iter_content(chunk_size=8192)) + + # Hinweis: Den Text-Trace (Transkript/Antwort) liefert /api/chat nur im JSON, + # wenn man ?debug=true anhängt - nicht als Header im Audio-Stream. + print("Audio-Format:", resp.headers.get("X-Audio-Format")) + print("Sample-Rate:", resp.headers.get("X-Audio-Sample-Rate")) + + wav_path = "chat_reply.wav" + pcm_to_wav(pcm_bytes, wav_path) + print(f"WAV gespeichert unter {wav_path}") + play_wav(wav_path) + + +if __name__ == "__main__": + if len(sys.argv) > 1: + user_text = " ".join(sys.argv[1:]) + else: + user_text = "Wie wird das Wetter morgen in Bünde?" + + chat_and_play(user_text, language="de") + diff --git a/config/pronunciation.de.yaml b/config/pronunciation.de.yaml new file mode 100644 index 0000000..9b18e97 --- /dev/null +++ b/config/pronunciation.de.yaml @@ -0,0 +1,17 @@ +abbreviations: {} +units: {} +terms: + bläst: blähst + büßt: bühßt + Chat: Tschätt + Dornröschen: Dornröhß-chen + Glycerin: Glüzeriehn + grüßt: grühßt + Iljitsch: Illjitsch + löst: löhst + Mond: Mohnd + Piotr: Pjottr + strömt: ströhmt + strömte: ströhmte + Tchaikovsky: Tschai'kowski + tönt: töhnt diff --git a/config/pronunciation.en.yaml b/config/pronunciation.en.yaml new file mode 100644 index 0000000..b4fed3a --- /dev/null +++ b/config/pronunciation.en.yaml @@ -0,0 +1,5 @@ +abbreviations: {} +units: {} +terms: + Dieter: Deeter + Schlüter: Shleeter diff --git a/config/pronunciation.es.yaml b/config/pronunciation.es.yaml new file mode 100644 index 0000000..8acdfc2 --- /dev/null +++ b/config/pronunciation.es.yaml @@ -0,0 +1,12 @@ +# Léxico de pronunciación (Español) para la normalización TTS antes de Piper. +# Wirkt nur bei lokalem TTS (piper, Stufe "full"). + +abbreviations: + +units: + +terms: + # Nombre del responsable del sistema — aproximación fonética para espeak-ng es. + # ES no tiene /ʃ/; "Schlueter" es la mejor aproximación disponible. + # espeak-es leerá "sch" como /sk/ y "ue" como /we/ → /ˈsklweter/. + "Schlüter": "Schlueter" diff --git a/config/pronunciation.fr.yaml b/config/pronunciation.fr.yaml new file mode 100644 index 0000000..0fbab40 --- /dev/null +++ b/config/pronunciation.fr.yaml @@ -0,0 +1,5 @@ +abbreviations: {} +units: {} +terms: + Dieter: Diter + Schlüter: Chluteur diff --git a/config/pronunciation.it.yaml b/config/pronunciation.it.yaml new file mode 100644 index 0000000..6fb3da9 --- /dev/null +++ b/config/pronunciation.it.yaml @@ -0,0 +1,12 @@ +# Lessico di pronuncia (Italiano) per la normalizzazione TTS prima di Piper. +# Wirkt nur bei lokalem TTS (piper, Stufe "full"). + +abbreviations: + +units: + +terms: + # Nome del responsabile del sistema — approssimazione fonetica per espeak-ng it. + # IT: "sch" prima di consonante = /sk/; "ue" = /wɛ/ → /ˈsklwɛter/. + # Alternativa: "Scilueter" (sc+i = /ʃ/ in IT), ma suona strano. + "Schlüter": "Schlueter" diff --git a/config/pronunciation.nl.yaml b/config/pronunciation.nl.yaml new file mode 100644 index 0000000..b78bc94 --- /dev/null +++ b/config/pronunciation.nl.yaml @@ -0,0 +1,12 @@ +# Uitspraak-lexicon (Nederlands) voor TTS-normalisatie vóór Piper. +# Wirkt nur bei lokalem TTS (piper, Stufe "full"). + +abbreviations: + +units: + +terms: + # Naam van de systeembeheerder — fonetische benadering voor espeak-ng nl. + # NL: "sch" = /sx/, "uu" = /yː/ (= Duits ü) → /sxlyːtər/ ≈ Duits /ʃlyːtɐ/. + # "sch" klinkt anders dan Duits (sx vs. ʃ), maar "uu" treft de klinker exact. + "Schlüter": "Schluuter" diff --git a/config/pronunciation.ru.yaml b/config/pronunciation.ru.yaml new file mode 100644 index 0000000..69cf925 --- /dev/null +++ b/config/pronunciation.ru.yaml @@ -0,0 +1,14 @@ +# Словарь произношения (Русский) для нормализации TTS перед Piper. +# Wirkt nur bei lokalem TTS (piper, Stufe "full"). +# Kyrillisch verwenden — espeak-ng ru phonemisiert lateinische Buchstaben schlecht. + +abbreviations: + +units: + +terms: + # Имя ответственного за систему — кириллическая транскрипция. + # "Шлютер": Ш=/ʃ/, лю=/lʲu/ (nächste Annäherung an /lyː/), тер=/tʲɛr/. + # "Дитер": Д=/d/, и=/i/, тер=/tʲɛr/ → /dʲitʲɛr/ ≈ deutsch /ˈdiːtɐ/. + "Dieter": "Дитер" + "Schlüter": "Шлютер" diff --git a/config/pronunciation.zh.yaml b/config/pronunciation.zh.yaml new file mode 100644 index 0000000..2592b16 --- /dev/null +++ b/config/pronunciation.zh.yaml @@ -0,0 +1,14 @@ +# 发音词典(中文)TTS 前文本规范化。 +# Wirkt nur bei lokalem TTS (piper, Stufe "full"). +# Hanzi verwenden — espeak-ng zh phonemisiert lateinische Buchstaben schlecht. + +abbreviations: + +units: + +terms: + # 系统负责人姓名——汉字音译。 + # 施=/ʃɨ/ (sh-Sound), 吕=lǚ=/ly/ (exakt das deutsche ü!), 特=tè=/tɛ/ → 施吕特≈/ʃɨlytɛ/. + # 迪=Dí=/di/, 特=tè=/tɛ/ → 迪特≈/dite/ ≈ deutsch /ˈdiːtɐ/. + "Dieter": "迪特" + "Schlüter": "施吕特" diff --git a/config/voice-assistant.example.toml b/config/voice-assistant.example.toml new file mode 100644 index 0000000..1bfb4b9 --- /dev/null +++ b/config/voice-assistant.example.toml @@ -0,0 +1,45 @@ +# Zentrale Konfiguration des Voice-Assistant-Gateways. +# +# WICHTIG: Secrets (API-Keys) gehoeren NICHT in diese Datei -> ausschliesslich +# ueber Umgebungsvariablen (z. B. OPENROUTER_API_KEY). +# +# Praezedenz (hoeher gewinnt): +# eingebaute Defaults < diese TOML-Datei < ENV/.env < Session-Route < Request +# +# Aktives Profil waehlen via ENV: VA_PROFILE=local-dev | hybrid | cloud +# Eigenen Pfad setzen via ENV: VA_CONFIG_FILE=/pfad/zu/voice-assistant.toml +# +# Diese Datei nach config/voice-assistant.toml kopieren und anpassen. + +# Basiswerte, die fuer alle Profile gelten (von Profilen ueberschreibbar). +[defaults] +default_language = "de" +default_input_endpoint = "local-default" +default_output_endpoint = "local-default" + +openrouter_stt_model = "openai/whisper-large-v3" +openrouter_tts_model = "openai/gpt-4o-mini-tts" +openrouter_tts_voice = "alloy" +openrouter_llm_model = "openai/gpt-4.1-mini" + +# Lokaler llama.cpp-Server (zentrale, unzensierte KI) - Start: scripts/llm-server/start-llm-server.sh +local_llm_base_url = "http://127.0.0.1:8001/v1" +local_llm_model = "va_llm" # = --alias des llama.cpp-Servers + +# Reines lokales Setup (eigene Hardware/KI) - z. B. fuer Entwicklung/Offline-Test. +[profiles.local-dev] +default_stt_provider = "faster-whisper" +default_llm_provider = "local-openai-compatible" +default_tts_provider = "piper" + +# Hybrid: STT/TTS remote, Haupt-LLM lokal. +[profiles.hybrid] +default_stt_provider = "openrouter" +default_llm_provider = "local-openai-compatible" +default_tts_provider = "openrouter" + +# Voll-Cloud: alle KI-Module remote (Standard fuer den produktiven vHost-Betrieb). +[profiles.cloud] +default_stt_provider = "openrouter" +default_llm_provider = "openrouter" +default_tts_provider = "openrouter" diff --git a/config/voices/README.md b/config/voices/README.md new file mode 100644 index 0000000..b01db61 --- /dev/null +++ b/config/voices/README.md @@ -0,0 +1,37 @@ +# Native Referenz-Stimmen (Chatterbox cross-lingual) + +Je Sprache eine kurze Referenz-WAV (~6–8 s). Der Chatterbox-TTS-Provider wählt +sie automatisch nach der Antwortsprache aus (Konvention `.wav`, siehe +`CHATTERBOX_VOICES_DIR`). Das Timbre der Referenz wird cross-lingual auf die +jeweilige Sprache übertragen. Fehlt eine Datei, greift `CHATTERBOX_VOICE` +(persönlicher Klon) bzw. die Standardstimme des Dienstes. + +Deutsch (`de`) hat bewusst **keine** Datei hier — dafür gilt der persönliche +Klon aus `CHATTERBOX_VOICE`. + +## Quelle & Lizenz + +Die Clips stammen aus dem **FLEURS**-Datensatz (Google), Split `validation`: + +| Datei | FLEURS-Config | Sprache | +|-------|---------------|---------| +| en.wav | en_us | Englisch (US) | +| fr.wav | fr_fr | Französisch | +| es.wav | es_419 | Spanisch (Lateinamerika) | +| it.wav | it_it | Italienisch | +| nl.wav | nl_nl | Niederländisch | +| ru.wav | ru_ru | Russisch | +| zh.wav | cmn_hans_cn | Mandarin-Chinesisch | + +Die Clips wurden auf einheitliche Lautheit normalisiert (EBU R128, `loudnorm` +I=-16 LUFS, TP=-1.5 dB) — die FLEURS-Originalpegel waren stark uneinheitlich +(z. B. ru/en/nl deutlich zu leise). Nur Pegelanpassung, kein inhaltlicher Eingriff. + +**Lizenz: CC-BY 4.0** — Namensnennung erforderlich. + +> FLEURS: Conneau et al., "FLEURS: Few-shot Learning Evaluation of Universal +> Representations of Speech" (Google Research). Datensatz: `google/fleurs` +> auf Hugging Face. Lizenz: Creative Commons Attribution 4.0 (CC-BY 4.0). + +Zum Austauschen einfach eine andere `.wav` ablegen (Pfad muss für den +Chatterbox-Dienst lesbar sein; der Provider löst ihn absolut auf). diff --git a/config/voices/en.wav b/config/voices/en.wav new file mode 100644 index 0000000..aae052c Binary files /dev/null and b/config/voices/en.wav differ diff --git a/config/voices/es.wav b/config/voices/es.wav new file mode 100644 index 0000000..0468022 Binary files /dev/null and b/config/voices/es.wav differ diff --git a/config/voices/fr.wav b/config/voices/fr.wav new file mode 100644 index 0000000..e3d9f45 Binary files /dev/null and b/config/voices/fr.wav differ diff --git a/config/voices/it.wav b/config/voices/it.wav new file mode 100644 index 0000000..376845d Binary files /dev/null and b/config/voices/it.wav differ diff --git a/config/voices/nl.wav b/config/voices/nl.wav new file mode 100644 index 0000000..58f4328 Binary files /dev/null and b/config/voices/nl.wav differ diff --git a/config/voices/ru.wav b/config/voices/ru.wav new file mode 100644 index 0000000..44b41e8 Binary files /dev/null and b/config/voices/ru.wav differ diff --git a/config/voices/zh.wav b/config/voices/zh.wav new file mode 100644 index 0000000..5f316d3 Binary files /dev/null and b/config/voices/zh.wav differ diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..fa8ed48 --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,96 @@ +# Remote-Betrieb über YunoHost (va.linix.de) + +Web-UI + Mikrofon vom Handy/Browser, abgesichert per HTTPS und YunoHost-SSO. +Topologie: `https://va.linix.de` → nginx@YunoHost (TLS, SSO) → über LAN → +`http://:8003` (Gateway). Der interne LAN-Hop ist unverschlüsselt +(vertrautes LAN, wie bei Ollama). + +> **Warum HTTPS Pflicht ist:** Browser geben das Mikrofon (`getUserMedia`) nur in +> einem „secure context" frei – also HTTPS oder `http://localhost`. Über einfaches +> `http://` vom Handy gibt es **kein** Mikrofon. + +## 1. Gateway auf der GPU-Box + +`deploy/voice-assistant.env.example` → `/etc/voice-assistant/voice-assistant.env` anpassen: +- `HOST` = **LAN-IP** der GPU-Box (nicht `0.0.0.0`), `PORT=8003` +- `AUTH_ENABLED=true` +- `TRUSTED_AUTH_HEADER` (nach Discovery, s. u.), `TRUSTED_PROXY_IPS` = LAN-IP von linix.de +- `ADMIN_USERS=atoor,dieterschlueter,dschlueter`, `SSO_LOGOUT_URL=…` + +> Kein `--forwarded-allow-ips` setzen: Das Identitäts-Vertrauen prüft die +> **direkte Quell-IP** (= der Proxy). Würde uvicorn den Client aus +> `X-Forwarded-For` überschreiben, schlüge der Proxy-IP-Check fehl. + +**Dauerhaft laufen lassen (systemd-User-Dienst, ohne root):** Vorlage +`deploy/voice-assistant.user.service`. Die App liest `.env` aus dem WorkingDirectory. +```bash +cp deploy/voice-assistant.user.service ~/.config/systemd/user/voice-assistant.service +loginctl enable-linger "$USER" # sudo -> ueberlebt Logout/Reboot +systemctl --user daemon-reload +systemctl --user enable --now voice-assistant +systemctl --user status voice-assistant +journalctl --user -u voice-assistant -f # Logs +``` +> Der llama.cpp-Container `va_llm` läuft separat via Docker (`restart unless-stopped`) +> und kommt nach einem Reboot von selbst hoch. + +## 2. Drei Sicherheits-Pflichten +1. **Bind:** Gateway nur an die LAN-IP (Schritt 1). +2. **Firewall:** Port 8003 der GPU-Box **nur** von der linix.de-IP erlauben, z. B.: + ``` + sudo ufw allow from to any port 8003 proto tcp + sudo ufw deny 8003 + ``` +3. **Header überschreiben:** nginx setzt den Identitäts-Header selbst; Client-Eingaben + werden verworfen (siehe nginx-Conf). App-seitig zusätzlich der Proxy-IP-Check. + +## 3. nginx auf YunoHost +`deploy/va.linix.de.nginx.conf` als Vorlage → auf dem YunoHost-Server unter +`/etc/nginx/conf.d/va.linix.de.d/assistant.conf` ablegen, `GPU_BOX_LAN_IP` +eintragen, die `map $http_upgrade …` einmalig im http{}-Kontext anlegen, dann +`nginx -t && systemctl reload nginx`. Subdomain `va.linix.de` in YunoHost +anlegen (Let's Encrypt) und per SSO schützen (nur erlaubte Tester/Gruppe). + +## 4. Identität: YunoHost liefert sie im Cookie (nicht als Header) +Discovery (`/api/admin/request-headers?key=` hinter dem SSO) zeigt: +YunoHost reicht den Usernamen **nicht** als eigenen Header durch, sondern im **JWT-Cookie +`yunohost.portal`** (Claim `user`). Das Gateway liest diesen Cookie direkt — daher: + +```ini +# .env auf der GPU-Box: +TRUSTED_AUTH_COOKIE=yunohost.portal +TRUSTED_AUTH_COOKIE_CLAIM=user +TRUSTED_PROXY_IPS= +# optional (Härtung): HS256-Secret des Portals -> Signaturpruefung +# TRUSTED_AUTH_JWT_SECRET=... +``` + +In der nginx-Conf ist **keine** `proxy_set_header`-Identitätszeile nötig — Cookies +werden ohnehin durchgereicht. + +> **Sicherheit:** Ohne `TRUSTED_AUTH_JWT_SECRET` wird die Cookie-Payload ungeprüft +> gelesen. Das ist nur sicher, weil (a) nur die Proxy-Quell-IP akzeptiert wird **und** +> (b) die Subdomain **per SSO geschützt** sein muss (dann lässt YunoHost nur validierte +> Cookies durch). Für Härtung das Portal-HS256-Secret in `TRUSTED_AUTH_JWT_SECRET` +> setzen → die Signatur wird dann selbst geprüft. + +## 5. Test +- `https://va.linix.de/` lädt die UI, links „Angemeldet als ". +- Text-Chat funktioniert; **Mikrofon-Button** nimmt auf und spielt die Antwort ab. +- Admins (`ADMIN_USERS`) sehen den Admin-Bereich (Nutzerliste). + +## 6. Chatterbox-TTS (optional, hohe Qualität + Voice-Cloning) +Eigener HTTP-Dienst (`~/chatterbox-tts-cli`, Conda-Env, GPU). systemd-User-Unit +`chatterbox-tts.service` (Port 9999). Wichtig: +- **GPU pinnen per UUID** (CUDA-Default-Order ist „fastest first" → Index trügt): + `Environment="CUDA_VISIBLE_DEVICES=GPU-"` (UUID via `nvidia-smi -L`). +- **`TTS_PRELOAD_LANG=de`** lädt das Modell beim Start (Warm-up). +- Der Dienst wird vom Gateway mit **`no_playback=true`** aufgerufen → er spielt **nicht** + lokal ab, sondern liefert nur die WAV (`tts_service.py` unterstützt das Flag). +```bash +systemctl --user enable --now chatterbox-tts +curl -s http://127.0.0.1:9999/health # {"status":"ok","device":"cuda:0"} +``` +Gateway: `tts_provider=chatterbox` (pro Request/Session) + `CHATTERBOX_VOICE` = Referenz-WAV. +> Chatterbox ist ~echtzeit-langsam → als Qualitäts-Option gedacht; `piper` bleibt der +> schnelle Default. Braucht ~3–4 GB GPU-Speicher. diff --git a/deploy/ollama-keepalive.conf b/deploy/ollama-keepalive.conf new file mode 100644 index 0000000..4270a74 --- /dev/null +++ b/deploy/ollama-keepalive.conf @@ -0,0 +1,19 @@ +# systemd-Drop-in fuer den Ollama-System-Dienst: Modelle nach Leerlauf entladen, +# damit die GPU automatisch frei wird (statt OLLAMA_KEEP_ALIVE=-1 = nie entladen). +# +# Installation (root noetig, betrifft den systemweiten ollama-Dienst): +# 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 +# # Pruefen: +# systemctl show ollama -p Environment | tr ' ' '\n' | grep KEEP_ALIVE +# +# Wirkung: Ein geladenes Modell wird nach 5 Minuten Inaktivitaet aus dem VRAM +# entladen -> GPU 1 ist im Leerlauf wieder fuer andere Zwecke frei. Der erste +# Request danach zahlt einmalig die Ladezeit (Kaltstart). +# +# Wert anpassbar: 0 = sofort entladen, 30m = laenger halten, -1 = nie (Default). + +[Service] +Environment="OLLAMA_KEEP_ALIVE=5m" diff --git a/deploy/va.linix.de.nginx.conf b/deploy/va.linix.de.nginx.conf new file mode 100644 index 0000000..91bcf4f --- /dev/null +++ b/deploy/va.linix.de.nginx.conf @@ -0,0 +1,38 @@ +# Reverse-Proxy fuer die Voice-Assistant-Web-UI auf einem YunoHost-Server. +# +# Ziel: https://va.linix.de -> http://:8003 (Gateway im LAN) +# YunoHost terminiert TLS (Let's Encrypt) und schuetzt die Subdomain per SSO. +# +# Ablage auf dem YunoHost-Server (Beispiel): +# /etc/nginx/conf.d/va.linix.de.d/assistant.conf +# danach: nginx -t && systemctl reload nginx +# +# WICHTIG (Unterschied zu Ollama): WebSockets (/ws/voice, /ws/chat) brauchen die +# Upgrade-Header und einen langen read-timeout - sonst bricht der Mikrofon-Button. + +# --- einmalig im http{}-Kontext (z. B. /etc/nginx/conf.d/websocket-upgrade.conf) --- +# map $http_upgrade $connection_upgrade { +# default upgrade; +# '' close; +# } + +location / { + proxy_pass http://GPU_BOX_LAN_IP:8003; # <-- LAN-IP der GPU-Box eintragen + proxy_http_version 1.1; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # WebSocket-Upgrade (zwingend fuer das Mikrofon): + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + + # Identitaet: KEIN eigener Header noetig. YunoHost liefert den Usernamen im + # Cookie "yunohost.portal" (JWT, Claim "user"); Cookies werden hier ohnehin + # durchgereicht. Das Gateway liest den Cookie (TRUSTED_AUTH_COOKIE=yunohost.portal). + # Wichtig: die Subdomain MUSS per SSO geschuetzt sein (sonst Identitaets-Spoofing). +} diff --git a/deploy/voice-assistant.env.example b/deploy/voice-assistant.env.example new file mode 100644 index 0000000..21341af --- /dev/null +++ b/deploy/voice-assistant.env.example @@ -0,0 +1,22 @@ +# Nur an die LAN-IP binden (NICHT 0.0.0.0), erreichbar allein fuer den Reverse-Proxy. +HOST=192.168.0.50 # <-- LAN-IP der GPU-Box +PORT=8003 +OPENROUTER_API_KEY= + +# Profil und Konfiguration +VA_PROFILE=cloud + +# Authentifizierung (Produktion: an). ADMIN_API_KEY fuer die Nutzerverwaltung (Fallback). +AUTH_ENABLED=true +ADMIN_API_KEY= + +# --- Forward-/Trusted-Header-Auth via YunoHost-SSO --------------------------- +# Nach der Discovery (GET /api/admin/request-headers hinter dem SSO) den echten +# Header-Namen eintragen. Identitaet wird nur von der Proxy-Quell-IP akzeptiert. +TRUSTED_AUTH_HEADER=X-Remote-User +TRUSTED_PROXY_IPS=192.168.0.10 # <-- LAN-IP des YunoHost-Servers (linix.de) +ADMIN_USERS=atoor,dieterschlueter,dschlueter +SSO_LOGOUT_URL=https://linix.de/yunohost/sso/?action=logout + +# Persistente Datenbank (Pfad auf dem Host) +DB_PATH=/var/lib/voice-assistant/voice-assistant.db diff --git a/deploy/voice-assistant.service b/deploy/voice-assistant.service new file mode 100644 index 0000000..aefca4b --- /dev/null +++ b/deploy/voice-assistant.service @@ -0,0 +1,15 @@ +[Unit] +Description=Voice Assistant Gateway +After=network.target + +[Service] +Type=simple +User=voice +WorkingDirectory=/opt/voice-assistant +EnvironmentFile=/etc/voice-assistant/voice-assistant.env +ExecStart=/opt/voice-assistant/.venv/bin/uvicorn app.main:app --host ${HOST} --port ${PORT} +Restart=always +RestartSec=2 + +[Install] +WantedBy=multi-user.target diff --git a/deploy/voice-assistant.user.service b/deploy/voice-assistant.user.service new file mode 100644 index 0000000..5d54725 --- /dev/null +++ b/deploy/voice-assistant.user.service @@ -0,0 +1,27 @@ +# systemd-User-Service (laeuft ohne root, ueberlebt Logout/Reboot mit linger). +# +# Installation: +# cp deploy/voice-assistant.user.service ~/.config/systemd/user/voice-assistant.service +# # Pfade unten ggf. anpassen +# loginctl enable-linger "$USER" # sudo noetig -> ueberlebt Logout/Reboot +# systemctl --user daemon-reload +# systemctl --user enable --now voice-assistant +# systemctl --user status voice-assistant +# +# Die App liest .env aus dem WorkingDirectory selbst (pydantic-settings) -> kein +# EnvironmentFile noetig. Der llama.cpp-Container (va_llm) laeuft separat via Docker +# (restart unless-stopped); lokale Modelle werden beim Start vorgeladen (Warm-up). + +[Unit] +Description=Voice Assistant Gateway (user service) +After=network.target + +[Service] +Type=simple +WorkingDirectory=/home/dschlueter/my_voice_assistant_v3 +ExecStart=/home/dschlueter/my_voice_assistant_v3/.venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8003 +Restart=always +RestartSec=2 + +[Install] +WantedBy=default.target diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..fd32a2a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,15 @@ +services: + voice-assistant: + build: . + ports: + - "${PORT:-8080}:${PORT:-8080}" + env_file: + - .env + environment: + HOST: "${HOST:-0.0.0.0}" + PORT: "${PORT:-8080}" + # Secret aus der Shell-Umgebung durchreichen (nicht aus .env), z. B. export in ~/.bashrc + OPENROUTER_API_KEY: "${OPENROUTER_API_KEY:?OPENROUTER_API_KEY ist nicht gesetzt}" + command: > + sh -c 'uvicorn app.main:app --host "$${HOST}" --port "$${PORT}"' + restart: unless-stopped diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b1ec74f --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,34 @@ +[project] +name = "voice-assistant-gateway" +version = "0.1.0" +description = "Modular voice assistant gateway with pluggable audio endpoints and provider adapters" +readme = "README.md" +requires-python = ">=3.11" +dependencies = [ + "fastapi>=0.116.0", + "uvicorn[standard]>=0.35.0", + "httpx>=0.28.0", + "pydantic>=2.11.0", + "pydantic-settings>=2.10.0", + "python-multipart>=0.0.20" +] + +[project.optional-dependencies] +test = [ + "pytest>=8.0" +] +# Lokale KI-Module (optional, schwergewichtig): lokales STT via faster-whisper, +# lokales TTS via piper (in-process -> Modell bleibt geladen, kein Start pro Satz). +local = [ + "faster-whisper>=1.0", + "piper-tts>=1.2" +] + +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[tool.setuptools.packages.find] +where = ["."] +include = ["app*"] +exclude = ["deploy*", "tests*"] diff --git a/scripts/add_pronunciation.py b/scripts/add_pronunciation.py new file mode 100644 index 0000000..97b67be --- /dev/null +++ b/scripts/add_pronunciation.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +"""Fügt ein Wort + Aussprache ins Aussprache-Lexikon ein. + +Das Lexikon (config/pronunciation..yaml) wird vor dem lokalen TTS (piper) +angewandt: der geschriebene Begriff wird durch eine "sprechbare" Umschreibung +ersetzt (z. B. langer Vokal per eingefügtem 'h'). + +Beispiele: + python scripts/add_pronunciation.py strömen ströhmen + python scripts/add_pronunciation.py "strömen:ströhmen" + python scripts/add_pronunciation.py CPU "Ze Pe U" --section terms + python scripts/add_pronunciation.py kWh "Kilowattstunden" --section units + python scripts/add_pronunciation.py strömen ströhmen --verify # espeak-Phoneme zeigen + +Hinweis: Damit der laufende Server die Änderung nutzt, einmal neu starten. +""" + +from __future__ import annotations + +import argparse +import shutil +import subprocess +import sys +from pathlib import Path + +try: + import yaml +except ModuleNotFoundError: + sys.exit("Fehlt: Python-Paket 'PyYAML' (pip install pyyaml).") + +BASE_DIR = Path(__file__).resolve().parent.parent +SECTIONS = ("terms", "abbreviations", "units") + + +def _quote(s: str) -> str: + """YAML-sicher als doppelt-gequoteter String.""" + return '"' + s.replace("\\", "\\\\").replace('"', '\\"') + '"' + + +def _espeak(word: str, lang: str) -> str | None: + if not shutil.which("espeak-ng"): + return None + try: + out = subprocess.run( + ["espeak-ng", "-v", lang, "-q", "-x", word], + capture_output=True, text=True, timeout=10, + ) + return out.stdout.strip() or None + except (subprocess.SubprocessError, OSError): + return None + + +def _entry_line_index(lines: list[str], section: str, word: str) -> int | None: + """Index einer vorhandenen, aktiven Zeile ' "word": ...' in der Sektion.""" + in_section = False + target = word.lower() + for i, line in enumerate(lines): + stripped = line.strip() + if not line.startswith(" ") and stripped.rstrip(":") in SECTIONS: + in_section = stripped.rstrip(":") == section + continue + if in_section and stripped and not stripped.startswith("#"): + key = stripped.split(":", 1)[0].strip().strip('"').strip("'") + if key.lower() == target: + return i + return None + + +def _section_header_index(lines: list[str], section: str) -> int | None: + for i, line in enumerate(lines): + if not line.startswith(" ") and line.strip().rstrip(":") == section \ + and line.rstrip().endswith(":"): + return i + return None + + +def upsert(path: Path, section: str, word: str, pron: str) -> str: + text = path.read_text(encoding="utf-8") if path.exists() else "" + lines = text.splitlines() + new_line = f" {_quote(word)}: {_quote(pron)}" + + existing = _entry_line_index(lines, section, word) + if existing is not None: + action = "aktualisiert" + lines[existing] = new_line + else: + action = "hinzugefügt" + header = _section_header_index(lines, section) + if header is None: # Sektion fehlt -> am Ende anlegen + if lines and lines[-1].strip(): + lines.append("") + lines.append(f"{section}:") + lines.append(new_line) + else: + lines.insert(header + 1, new_line) + + result = "\n".join(lines) + "\n" + # Vor dem Schreiben validieren -> niemals kaputtes YAML hinterlassen. + parsed = yaml.safe_load(result) or {} + if (parsed.get(section) or {}).get(word) != pron: + raise SystemExit("Abbruch: Eintrag nach dem Schreiben nicht wie erwartet (YAML-Problem).") + path.write_text(result, encoding="utf-8") + return action + + +def main() -> None: + p = argparse.ArgumentParser(description="Wort + Aussprache ins Lexikon eintragen.") + p.add_argument("word", help="Wort wie geschrieben (oder 'wort:aussprache').") + p.add_argument("pron", nargs="?", help="sprechbare Umschreibung.") + p.add_argument("--section", choices=SECTIONS, default="terms") + p.add_argument("--lang", default="de") + p.add_argument("--file", default=None, help="Lexikon-Pfad (Default: config/pronunciation..yaml)") + p.add_argument("--verify", action="store_true", help="espeak-Phoneme vorher/nachher zeigen") + args = p.parse_args() + + word, pron = args.word, args.pron + if pron is None: + if ":" not in word: + p.error("Bitte 'wort aussprache' oder 'wort:aussprache' angeben.") + word, pron = (s.strip() for s in word.split(":", 1)) + if not word or not pron: + p.error("Wort und Aussprache dürfen nicht leer sein.") + + path = Path(args.file) if args.file else BASE_DIR / "config" / f"pronunciation.{args.lang}.yaml" + + if args.verify: + before, after = _espeak(word, args.lang), _espeak(pron, args.lang) + if before is None: + print(" (espeak-ng nicht gefunden – Verifikation übersprungen)") + else: + print(f" espeak {word!r:14} -> {before}") + print(f" espeak {pron!r:14} -> {after}") + if before == after: + print(" ⚠ Phoneme identisch – die Umschreibung ändert die Aussprache NICHT.") + + action = upsert(path, args.section, word, pron) + print(f"✓ {action}: [{args.section}] {word!r} -> {pron!r} ({path})") + print(" Hinweis: laufenden Server neu starten, damit die Änderung greift.") + + +if __name__ == "__main__": + main() diff --git a/scripts/diag_record.py b/scripts/diag_record.py new file mode 100644 index 0000000..cc1d418 --- /dev/null +++ b/scripts/diag_record.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +"""Diagnose: Countdown + grünes Signal -> Aufnahme -> sofort transkribieren. + +Ein Durchlauf, kein Timing-Problem, keine Verwechslung der Datei. Zeigt die +WAV-Dauer und die faster-whisper-Segmente (Standard und mit VAD-Filter), damit man +sieht, ob Sätze nach Pausen verloren gehen. + + .venv/bin/python scripts/diag_record.py + .venv/bin/python scripts/diag_record.py --device plughw:5,0 --seconds 20 --model base +""" + +import argparse +import shutil +import subprocess +import sys +import tempfile +import time +import wave + +GREEN = "\033[1;32m" +RED = "\033[1;31m" +RESET = "\033[0m" + + +def main() -> None: + ap = argparse.ArgumentParser() + ap.add_argument("--device", default="plughw:5,0", help="Aufnahmegerät (arecord -L)") + ap.add_argument("--seconds", type=int, default=20) + ap.add_argument("--rate", type=int, default=16000) + ap.add_argument("--model", default="base", help="tiny|base|small|medium|large-v3") + args = ap.parse_args() + + if not shutil.which("arecord"): + sys.exit("Fehlt: arecord") + + wav = tempfile.NamedTemporaryFile(suffix=".wav", delete=False) + wav.close() + + print() + for n in (3, 2, 1): + print(f" Aufnahme startet in {n} …", end="\r", flush=True) + time.sleep(1) + print(f"\n\n{GREEN}🟢🟢🟢🟢🟢 S P R I C H J E T Z T ({args.seconds}s) 🟢🟢🟢🟢🟢{RESET}\n") + + result = subprocess.run( + ["arecord", "-q", "-f", "S16_LE", "-r", str(args.rate), "-c", "1", + "-D", args.device, "-d", str(args.seconds), wav.name] + ) + print(f"{RED}🔴 STOP — Aufnahme fertig.{RESET}\n") + if result.returncode != 0: + sys.exit(f"arecord-Fehler (Gerät {args.device}?). Geräte: arecord -L") + + with wave.open(wav.name, "rb") as w: + dur = w.getnframes() / w.getframerate() + print(f"WAV-Dauer: {dur:.1f}s (Datei: {wav.name})\n") + + print("Lade faster-whisper …") + from faster_whisper import WhisperModel + + model = WhisperModel(args.model, device="cpu", compute_type="int8") + for title, kw in [("Standard (wie im Provider)", {}), ("mit vad_filter=True", {"vad_filter": True})]: + print(f"--- {title} ---") + segments, _ = model.transcribe(wav.name, language="de", **kw) + count = 0 + for seg in segments: + count += 1 + print(f" [{seg.start:5.1f}-{seg.end:5.1f}] {seg.text.strip()!r}") + if count == 0: + print(" (nichts erkannt)") + + +if __name__ == "__main__": + main() diff --git a/scripts/llm-server/start-llm-server.sh b/scripts/llm-server/start-llm-server.sh new file mode 100755 index 0000000..d1732e4 --- /dev/null +++ b/scripts/llm-server/start-llm-server.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Startet das zentrale, lokale LLM des Voice-Assistants als llama.cpp-Server +# (OpenAI-kompatibel). Dieses grosse, unzensierte Modell ist die Haupt-KI fuer +# den Provider "local-openai-compatible". +# +# Alles ueber ENV ueberschreibbar, z. B.: +# HOST_PORT=8101 GPU_DEVICE=2 ./start-llm-server.sh +# MODEL_REL_PATH="models/qwen3/anderes-modell.gguf" ./start-llm-server.sh + +HF_HOME="${HF_HOME:-/home/dschlueter/nvme2n1p7_home/huggingface}" +MODEL_REL_PATH="${MODEL_REL_PATH:-models/qwen3/Qwen3.6-35B-A3B-Uncensored-HauhauCS-Aggressive-Q4_K_M.gguf}" +IMAGE="${IMAGE:-ghcr.io/ggml-org/llama.cpp:server-cuda}" +CONTAINER_NAME="${CONTAINER_NAME:-va_llm}" +HOST_PORT="${HOST_PORT:-8001}" +CONTAINER_PORT="${CONTAINER_PORT:-8000}" +MODEL_ALIAS="${MODEL_ALIAS:-va_llm}" +GPU_DEVICE="${GPU_DEVICE:-1}" + +echo "[*] Verwende HF_HOME = $HF_HOME" +echo "[*] Modell = $MODEL_REL_PATH" +echo "[*] GPU device = $GPU_DEVICE (ueberschreibbar: GPU_DEVICE=2 ./start-llm-server.sh)" +echo "[*] Port = $HOST_PORT | Alias = $MODEL_ALIAS | Container = $CONTAINER_NAME" +if [ ! -f "$HF_HOME/$MODEL_REL_PATH" ]; then + echo "[!] Modell-Datei nicht gefunden: $HF_HOME/$MODEL_REL_PATH" >&2 + exit 1 +fi + +if docker ps -a --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}\$"; then + echo "[*] Stoppe existierenden Container $CONTAINER_NAME ..." + docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true +fi + +echo "[*] Starte llama.cpp-Server (zentrale Voice-Assistant-KI) ..." +docker run -d \ + --gpus "\"device=${GPU_DEVICE}\"" \ + --name "$CONTAINER_NAME" \ + --restart unless-stopped \ + -e HF_HOME="/hf_home" \ + -v "$HF_HOME:/hf_home:ro" \ + -p "${HOST_PORT}:${CONTAINER_PORT}" \ + "$IMAGE" \ + -m "/hf_home/${MODEL_REL_PATH}" \ + --alias "${MODEL_ALIAS}" \ + -c 262144 \ + -n 16384 \ + --jinja \ + --reasoning on \ + --no-context-shift \ + --temp 0.65 \ + --top-p 0.80 \ + --top-k 20 \ + --min-p 0.01 \ + --repeat-penalty 1.05 \ + --main-gpu 0 \ + -ngl 999 \ + -fa on \ + --kv-unified \ + --cache-type-k q4_0 \ + --cache-type-v q4_0 \ + --batch-size 1024 \ + --ubatch-size 512 \ + --parallel 1 \ + --cont-batching \ + --host 0.0.0.0 \ + --port "$CONTAINER_PORT" + +echo "[*] Warte auf Modell-Bereitschaft (Completion-Check, max. 300 s) ..." +MODEL_READY=0 +for i in {1..150}; do + HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 \ + -X POST "http://localhost:${HOST_PORT}/v1/chat/completions" \ + -H "Content-Type: application/json" \ + -d "{\"model\":\"${MODEL_ALIAS}\",\"messages\":[{\"role\":\"user\",\"content\":\"ping\"}],\"max_tokens\":1,\"temperature\":0.0,\"stream\":false}") || HTTP_CODE="000" + if [ "$HTTP_CODE" = "200" ]; then MODEL_READY=1; break; fi + echo " [${i}/150] HTTP ${HTTP_CODE:-000} — Modell laedt noch, warte 2s ..." + sleep 2 +done + +if [ "$MODEL_READY" -ne 1 ]; then + echo "[!] Modell wurde nicht rechtzeitig bereit (kein HTTP 200 auf Completion)." >&2 + docker logs --tail 200 "$CONTAINER_NAME" || true + exit 1 +fi + +echo "[*] Modell bereit — erster Completion-Request erfolgreich (HTTP 200)." +echo "[*] Server laeuft auf http://0.0.0.0:${HOST_PORT}/v1 (Alias: ${MODEL_ALIAS})" +echo "[*] Setze im Gateway: LOCAL_LLM_BASE_URL=http://127.0.0.1:${HOST_PORT}/v1 LOCAL_LLM_MODEL=${MODEL_ALIAS}" +echo "[*] Stoppen mit: scripts/llm-server/stop-llm-server.sh" diff --git a/scripts/llm-server/status-llm-server.sh b/scripts/llm-server/status-llm-server.sh new file mode 100755 index 0000000..b9c21ee --- /dev/null +++ b/scripts/llm-server/status-llm-server.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash + +# Zeigt Container- und HTTP-Status des lokalen LLM-Servers (llama.cpp). +# Name/Port ueber ENV ueberschreibbar: CONTAINER_NAME=... HOST_PORT=... ./status-llm-server.sh + +CONTAINER_NAME="${CONTAINER_NAME:-va_llm}" +HOST_PORT="${HOST_PORT:-8001}" + +check_server() { + local NAME="$1" + local PORT="$2" + + printf "%-28s" "$NAME (Port $PORT):" + + # Docker-Status + if docker ps --format '{{.Names}}' | grep -q "^${NAME}\$"; then + printf " Container=\033[32mRUNNING\033[0m" + elif docker ps -a --format '{{.Names}}' | grep -q "^${NAME}\$"; then + printf " Container=\033[33mSTOPPED\033[0m" + else + printf " Container=\033[31mNOT FOUND\033[0m" + echo + return + fi + + # HTTP-Erreichbarkeit + if curl -s --max-time 3 "http://localhost:${PORT}/health" >/dev/null 2>&1 || \ + curl -s --max-time 3 "http://localhost:${PORT}/v1/models" >/dev/null 2>&1; then + printf " HTTP=\033[32mOK\033[0m" + else + printf " HTTP=\033[31mNOT READY\033[0m" + fi + + echo +} + +echo "=== Voice-Assistant LLM-Server Status ===" +check_server "$CONTAINER_NAME" "$HOST_PORT" diff --git a/scripts/llm-server/stop-llm-server.sh b/scripts/llm-server/stop-llm-server.sh new file mode 100755 index 0000000..d6a4208 --- /dev/null +++ b/scripts/llm-server/stop-llm-server.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Stoppt den lokalen LLM-Server (llama.cpp) des Voice-Assistants. +# Container-Name ueber ENV ueberschreibbar: CONTAINER_NAME=... ./stop-llm-server.sh + +CONTAINER_NAME="${CONTAINER_NAME:-va_llm}" + +if docker ps -a --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}\$"; then + docker rm -f "$CONTAINER_NAME" >/dev/null + echo "[*] Gestoppt: $CONTAINER_NAME" +else + echo "[-] Nicht gefunden: $CONTAINER_NAME" +fi diff --git a/scripts/llm-server/switch-llm.sh b/scripts/llm-server/switch-llm.sh new file mode 100755 index 0000000..9e08d09 --- /dev/null +++ b/scripts/llm-server/switch-llm.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Wechselt das lokale LLM-Backend zwischen Ollama und llama.cpp. +# Aufruf: switch-llm.sh ollama | llamacpp +# +# Das Skript (1) gibt den GPU-Speicher des jeweils anderen Backends frei, +# (2) startet das gewuenschte Backend, (3) passt die aktiven LOCAL_LLM_*-Zeilen +# in .env an und (4) sorgt dafuer, dass das Gateway die .env neu liest +# (Dienst-Neustart, sonst Hinweis). Provider bleibt 'local-openai-compatible'. +# +# Modelle/Ports ueber ENV ueberschreibbar, z. B.: +# OLLAMA_MODEL=qwen2.5:latest scripts/llm-server/switch-llm.sh ollama + +TARGET="${1:-}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" +ENV_FILE="$ROOT_DIR/.env" + +# Lock gegen parallele Backend-Wechsel (z. B. zwei Admin-Klicks gleichzeitig). +# flock haelt das Lock fuer die gesamte Laufzeit dieses Skripts; ein zweiter +# Aufruf scheitert sofort mit Exit 75 (-> Endpoint meldet "läuft bereits"). +LOCK_FILE="${LOCK_FILE:-/tmp/va-llm-switch.lock}" +exec 9>"$LOCK_FILE" +if ! flock -n 9; then + echo "[!] Ein Backend-Wechsel laeuft bereits (Lock: $LOCK_FILE)." >&2 + exit 75 +fi + +OLLAMA_BASE_URL="${OLLAMA_BASE_URL:-http://127.0.0.1:11434/v1}" +OLLAMA_MODEL="${OLLAMA_MODEL:-gemma3:latest}" +LLAMACPP_BASE_URL="${LLAMACPP_BASE_URL:-http://127.0.0.1:8001/v1}" +LLAMACPP_MODEL="${LLAMACPP_MODEL:-va_llm}" +CONTAINER_NAME="${CONTAINER_NAME:-va_llm}" + +# Setzt eine aktive (unkommentierte) KEY=VALUE-Zeile in .env (kommentierte # KEY bleiben unberuehrt). +set_env() { + local key="$1" val="$2" + if grep -qE "^${key}=" "$ENV_FILE" 2>/dev/null; then + sed -i "s|^${key}=.*|${key}=${val}|" "$ENV_FILE" + else + printf '%s=%s\n' "$key" "$val" >> "$ENV_FILE" + fi +} + +# Entlaedt alle in Ollama geladenen Modelle (gibt GPU frei) -- der Dienst bleibt laufen. +unload_ollama_models() { + local loaded + loaded="$(ollama ps 2>/dev/null | awk 'NR>1 {print $1}')" || true + for m in $loaded; do + echo "[*] Entlade Ollama-Modell: $m" + ollama stop "$m" >/dev/null 2>&1 || true + done +} + +restart_gateway() { + export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}" + if systemctl --user is-active --quiet voice-assistant.service 2>/dev/null; then + echo "[*] Starte Gateway-Dienst neu (liest .env neu) ..." + systemctl --user restart voice-assistant.service + else + echo "[!] Gateway laeuft nicht als Dienst (vermutlich 'make run' im Vordergrund)." + echo " -> Dort neu starten, damit die neue .env greift:" + echo " Strg+C, dann in einer FRISCHEN Shell: make run" + fi +} + +case "$TARGET" in + ollama) + echo "[*] Wechsel auf OLLAMA (Modell: $OLLAMA_MODEL)" + if docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1; then + echo "[*] llama.cpp-Container '$CONTAINER_NAME' gestoppt (GPU frei)." + else + echo "[*] llama.cpp lief nicht." + fi + if ! curl -s -m 5 -o /dev/null "${OLLAMA_BASE_URL%/v1}/api/tags" 2>/dev/null; then + echo "[*] ollama-Dienst nicht erreichbar -> starte ihn ..." + sudo systemctl start ollama || true + fi + set_env LOCAL_LLM_BASE_URL "$OLLAMA_BASE_URL" + set_env LOCAL_LLM_API_KEY "ollama" + set_env LOCAL_LLM_MODEL "$OLLAMA_MODEL" + echo "[*] .env -> $OLLAMA_BASE_URL | Modell $OLLAMA_MODEL" + ;; + llamacpp) + echo "[*] Wechsel auf LLAMA.CPP (Modell-Alias: $LLAMACPP_MODEL)" + unload_ollama_models + bash "$SCRIPT_DIR/start-llm-server.sh" + set_env LOCAL_LLM_BASE_URL "$LLAMACPP_BASE_URL" + set_env LOCAL_LLM_API_KEY "dummy" + set_env LOCAL_LLM_MODEL "$LLAMACPP_MODEL" + echo "[*] .env -> $LLAMACPP_BASE_URL | Modell $LLAMACPP_MODEL" + ;; + *) + echo "Aufruf: $0 ollama|llamacpp" >&2 + exit 2 + ;; +esac + +restart_gateway +echo "[✓] LLM-Backend auf '$TARGET' umgestellt." diff --git a/scripts/smoke_e2e.py b/scripts/smoke_e2e.py new file mode 100644 index 0000000..1e1bc27 --- /dev/null +++ b/scripts/smoke_e2e.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +"""End-to-End-Rauchtest gegen die echten Provider (OpenRouter). + +ACHTUNG: macht echte Netz-Aufrufe und verursacht (geringe) Kosten. Bewusst NICHT +Teil der pytest-Suite. Manuell ausfuehren: + + OPENROUTER_API_KEY=sk-or-... python scripts/smoke_e2e.py + # oder, wenn der Key in ~/.bashrc exportiert ist: + python scripts/smoke_e2e.py + +Prueft drei Dinge isoliert gegen OpenRouter: + 1. LLM (Chat-Antwort; TTS auf lokalen Stub, um den LLM zu isolieren) + 2. TTS (Text -> Audio) + 3. STT (Round-Trip: TTS-Audio als WAV zurueck durch die Transkription) + +Exit-Code 0 = alles ok, 1 = mindestens ein Test fehlgeschlagen, 2 = kein API-Key. +""" + +import io +import os +import sys +import tempfile +import wave + +# Defaults setzen, BEVOR die App-Config geladen wird. +os.environ.setdefault("AUTH_ENABLED", "false") +os.environ.setdefault("DB_PATH", os.path.join(tempfile.mkdtemp(), "smoke.db")) + +from fastapi.testclient import TestClient # noqa: E402 + +from app.config import settings # noqa: E402 +from app.main import app # noqa: E402 + +OR = "openrouter" + + +def _check_key() -> None: + if not settings.openrouter_api_key.strip(): + print("FEHLER: OPENROUTER_API_KEY ist nicht gesetzt (Umgebung).") + sys.exit(2) + + +def _pcm_to_wav(pcm: bytes, sample_rate: int = 24000) -> bytes: + buf = io.BytesIO() + with wave.open(buf, "wb") as w: + w.setnchannels(1) + w.setsampwidth(2) + w.setframerate(sample_rate) + w.writeframes(pcm) + return buf.getvalue() + + +def main() -> int: + _check_key() + print(f"Modelle: LLM={settings.openrouter_llm_model}, " + f"TTS={settings.openrouter_tts_model} ({settings.openrouter_tts_voice}), " + f"STT={settings.openrouter_stt_model}\n") + + client = TestClient(app) + failures = 0 + + # 1) LLM (TTS via piper-Stub isoliert) + try: + r = client.post( + "/api/chat?debug=true", + json={"text": "Sag bitte in einem kurzen Satz Hallo.", + "llm_provider": OR, "tts_provider": "piper"}, + ) + answer = r.json().get("trace", {}).get("semantic_response") if r.status_code == 200 else None + if r.status_code == 200 and answer: + print(f"[OK] LLM -> {answer!r}") + else: + failures += 1 + print(f"[FAIL] LLM -> HTTP {r.status_code}: {r.text[:200]}") + except Exception as exc: # noqa: BLE001 + failures += 1 + print(f"[FAIL] LLM -> {exc}") + + # 2) TTS + pcm = b"" + try: + r = client.post("/api/speak", json={"text": "Guten Tag, schoen dass Sie da sind.", + "tts_provider": OR}) + if r.status_code == 200 and r.content: + pcm = r.content + print(f"[OK] TTS -> {len(pcm)} Bytes Audio") + else: + failures += 1 + print(f"[FAIL] TTS -> HTTP {r.status_code}: {r.text[:200]}") + except Exception as exc: # noqa: BLE001 + failures += 1 + print(f"[FAIL] TTS -> {exc}") + + # 3) STT (Round-Trip ueber das TTS-Audio) + if pcm: + try: + wav = _pcm_to_wav(pcm) + r = client.post( + "/api/transcribe", + files={"file": ("roundtrip.wav", wav, "audio/wav")}, + data={"language": "de", "stt_provider": OR}, + ) + text = r.json().get("trace", {}).get("raw_transcript") if r.status_code == 200 else None + if r.status_code == 200 and text: + print(f"[OK] STT -> {text!r}") + else: + failures += 1 + print(f"[FAIL] STT -> HTTP {r.status_code}: {r.text[:200]}") + except Exception as exc: # noqa: BLE001 + failures += 1 + print(f"[FAIL] STT -> {exc}") + else: + print("[SKIP] STT -> kein TTS-Audio fuer den Round-Trip") + + print() + if failures: + print(f"ERGEBNIS: {failures} Test(s) fehlgeschlagen.") + return 1 + print("ERGEBNIS: alle Live-Tests bestanden.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/voice_loop.py b/scripts/voice_loop.py new file mode 100644 index 0000000..23e8915 --- /dev/null +++ b/scripts/voice_loop.py @@ -0,0 +1,531 @@ +#!/usr/bin/env python3 +"""Sprech-Loop: sprechen -> Antwort hoeren -> erneut sprechen. + +Nimmt vom Mikrofon auf (Push-to-Talk), schickt das Audio ueber EINE +/ws/voice-Verbindung an das Gateway und spielt die Antwort ab. Das Gespraechs- +gedaechtnis bleibt ueber die `session_id` erhalten. + +Standardmaessig folgt der Loop dem **System-Standard-Mikrofon und -Lautsprecher** +(inkl. Bluetooth). Ein bestimmtes Geraet nur, wenn `--device` explizit gesetzt ist. + +Beispiele: + python scripts/voice_loop.py # System-Standardgeraete + python scripts/voice_loop.py --url ws://localhost:8003/ws/voice --session oma-anna + python scripts/voice_loop.py --recorder arecord --device plughw:6,0 # bestimmtes Mikrofon + python scripts/voice_loop.py --llm-provider openrouter --tts-provider openrouter + python scripts/voice_loop.py --tts-provider openrouter --voice Puck # Cloud-Stimme testen + python scripts/voice_loop.py --file frage.wav # ohne Mikrofon (Test) + +Voraussetzungen: laufendes Gateway, ein Aufnahmewerkzeug (`ffmpeg`/`parecord`/`arecord`), +ein Player (`ffplay`/`paplay`/`aplay`), Python-Paket `websockets`. +""" + +from __future__ import annotations + +import argparse +import asyncio +import io +import json +import os +import shutil +import signal +import subprocess +import sys +import tempfile +import select +import termios +import time +import wave + +try: + import websockets +except ModuleNotFoundError: + sys.exit("Fehlt: Python-Paket 'websockets' (kommt mit uvicorn[standard]).") + +TTS_SAMPLE_RATE = 24000 # Antwort-Audio des Gateways (s16le, mono) + + +def _require(tool: str) -> str: + path = shutil.which(tool) + if not path: + sys.exit(f"Fehlt: '{tool}' nicht gefunden. Bitte installieren.") + return path + + +def _first_player() -> list[str] | None: + # paplay vor aplay: paplay folgt dem System-Standard-Ausgabegeraet (PulseAudio), + # aplay nutzt das ALSA-Default, das auf PipeWire-Systemen oft tot ist. + if shutil.which("ffplay"): + return ["ffplay", "-loglevel", "quiet", "-nodisp", "-autoexit"] + if shutil.which("paplay"): + return ["paplay"] + if shutil.which("aplay"): + return ["aplay", "-q"] + return None + + +def resolve_recorder(choice: str, rate: int = 16000) -> str: + """Waehlt das Aufnahmewerkzeug. + + 'auto' folgt dem **System-Standard-Mikrofon** und prueft per kurzem Test, dass das + Werkzeug WIRKLICH Audio liefert -> es wird nie ein totes Geraet gewaehlt. Reihenfolge + der Kandidaten: ffmpeg (PulseAudio-Default) -> parecord -> arecord -> pw-record. + """ + if choice != "auto": + if not shutil.which(choice): + sys.exit(f"Fehlt: Aufnahmewerkzeug '{choice}' nicht gefunden.") + return choice + available = [t for t in ("ffmpeg", "parecord", "arecord", "pw-record") if shutil.which(t)] + if not available: + sys.exit("Kein Aufnahmewerkzeug gefunden (ffmpeg / parecord / arecord / pw-record).") + print(" Prüfe Standard-Aufnahmegerät …", flush=True) + for tool in available: + if _probe_records(tool, rate): + return tool + print(f" ⚠ Kein Werkzeug lieferte im Test Audio; nutze '{available[0]}'." + " Ggf. --recorder/--device explizit setzen.") + return available[0] + + +def _probe_records(recorder: str, rate: int) -> bool: + """Kurztest (~0,6 s), ob 'recorder' am System-Default tatsaechlich aufnimmt.""" + tmp = tempfile.NamedTemporaryFile(suffix=".wav", delete=False) + tmp.close() + size = 0 + try: + proc = subprocess.Popen( + _record_cmd(recorder, None, rate, tmp.name), + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + ) + time.sleep(0.6) + proc.send_signal(signal.SIGINT) + try: + proc.wait(timeout=3) + except subprocess.TimeoutExpired: + proc.kill() + size = os.path.getsize(tmp.name) + except Exception: # noqa: BLE001 - jedes Problem = Werkzeug taugt nicht + size = 0 + finally: + try: + os.unlink(tmp.name) + except OSError: + pass + return size > 2000 # mehr als WAV-Header (44 B) + etwas Audio + + +def _record_cmd(recorder: str, device: str | None, rate: int, outfile: str) -> list[str]: + if recorder == "ffmpeg": + # PulseAudio-Eingang folgt dem System-Standard-Mikrofon (device=None -> "default"). + return ["ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "error", "-y", + "-f", "pulse", "-i", device or "default", + "-ar", str(rate), "-ac", "1", outfile] + if recorder == "arecord": + cmd = ["arecord", "-q", "-f", "S16_LE", "-r", str(rate), "-c", "1"] + if device: + cmd += ["-D", device] + return cmd + [outfile] + if recorder == "pw-record": + cmd = ["pw-record", "--rate", str(rate), "--channels", "1", "--format", "s16"] + if device: + cmd += ["--target", device] + return cmd + [outfile] + if recorder == "parecord": + cmd = ["parecord", f"--rate={rate}", "--channels=1", "--format=s16le", + "--file-format=wav"] + if device: + cmd += [f"--device={device}"] + return cmd + [outfile] + sys.exit(f"Unbekanntes Aufnahmewerkzeug: {recorder}") + + +def record_utterance(recorder: str, device: str | None, rate: int) -> bytes: + """Push-to-Talk: Enter startet, Enter stoppt die Aufnahme; gibt WAV-Bytes zurueck. + + Das Recorder-stderr wird abgefangen: Beim absichtlichen Stoppen per SIGINT meldet + arecord ein harmloses 'Unterbrechung während des Betriebssystemaufrufs' (EINTR). + Diese Meldung wird nur angezeigt, wenn die Aufnahme tatsaechlich fehlschlaegt. + """ + tmp = tempfile.NamedTemporaryFile(suffix=".wav", delete=False) + tmp.close() + cmd = _record_cmd(recorder, device, rate, tmp.name) + + input("\n[Enter] = Aufnahme START …") + err = tempfile.TemporaryFile() + proc = subprocess.Popen(cmd, stderr=err) + input("[Enter] = Aufnahme STOP …") + proc.send_signal(signal.SIGINT) + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + + with open(tmp.name, "rb") as fh: + data = fh.read() + # Aufnahmedauer anzeigen (hilft, zu fruehes Stoppen sofort zu erkennen). + # Hinweis: arecord schreibt beim Stoppen per Signal eine falsche Laenge in den + # WAV-Header -> Dauer aus der TATSAECHLICHEN Datenmenge berechnen, nicht aus dem Header. + try: + with wave.open(io.BytesIO(data), "rb") as wav_in: + rate = wav_in.getframerate() or 16000 + channels = wav_in.getnchannels() or 1 + width = wav_in.getsampwidth() or 2 + raw = wav_in.readframes(10 ** 9) # liest alles Vorhandene (Header-Laenge unzuverlaessig) + seconds = len(raw) / (rate * channels * width) + print(f" Aufnahme: {seconds:.1f}s") + except (wave.Error, EOFError): + pass + # Recorder-Fehlertext nur zeigen, wenn nichts/zu wenig aufgenommen wurde. + if len(data) < 1000: + err.seek(0) + message = err.read().decode("utf-8", "replace").strip() + if message: + print(f" ({recorder}: {message})") + err.close() + return data + + +def play_pcm(pcm: bytes) -> None: + player = _first_player() + if not player: + print("(kein Player gefunden – Antwort-Audio wird nicht abgespielt)") + return + buf = io.BytesIO() + with wave.open(buf, "wb") as w: + w.setnchannels(1) + w.setsampwidth(2) + w.setframerate(TTS_SAMPLE_RATE) + w.writeframes(pcm) + if player[0] == "ffplay": + # ffplay liest WAV von stdin via pipe:0 + subprocess.run([*player, "-i", "pipe:0"], input=buf.getvalue()) + else: + subprocess.run(player + ["/dev/stdin"], input=buf.getvalue()) + + +def open_stream_player(): + """Startet einen durchgehenden Player, der rohes s16le-PCM von stdin abspielt. + + So koennen Audio-Haeppchen (satzweises TTS) sofort und luekenlos abgespielt werden, + waehrend die KI noch weiter generiert. None, wenn kein Player gefunden wird. + """ + rate = str(TTS_SAMPLE_RATE) + # paplay vor aplay: folgt dem System-Standard-Ausgabegeraet (PulseAudio/PipeWire). + if shutil.which("ffplay"): + cmd = ["ffplay", "-loglevel", "quiet", "-nodisp", "-autoexit", + "-f", "s16le", "-ar", rate, "-ac", "1", "-i", "pipe:0"] + elif shutil.which("paplay"): + cmd = ["paplay", "--raw", f"--rate={rate}", "--format=s16le", "--channels=1"] + elif shutil.which("aplay"): + cmd = ["aplay", "-q", "-f", "S16_LE", "-r", rate, "-c", "1"] + else: + return None + return subprocess.Popen(cmd, stdin=subprocess.PIPE) + + +def _feed_player(player, chunk: bytes) -> None: + try: + player.stdin.write(chunk) + player.stdin.flush() + except (BrokenPipeError, ValueError): + pass + + +def _close_player(player) -> None: + try: + player.stdin.close() + except (BrokenPipeError, ValueError): + pass + try: + player.wait(timeout=60) + except subprocess.TimeoutExpired: + player.kill() + + +async def _player_feeder(player, queue: "asyncio.Queue") -> None: + """Spielt PCM-Haeppchen aus der Queue ab — entkoppelt vom Empfangs-Loop. + + Wichtig gegen 1011-Keepalive-Timeouts: Die Echtzeit-Wiedergabe (Player-stdin + blockiert, wenn der Puffer voll ist) darf NICHT den WebSocket-Empfang bremsen. + Sonst stauen sich eingehende Frames, websockets pausiert per Backpressure den + Transport-Reader, Pings/Pongs werden nicht mehr verarbeitet -> Verbindung bricht. + Der Empfangs-Loop legt Haeppchen nur in die Queue (put_nowait) und drainiert die + Verbindung durchgehend; hier wird in Ruhe (im Thread) abgespielt. None = Ende.""" + while True: + chunk = await queue.get() + if chunk is None: + return + await asyncio.to_thread(_feed_player, player, chunk) + + +async def _stdin_watcher() -> None: + """Wartet per Polling auf Enter-Tastendruck fuer Barge-in. + + Kein blockierender Thread: select.select mit Timeout 0 ist nicht-blockierend + und funktioniert zuverlaessig auf Linux/macOS mit echtem tty-stdin. + Canceln ist sicher (kein Zeichen wird konsumiert, ausser Enter wird tatsaechlich + gedrueckt und diese Funktion war diejenige, die ihn gelesen hat). + """ + while True: + ready, _, _ = select.select([sys.stdin], [], [], 0) + if ready: + sys.stdin.readline() # Enter-Zeilenumbruch konsumieren + return + await asyncio.sleep(0.05) + + +def _start_frame(args) -> dict: + frame = {"type": "start", "format": "wav"} + # audio_stream ist Tri-State: True/False explizit, None -> Server-Default entscheidet. + if getattr(args, "stream_audio", None) is not None: + frame["audio_stream"] = args.stream_audio + if getattr(args, "stream_text", False): + frame["stream"] = True + for key in ("stt_provider", "llm_provider", "tts_provider", "language", "voice"): + value = getattr(args, key, None) + if value: + frame[key] = value + return frame + + +async def one_turn(ws, wav: bytes, start_frame: dict, queue: "asyncio.Queue | None" = None) -> bytes: + """Sendet eine Aeusserung und verarbeitet die Antwort-Events. + + Ist `queue` gesetzt (Stream-Audio): jedes PCM-Haeppchen wird sofort (nicht blockierend) + in die Queue gelegt und von `_player_feeder` abgespielt; gibt b"" zurueck. Der Empfang + bleibt dadurch reaktiv (kein Keepalive-Timeout bei langen Antworten). Ohne Queue wird + das komplette Audio gesammelt und zurueckgegeben (Wiedergabe spaeter). + """ + await ws.send(json.dumps(start_frame)) + await ws.send(wav) + await ws.send(json.dumps({"type": "end"})) + + audio = b"" + token_started = False + while True: + msg = await ws.recv() + if isinstance(msg, (bytes, bytearray)): + if queue is not None: + # Nicht blockierend ablegen -> Verbindung wird durchgehend drainiert. + queue.put_nowait(bytes(msg)) + else: + audio = bytes(msg) + continue + event = json.loads(msg) + etype = event.get("type") + if etype == "transcript": + print(f" Du: {event.get('text','')!r}") + elif etype == "token": + # Live-Anzeige des Antworttextes, waehrend die KI ihn erzeugt. + if not token_started: + sys.stdout.write(" Assistent: ") + token_started = True + sys.stdout.write(event.get("text", "")) + sys.stdout.flush() + elif etype == "semantic": + if token_started: + sys.stdout.write("\n") # Live-Zeile abschliessen (Text steht schon) + sys.stdout.flush() + else: + print(f" Assistent: {event.get('text','')!r}") + elif etype == "emergency": + print(f" ⚠ NOTFALL erkannt (Kategorie: {event.get('category')})") + elif etype == "error": + if token_started: + sys.stdout.write("\n") + print(f" Fehler {event.get('status','')}: {event.get('detail')}") + return b"" + elif etype == "done": + break + return audio + + +async def _send_and_play(url: str, wav: bytes, start_frame: dict) -> None: + """Eine kurze Verbindung pro Runde (Gedaechtnis bleibt serverseitig via session_id). + + Barge-in in zwei Phasen: + Phase 1 (LLM-Streaming): _stdin_watcher() laeuft parallel zu one_turn(); Enter + schickt {"type":"interrupt"} an den Server und bricht den Turn ab. + Phase 2 (Audio-Wiedergabe): Nach dem LLM-Ende laeuft _stdin_watcher() weiter; + Enter beendet den Player-Prozess sofort. + + Es wird immer ein durchgehender Player benutzt: der spielt sowohl viele Haeppchen + (Stream-Audio) als auch ein einzelnes Komplett-Audio luekenlos ab. Wird kein Player + gefunden, wird das Audio gesammelt und als WAV abgespielt. Die Wiedergabe blockiert + nie die offene Verbindung (Schreiben im Thread; Player wird nach Schliessen geleert).""" + player = open_stream_player() + queue: asyncio.Queue | None = asyncio.Queue() if player is not None else None + feeder = asyncio.create_task(_player_feeder(player, queue)) if queue is not None else None + audio = b"" + interrupted = False + # Watcher laeuft durch beide Phasen — wird erst im finally-Block beendet. + watcher_task = asyncio.create_task(_stdin_watcher()) + + try: + # ping_timeout/max_queue defensiv: lange Antworten + Echtzeit-Wiedergabe ueberleben. + async with websockets.connect(url, max_size=None, max_queue=None, ping_timeout=60) as ws: + turn_task = asyncio.create_task(one_turn(ws, wav, start_frame, queue)) + # Phase 1: LLM-Turn vs Enter + done, _ = await asyncio.wait( + {turn_task, watcher_task}, + return_when=asyncio.FIRST_COMPLETED, + ) + if watcher_task in done and turn_task not in done: + # Barge-in waehrend LLM-Streaming + interrupted = True + try: + await ws.send(json.dumps({"type": "interrupt"})) + except Exception: + pass + turn_task.cancel() + try: + await turn_task + except asyncio.CancelledError: + pass + else: + # Normales LLM-Ende (oder beide gleichzeitig) + audio = turn_task.result() + # watcher_task laeuft weiter -> Phase 2 im finally-Block + finally: + if feeder is not None: + queue.put_nowait(None) # Feeder-Ende signalisieren + if interrupted: + # Phase 1 unterbrochen: Player sofort beenden + if player is not None: + player.terminate() + if not watcher_task.done(): + watcher_task.cancel() + try: + await feeder + except Exception: + pass + if player is not None: + try: + player.wait(timeout=2) + except subprocess.TimeoutExpired: + player.kill() + else: + # Phase 2: Feeder abwarten, dann Player vs Enter racen + try: + await feeder + except Exception: + pass + if player is not None: + if not watcher_task.done(): + # Race: Player laeuft aus vs Enter (Barge-in waehrend Audio) + close_task = asyncio.create_task( + asyncio.to_thread(_close_player, player) + ) + done2, _ = await asyncio.wait( + {close_task, watcher_task}, + return_when=asyncio.FIRST_COMPLETED, + ) + if watcher_task in done2 and close_task not in done2: + # Barge-in waehrend Wiedergabe: Player sofort killen + interrupted = True + try: + player.kill() + except (ProcessLookupError, OSError): + pass + else: + watcher_task.cancel() + if not close_task.done(): + try: + await close_task + except Exception: + pass + else: + # Watcher lief gleichzeitig mit Turn ab — nicht als Barge-in werten + await asyncio.to_thread(_close_player, player) + else: + # Kein Player/Feeder + if not watcher_task.done(): + watcher_task.cancel() + + if interrupted: + print(" ↩ Unterbrochen — drücke [Enter] für neue Aufnahme …") + elif audio: # nur wenn kein Streaming-Player verfuegbar war + play_pcm(audio) + # Gepufferten stdin leeren: ein Enter, der waehrend des Turns nicht als Barge-in + # verarbeitet wurde, wuerde sonst den START-Prompt sofort ueberspringen. + try: + termios.tcflush(sys.stdin.fileno(), termios.TCIFLUSH) + except Exception: + pass + + +async def run(args) -> None: + url = f"{args.url}?session_id={args.session}" + if args.token: + url += f"&token={args.token}" + start_frame = _start_frame(args) + + if args.file: + with open(args.file, "rb") as fh: + wav = fh.read() + print(f"Sende Datei: {args.file}") + await _send_and_play(url, wav, start_frame) + return + + recorder = resolve_recorder(args.recorder, args.rate) + print(f"Ziel: {args.url} (Session '{args.session}')") + print(f"Aufnahme mit: {recorder}" + + (f" (Gerät: {args.device})" if args.device else " (System-Standardgerät)")) + print("Sprich nach 'START', stoppe mit Enter. Strg+C beendet den Loop.") + while True: + try: + wav = record_utterance(recorder, args.device, args.rate) + except KeyboardInterrupt: + print("\nEnde.") + return + if len(wav) < 1000: + print(" ⚠ Keine/zu kurze Aufnahme. Pruefe das System-Standard-Mikrofon" + " (Ubuntu: Einstellungen → Ton → Eingabe) und ob es Pegel zeigt.") + print(" Notfalls direktes ALSA-Geraet erzwingen (arecord -l zeigt die Nummer):") + print(" python scripts/voice_loop.py --recorder arecord --device plughw:6,0") + continue + try: + await _send_and_play(url, wav, start_frame) + except Exception as exc: # noqa: BLE001 - Verbindung pro Runde; Fehler nicht fatal + print(f" Verbindungsfehler: {exc}") + + +def main() -> None: + p = argparse.ArgumentParser(description="Sprech-Loop fuer das Voice-Assistant-Gateway") + p.add_argument("--url", default="ws://127.0.0.1:8003/ws/voice") + p.add_argument("--session", default="voice-loop") + p.add_argument("--token", default=None, help="Bearer-Token, falls AUTH_ENABLED=true") + p.add_argument("--device", default=None, + help="Aufnahmegeraet explizit (ffmpeg/parecord: PulseAudio-Quelle; " + "arecord: ALSA z. B. plughw:6,0; pw-record: --target). " + "Ohne Angabe folgt der Loop dem System-Standardgeraet.") + p.add_argument("--recorder", default="auto", + choices=["auto", "ffmpeg", "pw-record", "parecord", "arecord"], + help="Aufnahmewerkzeug; 'auto' folgt dem System-Standardmikrofon und " + "prueft per Kurztest, dass es wirklich aufnimmt") + p.add_argument("--rate", type=int, default=16000) + p.add_argument("--stt-provider", dest="stt_provider", default=None) + p.add_argument("--llm-provider", dest="llm_provider", default=None) + p.add_argument("--tts-provider", dest="tts_provider", default=None) + p.add_argument("--voice", default=None, + help="TTS-Stimme (provider-spezifisch; ohne Angabe gilt der Provider-Default, " + "z. B. OPENROUTER_TTS_VOICE bzw. PIPER_VOICE)") + p.add_argument("--language", default=None) + # Audio-Streaming: Default entscheidet der Server (AUDIO_STREAM_DEFAULT, i. d. R. an). + audio_grp = p.add_mutually_exclusive_group() + audio_grp.add_argument("--stream-audio", dest="stream_audio", action="store_const", const=True, + default=None, help="satzweises Vorlesen erzwingen (frueher Ton)") + audio_grp.add_argument("--no-stream-audio", dest="stream_audio", action="store_const", const=False, + default=None, help="satzweises Vorlesen abschalten (komplett am Ende)") + p.add_argument("--stream-text", dest="stream_text", action="store_true", + help="Antworttext live am Monitor anzeigen, waehrend die KI ihn erzeugt") + p.add_argument("--file", default=None, help="WAV statt Mikrofon senden (Test ohne Aufnahme)") + args = p.parse_args() + try: + asyncio.run(run(args)) + except KeyboardInterrupt: + print("\nEnde.") + + +if __name__ == "__main__": + main() diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..ca09f51 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,33 @@ +import pytest + +import app.dependencies as deps +from app.config import settings +from app.store import SQLiteStore +from app.metrics import metrics + + +@pytest.fixture(autouse=True) +def reset_state(tmp_path, monkeypatch): + """Pro Test: frische SQLite-DB, frischer Singleton-Audio-Router, Auth aus, Metriken leer. + + Auth-Tests schalten `settings.auth_enabled` selbst wieder ein. + """ + deps._store = SQLiteStore(str(tmp_path / "test.db")) + deps._audio_router = None + metrics.reset() + monkeypatch.setattr(settings, "auth_enabled", False) + monkeypatch.setattr(settings, "admin_api_key", "") + # deterministische Event-Reihenfolge in Tests; Stream-Tests setzen es explizit + monkeypatch.setattr(settings, "audio_stream_default", False) + yield + deps._store = None + deps._audio_router = None + + +def loopback_output(): + """Liefert den LoopbackOutput aus dem aktuellen Singleton-Router.""" + router = deps.get_audio_router() + for endpoint in router.outputs: + if type(endpoint).__name__ == "LoopbackOutput": + return endpoint + raise AssertionError("LoopbackOutput nicht gefunden") diff --git a/tests/test_admin_llm_status.py b/tests/test_admin_llm_status.py new file mode 100644 index 0000000..c327228 --- /dev/null +++ b/tests/test_admin_llm_status.py @@ -0,0 +1,77 @@ +"""Tests für den read-only LLM-/System-Status (Admin-Panel). + +Getestet: GET /api/admin/llm/status — Auth-Gate + Antwortschema. Die Status-Helfer +rufen externe Tools (docker/ollama/nvidia-smi/systemctl) auf; fehlen sie in der +Testumgebung, liefern sie sichere Defaults -> der Endpunkt bleibt 200. +""" + +import pytest +from fastapi.testclient import TestClient + +from app.main import app +from app.config import settings + +client = TestClient(app) +ADMIN = "test-admin-key" +ADM_HDR = {"X-Admin-Key": ADMIN} + + +@pytest.fixture(autouse=True) +def _setup(monkeypatch): + monkeypatch.setattr(settings, "admin_api_key", ADMIN) + monkeypatch.setattr(settings, "auth_enabled", False) + yield + + +def test_status_requires_admin(): + # Ohne Admin-Key (Auth aus -> anonym, kein Admin) -> 401. + assert client.get("/api/admin/llm/status").status_code == 401 + + +def test_status_schema(): + resp = client.get("/api/admin/llm/status", headers=ADM_HDR) + assert resp.status_code == 200 + data = resp.json() + for key in ("backend", "model", "base_url", "llamacpp_running", + "ollama_reachable", "ollama_loaded", "gpus", "gateway_service_active"): + assert key in data, key + assert isinstance(data["ollama_loaded"], list) + assert isinstance(data["gpus"], list) + # backend wird aus der base_url abgeleitet. + assert data["backend"] in ("ollama", "llamacpp", "unknown") + + +# ── Schreibende Steuerung: Validierung & Auth (ohne echten Switch) ─────────── + +def test_backend_switch_requires_admin(): + assert client.post("/api/admin/llm/backend", json={"backend": "ollama"}).status_code == 401 + + +def test_backend_switch_rejects_unknown_backend(): + # Ungültiges Backend -> 422, KEIN Prozess wird gestartet. + r = client.post("/api/admin/llm/backend", json={"backend": "boese; rm -rf /"}, headers=ADM_HDR) + assert r.status_code == 422 + + +def test_backend_switch_rejects_bad_model(monkeypatch): + # Modell, das nicht in 'ollama list' ist -> 422 (sofern eine Liste vorhanden ist). + import app.admin_llm as al + + async def fake_models(): + return ["gemma3:latest", "qwen2.5:latest"] + + monkeypatch.setattr(al, "available_ollama_models", fake_models) + r = client.post("/api/admin/llm/backend", + json={"backend": "ollama", "model": "gibtsnicht:99b"}, headers=ADM_HDR) + assert r.status_code == 422 + + +def test_backend_switch_rejects_malformed_model(monkeypatch): + # Modellname mit Shell-Metazeichen -> 422 (Format-Regex), kein Subprozess. + r = client.post("/api/admin/llm/backend", + json={"backend": "ollama", "model": "a; rm -rf /"}, headers=ADM_HDR) + assert r.status_code == 422 + + +def test_gateway_restart_requires_admin(): + assert client.post("/api/admin/gateway/restart").status_code == 401 diff --git a/tests/test_admin_settings.py b/tests/test_admin_settings.py new file mode 100644 index 0000000..feefcf8 --- /dev/null +++ b/tests/test_admin_settings.py @@ -0,0 +1,191 @@ +"""Tests für den Admin-Einstellungen-Tab (Provider & Sprache + Laufzeit-Config). + +Getestet: GET /api/admin/config, PUT /api/admin/config/{key}, + DELETE /api/admin/config/{key} — Auth, Custom-Values, Persistenz. +""" + +import pytest +from fastapi.testclient import TestClient + +import app.dependencies as deps +from app.main import app +from app.config import settings +from app.runtime_config import RUNTIME_SETTABLE, invalidate_cache + +client = TestClient(app) +ADMIN = "test-admin-key" +ADM_HDR = {"X-Admin-Key": ADMIN} + +PROVIDER_KEYS = [ + "default_stt_provider", + "default_llm_provider", + "default_tts_provider", + "default_language", +] + + +@pytest.fixture(autouse=True) +def _setup(monkeypatch): + monkeypatch.setattr(settings, "admin_api_key", ADMIN) + monkeypatch.setattr(settings, "auth_enabled", False) + invalidate_cache() + yield + invalidate_cache() + + +# ── GET /api/admin/config ──────────────────────────────────────────────────── + +def test_get_config_returns_all_keys(): + resp = client.get("/api/admin/config", headers=ADM_HDR) + assert resp.status_code == 200 + keys = {e["key"] for e in resp.json()} + assert keys == set(RUNTIME_SETTABLE.keys()) + + +def test_get_config_requires_admin_key(): + resp = client.get("/api/admin/config") + assert resp.status_code == 401 + + +def test_get_config_provider_keys_present(): + resp = client.get("/api/admin/config", headers=ADM_HDR) + keys = {e["key"] for e in resp.json()} + for k in PROVIDER_KEYS: + assert k in keys, f"Schlüssel {k!r} fehlt in /api/admin/config" + + +def test_get_config_entry_structure(): + resp = client.get("/api/admin/config", headers=ADM_HDR) + entry = next(e for e in resp.json() if e["key"] == "default_stt_provider") + assert "label" in entry + assert "hint" in entry + assert "effective_value" in entry + assert "base_value" in entry + assert "is_overridden" in entry + assert entry["is_overridden"] is False + + +def test_get_config_not_overridden_initially(): + resp = client.get("/api/admin/config", headers=ADM_HDR) + for entry in resp.json(): + if entry["key"] in PROVIDER_KEYS: + assert not entry["is_overridden"], ( + f"{entry['key']} sollte initial nicht überschrieben sein" + ) + + +# ── PUT /api/admin/config/{key} ────────────────────────────────────────────── + +@pytest.mark.parametrize("key,value", [ + ("default_stt_provider", "openrouter"), + ("default_stt_provider", "faster-whisper"), + ("default_llm_provider", "openrouter"), + ("default_llm_provider", "local-openai-compatible"), + ("default_tts_provider", "openrouter"), + ("default_tts_provider", "piper"), + ("default_tts_provider", "chatterbox"), + ("default_language", "de"), + ("default_language", "en"), +]) +def test_put_known_value_accepted(key, value): + resp = client.put(f"/api/admin/config/{key}", headers=ADM_HDR, json={"value": value}) + assert resp.status_code == 200 + assert resp.json()["value"] == value + + +@pytest.mark.parametrize("key,custom_value", [ + ("default_stt_provider", "my-custom-stt"), + ("default_llm_provider", "local-openai-compatible-v2"), + ("default_tts_provider", "future-tts-engine"), + ("default_language", "pl"), +]) +def test_put_custom_value_accepted(key, custom_value): + """Eigener Wert (nicht in der Dropdown-Liste) muss akzeptiert werden.""" + resp = client.put(f"/api/admin/config/{key}", headers=ADM_HDR, json={"value": custom_value}) + assert resp.status_code == 200 + assert resp.json()["value"] == custom_value + + +def test_put_custom_value_reflected_in_get(): + """Gespeicherter Custom-Wert erscheint in effective_value.""" + client.put("/api/admin/config/default_stt_provider", headers=ADM_HDR, + json={"value": "my-new-stt"}) + invalidate_cache() + resp = client.get("/api/admin/config", headers=ADM_HDR) + entry = next(e for e in resp.json() if e["key"] == "default_stt_provider") + assert entry["effective_value"] == "my-new-stt" + assert entry["is_overridden"] is True + + +def test_put_unknown_key_returns_400(): + resp = client.put("/api/admin/config/nonexistent_key", headers=ADM_HDR, + json={"value": "x"}) + assert resp.status_code == 400 + + +def test_put_trims_whitespace(): + resp = client.put("/api/admin/config/default_stt_provider", headers=ADM_HDR, + json={"value": " openrouter "}) + assert resp.status_code == 200 + assert resp.json()["value"] == "openrouter" + + +def test_put_requires_admin_key(): + resp = client.put("/api/admin/config/default_stt_provider", json={"value": "x"}) + assert resp.status_code == 401 + + +# ── DELETE /api/admin/config/{key} ────────────────────────────────────────── + +def test_delete_removes_override(): + client.put("/api/admin/config/default_tts_provider", headers=ADM_HDR, + json={"value": "future-engine"}) + invalidate_cache() + + del_resp = client.delete("/api/admin/config/default_tts_provider", headers=ADM_HDR) + assert del_resp.status_code == 200 + assert del_resp.json()["deleted"] == "default_tts_provider" + + invalidate_cache() + cfg = {e["key"]: e for e in + client.get("/api/admin/config", headers=ADM_HDR).json()} + assert not cfg["default_tts_provider"]["is_overridden"] + + +def test_delete_nonexistent_override_returns_404(): + resp = client.delete("/api/admin/config/default_tts_provider", headers=ADM_HDR) + assert resp.status_code == 404 + + +def test_delete_unknown_key_returns_400(): + resp = client.delete("/api/admin/config/does_not_exist", headers=ADM_HDR) + assert resp.status_code == 400 + + +def test_delete_requires_admin_key(): + resp = client.delete("/api/admin/config/default_tts_provider") + assert resp.status_code == 401 + + +# ── Persistenz: Override wirkt sich auf Runtime aus ────────────────────────── + +def test_override_reflected_in_runtime_settings(): + from app.runtime_config import runtime_settings + client.put("/api/admin/config/default_language", headers=ADM_HDR, + json={"value": "fr"}) + invalidate_cache() + assert runtime_settings.default_language == "fr" + + +def test_delete_restores_base_value(): + from app.runtime_config import runtime_settings + base = str(settings.default_language) + + client.put("/api/admin/config/default_language", headers=ADM_HDR, + json={"value": "it"}) + invalidate_cache() + assert runtime_settings.default_language == "it" + + client.delete("/api/admin/config/default_language", headers=ADM_HDR) + invalidate_cache() + assert str(runtime_settings.default_language) == base diff --git a/tests/test_audio_router.py b/tests/test_audio_router.py new file mode 100644 index 0000000..bfef57b --- /dev/null +++ b/tests/test_audio_router.py @@ -0,0 +1,43 @@ +import asyncio + +import pytest + +from app.audio.router import AudioRouter +from app.audio.endpoints.input.local_default import LocalDefaultInput +from app.audio.endpoints.input.bluetooth import BluetoothInput +from app.audio.endpoints.output.local_default import LocalDefaultOutput +from app.audio.endpoints.output.loopback import LoopbackOutput +from app.errors import UnknownEndpointError + + +def make_router(): + return AudioRouter( + inputs=[LocalDefaultInput(), BluetoothInput()], + outputs=[LocalDefaultOutput(), LoopbackOutput()], + ) + + +def test_select_default_output(): + router = make_router() + endpoint = asyncio.run(router.select_output()) + caps = asyncio.run(endpoint.capabilities()) + assert caps.default is True + assert caps.kind == "local-default" + + +def test_select_output_by_kind(): + router = make_router() + endpoint = asyncio.run(router.select_output("loopback")) + assert asyncio.run(endpoint.capabilities()).kind == "loopback" + + +def test_select_input_by_id(): + router = make_router() + endpoint = asyncio.run(router.select_input("local-default-mic")) + assert asyncio.run(endpoint.capabilities()).id == "local-default-mic" + + +def test_unknown_endpoint_raises(): + router = make_router() + with pytest.raises(UnknownEndpointError): + asyncio.run(router.select_output("does-not-exist")) diff --git a/tests/test_audit.py b/tests/test_audit.py new file mode 100644 index 0000000..b5415e8 --- /dev/null +++ b/tests/test_audit.py @@ -0,0 +1,46 @@ +"""Tests für das Audit-Logging der Admin-Aktionen.""" + +import logging + +import pytest +from fastapi.testclient import TestClient + +from app.main import app +from app.config import settings +from app.runtime_config import invalidate_cache + +client = TestClient(app) +ADMIN = "test-admin-key" +ADM_HDR = {"X-Admin-Key": ADMIN} + + +@pytest.fixture(autouse=True) +def _setup(monkeypatch): + monkeypatch.setattr(settings, "admin_api_key", ADMIN) + monkeypatch.setattr(settings, "auth_enabled", False) + invalidate_cache() + yield + invalidate_cache() + + +def test_config_set_is_audited(caplog): + with caplog.at_level(logging.INFO, logger="va.audit"): + r = client.put("/api/admin/config/local_llm_top_p", + json={"value": "0.7"}, headers=ADM_HDR) + assert r.status_code == 200 + line = "\n".join(caplog.messages) + assert "action=config_set" in line + assert "local_llm_top_p" in line + assert "user=admin-key" in line + # aufräumen + client.delete("/api/admin/config/local_llm_top_p", headers=ADM_HDR) + + +def test_backend_switch_rejection_is_audited(caplog): + with caplog.at_level(logging.INFO, logger="va.audit"): + r = client.post("/api/admin/llm/backend", + json={"backend": "boese"}, headers=ADM_HDR) + assert r.status_code == 422 + assert "action=llm_backend_switch_rejected" in "\n".join(caplog.messages) + + diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..2a62e53 --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,124 @@ +from fastapi.testclient import TestClient + +import app.dependencies as deps +from app.main import app +from app.config import settings + +client = TestClient(app) + +ADMIN = "admin-secret" + + +def _enable_auth(monkeypatch): + monkeypatch.setattr(settings, "auth_enabled", True) + monkeypatch.setattr(settings, "admin_api_key", ADMIN) + + +def _create_user(name: str) -> str: + resp = client.post( + "/api/admin/users", headers={"X-Admin-Key": ADMIN}, json={"display_name": name} + ) + assert resp.status_code == 200 + return resp.json()["token"] + + +def test_protected_endpoint_requires_token(monkeypatch): + _enable_auth(monkeypatch) + resp = client.post("/api/speak", json={"text": "x", "tts_provider": "piper"}) + assert resp.status_code == 401 + + +def test_admin_requires_key(monkeypatch): + _enable_auth(monkeypatch) + resp = client.post("/api/admin/users", json={"display_name": "Anna"}) + assert resp.status_code == 401 + + +def test_create_user_and_call_me(monkeypatch): + _enable_auth(monkeypatch) + token = _create_user("Anna") + auth = {"Authorization": f"Bearer {token}"} + + me = client.get("/api/me", headers=auth) + assert me.status_code == 200 + assert me.json()["display_name"] == "Anna" + + bad = client.get("/api/me", headers={"Authorization": "Bearer nope"}) + assert bad.status_code == 401 + + +def test_tenant_isolation_returns_403(monkeypatch): + _enable_auth(monkeypatch) + token_a = _create_user("A") + token_b = _create_user("B") + + client.post( + "/api/sessions/shared/route", + headers={"Authorization": f"Bearer {token_a}"}, + json={"tts_provider": "piper"}, + ) + # B versucht die Session von A zu nutzen. + resp = client.post( + "/api/speak?session_id=shared", + headers={"Authorization": f"Bearer {token_b}"}, + json={"text": "x"}, + ) + assert resp.status_code == 403 + + +def test_user_prefs_applied_to_route(monkeypatch): + _enable_auth(monkeypatch) + + class StubTTS: + async def synthesize(self, text, voice=None, audio_format="pcm", language=None): + return b"" + monkeypatch.setitem(deps.TTS_REGISTRY, "stub-tts", lambda s: StubTTS()) + + token = _create_user("Pref") + auth = {"Authorization": f"Bearer {token}"} + + client.put( + "/api/me/prefs", + headers=auth, + json={"tts_provider": "stub-tts", "output_endpoint": "loopback"}, + ) + resp = client.post("/api/speak", headers=auth, json={"text": "hallo"}) + assert resp.status_code == 200 + assert resp.headers["X-TTS-Provider"] == "stub-tts" + assert resp.headers["X-Output-Endpoint"] == "loopback" + + +def test_memories_crud(monkeypatch): + _enable_auth(monkeypatch) + auth = {"Authorization": f"Bearer {_create_user('Mem')}"} + + assert client.get("/api/me/memories", headers=auth).json() == [] + + created = client.post("/api/me/memories", headers=auth, json={"content": "mag Tee"}) + assert created.status_code == 200 + mid = created.json()["id"] + + listed = client.get("/api/me/memories", headers=auth).json() + assert len(listed) == 1 and listed[0]["content"] == "mag Tee" + + assert client.delete(f"/api/me/memories/{mid}", headers=auth).status_code == 200 + assert client.get("/api/me/memories", headers=auth).json() == [] + assert client.delete("/api/me/memories/9999", headers=auth).status_code == 404 + + +def test_memories_are_per_user(monkeypatch): + _enable_auth(monkeypatch) + auth_a = {"Authorization": f"Bearer {_create_user('A')}"} + auth_b = {"Authorization": f"Bearer {_create_user('B')}"} + client.post("/api/me/memories", headers=auth_a, json={"content": "geheim A"}) + assert client.get("/api/me/memories", headers=auth_b).json() == [] + + +def test_admin_unconfigured_returns_503(monkeypatch): + # Auth an, aber kein Admin-Key gesetzt. + monkeypatch.setattr(settings, "auth_enabled", True) + monkeypatch.setattr(settings, "admin_api_key", "") + resp = client.post( + "/api/admin/users", headers={"X-Admin-Key": "irgendwas"}, json={"display_name": "X"} + ) + assert resp.status_code == 503 diff --git a/tests/test_basic_layout.py b/tests/test_basic_layout.py new file mode 100644 index 0000000..3d45010 --- /dev/null +++ b/tests/test_basic_layout.py @@ -0,0 +1,7 @@ +from pathlib import Path + +def test_project_files_exist(): + root = Path(__file__).resolve().parents[1] + assert (root / "app" / "main.py").exists() + assert (root / "pyproject.toml").exists() + assert (root / "docker-compose.yml").exists() diff --git a/tests/test_chatterbox_tts.py b/tests/test_chatterbox_tts.py new file mode 100644 index 0000000..55add06 --- /dev/null +++ b/tests/test_chatterbox_tts.py @@ -0,0 +1,91 @@ +import io +import wave + +import httpx +import pytest + +import app.providers.tts.chatterbox as cb +from app.providers.tts.chatterbox import ChatterboxTTSProvider + + +def _wav(rate=24000, payload=b"\x01\x02" * 2400) -> bytes: + buf = io.BytesIO() + with wave.open(buf, "wb") as w: + w.setnchannels(1) + w.setsampwidth(2) + w.setframerate(rate) + w.writeframes(payload) + return buf.getvalue() + + +def _install(monkeypatch, handler): + real = httpx.AsyncClient # echte Klasse sichern (vor dem Patch) + + def make_client(*args, **kwargs): + kwargs.pop("timeout", None) + return real(transport=httpx.MockTransport(handler)) + monkeypatch.setattr(cb.httpx, "AsyncClient", make_client) + + +def _ok_handler(status="done", wav_rate=24000): + def handler(request: httpx.Request) -> httpx.Response: + p = request.url.path + if request.method == "POST" and p == "/speak": + return httpx.Response(200, json={"job_id": "j1", "status": "pending"}) + if p == "/status": + return httpx.Response(200, json={"recent_jobs": [{"id": "j1", "status": status, + "error": "boom" if status == "error" else None}]}) + if p == "/audio/j1": + return httpx.Response(200, content=_wav(wav_rate), headers={"content-type": "audio/wav"}) + return httpx.Response(404) + return handler + + +def test_synthesize_returns_pcm(monkeypatch): + _install(monkeypatch, _ok_handler()) + p = ChatterboxTTSProvider("http://x:9999", voice="/ref.wav") + pcm = __import__("asyncio").run(p.synthesize("Hallo Welt")) + assert pcm == b"\x01\x02" * 2400 # 24000 Hz -> kein Resampling, Frames unveraendert + + +def test_synthesize_wav_format(monkeypatch): + _install(monkeypatch, _ok_handler()) + p = ChatterboxTTSProvider("http://x:9999") + wav = __import__("asyncio").run(p.synthesize("Hallo", audio_format="wav")) + assert wav.startswith(b"RIFF") and b"WAVE" in wav[:16] + + +def test_resample_when_rate_differs(monkeypatch): + _install(monkeypatch, _ok_handler(wav_rate=22050)) + p = ChatterboxTTSProvider("http://x:9999", target_rate=24000) + pcm = __import__("asyncio").run(p.synthesize("Hallo")) + assert isinstance(pcm, bytes) and len(pcm) > 0 + + +def test_job_error_raises(monkeypatch): + _install(monkeypatch, _ok_handler(status="error")) + p = ChatterboxTTSProvider("http://x:9999") + with pytest.raises(RuntimeError): + __import__("asyncio").run(p.synthesize("Hallo")) + + +def test_empty_text_raises(monkeypatch): + p = ChatterboxTTSProvider("http://x:9999") + with pytest.raises(ValueError): + __import__("asyncio").run(p.synthesize(" ")) + + +def test_ref_voice_selection(): + p = ChatterboxTTSProvider("http://x:9999", voice="/default.wav") + assert p._ref_voice(None, "de") == "/default.wav" + assert p._ref_voice("Zephyr", "de") == "/default.wav" # Cloud-Name -> Default + assert p._ref_voice("/custom.wav", "de") == "/custom.wav" # Pfad -> uebernommen + + +def test_ref_voice_per_language(tmp_path): + # Native Sprach-Referenz gewinnt über die Default-Stimme, fehlt sie -> Default. + (tmp_path / "fr.wav").write_bytes(b"RIFF") + p = ChatterboxTTSProvider("http://x:9999", voice="/default.wav", voices_dir=str(tmp_path)) + assert p._ref_voice(None, "fr") == str(tmp_path / "fr.wav") # native fr-Stimme + assert p._ref_voice(None, "de") == "/default.wav" # keine de.wav -> Default + assert p._ref_voice("/custom.wav", "fr") == "/custom.wav" # expliziter Pfad gewinnt diff --git a/tests/test_config_profiles.py b/tests/test_config_profiles.py new file mode 100644 index 0000000..6932d6b --- /dev/null +++ b/tests/test_config_profiles.py @@ -0,0 +1,60 @@ +import pytest +from pydantic_settings import SettingsConfigDict + +from app import config as cfg + +EXAMPLE_TOML = str(cfg.BASE_DIR / "config" / "voice-assistant.example.toml") + + +class IsolatedSettings(cfg.Settings): + # .env ausblenden, damit nur TOML/Defaults/ENV-Monkeypatch zaehlen. + model_config = SettingsConfigDict(env_file=None, case_sensitive=False, extra="ignore") + + +def _clear_provider_env(monkeypatch): + for name in ("DEFAULT_STT_PROVIDER", "DEFAULT_LLM_PROVIDER", "DEFAULT_TTS_PROVIDER"): + monkeypatch.delenv(name, raising=False) + monkeypatch.setenv("VA_CONFIG_FILE", EXAMPLE_TOML) + + +def test_profile_local_dev(monkeypatch): + _clear_provider_env(monkeypatch) + monkeypatch.setenv("VA_PROFILE", "local-dev") + s = IsolatedSettings() + assert s.default_stt_provider == "faster-whisper" + assert s.default_llm_provider == "local-openai-compatible" + assert s.default_tts_provider == "piper" + + +def test_profile_cloud(monkeypatch): + _clear_provider_env(monkeypatch) + monkeypatch.setenv("VA_PROFILE", "cloud") + s = IsolatedSettings() + assert s.default_stt_provider == "openrouter" + assert s.default_llm_provider == "openrouter" + assert s.default_tts_provider == "openrouter" + + +def test_env_overrides_toml(monkeypatch): + _clear_provider_env(monkeypatch) + monkeypatch.setenv("VA_PROFILE", "local-dev") + monkeypatch.setenv("DEFAULT_LLM_PROVIDER", "openrouter") # ENV gewinnt ueber TOML + s = IsolatedSettings() + assert s.default_llm_provider == "openrouter" + assert s.default_tts_provider == "piper" # vom Profil, nicht ueberschrieben + + +def test_unknown_profile_raises(monkeypatch): + _clear_provider_env(monkeypatch) + monkeypatch.setenv("VA_PROFILE", "gibtsnicht") + with pytest.raises(ValueError): + IsolatedSettings() + + +def test_missing_config_file_falls_back_to_defaults(monkeypatch): + for name in ("DEFAULT_STT_PROVIDER", "DEFAULT_LLM_PROVIDER", "DEFAULT_TTS_PROVIDER"): + monkeypatch.delenv(name, raising=False) + monkeypatch.setenv("VA_CONFIG_FILE", "/nonexistent/voice-assistant.toml") + monkeypatch.delenv("VA_PROFILE", raising=False) + s = IsolatedSettings() + assert s.default_stt_provider == "openrouter" # eingebauter Field-Default diff --git a/tests/test_cookie_auth.py b/tests/test_cookie_auth.py new file mode 100644 index 0000000..e51d218 --- /dev/null +++ b/tests/test_cookie_auth.py @@ -0,0 +1,72 @@ +import base64 +import hashlib +import hmac +import json + +import pytest + +from app.auth import _username_from_cookie, authenticate +from app.config import settings + + +def _b64url(raw: bytes) -> str: + return base64.urlsafe_b64encode(raw).decode().rstrip("=") + + +def _make_jwt(payload: dict, secret: str | None = None) -> str: + header = _b64url(json.dumps({"alg": "HS256", "typ": "JWT"}).encode()) + body = _b64url(json.dumps(payload).encode()) + signing = f"{header}.{body}".encode() + sig = hmac.new((secret or "x").encode(), signing, hashlib.sha256).digest() + return f"{header}.{body}.{_b64url(sig)}" + + +class _Headers(dict): + """Case-insensitives .get wie Starlette-Headers (nur was wir brauchen).""" + def get(self, key, default=None): + return super().get(key.lower(), default) + + +def _cfg(monkeypatch, **over): + monkeypatch.setattr(settings, "trusted_auth_cookie", "yunohost.portal") + monkeypatch.setattr(settings, "trusted_auth_cookie_claim", "user") + monkeypatch.setattr(settings, "trusted_auth_jwt_secret", over.get("secret", "")) + return settings + + +def test_username_from_unsigned_cookie(monkeypatch): + _cfg(monkeypatch) + jwt = _make_jwt({"user": "dieterschlueter", "host": "linix.de"}) + headers = _Headers({"cookie": f"foo=bar; yunohost.portal={jwt}; baz=qux"}) + assert _username_from_cookie(headers, settings) == "dieterschlueter" + + +def test_no_cookie_returns_none(monkeypatch): + _cfg(monkeypatch) + assert _username_from_cookie(_Headers({"cookie": "foo=bar"}), settings) is None + assert _username_from_cookie(_Headers({}), settings) is None + + +def test_signature_checked_when_secret_set(monkeypatch): + _cfg(monkeypatch, secret="geheim") + good = _make_jwt({"user": "atoor"}, secret="geheim") + bad = _make_jwt({"user": "atoor"}, secret="falsch") + assert _username_from_cookie(_Headers({"cookie": f"yunohost.portal={good}"}), settings) == "atoor" + assert _username_from_cookie(_Headers({"cookie": f"yunohost.portal={bad}"}), settings) is None + + +def test_authenticate_via_cookie_from_trusted_proxy(monkeypatch): + import app.dependencies as deps + _cfg(monkeypatch) + monkeypatch.setattr(settings, "trusted_auth_header", "") + monkeypatch.setattr(settings, "trusted_proxy_ips", "192.168.179.10") + monkeypatch.setattr(settings, "admin_users", "atoor,dieterschlueter,dschlueter") + jwt = _make_jwt({"user": "dieterschlueter"}) + headers = _Headers({"cookie": f"yunohost.portal={jwt}"}) + + user = authenticate(headers, "192.168.179.10", None) + assert user is not None + assert user.external_id == "dieterschlueter" and user.is_admin is True + # Von fremder IP wird das Cookie ignoriert. + assert authenticate(headers, "10.0.0.1", None) is None or \ + authenticate(headers, "10.0.0.1", None).external_id != "dieterschlueter" diff --git a/tests/test_emergency_llm.py b/tests/test_emergency_llm.py new file mode 100644 index 0000000..5fefaf0 --- /dev/null +++ b/tests/test_emergency_llm.py @@ -0,0 +1,128 @@ +import asyncio + +import pytest + +from app.config import settings +from app.safety import emergency as em +from app.safety import llm_classifier as lc + + +class StubLLM: + def __init__(self, raw): + self.raw = raw + self.calls = 0 + + async def complete(self, text, history=None, session_id=None, language=None): + self.calls += 1 + return self.raw + + +class FakeUser: + id = "anonymous" + display_name = "Test" + + +class FakeStore: + def __init__(self): + self.logged = [] + + def log_emergency(self, user_id, category, snippet): + self.logged.append((user_id, category, snippet)) + + +@pytest.fixture(autouse=True) +def _clear_pending(): + em._pending.clear() + yield + em._pending.clear() + + +def test_parse_classification_variants(): + assert lc.parse_classification('{"category":"medical","confidence":0.9}')["category"] == "medical" + # umschlossen von Text + got = lc.parse_classification('Antwort: {"category":"help","confidence":0.7,"reason":"Feuer"}') + assert got["category"] == "help" and got["confidence"] == 0.7 + # 'none' ist kein Notfall + assert lc.parse_classification('{"category":"none","confidence":0.99}') is None + # unbekannte Kategorie + assert lc.parse_classification('{"category":"foo","confidence":0.99}') is None + assert lc.parse_classification("kein json") is None + assert lc.parse_classification("") is None + + +def test_classify_respects_confidence_threshold(monkeypatch): + monkeypatch.setattr(settings, "emergency_llm_min_confidence", 0.6) + monkeypatch.setattr(lc, "_build_classifier_llm", + lambda cfg: StubLLM('{"category":"medical","confidence":0.4}')) + # unter der Schwelle -> kein Notfall + assert asyncio.run(lc.classify_emergency("mir ist schwindelig")) is None + + monkeypatch.setattr(lc, "_build_classifier_llm", + lambda cfg: StubLLM('{"category":"medical","confidence":0.85}')) + res = asyncio.run(lc.classify_emergency("mir wird ganz schwarz vor augen")) + assert res["category"] == "medical" + + +def test_schedule_skips_when_keyword_already_hit(monkeypatch): + store = FakeStore() + called = StubLLM('{"category":"medical","confidence":0.9}') + monkeypatch.setattr(lc, "_build_classifier_llm", lambda cfg: called) + + async def run(): + # keyword_hit ist gesetzt -> Stufe 2 wird uebersprungen + task = em.schedule_llm_emergency_check( + FakeUser(), "egal", store, {"category": "medical", "matched": "x"} + ) + return task + + assert asyncio.run(run()) is None + assert called.calls == 0 + assert store.logged == [] + + +def test_schedule_disabled_is_noop(monkeypatch): + monkeypatch.setattr(settings, "emergency_llm_enabled", False) + store = FakeStore() + + async def run(): + return em.schedule_llm_emergency_check(FakeUser(), "hilfe", store, None) + + assert asyncio.run(run()) is None + assert store.logged == [] + + +def test_schedule_escalates_and_calls_callback(monkeypatch): + monkeypatch.setattr(settings, "emergency_llm_enabled", True) + monkeypatch.setattr(settings, "emergency_llm_min_confidence", 0.6) + monkeypatch.setattr(lc, "_build_classifier_llm", + lambda cfg: StubLLM('{"category":"self_harm","confidence":0.95}')) + store = FakeStore() + events = [] + + async def on_emergency(category): + events.append(category) + + async def run(): + task = em.schedule_llm_emergency_check( + FakeUser(), "ich will nicht mehr weiterleben", + store, None, on_emergency=on_emergency, + ) + await task + + asyncio.run(run()) + assert store.logged == [("anonymous", "self_harm", "ich will nicht mehr weiterleben")] + assert events == ["self_harm"] + + +def test_schedule_malformed_output_no_escalation(monkeypatch): + monkeypatch.setattr(settings, "emergency_llm_enabled", True) + monkeypatch.setattr(lc, "_build_classifier_llm", + lambda cfg: StubLLM("Tut mir leid, kein JSON.")) + store = FakeStore() + + async def run(): + task = em.schedule_llm_emergency_check(FakeUser(), "irgendwas", store, None) + await task + + asyncio.run(run()) + assert store.logged == [] diff --git a/tests/test_endpoints_e2e.py b/tests/test_endpoints_e2e.py new file mode 100644 index 0000000..8593723 --- /dev/null +++ b/tests/test_endpoints_e2e.py @@ -0,0 +1,112 @@ +import json + +from fastapi.testclient import TestClient + +import app.dependencies as deps +from app.main import app +from tests.conftest import loopback_output + +client = TestClient(app) + + +def _stub_tts(monkeypatch, name="stub-tts", audio=b""): + """Registriert einen deterministischen TTS-Stub (kein Netz, kein lokales Binary).""" + class StubTTS: + async def synthesize(self, text, voice=None, audio_format="pcm", language=None): + return audio + monkeypatch.setitem(deps.TTS_REGISTRY, name, lambda s: StubTTS()) + return name + + +def test_speak_loopback_collects_chunks(monkeypatch): + # Stub-TTS liefert b"" -> kein Netzcall; Loopback sammelt den Chunk. + tts = _stub_tts(monkeypatch) + resp = client.post( + "/api/speak", + json={"text": "Hallo Welt", "tts_provider": tts, "output_endpoint": "loopback"}, + ) + assert resp.status_code == 200 + assert resp.headers["X-Output-Endpoint"] == "loopback" + assert resp.headers["X-TTS-Provider"] == tts + assert len(loopback_output().chunks) == 1 + + +def test_unknown_endpoint_returns_422(): + resp = client.post( + "/api/speak", + json={"text": "x", "tts_provider": "piper", "output_endpoint": "gibtsnicht"}, + ) + assert resp.status_code == 422 + assert "gibtsnicht" in resp.json()["detail"] + + +def test_unknown_provider_returns_422(): + resp = client.post("/api/speak", json={"text": "x", "tts_provider": "gibtsnicht"}) + assert resp.status_code == 422 + + +def test_session_route_applies(monkeypatch): + tts = _stub_tts(monkeypatch) + client.post( + "/api/sessions/s1/route", + json={"tts_provider": tts, "output_endpoint": "loopback"}, + ) + resp = client.post("/api/speak?session_id=s1", json={"text": "hallo"}) + assert resp.status_code == 200 + assert resp.headers["X-Output-Endpoint"] == "loopback" + assert resp.headers["X-TTS-Provider"] == tts + + +def test_chat_per_request_override_and_loopback(monkeypatch): + class StubLLM: + async def complete(self, text, history=None, session_id=None, language=None): + return "Mir geht es gut, danke." + + class StubTTS: + async def synthesize(self, text, voice=None, audio_format="pcm", language=None): + return b"AUDIO" + + monkeypatch.setitem(deps.LLM_REGISTRY, "stub", lambda s: StubLLM()) + monkeypatch.setitem(deps.TTS_REGISTRY, "stub", lambda s: StubTTS()) + + resp = client.post( + "/api/chat?debug=true", + json={ + "text": "Wie geht es dir?", + "llm_provider": "stub", + "tts_provider": "stub", + "output_endpoint": "loopback", + }, + ) + assert resp.status_code == 200 + body = resp.json() + assert body["route"]["llm_provider"] == "stub" + assert body["route"]["output_endpoint"] == "loopback" + assert body["trace"]["semantic_response"] == "Mir geht es gut, danke." + assert loopback_output().chunks[0].data == b"AUDIO" + + +def test_transcribe_with_stub_stt(monkeypatch): + class StubSTT: + async def transcribe(self, audio_bytes, fmt, language=None): + return "erkannter text" + + monkeypatch.setitem(deps.STT_REGISTRY, "stub-stt", lambda s: StubSTT()) + files = {"file": ("a.wav", b"RIFFdata", "audio/wav")} + data = {"stt_provider": "stub-stt"} + resp = client.post("/api/transcribe", data=data, files=files) + assert resp.status_code == 200 + body = resp.json() + assert body["route"]["stt_provider"] == "stub-stt" + assert body["trace"]["raw_transcript"] == "erkannter text" + + +def test_config_endpoint_exposes_no_secrets(): + resp = client.get("/api/config") + assert resp.status_code == 200 + body = resp.json() + assert "piper" in body["available"]["tts_providers"] + assert "loopback" in {e["kind"] for e in body["available"]["output_endpoints"]} + # Keine echten Secrets im Body. + assert "sk-or-" not in json.dumps(body) + assert set(body["secrets"].keys()) == {"openrouter_api_key_set"} diff --git a/tests/test_forward_auth.py b/tests/test_forward_auth.py new file mode 100644 index 0000000..d83f168 --- /dev/null +++ b/tests/test_forward_auth.py @@ -0,0 +1,108 @@ +import pytest +from fastapi.testclient import TestClient + +import app.dependencies as deps +from app.config import settings +from app.main import app + +client = TestClient(app) + +HEADER = "X-Remote-User" +# Starlette-TestClient meldet sich als Host "testclient" -> als vertrauten Proxy setzen. +TRUSTED = "testclient" + + +@pytest.fixture +def forward_auth(monkeypatch): + monkeypatch.setattr(settings, "trusted_auth_header", HEADER) + monkeypatch.setattr(settings, "trusted_proxy_ips", TRUSTED) + monkeypatch.setattr(settings, "admin_users", "atoor,dieterschlueter,dschlueter") + monkeypatch.setattr(settings, "auth_enabled", True) + + +def test_forward_auth_provisions_user(forward_auth): + r = client.get("/api/me", headers={HEADER: "lieschen"}) + assert r.status_code == 200 + body = r.json() + assert body["external_id"] == "lieschen" + assert body["is_admin"] is False + # Nutzer wurde im Store angelegt. + assert deps.get_store().get_user_by_external_id("lieschen") is not None + + +def test_admin_users_flagged(forward_auth): + for name in ("atoor", "dieterschlueter", "dschlueter"): + r = client.get("/api/me", headers={HEADER: name}) + assert r.status_code == 200 + assert r.json()["is_admin"] is True, name + + +def test_untrusted_ip_ignores_header(monkeypatch): + # Proxy-IP passt NICHT zur TestClient-Quelle -> Header wird ignoriert. + monkeypatch.setattr(settings, "trusted_auth_header", HEADER) + monkeypatch.setattr(settings, "trusted_proxy_ips", "10.9.9.9") + monkeypatch.setattr(settings, "auth_enabled", False) # Fallback -> anonym + r = client.get("/api/me", headers={HEADER: "angreifer"}) + assert r.status_code == 200 + assert r.json()["external_id"] is None # nicht als 'angreifer' uebernommen + + +def test_missing_header_from_trusted_proxy_401(forward_auth): + r = client.get("/api/me") # vertrauter Proxy, aber kein Identitaets-Header + assert r.status_code == 401 + + +def test_get_or_create_idempotent(forward_auth): + r1 = client.get("/api/me", headers={HEADER: "wiederkehr"}) + r2 = client.get("/api/me", headers={HEADER: "wiederkehr"}) + assert r1.json()["user_id"] == r2.json()["user_id"] + users = [u for u in deps.get_store().list_users() if u.external_id == "wiederkehr"] + assert len(users) == 1 + + +def test_admin_users_endpoint_gated(forward_auth): + # Admin darf die Liste sehen. + client.get("/api/me", headers={HEADER: "lieschen"}) # einen Nutzer anlegen + r_admin = client.get("/api/admin/users", headers={HEADER: "dschlueter"}) + assert r_admin.status_code == 200 + assert any(u["external_id"] == "lieschen" for u in r_admin.json()) + # Nicht-Admin wird abgewiesen. + r_user = client.get("/api/admin/users", headers={HEADER: "lieschen"}) + assert r_user.status_code == 403 + + +def test_static_index_served(): + # Auth aus (LAN-Dev) -> anonymer Nutzer -> Seite wird ausgeliefert. + r = client.get("/") + assert r.status_code == 200 + assert "Voice Assistant" in r.text + + +def test_static_index_gated_without_auth(monkeypatch): + # Auth an, keine Identitaet -> Seite ist gesperrt (kein Login-URL -> 401). + monkeypatch.setattr(settings, "auth_enabled", True) + monkeypatch.setattr(settings, "trusted_auth_header", "") + monkeypatch.setattr(settings, "trusted_auth_cookie", "") + monkeypatch.setattr(settings, "sso_login_url", "") + r = client.get("/", follow_redirects=False) + assert r.status_code == 401 + + +def test_static_index_redirects_to_sso(monkeypatch): + # Auth an, keine Identitaet, Login-URL gesetzt -> Redirect zum SSO-Portal. + monkeypatch.setattr(settings, "auth_enabled", True) + monkeypatch.setattr(settings, "trusted_auth_header", "") + monkeypatch.setattr(settings, "trusted_auth_cookie", "") + monkeypatch.setattr(settings, "sso_login_url", "https://linix.de/yunohost/sso/") + r = client.get("/", follow_redirects=False) + assert r.status_code == 302 + assert r.headers["location"].startswith("https://linix.de/yunohost/sso/") + + +def test_health_open_without_auth(monkeypatch): + # Health-Check bleibt auch bei aktiver Auth ohne Identitaet erreichbar. + monkeypatch.setattr(settings, "auth_enabled", True) + monkeypatch.setattr(settings, "trusted_auth_header", "") + monkeypatch.setattr(settings, "trusted_auth_cookie", "") + r = client.get("/health") + assert r.status_code == 200 diff --git a/tests/test_local_llm_messages.py b/tests/test_local_llm_messages.py new file mode 100644 index 0000000..4c5ea46 --- /dev/null +++ b/tests/test_local_llm_messages.py @@ -0,0 +1,30 @@ +from app.providers.llm.local_openai_compatible import LocalOpenAICompatibleLLM + + +def _llm(system_prompt="Sei kurz."): + return LocalOpenAICompatibleLLM("http://x/v1", "k", "m", system_prompt=system_prompt) + + +def test_single_system_message_when_history_has_system(): + llm = _llm() + history = [ + {"role": "system", "content": "Was du ueber den Nutzer weisst:\n- heisst Anna"}, + {"role": "user", "content": "Hallo"}, + {"role": "assistant", "content": "Hi Anna"}, + ] + msgs = llm._build_messages("Wie geht es dir?", history) + + # Genau EINE System-Nachricht, ganz am Anfang (Qwen3-Template-Anforderung). + assert sum(1 for m in msgs if m["role"] == "system") == 1 + assert msgs[0]["role"] == "system" + assert "Sei kurz." in msgs[0]["content"] + assert "heisst Anna" in msgs[0]["content"] + # Reihenfolge der Nicht-System-Nachrichten bleibt erhalten, User zuletzt. + assert [m["role"] for m in msgs] == ["system", "user", "assistant", "user"] + assert msgs[-1] == {"role": "user", "content": "Wie geht es dir?"} + + +def test_no_system_message_without_prompt_or_history(): + llm = _llm(system_prompt="") + msgs = llm._build_messages("Hallo", None) + assert msgs == [{"role": "user", "content": "Hallo"}] diff --git a/tests/test_memory.py b/tests/test_memory.py new file mode 100644 index 0000000..314afbe --- /dev/null +++ b/tests/test_memory.py @@ -0,0 +1,81 @@ +from fastapi.testclient import TestClient + +import app.dependencies as deps +from app.main import app + +client = TestClient(app) + + +def _install_stubs(monkeypatch, captured): + class MemLLM: + async def complete(self, text, history=None, session_id=None, language=None): + captured["history"] = list(history or []) + return f"Antwort auf: {text}" + + class StubTTS: + async def synthesize(self, text, voice=None, audio_format="pcm", language=None): + return b"AUDIO" + + monkeypatch.setitem(deps.LLM_REGISTRY, "mem", lambda s: MemLLM()) + monkeypatch.setitem(deps.TTS_REGISTRY, "stub", lambda s: StubTTS()) + return {"llm_provider": "mem", "tts_provider": "stub", "output_endpoint": "loopback"} + + +def test_history_accumulates_and_flows_to_llm(monkeypatch): + captured = {} + base = _install_stubs(monkeypatch, captured) + + # Turn 1: noch kein Verlauf. + r1 = client.post("/api/chat?debug=true&session_id=conv1", json={"text": "Hallo", **base}) + assert r1.status_code == 200 + assert r1.json()["history_len"] == 0 + assert captured["history"] == [] + + # Turn 2: Verlauf enthaelt User+Assistant aus Turn 1. + r2 = client.post("/api/chat?debug=true&session_id=conv1", json={"text": "Und weiter?", **base}) + assert r2.status_code == 200 + assert r2.json()["history_len"] == 2 + assert captured["history"] == [ + {"role": "user", "content": "Hallo"}, + {"role": "assistant", "content": "Antwort auf: Hallo"}, + ] + + +def test_no_session_id_is_stateless(monkeypatch): + captured = {} + base = _install_stubs(monkeypatch, captured) + + client.post("/api/chat?debug=true", json={"text": "A", **base}) + client.post("/api/chat?debug=true", json={"text": "B", **base}) + assert captured["history"] == [] # ohne session_id kein Gedaechtnis + + +def test_memories_injected_as_context(monkeypatch): + captured = {} + base = _install_stubs(monkeypatch, captured) + + # Erinnerung fuer den (anonymen) Nutzer ablegen. + deps.get_store().ensure_anonymous_user() + deps.get_store().add_memory("anonymous", "heisst Anna") + + # Ohne session_id -> kein Verlauf, aber Erinnerung wird trotzdem injiziert. + r = client.post("/api/chat?debug=true", json={"text": "Hallo", **base}) + assert r.status_code == 200 + assert r.json()["memories_len"] == 1 + assert captured["history"][0]["role"] == "system" + assert "heisst Anna" in captured["history"][0]["content"] + + +def test_history_limited_by_setting(monkeypatch): + from app.config import settings + + captured = {} + base = _install_stubs(monkeypatch, captured) + monkeypatch.setattr(settings, "history_max_messages", 2) + + for text in ("eins", "zwei", "drei"): + client.post("/api/chat?debug=true&session_id=limit", json={"text": text, **base}) + + # Vor dem 3. Turn liegen 4 Nachrichten vor; geladen werden nur die letzten 2. + assert len(captured["history"]) == 2 + assert captured["history"][-1] == {"role": "assistant", "content": "Antwort auf: zwei"} diff --git a/tests/test_memory_extraction.py b/tests/test_memory_extraction.py new file mode 100644 index 0000000..850dfa2 --- /dev/null +++ b/tests/test_memory_extraction.py @@ -0,0 +1,135 @@ +import asyncio + +import pytest + +import app.dependencies as deps +from app.config import settings +from app.core import memory_extractor as me + + +class StubLLM: + def __init__(self, raw): + self.raw = raw + self.calls = 0 + + async def complete(self, text, history=None, session_id=None, language=None): + self.calls += 1 + return self.raw + + +@pytest.fixture(autouse=True) +def _clear_turn_counts(): + me._turn_counts.clear() + yield + me._turn_counts.clear() + + +def _seed_conversation(store, session_id="conv", user_id="anonymous"): + store.append_message(session_id, user_id, "user", "Ich heisse Anna und wohne in Kiel.") + store.append_message(session_id, user_id, "assistant", "Schoen, Anna!") + + +def test_parse_facts_variants(): + assert me.parse_facts('["heisst Anna", "wohnt in Kiel"]') == ["heisst Anna", "wohnt in Kiel"] + # umschlossen von Geschwafel/Markdown + assert me.parse_facts('Hier:\n```json\n["x"]\n```') == ["x"] + assert me.parse_facts("[]") == [] + assert me.parse_facts("kein json") == [] + assert me.parse_facts("") == [] + # Nicht-Strings werden ignoriert + assert me.parse_facts('["ok", 5, null, " "]') == ["ok"] + + +def test_extracts_and_stores_new_facts(monkeypatch): + store = deps.get_store() + _seed_conversation(store) + monkeypatch.setattr(me, "_build_extractor_llm", + lambda cfg: StubLLM('["heisst Anna", "wohnt in Kiel"]')) + + added = asyncio.run(me.extract_and_store(store, "anonymous", "conv", settings)) + + assert added == 2 + contents = [m.content for m in store.get_memories("anonymous")] + assert contents == ["heisst Anna", "wohnt in Kiel"] + + +def test_dedup_skips_known(monkeypatch): + store = deps.get_store() + _seed_conversation(store) + store.add_memory("anonymous", "heisst Anna") + # LLM liefert einen bekannten (anders gross-/kleingeschrieben) + einen neuen Fakt. + monkeypatch.setattr(me, "_build_extractor_llm", + lambda cfg: StubLLM('["Heisst Anna", "wohnt in Kiel"]')) + + added = asyncio.run(me.extract_and_store(store, "anonymous", "conv", settings)) + + assert added == 1 + contents = [m.content for m in store.get_memories("anonymous")] + assert contents == ["heisst Anna", "wohnt in Kiel"] + + +def test_malformed_output_no_crash(monkeypatch): + store = deps.get_store() + _seed_conversation(store) + monkeypatch.setattr(me, "_build_extractor_llm", + lambda cfg: StubLLM("Tut mir leid, kein JSON hier.")) + + added = asyncio.run(me.extract_and_store(store, "anonymous", "conv", settings)) + + assert added == 0 + assert store.get_memories("anonymous") == [] + + +def test_cap_respected(monkeypatch): + store = deps.get_store() + _seed_conversation(store) + monkeypatch.setattr(settings, "memory_extraction_max", 1) + monkeypatch.setattr(me, "_build_extractor_llm", + lambda cfg: StubLLM('["fakt a", "fakt b", "fakt c"]')) + + added = asyncio.run(me.extract_and_store(store, "anonymous", "conv", settings)) + + assert added == 1 + assert len(store.get_memories("anonymous")) == 1 + + +def test_empty_conversation_skips_llm(monkeypatch): + store = deps.get_store() + called = StubLLM("[]") + monkeypatch.setattr(me, "_build_extractor_llm", lambda cfg: called) + + added = asyncio.run(me.extract_and_store(store, "anonymous", "leer", settings)) + + assert added == 0 + assert called.calls == 0 # ohne Gespraech kein LLM-Aufruf + + +def test_schedule_only_every_n_turns(monkeypatch): + store = deps.get_store() + monkeypatch.setattr(settings, "memory_extraction_enabled", True) + monkeypatch.setattr(settings, "memory_extraction_every_n_turns", 3) + + async def run(): + # Session "s1" hat keine Nachrichten -> der geplante Task endet sofort (kein LLM). + results = [me.maybe_schedule_extraction(store, "anonymous", "s1") for _ in range(3)] + for task in results: + if task is not None: + await task + return results + + tasks = asyncio.run(run()) + # nur der 3. Aufruf plant einen Task + assert tasks[0] is None and tasks[1] is None + assert tasks[2] is not None + + +def test_schedule_disabled_is_noop(monkeypatch): + store = deps.get_store() + monkeypatch.setattr(settings, "memory_extraction_enabled", False) + assert me.maybe_schedule_extraction(store, "anonymous", "s1") is None + + +def test_schedule_without_session_is_noop(monkeypatch): + store = deps.get_store() + monkeypatch.setattr(settings, "memory_extraction_enabled", True) + assert me.maybe_schedule_extraction(store, "anonymous", None) is None diff --git a/tests/test_openrouter_tts.py b/tests/test_openrouter_tts.py new file mode 100644 index 0000000..62394bd --- /dev/null +++ b/tests/test_openrouter_tts.py @@ -0,0 +1,70 @@ +"""Tests fuer das Retry-Verhalten des OpenRouter-TTS (transiente leere/5xx-Antworten).""" + +import asyncio + +import httpx +import pytest + +import app.providers.tts.openrouter as orm +from app.providers.tts.openrouter import OpenRouterTTSProvider + +REQ = httpx.Request("POST", "https://openrouter.ai/api/v1/audio/speech") + + +def _run(coro): + return asyncio.run(coro) + + +def _patch(monkeypatch, responses): + """httpx-POST liefert nacheinander die vorgegebenen Antworten; kein echtes Sleep.""" + it = iter(responses) + calls = {"n": 0} + + async def fake_post(self, url, **kw): + calls["n"] += 1 + return next(it) + + async def no_sleep(*a, **k): + pass + + monkeypatch.setattr(httpx.AsyncClient, "post", fake_post) + monkeypatch.setattr(orm.asyncio, "sleep", no_sleep) + return calls + + +def _provider(): + return OpenRouterTTSProvider("key", "some/model", "Zephyr") + + +def test_retries_on_empty_then_succeeds(monkeypatch): + calls = _patch(monkeypatch, [ + httpx.Response(200, content=b"", request=REQ), # transienter Aussetzer + httpx.Response(200, content=b"AUDIO", request=REQ), # dann echtes Audio + ]) + assert _run(_provider().synthesize("Hallo")) == b"AUDIO" + assert calls["n"] == 2 + + +def test_4xx_raises_immediately_without_retry(monkeypatch): + calls = _patch(monkeypatch, [ + httpx.Response(400, text="invalid voice", request=REQ), + ]) + with pytest.raises(RuntimeError, match="400"): + _run(_provider().synthesize("Hallo")) + assert calls["n"] == 1 # kein Retry bei 4xx + + +def test_all_empty_raises_after_attempts(monkeypatch): + calls = _patch(monkeypatch, [httpx.Response(200, content=b"", request=REQ)] * orm._MAX_ATTEMPTS) + with pytest.raises(RuntimeError, match="empty audio content"): + _run(_provider().synthesize("Hallo")) + assert calls["n"] == orm._MAX_ATTEMPTS + + +def test_5xx_then_success(monkeypatch): + calls = _patch(monkeypatch, [ + httpx.Response(503, text="upstream", request=REQ), + httpx.Response(200, content=b"OK", request=REQ), + ]) + assert _run(_provider().synthesize("Hallo")) == b"OK" + assert calls["n"] == 2 diff --git a/tests/test_piper_tts.py b/tests/test_piper_tts.py new file mode 100644 index 0000000..7e841e7 --- /dev/null +++ b/tests/test_piper_tts.py @@ -0,0 +1,84 @@ +"""Tests fuer den lokalen Piper-TTS-Provider (offline, mit Fake-Binary).""" + +import asyncio +import json +import shutil +import stat + +import pytest + +import app.providers.tts.piper as piper_mod +from app.providers.tts.piper import PiperTTSProvider + + +@pytest.fixture(autouse=True) +def _force_binary_path(monkeypatch): + """Diese Tests pruefen den Binary-Fallback (Fake-piper). Ist die piper-Python-Lib + installiert, wuerde sonst der In-Process-Pfad das Fake-Modell zu laden versuchen.""" + monkeypatch.setattr(piper_mod, "_PIPER_LIB", False) + + +# PCM-Nutzlast des Fake-Binaries. 4000 Bytes = 2000 s16le-Samples. +FAKE_PCM = b"\x01\x02" * 2000 + + +def _run(coro): + return asyncio.run(coro) + + +def _make_fake_voice(tmp_path, sample_rate): + """Legt ein Fake-piper-Binary + Stimmmodell (.onnx/.onnx.json) an.""" + voices = tmp_path / "voices" + voices.mkdir() + (voices / "de_DE-test.onnx").write_bytes(b"fake-model") + (voices / "de_DE-test.onnx.json").write_text( + json.dumps({"audio": {"sample_rate": sample_rate}}) + ) + + # Fake-Binary: ignoriert Argumente, schreibt feste Roh-PCM-Bytes nach stdout. + binp = tmp_path / "piper" + binp.write_text( + "#!/usr/bin/env python3\n" + "import sys\n" + f"sys.stdout.buffer.write({FAKE_PCM!r})\n" + ) + binp.chmod(binp.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH) + return str(binp), str(voices) + + +def test_piper_returns_pcm_without_resampling(tmp_path): + # Native Rate == Ziel-Rate -> kein ffmpeg noetig, Bytes unveraendert. + binp, voices = _make_fake_voice(tmp_path, sample_rate=24000) + provider = PiperTTSProvider(binp, voices, "de_DE-test", target_rate=24000) + pcm = _run(provider.synthesize("Hallo Welt")) + assert pcm == FAKE_PCM + + +def test_piper_wraps_wav_on_request(tmp_path): + binp, voices = _make_fake_voice(tmp_path, sample_rate=24000) + provider = PiperTTSProvider(binp, voices, "de_DE-test", target_rate=24000) + wav = _run(provider.synthesize("Hallo", audio_format="wav")) + assert wav.startswith(b"RIFF") and b"WAVE" in wav[:16] + + +def test_piper_rejects_empty_text(tmp_path): + binp, voices = _make_fake_voice(tmp_path, sample_rate=24000) + provider = PiperTTSProvider(binp, voices, "de_DE-test") + with pytest.raises(ValueError): + _run(provider.synthesize(" ")) + + +def test_piper_unknown_voice_raises(tmp_path): + binp, voices = _make_fake_voice(tmp_path, sample_rate=24000) + provider = PiperTTSProvider(binp, voices, "gibt-es-nicht") + with pytest.raises(RuntimeError): + _run(provider.synthesize("Hallo")) + + +@pytest.mark.skipif(not shutil.which("ffmpeg"), reason="ffmpeg nicht installiert") +def test_piper_resamples_when_rates_differ(tmp_path): + # Native 16000 -> Ziel 24000: ffmpeg laeuft, Ergebnis ist gueltiges (nicht leeres) PCM. + binp, voices = _make_fake_voice(tmp_path, sample_rate=16000) + provider = PiperTTSProvider(binp, voices, "de_DE-test", target_rate=24000) + pcm = _run(provider.synthesize("Hallo")) + assert isinstance(pcm, bytes) and len(pcm) > 0 diff --git a/tests/test_quota_safety.py b/tests/test_quota_safety.py new file mode 100644 index 0000000..0d20ad6 --- /dev/null +++ b/tests/test_quota_safety.py @@ -0,0 +1,81 @@ +from fastapi.testclient import TestClient + +import app.dependencies as deps +from app.main import app +from app.config import settings +from app.safety.emergency import detect + +client = TestClient(app) + + +def _stub_chat(monkeypatch, answer="ok"): + class StubLLM: + async def complete(self, text, history=None, session_id=None, language=None): + return answer + + class StubTTS: + async def synthesize(self, text, voice=None, audio_format="pcm", language=None): + return b"A" + + monkeypatch.setitem(deps.LLM_REGISTRY, "l", lambda s: StubLLM()) + monkeypatch.setitem(deps.TTS_REGISTRY, "t", lambda s: StubTTS()) + return {"llm_provider": "l", "tts_provider": "t"} + + +# --- Notfall-Erkennung (Einheit) ------------------------------------------ + +def test_detect_categories(): + assert detect("Ich habe Brustschmerzen")[0] == "medical" + assert detect("Bitte ruf einen Arzt")[0] == "help" + assert detect("I want to die")[0] == "self_harm" + assert detect("Wie wird das Wetter morgen?") is None + assert detect("") is None + + +# --- Quota ----------------------------------------------------------------- + +def test_quota_blocks_after_limit(monkeypatch): + base = _stub_chat(monkeypatch) + monkeypatch.setattr(settings, "daily_request_limit", 1) + body = {"text": "Hallo", **base} + assert client.post("/api/chat?debug=true", json=body).status_code == 200 + assert client.post("/api/chat?debug=true", json=body).status_code == 429 + + +def test_quota_unlimited_by_default(monkeypatch): + base = _stub_chat(monkeypatch) + body = {"text": "Hallo", **base} + for _ in range(3): + assert client.post("/api/chat?debug=true", json=body).status_code == 200 + + +# --- Notfall ueber Endpunkt ------------------------------------------------ + +def test_emergency_surfaced_in_response(monkeypatch): + base = _stub_chat(monkeypatch, answer="Bleiben Sie ruhig.") + resp = client.post( + "/api/chat?debug=true", + json={"text": "Ich habe starke Schmerzen in der Brust", **base}, + ) + assert resp.status_code == 200 + assert resp.json()["emergency"]["category"] == "medical" + + +def test_emergency_bypasses_quota(monkeypatch): + base = _stub_chat(monkeypatch) + monkeypatch.setattr(settings, "daily_request_limit", 1) + body = {**base} + assert client.post("/api/chat?debug=true", json={"text": "hallo", **body}).status_code == 200 + assert client.post("/api/chat?debug=true", json={"text": "hallo", **body}).status_code == 429 + rescue = client.post("/api/chat?debug=true", json={"text": "ich kann nicht atmen", **body}) + assert rescue.status_code == 200 + assert rescue.json()["emergency"]["category"] == "medical" + + +def test_ws_emergency_event(monkeypatch): + base = _stub_chat(monkeypatch, answer="Ruhig bleiben, Hilfe kommt.") + with client.websocket_connect("/ws/chat") as ws: + ws.send_json({"text": "Ich bin gestürzt und komme nicht hoch", **base}) + event = ws.receive_json() + assert event["type"] == "emergency" and event["category"] == "medical" + assert ws.receive_json()["type"] == "ack" diff --git a/tests/test_realtime.py b/tests/test_realtime.py new file mode 100644 index 0000000..9f0cc53 --- /dev/null +++ b/tests/test_realtime.py @@ -0,0 +1,129 @@ +import array +import asyncio + +from fastapi.testclient import TestClient + +import app.dependencies as deps +from app.main import app +from app.audio.vad import rms, EnergyVAD + +client = TestClient(app) + + +def _pcm(amplitude: int, n_samples: int) -> bytes: + return array.array("h", [amplitude] * n_samples).tobytes() + + +# --- VAD-Unit-Tests -------------------------------------------------------- + +def test_rms_silence_vs_loud(): + assert rms(b"") == 0.0 + assert rms(_pcm(0, 100)) == 0.0 + assert rms(_pcm(3000, 100)) > 2000 + + +def test_energy_vad_ends_after_speech_then_silence(): + vad = EnergyVAD(sample_rate=16000, threshold=500, silence_ms=300) + loud = _pcm(3000, 1600) # 100 ms Sprache + silent = _pcm(0, 1600) # 100 ms Stille + assert vad.feed(loud) is False + assert vad.feed(silent) is False # 100 ms + assert vad.feed(silent) is False # 200 ms + assert vad.feed(silent) is True # 300 ms -> Ende + + +def test_energy_vad_ignores_silence_without_speech(): + vad = EnergyVAD(sample_rate=16000, threshold=500, silence_ms=100) + for _ in range(10): + assert vad.feed(_pcm(0, 1600)) is False + + +# --- Barge-in -------------------------------------------------------------- + +def _install_slow_stream(monkeypatch): + class SlowLLM: + async def complete(self, text, history=None, session_id=None, language=None): + return "fertig" + + async def stream(self, text, history=None, session_id=None, language=None): + for i in range(200): + await asyncio.sleep(0.005) + yield f"t{i} " + + class StubTTS: + async def synthesize(self, text, voice=None, audio_format="pcm", language=None): + return b"A" + + monkeypatch.setitem(deps.LLM_REGISTRY, "slow", lambda s: SlowLLM()) + monkeypatch.setitem(deps.TTS_REGISTRY, "stub", lambda s: StubTTS()) + return { + "llm_provider": "slow", + "tts_provider": "stub", + "output_endpoint": "loopback", + "stream": True, + } + + +def test_ws_chat_interrupt_cancels_response(monkeypatch): + base = _install_slow_stream(monkeypatch) + with client.websocket_connect("/ws/chat") as ws: + ws.send_json({"text": "Hallo", **base}) + assert ws.receive_json()["type"] == "ack" + assert ws.receive_json()["type"] == "token" # Antwort laeuft + + ws.send_json({"type": "interrupt"}) + + event = None + for _ in range(500): + event = ws.receive_json() + if event["type"] in ("interrupted", "done"): + break + assert event["type"] == "interrupted" # abgebrochen, nicht fertig + + +# --- VAD im WebSocket ------------------------------------------------------ + +def _install_voice_stubs(monkeypatch): + class STT: + async def transcribe(self, audio_bytes, fmt, language=None): + return "ok" + + class LLM: + async def complete(self, text, history=None, session_id=None, language=None): + return "antwort" + + class TTS: + async def synthesize(self, text, voice=None, audio_format="pcm", language=None): + return b"A" + + monkeypatch.setitem(deps.STT_REGISTRY, "s", lambda x: STT()) + monkeypatch.setitem(deps.LLM_REGISTRY, "l", lambda x: LLM()) + monkeypatch.setitem(deps.TTS_REGISTRY, "t", lambda x: TTS()) + return {"stt_provider": "s", "llm_provider": "l", "tts_provider": "t", "output_endpoint": "loopback"} + + +def test_ws_voice_vad_auto_segments_utterance(monkeypatch): + opts = _install_voice_stubs(monkeypatch) + start = { + "type": "start", + "vad": True, + "sample_rate": 16000, + "vad_silence_ms": 200, + "format": "pcm", + **opts, + } + loud = _pcm(3000, 1600) # 100 ms Sprache + silent = _pcm(0, 1600) # je 100 ms Stille + + with client.websocket_connect("/ws/voice") as ws: + ws.send_json(start) + ws.send_bytes(loud) + ws.send_bytes(silent) # 100 ms + ws.send_bytes(silent) # 200 ms -> VAD-Ende, Turn startet automatisch + + transcript = ws.receive_json() + assert transcript["type"] == "transcript" and transcript["text"] == "ok" + assert ws.receive_json()["type"] == "ack" + assert ws.receive_json()["type"] == "semantic" + assert ws.receive_bytes() == b"A" + assert ws.receive_json()["type"] == "done" diff --git a/tests/test_resilience.py b/tests/test_resilience.py new file mode 100644 index 0000000..85c5ad6 --- /dev/null +++ b/tests/test_resilience.py @@ -0,0 +1,129 @@ +import asyncio + +import pytest +from fastapi.testclient import TestClient + +import app.dependencies as deps +from app.main import app +from app.config import settings +from app.metrics import metrics +from app.providers.fallback import FallbackLLMProvider + +client = TestClient(app) + + +def _run(coro): + return asyncio.run(coro) + + +# --- Fallback-Einheiten ---------------------------------------------------- + +def test_llm_fallback_uses_second_on_error(): + class BadLLM: + async def complete(self, text, history=None, session_id=None, language=None): + raise RuntimeError("down") + + class GoodLLM: + async def complete(self, text, history=None, session_id=None, language=None): + return "ok" + + chain = FallbackLLMProvider("llm", [("bad", BadLLM()), ("good", GoodLLM())]) + assert _run(chain.complete("x")) == "ok" + + counters = metrics.snapshot()["counters"] + assert any("provider_fallback_total" in key for key in counters) + assert any('provider_error_total{module="llm",provider="bad"}' in key for key in counters) + + +def test_llm_fallback_all_fail_raises(): + class BadLLM: + async def complete(self, text, history=None, session_id=None, language=None): + raise RuntimeError("x") + + chain = FallbackLLMProvider("llm", [("a", BadLLM()), ("b", BadLLM())]) + with pytest.raises(RuntimeError): + _run(chain.complete("x")) + + +def test_llm_stream_fallback_before_first_token(): + class BadStream: + async def complete(self, text, history=None, session_id=None, language=None): + return "x" + + async def stream(self, text, history=None, session_id=None, language=None): + raise RuntimeError("boom") + yield # macht die Funktion zum Generator + + class GoodStream: + async def complete(self, text, history=None, session_id=None, language=None): + return "ok" + + async def stream(self, text, history=None, session_id=None, language=None): + yield "he" + yield "llo" + + chain = FallbackLLMProvider("llm", [("bad", BadStream()), ("good", GoodStream())]) + + async def collect(): + return [delta async for delta in chain.stream("x")] + + assert _run(collect()) == ["he", "llo"] + + +# --- Fallback ueber Config + Endpunkt -------------------------------------- + +def test_config_llm_fallback_applied(monkeypatch): + class BadLLM: + async def complete(self, text, history=None, session_id=None, language=None): + raise RuntimeError("primary down") + + class GoodLLM: + async def complete(self, text, history=None, session_id=None, language=None): + return "rescued" + + class StubTTS: + async def synthesize(self, text, voice=None, audio_format="pcm", language=None): + return b"A" + + monkeypatch.setitem(deps.LLM_REGISTRY, "bad", lambda s: BadLLM()) + monkeypatch.setitem(deps.LLM_REGISTRY, "good", lambda s: GoodLLM()) + monkeypatch.setitem(deps.TTS_REGISTRY, "t", lambda s: StubTTS()) + monkeypatch.setattr(settings, "llm_fallback", "good") + + resp = client.post( + "/api/chat?debug=true", + json={"text": "x", "llm_provider": "bad", "tts_provider": "t"}, + ) + assert resp.status_code == 200 + assert resp.json()["trace"]["semantic_response"] == "rescued" + + +# --- Metriken -------------------------------------------------------------- + +def test_metrics_endpoint_records_requests_and_stages(monkeypatch): + class StubLLM: + async def complete(self, text, history=None, session_id=None, language=None): + return "hi" + + class StubTTS: + async def synthesize(self, text, voice=None, audio_format="pcm", language=None): + return b"A" + + monkeypatch.setitem(deps.LLM_REGISTRY, "l", lambda s: StubLLM()) + monkeypatch.setitem(deps.TTS_REGISTRY, "t", lambda s: StubTTS()) + + resp = client.post( + "/api/chat?debug=true", json={"text": "x", "llm_provider": "l", "tts_provider": "t"} + ) + assert resp.status_code == 200 + + snap = client.get("/api/metrics").json() + assert any("http_requests_total" in k and "chat" in k for k in snap["counters"]) + assert any('stage_duration_seconds{stage="llm"}' in k for k in snap["timers"]) + assert any('stage_duration_seconds{stage="tts"}' in k for k in snap["timers"]) + + +def test_metrics_prometheus_format(monkeypatch): + client.get("/health") + text = client.get("/api/metrics?format=prometheus").text + assert "http_requests_total" in text diff --git a/tests/test_routing.py b/tests/test_routing.py new file mode 100644 index 0000000..4eeab76 --- /dev/null +++ b/tests/test_routing.py @@ -0,0 +1,66 @@ +import pytest + +from app.config import Settings +from app.errors import UnknownComponentError +from app.store import SessionOwnershipError +from app.dependencies import ( + resolve_route, + get_llm_provider, + get_tts_provider, + get_store, +) + + +def test_default_route_from_settings(): + cfg = Settings() + route = resolve_route(cfg=cfg) + assert route.stt_provider == cfg.default_stt_provider + assert route.llm_provider == cfg.default_llm_provider + assert route.input_endpoint == cfg.default_input_endpoint + assert route.language == cfg.default_language + + +def test_request_overrides_win(): + route = resolve_route(overrides={"llm_provider": "openrouter", "output_endpoint": "loopback"}) + assert route.llm_provider == "openrouter" + assert route.output_endpoint == "loopback" + + +def test_user_prefs_session_request_precedence(): + store = get_store() + user, _ = store.create_user("Tester") + store.set_user_prefs(user.id, {"tts_provider": "chatterbox", "language": "en"}) + user = store.get_user(user.id) + + # Nutzer-Prefs gelten. + route = resolve_route(user) + assert route.tts_provider == "chatterbox" + assert route.language == "en" + + # Session schlägt Nutzer-Prefs. + store.update_session("s1", user.id, {"tts_provider": "piper"}) + route2 = resolve_route(user, "s1") + assert route2.tts_provider == "piper" + assert route2.language == "en" # weiterhin aus den Nutzer-Prefs + + # Request schlägt Session. + route3 = resolve_route(user, "s1", {"tts_provider": "openrouter"}) + assert route3.tts_provider == "openrouter" + + +def test_session_ownership_enforced_in_route(): + store = get_store() + owner, _ = store.create_user("A") + other, _ = store.create_user("B") + store.update_session("shared", owner.id, {"tts_provider": "piper"}) + with pytest.raises(SessionOwnershipError): + resolve_route(other, "shared") + + +def test_registry_unknown_provider_raises(): + with pytest.raises(UnknownComponentError): + get_llm_provider("does-not-exist") + + +def test_registry_known_provider(): + assert type(get_tts_provider("piper")).__name__ == "PiperTTSProvider" diff --git a/tests/test_streaming.py b/tests/test_streaming.py new file mode 100644 index 0000000..363a912 --- /dev/null +++ b/tests/test_streaming.py @@ -0,0 +1,153 @@ +import asyncio + +from fastapi.testclient import TestClient + +import app.dependencies as deps +from app.main import app +from app.providers.llm.base import sse_delta, LLMProvider +from app.pipeline.sentence_chunker import SentenceChunker + +client = TestClient(app) + + +def test_sentence_chunker_incremental(): + ch = SentenceChunker() + emitted = [] + for tok in ["Hallo", " Anna", ". ", "Wie", " geht", " es", "? ", "Tschuess"]: + emitted += ch.feed(tok) + assert emitted == ["Hallo Anna.", "Wie geht es?"] + assert ch.flush() == "Tschuess" + assert SentenceChunker().feed("Eins. Zwei! Drei? Vier") == ["Eins.", "Zwei!", "Drei?"] + + +def test_sentence_chunker_keeps_ordinals_and_abbreviations(): + # "1." (Ziffer+Punkt) darf KEINE Satzgrenze sein. + ch = SentenceChunker() + assert ch.feed("Am 1. Mai ist frei. ") == ["Am 1. Mai ist frei."] + # Abkuerzung "z. B." darf den Satz nicht zerschneiden. + ch2 = SentenceChunker() + assert ch2.feed("Obst, z. B. Äpfel und Birnen. ") == ["Obst, z. B. Äpfel und Birnen."] + + +def test_sse_delta_parsing(): + assert sse_delta('data: {"choices":[{"delta":{"content":"Hal"}}]}') == "Hal" + assert sse_delta("data: [DONE]") is None + assert sse_delta("") is None + assert sse_delta(": keep-alive") is None + assert sse_delta('data: {"choices":[{"delta":{}}]}') is None + assert sse_delta("data: nicht-json") is None + + +def test_base_stream_default_yields_full_completion(): + class P(LLMProvider): + async def complete(self, text, history=None, session_id=None, language=None): + return "ganze Antwort" + + async def run(): + return [delta async for delta in P().stream("x")] + + assert asyncio.run(run()) == ["ganze Antwort"] + + +def _install_streaming(monkeypatch, tokens): + class StreamLLM: + async def complete(self, text, history=None, session_id=None, language=None): + return "".join(tokens) + + async def stream(self, text, history=None, session_id=None, language=None): + for tok in tokens: + yield tok + + class StubTTS: + async def synthesize(self, text, voice=None, audio_format="pcm", language=None): + return b"AUD" + + monkeypatch.setitem(deps.LLM_REGISTRY, "stream", lambda s: StreamLLM()) + monkeypatch.setitem(deps.TTS_REGISTRY, "stub", lambda s: StubTTS()) + return {"llm_provider": "stream", "tts_provider": "stub", "output_endpoint": "loopback"} + + +def test_ws_stream_emits_token_events(monkeypatch): + base = _install_streaming(monkeypatch, ["Gu", "ten ", "Tag"]) + with client.websocket_connect("/ws/chat") as ws: + ws.send_json({"text": "Hallo", "stream": True, **base}) + assert ws.receive_json()["type"] == "ack" + + tokens = [] + event = ws.receive_json() + while event["type"] == "token": + tokens.append(event["text"]) + event = ws.receive_json() + + assert tokens == ["Gu", "ten ", "Tag"] + assert event["type"] == "semantic" and event["text"] == "Guten Tag" + assert ws.receive_bytes() == b"AUD" + assert ws.receive_json()["type"] == "done" + + +def test_ws_without_stream_flag_has_no_tokens(monkeypatch): + base = _install_streaming(monkeypatch, ["a", "b"]) + with client.websocket_connect("/ws/chat") as ws: + ws.send_json({"text": "Hallo", **base}) # kein stream-Flag + assert ws.receive_json()["type"] == "ack" + assert ws.receive_json()["type"] == "semantic" # direkt, keine token-Events + + +def test_ws_stream_fallback_for_nonstreaming_llm(monkeypatch): + class OnlyComplete: + async def complete(self, text, history=None, session_id=None, language=None): + return "komplett" + + class StubTTS: + async def synthesize(self, text, voice=None, audio_format="pcm", language=None): + return b"X" + + monkeypatch.setitem(deps.LLM_REGISTRY, "oc", lambda s: OnlyComplete()) + monkeypatch.setitem(deps.TTS_REGISTRY, "stub", lambda s: StubTTS()) + base = {"llm_provider": "oc", "tts_provider": "stub", "output_endpoint": "loopback"} + + with client.websocket_connect("/ws/chat") as ws: + ws.send_json({"text": "x", "stream": True, **base}) + assert ws.receive_json()["type"] == "ack" + token = ws.receive_json() + assert token["type"] == "token" and token["text"] == "komplett" + assert ws.receive_json()["type"] == "semantic" + + +def test_ws_audio_stream_sends_chunks_per_sentence(monkeypatch): + tts_calls = [] + + class StreamLLM: + async def complete(self, text, history=None, session_id=None, language=None): + return "Satz eins. Satz zwei." + + async def stream(self, text, history=None, session_id=None, language=None): + for tok in ["Satz ", "eins. ", "Satz ", "zwei."]: + yield tok + + class CountTTS: + async def synthesize(self, text, voice=None, audio_format="pcm", language=None): + tts_calls.append(text) + return b"A" * len(tts_calls) + + monkeypatch.setitem(deps.LLM_REGISTRY, "stream", lambda s: StreamLLM()) + monkeypatch.setitem(deps.TTS_REGISTRY, "cnt", lambda s: CountTTS()) + base = {"llm_provider": "stream", "tts_provider": "cnt", "output_endpoint": "loopback"} + + with client.websocket_connect("/ws/chat") as ws: + ws.send_json({"text": "x", "audio_stream": True, **base}) + assert ws.receive_json()["type"] == "ack" + + audio_events = 0 + event = ws.receive_json() + while event["type"] != "semantic": + assert event["type"] == "audio" + assert ws.receive_bytes() # binärer Audio-Chunk folgt + audio_events += 1 + event = ws.receive_json() + + assert audio_events == 2 # zwei Sätze -> zwei Chunks + # Kein finales Vollaudio mehr -> direkt done. + assert ws.receive_json()["type"] == "done" + + assert len(tts_calls) == 2 # TTS pro Satz diff --git a/tests/test_tts_normalizer.py b/tests/test_tts_normalizer.py new file mode 100644 index 0000000..cd6bf5a --- /dev/null +++ b/tests/test_tts_normalizer.py @@ -0,0 +1,99 @@ +"""Tests fuer die TTS-Text-Normalisierung (Aussprache vor Piper).""" + +import asyncio + +from app.pipeline.tts_normalizer import TTSNormalizer +from app.pipeline.spoken_response_adapter import SpokenResponseAdapter +from app.pipeline import german_numbers as gn + + +def _run(coro): + return asyncio.run(coro) + + +def _norm(text, level="full", language="de"): + return _run(TTSNormalizer().run(text, language=language, level=level)) + + +# --- Ordinalzahlen --------------------------------------------------------- + +def test_date_ordinal_to_attributive(): + assert "erster Mai" in _norm("Am 1. Mai feiern wir.") + assert "dritter Oktober" in _norm("Der 3. Oktober ist ein Feiertag.") + + +def test_enumeration_run_to_adverbial(): + out = _norm("1. 2. 3. fertig") + assert "erstens" in out and "zweitens" in out and "drittens" in out + + +def test_enum_marker_paren(): + assert "erstens" in _norm("1) Milch") + assert "zweitens" in _norm("2.) Eier") + + +def test_plain_trailing_number_not_touched(): + # Eine einzelne Zahl am Satzende ist KEINE Ordinalzahl -> bleibt Zahl. + out = _norm("Ich nehme 5.") + assert "fünftens" not in out and "fünfter" not in out + assert "5" in out + + +# --- Einheiten ------------------------------------------------------------- + +def test_units_after_number(): + assert "Kilogramm" in _norm("Das wiegt 10 kg.") + assert "Kilometer pro Stunde" in _norm("Tempo 50 km/h.") + + +def test_unit_letter_not_triggered_without_number(): + # "m" ohne vorangehende Zahl darf nicht zu "Meter" werden. + assert "Meter" not in _norm("Am Morgen.") + + +# --- Abkuerzungen / Lexikon ------------------------------------------------ + +def test_builtin_abbreviations(): + assert "Doktor" in _norm("Dr. Müller kommt.") + assert "und so weiter" in _norm("Brot, Milch usw.") + + +# --- Stufen ---------------------------------------------------------------- + +def test_light_leaves_numbers_and_abbrev_for_cloud(): + out = _norm("Am 1. Mai wiegt es 10 kg, Dr. Müller.", level="light") + assert "erster" not in out and "Kilogramm" not in out and "Doktor" not in out + assert "1." in out and "kg" in out # Cloud-TTS macht das selbst + +def test_off_returns_unchanged(): + src = "Am 1. Mai, 10 kg." + assert _norm(src, level="off") == src + + +def test_url_and_email_squashed_in_light(): + out = _norm("Schau auf https://example.org oder mail@example.org.", level="light") + assert "Link" in out and "E-Mail-Adresse" in out + + +# --- Zusammenspiel mit dem SpokenResponseAdapter --------------------------- + +def test_adapter_numbered_list_to_ordinal(): + md = "1. Milch\n2. Eier\n3. Brot" + out = _run(SpokenResponseAdapter().run(md)) + assert "erstens, Milch" in out and "zweitens, Eier" in out + + +def test_terms_dict_is_case_insensitive(): + from app.pipeline.tts_normalizer import _apply_dict + mapping = {"strömt": "ströhmt"} + assert _apply_dict("Der Bach strömt.", mapping, ignore_case=True) == "Der Bach ströhmt." + # Satzanfang (Großschreibung) wird ebenfalls erfasst. + assert _apply_dict("Strömt es?", mapping, ignore_case=True) == "ströhmt es?" + # Teilwort wird NICHT getroffen (Wortgrenze). + assert _apply_dict("Strömung", mapping, ignore_case=True) == "Strömung" + + +def test_german_numbers_table(): + assert gn.ordinal_attributive(1) == "erster" + assert gn.ordinal_adverbial(3) == "drittens" + assert gn.has_ordinal(31) and not gn.has_ordinal(32) diff --git a/tests/test_warmup.py b/tests/test_warmup.py new file mode 100644 index 0000000..3ab8426 --- /dev/null +++ b/tests/test_warmup.py @@ -0,0 +1,25 @@ +import asyncio + +from app.core.warmup import warmup_local_models + + +def test_warmup_is_noop_for_cloud_defaults(): + # Mit den Test-Defaults (openrouter-Provider) lädt warmup nichts und wirft nicht. + asyncio.run(warmup_local_models()) + + +def test_warmup_loads_piper(monkeypatch): + calls = {} + + class FakePiperTTSProvider: # Name muss zur Typpruefung im warmup passen + async def synthesize(self, text, voice=None, audio_format="pcm", language=None): + calls["tts"] = text + return b"AUDIO" + + import app.dependencies as deps + monkeypatch.setattr(deps, "get_tts_provider", lambda *a, **k: FakePiperTTSProvider()) + # Klassennamen auf den vom warmup geprueften Namen setzen. + FakePiperTTSProvider.__name__ = "PiperTTSProvider" + + asyncio.run(warmup_local_models()) + assert calls.get("tts") # synthesize wurde aufgerufen diff --git a/tests/test_ws.py b/tests/test_ws.py new file mode 100644 index 0000000..655b8d2 --- /dev/null +++ b/tests/test_ws.py @@ -0,0 +1,157 @@ +import pytest +from fastapi.testclient import TestClient +from starlette.websockets import WebSocketDisconnect + +import app.dependencies as deps +from app.main import app +from app.config import settings + +client = TestClient(app) + + +def _install_stubs(monkeypatch, captured): + class MemLLM: + async def complete(self, text, history=None, session_id=None, language=None): + captured["history"] = list(history or []) + return f"Echo: {text}" + + class StubTTS: + async def synthesize(self, text, voice=None, audio_format="pcm", language=None): + return b"WSAUDIO" + + monkeypatch.setitem(deps.LLM_REGISTRY, "mem", lambda s: MemLLM()) + monkeypatch.setitem(deps.TTS_REGISTRY, "stub", lambda s: StubTTS()) + return {"llm_provider": "mem", "tts_provider": "stub", "output_endpoint": "loopback"} + + +def _drain_turn(ws): + assert ws.receive_json()["type"] == "ack" + sem = ws.receive_json() + assert ws.receive_bytes() == b"WSAUDIO" + assert ws.receive_json()["type"] == "done" + return sem + + +def test_ws_streams_events_and_remembers(monkeypatch): + captured = {} + base = _install_stubs(monkeypatch, captured) + + with client.websocket_connect("/ws/chat?session_id=wsconv") as ws: + ws.send_json({"text": "Hallo", **base}) + sem1 = _drain_turn(ws) + assert sem1["type"] == "semantic" and sem1["text"] == "Echo: Hallo" + + ws.send_json({"text": "Weiter", **base}) + _drain_turn(ws) + + # Zweiter Turn hat den Verlauf des ersten erhalten. + assert captured["history"] == [ + {"role": "user", "content": "Hallo"}, + {"role": "assistant", "content": "Echo: Hallo"}, + ] + + +def test_ws_unknown_provider_sends_error_event(monkeypatch): + captured = {} + _install_stubs(monkeypatch, captured) + with client.websocket_connect("/ws/chat") as ws: + ws.send_json({"text": "x", "llm_provider": "gibtsnicht"}) + err = ws.receive_json() + assert err["type"] == "error" and err["status"] == 422 + + +def test_ws_requires_token_when_auth_enabled(monkeypatch): + monkeypatch.setattr(settings, "auth_enabled", True) + monkeypatch.setattr(settings, "admin_api_key", "k") + with pytest.raises(WebSocketDisconnect): + with client.websocket_connect("/ws/chat"): + pass + + +def _install_voice_stubs(monkeypatch): + class StubSTT: + async def transcribe(self, audio_bytes, fmt, language=None): + return f"erkannt({len(audio_bytes)})" + + class StubLLM: + async def complete(self, text, history=None, session_id=None, language=None): + return f"Antwort zu {text}" + + class StubTTS: + async def synthesize(self, text, voice=None, audio_format="pcm", language=None): + return b"VOICEAUD" + + monkeypatch.setitem(deps.STT_REGISTRY, "ss", lambda s: StubSTT()) + monkeypatch.setitem(deps.LLM_REGISTRY, "ll", lambda s: StubLLM()) + monkeypatch.setitem(deps.TTS_REGISTRY, "tt", lambda s: StubTTS()) + return { + "stt_provider": "ss", + "llm_provider": "ll", + "tts_provider": "tt", + "output_endpoint": "loopback", + } + + +def test_ws_voice_transcribes_and_answers(monkeypatch): + opts = _install_voice_stubs(monkeypatch) + with client.websocket_connect("/ws/voice?session_id=v1") as ws: + ws.send_bytes(b"PCMDATA") # 7 Bytes + ws.send_bytes(b"MORE") # 4 Bytes -> insgesamt 11 + ws.send_json({"type": "end", **opts}) + + transcript = ws.receive_json() + assert transcript["type"] == "transcript" and transcript["text"] == "erkannt(11)" + assert ws.receive_json()["type"] == "ack" + semantic = ws.receive_json() + assert semantic["type"] == "semantic" and semantic["text"] == "Antwort zu erkannt(11)" + assert ws.receive_bytes() == b"VOICEAUD" + assert ws.receive_json()["type"] == "done" + + +def test_ws_voice_start_frame_options_are_honored(monkeypatch): + # Provider stehen im START-Frame, der end-Frame ist leer -> muessen trotzdem gelten. + opts = _install_voice_stubs(monkeypatch) + with client.websocket_connect("/ws/voice") as ws: + ws.send_json({"type": "start", "format": "wav", **opts}) + ws.send_bytes(b"PCMDATA") # 7 Bytes + ws.send_json({"type": "end"}) + transcript = ws.receive_json() + # Stub-STT (aus dem START-Frame) liefert "erkannt()" -> beweist: Override greift + assert transcript["type"] == "transcript" and transcript["text"] == "erkannt(7)" + assert ws.receive_json()["type"] == "ack" + assert ws.receive_json()["type"] == "semantic" + ws.receive_bytes() + assert ws.receive_json()["type"] == "done" + + +def test_ws_voice_stream_text_emits_token_events(monkeypatch): + # stream:true im START-Frame -> der Server schickt token-Events (Live-Text). + opts = _install_voice_stubs(monkeypatch) + with client.websocket_connect("/ws/voice") as ws: + ws.send_json({"type": "start", "format": "wav", "stream": True, **opts}) + ws.send_bytes(b"PCMDATA") + ws.send_json({"type": "end"}) + assert ws.receive_json()["type"] == "transcript" + assert ws.receive_json()["type"] == "ack" + assert ws.receive_json()["type"] == "token" # Antworttext kommt als token-Event(e) + + +def test_ws_voice_audio_stream_default_on(monkeypatch): + # Server-Default an -> Audio wird gestreamt, auch ohne expliziten audio_stream-Flag. + monkeypatch.setattr(settings, "audio_stream_default", True) + opts = _install_voice_stubs(monkeypatch) + with client.websocket_connect("/ws/voice") as ws: + ws.send_json({"type": "start", "format": "wav", **opts}) # kein audio_stream gesetzt + ws.send_bytes(b"PCMDATA") + ws.send_json({"type": "end"}) + assert ws.receive_json()["type"] == "transcript" + assert ws.receive_json()["type"] == "ack" + assert ws.receive_json()["type"] == "audio" # Audio VOR semantic -> Streaming aktiv + + +def test_ws_voice_empty_buffer_errors(monkeypatch): + opts = _install_voice_stubs(monkeypatch) + with client.websocket_connect("/ws/voice") as ws: + ws.send_json({"type": "end", **opts}) # kein Audio gesendet + err = ws.receive_json() + assert err["type"] == "error" and "no audio" in err["detail"]