From 293ed257dbe4ddf1d79155b336db9636dfd51bda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Wed, 17 Jun 2026 01:48:56 +0200 Subject: [PATCH 001/113] Initial commit: Voice Assistant Gateway mit Konfig-/Routing-Fundament - FastAPI-Gateway mit REST-Endpunkten (chat/speak/transcribe/devices/sessions/config) - Geschichtete Konfiguration mit Profilen (local-dev/hybrid/cloud) via TOML + ENV - Registry-Pattern + einheitliche Route-Aufloesung (Default->Profil->ENV->Session->Request) - Device Router (strikt, Singleton) und Output-Lifecycle im Orchestrator - OpenRouter-Adapter (STT multipart/LLM/TTS) + lokale Provider-Stubs - Regelbasierte Pipeline (Cleaner/Spoken-Adapter/TTS-Normalizer) - 22 automatisierte Tests; Doku: README, BEDIENUNGSANLEITUNG, Architektur - Secrets ausschliesslich ueber Umgebung; .env und lokale config gitignored Co-Authored-By: Claude Opus 4.8 --- .env.example | 31 +++ .gitignore | 21 ++ BEDIENUNGSANLEITUNG.md | 223 +++++++++++++++++++ Dockerfile | 7 + Docs/voice-assistant-architecture.md | 220 ++++++++++++++++++ Makefile | 28 +++ README.md | 130 +++++++++++ app/__init__.py | 0 app/api/__init__.py | 0 app/api/chat.py | 92 ++++++++ app/api/config.py | 37 +++ app/api/devices.py | 12 + app/api/health.py | 7 + app/api/sessions.py | 10 + app/api/speak.py | 60 +++++ app/api/transcribe.py | 50 +++++ app/audio/__init__.py | 0 app/audio/endpoints/__init__.py | 0 app/audio/endpoints/input/__init__.py | 0 app/audio/endpoints/input/base.py | 17 ++ app/audio/endpoints/input/bluetooth.py | 23 ++ app/audio/endpoints/input/local_default.py | 24 ++ app/audio/endpoints/input/mobile_webrtc.py | 25 +++ app/audio/endpoints/input/mobile_ws.py | 24 ++ app/audio/endpoints/output/__init__.py | 0 app/audio/endpoints/output/base.py | 20 ++ app/audio/endpoints/output/bluetooth.py | 26 +++ app/audio/endpoints/output/local_default.py | 26 +++ app/audio/endpoints/output/loopback.py | 28 +++ app/audio/endpoints/output/mobile_webrtc.py | 27 +++ app/audio/endpoints/output/mobile_ws.py | 27 +++ app/audio/router.py | 38 ++++ app/audio/transport_router.py | 11 + app/config.py | 146 ++++++++++++ app/core/__init__.py | 0 app/core/orchestrator.py | 105 +++++++++ app/core/session_manager.py | 11 + app/dependencies.py | 187 ++++++++++++++++ app/errors.py | 13 ++ app/main.py | 17 ++ app/pipeline/__init__.py | 0 app/pipeline/input_cleaner.py | 4 + app/pipeline/spoken_response_adapter.py | 37 +++ app/pipeline/tts_normalizer.py | 59 +++++ app/providers/__init__.py | 0 app/providers/llm/__init__.py | 0 app/providers/llm/base.py | 5 + app/providers/llm/local_openai_compatible.py | 24 ++ app/providers/llm/openrouter.py | 101 +++++++++ app/providers/stt/__init__.py | 0 app/providers/stt/base.py | 5 + app/providers/stt/faster_whisper.py | 5 + app/providers/stt/openrouter.py | 46 ++++ app/providers/tts/__init__.py | 0 app/providers/tts/base.py | 5 + app/providers/tts/chatterbox.py | 5 + app/providers/tts/openrouter.py | 62 ++++++ app/providers/tts/piper.py | 5 + app/schemas.py | 71 ++++++ app/utils/__init__.py | 0 chat_client.py | 76 +++++++ config/voice-assistant.example.toml | 44 ++++ deploy/voice-assistant.env.example | 3 + deploy/voice-assistant.service | 15 ++ docker-compose.yml | 15 ++ pyproject.toml | 28 +++ tests/conftest.py | 22 ++ tests/test_audio_router.py | 43 ++++ tests/test_basic_layout.py | 7 + tests/test_config_profiles.py | 60 +++++ tests/test_endpoints_e2e.py | 96 ++++++++ tests/test_routing.py | 46 ++++ 72 files changed, 2612 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 BEDIENUNGSANLEITUNG.md create mode 100644 Dockerfile create mode 100644 Docs/voice-assistant-architecture.md create mode 100644 Makefile create mode 100644 README.md create mode 100644 app/__init__.py create mode 100644 app/api/__init__.py create mode 100644 app/api/chat.py create mode 100644 app/api/config.py create mode 100644 app/api/devices.py create mode 100644 app/api/health.py create mode 100644 app/api/sessions.py create mode 100644 app/api/speak.py create mode 100644 app/api/transcribe.py create mode 100644 app/audio/__init__.py create mode 100644 app/audio/endpoints/__init__.py create mode 100644 app/audio/endpoints/input/__init__.py create mode 100644 app/audio/endpoints/input/base.py create mode 100644 app/audio/endpoints/input/bluetooth.py create mode 100644 app/audio/endpoints/input/local_default.py create mode 100644 app/audio/endpoints/input/mobile_webrtc.py create mode 100644 app/audio/endpoints/input/mobile_ws.py create mode 100644 app/audio/endpoints/output/__init__.py create mode 100644 app/audio/endpoints/output/base.py create mode 100644 app/audio/endpoints/output/bluetooth.py create mode 100644 app/audio/endpoints/output/local_default.py create mode 100644 app/audio/endpoints/output/loopback.py create mode 100644 app/audio/endpoints/output/mobile_webrtc.py create mode 100644 app/audio/endpoints/output/mobile_ws.py create mode 100644 app/audio/router.py create mode 100644 app/audio/transport_router.py create mode 100644 app/config.py create mode 100644 app/core/__init__.py create mode 100644 app/core/orchestrator.py create mode 100644 app/core/session_manager.py create mode 100644 app/dependencies.py create mode 100644 app/errors.py create mode 100644 app/main.py create mode 100644 app/pipeline/__init__.py create mode 100644 app/pipeline/input_cleaner.py create mode 100644 app/pipeline/spoken_response_adapter.py create mode 100644 app/pipeline/tts_normalizer.py create mode 100644 app/providers/__init__.py create mode 100644 app/providers/llm/__init__.py create mode 100644 app/providers/llm/base.py create mode 100644 app/providers/llm/local_openai_compatible.py create mode 100644 app/providers/llm/openrouter.py create mode 100644 app/providers/stt/__init__.py create mode 100644 app/providers/stt/base.py create mode 100644 app/providers/stt/faster_whisper.py create mode 100644 app/providers/stt/openrouter.py create mode 100644 app/providers/tts/__init__.py create mode 100644 app/providers/tts/base.py create mode 100644 app/providers/tts/chatterbox.py create mode 100644 app/providers/tts/openrouter.py create mode 100644 app/providers/tts/piper.py create mode 100644 app/schemas.py create mode 100644 app/utils/__init__.py create mode 100644 chat_client.py create mode 100644 config/voice-assistant.example.toml create mode 100644 deploy/voice-assistant.env.example create mode 100644 deploy/voice-assistant.service create mode 100644 docker-compose.yml create mode 100644 pyproject.toml create mode 100644 tests/conftest.py create mode 100644 tests/test_audio_router.py create mode 100644 tests/test_basic_layout.py create mode 100644 tests/test_config_profiles.py create mode 100644 tests/test_endpoints_e2e.py create mode 100644 tests/test_routing.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5f554e4 --- /dev/null +++ b/.env.example @@ -0,0 +1,31 @@ +# 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= + +# --- 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 +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 +LOCAL_LLM_BASE_URL=http://127.0.0.1:11434/v1 +LOCAL_LLM_API_KEY=dummy +LOCAL_LLM_MODEL=llama3.1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a8cfe85 --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +# Secrets / lokale Konfiguration +.env + +# Lokale/instanzspezifische Konfiguration (nur die *.example.toml wird versioniert) +config/voice-assistant.toml + +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +.venv/ +venv/ +.pytest_cache/ + +# Lokale Tool-/Editor-Konfiguration +.claude/ + +# Editor-/Backup-Reste +*.bak +*.patch +*.orig diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md new file mode 100644 index 0000000..6f5d1ee --- /dev/null +++ b/BEDIENUNGSANLEITUNG.md @@ -0,0 +1,223 @@ +# Bedienungsanleitung — Voice Assistant Gateway + +Diese Anleitung führt Schritt für Schritt durch Installation, Start, Konfiguration +und Fehlerbehebung. Technische Hintergründe stehen im +[Architektur-Dokument](Docs/voice-assistant-architecture.md), eine kompakte +Übersicht im [README](README.md). + +--- + +## 1. Voraussetzungen + +- **Python 3.11 oder neuer** (`python3 --version`) +- Ein **OpenRouter-API-Key** — nur nötig, wenn ein Profil entfernte KI nutzt + (`hybrid`, `cloud`). Für rein lokalen Betrieb (`local-dev`) nicht erforderlich. +- Optional: Docker, falls im Container betrieben. + +--- + +## 2. Installation + +```bash +cd voice-assistant-scaffold +python3 -m venv .venv +source .venv/bin/activate +pip install -U pip +pip install -e .[test] +``` + +Danach die zentrale Konfigurationsdatei anlegen: + +```bash +cp config/voice-assistant.example.toml config/voice-assistant.toml +``` + +--- + +## 3. API-Key hinterlegen (für Cloud/Hybrid) + +Der Schlüssel wird **aus der Umgebung** gelesen und gehört **nicht** in eine Datei. +Dauerhaft am besten in `~/.bashrc`: + +```bash +echo 'export OPENROUTER_API_KEY=sk-or-v1-DEIN_KEY' >> ~/.bashrc +chmod 600 ~/.bashrc +source ~/.bashrc +``` + +Prüfen, ob er ankommt: + +```bash +echo ${OPENROUTER_API_KEY:0:8} # zeigt nur den Anfang +``` + +> **Sicherheit:** Den Key niemals in `.env` oder `config/*.toml` schreiben. Wird ein +> Key versehentlich öffentlich, im OpenRouter-Dashboard löschen (= widerrufen) und +> neu erzeugen. + +--- + +## 4. Betriebsart (Profil) wählen + +Profile bestimmen, welche KI-Module genutzt werden: + +| Profil | Bedeutung | Key nötig? | +|-------------|--------------------------------------------|------------| +| `local-dev` | alles lokal (eigene KI/Hardware) | nein | +| `hybrid` | STT/TTS über Cloud, Haupt-LLM lokal | ja | +| `cloud` | alles über OpenRouter (Standardbetrieb) | ja | + +Profil **einmalig** für einen Start: + +```bash +VA_PROFILE=cloud make run +``` + +Profil **dauerhaft** — in `.env` eintragen: + +``` +VA_PROFILE=cloud +``` + +> Hinweis: Stehen in `.env` noch `DEFAULT_STT_PROVIDER` / `DEFAULT_LLM_PROVIDER` / +> `DEFAULT_TTS_PROVIDER`, überschreiben diese das Profil. Für profilbasiertes +> Umschalten sollten sie auskommentiert sein. + +--- + +## 5. Starten und Stoppen + +```bash +make run +``` + +Standard-Adresse: `http://localhost:8080` (Port änderbar, siehe Abschnitt 8). +Beenden mit **Strg + C**. + +Schnelltest in einem zweiten Terminal: + +```bash +curl http://localhost:8080/health +# {"status":"ok"} + +curl http://localhost:8080/api/config +# zeigt aktives Profil und die aufgelöste Standard-Route +``` + +--- + +## 6. Tägliche Bedienung — typische Aufgaben + +### a) Text sprechen lassen (`/api/speak`) + +```bash +curl -X POST http://localhost:8080/api/speak \ + -H 'Content-Type: application/json' \ + -d '{"text":"Guten Morgen, wie geht es Ihnen?"}' \ + --output antwort.pcm +``` + +### b) Chatten (Text rein, gesprochene Antwort raus) (`/api/chat`) + +Nur den Trace als JSON ansehen (ohne Audio): + +```bash +curl -X POST "http://localhost:8080/api/chat?debug=true" \ + -H 'Content-Type: application/json' \ + -d '{"text":"Wie wird das Wetter morgen?"}' +``` + +Komfortabler mit dem mitgelieferten Client (spielt die Antwort ab): + +```bash +python chat_client.py "Erzähl mir einen guten Morgen-Spruch" +``` + +> `chat_client.py` erwartet den Dienst auf Port **8003** — bei Bedarf im Skript +> `GATEWAY_URL` anpassen oder den Dienst mit `PORT=8003 make run` starten. + +### c) Audio transkribieren (`/api/transcribe`) + +```bash +curl -X POST http://localhost:8080/api/transcribe \ + -F "file=@aufnahme.wav" -F "language=de" +``` + +### d) Gerät oder Provider einmalig umstellen (pro Aufruf) + +```bash +curl -X POST http://localhost:8080/api/speak \ + -H 'Content-Type: application/json' \ + -d '{"text":"Test","tts_provider":"piper","output_endpoint":"loopback"}' +``` + +### e) Präferenzen für eine Session festlegen + +```bash +# einmal setzen +curl -X POST http://localhost:8080/api/sessions/oma-anna/route \ + -H 'Content-Type: application/json' \ + -d '{"llm_provider":"openrouter","language":"de"}' + +# danach mit dieser Session nutzen +curl -X POST "http://localhost:8080/api/chat?session_id=oma-anna&debug=true" \ + -H 'Content-Type: application/json' -d '{"text":"Hallo!"}' +``` + +--- + +## 7. Verfügbare Geräte und Bausteine ansehen + +```bash +curl http://localhost:8080/api/devices # Audio-Endpunkte mit Fähigkeiten +curl http://localhost:8080/api/config # Profil, Route, Provider, Endpunkte +``` + +--- + +## 8. Port ändern + +```bash +PORT=8003 make run # einmalig +sed -i 's/^PORT=.*/PORT=8003/' .env # dauerhaft +``` + +--- + +## 9. Mit Docker betreiben + +```bash +export OPENROUTER_API_KEY=sk-or-v1-... +docker compose up --build +``` + +Der Key wird aus der Shell in den Container durchgereicht; fehlt er, bricht der +Start mit klarer Meldung ab. + +--- + +## 10. Fehlerbehebung + +| Symptom | Ursache | Lösung | +|---|---|---| +| `OPENROUTER_API_KEY is empty` / 401 | Key nicht in der Umgebung | `export OPENROUTER_API_KEY=…`, neues Terminal / `source ~/.bashrc` | +| HTTP **422** „Unbekannter …-Provider/Endpunkt" | Tippfehler in `*_provider` / `*_endpoint` | gültige Werte via `GET /api/config` prüfen | +| `VA_PROFILE` wirkt nicht | `DEFAULT_*_PROVIDER` in `.env` überschreibt es | diese Zeilen in `.env` auskommentieren | +| LLM-Timeout / Connection refused (lokal) | lokaler LLM-Server (Port 11434) läuft nicht | LLM-Server starten oder Profil `cloud` wählen | +| `Address already in use` | Port belegt | anderen `PORT` setzen (Abschnitt 8) | +| `chat_client.py` bekommt keine Antwort | Client nutzt Port 8003 | Dienst mit `PORT=8003` starten oder `GATEWAY_URL` anpassen | +| Profil greift nicht / Standardwerte | `config/voice-assistant.toml` fehlt | Datei aus `*.example.toml` kopieren (Abschnitt 2) | + +Logs erscheinen im Terminal, in dem `make run` läuft. Für mehr Details +`LOG_LEVEL=debug` in `.env` setzen. + +--- + +## 11. Tests ausführen + +```bash +make test +``` + +Alle Tests sollten grün sein. Schlägt etwas fehl, gibt die Ausgabe den genauen +Testnamen und die Ursache an. 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/voice-assistant-architecture.md b/Docs/voice-assistant-architecture.md new file mode 100644 index 0000000..97c2c09 --- /dev/null +++ b/Docs/voice-assistant-architecture.md @@ -0,0 +1,220 @@ +# 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 < 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(session_id, overrides)`): Defaults < Session-Route < Request. +Die Route umfasst `input_endpoint`, `output_endpoint`, `stt_provider`, +`llm_provider`, `tts_provider`, `language`. + +### 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, Listen → Sätze, Uhrzeiten erhalten). +- **TTS Normalizer** (`pipeline/tts_normalizer.py`) – Zahlen/Abkürzungen/Einheiten verbal ausformulieren, sprachabhängig (de/en). + +Empfehlung: nicht jede Zwischenstufe braucht ein großes LLM — Cleaner und +Normalizer überwiegend regelbasiert (so heute umgesetzt), Adapter promptbasiert. + +## 6. Orchestrator + +`app/core/orchestrator.py` verbindet Provider, Pipeline und Output-Endpunkt. + +- `chat_text(text, language, voice, output)` → `(trace, audio_bytes)` +- `speak_only(text, voice, language, output)` → `audio_bytes` +- `transcribe_only(audio_bytes, fmt, language, input)` → `trace` + +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) | + +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. + +## 8. Stand der Implementierung + +**Umgesetzt:** FastAPI-Gateway, alle o. g. REST-Endpunkte; OpenRouter-Adapter für +STT (multipart), LLM und TTS; lokaler OpenAI-kompatibler LLM-Adapter; regelbasierte +Pipeline; geschichtete Config + Profile; Registry + einheitliche Route-Auflösung; +Device Router (strikt, Singleton); Output-Lifecycle; 22 automatisierte Tests. + +**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. Lokale Provider `faster-whisper`, `piper`, +`chatterbox` sind Stubs. `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. **Cloud-Fundament:** Authentifizierung + Mehrbenutzer + persistenter Session-/Profil-Store (heute ist `SessionManager` in-memory → nicht skalierend, ohne Auth). +3. **Konversationsgedächtnis:** Verlauf + Langzeit-Präferenzen (heute ist `llm.complete` zustandslos). +4. **Echtzeit:** Streaming-STT/TTS, WebSocket/WebRTC-Endpunkte real, Barge-in, Turn-Manager. +5. **Resilienz:** Fallback-Policy (remote KI fällt aus → lokaler/alternativer Provider), Metriken/Tracing. +6. **Betrieb:** Kosten-/Quota-Kontrolle pro Nutzer; Notfall-/Eskalationskonzept (Senioren-Kontext). +7. **TransportRouter** als eigene lokal/remote-Achse aktivieren. + +**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 +voice-assistant-scaffold/ +├── app/ +│ ├── main.py # FastAPI-App + Router-Registrierung +│ ├── config.py # Settings, TOML-Profile, Präzedenz +│ ├── dependencies.py # Registries, ResolvedRoute, resolve_route, Singleton-Router +│ ├── errors.py # RoutingError -> HTTP 422 +│ ├── schemas.py # Pydantic-Modelle +│ ├── api/ # health, chat, speak, transcribe, devices, sessions, config +│ ├── core/ # orchestrator, session_manager +│ ├── audio/ # router, transport_router, endpoints/input|output/* +│ ├── pipeline/ # input_cleaner, spoken_response_adapter, tts_normalizer +│ └── providers/ # stt/ llm/ tts/ (openrouter + lokale Stubs) +├── config/ # voice-assistant.example.toml (+ lokale .toml, 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/Makefile b/Makefile new file mode 100644 index 0000000..35d0775 --- /dev/null +++ b/Makefile @@ -0,0 +1,28 @@ +ifneq (,$(wildcard ./.env)) +include .env +export +endif + +PORT ?= 8080 +HOST ?= 0.0.0.0 + +.PHONY: ensure-env install run test docker-build + +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/ + +docker-build: + docker build -t voice-assistant-gateway . diff --git a/README.md b/README.md new file mode 100644 index 0000000..73ed35d --- /dev/null +++ b/README.md @@ -0,0 +1,130 @@ +# Voice Assistant Gateway + +Modulares FastAPI-Gateway für einen **seniorengerechten Sprachassistenten** — +cloud-first, aber hybrid/lokal betreibbar, mit austauschbaren Audio-Endpunkten und +STT-/LLM-/TTS-Providern. + +Jede Achse — **Hardware** (Audio In/Out), **Betrieb** (lokal/cloud) und **Software** +(lokale/remote KI) — ist frei konfigurierbar, ohne Code zu ändern. Konzept und +Details: [`Docs/voice-assistant-architecture.md`](Docs/voice-assistant-architecture.md). +Praktische Bedienung: [`BEDIENUNGSANLEITUNG.md`](BEDIENUNGSANLEITUNG.md). + +## Features + +- **Pipeline mit getrennter Semantik/Sprache:** STT → Input-Cleaner → LLM → Spoken-Adapter → TTS-Normalizer → TTS +- **Provider austauschbar** über Registry (OpenRouter remote; faster-whisper/piper/chatterbox als lokale Stubs) +- **Geschichtete Konfiguration** mit Profilen (`local-dev` / `hybrid` / `cloud`) +- **Routing auf jeder Ebene:** Default → Profil → ENV → Session → Request +- **REST-API** für Chat, Transkription, Sprachausgabe, Geräte, Sessions, Config +- **Ohne Secrets im Code** — API-Keys nur über die Umgebung + +## Schnellstart + +```bash +python3 -m venv .venv +source .venv/bin/activate +pip install -U pip +pip install -e .[test] + +cp config/voice-assistant.example.toml config/voice-assistant.toml +export OPENROUTER_API_KEY=sk-or-v1-... # nur für Cloud-/Hybrid-Profile nötig +make run +``` + +Fehlt `.env`, wird sie beim ersten `make run` aus `.env.example` erzeugt. +Die App läuft dann auf `http://localhost:8080` (bzw. dem in `.env` gesetzten `PORT`). + +Kurztest: + +```bash +curl http://localhost:8080/health +curl http://localhost:8080/api/config +``` + +## Konfiguration & Profile + +Höhere Ebene gewinnt: + +``` +eingebaute Defaults < config/voice-assistant.toml (inkl. aktivem Profil) + < ENV / .env < Session-Route < Request +``` + +**Profile** umschalten per Umgebungsvariable (oder dauerhaft in `.env`): + +```bash +VA_PROFILE=local-dev make run # alles lokal (faster-whisper / lokales LLM / piper) +VA_PROFILE=hybrid make run # STT/TTS remote, LLM lokal +VA_PROFILE=cloud make run # alles über OpenRouter +``` + +> Secrets gehören **nicht** in `config/*.toml` — nur in die Umgebung +> (`export OPENROUTER_API_KEY=…`). Gesetzte `DEFAULT_*_PROVIDER`-Werte in `.env` +> überschreiben ein `VA_PROFILE`. + +**Pro Session:** `POST /api/sessions/{id}/route` (`input_endpoint`, `output_endpoint`, +`stt_provider`, `llm_provider`, `tts_provider`, `language`), dann Aufrufe mit `?session_id=…`. + +**Pro Request:** dieselben Felder im Body von `/api/chat` bzw. `/api/speak`. + +Aktive Konfiguration prüfen: `curl http://localhost:8080/api/config`. + +## API-Überblick + +| Methode & Pfad | Zweck | +|---------------------------------|-------| +| `GET /health` | Liveness-Check | +| `POST /api/chat` | Text rein → Audio raus (`?debug=true` → JSON-Trace) | +| `POST /api/speak` | Text rein → TTS-Audio raus | +| `POST /api/transcribe` | Audio-Upload → Transkript | +| `GET /api/devices` | verfügbare Audio-Endpunkte + Capabilities | +| `POST /api/sessions/{id}/route` | Geräte/Provider/Sprache je Session setzen | +| `GET /api/config` | aktives Profil + aufgelöste Route (ohne Secrets) | + +Beispiel (Sprachausgabe an den Test-Loopback, lokaler TTS-Stub): + +```bash +curl -X POST http://localhost:8080/api/speak \ + -H 'Content-Type: application/json' \ + -d '{"text":"Guten Morgen!","tts_provider":"piper","output_endpoint":"loopback"}' +``` + +Unbekannter Endpunkt/Provider → `HTTP 422` mit Klartext-Hinweis. + +## Tests + +```bash +make test # oder: pytest -q +``` + +Abgedeckt: Config-Profile & Präzedenz, Route-Auflösung, Device Router, +End-to-End (Loopback, 422-Fälle, Session-/Request-Override, `/api/config`). + +## Port ändern + +```bash +PORT=8003 make run # einmalig +sed -i 's/^PORT=.*/PORT=8003/' .env # dauerhaft +PORT=8003 docker compose up # mit Docker +``` + +## Deployment + +- **Docker:** `docker compose up --build` (reicht `OPENROUTER_API_KEY` aus der Shell durch) +- **systemd:** Vorlagen unter `deploy/` (`voice-assistant.service`, `voice-assistant.env.example`) + +## Projektstruktur (Kurzform) + +```text +app/ Gateway: config, dependencies, api/, core/, audio/, pipeline/, providers/ +config/ voice-assistant.example.toml (lokale .toml ist gitignored) +deploy/ systemd-Unit + env-Beispiel +tests/ Pytest-Suite +Docs/ Architektur-Dokument +``` + +## Lizenz / Status + +Frühes, aktiv entwickeltes Projektgerüst. Audio-Hardware-/Streaming-Anbindung, +Authentifizierung, Persistenz und Gedächtnis sind als nächste Schritte vorgesehen +(siehe Roadmap im Architektur-Dokument). diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/chat.py b/app/api/chat.py new file mode 100644 index 0000000..300a500 --- /dev/null +++ b/app/api/chat.py @@ -0,0 +1,92 @@ +from io import BytesIO + +from fastapi import APIRouter, HTTPException, Query +from fastapi.responses import JSONResponse, StreamingResponse + +from app.config import settings +from app.errors import RoutingError +from app.dependencies import ( + resolve_route, + build_orchestrator, + resolve_output_endpoint, +) +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", + ), +): + overrides = { + "input_endpoint": payload.input_endpoint, + "output_endpoint": payload.output_endpoint, + "language": payload.language, + "stt_provider": payload.stt_provider, + "llm_provider": payload.llm_provider, + "tts_provider": payload.tts_provider, + } + route = resolve_route(session_id, overrides) + voice = payload.voice or settings.openrouter_tts_voice + + try: + orchestrator = build_orchestrator(route) + output = await resolve_output_endpoint(route) + except RoutingError as exc: + raise HTTPException(status_code=422, detail=str(exc)) + + try: + trace, audio = await orchestrator.chat_text( + payload.text, + language=route.language, + voice=voice, + output=output, + ) + + if debug: + return JSONResponse( + content={ + "ok": True, + "voice": voice, + "route": route.as_dict(), + "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), + } + 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/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/sessions.py b/app/api/sessions.py new file mode 100644 index 0000000..181ff9d --- /dev/null +++ b/app/api/sessions.py @@ -0,0 +1,10 @@ +from fastapi import APIRouter +from app.schemas import SessionRouteRequest +from app.dependencies import session_manager + +router = APIRouter() + +@router.post("/sessions/{session_id}/route") +async def set_session_route(session_id: str, payload: SessionRouteRequest): + session = session_manager.update(session_id, payload.model_dump()) + return {"session_id": session_id, "route": session} diff --git a/app/api/speak.py b/app/api/speak.py new file mode 100644 index 0000000..4573de9 --- /dev/null +++ b/app/api/speak.py @@ -0,0 +1,60 @@ +from io import BytesIO + +from fastapi import APIRouter, HTTPException, Query +from fastapi.responses import StreamingResponse + +from app.config import settings +from app.errors import RoutingError +from app.dependencies import ( + resolve_route, + build_orchestrator, + resolve_output_endpoint, +) +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", + ), +): + overrides = { + "output_endpoint": payload.output_endpoint, + "language": payload.language, + "tts_provider": payload.tts_provider, + } + route = resolve_route(session_id, overrides) + voice = payload.voice or settings.openrouter_tts_voice + + try: + orchestrator = build_orchestrator(route) + output = await resolve_output_endpoint(route) + except RoutingError as exc: + raise HTTPException(status_code=422, detail=str(exc)) + + try: + audio = await orchestrator.speak_only( + payload.text, + voice=voice, + language=route.language, + output=output, + ) + + 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..b727849 --- /dev/null +++ b/app/api/transcribe.py @@ -0,0 +1,50 @@ +from fastapi import APIRouter, File, Form, HTTPException, Query, UploadFile + +from app.errors import RoutingError +from app.dependencies import ( + resolve_route, + build_orchestrator, + resolve_input_endpoint, +) + +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", + ), +): + overrides = { + "input_endpoint": input_endpoint, + "language": language, + "stt_provider": stt_provider, + } + route = resolve_route(session_id, overrides) + + try: + orchestrator = build_orchestrator(route) + source = await resolve_input_endpoint(route) + except RoutingError as exc: + raise HTTPException(status_code=422, 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)) + + return {"route": route.as_dict(), "trace": trace.model_dump()} 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/config.py b/app/config.py new file mode 100644 index 0000000..6d40398 --- /dev/null +++ b/app/config.py @@ -0,0 +1,146 @@ +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_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" + local_llm_base_url: str = "http://127.0.0.1:11434/v1" + local_llm_api_key: str = "dummy" + local_llm_model: str = "llama3.1" + 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/orchestrator.py b/app/core/orchestrator.py new file mode 100644 index 0000000..4ab6322 --- /dev/null +++ b/app/core/orchestrator.py @@ -0,0 +1,105 @@ +from app.schemas import AudioChunk, PipelineTrace + +# 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): + self.stt = stt + self.llm = llm + self.tts = tts + self.input_cleaner = input_cleaner + self.spoken_adapter = spoken_adapter + self.tts_normalizer = tts_normalizer + + 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() + 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) + audio = await self.tts.synthesize(normalized, voice=voice) + 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, + ): + trace = PipelineTrace() + + trace.raw_transcript = text + trace.cleaned_transcript = await self.input_cleaner.run(text or "") + + trace.semantic_response = await self.llm.complete( + trace.cleaned_transcript or "" + ) + if not trace.semantic_response: + raise RuntimeError("LLM returned an empty response") + + trace.spoken_response = await self.spoken_adapter.run( + trace.semantic_response, + language=language, + ) + trace.tts_ready_text = await self.tts_normalizer.run( + trace.spoken_response, + language=language, + ) + + audio = await self.tts.synthesize( + trace.tts_ready_text, + voice=voice, + ) + await self._emit_to_output(audio, output) + return trace, audio diff --git a/app/core/session_manager.py b/app/core/session_manager.py new file mode 100644 index 0000000..821c707 --- /dev/null +++ b/app/core/session_manager.py @@ -0,0 +1,11 @@ +class SessionManager: + def __init__(self): + self._sessions = {} + + def get(self, session_id: str) -> dict: + return self._sessions.setdefault(session_id, {}) + + def update(self, session_id: str, values: dict) -> dict: + session = self.get(session_id) + session.update({k: v for k, v in values.items() if v is not None}) + return session diff --git a/app/dependencies.py b/app/dependencies.py new file mode 100644 index 0000000..1c05482 --- /dev/null +++ b/app/dependencies.py @@ -0,0 +1,187 @@ +from dataclasses import dataclass + +from app.config import Settings, 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.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.core.session_manager import SessionManager + +session_manager = SessionManager() + +# --------------------------------------------------------------------------- +# 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 + ), +} + +TTS_REGISTRY = { + "openrouter": lambda s: OpenRouterTTSProvider( + s.openrouter_api_key, s.openrouter_tts_model, s.openrouter_tts_voice + ), + "chatterbox": lambda s: ChatterboxTTSProvider(), + "piper": lambda s: PiperTTSProvider(), +} + + +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: Settings = settings): + return _from_registry(STT_REGISTRY, name or cfg.default_stt_provider, "STT", cfg) + + +def get_llm_provider(name: str | None = None, cfg: Settings = settings): + return _from_registry(LLM_REGISTRY, name or cfg.default_llm_provider, "LLM", cfg) + + +def get_tts_provider(name: str | None = None, cfg: Settings = 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", +) + + +@dataclass +class ResolvedRoute: + input_endpoint: str + output_endpoint: str + stt_provider: str + llm_provider: str + tts_provider: str + language: str + + 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, + } + + +def get_session_route(session_id: str | None) -> dict: + """Liefert die gespeicherte Route einer Session (leeres dict ohne session_id).""" + return session_manager.get(session_id) if session_id else {} + + +def resolve_route( + session_id: str | None = None, + overrides: dict | None = None, + cfg: Settings = settings, +) -> ResolvedRoute: + """Loest die effektive Route aus Defaults, Session und Request-Overrides auf.""" + 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, + } + + session_route = get_session_route(session_id) + request_overrides = overrides or {} + + for layer in (session_route, request_overrides): + for key in ROUTE_KEYS: + value = layer.get(key) + if value is not None: + resolved[key] = value + + return ResolvedRoute(**resolved) + + +def build_orchestrator(route: ResolvedRoute, cfg: Settings = settings) -> Orchestrator: + return Orchestrator( + stt=get_stt_provider(route.stt_provider, cfg), + llm=get_llm_provider(route.llm_provider, cfg), + tts=get_tts_provider(route.tts_provider, cfg), + input_cleaner=InputCleaner(), + spoken_adapter=SpokenResponseAdapter(), + tts_normalizer=TTSNormalizer(), + ) + + +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..eadbef0 --- /dev/null +++ b/app/main.py @@ -0,0 +1,17 @@ +from fastapi import FastAPI +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 + +app = FastAPI(title="Voice Assistant Gateway") +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") diff --git a/app/pipeline/__init__.py b/app/pipeline/__init__.py new file mode 100644 index 0000000..e69de29 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/spoken_response_adapter.py b/app/pipeline/spoken_response_adapter.py new file mode 100644 index 0000000..7df2114 --- /dev/null +++ b/app/pipeline/spoken_response_adapter.py @@ -0,0 +1,37 @@ +import re + + +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 + + # Listen entschärfen + text = re.sub(r"(?m)^\s*[-•]\s+", "", text) + text = re.sub(r"(?m)^\s*\d+\.\s+", "", 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..34342e1 --- /dev/null +++ b/app/pipeline/tts_normalizer.py @@ -0,0 +1,59 @@ +import re + + +class TTSNormalizer: + async def run(self, text: str, language: str = "de") -> str: + if not text: + return "" + + normalized = text + + if language == "de": + replacements = { + "24/7": "vierundzwanzig sieben", + "&": " und ", + "%": " Prozent", + "€": " Euro", + "$": " Dollar", + "km/h": " Kilometer pro Stunde", + "z.B.": "zum Beispiel", + "bzw.": "beziehungsweise", + "u.a.": "unter anderem", + "ca.": "circa", + } + else: + replacements = { + "24/7": "twenty four seven", + "&": " and ", + "%": " percent", + "€": " euros", + "$": " dollars", + "km/h": " kilometers per hour", + "e.g.": "for example", + "i.e.": "that is", + } + + for old, new in replacements.items(): + normalized = normalized.replace(old, new) + + # Slashes zwischen Wörtern/Zahlen sprachfreundlicher machen + normalized = re.sub(r"(\w)/(\w)", r"\1 oder \2", normalized) + + # Datums-/Versions-/Bereichsstriche etwas entschärfen + normalized = normalized.replace("–", " bis ") + normalized = normalized.replace("—", ", ") + normalized = normalized.replace(" - ", ", ") + + # URLs und E-Mails nicht roh vorlesen + normalized = re.sub(r"https?://\S+", "Link", normalized) + normalized = re.sub(r"\b[\w\.-]+@[\w\.-]+\.\w+\b", "E-Mail-Adresse", normalized) + + # Mehrfache Leerzeichen glätten + normalized = re.sub(r"\s+", " ", normalized).strip() + + if normalized and not normalized.endswith((".", "!", "?")): + normalized += "." + + return normalized + + diff --git a/app/providers/__init__.py b/app/providers/__init__.py new file mode 100644 index 0000000..e69de29 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..2e3517c --- /dev/null +++ b/app/providers/llm/base.py @@ -0,0 +1,5 @@ +from abc import ABC, abstractmethod + +class LLMProvider(ABC): + @abstractmethod + async def complete(self, text: str, session_id: str | None = None) -> str: ... diff --git a/app/providers/llm/local_openai_compatible.py b/app/providers/llm/local_openai_compatible.py new file mode 100644 index 0000000..147a404 --- /dev/null +++ b/app/providers/llm/local_openai_compatible.py @@ -0,0 +1,24 @@ +import httpx +from app.providers.llm.base import LLMProvider + +class LocalOpenAICompatibleLLM(LLMProvider): + def __init__(self, base_url: str, api_key: str, model: str): + self.base_url = base_url.rstrip("/") + self.api_key = api_key + self.model = model + + async def complete(self, text: str, session_id: str | None = None) -> str: + payload = { + "model": self.model, + "messages": [{"role": "user", "content": text}], + "temperature": 0.3, + } + 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=payload, + ) + response.raise_for_status() + data = response.json() + return data["choices"][0]["message"]["content"] diff --git a/app/providers/llm/openrouter.py b/app/providers/llm/openrouter.py new file mode 100644 index 0000000..66f507f --- /dev/null +++ b/app/providers/llm/openrouter.py @@ -0,0 +1,101 @@ +import httpx + +from app.providers.llm.base import LLMProvider + + +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() + + async def complete(self, text: str, session_id: str | None = None) -> str: + 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") + + payload = { + "model": self.model, + "messages": [ + {"role": "system", "content": SYSTEM_PROMPT}, + {"role": "user", "content": text.strip()}, + ], + } + + 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() + 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..79a3844 --- /dev/null +++ b/app/providers/stt/base.py @@ -0,0 +1,5 @@ +from abc import ABC, abstractmethod + +class STTProvider(ABC): + @abstractmethod + async def transcribe(self, audio_bytes: bytes, fmt: str, language: str | None = None) -> str: ... diff --git a/app/providers/stt/faster_whisper.py b/app/providers/stt/faster_whisper.py new file mode 100644 index 0000000..16fd21c --- /dev/null +++ b/app/providers/stt/faster_whisper.py @@ -0,0 +1,5 @@ +from app.providers.stt.base import STTProvider + +class FasterWhisperProvider(STTProvider): + async def transcribe(self, audio_bytes: bytes, fmt: str, language: str | None = None) -> str: + return "[local transcription placeholder]" diff --git a/app/providers/stt/openrouter.py b/app/providers/stt/openrouter.py new file mode 100644 index 0000000..81a3f6a --- /dev/null +++ b/app/providers/stt/openrouter.py @@ -0,0 +1,46 @@ +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") + + # OpenAI-kompatibler /audio/transcriptions-Endpunkt erwartet multipart/form-data + # mit binärem file-Feld, nicht JSON mit base64. + files = {"file": (f"audio.{fmt}", audio_bytes, f"audio/{fmt}")} + data: dict[str, str] = {"model": self.model} + if language: + data["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}"}, + files=files, + data=data, + ) + 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 + + return response.json().get("text", "") 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..8f17bae --- /dev/null +++ b/app/providers/tts/base.py @@ -0,0 +1,5 @@ +from abc import ABC, abstractmethod + +class TTSProvider(ABC): + @abstractmethod + async def synthesize(self, text: str, voice: str | None = None, audio_format: str = "pcm") -> bytes: ... diff --git a/app/providers/tts/chatterbox.py b/app/providers/tts/chatterbox.py new file mode 100644 index 0000000..abdf298 --- /dev/null +++ b/app/providers/tts/chatterbox.py @@ -0,0 +1,5 @@ +from app.providers.tts.base import TTSProvider + +class ChatterboxTTSProvider(TTSProvider): + async def synthesize(self, text: str, voice: str | None = None, audio_format: str = "pcm") -> bytes: + return b"" diff --git a/app/providers/tts/openrouter.py b/app/providers/tts/openrouter.py new file mode 100644 index 0000000..28186d1 --- /dev/null +++ b/app/providers/tts/openrouter.py @@ -0,0 +1,62 @@ +import httpx + +from app.providers.tts.base import TTSProvider + + +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", + ) -> 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: + 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: + raise RuntimeError( + f"OpenRouter TTS error {exc.response.status_code}: {exc.response.text}" + ) from exc + except httpx.TimeoutException as exc: + raise RuntimeError("OpenRouter TTS timeout") from exc + except httpx.HTTPError as exc: + raise RuntimeError(f"OpenRouter TTS transport error: {exc}") from exc + + if not response.content: + raise RuntimeError("OpenRouter TTS returned empty audio content") + + return response.content + diff --git a/app/providers/tts/piper.py b/app/providers/tts/piper.py new file mode 100644 index 0000000..7a8e9b9 --- /dev/null +++ b/app/providers/tts/piper.py @@ -0,0 +1,5 @@ +from app.providers.tts.base import TTSProvider + +class PiperTTSProvider(TTSProvider): + async def synthesize(self, text: str, voice: str | None = None, audio_format: str = "pcm") -> bytes: + return b"" diff --git a/app/schemas.py b/app/schemas.py new file mode 100644 index 0000000..173957b --- /dev/null +++ b/app/schemas.py @@ -0,0 +1,71 @@ +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 + voice: str | None = None + stt_provider: str | None = None + llm_provider: str | None = None + tts_provider: str | None = None + + +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 + diff --git a/app/utils/__init__.py b/app/utils/__init__.py new file mode 100644 index 0000000..e69de29 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/voice-assistant.example.toml b/config/voice-assistant.example.toml new file mode 100644 index 0000000..3a5937c --- /dev/null +++ b/config/voice-assistant.example.toml @@ -0,0 +1,44 @@ +# 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" + +local_llm_base_url = "http://127.0.0.1:11434/v1" +local_llm_model = "llama3.1" + +# 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/deploy/voice-assistant.env.example b/deploy/voice-assistant.env.example new file mode 100644 index 0000000..ce273b5 --- /dev/null +++ b/deploy/voice-assistant.env.example @@ -0,0 +1,3 @@ +HOST=0.0.0.0 +PORT=8080 +OPENROUTER_API_KEY= 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/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..e1d220a --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,28 @@ +[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" +] + +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[tool.setuptools.packages.find] +where = ["."] +include = ["app*"] +exclude = ["deploy*", "tests*"] diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..1879910 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,22 @@ +import pytest + +import app.dependencies as deps + + +@pytest.fixture(autouse=True) +def reset_state(): + """Isoliert den Singleton-Audio-Router (Loopback-Buffer) und Sessions je Test.""" + deps._audio_router = None + deps.session_manager._sessions.clear() + yield + deps._audio_router = None + deps.session_manager._sessions.clear() + + +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_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_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_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_endpoints_e2e.py b/tests/test_endpoints_e2e.py new file mode 100644 index 0000000..17949da --- /dev/null +++ b/tests/test_endpoints_e2e.py @@ -0,0 +1,96 @@ +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 test_speak_loopback_collects_chunks(): + # piper-Stub liefert b"" -> kein Netzcall; Loopback sammelt den Chunk. + resp = client.post( + "/api/speak", + json={"text": "Hallo Welt", "tts_provider": "piper", "output_endpoint": "loopback"}, + ) + assert resp.status_code == 200 + assert resp.headers["X-Output-Endpoint"] == "loopback" + assert resp.headers["X-TTS-Provider"] == "piper" + 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(): + client.post( + "/api/sessions/s1/route", + json={"tts_provider": "piper", "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"] == "piper" + + +def test_chat_per_request_override_and_loopback(monkeypatch): + class StubLLM: + async def complete(self, text, session_id=None): + return "Mir geht es gut, danke." + + class StubTTS: + async def synthesize(self, text, voice=None, audio_format="pcm"): + 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_local_provider(): + files = {"file": ("a.wav", b"RIFFdata", "audio/wav")} + data = {"stt_provider": "faster-whisper"} + resp = client.post("/api/transcribe", data=data, files=files) + assert resp.status_code == 200 + body = resp.json() + assert body["route"]["stt_provider"] == "faster-whisper" + assert body["trace"]["raw_transcript"] == "[local transcription placeholder]" + + +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_routing.py b/tests/test_routing.py new file mode 100644 index 0000000..89f3c67 --- /dev/null +++ b/tests/test_routing.py @@ -0,0 +1,46 @@ +import pytest + +from app.config import Settings +from app.errors import UnknownComponentError +from app.dependencies import ( + resolve_route, + get_llm_provider, + get_tts_provider, + session_manager, +) + + +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_session_then_request_precedence(): + session_manager.update("s_test", {"tts_provider": "piper", "language": "en"}) + route = resolve_route("s_test") + assert route.tts_provider == "piper" + assert route.language == "en" + + # Request schlaegt Session. + route2 = resolve_route("s_test", {"tts_provider": "chatterbox"}) + assert route2.tts_provider == "chatterbox" + assert route2.language == "en" + + +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" From e0e69fdf15cc63dace3ba4b3bb47c09676e198fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Wed, 17 Jun 2026 02:14:25 +0200 Subject: [PATCH 002/113] feat: Cloud-Fundament - Auth, Persistenz und Mandanten-Trennung - SQLite-Store (app/store.py) hinter Store-Interface: Nutzer + Sessions persistent - Bearer-Token-Auth (app/auth.py); Nutzerverwaltung via Admin-Key (POST /api/admin/users) - GET /api/me, PUT /api/me/prefs (dauerhafte Nutzer-Praeferenzen) - chat/speak/transcribe/sessions auth-geschuetzt; Mandanten-Trennung (fremde Session -> 403) - Route-Aufloesung: Defaults < Profil < ENV < Nutzer-Prefs < Session < Request - SessionManager (in-memory) durch Store ersetzt - AUTH_ENABLED-Schalter (prod an, dev/Tests aus); DB_PATH/ADMIN_API_KEY - Doku aktualisiert (README, BEDIENUNGSANLEITUNG, Architektur, deploy-env); data/ gitignored - Tests: 29 gruen (Auth, Mandanten, Persistenz, Routing) Co-Authored-By: Claude Opus 4.8 --- .env.example | 7 + .gitignore | 3 + BEDIENUNGSANLEITUNG.md | 61 ++++++++- Docs/voice-assistant-architecture.md | 28 ++-- README.md | 31 ++++- app/api/admin.py | 14 ++ app/api/chat.py | 9 +- app/api/me.py | 21 +++ app/api/sessions.py | 21 ++- app/api/speak.py | 9 +- app/api/transcribe.py | 9 +- app/auth.py | 36 +++++ app/config.py | 3 + app/core/session_manager.py | 11 -- app/dependencies.py | 46 +++++-- app/main.py | 4 + app/schemas.py | 19 +++ app/store.py | 197 +++++++++++++++++++++++++++ deploy/voice-assistant.env.example | 9 ++ tests/conftest.py | 15 +- tests/test_auth.py | 91 +++++++++++++ tests/test_routing.py | 38 ++++-- 22 files changed, 625 insertions(+), 57 deletions(-) create mode 100644 app/api/admin.py create mode 100644 app/api/me.py create mode 100644 app/auth.py delete mode 100644 app/core/session_manager.py create mode 100644 app/store.py create mode 100644 tests/test_auth.py diff --git a/.env.example b/.env.example index 5f554e4..491b84a 100644 --- a/.env.example +++ b/.env.example @@ -7,6 +7,13 @@ LOG_LEVEL=info # Secret nur ueber die Umgebung setzen (nicht hier eintragen), z. B. export in ~/.bashrc OPENROUTER_API_KEY= +# --- 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= + # --- 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. diff --git a/.gitignore b/.gitignore index a8cfe85..5d1581d 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,9 @@ # Lokale/instanzspezifische Konfiguration (nur die *.example.toml wird versioniert) config/voice-assistant.toml +# Persistente Daten (SQLite-DB etc.) +data/ + # Python __pycache__/ *.py[cod] diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index 6f5d1ee..20082c6 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -196,11 +196,66 @@ Start mit klarer Meldung ab. --- -## 10. Fehlerbehebung +## 10. Authentifizierung & Mehrbenutzer + +Im Produktivbetrieb ist `AUTH_ENABLED=true` (Standard). Dann brauchen +`chat`/`speak`/`transcribe`/`sessions`/`me` ein **Bearer-Token pro Nutzer**. +Nutzer und Sessions werden in einer SQLite-Datei gespeichert (`DB_PATH`, Standard +`data/voice-assistant.db`). + +**Schritt 1 — Admin-Schlüssel setzen** (nur über die Umgebung): + +```bash +export ADMIN_API_KEY=ein-langes-geheimnis +``` + +**Schritt 2 — Nutzer anlegen** (Token erscheint **nur einmal**, sicher notieren): + +```bash +curl -X POST http://localhost:8080/api/admin/users \ + -H "X-Admin-Key: $ADMIN_API_KEY" \ + -H 'Content-Type: application/json' \ + -d '{"display_name":"Oma Anna"}' +``` + +**Schritt 3 — mit Token nutzen:** + +```bash +TOKEN= +curl http://localhost:8080/api/me -H "Authorization: Bearer $TOKEN" + +curl -X POST http://localhost:8080/api/speak \ + -H "Authorization: Bearer $TOKEN" \ + -H 'Content-Type: application/json' \ + -d '{"text":"Guten Morgen!"}' +``` + +**Dauerhafte Vorlieben** eines Nutzers (Gerät/Provider/Sprache) setzen: + +```bash +curl -X PUT http://localhost:8080/api/me/prefs \ + -H "Authorization: Bearer $TOKEN" \ + -H 'Content-Type: application/json' \ + -d '{"language":"de","llm_provider":"openrouter"}' +``` + +Diese Vorlieben gelten automatisch für alle Aufrufe dieses Nutzers (Ebene zwischen +Profil und Session). Eine fremde Session zu nutzen, wird mit `403` abgelehnt. + +> **Für lokale Entwicklung** ist in der mitgelieferten `.env` `AUTH_ENABLED=false` +> gesetzt — dann ist kein Token nötig (anonymer Nutzer). + +--- + +## 11. Fehlerbehebung | Symptom | Ursache | Lösung | |---|---|---| -| `OPENROUTER_API_KEY is empty` / 401 | Key nicht in der Umgebung | `export OPENROUTER_API_KEY=…`, neues Terminal / `source ~/.bashrc` | +| `OPENROUTER_API_KEY is empty` | Key nicht in der Umgebung | `export OPENROUTER_API_KEY=…`, neues Terminal / `source ~/.bashrc` | +| HTTP **401** „Bearer token required/Invalid token" | Auth an, Token fehlt/falsch | gültiges Token im Header `Authorization: Bearer …`, oder `AUTH_ENABLED=false` für dev | +| HTTP **401** bei `/api/admin/users` | falscher/fehlender Admin-Key | `X-Admin-Key` mit `ADMIN_API_KEY` abgleichen | +| HTTP **403** bei `?session_id=…` | Session gehört anderem Nutzer | eigene `session_id` verwenden | +| HTTP **503** bei `/api/admin/users` | `ADMIN_API_KEY` nicht gesetzt | Admin-Key in der Umgebung setzen | | HTTP **422** „Unbekannter …-Provider/Endpunkt" | Tippfehler in `*_provider` / `*_endpoint` | gültige Werte via `GET /api/config` prüfen | | `VA_PROFILE` wirkt nicht | `DEFAULT_*_PROVIDER` in `.env` überschreibt es | diese Zeilen in `.env` auskommentieren | | LLM-Timeout / Connection refused (lokal) | lokaler LLM-Server (Port 11434) läuft nicht | LLM-Server starten oder Profil `cloud` wählen | @@ -213,7 +268,7 @@ Logs erscheinen im Terminal, in dem `make run` läuft. Für mehr Details --- -## 11. Tests ausführen +## 12. Tests ausführen ```bash make test diff --git a/Docs/voice-assistant-architecture.md b/Docs/voice-assistant-architecture.md index 97c2c09..f4eef5b 100644 --- a/Docs/voice-assistant-architecture.md +++ b/Docs/voice-assistant-architecture.md @@ -38,7 +38,7 @@ einstellbar; höhere Ebene gewinnt: ``` eingebaute Defaults < config/voice-assistant.toml (inkl. aktivem Profil) - < ENV / .env < Session-Route < Request + < ENV / .env < Nutzer-Prefs < Session-Route < Request ``` ### 3.1 Zentrale Config + Profile @@ -64,9 +64,10 @@ werden aus echter Umgebung **oder** `.env` gelesen. ### 3.2 Einheitliche Route-Auflösung `app/dependencies.py` löst pro Aufruf eine `ResolvedRoute` auf -(`resolve_route(session_id, overrides)`): Defaults < Session-Route < Request. -Die Route umfasst `input_endpoint`, `output_endpoint`, `stt_provider`, -`llm_provider`, `tts_provider`, `language`. +(`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) @@ -162,6 +163,8 @@ ein No-op; `LoopbackOutput` sammelt die Chunks (testbar ohne Hardware). | `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) | +| `POST /api/admin/users` | Nutzer anlegen (Admin-Key) → Token einmalig | +| `GET /api/me` · `PUT /api/me/prefs` | aktueller Nutzer + dauerhafte Präferenzen | Endpunkt-/Provider-Auswahl ist über **Request-Body** (pro Aufruf), **Session** (`?session_id=…`) und **Defaults/Profil** steuerbar. Verwendete Route erscheint als @@ -172,7 +175,9 @@ Endpunkt-/Provider-Auswahl ist über **Request-Body** (pro Aufruf), **Session** **Umgesetzt:** FastAPI-Gateway, alle o. g. REST-Endpunkte; OpenRouter-Adapter für STT (multipart), LLM und TTS; lokaler OpenAI-kompatibler LLM-Adapter; regelbasierte Pipeline; geschichtete Config + Profile; Registry + einheitliche Route-Auflösung; -Device Router (strikt, Singleton); Output-Lifecycle; 22 automatisierte Tests. +Device Router (strikt, Singleton); Output-Lifecycle; **Authentifizierung +(Bearer-Token) + persistenter SQLite-Store für Nutzer/Sessions + Mandanten-Trennung ++ dauerhafte Nutzer-Präferenzen**; automatisierte Tests. **Platzhalter (Gerüst):** Audio-Endpunkte (`local-default`, `bluetooth`, `mobile-ws`, `mobile-webrtc`) liefern leere Chunks — nur Auswahl/Lifecycle sind @@ -185,8 +190,8 @@ nicht aktiv (lokal/remote trägt vorerst der Provider-Name). Reihenfolge der Weiterentwicklung: 1. **(erledigt)** Konfig- & Routing-Fundament: Profile, Device Router, Registry, Pro-Request-Override. -2. **Cloud-Fundament:** Authentifizierung + Mehrbenutzer + persistenter Session-/Profil-Store (heute ist `SessionManager` in-memory → nicht skalierend, ohne Auth). -3. **Konversationsgedächtnis:** Verlauf + Langzeit-Präferenzen (heute ist `llm.complete` zustandslos). +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. **Konversationsgedächtnis:** Verlauf + Langzeit-Erinnerungen (heute ist `llm.complete` zustandslos; Nutzer-Präferenzen sind als Basis vorhanden). 4. **Echtzeit:** Streaming-STT/TTS, WebSocket/WebRTC-Endpunkte real, Barge-in, Turn-Manager. 5. **Resilienz:** Fallback-Policy (remote KI fällt aus → lokaler/alternativer Provider), Metriken/Tracing. 6. **Betrieb:** Kosten-/Quota-Kontrolle pro Nutzer; Notfall-/Eskalationskonzept (Senioren-Kontext). @@ -203,15 +208,18 @@ voice-assistant-scaffold/ ├── app/ │ ├── main.py # FastAPI-App + Router-Registrierung │ ├── config.py # Settings, TOML-Profile, Präzedenz -│ ├── dependencies.py # Registries, ResolvedRoute, resolve_route, Singleton-Router +│ ├── dependencies.py # Registries, ResolvedRoute, resolve_route, Store-/Router-Singleton +│ ├── store.py # Persistenz: Store-Interface + SQLiteStore (Nutzer/Sessions) +│ ├── auth.py # Bearer-Token-Auth (require_user) + Admin-Schutz │ ├── errors.py # RoutingError -> HTTP 422 │ ├── schemas.py # Pydantic-Modelle -│ ├── api/ # health, chat, speak, transcribe, devices, sessions, config -│ ├── core/ # orchestrator, session_manager +│ ├── api/ # health, chat, speak, transcribe, devices, sessions, config, admin, me +│ ├── core/ # orchestrator │ ├── audio/ # router, transport_router, endpoints/input|output/* │ ├── pipeline/ # input_cleaner, spoken_response_adapter, tts_normalizer │ └── providers/ # stt/ llm/ tts/ (openrouter + lokale Stubs) ├── 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 diff --git a/README.md b/README.md index 73ed35d..a7e9471 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,8 @@ Praktische Bedienung: [`BEDIENUNGSANLEITUNG.md`](BEDIENUNGSANLEITUNG.md). - **Pipeline mit getrennter Semantik/Sprache:** STT → Input-Cleaner → LLM → Spoken-Adapter → TTS-Normalizer → TTS - **Provider austauschbar** über Registry (OpenRouter remote; faster-whisper/piper/chatterbox als lokale Stubs) - **Geschichtete Konfiguration** mit Profilen (`local-dev` / `hybrid` / `cloud`) -- **Routing auf jeder Ebene:** Default → Profil → ENV → Session → Request +- **Routing auf jeder Ebene:** Default → Profil → Nutzer → Session → Request +- **Authentifizierung** (Bearer-Token) + persistente Nutzer/Sessions (SQLite) - **REST-API** für Chat, Transkription, Sprachausgabe, Geräte, Sessions, Config - **Ohne Secrets im Code** — API-Keys nur über die Umgebung @@ -80,6 +81,9 @@ Aktive Konfiguration prüfen: `curl http://localhost:8080/api/config`. | `GET /api/devices` | verfügbare Audio-Endpunkte + Capabilities | | `POST /api/sessions/{id}/route` | Geräte/Provider/Sprache je Session setzen | | `GET /api/config` | aktives Profil + aufgelöste Route (ohne Secrets) | +| `POST /api/admin/users` | Nutzer anlegen (Admin-Key) → Token einmalig | +| `GET /api/me` | aktueller Nutzer + Präferenzen | +| `PUT /api/me/prefs` | dauerhafte Routing-Präferenzen des Nutzers setzen | Beispiel (Sprachausgabe an den Test-Loopback, lokaler TTS-Stub): @@ -91,6 +95,31 @@ curl -X POST http://localhost:8080/api/speak \ Unbekannter Endpunkt/Provider → `HTTP 422` mit Klartext-Hinweis. +## Authentifizierung + +Standardmäßig (`AUTH_ENABLED=true`) sind `chat`/`speak`/`transcribe`/`sessions`/`me` +durch ein **Bearer-Token pro Nutzer** geschützt. Nutzer/Sessions werden in SQLite +persistiert (`DB_PATH`, Default `data/voice-assistant.db`). + +```bash +# 1) Nutzer anlegen (Admin-Key aus der Umgebung) — Token erscheint EINMALIG +export ADMIN_API_KEY=ein-langes-geheimnis +curl -X POST http://localhost:8080/api/admin/users \ + -H "X-Admin-Key: $ADMIN_API_KEY" \ + -H 'Content-Type: application/json' \ + -d '{"display_name":"Oma Anna"}' +# -> {"user_id":"…","display_name":"Oma Anna","token":"…"} + +# 2) Mit dem Token aufrufen +curl http://localhost:8080/api/me -H "Authorization: Bearer " +``` + +Dauerhafte Präferenzen pro Nutzer (`PUT /api/me/prefs`) fließen in die Route-Auflösung +ein (Ebene zwischen Profil und Session). Fremde Sessions → `HTTP 403`. + +> **Lokale Entwicklung:** `AUTH_ENABLED=false` setzen — dann gilt ein anonymer +> Standardnutzer und es ist kein Token nötig. + ## Tests ```bash diff --git a/app/api/admin.py b/app/api/admin.py new file mode 100644 index 0000000..c69ee72 --- /dev/null +++ b/app/api/admin.py @@ -0,0 +1,14 @@ +from fastapi import APIRouter, Depends + +from app.auth import require_admin +from app.dependencies import get_store +from app.schemas import UserCreate, UserCreated + +router = APIRouter() + + +@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) diff --git a/app/api/chat.py b/app/api/chat.py index 300a500..be40b2a 100644 --- a/app/api/chat.py +++ b/app/api/chat.py @@ -1,10 +1,12 @@ from io import BytesIO -from fastapi import APIRouter, HTTPException, Query +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 User, SessionOwnershipError from app.dependencies import ( resolve_route, build_orchestrator, @@ -36,6 +38,7 @@ async def chat( default=None, description="Optional session id to apply a stored route", ), + user: User = Depends(require_user), ): overrides = { "input_endpoint": payload.input_endpoint, @@ -45,12 +48,14 @@ async def chat( "llm_provider": payload.llm_provider, "tts_provider": payload.tts_provider, } - route = resolve_route(session_id, overrides) voice = payload.voice or settings.openrouter_tts_voice try: + route = resolve_route(user, session_id, overrides) 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)) diff --git a/app/api/me.py b/app/api/me.py new file mode 100644 index 0000000..816a606 --- /dev/null +++ b/app/api/me.py @@ -0,0 +1,21 @@ +from fastapi import APIRouter, Depends + +from app.auth import require_user +from app.dependencies import get_store +from app.schemas import UserPrefs +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, "prefs": user.prefs} + + +@router.put("/me/prefs") +async def set_my_prefs(payload: UserPrefs, user: User = Depends(require_user)): + """Setzt die dauerhaften Routing-Praeferenzen des Nutzers (nur gesetzte Felder).""" + prefs = {k: v for k, v in payload.model_dump().items() if v is not None} + updated = get_store().set_user_prefs(user.id, prefs) + return {"user_id": updated.id, "prefs": updated.prefs} diff --git a/app/api/sessions.py b/app/api/sessions.py index 181ff9d..c5ac2e1 100644 --- a/app/api/sessions.py +++ b/app/api/sessions.py @@ -1,10 +1,21 @@ -from fastapi import APIRouter +from fastapi import APIRouter, Depends, HTTPException + from app.schemas import SessionRouteRequest -from app.dependencies import session_manager +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): - session = session_manager.update(session_id, payload.model_dump()) - return {"session_id": session_id, "route": session} +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 index 4573de9..7cdff77 100644 --- a/app/api/speak.py +++ b/app/api/speak.py @@ -1,10 +1,12 @@ from io import BytesIO -from fastapi import APIRouter, HTTPException, Query +from fastapi import APIRouter, Depends, HTTPException, Query from fastapi.responses import StreamingResponse from app.config import settings 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, @@ -22,18 +24,21 @@ async def speak( 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, } - route = resolve_route(session_id, overrides) voice = payload.voice or settings.openrouter_tts_voice try: + route = resolve_route(user, session_id, overrides) 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)) diff --git a/app/api/transcribe.py b/app/api/transcribe.py index b727849..24ad4ae 100644 --- a/app/api/transcribe.py +++ b/app/api/transcribe.py @@ -1,6 +1,8 @@ -from fastapi import APIRouter, File, Form, HTTPException, Query, UploadFile +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, @@ -20,17 +22,20 @@ async def transcribe( 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, } - route = resolve_route(session_id, overrides) 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)) diff --git a/app/auth.py b/app/auth.py new file mode 100644 index 0000000..93407d3 --- /dev/null +++ b/app/auth.py @@ -0,0 +1,36 @@ +from fastapi import Header, HTTPException + +from app.config import settings +from app.dependencies import get_store +from app.store import User + + +def require_user(authorization: str | None = Header(default=None)) -> User: + """FastAPI-Dependency: liefert den authentifizierten Nutzer. + + Bei AUTH_ENABLED=false gilt ein anonymer Standardnutzer (dev/Test). Sonst ist + ein gueltiges Bearer-Token noetig (sonst 401). + """ + store = get_store() + if not settings.auth_enabled: + return store.ensure_anonymous_user() + + if not authorization or not authorization.lower().startswith("bearer "): + raise HTTPException(status_code=401, detail="Bearer token required") + + token = authorization.split(" ", 1)[1].strip() + user = store.get_user_by_token(token) + if user is None: + raise HTTPException(status_code=401, detail="Invalid token") + return user + + +def require_admin(x_admin_key: str | None = Header(default=None)) -> None: + """Schuetzt die Nutzerverwaltung ueber ADMIN_API_KEY (aus der Umgebung).""" + expected = settings.admin_api_key.strip() + if not expected: + raise HTTPException( + status_code=503, detail="Admin API not configured (ADMIN_API_KEY unset)" + ) + if not x_admin_key or x_admin_key.strip() != expected: + raise HTTPException(status_code=401, detail="Invalid admin key") diff --git a/app/config.py b/app/config.py index 6d40398..64dbcbc 100644 --- a/app/config.py +++ b/app/config.py @@ -120,6 +120,9 @@ class Settings(BaseSettings): local_llm_base_url: str = "http://127.0.0.1:11434/v1" local_llm_api_key: str = "dummy" local_llm_model: str = "llama3.1" + db_path: str = str(BASE_DIR / "data" / "voice-assistant.db") + admin_api_key: str = "" + auth_enabled: bool = True model_config = SettingsConfigDict( env_file=ENV_FILE, case_sensitive=False, extra="ignore" ) diff --git a/app/core/session_manager.py b/app/core/session_manager.py deleted file mode 100644 index 821c707..0000000 --- a/app/core/session_manager.py +++ /dev/null @@ -1,11 +0,0 @@ -class SessionManager: - def __init__(self): - self._sessions = {} - - def get(self, session_id: str) -> dict: - return self._sessions.setdefault(session_id, {}) - - def update(self, session_id: str, values: dict) -> dict: - session = self.get(session_id) - session.update({k: v for k, v in values.items() if v is not None}) - return session diff --git a/app/dependencies.py b/app/dependencies.py index 1c05482..51c68c6 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -23,9 +23,20 @@ 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.core.session_manager import SessionManager +from app.store import SQLiteStore, Store, User -session_manager = SessionManager() +# --------------------------------------------------------------------------- +# 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. @@ -136,17 +147,35 @@ class ResolvedRoute: } -def get_session_route(session_id: str | None) -> dict: - """Liefert die gespeicherte Route einer Session (leeres dict ohne session_id).""" - return session_manager.get(session_id) if session_id else {} +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: Settings = settings, ) -> ResolvedRoute: - """Loest die effektive Route aus Defaults, Session und Request-Overrides auf.""" + """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, @@ -156,10 +185,11 @@ def resolve_route( "language": cfg.default_language, } - session_route = get_session_route(session_id) + 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 (session_route, request_overrides): + for layer in (user_prefs, session_route, request_overrides): for key in ROUTE_KEYS: value = layer.get(key) if value is not None: diff --git a/app/main.py b/app/main.py index eadbef0..adcc928 100644 --- a/app/main.py +++ b/app/main.py @@ -6,6 +6,8 @@ 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 app = FastAPI(title="Voice Assistant Gateway") app.include_router(health_router) @@ -15,3 +17,5 @@ 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") diff --git a/app/schemas.py b/app/schemas.py index 173957b..c8df4ad 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -69,3 +69,22 @@ class RouteInfo(BaseModel): 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 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 + diff --git a/app/store.py b/app/store.py new file mode 100644 index 0000000..98d61fb --- /dev/null +++ b/app/store.py @@ -0,0 +1,197 @@ +"""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 = "" + + +@dataclass +class Session: + id: str + user_id: str + data: dict = field(default_factory=dict) + + +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 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.""" + + +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 + ); + 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 + ); + """ + ) + + # ----- Nutzer ----------------------------------------------------------- + def _row_to_user(self, row: sqlite3.Row) -> User: + return User( + id=row["id"], + display_name=row["display_name"], + prefs=json.loads(row["prefs_json"] or "{}"), + created_at=row["created_at"], + ) + + 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) + + # ----- 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) diff --git a/deploy/voice-assistant.env.example b/deploy/voice-assistant.env.example index ce273b5..578603b 100644 --- a/deploy/voice-assistant.env.example +++ b/deploy/voice-assistant.env.example @@ -1,3 +1,12 @@ HOST=0.0.0.0 PORT=8080 OPENROUTER_API_KEY= + +# Profil und Konfiguration +VA_PROFILE=cloud + +# Authentifizierung (Produktion: an). ADMIN_API_KEY fuer die Nutzerverwaltung. +AUTH_ENABLED=true +ADMIN_API_KEY= +# Persistente Datenbank (Pfad auf dem Host) +DB_PATH=/var/lib/voice-assistant/voice-assistant.db diff --git a/tests/conftest.py b/tests/conftest.py index 1879910..e1bfbef 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,16 +1,23 @@ import pytest import app.dependencies as deps +from app.config import settings +from app.store import SQLiteStore @pytest.fixture(autouse=True) -def reset_state(): - """Isoliert den Singleton-Audio-Router (Loopback-Buffer) und Sessions je Test.""" +def reset_state(tmp_path, monkeypatch): + """Pro Test: frische SQLite-DB, frischer Singleton-Audio-Router, Auth aus. + + Auth-Tests schalten `settings.auth_enabled` selbst wieder ein. + """ + deps._store = SQLiteStore(str(tmp_path / "test.db")) deps._audio_router = None - deps.session_manager._sessions.clear() + monkeypatch.setattr(settings, "auth_enabled", False) + monkeypatch.setattr(settings, "admin_api_key", "") yield + deps._store = None deps._audio_router = None - deps.session_manager._sessions.clear() def loopback_output(): diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..4442b96 --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,91 @@ +from fastapi.testclient import TestClient + +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) + token = _create_user("Pref") + auth = {"Authorization": f"Bearer {token}"} + + client.put( + "/api/me/prefs", + headers=auth, + json={"tts_provider": "piper", "output_endpoint": "loopback"}, + ) + resp = client.post("/api/speak", headers=auth, json={"text": "hallo"}) + assert resp.status_code == 200 + assert resp.headers["X-TTS-Provider"] == "piper" + assert resp.headers["X-Output-Endpoint"] == "loopback" + + +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_routing.py b/tests/test_routing.py index 89f3c67..4eeab76 100644 --- a/tests/test_routing.py +++ b/tests/test_routing.py @@ -2,11 +2,12 @@ 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, - session_manager, + get_store, ) @@ -25,16 +26,35 @@ def test_request_overrides_win(): assert route.output_endpoint == "loopback" -def test_session_then_request_precedence(): - session_manager.update("s_test", {"tts_provider": "piper", "language": "en"}) - route = resolve_route("s_test") - assert route.tts_provider == "piper" +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" - # Request schlaegt Session. - route2 = resolve_route("s_test", {"tts_provider": "chatterbox"}) - assert route2.tts_provider == "chatterbox" - assert route2.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(): From 16a964032e18ab825c4198c612698c586d6cd39a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Wed, 17 Jun 2026 04:16:35 +0200 Subject: [PATCH 003/113] feat: Konversationsgedaechtnis - Kurzzeit-Gespraechsverlauf pro Session - Store: messages-Tabelle + append_message/get_recent_messages (mit Mandanten-Schutz) - LLMProvider.complete um history erweitert (openrouter + local bauen system+history+user) - Orchestrator.chat_text reicht den Verlauf ans LLM weiter - chat.py: bei session_id letzte HISTORY_MAX_MESSAGES laden, danach User-/Assistant-Turn speichern - ohne session_id weiterhin zustandslos; ?debug zeigt history_len - Config HISTORY_MAX_MESSAGES (Default 10) - Tests: 32 gruen (3 neue Gedaechtnis-Tests) - Doku aktualisiert (README, BEDIENUNGSANLEITUNG, Architektur) Co-Authored-By: Claude Opus 4.8 --- BEDIENUNGSANLEITUNG.md | 13 ++++ Docs/voice-assistant-architecture.md | 14 ++-- README.md | 15 +++++ app/api/chat.py | 69 ++++++++++++-------- app/config.py | 1 + app/core/orchestrator.py | 4 +- app/providers/llm/base.py | 7 +- app/providers/llm/local_openai_compatible.py | 11 +++- app/providers/llm/openrouter.py | 17 +++-- app/store.py | 49 ++++++++++++++ tests/test_endpoints_e2e.py | 2 +- tests/test_memory.py | 65 ++++++++++++++++++ 12 files changed, 226 insertions(+), 41 deletions(-) create mode 100644 tests/test_memory.py diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index 20082c6..599eedb 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -136,6 +136,19 @@ python chat_client.py "Erzähl mir einen guten Morgen-Spruch" > `chat_client.py` erwartet den Dienst auf Port **8003** — bei Bedarf im Skript > `GATEWAY_URL` anpassen oder den Dienst mit `PORT=8003 make run` starten. +**Fortlaufendes Gespräch (Gedächtnis):** Wird eine `session_id` mitgegeben, merkt +sich der Assistent den Verlauf und bezieht ihn in die nächste Antwort ein: + +```bash +curl -X POST "http://localhost:8080/api/chat?session_id=oma-anna&debug=true" \ + -H 'Content-Type: application/json' -d '{"text":"Ich heiße Anna."}' +curl -X POST "http://localhost:8080/api/chat?session_id=oma-anna&debug=true" \ + -H 'Content-Type: application/json' -d '{"text":"Wie war noch mein Name?"}' +``` + +Ohne `session_id` ist jeder Aufruf eigenständig (kein Gedächtnis). Wie viele +zurückliegende Nachrichten einfließen, steuert `HISTORY_MAX_MESSAGES` (Standard 10). + ### c) Audio transkribieren (`/api/transcribe`) ```bash diff --git a/Docs/voice-assistant-architecture.md b/Docs/voice-assistant-architecture.md index f4eef5b..ab153a2 100644 --- a/Docs/voice-assistant-architecture.md +++ b/Docs/voice-assistant-architecture.md @@ -143,10 +143,15 @@ Normalizer überwiegend regelbasiert (so heute umgesetzt), Adapter promptbasiert `app/core/orchestrator.py` verbindet Provider, Pipeline und Output-Endpunkt. -- `chat_text(text, language, voice, output)` → `(trace, audio_bytes)` +- `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 @@ -177,7 +182,8 @@ STT (multipart), LLM und TTS; lokaler OpenAI-kompatibler LLM-Adapter; regelbasie 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**; automatisierte Tests. ++ dauerhafte Nutzer-Präferenzen**; **Gesprächsgedächtnis pro Session (Verlauf im +Store, fließt ins LLM)**; automatisierte Tests. **Platzhalter (Gerüst):** Audio-Endpunkte (`local-default`, `bluetooth`, `mobile-ws`, `mobile-webrtc`) liefern leere Chunks — nur Auswahl/Lifecycle sind @@ -191,7 +197,7 @@ 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. **Konversationsgedächtnis:** Verlauf + Langzeit-Erinnerungen (heute ist `llm.complete` zustandslos; Nutzer-Präferenzen sind als Basis vorhanden). +3. **(teilweise erledigt)** Konversationsgedächtnis: Kurzzeit-Gesprächsverlauf pro Session ist umgesetzt (Store + ans LLM). Offen: **Langzeit-Erinnerungen** über Sessions hinweg (Fakten/Vorlieben, ggf. LLM-gestützt extrahiert und zusammengefasst). 4. **Echtzeit:** Streaming-STT/TTS, WebSocket/WebRTC-Endpunkte real, Barge-in, Turn-Manager. 5. **Resilienz:** Fallback-Policy (remote KI fällt aus → lokaler/alternativer Provider), Metriken/Tracing. 6. **Betrieb:** Kosten-/Quota-Kontrolle pro Nutzer; Notfall-/Eskalationskonzept (Senioren-Kontext). @@ -209,7 +215,7 @@ voice-assistant-scaffold/ │ ├── 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) +│ ├── store.py # Persistenz: Store-Interface + SQLiteStore (Nutzer/Sessions/Verlauf) │ ├── auth.py # Bearer-Token-Auth (require_user) + Admin-Schutz │ ├── errors.py # RoutingError -> HTTP 422 │ ├── schemas.py # Pydantic-Modelle diff --git a/README.md b/README.md index a7e9471..26ad6ec 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ Praktische Bedienung: [`BEDIENUNGSANLEITUNG.md`](BEDIENUNGSANLEITUNG.md). - **Geschichtete Konfiguration** mit Profilen (`local-dev` / `hybrid` / `cloud`) - **Routing auf jeder Ebene:** Default → Profil → Nutzer → Session → Request - **Authentifizierung** (Bearer-Token) + persistente Nutzer/Sessions (SQLite) +- **Gesprächsgedächtnis pro Session:** Verlauf wird gespeichert und fließt ins LLM - **REST-API** für Chat, Transkription, Sprachausgabe, Geräte, Sessions, Config - **Ohne Secrets im Code** — API-Keys nur über die Umgebung @@ -95,6 +96,20 @@ curl -X POST http://localhost:8080/api/speak \ Unbekannter Endpunkt/Provider → `HTTP 422` mit Klartext-Hinweis. +## Gesprächsgedächtnis + +Wird bei `/api/chat` eine `session_id` mitgegeben, merkt sich der Assistent den +Verlauf: vergangene Turns werden gespeichert und beim nächsten Aufruf ans LLM +gegeben (begrenzt auf die letzten `HISTORY_MAX_MESSAGES` Nachrichten, Default 10). +Ohne `session_id` bleibt der Aufruf zustandslos. + +```bash +curl -X POST "http://localhost:8080/api/chat?session_id=oma-anna&debug=true" \ + -H 'Content-Type: application/json' -d '{"text":"Ich heiße Anna."}' +curl -X POST "http://localhost:8080/api/chat?session_id=oma-anna&debug=true" \ + -H 'Content-Type: application/json' -d '{"text":"Wie war noch mein Name?"}' +``` + ## Authentifizierung Standardmäßig (`AUTH_ENABLED=true`) sind `chat`/`speak`/`transcribe`/`sessions`/`me` diff --git a/app/api/chat.py b/app/api/chat.py index be40b2a..38009ea 100644 --- a/app/api/chat.py +++ b/app/api/chat.py @@ -11,6 +11,7 @@ from app.dependencies import ( resolve_route, build_orchestrator, resolve_output_endpoint, + get_store, ) from app.schemas import ChatRequest @@ -50,10 +51,18 @@ async def chat( } voice = payload.voice or settings.openrouter_tts_voice + 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). + history = ( + store.get_recent_messages(session_id, settings.history_max_messages) + if session_id + else [] + ) except SessionOwnershipError as exc: raise HTTPException(status_code=403, detail=str(exc)) except RoutingError as exc: @@ -65,33 +74,39 @@ async def chat( language=route.language, voice=voice, output=output, + history=history, ) - - if debug: - return JSONResponse( - content={ - "ok": True, - "voice": voice, - "route": route.as_dict(), - "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), - } - return StreamingResponse(BytesIO(audio), media_type="audio/pcm", headers=headers) - except Exception as exc: raise HTTPException(status_code=502, detail=str(exc)) + + # 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) + + if debug: + return JSONResponse( + content={ + "ok": True, + "voice": voice, + "route": route.as_dict(), + "history_len": len(history), + "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), + } + return StreamingResponse(BytesIO(audio), media_type="audio/pcm", headers=headers) diff --git a/app/config.py b/app/config.py index 64dbcbc..58b8926 100644 --- a/app/config.py +++ b/app/config.py @@ -123,6 +123,7 @@ class Settings(BaseSettings): db_path: str = str(BASE_DIR / "data" / "voice-assistant.db") admin_api_key: str = "" auth_enabled: bool = True + history_max_messages: int = 10 model_config = SettingsConfigDict( env_file=ENV_FILE, case_sensitive=False, extra="ignore" ) diff --git a/app/core/orchestrator.py b/app/core/orchestrator.py index 4ab6322..213fe3f 100644 --- a/app/core/orchestrator.py +++ b/app/core/orchestrator.py @@ -76,6 +76,7 @@ class Orchestrator: language: str | None = None, voice: str | None = None, output=None, + history: list[dict] | None = None, ): trace = PipelineTrace() @@ -83,7 +84,8 @@ class Orchestrator: trace.cleaned_transcript = await self.input_cleaner.run(text or "") trace.semantic_response = await self.llm.complete( - trace.cleaned_transcript or "" + trace.cleaned_transcript or "", + history=history, ) if not trace.semantic_response: raise RuntimeError("LLM returned an empty response") diff --git a/app/providers/llm/base.py b/app/providers/llm/base.py index 2e3517c..5a72add 100644 --- a/app/providers/llm/base.py +++ b/app/providers/llm/base.py @@ -2,4 +2,9 @@ from abc import ABC, abstractmethod class LLMProvider(ABC): @abstractmethod - async def complete(self, text: str, session_id: str | None = None) -> str: ... + async def complete( + self, + text: str, + history: list[dict] | None = None, + session_id: str | None = None, + ) -> str: ... diff --git a/app/providers/llm/local_openai_compatible.py b/app/providers/llm/local_openai_compatible.py index 147a404..99d946b 100644 --- a/app/providers/llm/local_openai_compatible.py +++ b/app/providers/llm/local_openai_compatible.py @@ -7,10 +7,17 @@ class LocalOpenAICompatibleLLM(LLMProvider): self.api_key = api_key self.model = model - async def complete(self, text: str, session_id: str | None = None) -> str: + async def complete( + self, + text: str, + history: list[dict] | None = None, + session_id: str | None = None, + ) -> str: + messages = list(history) if history else [] + messages.append({"role": "user", "content": text}) payload = { "model": self.model, - "messages": [{"role": "user", "content": text}], + "messages": messages, "temperature": 0.3, } async with httpx.AsyncClient(timeout=120) as client: diff --git a/app/providers/llm/openrouter.py b/app/providers/llm/openrouter.py index 66f507f..c4143dd 100644 --- a/app/providers/llm/openrouter.py +++ b/app/providers/llm/openrouter.py @@ -49,7 +49,12 @@ class OpenRouterLLMProvider(LLMProvider): self.api_key = (api_key or "").strip() self.model = (model or "").strip() - async def complete(self, text: str, session_id: str | None = None) -> str: + async def complete( + self, + text: str, + history: list[dict] | None = None, + session_id: str | None = None, + ) -> str: if not self.api_key: raise ValueError("OPENROUTER_API_KEY is empty") if not self.model: @@ -57,12 +62,14 @@ class OpenRouterLLMProvider(LLMProvider): if not text or not text.strip(): raise ValueError("LLM input text is empty") + messages = [{"role": "system", "content": SYSTEM_PROMPT}] + if history: + messages.extend(history) + messages.append({"role": "user", "content": text.strip()}) + payload = { "model": self.model, - "messages": [ - {"role": "system", "content": SYSTEM_PROMPT}, - {"role": "user", "content": text.strip()}, - ], + "messages": messages, } timeout = httpx.Timeout(connect=10.0, read=120.0, write=30.0, pool=10.0) diff --git a/app/store.py b/app/store.py index 98d61fb..15421d9 100644 --- a/app/store.py +++ b/app/store.py @@ -70,6 +70,14 @@ class Store(ABC): 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'}, ...]).""" + class SQLiteStore(Store): def __init__(self, db_path: str): @@ -102,6 +110,15 @@ class SQLiteStore(Store): 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); """ ) @@ -195,3 +212,35 @@ class SQLiteStore(Store): (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)] diff --git a/tests/test_endpoints_e2e.py b/tests/test_endpoints_e2e.py index 17949da..a8efd5b 100644 --- a/tests/test_endpoints_e2e.py +++ b/tests/test_endpoints_e2e.py @@ -48,7 +48,7 @@ def test_session_route_applies(): def test_chat_per_request_override_and_loopback(monkeypatch): class StubLLM: - async def complete(self, text, session_id=None): + async def complete(self, text, history=None, session_id=None): return "Mir geht es gut, danke." class StubTTS: diff --git a/tests/test_memory.py b/tests/test_memory.py new file mode 100644 index 0000000..73a824b --- /dev/null +++ b/tests/test_memory.py @@ -0,0 +1,65 @@ +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): + captured["history"] = list(history or []) + return f"Antwort auf: {text}" + + class StubTTS: + async def synthesize(self, text, voice=None, audio_format="pcm"): + 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_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"} From 531b57e08d4cbb2fb6873583180628774a7d0db4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Wed, 17 Jun 2026 04:26:55 +0200 Subject: [PATCH 004/113] feat: Langzeit-Erinnerungen (#3b) und WebSocket-Streaming-Chat (#4, erster Increment) #3b Langzeit-Erinnerungen: - Store: memories-Tabelle + add/get/delete_memory (pro Nutzer) - API: GET/POST/DELETE /api/me/memories - chat.py injiziert Nutzer-Erinnerungen als System-Kontext ins LLM (sessionunabhaengig) - ?debug zeigt memories_len #4 Echtzeit (erster Increment): - WS /ws/chat: dauerhafter Kanal, Event-Folge ack -> semantic -> audio (binaer) -> done - Auth (Token-Query), Session-Gedaechtnis und Erinnerungen wie bei POST /api/chat - Fehler als error-Event (422/403/502) - Tests: 38 gruen (Erinnerungs-CRUD/Injektion, WebSocket-Streaming/Auth) - Doku aktualisiert (README, BEDIENUNGSANLEITUNG, Architektur) Co-Authored-By: Claude Opus 4.8 --- BEDIENUNGSANLEITUNG.md | 19 ++++ Docs/voice-assistant-architecture.md | 11 ++- README.md | 25 ++++++ app/api/chat.py | 16 +++- app/api/me.py | 22 ++++- app/api/ws.py | 126 +++++++++++++++++++++++++++ app/main.py | 2 + app/schemas.py | 10 +++ app/store.py | 57 ++++++++++++ tests/test_auth.py | 26 ++++++ tests/test_memory.py | 16 ++++ tests/test_ws.py | 68 +++++++++++++++ 12 files changed, 389 insertions(+), 9 deletions(-) create mode 100644 app/api/ws.py create mode 100644 tests/test_ws.py diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index 599eedb..5a613e6 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -255,6 +255,25 @@ curl -X PUT http://localhost:8080/api/me/prefs \ Diese Vorlieben gelten automatisch für alle Aufrufe dieses Nutzers (Ebene zwischen Profil und Session). Eine fremde Session zu nutzen, wird mit `403` abgelehnt. +**Langzeit-Erinnerungen** (dauerhafte Fakten/Vorlieben, gelten über alle Gespräche): + +```bash +# anlegen +curl -X POST http://localhost:8080/api/me/memories \ + -H "Authorization: Bearer $TOKEN" \ + -H 'Content-Type: application/json' -d '{"content":"Mag morgens Kamillentee."}' +# auflisten / löschen +curl http://localhost:8080/api/me/memories -H "Authorization: Bearer $TOKEN" +curl -X DELETE http://localhost:8080/api/me/memories/1 -H "Authorization: Bearer $TOKEN" +``` + +Diese Erinnerungen gibt der Assistent bei jedem Chat als Kontext mit — auch ohne +`session_id`. + +**Echtzeit-Chat über WebSocket** (`/ws/chat`): dauerhafter Kanal, pro Nachricht +`{"text": "..."}`; Antwort kommt als Event-Folge (`ack`, `semantic`, Audio, `done`). +Token per Query (`?token=…`), Gedächtnis per `?session_id=…`. + > **Für lokale Entwicklung** ist in der mitgelieferten `.env` `AUTH_ENABLED=false` > gesetzt — dann ist kein Token nötig (anonymer Nutzer). diff --git a/Docs/voice-assistant-architecture.md b/Docs/voice-assistant-architecture.md index ab153a2..a13744d 100644 --- a/Docs/voice-assistant-architecture.md +++ b/Docs/voice-assistant-architecture.md @@ -170,6 +170,8 @@ ein No-op; `LoopbackOutput` sammelt die Chunks (testbar ohne Hardware). | `GET /api/config` | aktives Profil + aufgelöste Route (ohne Secrets) | | `POST /api/admin/users` | Nutzer anlegen (Admin-Key) → Token einmalig | | `GET /api/me` · `PUT /api/me/prefs` | aktueller Nutzer + dauerhafte Präferenzen | +| `GET/POST/DELETE /api/me/memories` | Langzeit-Erinnerungen des Nutzers | +| `WS /ws/chat` | Echtzeit-Chat (Streaming-Events über WebSocket) | Endpunkt-/Provider-Auswahl ist über **Request-Body** (pro Aufruf), **Session** (`?session_id=…`) und **Defaults/Profil** steuerbar. Verwendete Route erscheint als @@ -183,7 +185,8 @@ Pipeline; geschichtete Config + Profile; Registry + einheitliche Route-Auflösun 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)**; automatisierte Tests. +Store, fließt ins LLM)**; **Langzeit-Erinnerungen pro Nutzer (als LLM-Kontext)**; +**WebSocket-Streaming-Chat (`/ws/chat`)**; automatisierte Tests. **Platzhalter (Gerüst):** Audio-Endpunkte (`local-default`, `bluetooth`, `mobile-ws`, `mobile-webrtc`) liefern leere Chunks — nur Auswahl/Lifecycle sind @@ -197,8 +200,8 @@ 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. **(teilweise erledigt)** Konversationsgedächtnis: Kurzzeit-Gesprächsverlauf pro Session ist umgesetzt (Store + ans LLM). Offen: **Langzeit-Erinnerungen** über Sessions hinweg (Fakten/Vorlieben, ggf. LLM-gestützt extrahiert und zusammengefasst). -4. **Echtzeit:** Streaming-STT/TTS, WebSocket/WebRTC-Endpunkte real, Barge-in, Turn-Manager. +3. **(erledigt)** Konversationsgedächtnis: Kurzzeit-Gesprächsverlauf pro Session + Langzeit-Erinnerungen pro Nutzer (manuell gepflegt, als LLM-Kontext). Offen: **automatische** Extraktion/Zusammenfassung von Erinnerungen aus Gesprächen. +4. **(teilweise erledigt)** Echtzeit: WebSocket-Streaming-Chat (`/ws/chat`) mit Event-Folge (ack/semantic/audio/done) ist umgesetzt. Offen: **Token-Level-LLM-Streaming**, **Audio-Eingang/Streaming-STT**, **Barge-in/Turn-Manager**, **WebRTC**. 5. **Resilienz:** Fallback-Policy (remote KI fällt aus → lokaler/alternativer Provider), Metriken/Tracing. 6. **Betrieb:** Kosten-/Quota-Kontrolle pro Nutzer; Notfall-/Eskalationskonzept (Senioren-Kontext). 7. **TransportRouter** als eigene lokal/remote-Achse aktivieren. @@ -219,7 +222,7 @@ voice-assistant-scaffold/ │ ├── auth.py # Bearer-Token-Auth (require_user) + Admin-Schutz │ ├── errors.py # RoutingError -> HTTP 422 │ ├── schemas.py # Pydantic-Modelle -│ ├── api/ # health, chat, speak, transcribe, devices, sessions, config, admin, me +│ ├── api/ # health, chat, speak, transcribe, devices, sessions, config, admin, me, ws │ ├── core/ # orchestrator │ ├── audio/ # router, transport_router, endpoints/input|output/* │ ├── pipeline/ # input_cleaner, spoken_response_adapter, tts_normalizer diff --git a/README.md b/README.md index 26ad6ec..4ca0bcc 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,8 @@ Praktische Bedienung: [`BEDIENUNGSANLEITUNG.md`](BEDIENUNGSANLEITUNG.md). - **Routing auf jeder Ebene:** Default → Profil → Nutzer → Session → Request - **Authentifizierung** (Bearer-Token) + persistente Nutzer/Sessions (SQLite) - **Gesprächsgedächtnis pro Session:** Verlauf wird gespeichert und fließt ins LLM +- **Langzeit-Erinnerungen pro Nutzer:** dauerhafte Fakten/Vorlieben als LLM-Kontext +- **WebSocket-Streaming-Chat** (`/ws/chat`) als Echtzeit-Transport - **REST-API** für Chat, Transkription, Sprachausgabe, Geräte, Sessions, Config - **Ohne Secrets im Code** — API-Keys nur über die Umgebung @@ -85,6 +87,8 @@ Aktive Konfiguration prüfen: `curl http://localhost:8080/api/config`. | `POST /api/admin/users` | Nutzer anlegen (Admin-Key) → Token einmalig | | `GET /api/me` | aktueller Nutzer + Präferenzen | | `PUT /api/me/prefs` | dauerhafte Routing-Präferenzen des Nutzers setzen | +| `GET/POST/DELETE /api/me/memories` | Langzeit-Erinnerungen des Nutzers verwalten | +| `WS /ws/chat` | Echtzeit-Chat über WebSocket (Streaming-Events) | Beispiel (Sprachausgabe an den Test-Loopback, lokaler TTS-Stub): @@ -110,6 +114,27 @@ curl -X POST "http://localhost:8080/api/chat?session_id=oma-anna&debug=true" \ -H 'Content-Type: application/json' -d '{"text":"Wie war noch mein Name?"}' ``` +**Langzeit-Erinnerungen** (über Sessions hinweg, pro Nutzer) werden über +`/api/me/memories` gepflegt und bei jedem Chat als Kontext ans LLM gegeben — auch +ohne `session_id`: + +```bash +curl -X POST http://localhost:8080/api/me/memories \ + -H "Authorization: Bearer $TOKEN" \ + -H 'Content-Type: application/json' -d '{"content":"Mag morgens Kamillentee."}' +``` + +## Echtzeit-Chat (WebSocket) + +`/ws/chat` bietet einen dauerhaften, bidirektionalen Kanal. Der Client sendet pro +Turn eine JSON-Nachricht (`{"text": "..."}`, optional Provider/Endpunkt-Overrides), +der Server streamt strukturierte Events zurück: `ack` → `semantic` → Audio (binär) +→ `done`. Auth (Token-Query `?token=…`), Session-Gedächtnis (`?session_id=…`) und +Erinnerungen gelten wie bei `POST /api/chat`. + +> Token-Level-LLM-Streaming, Audio-Eingang/Streaming-STT, Barge-in und WebRTC sind +> als nächste Increments vorgesehen (siehe Architektur-Dokument). + ## Authentifizierung Standardmäßig (`AUTH_ENABLED=true`) sind `chat`/`speak`/`transcribe`/`sessions`/`me` diff --git a/app/api/chat.py b/app/api/chat.py index 38009ea..25fd247 100644 --- a/app/api/chat.py +++ b/app/api/chat.py @@ -58,23 +58,32 @@ async def chat( orchestrator = build_orchestrator(route) output = await resolve_output_endpoint(route) # Gespraechsverlauf laden (nur bei gesetzter session_id -> sonst zustandslos). - history = ( + 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) + if memories: + memory_text = "Was du ueber den Nutzer weisst:\n" + "\n".join( + f"- {m.content}" for m in memories + ) + llm_context = [{"role": "system", "content": memory_text}] + llm_context + try: trace, audio = await orchestrator.chat_text( payload.text, language=route.language, voice=voice, output=output, - history=history, + history=llm_context, ) except Exception as exc: raise HTTPException(status_code=502, detail=str(exc)) @@ -90,7 +99,8 @@ async def chat( "ok": True, "voice": voice, "route": route.as_dict(), - "history_len": len(history), + "history_len": len(conversation), + "memories_len": len(memories), "trace": { "raw_transcript": trace.raw_transcript, "cleaned_transcript": trace.cleaned_transcript, diff --git a/app/api/me.py b/app/api/me.py index 816a606..95855e8 100644 --- a/app/api/me.py +++ b/app/api/me.py @@ -1,8 +1,8 @@ -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException from app.auth import require_user from app.dependencies import get_store -from app.schemas import UserPrefs +from app.schemas import UserPrefs, MemoryCreate, MemoryOut from app.store import User router = APIRouter() @@ -19,3 +19,21 @@ async def set_my_prefs(payload: UserPrefs, user: User = Depends(require_user)): prefs = {k: v for k, v in payload.model_dump().items() if v is not None} updated = get_store().set_user_prefs(user.id, prefs) 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/ws.py b/app/api/ws.py new file mode 100644 index 0000000..6c505bc --- /dev/null +++ b/app/api/ws.py @@ -0,0 +1,126 @@ +"""WebSocket-Streaming-Chat (Echtzeit-Transport, erster Increment). + +Etabliert einen dauerhaften, bidirektionalen Kanal: der Client schickt pro Turn +eine JSON-Nachricht, der Server streamt strukturierte Events zurueck +(ack -> semantic -> audio (binaer) -> done). Auth, Session-Gedaechtnis und +Langzeit-Erinnerungen gelten wie bei POST /api/chat. + +Bewusst spaeter (eigene Increments): Token-Level-LLM-Streaming, Audio-Eingang/ +Streaming-STT, Barge-in/Interrupt und WebRTC. +""" + +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, +) +from app.store import SessionOwnershipError + +router = APIRouter() + + +def _authenticate(token: str | None): + store = get_store() + if not settings.auth_enabled: + return store.ensure_anonymous_user() + if not token: + return None + return store.get_user_by_token(token) + + +@router.websocket("/ws/chat") +async def ws_chat( + websocket: WebSocket, + session_id: str | None = None, + token: str | None = None, +): + user = _authenticate(token) + if user is None: + # Vor accept() schliessen -> Handshake wird mit 403 abgelehnt. + await websocket.close(code=1008) + return + + await websocket.accept() + store = get_store() + + try: + while True: + msg = await websocket.receive_json() + text = (msg.get("text") or "").strip() + if not text: + await websocket.send_json({"type": "error", "detail": "empty text"}) + continue + + overrides = { + key: msg.get(key) + for key in ( + "input_endpoint", + "output_endpoint", + "language", + "stt_provider", + "llm_provider", + "tts_provider", + ) + } + + try: + route = resolve_route(user, session_id, overrides) + orchestrator = build_orchestrator(route) + output = await resolve_output_endpoint(route) + conversation = ( + store.get_recent_messages(session_id, settings.history_max_messages) + if session_id + else [] + ) + memories = store.get_memories(user.id) + except SessionOwnershipError as exc: + await websocket.send_json({"type": "error", "status": 403, "detail": str(exc)}) + continue + except RoutingError as exc: + await websocket.send_json({"type": "error", "status": 422, "detail": str(exc)}) + continue + + llm_context = list(conversation) + if memories: + memory_text = "Was du ueber den Nutzer weisst:\n" + "\n".join( + f"- {m.content}" for m in memories + ) + llm_context = [{"role": "system", "content": memory_text}] + llm_context + + await websocket.send_json({"type": "ack", "route": route.as_dict()}) + + voice = msg.get("voice") or settings.openrouter_tts_voice + try: + trace, audio = await orchestrator.chat_text( + text, + language=route.language, + voice=voice, + output=output, + history=llm_context, + ) + except Exception as exc: + await websocket.send_json({"type": "error", "status": 502, "detail": str(exc)}) + continue + + if session_id: + store.append_message(session_id, user.id, "user", text) + store.append_message(session_id, user.id, "assistant", trace.semantic_response) + + await websocket.send_json( + { + "type": "semantic", + "text": trace.semantic_response, + "spoken": trace.spoken_response, + } + ) + await websocket.send_bytes(audio) + await websocket.send_json( + {"type": "done", "audio_format": "pcm", "sample_rate": 24000} + ) + except WebSocketDisconnect: + return diff --git a/app/main.py b/app/main.py index adcc928..961b113 100644 --- a/app/main.py +++ b/app/main.py @@ -8,6 +8,7 @@ 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.ws import router as ws_router app = FastAPI(title="Voice Assistant Gateway") app.include_router(health_router) @@ -19,3 +20,4 @@ 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(ws_router) diff --git a/app/schemas.py b/app/schemas.py index c8df4ad..15c193a 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -88,3 +88,13 @@ class UserPrefs(BaseModel): tts_provider: str | None = None language: str | 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 index 15421d9..b939323 100644 --- a/app/store.py +++ b/app/store.py @@ -42,6 +42,13 @@ class Session: 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).""" @@ -78,6 +85,18 @@ class Store(ABC): 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.""" + class SQLiteStore(Store): def __init__(self, db_path: str): @@ -119,6 +138,14 @@ class SQLiteStore(Store): ); 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); """ ) @@ -244,3 +271,33 @@ class SQLiteStore(Store): (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 diff --git a/tests/test_auth.py b/tests/test_auth.py index 4442b96..ba3959b 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -81,6 +81,32 @@ def test_user_prefs_applied_to_route(monkeypatch): 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) diff --git a/tests/test_memory.py b/tests/test_memory.py index 73a824b..d29d260 100644 --- a/tests/test_memory.py +++ b/tests/test_memory.py @@ -50,6 +50,22 @@ def test_no_session_id_is_stateless(monkeypatch): 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 diff --git a/tests/test_ws.py b/tests/test_ws.py new file mode 100644 index 0000000..082e0fa --- /dev/null +++ b/tests/test_ws.py @@ -0,0 +1,68 @@ +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): + captured["history"] = list(history or []) + return f"Echo: {text}" + + class StubTTS: + async def synthesize(self, text, voice=None, audio_format="pcm"): + 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 From 379e002460b463d561b5a8b576161ccff4399d79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Wed, 17 Jun 2026 04:37:37 +0200 Subject: [PATCH 005/113] =?UTF-8?q?feat:=20Token-Level-LLM-Streaming=20?= =?UTF-8?q?=C3=BCber=20WebSocket=20(#4=20Ausbau)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LLMProvider.stream: Basis-Default (Fallback über complete) + SSE-Streaming fuer OpenRouter und lokalen OpenAI-kompatiblen Provider; gemeinsamer Parser sse_delta - Orchestrator.chat_stream: LLM-Token live via on_token-Callback, danach Spoken-Adapter/Normalizer/TTS/Output; Fallback fuer Provider ohne stream - WS /ws/chat: opt-in {"stream":true} -> ack -> token* -> semantic -> audio -> done - Tests: 43 gruen (+5: SSE-Parsing, Default-Fallback, WS-Token-Flow) - Doku aktualisiert; .gitignore: *.wav (generierte Audio-Ausgaben) Co-Authored-By: Claude Opus 4.8 --- .gitignore | 3 + BEDIENUNGSANLEITUNG.md | 4 +- Docs/voice-assistant-architecture.md | 5 +- README.md | 10 ++- app/api/ws.py | 28 ++++-- app/core/orchestrator.py | 49 ++++++++++ app/providers/llm/base.py | 29 ++++++ app/providers/llm/local_openai_compatible.py | 45 +++++++++- app/providers/llm/openrouter.py | 59 ++++++++++-- tests/test_streaming.py | 94 ++++++++++++++++++++ 10 files changed, 302 insertions(+), 24 deletions(-) create mode 100644 tests/test_streaming.py diff --git a/.gitignore b/.gitignore index 5d1581d..fdc2a92 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,9 @@ config/voice-assistant.toml # Persistente Daten (SQLite-DB etc.) data/ +# Generierte Audio-Ausgaben (z. B. chat_client.py) +*.wav + # Python __pycache__/ *.py[cod] diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index 5a613e6..d49d4ff 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -272,7 +272,9 @@ Diese Erinnerungen gibt der Assistent bei jedem Chat als Kontext mit — auch oh **Echtzeit-Chat über WebSocket** (`/ws/chat`): dauerhafter Kanal, pro Nachricht `{"text": "..."}`; Antwort kommt als Event-Folge (`ack`, `semantic`, Audio, `done`). -Token per Query (`?token=…`), Gedächtnis per `?session_id=…`. +Token per Query (`?token=…`), Gedächtnis per `?session_id=…`. Mit +`{"text": "...", "stream": true}` kommt die Antwort schon während der Generierung +als `token`-Events (geringere wahrgenommene Latenz). > **Für lokale Entwicklung** ist in der mitgelieferten `.env` `AUTH_ENABLED=false` > gesetzt — dann ist kein Token nötig (anonymer Nutzer). diff --git a/Docs/voice-assistant-architecture.md b/Docs/voice-assistant-architecture.md index a13744d..da60379 100644 --- a/Docs/voice-assistant-architecture.md +++ b/Docs/voice-assistant-architecture.md @@ -186,7 +186,8 @@ 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`)**; automatisierte Tests. +**WebSocket-Streaming-Chat (`/ws/chat`) inkl. Token-Level-LLM-Streaming (SSE, +opt-in via `stream:true`)**; automatisierte Tests. **Platzhalter (Gerüst):** Audio-Endpunkte (`local-default`, `bluetooth`, `mobile-ws`, `mobile-webrtc`) liefern leere Chunks — nur Auswahl/Lifecycle sind @@ -201,7 +202,7 @@ 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 gepflegt, als LLM-Kontext). Offen: **automatische** Extraktion/Zusammenfassung von Erinnerungen aus Gesprächen. -4. **(teilweise erledigt)** Echtzeit: WebSocket-Streaming-Chat (`/ws/chat`) mit Event-Folge (ack/semantic/audio/done) ist umgesetzt. Offen: **Token-Level-LLM-Streaming**, **Audio-Eingang/Streaming-STT**, **Barge-in/Turn-Manager**, **WebRTC**. +4. **(teilweise erledigt)** Echtzeit: WebSocket-Streaming-Chat (`/ws/chat`) mit Event-Folge (ack/semantic/audio/done) **und Token-Level-LLM-Streaming (SSE, opt-in `stream:true`)** sind umgesetzt. Offen: **Audio-Streaming (chunked TTS)**, **Audio-Eingang/Streaming-STT**, **Barge-in/Turn-Manager**, **WebRTC**. 5. **Resilienz:** Fallback-Policy (remote KI fällt aus → lokaler/alternativer Provider), Metriken/Tracing. 6. **Betrieb:** Kosten-/Quota-Kontrolle pro Nutzer; Notfall-/Eskalationskonzept (Senioren-Kontext). 7. **TransportRouter** als eigene lokal/remote-Achse aktivieren. diff --git a/README.md b/README.md index 4ca0bcc..74be773 100644 --- a/README.md +++ b/README.md @@ -132,8 +132,14 @@ der Server streamt strukturierte Events zurück: `ack` → `semantic` → Audio → `done`. Auth (Token-Query `?token=…`), Session-Gedächtnis (`?session_id=…`) und Erinnerungen gelten wie bei `POST /api/chat`. -> Token-Level-LLM-Streaming, Audio-Eingang/Streaming-STT, Barge-in und WebRTC sind -> als nächste Increments vorgesehen (siehe Architektur-Dokument). +**Token-Streaming:** Mit `{"text": "...", "stream": true}` schickt der Server die +LLM-Antwort schon während der Generierung als `token`-Events +(`ack` → `token*` → `semantic` → Audio → `done`) — spürbar geringere wahrgenommene +Latenz. OpenRouter und der lokale OpenAI-kompatible Provider streamen via SSE; +Provider ohne Streaming liefern die komplette Antwort als ein `token`-Event. + +> Audio-Streaming (chunked TTS), Audio-Eingang/Streaming-STT, Barge-in und WebRTC +> sind als nächste Increments vorgesehen (siehe Architektur-Dokument). ## Authentifizierung diff --git a/app/api/ws.py b/app/api/ws.py index 6c505bc..6570a7f 100644 --- a/app/api/ws.py +++ b/app/api/ws.py @@ -95,14 +95,28 @@ async def ws_chat( await websocket.send_json({"type": "ack", "route": route.as_dict()}) voice = msg.get("voice") or settings.openrouter_tts_voice + stream = bool(msg.get("stream")) try: - trace, audio = await orchestrator.chat_text( - text, - language=route.language, - voice=voice, - output=output, - history=llm_context, - ) + if stream: + async def on_token(delta): + await websocket.send_json({"type": "token", "text": delta}) + + trace, audio = await orchestrator.chat_stream( + text, + language=route.language, + voice=voice, + output=output, + history=llm_context, + on_token=on_token, + ) + else: + trace, audio = await orchestrator.chat_text( + text, + language=route.language, + voice=voice, + output=output, + history=llm_context, + ) except Exception as exc: await websocket.send_json({"type": "error", "status": 502, "detail": str(exc)}) continue diff --git a/app/core/orchestrator.py b/app/core/orchestrator.py index 213fe3f..de4cfb6 100644 --- a/app/core/orchestrator.py +++ b/app/core/orchestrator.py @@ -105,3 +105,52 @@ class Orchestrator: ) 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, + ): + """Wie chat_text, aber die LLM-Antwort wird tokenweise gestreamt. + + `on_token(delta)` (async) wird pro Token-Delta aufgerufen. Audio/Output + werden erst nach der vollstaendigen Antwort erzeugt (TTS ist nicht streamend). + """ + trace = PipelineTrace() + trace.raw_transcript = text + trace.cleaned_transcript = await self.input_cleaner.run(text or "") + + 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): + parts.append(delta) + if on_token: + await on_token(delta) + else: + # Provider ohne Streaming -> komplette Antwort als ein Token. + result = await self.llm.complete(trace.cleaned_transcript or "", history=history) + parts.append(result) + if on_token: + await on_token(result) + + 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=language, + ) + trace.tts_ready_text = await self.tts_normalizer.run( + trace.spoken_response, + language=language, + ) + + audio = await self.tts.synthesize(trace.tts_ready_text, voice=voice) + await self._emit_to_output(audio, output) + return trace, audio diff --git a/app/providers/llm/base.py b/app/providers/llm/base.py index 5a72add..2721b00 100644 --- a/app/providers/llm/base.py +++ b/app/providers/llm/base.py @@ -1,4 +1,21 @@ +import json from abc import ABC, abstractmethod +from collections.abc import AsyncIterator + + +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 @@ -8,3 +25,15 @@ class LLMProvider(ABC): history: list[dict] | None = None, session_id: str | None = None, ) -> str: ... + + async def stream( + self, + text: str, + history: list[dict] | None = None, + session_id: 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) diff --git a/app/providers/llm/local_openai_compatible.py b/app/providers/llm/local_openai_compatible.py index 99d946b..d0bbad1 100644 --- a/app/providers/llm/local_openai_compatible.py +++ b/app/providers/llm/local_openai_compatible.py @@ -1,5 +1,9 @@ +from collections.abc import AsyncIterator + import httpx -from app.providers.llm.base import LLMProvider + +from app.providers.llm.base import LLMProvider, sse_delta + class LocalOpenAICompatibleLLM(LLMProvider): def __init__(self, base_url: str, api_key: str, model: str): @@ -7,17 +11,20 @@ class LocalOpenAICompatibleLLM(LLMProvider): self.api_key = api_key self.model = model + def _build_messages(self, text: str, history: list[dict] | None) -> list[dict]: + messages = list(history) if history else [] + messages.append({"role": "user", "content": text}) + return messages + async def complete( self, text: str, history: list[dict] | None = None, session_id: str | None = None, ) -> str: - messages = list(history) if history else [] - messages.append({"role": "user", "content": text}) payload = { "model": self.model, - "messages": messages, + "messages": self._build_messages(text, history), "temperature": 0.3, } async with httpx.AsyncClient(timeout=120) as client: @@ -29,3 +36,33 @@ class LocalOpenAICompatibleLLM(LLMProvider): 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, + ) -> AsyncIterator[str]: + payload = { + "model": self.model, + "messages": self._build_messages(text, history), + "temperature": 0.3, + "stream": True, + } + 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=payload, + ) 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 index c4143dd..369140e 100644 --- a/app/providers/llm/openrouter.py +++ b/app/providers/llm/openrouter.py @@ -1,6 +1,8 @@ +from collections.abc import AsyncIterator + import httpx -from app.providers.llm.base import LLMProvider +from app.providers.llm.base import LLMProvider, sse_delta SYSTEM_PROMPT = """ @@ -49,12 +51,7 @@ class OpenRouterLLMProvider(LLMProvider): self.api_key = (api_key or "").strip() self.model = (model or "").strip() - async def complete( - self, - text: str, - history: list[dict] | None = None, - session_id: str | None = None, - ) -> str: + def _build_messages(self, text: str, history: list[dict] | None) -> list[dict]: if not self.api_key: raise ValueError("OPENROUTER_API_KEY is empty") if not self.model: @@ -66,10 +63,17 @@ class OpenRouterLLMProvider(LLMProvider): if history: messages.extend(history) messages.append({"role": "user", "content": text.strip()}) + return messages + async def complete( + self, + text: str, + history: list[dict] | None = None, + session_id: str | None = None, + ) -> str: payload = { "model": self.model, - "messages": messages, + "messages": self._build_messages(text, history), } timeout = httpx.Timeout(connect=10.0, read=120.0, write=30.0, pool=10.0) @@ -106,3 +110,42 @@ class OpenRouterLLMProvider(LLMProvider): return str(content).strip() + async def stream( + self, + text: str, + history: list[dict] | None = None, + session_id: str | None = None, + ) -> AsyncIterator[str]: + payload = { + "model": self.model, + "messages": self._build_messages(text, history), + "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/tests/test_streaming.py b/tests/test_streaming.py new file mode 100644 index 0000000..6f5a6d8 --- /dev/null +++ b/tests/test_streaming.py @@ -0,0 +1,94 @@ +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 + +client = TestClient(app) + + +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): + 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): + return "".join(tokens) + + async def stream(self, text, history=None, session_id=None): + for tok in tokens: + yield tok + + class StubTTS: + async def synthesize(self, text, voice=None, audio_format="pcm"): + 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): + return "komplett" + + class StubTTS: + async def synthesize(self, text, voice=None, audio_format="pcm"): + 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" From b5913b0a4441caefd9a7f79c1bd42534c7742c86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Wed, 17 Jun 2026 04:43:50 +0200 Subject: [PATCH 006/113] =?UTF-8?q?feat:=20Audio-Streaming=20(chunked=20TT?= =?UTF-8?q?S)=20=C3=BCber=20WebSocket=20(#4=20Ausbau)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SentenceChunker (pipeline/sentence_chunker.py): inkrementelle Satzsegmentierung - Orchestrator.chat_stream(on_audio): satzweise TTS, Audio-Chunk pro fertigem Satz; Gesamtaudio zusaetzlich an den Output-Endpunkt - WS /ws/chat {"audio_stream":true}: audio-Events (json seq + binaerer Frame) live, kein finales Vollaudio; mit stream kombinierbar - Tests: 45 gruen (+2: Sentence-Chunker, Audio-Streaming) - Doku aktualisiert (README, BEDIENUNGSANLEITUNG, Architektur) Co-Authored-By: Claude Opus 4.8 --- BEDIENUNGSANLEITUNG.md | 4 ++- Docs/voice-assistant-architecture.md | 7 ++-- README.md | 16 +++++---- app/api/ws.py | 27 ++++++++++++--- app/core/orchestrator.py | 36 +++++++++++++++++--- app/pipeline/sentence_chunker.py | 36 ++++++++++++++++++++ tests/test_streaming.py | 50 ++++++++++++++++++++++++++++ 7 files changed, 157 insertions(+), 19 deletions(-) create mode 100644 app/pipeline/sentence_chunker.py diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index d49d4ff..7436afe 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -274,7 +274,9 @@ Diese Erinnerungen gibt der Assistent bei jedem Chat als Kontext mit — auch oh `{"text": "..."}`; Antwort kommt als Event-Folge (`ack`, `semantic`, Audio, `done`). Token per Query (`?token=…`), Gedächtnis per `?session_id=…`. Mit `{"text": "...", "stream": true}` kommt die Antwort schon während der Generierung -als `token`-Events (geringere wahrgenommene Latenz). +als `token`-Events (geringere wahrgenommene Latenz). Mit `"audio_stream": true` +kommt zusätzlich das Audio satzweise (`audio`-Event + binärer Frame), sobald ein +Satz fertig ist. > **Für lokale Entwicklung** ist in der mitgelieferten `.env` `AUTH_ENABLED=false` > gesetzt — dann ist kein Token nötig (anonymer Nutzer). diff --git a/Docs/voice-assistant-architecture.md b/Docs/voice-assistant-architecture.md index da60379..4ca8d87 100644 --- a/Docs/voice-assistant-architecture.md +++ b/Docs/voice-assistant-architecture.md @@ -187,7 +187,8 @@ Device Router (strikt, Singleton); Output-Lifecycle; **Authentifizierung + 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, -opt-in via `stream:true`)**; automatisierte Tests. +`stream:true`) und satzweisem Audio-Streaming (chunked TTS, `audio_stream:true`)**; +automatisierte Tests. **Platzhalter (Gerüst):** Audio-Endpunkte (`local-default`, `bluetooth`, `mobile-ws`, `mobile-webrtc`) liefern leere Chunks — nur Auswahl/Lifecycle sind @@ -202,7 +203,7 @@ 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 gepflegt, als LLM-Kontext). Offen: **automatische** Extraktion/Zusammenfassung von Erinnerungen aus Gesprächen. -4. **(teilweise erledigt)** Echtzeit: WebSocket-Streaming-Chat (`/ws/chat`) mit Event-Folge (ack/semantic/audio/done) **und Token-Level-LLM-Streaming (SSE, opt-in `stream:true`)** sind umgesetzt. Offen: **Audio-Streaming (chunked TTS)**, **Audio-Eingang/Streaming-STT**, **Barge-in/Turn-Manager**, **WebRTC**. +4. **(teilweise erledigt)** Echtzeit: WebSocket-Streaming-Chat (`/ws/chat`), **Token-Level-LLM-Streaming (SSE, `stream:true`)** und **Audio-Streaming (chunked TTS satzweise, `audio_stream:true`)** sind umgesetzt. Offen: **Audio-Eingang/Streaming-STT**, **Barge-in/Turn-Manager**, **WebRTC**. 5. **Resilienz:** Fallback-Policy (remote KI fällt aus → lokaler/alternativer Provider), Metriken/Tracing. 6. **Betrieb:** Kosten-/Quota-Kontrolle pro Nutzer; Notfall-/Eskalationskonzept (Senioren-Kontext). 7. **TransportRouter** als eigene lokal/remote-Achse aktivieren. @@ -226,7 +227,7 @@ voice-assistant-scaffold/ │ ├── api/ # health, chat, speak, transcribe, devices, sessions, config, admin, me, ws │ ├── core/ # orchestrator │ ├── audio/ # router, transport_router, endpoints/input|output/* -│ ├── pipeline/ # input_cleaner, spoken_response_adapter, tts_normalizer +│ ├── pipeline/ # input_cleaner, spoken_response_adapter, tts_normalizer, sentence_chunker │ └── providers/ # stt/ llm/ tts/ (openrouter + lokale Stubs) ├── config/ # voice-assistant.example.toml (+ lokale .toml, gitignored) ├── data/ # SQLite-DB (gitignored) diff --git a/README.md b/README.md index 74be773..9ac9288 100644 --- a/README.md +++ b/README.md @@ -133,13 +133,17 @@ der Server streamt strukturierte Events zurück: `ack` → `semantic` → Audio Erinnerungen gelten wie bei `POST /api/chat`. **Token-Streaming:** Mit `{"text": "...", "stream": true}` schickt der Server die -LLM-Antwort schon während der Generierung als `token`-Events -(`ack` → `token*` → `semantic` → Audio → `done`) — spürbar geringere wahrgenommene -Latenz. OpenRouter und der lokale OpenAI-kompatible Provider streamen via SSE; -Provider ohne Streaming liefern die komplette Antwort als ein `token`-Event. +LLM-Antwort schon während der Generierung als `token`-Events — spürbar geringere +wahrgenommene Latenz. OpenRouter und der lokale OpenAI-kompatible Provider streamen +via SSE; Provider ohne Streaming liefern die komplette Antwort als ein `token`-Event. -> Audio-Streaming (chunked TTS), Audio-Eingang/Streaming-STT, Barge-in und WebRTC -> sind als nächste Increments vorgesehen (siehe Architektur-Dokument). +**Audio-Streaming:** Mit `{"text": "...", "audio_stream": true}` wird das Audio +**satzweise** erzeugt (chunked TTS) und pro fertigem Satz als `audio`-Event (JSON +mit `seq` + binärer Frame) gesendet — die Ausgabe beginnt, bevor die Antwort fertig +ist. `stream` und `audio_stream` lassen sich kombinieren. + +> Audio-Eingang/Streaming-STT, Barge-in/Turn-Manager und WebRTC sind als nächste +> Increments vorgesehen (siehe Architektur-Dokument). ## Authentifizierung diff --git a/app/api/ws.py b/app/api/ws.py index 6570a7f..630b564 100644 --- a/app/api/ws.py +++ b/app/api/ws.py @@ -96,11 +96,25 @@ async def ws_chat( voice = msg.get("voice") or settings.openrouter_tts_voice stream = bool(msg.get("stream")) - try: - if stream: - async def on_token(delta): - await websocket.send_json({"type": "token", "text": delta}) + audio_stream = bool(msg.get("audio_stream")) + 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, @@ -108,6 +122,7 @@ async def ws_chat( output=output, history=llm_context, on_token=on_token, + on_audio=on_audio, ) else: trace, audio = await orchestrator.chat_text( @@ -132,7 +147,9 @@ async def ws_chat( "spoken": trace.spoken_response, } ) - await websocket.send_bytes(audio) + # Bei audio_stream wurden die Audio-Chunks bereits live gesendet. + if not audio_stream: + await websocket.send_bytes(audio) await websocket.send_json( {"type": "done", "audio_format": "pcm", "sample_rate": 24000} ) diff --git a/app/core/orchestrator.py b/app/core/orchestrator.py index de4cfb6..15ef63b 100644 --- a/app/core/orchestrator.py +++ b/app/core/orchestrator.py @@ -1,4 +1,5 @@ from app.schemas import AudioChunk, PipelineTrace +from app.pipeline.sentence_chunker import SentenceChunker # Festes Ausgabeformat der TTS-Stufe (s16le PCM, 24 kHz, mono). TTS_AUDIO_FORMAT = "pcm" @@ -114,16 +115,30 @@ class Orchestrator: output=None, history: list[dict] | None = None, on_token=None, + on_audio=None, ): - """Wie chat_text, aber die LLM-Antwort wird tokenweise gestreamt. + """Wie chat_text, aber gestreamt. - `on_token(delta)` (async) wird pro Token-Delta aufgerufen. Audio/Output - werden erst nach der vollstaendigen Antwort erzeugt (TTS ist nicht streamend). + `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 "") + chunker = SentenceChunker() if on_audio else None + audio_parts: list[bytes] = [] + + async def _emit_sentence(sentence: str) -> None: + spoken = await self.spoken_adapter.run(sentence, language=language) + ready = await self.tts_normalizer.run(spoken, language=language) + if not ready.strip(): + return + chunk = await self.tts.synthesize(ready, voice=voice) + audio_parts.append(chunk) + await on_audio(chunk) + parts: list[str] = [] stream_fn = getattr(self.llm, "stream", None) if stream_fn is not None: @@ -131,12 +146,18 @@ class Orchestrator: parts.append(delta) if on_token: await on_token(delta) + if chunker: + for sentence in chunker.feed(delta): + await _emit_sentence(sentence) else: # Provider ohne Streaming -> komplette Antwort als ein Token. result = await self.llm.complete(trace.cleaned_transcript or "", history=history) 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: @@ -151,6 +172,13 @@ class Orchestrator: language=language, ) - audio = await self.tts.synthesize(trace.tts_ready_text, voice=voice) + 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) + await self._emit_to_output(audio, output) return trace, audio diff --git a/app/pipeline/sentence_chunker.py b/app/pipeline/sentence_chunker.py new file mode 100644 index 0000000..39e0afb --- /dev/null +++ b/app/pipeline/sentence_chunker.py @@ -0,0 +1,36 @@ +import re + +# Satzende: . ! ? … gefolgt von Whitespace (oder Stringende beim flush). +_SENTENCE_END = re.compile(r"[.!?…]+(?=\s)") + + +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. + """ + + def __init__(self): + self._buffer = "" + + def feed(self, text: str) -> list[str]: + self._buffer += text + sentences: list[str] = [] + while True: + match = _SENTENCE_END.search(self._buffer) + if not match: + break + end = match.end() + sentence = self._buffer[:end].strip() + self._buffer = self._buffer[end:] + if sentence: + sentences.append(sentence) + return sentences + + def flush(self) -> str: + rest = self._buffer.strip() + self._buffer = "" + return rest diff --git a/tests/test_streaming.py b/tests/test_streaming.py index 6f5a6d8..e5db1fc 100644 --- a/tests/test_streaming.py +++ b/tests/test_streaming.py @@ -5,10 +5,21 @@ 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_sse_delta_parsing(): assert sse_delta('data: {"choices":[{"delta":{"content":"Hal"}}]}') == "Hal" assert sse_delta("data: [DONE]") is None @@ -92,3 +103,42 @@ def test_ws_stream_fallback_for_nonstreaming_llm(monkeypatch): 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): + return "Satz eins. Satz zwei." + + async def stream(self, text, history=None, session_id=None): + for tok in ["Satz ", "eins. ", "Satz ", "zwei."]: + yield tok + + class CountTTS: + async def synthesize(self, text, voice=None, audio_format="pcm"): + 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 From 9340d3f998e2a0d41f8cb96bf467a33c32b7aed3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Wed, 17 Jun 2026 04:51:49 +0200 Subject: [PATCH 007/113] =?UTF-8?q?feat:=20Audio-Eingang=20=C3=BCber=20Web?= =?UTF-8?q?Socket=20(/ws/voice)=20-=20Sprach-zu-Sprach=20(#4=20Ausbau)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /ws/voice: binaere Audio-Frames puffern, {"type":"end"} -> STT-Transkription -> transcript-Event -> bestehende Antwort-Pipeline (ack/token/audio/semantic/done) - ws.py refaktoriert: gemeinsame _resolve + _run_turn fuer /ws/chat und /ws/voice - stream/audio_stream auch fuer Sprach-Turns nutzbar; mehrere Utterances pro Verbindung - Tests: 47 gruen (+2: Transkript->Antwort, leerer Puffer) - Doku aktualisiert (README, BEDIENUNGSANLEITUNG, Architektur) Hinweis: STT pro Aeusserung (gepuffert); partielle Live-Transkripte (Streaming-STT mit VAD) bleiben naechster Increment. Co-Authored-By: Claude Opus 4.8 --- BEDIENUNGSANLEITUNG.md | 4 + Docs/voice-assistant-architecture.md | 9 +- README.md | 13 +- app/api/ws.py | 273 +++++++++++++++++---------- tests/test_ws.py | 48 +++++ 5 files changed, 241 insertions(+), 106 deletions(-) diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index 7436afe..e25b2c1 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -278,6 +278,10 @@ als `token`-Events (geringere wahrgenommene Latenz). Mit `"audio_stream": true` kommt zusätzlich das Audio satzweise (`audio`-Event + binärer Frame), sobald ein Satz fertig ist. +**Sprach-Eingang** (`/ws/voice`): Mikrofon-Audio als binäre Frames senden, dann +`{"type":"end"}`. Der Server schickt ein `transcript`-Event und danach die Antwort +wie bei `/ws/chat` (`stream`/`audio_stream` im `end`-Frame möglich). + > **Für lokale Entwicklung** ist in der mitgelieferten `.env` `AUTH_ENABLED=false` > gesetzt — dann ist kein Token nötig (anonymer Nutzer). diff --git a/Docs/voice-assistant-architecture.md b/Docs/voice-assistant-architecture.md index 4ca8d87..2743be8 100644 --- a/Docs/voice-assistant-architecture.md +++ b/Docs/voice-assistant-architecture.md @@ -171,7 +171,8 @@ ein No-op; `LoopbackOutput` sammelt die Chunks (testbar ohne Hardware). | `POST /api/admin/users` | Nutzer anlegen (Admin-Key) → Token einmalig | | `GET /api/me` · `PUT /api/me/prefs` | aktueller Nutzer + dauerhafte Präferenzen | | `GET/POST/DELETE /api/me/memories` | Langzeit-Erinnerungen des Nutzers | -| `WS /ws/chat` | Echtzeit-Chat (Streaming-Events über WebSocket) | +| `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 @@ -187,8 +188,8 @@ Device Router (strikt, Singleton); Output-Lifecycle; **Authentifizierung + 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`)**; -automatisierte Tests. +`stream:true`) und satzweisem Audio-Streaming (chunked TTS, `audio_stream:true`)**; **Sprach-Eingang +über WebSocket (`/ws/voice`: Audio rein → STT → Antwort-Pipeline)**; automatisierte Tests. **Platzhalter (Gerüst):** Audio-Endpunkte (`local-default`, `bluetooth`, `mobile-ws`, `mobile-webrtc`) liefern leere Chunks — nur Auswahl/Lifecycle sind @@ -203,7 +204,7 @@ 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 gepflegt, als LLM-Kontext). Offen: **automatische** Extraktion/Zusammenfassung von Erinnerungen aus Gesprächen. -4. **(teilweise erledigt)** Echtzeit: WebSocket-Streaming-Chat (`/ws/chat`), **Token-Level-LLM-Streaming (SSE, `stream:true`)** und **Audio-Streaming (chunked TTS satzweise, `audio_stream:true`)** sind umgesetzt. Offen: **Audio-Eingang/Streaming-STT**, **Barge-in/Turn-Manager**, **WebRTC**. +4. **(teilweise erledigt)** Echtzeit: WebSocket-Streaming-Chat (`/ws/chat`), **Token-Level-LLM-Streaming (SSE, `stream:true`)**, **Audio-Streaming (chunked TTS satzweise, `audio_stream:true`)** und **Audio-Eingang (`/ws/voice`: binaere Chunks -> STT -> Antwort-Pipeline)** sind umgesetzt. Offen: **partielle Live-Transkripte (Streaming-STT mit VAD)**, **Barge-in/Turn-Manager**, **WebRTC**. 5. **Resilienz:** Fallback-Policy (remote KI fällt aus → lokaler/alternativer Provider), Metriken/Tracing. 6. **Betrieb:** Kosten-/Quota-Kontrolle pro Nutzer; Notfall-/Eskalationskonzept (Senioren-Kontext). 7. **TransportRouter** als eigene lokal/remote-Achse aktivieren. diff --git a/README.md b/README.md index 9ac9288..754d56d 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,8 @@ Aktive Konfiguration prüfen: `curl http://localhost:8080/api/config`. | `GET /api/me` | aktueller Nutzer + Präferenzen | | `PUT /api/me/prefs` | dauerhafte Routing-Präferenzen des Nutzers setzen | | `GET/POST/DELETE /api/me/memories` | Langzeit-Erinnerungen des Nutzers verwalten | -| `WS /ws/chat` | Echtzeit-Chat über WebSocket (Streaming-Events) | +| `WS /ws/chat` | Echtzeit-Chat über WebSocket (Text rein, Streaming-Events) | +| `WS /ws/voice` | Echtzeit-Sprache (Audio rein → Transkript → Antwort) | Beispiel (Sprachausgabe an den Test-Loopback, lokaler TTS-Stub): @@ -142,8 +143,14 @@ via SSE; Provider ohne Streaming liefern die komplette Antwort als ein `token`-E mit `seq` + binärer Frame) gesendet — die Ausgabe beginnt, bevor die Antwort fertig ist. `stream` und `audio_stream` lassen sich kombinieren. -> Audio-Eingang/Streaming-STT, Barge-in/Turn-Manager und WebRTC sind als nächste -> Increments vorgesehen (siehe Architektur-Dokument). +**Sprach-Eingang (`/ws/voice`):** Der Client streamt Mikrofon-Audio als binäre +Frames; ein `{"type":"end"}`-Control-Frame schließt die Äußerung ab. Der Server +transkribiert (STT), sendet ein `transcript`-Event und durchläuft dann dieselbe +Antwort-Pipeline wie `/ws/chat` (inkl. `stream`/`audio_stream`). Damit ist +Sprach-zu-Sprach-Konversation über einen Kanal möglich. + +> Partielle Live-Transkripte (Streaming-STT mit VAD), Barge-in/Turn-Manager und +> WebRTC sind als nächste Increments vorgesehen (siehe Architektur-Dokument). ## Authentifizierung diff --git a/app/api/ws.py b/app/api/ws.py index 630b564..960c549 100644 --- a/app/api/ws.py +++ b/app/api/ws.py @@ -1,14 +1,18 @@ -"""WebSocket-Streaming-Chat (Echtzeit-Transport, erster Increment). +"""WebSocket-Echtzeit-Chat und -Sprache. -Etabliert einen dauerhaften, bidirektionalen Kanal: der Client schickt pro Turn -eine JSON-Nachricht, der Server streamt strukturierte Events zurueck -(ack -> semantic -> audio (binaer) -> done). Auth, Session-Gedaechtnis und -Langzeit-Erinnerungen gelten wie bei POST /api/chat. +- /ws/chat : Text rein (JSON pro Turn), Antwort als Event-Folge zurueck. +- /ws/voice: Audio rein (binaere Chunks + Control), Transkription -> selbe Pipeline. -Bewusst spaeter (eigene Increments): Token-Level-LLM-Streaming, Audio-Eingang/ -Streaming-STT, Barge-in/Interrupt und WebRTC. +Event-Folge der Antwort: 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. + +Spaeter (eigene Increments): partielle Live-Transkripte (Streaming-STT mit VAD), +Barge-in/Turn-Manager und WebRTC. """ +import json + from fastapi import APIRouter, WebSocket, WebSocketDisconnect from app.config import settings @@ -23,6 +27,15 @@ from app.store import SessionOwnershipError router = APIRouter() +_OVERRIDE_KEYS = ( + "input_endpoint", + "output_endpoint", + "language", + "stt_provider", + "llm_provider", + "tts_provider", +) + def _authenticate(token: str | None): store = get_store() @@ -33,18 +46,92 @@ def _authenticate(token: str | None): return store.get_user_by_token(token) -@router.websocket("/ws/chat") -async def ws_chat( - websocket: WebSocket, - session_id: str | None = None, - token: str | None = None, -): - user = _authenticate(token) - if user is None: - # Vor accept() schliessen -> Handshake wird mit 403 abgelehnt. - await websocket.close(code=1008) +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): + """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) + if memories: + memory_text = "Was du ueber den Nutzer weisst:\n" + "\n".join( + f"- {m.content}" for m in memories + ) + llm_context = [{"role": "system", "content": memory_text}] + llm_context + + await websocket.send_json({"type": "ack", "route": route.as_dict()}) + + voice = options.get("voice") or settings.openrouter_tts_voice + stream = bool(options.get("stream")) + audio_stream = bool(options.get("audio_stream")) + + 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, + ) + else: + trace, audio = await orchestrator.chat_text( + text, + language=route.language, + voice=voice, + output=output, + history=llm_context, + ) + except Exception as exc: + await websocket.send_json({"type": "error", "status": 502, "detail": str(exc)}) return + if session_id: + store.append_message(session_id, user.id, "user", text) + store.append_message(session_id, user.id, "assistant", trace.semantic_response) + + await websocket.send_json( + {"type": "semantic", "text": trace.semantic_response, "spoken": trace.spoken_response} + ) + if not audio_stream: + await websocket.send_bytes(audio) + await websocket.send_json({"type": "done", "audio_format": "pcm", "sample_rate": 24000}) + + +@router.websocket("/ws/chat") +async def ws_chat(websocket: WebSocket, session_id: str | None = None, token: str | None = None): + user = _authenticate(token) + if user is None: + await websocket.close(code=1008) + return await websocket.accept() store = get_store() @@ -55,103 +142,91 @@ async def ws_chat( if not text: await websocket.send_json({"type": "error", "detail": "empty text"}) continue - - overrides = { - key: msg.get(key) - for key in ( - "input_endpoint", - "output_endpoint", - "language", - "stt_provider", - "llm_provider", - "tts_provider", - ) - } - try: - route = resolve_route(user, session_id, overrides) - orchestrator = build_orchestrator(route) - output = await resolve_output_endpoint(route) - conversation = ( - store.get_recent_messages(session_id, settings.history_max_messages) - if session_id - else [] - ) - memories = store.get_memories(user.id) + route, orchestrator, output = await _resolve(user, session_id, msg) except SessionOwnershipError as exc: await websocket.send_json({"type": "error", "status": 403, "detail": str(exc)}) continue except RoutingError as exc: await websocket.send_json({"type": "error", "status": 422, "detail": str(exc)}) continue + await _run_turn(websocket, store, user, session_id, route, orchestrator, output, text, msg) + except WebSocketDisconnect: + return - llm_context = list(conversation) - if memories: - memory_text = "Was du ueber den Nutzer weisst:\n" + "\n".join( - f"- {m.content}" for m in memories - ) - llm_context = [{"role": "system", "content": memory_text}] + llm_context - await websocket.send_json({"type": "ack", "route": route.as_dict()}) +@router.websocket("/ws/voice") +async def ws_voice(websocket: WebSocket, session_id: str | None = None, token: str | None = None): + user = _authenticate(token) + if user is None: + await websocket.close(code=1008) + return + await websocket.accept() + store = get_store() - voice = msg.get("voice") or settings.openrouter_tts_voice - stream = bool(msg.get("stream")) - audio_stream = bool(msg.get("audio_stream")) + audio_buffer = bytearray() + fmt = "wav" - on_token = None - if stream: - async def on_token(delta): - await websocket.send_json({"type": "token", "text": delta}) + try: + while True: + message = await websocket.receive() + if message["type"] == "websocket.disconnect": + return - 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, - ) - else: - trace, audio = await orchestrator.chat_text( - text, - language=route.language, - voice=voice, - output=output, - history=llm_context, - ) - except Exception as exc: - await websocket.send_json({"type": "error", "status": 502, "detail": str(exc)}) + if message.get("bytes") is not None: + audio_buffer.extend(message["bytes"]) continue - if session_id: - store.append_message(session_id, user.id, "user", text) - store.append_message(session_id, user.id, "assistant", trace.semantic_response) + 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 - await websocket.send_json( - { - "type": "semantic", - "text": trace.semantic_response, - "spoken": trace.spoken_response, - } - ) - # Bei audio_stream wurden die Audio-Chunks bereits live gesendet. - if not audio_stream: - await websocket.send_bytes(audio) - await websocket.send_json( - {"type": "done", "audio_format": "pcm", "sample_rate": 24000} + ctype = control.get("type") + if ctype == "start": + audio_buffer.clear() + fmt = control.get("format", "wav") + continue + if ctype != "end": + continue + + if not audio_buffer: + await websocket.send_json({"type": "error", "detail": "no audio received"}) + continue + + try: + route, orchestrator, output = await _resolve(user, session_id, control) + except SessionOwnershipError as exc: + await websocket.send_json({"type": "error", "status": 403, "detail": str(exc)}) + audio_buffer.clear() + continue + except RoutingError as exc: + await websocket.send_json({"type": "error", "status": 422, "detail": str(exc)}) + audio_buffer.clear() + continue + + try: + transcript = await orchestrator.stt.transcribe( + bytes(audio_buffer), fmt=fmt, language=route.language + ) + except Exception as exc: + await websocket.send_json({"type": "error", "status": 502, "detail": str(exc)}) + audio_buffer.clear() + continue + finally: + audio_buffer.clear() + + await websocket.send_json({"type": "transcript", "text": transcript}) + if not transcript or not transcript.strip(): + await websocket.send_json({"type": "error", "detail": "empty transcript"}) + continue + + await _run_turn( + websocket, store, user, session_id, route, orchestrator, output, transcript, control ) except WebSocketDisconnect: return diff --git a/tests/test_ws.py b/tests/test_ws.py index 082e0fa..5619aa7 100644 --- a/tests/test_ws.py +++ b/tests/test_ws.py @@ -66,3 +66,51 @@ def test_ws_requires_token_when_auth_enabled(monkeypatch): 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): + return f"Antwort zu {text}" + + class StubTTS: + async def synthesize(self, text, voice=None, audio_format="pcm"): + 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_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"] From 093da817d889a1672102e42c99b3c700eae5a175 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Wed, 17 Jun 2026 05:06:58 +0200 Subject: [PATCH 008/113] feat: Barge-in/Turn-Manager und VAD-Aeusserungserkennung (#4 Ausbau) - Barge-in: Antwort-Turn als abbrechbarer asyncio.Task; {"type":"interrupt"} oder neue Eingabe bricht laufende Antwort ab -> interrupted-Event (/ws/chat + /ws/voice) - VAD (app/audio/vad.py): energie-basierte Stille-Erkennung (reines Python, int16-PCM) - /ws/voice opt-in {"type":"start","vad":true}: automatisches Aeusserungsende ohne end - Tests: 52 gruen (+5: VAD-Unit, Barge-in, VAD-Auto-Segmentierung) - Doku aktualisiert (README, BEDIENUNGSANLEITUNG, Architektur) Offen (schwere Deps/Dienste): echte partielle Live-Transkripte (Streaming-STT), WebRTC (aiortc). STT laeuft heute pro Aeusserung. Co-Authored-By: Claude Opus 4.8 --- BEDIENUNGSANLEITUNG.md | 7 +- Docs/voice-assistant-architecture.md | 7 +- README.md | 14 ++- app/api/ws.py | 144 +++++++++++++++++++-------- app/audio/vad.py | 59 +++++++++++ tests/test_realtime.py | 129 ++++++++++++++++++++++++ 6 files changed, 312 insertions(+), 48 deletions(-) create mode 100644 app/audio/vad.py create mode 100644 tests/test_realtime.py diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index e25b2c1..da8b369 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -280,7 +280,12 @@ Satz fertig ist. **Sprach-Eingang** (`/ws/voice`): Mikrofon-Audio als binäre Frames senden, dann `{"type":"end"}`. Der Server schickt ein `transcript`-Event und danach die Antwort -wie bei `/ws/chat` (`stream`/`audio_stream` im `end`-Frame möglich). +wie bei `/ws/chat` (`stream`/`audio_stream` im `end`-Frame möglich). Mit +`{"type":"start","vad":true,"format":"pcm","sample_rate":16000}` erkennt der Server +das Äußerungsende automatisch an einer Sprechpause (kein `end` nötig). + +**Unterbrechen (Barge-in):** Während der Assistent antwortet, `{"type":"interrupt"}` +senden — die laufende Antwort wird abgebrochen (`interrupted`-Event). > **Für lokale Entwicklung** ist in der mitgelieferten `.env` `AUTH_ENABLED=false` > gesetzt — dann ist kein Token nötig (anonymer Nutzer). diff --git a/Docs/voice-assistant-architecture.md b/Docs/voice-assistant-architecture.md index 2743be8..228732d 100644 --- a/Docs/voice-assistant-architecture.md +++ b/Docs/voice-assistant-architecture.md @@ -189,7 +189,8 @@ Device Router (strikt, Singleton); Output-Lifecycle; **Authentifizierung 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)**; automatisierte Tests. +über WebSocket (`/ws/voice`: Audio rein → STT → Antwort-Pipeline) mit VAD-Aeusserungs- +erkennung und Barge-in (`interrupt`)**; automatisierte Tests. **Platzhalter (Gerüst):** Audio-Endpunkte (`local-default`, `bluetooth`, `mobile-ws`, `mobile-webrtc`) liefern leere Chunks — nur Auswahl/Lifecycle sind @@ -204,7 +205,7 @@ 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 gepflegt, als LLM-Kontext). Offen: **automatische** Extraktion/Zusammenfassung von Erinnerungen aus Gesprächen. -4. **(teilweise erledigt)** Echtzeit: WebSocket-Streaming-Chat (`/ws/chat`), **Token-Level-LLM-Streaming (SSE, `stream:true`)**, **Audio-Streaming (chunked TTS satzweise, `audio_stream:true`)** und **Audio-Eingang (`/ws/voice`: binaere Chunks -> STT -> Antwort-Pipeline)** sind umgesetzt. Offen: **partielle Live-Transkripte (Streaming-STT mit VAD)**, **Barge-in/Turn-Manager**, **WebRTC**. +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. **Resilienz:** Fallback-Policy (remote KI fällt aus → lokaler/alternativer Provider), Metriken/Tracing. 6. **Betrieb:** Kosten-/Quota-Kontrolle pro Nutzer; Notfall-/Eskalationskonzept (Senioren-Kontext). 7. **TransportRouter** als eigene lokal/remote-Achse aktivieren. @@ -227,7 +228,7 @@ voice-assistant-scaffold/ │ ├── schemas.py # Pydantic-Modelle │ ├── api/ # health, chat, speak, transcribe, devices, sessions, config, admin, me, ws │ ├── core/ # orchestrator -│ ├── audio/ # router, transport_router, endpoints/input|output/* +│ ├── audio/ # router, transport_router, vad, endpoints/input|output/* │ ├── pipeline/ # input_cleaner, spoken_response_adapter, tts_normalizer, sentence_chunker │ └── providers/ # stt/ llm/ tts/ (openrouter + lokale Stubs) ├── config/ # voice-assistant.example.toml (+ lokale .toml, gitignored) diff --git a/README.md b/README.md index 754d56d..6929aa8 100644 --- a/README.md +++ b/README.md @@ -149,8 +149,18 @@ transkribiert (STT), sendet ein `transcript`-Event und durchläuft dann dieselbe Antwort-Pipeline wie `/ws/chat` (inkl. `stream`/`audio_stream`). Damit ist Sprach-zu-Sprach-Konversation über einen Kanal möglich. -> Partielle Live-Transkripte (Streaming-STT mit VAD), Barge-in/Turn-Manager und -> WebRTC sind als nächste Increments vorgesehen (siehe Architektur-Dokument). +**VAD (automatische Äußerungserkennung):** Mit `{"type":"start","vad":true, +"sample_rate":16000,"format":"pcm"}` segmentiert der Server Äußerungen selbst anhand +von Stille (energie-basiert, reines Python) — ohne explizites `end`. Optional: +`vad_silence_ms`, `vad_threshold`. + +**Barge-in:** Eine laufende Antwort lässt sich mit `{"type":"interrupt"}` (oder durch +eine neue Eingabe) abbrechen — der Server stoppt das Streaming und meldet +`{"type":"interrupted"}`. Wichtig für natürliche Gespräche. + +> Echte **partielle Live-Transkripte** (Streaming-STT-Dienst, wortweise während des +> Sprechens) und **WebRTC** sind als nächste Increments vorgesehen (siehe +> Architektur-Dokument). Heute läuft STT pro Äußerung. ## Authentifizierung diff --git a/app/api/ws.py b/app/api/ws.py index 960c549..5fe7488 100644 --- a/app/api/ws.py +++ b/app/api/ws.py @@ -3,14 +3,18 @@ - /ws/chat : Text rein (JSON pro Turn), Antwort als Event-Folge zurueck. - /ws/voice: Audio rein (binaere Chunks + Control), Transkription -> selbe Pipeline. -Event-Folge der Antwort: ack -> [token*] -> [audio*] -> semantic -> done. +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. -Spaeter (eigene Increments): partielle Live-Transkripte (Streaming-STT mit VAD), -Barge-in/Turn-Manager und WebRTC. +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 @@ -24,6 +28,7 @@ from app.dependencies import ( resolve_output_endpoint, ) from app.store import SessionOwnershipError +from app.audio.vad import EnergyVAD router = APIRouter() @@ -126,6 +131,51 @@ async def _run_turn(websocket, store, user, session_id, route, orchestrator, out 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: + transcript = await orchestrator.stt.transcribe(audio, fmt=fmt, language=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}) + if not transcript or not transcript.strip(): + await websocket.send_json({"type": "error", "detail": "empty transcript"}) + return + await _run_turn(websocket, store, user, session_id, route, orchestrator, output, transcript, options) + + @router.websocket("/ws/chat") async def ws_chat(websocket: WebSocket, session_id: str | None = None, token: str | None = None): user = _authenticate(token) @@ -134,24 +184,26 @@ async def ws_chat(websocket: WebSocket, session_id: str | None = None, token: st 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 - try: - route, orchestrator, output = await _resolve(user, session_id, msg) - except SessionOwnershipError as exc: - await websocket.send_json({"type": "error", "status": 403, "detail": str(exc)}) - continue - except RoutingError as exc: - await websocket.send_json({"type": "error", "status": 422, "detail": str(exc)}) - continue - await _run_turn(websocket, store, user, session_id, route, orchestrator, output, text, msg) + 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 @@ -166,15 +218,33 @@ async def ws_voice(websocket: WebSocket, session_id: str | None = None, token: s audio_buffer = bytearray() fmt = "wav" + active = None + vad = None + vad_options: dict = {} + + 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, vad_options) continue raw = message.get("text") @@ -187,9 +257,22 @@ async def ws_voice(websocket: WebSocket, session_id: str | None = None, token: s continue ctype = control.get("type") + if ctype == "interrupt": + await _cancel_active(active, websocket) + active = None + continue if ctype == "start": audio_buffer.clear() 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), + ) + vad_options = control + else: + vad = None continue if ctype != "end": continue @@ -198,35 +281,12 @@ async def ws_voice(websocket: WebSocket, session_id: str | None = None, token: s await websocket.send_json({"type": "error", "detail": "no audio received"}) continue - try: - route, orchestrator, output = await _resolve(user, session_id, control) - except SessionOwnershipError as exc: - await websocket.send_json({"type": "error", "status": 403, "detail": str(exc)}) - audio_buffer.clear() - continue - except RoutingError as exc: - await websocket.send_json({"type": "error", "status": 422, "detail": str(exc)}) - audio_buffer.clear() - continue - - try: - transcript = await orchestrator.stt.transcribe( - bytes(audio_buffer), fmt=fmt, language=route.language - ) - except Exception as exc: - await websocket.send_json({"type": "error", "status": 502, "detail": str(exc)}) - audio_buffer.clear() - continue - finally: - audio_buffer.clear() - - await websocket.send_json({"type": "transcript", "text": transcript}) - if not transcript or not transcript.strip(): - await websocket.send_json({"type": "error", "detail": "empty transcript"}) - continue - - await _run_turn( - websocket, store, user, session_id, route, orchestrator, output, transcript, control - ) + audio = bytes(audio_buffer) + audio_buffer.clear() + if vad is not None: + vad.reset() + await _start_voice(audio, control) except WebSocketDisconnect: + if active and not active.done(): + active.cancel() return 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/tests/test_realtime.py b/tests/test_realtime.py new file mode 100644 index 0000000..8df0b21 --- /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): + return "fertig" + + async def stream(self, text, history=None, session_id=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"): + 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): + return "antwort" + + class TTS: + async def synthesize(self, text, voice=None, audio_format="pcm"): + 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" From 6422444017243c259d960399e08ef7e3e80b1c63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Wed, 17 Jun 2026 05:19:07 +0200 Subject: [PATCH 009/113] feat: Resilienz (Fallback-Ketten) und Metriken (#5) - Fallback-Provider (app/providers/fallback.py) fuer STT/LLM/TTS: Provider-Kette der Reihe nach; Config *_FALLBACK; build_orchestrator baut Ketten (dedupliziert) - LLM-Stream-Fallback nur solange kein Token gesendet wurde - Metriken (app/metrics.py): In-Memory Counter/Timer, keine externe Dependency - HTTP-Middleware (Requests/Latenz/Status je Pfad); Pipeline-Stufen-Timing stt/llm/tts; Fallback-/Fehlerzaehler; GET /api/metrics (JSON + Prometheus) - Tests: 58 gruen (+6); Doku aktualisiert (README, BEDIENUNGSANLEITUNG, Architektur, .env.example) Co-Authored-By: Claude Opus 4.8 --- .env.example | 6 ++ BEDIENUNGSANLEITUNG.md | 27 +++++- Docs/voice-assistant-architecture.md | 6 +- README.md | 26 ++++++ app/api/metrics.py | 13 +++ app/config.py | 3 + app/core/orchestrator.py | 37 +++++--- app/dependencies.py | 32 ++++++- app/main.py | 25 +++++- app/metrics.py | 82 +++++++++++++++++ app/providers/fallback.py | 85 ++++++++++++++++++ tests/conftest.py | 4 +- tests/test_resilience.py | 129 +++++++++++++++++++++++++++ 13 files changed, 452 insertions(+), 23 deletions(-) create mode 100644 app/api/metrics.py create mode 100644 app/metrics.py create mode 100644 app/providers/fallback.py create mode 100644 tests/test_resilience.py diff --git a/.env.example b/.env.example index 491b84a..46e1324 100644 --- a/.env.example +++ b/.env.example @@ -36,3 +36,9 @@ DEFAULT_OUTPUT_ENDPOINT=local-default LOCAL_LLM_BASE_URL=http://127.0.0.1:11434/v1 LOCAL_LLM_API_KEY=dummy LOCAL_LLM_MODEL=llama3.1 + +# --- 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 diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index da8b369..8a698c8 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -292,7 +292,30 @@ senden — die laufende Antwort wird abgebrochen (`interrupted`-Event). --- -## 11. Fehlerbehebung +## 11. Resilienz & Metriken (Betrieb) + +**Fallback bei Provider-Ausfall:** Pro Modul eine Ersatzliste setzen (in `.env`). +Fällt der primäre Provider aus, übernimmt der nächste automatisch: + +``` +LLM_FALLBACK=local-openai-compatible +STT_FALLBACK=faster-whisper +TTS_FALLBACK=piper +``` + +**Metriken ansehen:** + +```bash +curl http://localhost:8080/api/metrics # JSON +curl http://localhost:8080/api/metrics?format=prometheus +``` + +Enthält Request-Zahlen/-Laufzeiten, Pipeline-Stufen (`stt`/`llm`/`tts`) und +Fallback-/Fehlerzähler. Die Werte gelten pro laufendem Prozess. + +--- + +## 12. Fehlerbehebung | Symptom | Ursache | Lösung | |---|---|---| @@ -313,7 +336,7 @@ Logs erscheinen im Terminal, in dem `make run` läuft. Für mehr Details --- -## 12. Tests ausführen +## 13. Tests ausführen ```bash make test diff --git a/Docs/voice-assistant-architecture.md b/Docs/voice-assistant-architecture.md index 228732d..e968e41 100644 --- a/Docs/voice-assistant-architecture.md +++ b/Docs/voice-assistant-architecture.md @@ -171,6 +171,7 @@ ein No-op; `LoopbackOutput` sammelt die Chunks (testbar ohne Hardware). | `POST /api/admin/users` | Nutzer anlegen (Admin-Key) → Token einmalig | | `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) | @@ -206,7 +207,7 @@ Reihenfolge der Weiterentwicklung: 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 gepflegt, als LLM-Kontext). Offen: **automatische** Extraktion/Zusammenfassung von Erinnerungen aus Gesprächen. 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. **Resilienz:** Fallback-Policy (remote KI fällt aus → lokaler/alternativer Provider), Metriken/Tracing. +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. **Betrieb:** Kosten-/Quota-Kontrolle pro Nutzer; Notfall-/Eskalationskonzept (Senioren-Kontext). 7. **TransportRouter** als eigene lokal/remote-Achse aktivieren. @@ -224,13 +225,14 @@ voice-assistant-scaffold/ │ ├── 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 +│ ├── metrics.py # In-Memory-Metriken (Counter/Timer, JSON + Prometheus) │ ├── errors.py # RoutingError -> HTTP 422 │ ├── schemas.py # Pydantic-Modelle │ ├── api/ # health, chat, speak, transcribe, devices, sessions, config, admin, me, ws │ ├── core/ # orchestrator │ ├── audio/ # router, transport_router, vad, endpoints/input|output/* │ ├── pipeline/ # input_cleaner, spoken_response_adapter, tts_normalizer, sentence_chunker -│ └── providers/ # stt/ llm/ tts/ (openrouter + lokale Stubs) +│ └── providers/ # stt/ llm/ tts/ (openrouter + lokale Stubs) + fallback.py ├── config/ # voice-assistant.example.toml (+ lokale .toml, gitignored) ├── data/ # SQLite-DB (gitignored) ├── deploy/ # systemd unit + env-Beispiel diff --git a/README.md b/README.md index 6929aa8..483e2ad 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ Praktische Bedienung: [`BEDIENUNGSANLEITUNG.md`](BEDIENUNGSANLEITUNG.md). - **Geschichtete Konfiguration** mit Profilen (`local-dev` / `hybrid` / `cloud`) - **Routing auf jeder Ebene:** Default → Profil → Nutzer → Session → Request - **Authentifizierung** (Bearer-Token) + persistente Nutzer/Sessions (SQLite) +- **Resilienz:** Fallback-Ketten je Modul (Provider fällt aus → nächster) + Metriken - **Gesprächsgedächtnis pro Session:** Verlauf wird gespeichert und fließt ins LLM - **Langzeit-Erinnerungen pro Nutzer:** dauerhafte Fakten/Vorlieben als LLM-Kontext - **WebSocket-Streaming-Chat** (`/ws/chat`) als Echtzeit-Transport @@ -90,6 +91,7 @@ Aktive Konfiguration prüfen: `curl http://localhost:8080/api/config`. | `GET/POST/DELETE /api/me/memories` | Langzeit-Erinnerungen des Nutzers verwalten | | `WS /ws/chat` | Echtzeit-Chat über WebSocket (Text rein, Streaming-Events) | | `WS /ws/voice` | Echtzeit-Sprache (Audio rein → Transkript → Antwort) | +| `GET /api/metrics` | Metriken (JSON, oder `?format=prometheus`) | Beispiel (Sprachausgabe an den Test-Loopback, lokaler TTS-Stub): @@ -162,6 +164,30 @@ eine neue Eingabe) abbrechen — der Server stoppt das Streaming und meldet > Sprechens) und **WebRTC** sind als nächste Increments vorgesehen (siehe > Architektur-Dokument). Heute läuft STT pro Äußerung. +## Resilienz & Metriken + +**Fallback-Ketten:** Pro Modul lässt sich eine Ersatz-Provider-Liste setzen. Fällt +der primäre Provider aus (Timeout/Fehler), übernimmt transparent der nächste: + +```bash +# z. B. Cloud-LLM mit lokalem Fallback +LLM_FALLBACK=local-openai-compatible +STT_FALLBACK=faster-whisper +TTS_FALLBACK=piper +``` + +Die Kette ist `Route-Provider` + `*_FALLBACK` (dedupliziert). Erfolgreiche Fallbacks +und Provider-Fehler werden gezählt. + +**Metriken** (`GET /api/metrics`): Request-Counts/-Latenzen pro Pfad, Pipeline-Stufen +(`stt`/`llm`/`tts`), Fallback-/Fehlerzähler — als JSON oder Prometheus-Text +(`?format=prometheus`). In-Memory pro Prozess (keine externe Dependency). + +```bash +curl http://localhost:8080/api/metrics +curl http://localhost:8080/api/metrics?format=prometheus +``` + ## Authentifizierung Standardmäßig (`AUTH_ENABLED=true`) sind `chat`/`speak`/`transcribe`/`sessions`/`me` 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/config.py b/app/config.py index 58b8926..ae9db7b 100644 --- a/app/config.py +++ b/app/config.py @@ -124,6 +124,9 @@ class Settings(BaseSettings): admin_api_key: str = "" auth_enabled: bool = True history_max_messages: int = 10 + stt_fallback: str = "" # kommaseparierte Provider-Namen (Fallback-Kette) + llm_fallback: str = "" + tts_fallback: str = "" model_config = SettingsConfigDict( env_file=ENV_FILE, case_sensitive=False, extra="ignore" ) diff --git a/app/core/orchestrator.py b/app/core/orchestrator.py index 15ef63b..10755c6 100644 --- a/app/core/orchestrator.py +++ b/app/core/orchestrator.py @@ -1,5 +1,10 @@ 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" @@ -48,11 +53,12 @@ class Orchestrator: # input dient hier nur der Validierung/Metadaten; das Audio kommt per Upload. if input is not None: await input.capabilities() - trace.raw_transcript = await self.stt.transcribe( - audio_bytes, - fmt=fmt, - language=language, - ) + 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 "" ) @@ -67,7 +73,8 @@ class Orchestrator: ): spoken = await self.spoken_adapter.run(text, language=language) normalized = await self.tts_normalizer.run(spoken, language=language) - audio = await self.tts.synthesize(normalized, voice=voice) + with _stage("tts"): + audio = await self.tts.synthesize(normalized, voice=voice) await self._emit_to_output(audio, output) return audio @@ -84,10 +91,11 @@ class Orchestrator: trace.raw_transcript = text trace.cleaned_transcript = await self.input_cleaner.run(text or "") - trace.semantic_response = await self.llm.complete( - trace.cleaned_transcript or "", - history=history, - ) + with _stage("llm"): + trace.semantic_response = await self.llm.complete( + trace.cleaned_transcript or "", + history=history, + ) if not trace.semantic_response: raise RuntimeError("LLM returned an empty response") @@ -100,10 +108,11 @@ class Orchestrator: language=language, ) - audio = await self.tts.synthesize( - trace.tts_ready_text, - voice=voice, - ) + with _stage("tts"): + audio = await self.tts.synthesize( + trace.tts_ready_text, + voice=voice, + ) await self._emit_to_output(audio, output) return trace, audio diff --git a/app/dependencies.py b/app/dependencies.py index 51c68c6..c686400 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -19,6 +19,11 @@ 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 @@ -198,11 +203,32 @@ def resolve_route( 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 build_orchestrator(route: ResolvedRoute, cfg: Settings = settings) -> Orchestrator: return Orchestrator( - stt=get_stt_provider(route.stt_provider, cfg), - llm=get_llm_provider(route.llm_provider, cfg), - tts=get_tts_provider(route.tts_provider, cfg), + 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(), diff --git a/app/main.py b/app/main.py index 961b113..0b12281 100644 --- a/app/main.py +++ b/app/main.py @@ -1,4 +1,8 @@ -from fastapi import FastAPI +import time + +from fastapi import FastAPI, Request + +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 @@ -8,9 +12,27 @@ 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 app = FastAPI(title="Voice Assistant Gateway") + + +@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 + + app.include_router(health_router) app.include_router(chat_router, prefix="/api") app.include_router(transcribe_router, prefix="/api") @@ -20,4 +42,5 @@ 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) 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/providers/fallback.py b/app/providers/fallback.py new file mode 100644 index 0000000..3e7e71d --- /dev/null +++ b/app/providers/fallback.py @@ -0,0 +1,85 @@ +"""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 + + +class FallbackLLMProvider(_Chain): + async def complete(self, text, history=None, session_id=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) + 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) -> 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): + 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") -> bytes: + last_exc = None + for index, (name, provider) in enumerate(self.entries): + try: + result = await provider.synthesize(text, voice=voice, audio_format=audio_format) + 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/tests/conftest.py b/tests/conftest.py index e1bfbef..cf9e574 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,16 +3,18 @@ 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. + """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", "") yield diff --git a/tests/test_resilience.py b/tests/test_resilience.py new file mode 100644 index 0000000..7d25933 --- /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): + raise RuntimeError("down") + + class GoodLLM: + async def complete(self, text, history=None, session_id=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): + 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): + return "x" + + async def stream(self, text, history=None, session_id=None): + raise RuntimeError("boom") + yield # macht die Funktion zum Generator + + class GoodStream: + async def complete(self, text, history=None, session_id=None): + return "ok" + + async def stream(self, text, history=None, session_id=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): + raise RuntimeError("primary down") + + class GoodLLM: + async def complete(self, text, history=None, session_id=None): + return "rescued" + + class StubTTS: + async def synthesize(self, text, voice=None, audio_format="pcm"): + 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): + return "hi" + + class StubTTS: + async def synthesize(self, text, voice=None, audio_format="pcm"): + 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 From 1eb79c1f09ddfade0ab811b3b8dd3c3b46c13b13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Wed, 17 Jun 2026 05:29:30 +0200 Subject: [PATCH 010/113] feat: Tageskontingent und Notfall-Eskalation (#6) - Quota (app/quota.py): Anfragen pro Nutzer/Tag (usage-Tabelle), DAILY_REQUEST_LIMIT, pro Nutzer via prefs uebersteuerbar; 429 (REST) bzw. error-Event (WS); Metrik - Notfall (app/safety/emergency.py): heuristische Erkennung (de/en); Log im Store (emergency_events) + optionaler Webhook (best-effort) + X-Emergency/emergency-Event; Metrik emergency_total; Notfaelle umgehen das Kontingent - verdrahtet in chat/speak/transcribe + WS-Turns - Tests: 64 gruen (+6); Doku aktualisiert (README, BEDIENUNGSANLEITUNG, Architektur, .env.example) Hinweis: Notfall-Erkennung ist eine Heuristik (kein Lebensretter); erkannte Texte sind sensibel -> DSGVO beachten. Co-Authored-By: Claude Opus 4.8 --- .env.example | 4 ++ BEDIENUNGSANLEITUNG.md | 21 +++++++ Docs/voice-assistant-architecture.md | 8 ++- README.md | 26 +++++++++ app/api/chat.py | 16 ++++++ app/api/speak.py | 9 +++ app/api/transcribe.py | 10 ++++ app/api/ws.py | 15 +++++ app/config.py | 2 + app/quota.py | 40 +++++++++++++ app/safety/__init__.py | 0 app/safety/emergency.py | 84 ++++++++++++++++++++++++++++ app/store.py | 64 +++++++++++++++++++++ tests/test_quota_safety.py | 81 +++++++++++++++++++++++++++ 14 files changed, 378 insertions(+), 2 deletions(-) create mode 100644 app/quota.py create mode 100644 app/safety/__init__.py create mode 100644 app/safety/emergency.py create mode 100644 tests/test_quota_safety.py diff --git a/.env.example b/.env.example index 46e1324..e262b9c 100644 --- a/.env.example +++ b/.env.example @@ -42,3 +42,7 @@ LOCAL_LLM_MODEL=llama3.1 # STT_FALLBACK=faster-whisper # LLM_FALLBACK=local-openai-compatible # TTS_FALLBACK=piper + +# --- Betrieb: Kontingent & Notfall ----------------------------------------- +DAILY_REQUEST_LIMIT=0 # Anfragen pro Nutzer/Tag (0 = unbegrenzt) +# EMERGENCY_WEBHOOK_URL=https://example.org/alert # optionale Eskalation diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index 8a698c8..7f3f16d 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -313,6 +313,27 @@ curl http://localhost:8080/api/metrics?format=prometheus Enthält Request-Zahlen/-Laufzeiten, Pipeline-Stufen (`stt`/`llm`/`tts`) und Fallback-/Fehlerzähler. Die Werte gelten pro laufendem Prozess. +**Tageskontingent** (Kostenbremse) in `.env`: + +``` +DAILY_REQUEST_LIMIT=200 # Anfragen pro Nutzer/Tag; 0 = unbegrenzt +``` + +Bei Überschreitung antwortet der Dienst mit `429`. Notfall-Eingaben werden nie +blockiert. + +**Notfall-Eskalation:** Erkennt der Dienst in einer Chat-/Sprach-Eingabe ein +Notlagen-Signal (z. B. „Schmerzen in der Brust", „gestürzt", „kann nicht atmen"), +protokolliert er das, macht es sichtbar (`X-Emergency` / `emergency`-Event) und ruft +optional einen Webhook auf: + +``` +# EMERGENCY_WEBHOOK_URL=https://example.org/alert +``` + +> ⚠️ Nur eine **Heuristik** — kein Ersatz für einen echten Notruf. Erkannte Texte +> sind sensibel; auf Einwilligung und Datenschutz achten. + --- ## 12. Fehlerbehebung diff --git a/Docs/voice-assistant-architecture.md b/Docs/voice-assistant-architecture.md index e968e41..c842fcf 100644 --- a/Docs/voice-assistant-architecture.md +++ b/Docs/voice-assistant-architecture.md @@ -191,7 +191,9 @@ 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`)**; automatisierte Tests. +erkennung und Barge-in (`interrupt`)**; **Resilienz (Fallback-Ketten je Modul, +In-Memory-Metriken `/api/metrics`), Tageskontingent pro Nutzer und heuristische +Notfall-Eskalation**; automatisierte Tests. **Platzhalter (Gerüst):** Audio-Endpunkte (`local-default`, `bluetooth`, `mobile-ws`, `mobile-webrtc`) liefern leere Chunks — nur Auswahl/Lifecycle sind @@ -208,7 +210,7 @@ Reihenfolge der Weiterentwicklung: 3. **(erledigt)** Konversationsgedächtnis: Kurzzeit-Gesprächsverlauf pro Session + Langzeit-Erinnerungen pro Nutzer (manuell gepflegt, als LLM-Kontext). Offen: **automatische** Extraktion/Zusammenfassung von Erinnerungen aus Gesprächen. 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. **Betrieb:** Kosten-/Quota-Kontrolle pro Nutzer; Notfall-/Eskalationskonzept (Senioren-Kontext). +6. **(weitgehend erledigt)** Betrieb: Tageskontingent pro Nutzer (`DAILY_REQUEST_LIMIT`, 429) und heuristische Notfall-Eskalation (Erkennung -> Log + optionaler Webhook + Flag/Event). Offen: echte Klassifikation statt Schluesselwort-Heuristik, Telefon-/Angehoerigen-Integration, Abrechnung. 7. **TransportRouter** als eigene lokal/remote-Achse aktivieren. **Datenschutz (querschnittlich, ab sofort mitdenken):** Senioren-Sprachdaten sind @@ -226,6 +228,8 @@ voice-assistant-scaffold/ │ ├── store.py # Persistenz: Store-Interface + SQLiteStore (Nutzer/Sessions/Verlauf) │ ├── auth.py # Bearer-Token-Auth (require_user) + Admin-Schutz │ ├── metrics.py # In-Memory-Metriken (Counter/Timer, JSON + Prometheus) +│ ├── quota.py # Tageskontingent pro Nutzer (Kostenkontrolle) +│ ├── safety/ # emergency.py: heuristische Notfall-Erkennung/-Eskalation │ ├── errors.py # RoutingError -> HTTP 422 │ ├── schemas.py # Pydantic-Modelle │ ├── api/ # health, chat, speak, transcribe, devices, sessions, config, admin, me, ws diff --git a/README.md b/README.md index 483e2ad..22e3d05 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ Praktische Bedienung: [`BEDIENUNGSANLEITUNG.md`](BEDIENUNGSANLEITUNG.md). - **Routing auf jeder Ebene:** Default → Profil → Nutzer → Session → Request - **Authentifizierung** (Bearer-Token) + persistente Nutzer/Sessions (SQLite) - **Resilienz:** Fallback-Ketten je Modul (Provider fällt aus → nächster) + Metriken +- **Betrieb:** Tageskontingent pro Nutzer (`429`) + heuristische Notfall-Eskalation - **Gesprächsgedächtnis pro Session:** Verlauf wird gespeichert und fließt ins LLM - **Langzeit-Erinnerungen pro Nutzer:** dauerhafte Fakten/Vorlieben als LLM-Kontext - **WebSocket-Streaming-Chat** (`/ws/chat`) als Echtzeit-Transport @@ -188,6 +189,31 @@ curl http://localhost:8080/api/metrics curl http://localhost:8080/api/metrics?format=prometheus ``` +## Kontingent & Notfall-Eskalation + +**Tageskontingent** pro Nutzer begrenzt die Kosten (Cloud-LLM/TTS). Bei Überschreitung +`HTTP 429` (bzw. `error`-Event über WebSocket): + +```bash +DAILY_REQUEST_LIMIT=200 # 0 = unbegrenzt; pro Nutzer/Tag +``` +Pro Nutzer übersteuerbar via `prefs.daily_request_limit` (siehe `PUT /api/me/prefs`). + +**Notfall-Eskalation:** `/api/chat` und `/ws/chat` prüfen die Nutzereingabe heuristisch +auf Notlagen-Signale (medizinisch, Selbstgefährdung, Hilferuf — de/en). Bei Treffer +wird der Vorfall protokolliert, optional ein Webhook ausgelöst und das Signal sichtbar +gemacht (`X-Emergency`-Header / `emergency`-Feld / WebSocket-`emergency`-Event). Eine +Notfall-Eingabe umgeht das Kontingent (wird nie geblockt). + +```bash +EMERGENCY_WEBHOOK_URL=https://example.org/alert # optional, Benachrichtigung +``` + +> ⚠️ Die Erkennung ist eine **Schlüsselwort-Heuristik** — kein verlässlicher +> Lebensretter und kein Ersatz für einen echten Notruf. Sie kann Notlagen verpassen +> oder Fehlalarme auslösen. Erkannte Texte sind hochsensibel (DSGVO: Einwilligung, +> Aufbewahrung, Zugriff beachten). + ## Authentifizierung Standardmäßig (`AUTH_ENABLED=true`) sind `chat`/`speak`/`transcribe`/`sessions`/`me` diff --git a/app/api/chat.py b/app/api/chat.py index 25fd247..79572b8 100644 --- a/app/api/chat.py +++ b/app/api/chat.py @@ -13,6 +13,8 @@ from app.dependencies import ( resolve_output_endpoint, get_store, ) +from app.quota import enforce_quota, record_usage, QuotaExceededError +from app.safety.emergency import handle_emergency from app.schemas import ChatRequest router = APIRouter() @@ -77,6 +79,15 @@ async def chat( ) llm_context = [{"role": "system", "content": memory_text}] + llm_context + # Notfall-Erkennung zuerst (immer eskalieren, auch bei Quota-Limit). + emergency = handle_emergency(user, payload.text, store) + + if emergency is None: + try: + enforce_quota(user, store) + except QuotaExceededError as exc: + raise HTTPException(status_code=429, detail=str(exc)) + try: trace, audio = await orchestrator.chat_text( payload.text, @@ -88,6 +99,8 @@ async def chat( 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) @@ -101,6 +114,7 @@ async def chat( "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, @@ -119,4 +133,6 @@ async def chat( "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/speak.py b/app/api/speak.py index 7cdff77..c4c7cfb 100644 --- a/app/api/speak.py +++ b/app/api/speak.py @@ -11,7 +11,9 @@ from app.dependencies import ( resolve_route, build_orchestrator, resolve_output_endpoint, + get_store, ) +from app.quota import enforce_quota, record_usage, QuotaExceededError from app.schemas import SpeakRequest router = APIRouter() @@ -42,6 +44,12 @@ async def speak( 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, @@ -49,6 +57,7 @@ async def speak( language=route.language, output=output, ) + record_usage(user, store, len(payload.text)) headers = { "Content-Language": route.language, diff --git a/app/api/transcribe.py b/app/api/transcribe.py index 24ad4ae..2c86446 100644 --- a/app/api/transcribe.py +++ b/app/api/transcribe.py @@ -7,7 +7,9 @@ from app.dependencies import ( resolve_route, build_orchestrator, resolve_input_endpoint, + get_store, ) +from app.quota import enforce_quota, record_usage, QuotaExceededError router = APIRouter() @@ -39,6 +41,12 @@ async def transcribe( 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() @@ -52,4 +60,6 @@ async def transcribe( 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 index 5fe7488..f11d1a6 100644 --- a/app/api/ws.py +++ b/app/api/ws.py @@ -29,6 +29,8 @@ from app.dependencies import ( ) from app.store import SessionOwnershipError from app.audio.vad import EnergyVAD +from app.quota import enforce_quota, record_usage, QuotaExceededError +from app.safety.emergency import handle_emergency router = APIRouter() @@ -75,6 +77,17 @@ async def _run_turn(websocket, store, user, session_id, route, orchestrator, out ) llm_context = [{"role": "system", "content": memory_text}] + llm_context + # Notfall-Erkennung zuerst (immer eskalieren, auch bei Quota-Limit). + emergency = handle_emergency(user, text, store) + 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()}) voice = options.get("voice") or settings.openrouter_tts_voice @@ -119,6 +132,8 @@ async def _run_turn(websocket, store, user, session_id, route, orchestrator, out 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) diff --git a/app/config.py b/app/config.py index ae9db7b..8e2e8d7 100644 --- a/app/config.py +++ b/app/config.py @@ -127,6 +127,8 @@ class Settings(BaseSettings): 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 model_config = SettingsConfigDict( env_file=ENV_FILE, case_sensitive=False, extra="ignore" ) diff --git a/app/quota.py b/app/quota.py new file mode 100644 index 0000000..06cae8d --- /dev/null +++ b/app/quota.py @@ -0,0 +1,40 @@ +"""Pro-Nutzer-Tageskontingent (Kostenkontrolle). + +Limit aus Settings (`daily_request_limit`), pro Nutzer ueber `prefs.daily_request_limit` +ueberschreibbar. 0 bedeutet unbegrenzt. +""" + +from app.config import settings, Settings +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: Settings = settings) -> int: + 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: Settings = settings) -> 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/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..fbef7a9 --- /dev/null +++ b/app/safety/emergency.py @@ -0,0 +1,84 @@ +"""Heuristische Notfall-Erkennung und Eskalation (Senioren-Kontext). + +WICHTIG: Schluesselwort-Heuristik, KEIN Ersatz fuer eine echte Klassifikation. +Sie kann Notlagen verpassen oder Fehlalarme ausloesen. Die erkannten Textauszuege +sind hochsensibel und werden bewusst protokolliert (DSGVO beachten: Einwilligung, +Aufbewahrung, Zugriff). +""" + +import asyncio + +import httpx + +from app.config import settings, Settings +from app.metrics import metrics + +# 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 handle_emergency(user, text: str, store, cfg: Settings = settings): + """Erkennt, protokolliert und eskaliert ein Notfall-Signal. + + 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 + snippet = text[:500] + store.log_emergency(user.id, category, snippet) + metrics.inc("emergency_total", {"category": category}) + 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 + return {"category": category, "matched": phrase} diff --git a/app/store.py b/app/store.py index b939323..a06b2da 100644 --- a/app/store.py +++ b/app/store.py @@ -97,6 +97,18 @@ class Store(ABC): 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 log_emergency(self, user_id: str, category: str, snippet: str) -> None: + """Protokolliert ein erkanntes Notfall-Signal (sensibel!).""" + class SQLiteStore(Store): def __init__(self, db_path: str): @@ -146,6 +158,20 @@ class SQLiteStore(Store): ); 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 + ); """ ) @@ -301,3 +327,41 @@ class SQLiteStore(Store): (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"]) + + # ----- 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()), + ) diff --git a/tests/test_quota_safety.py b/tests/test_quota_safety.py new file mode 100644 index 0000000..928cb7a --- /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): + return answer + + class StubTTS: + async def synthesize(self, text, voice=None, audio_format="pcm"): + 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" From 1d338810b1333eb742dc40173d7d44cb8efdffb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Wed, 17 Jun 2026 09:31:34 +0200 Subject: [PATCH 011/113] fix: OpenRouter-STT auf JSON/base64 statt multipart (passend zur OpenRouter-API) Live-Test gegen OpenRouter ergab: /audio/transcriptions erwartet JSON mit input_audio:{data(base64),format} - NICHT multipart/form-data. Die fruehere Umstellung auf multipart (OpenAI-Standard) war fuer OpenRouter falsch. - STT-Provider zurueck auf JSON/base64 (mit robuster Fehlerbehandlung) - verifiziert per TTS->WAV->STT Round-Trip: Text korrekt zurueck transkribiert - LLM, TTS und STT funktionieren jetzt alle live gegen OpenRouter - Doku-Referenz aktualisiert (multipart -> JSON/base64) Co-Authored-By: Claude Opus 4.8 --- Docs/voice-assistant-architecture.md | 2 +- app/providers/stt/openrouter.py | 25 +++++++++++++++++-------- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/Docs/voice-assistant-architecture.md b/Docs/voice-assistant-architecture.md index c842fcf..ca7c72c 100644 --- a/Docs/voice-assistant-architecture.md +++ b/Docs/voice-assistant-architecture.md @@ -182,7 +182,7 @@ Endpunkt-/Provider-Auswahl ist über **Request-Body** (pro Aufruf), **Session** ## 8. Stand der Implementierung **Umgesetzt:** FastAPI-Gateway, alle o. g. REST-Endpunkte; OpenRouter-Adapter für -STT (multipart), LLM und TTS; lokaler OpenAI-kompatibler LLM-Adapter; regelbasierte +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 diff --git a/app/providers/stt/openrouter.py b/app/providers/stt/openrouter.py index 81a3f6a..45f02dc 100644 --- a/app/providers/stt/openrouter.py +++ b/app/providers/stt/openrouter.py @@ -1,3 +1,5 @@ +import base64 + import httpx from app.providers.stt.base import STTProvider @@ -16,12 +18,17 @@ class OpenRouterSTTProvider(STTProvider): if not audio_bytes: raise ValueError("STT input audio is empty") - # OpenAI-kompatibler /audio/transcriptions-Endpunkt erwartet multipart/form-data - # mit binärem file-Feld, nicht JSON mit base64. - files = {"file": (f"audio.{fmt}", audio_bytes, f"audio/{fmt}")} - data: dict[str, str] = {"model": self.model} + # 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: - data["language"] = language + payload["language"] = language timeout = httpx.Timeout(connect=10.0, read=120.0, write=30.0, pool=10.0) @@ -29,9 +36,11 @@ class OpenRouterSTTProvider(STTProvider): try: response = await client.post( "https://openrouter.ai/api/v1/audio/transcriptions", - headers={"Authorization": f"Bearer {self.api_key}"}, - files=files, - data=data, + headers={ + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + }, + json=payload, ) response.raise_for_status() except httpx.HTTPStatusError as exc: From 7b1acd774d73b9edc2b8ae8c2c06b49e9f13ee5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Wed, 17 Jun 2026 09:49:04 +0200 Subject: [PATCH 012/113] test: manuelles End-to-End-Smoke-Skript gegen OpenRouter (scripts/smoke_e2e.py) - prueft LLM, TTS und STT live (inkl. TTS->STT-Round-Trip); klare [OK]/[FAIL]-Ausgabe - bewusst ausserhalb von tests/ (pytest sammelt es nicht ein); macht echte Netz-Aufrufe - Makefile-Target `make smoke`; Doku in README + BEDIENUNGSANLEITUNG - live ausgefuehrt: LLM/TTS/STT alle bestanden Co-Authored-By: Claude Opus 4.8 --- BEDIENUNGSANLEITUNG.md | 11 +++- Makefile | 6 +- README.md | 11 +++- scripts/smoke_e2e.py | 124 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 148 insertions(+), 4 deletions(-) create mode 100644 scripts/smoke_e2e.py diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index 7f3f16d..39d3649 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -364,4 +364,13 @@ make test ``` Alle Tests sollten grün sein. Schlägt etwas fehl, gibt die Ausgabe den genauen -Testnamen und die Ursache an. +Testnamen und die Ursache an. Diese Tests laufen **offline** (mit Platzhaltern). + +**Echte Funktion gegen OpenRouter prüfen** (LLM, TTS und STT live): + +```bash +make smoke +``` + +Macht echte Cloud-Aufrufe (geringe Kosten) und meldet pro Modul `[OK]`/`[FAIL]`. +Braucht `OPENROUTER_API_KEY` in der Umgebung. diff --git a/Makefile b/Makefile index 35d0775..c44e1c3 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,7 @@ endif PORT ?= 8080 HOST ?= 0.0.0.0 -.PHONY: ensure-env install run test docker-build +.PHONY: ensure-env install run test smoke docker-build ensure-env: @if [ ! -f .env ] && [ -f .env.example ]; then \ @@ -24,5 +24,9 @@ run: ensure-env 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 . diff --git a/README.md b/README.md index 22e3d05..385938b 100644 --- a/README.md +++ b/README.md @@ -242,11 +242,18 @@ ein (Ebene zwischen Profil und Session). Fremde Sessions → `HTTP 403`. ## Tests ```bash -make test # oder: pytest -q +make test # oder: pytest -q (offline, mit Stubs) ``` Abgedeckt: Config-Profile & Präzedenz, Route-Auflösung, Device Router, -End-to-End (Loopback, 422-Fälle, Session-/Request-Override, `/api/config`). +Auth/Mandanten, Gedächtnis, Streaming, Resilienz, Quota/Notfall. + +**Echter End-to-End-Test gegen OpenRouter** (Netz-Aufrufe, geringe Kosten — prüft +LLM, TTS und STT live, inkl. TTS→STT-Round-Trip): + +```bash +make smoke # oder: python scripts/smoke_e2e.py +``` ## Port ändern 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()) From 7896f608fbaad40742ae520440332ddffc735c87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Wed, 17 Jun 2026 10:31:35 +0200 Subject: [PATCH 013/113] docs: Bedienungsanleitung (Sprech-Loop, Einstellungen, Praxis-Tests) + voice_loop.py - NEU scripts/voice_loop.py: Mikrofon -> /ws/voice -> Wiedergabe im Loop, mit Gedaechtnis (--session), Geraete-/Provider-Optionen, --file fuer Test ohne Mikrofon - BEDIENUNGSANLEITUNG.md neu strukturiert: Teil A (sprechen->hoeren->sprechen: voice_loop + manueller Loop + chat_client), Teil B (Einstellungen: KI/Provider auf allen Ebenen real; Sound-Quelle/-Ausgabe ehrlich auf OS-Ebene, Gateway-Endpunkte als vorbereitete Routing-Ebene), Teil C (Praxis-Tests + gemessene Reaktionszeiten: STT ~1,2s / LLM ~0,7s / TTS ~1,9s / Round-Trip ~4s; Konstellations-Empfehlung) - Alle JSON-Befehle mit '| jq'; Audio-Befehle in Datei + Player - README: kurzer Verweis auf den Sprech-Loop - live verifiziert (make smoke, Timings, voice_loop --file); 64 Tests gruen Co-Authored-By: Claude Opus 4.8 --- BEDIENUNGSANLEITUNG.md | 604 +++++++++++++++++++++-------------------- README.md | 9 + scripts/voice_loop.py | 178 ++++++++++++ 3 files changed, 493 insertions(+), 298 deletions(-) create mode 100644 scripts/voice_loop.py diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index 39d3649..d363595 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -1,20 +1,33 @@ # Bedienungsanleitung — Voice Assistant Gateway -Diese Anleitung führt Schritt für Schritt durch Installation, Start, Konfiguration -und Fehlerbehebung. Technische Hintergründe stehen im -[Architektur-Dokument](Docs/voice-assistant-architecture.md), eine kompakte -Übersicht im [README](README.md). +Schritt-für-Schritt-Anleitung zum Ausprobieren: **mit dem Assistenten sprechen**, +**Einstellungen ändern** und **Praxis-Tests mit Reaktionszeiten**. Technische +Hintergründe: [Architektur-Dokument](Docs/voice-assistant-architecture.md), +Kurzüberblick: [README](README.md). + +> **Tipp:** Alle Befehle, die JSON liefern, enden hier auf `| jq` (hübsche, lesbare +> Ausgabe). Dafür `jq` installieren: `sudo apt install jq`. Befehle, die **Audio** +> liefern, schreiben in eine Datei und spielen sie ab (kein `jq`). + +> In den Beispielen wird die Adresse als Variable genutzt — einmal setzen, dann überall +> einsetzbar (Port aus deiner `.env`, hier `8003`): +> ```bash +> export URL=http://localhost:8003 +> ``` --- +# Einrichtung + ## 1. Voraussetzungen -- **Python 3.11 oder neuer** (`python3 --version`) -- Ein **OpenRouter-API-Key** — nur nötig, wenn ein Profil entfernte KI nutzt - (`hybrid`, `cloud`). Für rein lokalen Betrieb (`local-dev`) nicht erforderlich. -- Optional: Docker, falls im Container betrieben. - ---- +- **Python 3.11+** (`python3 --version`) +- **jq** für lesbare JSON-Ausgabe (`sudo apt install jq`) +- Für den Sprech-Loop: **`arecord`** (Paket `alsa-utils`) und ein Player + (`ffplay`/`aplay`/`paplay`) — auf den meisten Linux-Desktops vorhanden +- **OpenRouter-API-Key** — nötig für Profile mit Cloud-KI (`hybrid`, `cloud`); + für rein lokalen Betrieb (`local-dev`) nicht +- Optional: Docker ## 2. Installation @@ -24,353 +37,348 @@ python3 -m venv .venv source .venv/bin/activate pip install -U pip pip install -e .[test] -``` - -Danach die zentrale Konfigurationsdatei anlegen: - -```bash cp config/voice-assistant.example.toml config/voice-assistant.toml ``` ---- - ## 3. API-Key hinterlegen (für Cloud/Hybrid) -Der Schlüssel wird **aus der Umgebung** gelesen und gehört **nicht** in eine Datei. -Dauerhaft am besten in `~/.bashrc`: +Der Schlüssel wird **aus der Umgebung** gelesen, nie aus einer Datei: ```bash echo 'export OPENROUTER_API_KEY=sk-or-v1-DEIN_KEY' >> ~/.bashrc chmod 600 ~/.bashrc source ~/.bashrc +echo ${OPENROUTER_API_KEY:0:8} # zeigt nur den Anfang zur Kontrolle ``` -Prüfen, ob er ankommt: +> **Sicherheit:** Key nie in `.env`/`config/*.toml`. Bei Leak im OpenRouter-Dashboard +> löschen (= widerrufen) und neu erzeugen. -```bash -echo ${OPENROUTER_API_KEY:0:8} # zeigt nur den Anfang -``` +## 4. Profil (Betriebsart) wählen -> **Sicherheit:** Den Key niemals in `.env` oder `config/*.toml` schreiben. Wird ein -> Key versehentlich öffentlich, im OpenRouter-Dashboard löschen (= widerrufen) und -> neu erzeugen. +| Profil | Bedeutung | Key nötig? | +|-------------|-----------------------------------------|------------| +| `local-dev` | alles lokal (eigene KI) | nein | +| `hybrid` | STT/TTS Cloud, Haupt-LLM lokal | ja | +| `cloud` | alles über OpenRouter (Standard) | ja | ---- - -## 4. Betriebsart (Profil) wählen - -Profile bestimmen, welche KI-Module genutzt werden: - -| Profil | Bedeutung | Key nötig? | -|-------------|--------------------------------------------|------------| -| `local-dev` | alles lokal (eigene KI/Hardware) | nein | -| `hybrid` | STT/TTS über Cloud, Haupt-LLM lokal | ja | -| `cloud` | alles über OpenRouter (Standardbetrieb) | ja | - -Profil **einmalig** für einen Start: - -```bash -VA_PROFILE=cloud make run -``` - -Profil **dauerhaft** — in `.env` eintragen: - -``` -VA_PROFILE=cloud -``` - -> Hinweis: Stehen in `.env` noch `DEFAULT_STT_PROVIDER` / `DEFAULT_LLM_PROVIDER` / -> `DEFAULT_TTS_PROVIDER`, überschreiben diese das Profil. Für profilbasiertes -> Umschalten sollten sie auskommentiert sein. - ---- +Dauerhaft in `.env`: `VA_PROFILE=cloud` — oder einmalig: `VA_PROFILE=cloud make run`. ## 5. Starten und Stoppen ```bash -make run +make run # startet im Vordergrund (Port aus .env, hier 8003) ``` - -Standard-Adresse: `http://localhost:8080` (Port änderbar, siehe Abschnitt 8). -Beenden mit **Strg + C**. - -Schnelltest in einem zweiten Terminal: +Beenden mit **Strg + C**. Schnelltest in einem zweiten Terminal: ```bash -curl http://localhost:8080/health -# {"status":"ok"} - -curl http://localhost:8080/api/config -# zeigt aktives Profil und die aufgelöste Standard-Route +curl -s $URL/health | jq +curl -s $URL/api/config | jq ``` +Im Hintergrund (Logs in Datei): +```bash +nohup make run > server.log 2>&1 & # starten +pkill -f "uvicorn app.main:app" # stoppen +``` +Docker: `export OPENROUTER_API_KEY=…; docker compose up --build`. +Port ändern: `PORT=8005 make run` (einmalig) bzw. `PORT=` in `.env` (dauerhaft). + --- -## 6. Tägliche Bedienung — typische Aufgaben +# Teil A — Mit dem Assistenten sprechen -### a) Text sprechen lassen (`/api/speak`) +## A1. Sprech-Loop: sprechen → hören → erneut sprechen (empfohlen) + +Der mitgelieferte Helfer nimmt vom Mikrofon auf, schickt die Aufnahme an das Gateway +und spielt die Antwort ab — fortlaufend, mit Gedächtnis: ```bash -curl -X POST http://localhost:8080/api/speak \ +source .venv/bin/activate +python scripts/voice_loop.py --session mein-gespraech +``` + +Ablauf je Runde: +1. **[Enter]** drücken → **sprechen** (z. B. „Guten Tag, wie heißt du?") +2. **[Enter]** drücken → Aufnahme stoppt; der Assistent **antwortet hörbar** +3. wieder **[Enter]** → **erneut sprechen**; der Verlauf bleibt erhalten +4. **Strg + C** → Loop beenden + +Nützliche Optionen: +```bash +python scripts/voice_loop.py --device hw:1,0 # bestimmtes Mikrofon (siehe: arecord -L) +python scripts/voice_loop.py --llm-provider openrouter --tts-provider openrouter +python scripts/voice_loop.py --token "$TOKEN" # falls AUTH_ENABLED=true +python scripts/voice_loop.py --file frage.wav # ohne Mikrofon: WAV senden (Test) +``` + +## A2. Nur tippen → Antwort hören + +```bash +python chat_client.py "Erzähl mir bitte einen guten Morgen-Spruch" +``` +Spielt die gesprochene Antwort ab (erwartet Port **8003**). + +## A3. Einzelschritte verstehen (manueller Loop) + +Pro Gesprächsrunde drei Schritte — gut, um die Pipeline zu verstehen: + +```bash +# 1) Aufnehmen (Strg+C zum Stoppen) +arecord -f S16_LE -r 16000 -c 1 frage.wav + +# 2) Transkribieren (Audio rein -> Text raus) +curl -s -X POST $URL/api/transcribe \ + -F "file=@frage.wav" -F "language=de" -F "stt_provider=openrouter" | jq + +# 3) Antwort erzeugen (Text rein -> Audio raus) und abspielen +curl -s -X POST "$URL/api/chat?session_id=loop" \ -H 'Content-Type: application/json' \ - -d '{"text":"Guten Morgen, wie geht es Ihnen?"}' \ - --output antwort.pcm + -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 ``` -### b) Chatten (Text rein, gesprochene Antwort raus) (`/api/chat`) - -Nur den Trace als JSON ansehen (ohne Audio): +## A4. Einzelne Bausteine direkt aufrufen ```bash -curl -X POST "http://localhost:8080/api/chat?debug=true" \ +# Nur Sprachausgabe (Text -> Audio): +curl -s -X POST $URL/api/speak \ -H 'Content-Type: application/json' \ - -d '{"text":"Wie wird das Wetter morgen?"}' -``` + -d '{"text":"Guten Morgen, wie geht es Ihnen?"}' --output gruss.pcm +ffplay -loglevel quiet -nodisp -autoexit -f s16le -ar 24000 -ac 1 gruss.pcm -Komfortabler mit dem mitgelieferten Client (spielt die Antwort ab): - -```bash -python chat_client.py "Erzähl mir einen guten Morgen-Spruch" -``` - -> `chat_client.py` erwartet den Dienst auf Port **8003** — bei Bedarf im Skript -> `GATEWAY_URL` anpassen oder den Dienst mit `PORT=8003 make run` starten. - -**Fortlaufendes Gespräch (Gedächtnis):** Wird eine `session_id` mitgegeben, merkt -sich der Assistent den Verlauf und bezieht ihn in die nächste Antwort ein: - -```bash -curl -X POST "http://localhost:8080/api/chat?session_id=oma-anna&debug=true" \ - -H 'Content-Type: application/json' -d '{"text":"Ich heiße Anna."}' -curl -X POST "http://localhost:8080/api/chat?session_id=oma-anna&debug=true" \ - -H 'Content-Type: application/json' -d '{"text":"Wie war noch mein Name?"}' -``` - -Ohne `session_id` ist jeder Aufruf eigenständig (kein Gedächtnis). Wie viele -zurückliegende Nachrichten einfließen, steuert `HISTORY_MAX_MESSAGES` (Standard 10). - -### c) Audio transkribieren (`/api/transcribe`) - -```bash -curl -X POST http://localhost:8080/api/transcribe \ - -F "file=@aufnahme.wav" -F "language=de" -``` - -### d) Gerät oder Provider einmalig umstellen (pro Aufruf) - -```bash -curl -X POST http://localhost:8080/api/speak \ +# Chat als Text-Trace (ohne Audio), schön lesbar: +curl -s -X POST "$URL/api/chat?debug=true" \ -H 'Content-Type: application/json' \ - -d '{"text":"Test","tts_provider":"piper","output_endpoint":"loopback"}' + -d '{"text":"Wie wird das Wetter morgen?"}' | jq ``` -### e) Präferenzen für eine Session festlegen +## A5. Weitere Features + +- **Fortlaufendes Gespräch (Gedächtnis):** `?session_id=name` anhängen — der Verlauf + fließt in die nächste Antwort. Ohne `session_id` ist jeder Aufruf eigenständig. + Wie viele Nachrichten einfließen, steuert `HISTORY_MAX_MESSAGES` (Standard 10). +- **Echtzeit-Streaming:** WebSocket `/ws/chat` mit `{"text":"…","stream":true}` liefert + die Antwort wortweise; `"audio_stream":true` zusätzlich das Audio satzweise. +- **Unterbrechen (Barge-in):** während der Assistent spricht `{"type":"interrupt"}` + senden → laufende Antwort wird abgebrochen. +- **Automatische Sprechpausen-Erkennung (VAD):** im Start-Frame von `/ws/voice` + `{"type":"start","vad":true,"format":"pcm","sample_rate":16000}` → kein manuelles Ende nötig. +- **Notfall-Erkennung:** Bei Notlagen-Signalen („Schmerzen in der Brust", „gestürzt"…) + wird eskaliert (Details siehe Teil 9). ⚠️ Nur Heuristik, kein Notruf-Ersatz. + +--- + +# Teil B — Einstellungen ändern (User / Entwickler / Admin) + +## B1. Software / KI wechseln (lokal ↔ remote) — wirkt sofort + +Welche KI (STT/LLM/TTS, lokal oder über die Cloud) genutzt wird, lässt sich auf +mehreren Ebenen festlegen. **Höhere Ebene gewinnt:** + +| Ebene | Wer | Wie | Beispiel | +|-------|-----|-----|----------| +| Profil/Global | Admin/Entwickler | `VA_PROFILE` bzw. `.env` | `VA_PROFILE=hybrid` | +| Fallback | Admin | `*_FALLBACK` in `.env` | `LLM_FALLBACK=local-openai-compatible` | +| Pro Nutzer | User/Admin | `PUT /api/me/prefs` | `{"llm_provider":"openrouter"}` | +| Pro Session | User | `POST /api/sessions/{id}/route` | `{"tts_provider":"piper"}` | +| Pro Aufruf | User | Felder im Request-Body | `{"text":"…","llm_provider":"openrouter"}` | ```bash -# einmal setzen -curl -X POST http://localhost:8080/api/sessions/oma-anna/route \ +# Verfügbare Provider + aktuell aufgelöste Auswahl ansehen: +curl -s $URL/api/config | jq '{profile, default_route, available}' + +# Pro Aufruf umschalten (hier: lokales TTS statt Cloud): +curl -s -X POST "$URL/api/chat?debug=true" \ -H 'Content-Type: application/json' \ - -d '{"llm_provider":"openrouter","language":"de"}' + -d '{"text":"Test","llm_provider":"openrouter","tts_provider":"piper"}' | jq '.route' -# danach mit dieser Session nutzen -curl -X POST "http://localhost:8080/api/chat?session_id=oma-anna&debug=true" \ - -H 'Content-Type: application/json' -d '{"text":"Hallo!"}' -``` - ---- - -## 7. Verfügbare Geräte und Bausteine ansehen - -```bash -curl http://localhost:8080/api/devices # Audio-Endpunkte mit Fähigkeiten -curl http://localhost:8080/api/config # Profil, Route, Provider, Endpunkte -``` - ---- - -## 8. Port ändern - -```bash -PORT=8003 make run # einmalig -sed -i 's/^PORT=.*/PORT=8003/' .env # dauerhaft -``` - ---- - -## 9. Mit Docker betreiben - -```bash -export OPENROUTER_API_KEY=sk-or-v1-... -docker compose up --build -``` - -Der Key wird aus der Shell in den Container durchgereicht; fehlt er, bricht der -Start mit klarer Meldung ab. - ---- - -## 10. Authentifizierung & Mehrbenutzer - -Im Produktivbetrieb ist `AUTH_ENABLED=true` (Standard). Dann brauchen -`chat`/`speak`/`transcribe`/`sessions`/`me` ein **Bearer-Token pro Nutzer**. -Nutzer und Sessions werden in einer SQLite-Datei gespeichert (`DB_PATH`, Standard -`data/voice-assistant.db`). - -**Schritt 1 — Admin-Schlüssel setzen** (nur über die Umgebung): - -```bash -export ADMIN_API_KEY=ein-langes-geheimnis -``` - -**Schritt 2 — Nutzer anlegen** (Token erscheint **nur einmal**, sicher notieren): - -```bash -curl -X POST http://localhost:8080/api/admin/users \ - -H "X-Admin-Key: $ADMIN_API_KEY" \ +# Pro Session dauerhaft (gilt für alle Aufrufe mit dieser session_id): +curl -s -X POST $URL/api/sessions/oma-anna/route \ -H 'Content-Type: application/json' \ - -d '{"display_name":"Oma Anna"}' + -d '{"llm_provider":"openrouter","language":"de"}' | jq ``` -**Schritt 3 — mit Token nutzen:** +Profil global umschalten (Entwickler/Admin): `VA_PROFILE=local-dev make run`. +## B2. Soundquelle & Ausgabe-Gerät wechseln (Mikrofon, Lautsprecher, Bluetooth, Handy) + +> **Wichtig — aktueller Stand:** Die Geräte-Endpunkte **im Gateway** +> (`input_endpoint`/`output_endpoint`) sind die **Auswahl-/Routing-Ebene** (sie werden +> validiert und in `/api/devices` aufgelistet), aber die eigentlichen **Gerätetreiber +> sind noch Platzhalter** — es fließt also noch **kein echtes Geräte-Audio durch das +> Gateway**. Welches Mikrofon/welcher Lautsprecher/welches Bluetooth-Gerät tatsächlich +> genutzt wird, steuerst du **heute auf Betriebssystem-Ebene** (bei Aufnahme/Wiedergabe). + +**Welche Geräte gibt es?** ```bash -TOKEN= -curl http://localhost:8080/api/me -H "Authorization: Bearer $TOKEN" +arecord -L # Eingabegeräte (Mikrofone) +aplay -L # Ausgabegeräte (Lautsprecher/Kopfhörer) +``` -curl -X POST http://localhost:8080/api/speak \ - -H "Authorization: Bearer $TOKEN" \ +**Mikrofon (Quelle) wählen:** +```bash +python scripts/voice_loop.py --device hw:1,0 # Helfer mit bestimmtem Mikrofon +arecord -D hw:1,0 -f S16_LE -r 16000 -c 1 frage.wav # manuell +``` + +**Lautsprecher/Kopfhörer (Ausgabe) wählen:** +```bash +aplay -D hw:0,0 -f S16_LE -r 24000 -c 1 antwort.pcm +``` + +**Bluetooth / Standardgerät (PipeWire/PulseAudio):** Gerät am System koppeln und als +Standard setzen — dann nutzen `arecord`/`aplay`/`voice_loop.py` automatisch dieses +Gerät. Grafisch mit `pavucontrol`, per Kommandozeile z. B. mit `wpctl status` / +`wpctl set-default `. + +**Gateway-Endpunkt-Auswahl (Routing-Ebene, vorbereitet):** +```bash +curl -s $URL/api/devices | jq '{inputs:[.inputs[].kind], outputs:[.outputs[].kind]}' +# Auswahl mitgeben (wird validiert; echtes Geräte-Audio folgt erst mit echten Treibern): +curl -s -X POST $URL/api/sessions/oma-anna/route \ -H 'Content-Type: application/json' \ - -d '{"text":"Guten Morgen!"}' + -d '{"input_endpoint":"bluetooth","output_endpoint":"local-default"}' | jq ``` +Ein unbekannter Endpunkt führt zu `HTTP 422`. *Roadmap: echte Geräte-Endpunkte +(PipeWire/Bluetooth/Handy) sind der nächste Ausbauschritt.* -**Dauerhafte Vorlieben** eines Nutzers (Gerät/Provider/Sprache) setzen: +## B3. Sprache wechseln -```bash -curl -X PUT http://localhost:8080/api/me/prefs \ - -H "Authorization: Bearer $TOKEN" \ - -H 'Content-Type: application/json' \ - -d '{"language":"de","llm_provider":"openrouter"}' -``` - -Diese Vorlieben gelten automatisch für alle Aufrufe dieses Nutzers (Ebene zwischen -Profil und Session). Eine fremde Session zu nutzen, wird mit `403` abgelehnt. - -**Langzeit-Erinnerungen** (dauerhafte Fakten/Vorlieben, gelten über alle Gespräche): - -```bash -# anlegen -curl -X POST http://localhost:8080/api/me/memories \ - -H "Authorization: Bearer $TOKEN" \ - -H 'Content-Type: application/json' -d '{"content":"Mag morgens Kamillentee."}' -# auflisten / löschen -curl http://localhost:8080/api/me/memories -H "Authorization: Bearer $TOKEN" -curl -X DELETE http://localhost:8080/api/me/memories/1 -H "Authorization: Bearer $TOKEN" -``` - -Diese Erinnerungen gibt der Assistent bei jedem Chat als Kontext mit — auch ohne -`session_id`. - -**Echtzeit-Chat über WebSocket** (`/ws/chat`): dauerhafter Kanal, pro Nachricht -`{"text": "..."}`; Antwort kommt als Event-Folge (`ack`, `semantic`, Audio, `done`). -Token per Query (`?token=…`), Gedächtnis per `?session_id=…`. Mit -`{"text": "...", "stream": true}` kommt die Antwort schon während der Generierung -als `token`-Events (geringere wahrgenommene Latenz). Mit `"audio_stream": true` -kommt zusätzlich das Audio satzweise (`audio`-Event + binärer Frame), sobald ein -Satz fertig ist. - -**Sprach-Eingang** (`/ws/voice`): Mikrofon-Audio als binäre Frames senden, dann -`{"type":"end"}`. Der Server schickt ein `transcript`-Event und danach die Antwort -wie bei `/ws/chat` (`stream`/`audio_stream` im `end`-Frame möglich). Mit -`{"type":"start","vad":true,"format":"pcm","sample_rate":16000}` erkennt der Server -das Äußerungsende automatisch an einer Sprechpause (kein `end` nötig). - -**Unterbrechen (Barge-in):** Während der Assistent antwortet, `{"type":"interrupt"}` -senden — die laufende Antwort wird abgebrochen (`interrupted`-Event). - -> **Für lokale Entwicklung** ist in der mitgelieferten `.env` `AUTH_ENABLED=false` -> gesetzt — dann ist kein Token nötig (anonymer Nutzer). +Global `DEFAULT_LANGUAGE=de` in `.env`, pro Nutzer via `PUT /api/me/prefs`, pro Session +via Route, oder pro Aufruf `{"text":"…","language":"en"}`. --- -## 11. Resilienz & Metriken (Betrieb) +# Teil C — Praxis-Tests & Reaktionszeiten -**Fallback bei Provider-Ausfall:** Pro Modul eine Ersatzliste setzen (in `.env`). -Fällt der primäre Provider aus, übernimmt der nächste automatisch: - -``` -LLM_FALLBACK=local-openai-compatible -STT_FALLBACK=faster-whisper -TTS_FALLBACK=piper -``` - -**Metriken ansehen:** - -```bash -curl http://localhost:8080/api/metrics # JSON -curl http://localhost:8080/api/metrics?format=prometheus -``` - -Enthält Request-Zahlen/-Laufzeiten, Pipeline-Stufen (`stt`/`llm`/`tts`) und -Fallback-/Fehlerzähler. Die Werte gelten pro laufendem Prozess. - -**Tageskontingent** (Kostenbremse) in `.env`: - -``` -DAILY_REQUEST_LIMIT=200 # Anfragen pro Nutzer/Tag; 0 = unbegrenzt -``` - -Bei Überschreitung antwortet der Dienst mit `429`. Notfall-Eingaben werden nie -blockiert. - -**Notfall-Eskalation:** Erkennt der Dienst in einer Chat-/Sprach-Eingabe ein -Notlagen-Signal (z. B. „Schmerzen in der Brust", „gestürzt", „kann nicht atmen"), -protokolliert er das, macht es sichtbar (`X-Emergency` / `emergency`-Event) und ruft -optional einen Webhook auf: - -``` -# EMERGENCY_WEBHOOK_URL=https://example.org/alert -``` - -> ⚠️ Nur eine **Heuristik** — kein Ersatz für einen echten Notruf. Erkannte Texte -> sind sensibel; auf Einwilligung und Datenschutz achten. - ---- - -## 12. Fehlerbehebung - -| Symptom | Ursache | Lösung | -|---|---|---| -| `OPENROUTER_API_KEY is empty` | Key nicht in der Umgebung | `export OPENROUTER_API_KEY=…`, neues Terminal / `source ~/.bashrc` | -| HTTP **401** „Bearer token required/Invalid token" | Auth an, Token fehlt/falsch | gültiges Token im Header `Authorization: Bearer …`, oder `AUTH_ENABLED=false` für dev | -| HTTP **401** bei `/api/admin/users` | falscher/fehlender Admin-Key | `X-Admin-Key` mit `ADMIN_API_KEY` abgleichen | -| HTTP **403** bei `?session_id=…` | Session gehört anderem Nutzer | eigene `session_id` verwenden | -| HTTP **503** bei `/api/admin/users` | `ADMIN_API_KEY` nicht gesetzt | Admin-Key in der Umgebung setzen | -| HTTP **422** „Unbekannter …-Provider/Endpunkt" | Tippfehler in `*_provider` / `*_endpoint` | gültige Werte via `GET /api/config` prüfen | -| `VA_PROFILE` wirkt nicht | `DEFAULT_*_PROVIDER` in `.env` überschreibt es | diese Zeilen in `.env` auskommentieren | -| LLM-Timeout / Connection refused (lokal) | lokaler LLM-Server (Port 11434) läuft nicht | LLM-Server starten oder Profil `cloud` wählen | -| `Address already in use` | Port belegt | anderen `PORT` setzen (Abschnitt 8) | -| `chat_client.py` bekommt keine Antwort | Client nutzt Port 8003 | Dienst mit `PORT=8003` starten oder `GATEWAY_URL` anpassen | -| Profil greift nicht / Standardwerte | `config/voice-assistant.toml` fehlt | Datei aus `*.example.toml` kopieren (Abschnitt 2) | - -Logs erscheinen im Terminal, in dem `make run` läuft. Für mehr Details -`LOG_LEVEL=debug` in `.env` setzen. - ---- - -## 13. Tests ausführen - -```bash -make test -``` - -Alle Tests sollten grün sein. Schlägt etwas fehl, gibt die Ausgabe den genauen -Testnamen und die Ursache an. Diese Tests laufen **offline** (mit Platzhaltern). - -**Echte Funktion gegen OpenRouter prüfen** (LLM, TTS und STT live): +## C1. Funktioniert alles? (echter Live-Check) ```bash make smoke ``` +Prüft LLM, TTS und STT **live** gegen OpenRouter (geringe Kosten) und meldet pro Modul +`[OK]`/`[FAIL]` — inkl. TTS→STT-Round-Trip. Braucht `OPENROUTER_API_KEY`. -Macht echte Cloud-Aufrufe (geringe Kosten) und meldet pro Modul `[OK]`/`[FAIL]`. -Braucht `OPENROUTER_API_KEY` in der Umgebung. +## C2. Reaktionszeiten messen + +Pro Aufruf die Gesamtzeit anzeigen (`curl -w`): +```bash +# Sprachausgabe (TTS): +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?"}' + +# Transkription (STT): +curl -s -o /dev/null -w "STT: %{time_total}s\n" \ + -X POST $URL/api/transcribe -F "file=@frage.wav" -F "language=de" + +# Chat-Antworttext (LLM, 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 Gruss.","tts_provider":"piper"}' +``` + +Durchschnitt der Pipeline-Stufen serverseitig: +```bash +curl -s $URL/api/metrics | jq '.timers | to_entries + | map(select(.key|test("stage_duration"))) + | map({stufe:.key, sekunden:.value.avg})' +``` + +**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** (STT→LLM→TTS) | — | **~4 s** | + +> Werte schwanken mit Netz, Textlänge, Modell und Region; der erste Aufruf ist oft +> langsamer (Verbindungsaufbau). Mit `stream`/`audio_stream` (Teil A5) sinkt die +> **wahrgenommene** Wartezeit deutlich, weil schon vor Fertigstellung Text/Audio kommt. + +## C3. Welche Konstellation für welchen Use-Case? + +| Use-Case | Empfehlung | Begründung | +|----------|------------|------------| +| Senioren-Standard (kein KI-Rechner zuhause) | **Profil `cloud`** | beste Qualität/Latenz ohne lokale Hardware (~4 s Round-Trip) | +| Datenschutz / offline | `local-dev` | alles lokal — benötigt echte lokale Modelle (heute Platzhalter) | +| Kosten/Ausfallsicherheit | `hybrid` + `*_FALLBACK` | teure Teile lokal, Rest Cloud; automatischer Fallback | + +Empfehlung für den Einstieg: **`cloud`** verwenden, Antwortzeiten mit C2 prüfen, dann +bei Bedarf einzelne Module umstellen (Teil B1). + +--- + +# Betrieb & Verwaltung + +## 9. Authentifizierung, Kontingent & Notfall + +**Auth (Mehrbenutzer):** Standard `AUTH_ENABLED=true` → geschützte Endpunkte brauchen +ein **Bearer-Token pro Nutzer**. (In der mitgelieferten `.env` ist es für die +Entwicklung auf `false` — dann ohne Token.) + +```bash +export ADMIN_API_KEY=ein-langes-geheimnis # Server muss damit laufen +# Nutzer anlegen (Token erscheint NUR einmal): +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 +# Mit Token nutzen: +TOKEN= +curl -s $URL/api/me -H "Authorization: Bearer $TOKEN" | jq +``` + +**Langzeit-Erinnerungen** (dauerhafte Fakten, gelten über alle Gespräche): +```bash +curl -s -X POST $URL/api/me/memories -H "Authorization: Bearer $TOKEN" \ + -H 'Content-Type: application/json' -d '{"content":"Mag morgens Kamillentee."}' | jq +curl -s $URL/api/me/memories -H "Authorization: Bearer $TOKEN" | jq +``` + +**Tageskontingent** (Kostenbremse) in `.env`: `DAILY_REQUEST_LIMIT=200` (0 = unbegrenzt). +Überschreitung → `HTTP 429`. Notfall-Eingaben werden nie blockiert. + +**Notfall-Eskalation:** Erkennt der Dienst ein Notlagen-Signal, protokolliert er es, +macht es sichtbar (`X-Emergency` / `emergency`-Event) und ruft optional einen Webhook +auf (`EMERGENCY_WEBHOOK_URL`). ⚠️ Nur eine **Heuristik** — kein Ersatz für einen echten +Notruf; erkannte Texte sind sensibel (Datenschutz/Einwilligung beachten). + +## 10. Resilienz & Metriken + +```bash +# Fallback je Modul (in .env): Provider fällt aus -> nächster übernimmt +LLM_FALLBACK=local-openai-compatible + +# Metriken (Requests, Latenzen, Stufen, Fallback/Fehler): +curl -s $URL/api/metrics | jq +curl -s "$URL/api/metrics?format=prometheus" # Prometheus-Text (kein jq) +``` + +## 11. Fehlerbehebung + +| Symptom | Ursache | Lösung | +|---|---|---| +| `OPENROUTER_API_KEY is empty` | Key nicht in der Umgebung | `export OPENROUTER_API_KEY=…`, neues Terminal / `source ~/.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` verwenden | +| HTTP **429** | Tageskontingent erreicht | `DAILY_REQUEST_LIMIT` erhöhen / Folgetag | +| HTTP **422** „Unbekannter Provider/Endpunkt" | Tippfehler | gültige Werte via `curl -s $URL/api/config \| jq` | +| HTTP **502** bei STT/TTS | Cloud-Fehler/Format | `make smoke` ausführen; Modellnamen in `.env` prüfen | +| `VA_PROFILE` wirkt nicht | `DEFAULT_*_PROVIDER` in `.env` überschreibt | diese Zeilen in `.env` auskommentieren | +| `Address already in use` | Port belegt | anderen `PORT` setzen | +| Keine Aufnahme/Wiedergabe | `arecord`/Player/Gerät | `arecord -L` / `aplay -L`; Paket `alsa-utils`, `ffmpeg` | +| Profil greift nicht | `config/voice-assistant.toml` fehlt | aus `*.example.toml` kopieren (Abschnitt 2) | + +Logs erscheinen im Terminal von `make run`; mehr Details mit `LOG_LEVEL=debug` in `.env`. + +## 12. Automatisierte Tests + +```bash +make test # offline (mit Platzhaltern) — schnell, kostenlos +make smoke # echter Live-Check gegen OpenRouter (LLM/TTS/STT) +``` diff --git a/README.md b/README.md index 385938b..bdbd698 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,15 @@ curl http://localhost:8080/health curl http://localhost:8080/api/config ``` +**Sprechen → Antwort hören → erneut sprechen** (Mikrofon-Loop): + +```bash +python scripts/voice_loop.py --session mein-gespraech +``` + +Vollständige, copy-&-paste-fertige Schritt-für-Schritt-Anleitung (Bedienung, +Einstellungen wechseln, Praxis-Tests & Reaktionszeiten): **[BEDIENUNGSANLEITUNG.md](BEDIENUNGSANLEITUNG.md)**. + ## Konfiguration & Profile Höhere Ebene gewinnt: diff --git a/scripts/voice_loop.py b/scripts/voice_loop.py new file mode 100644 index 0000000..ffcc40c --- /dev/null +++ b/scripts/voice_loop.py @@ -0,0 +1,178 @@ +#!/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. + +Beispiele: + python scripts/voice_loop.py + python scripts/voice_loop.py --url ws://localhost:8003/ws/voice --session oma-anna + python scripts/voice_loop.py --device hw:1,0 # bestimmtes Mikrofon (arecord -L) + python scripts/voice_loop.py --llm-provider openrouter --tts-provider openrouter + python scripts/voice_loop.py --file frage.wav # ohne Mikrofon (Test) + +Voraussetzungen: laufendes Gateway, `arecord` (Aufnahme), ein Player +(`ffplay`/`aplay`/`paplay`), Python-Paket `websockets`. +""" + +from __future__ import annotations + +import argparse +import asyncio +import io +import json +import shutil +import signal +import subprocess +import sys +import tempfile +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: + if shutil.which("ffplay"): + return ["ffplay", "-loglevel", "quiet", "-nodisp", "-autoexit"] + if shutil.which("aplay"): + return ["aplay", "-q"] + if shutil.which("paplay"): + return ["paplay"] + return None + + +def record_utterance(device: str | None, rate: int) -> bytes: + """Push-to-Talk: Enter startet, Enter stoppt die Aufnahme; gibt WAV-Bytes zurueck.""" + _require("arecord") + tmp = tempfile.NamedTemporaryFile(suffix=".wav", delete=False) + tmp.close() + cmd = ["arecord", "-q", "-f", "S16_LE", "-r", str(rate), "-c", "1"] + if device: + cmd += ["-D", device] + cmd.append(tmp.name) + + input("\n[Enter] = Aufnahme START …") + proc = subprocess.Popen(cmd) + input("[Enter] = Aufnahme STOP …") + proc.send_signal(signal.SIGINT) + proc.wait(timeout=5) + with open(tmp.name, "rb") as fh: + return fh.read() + + +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 _start_frame(args) -> dict: + frame = {"type": "start", "format": "wav"} + for key in ("stt_provider", "llm_provider", "tts_provider", "language"): + value = getattr(args, key, None) + if value: + frame[key] = value + return frame + + +async def one_turn(ws, wav: bytes, start_frame: dict) -> None: + await ws.send(json.dumps(start_frame)) + await ws.send(wav) + await ws.send(json.dumps({"type": "end"})) + + audio = b"" + while True: + msg = await ws.recv() + if isinstance(msg, (bytes, bytearray)): + audio = bytes(msg) + continue + event = json.loads(msg) + etype = event.get("type") + if etype == "transcript": + print(f" Du: {event.get('text','')!r}") + elif etype == "semantic": + print(f" Assistent: {event.get('text','')!r}") + elif etype == "emergency": + print(f" ⚠ NOTFALL erkannt (Kategorie: {event.get('category')})") + elif etype == "error": + print(f" Fehler {event.get('status','')}: {event.get('detail')}") + return + elif etype == "done": + break + if audio: + play_pcm(audio) + + +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) + + async with websockets.connect(url, max_size=None) as ws: + print(f"Verbunden: {args.url} (Session '{args.session}')") + if args.file: + with open(args.file, "rb") as fh: + wav = fh.read() + print(f"Sende Datei: {args.file}") + await one_turn(ws, wav, start_frame) + return + print("Sprich nach 'START', stoppe mit Enter. Strg+C beendet den Loop.") + while True: + try: + wav = record_utterance(args.device, args.rate) + except KeyboardInterrupt: + print("\nEnde.") + return + if len(wav) < 1000: + print(" (zu kurz/leer – nochmal)") + continue + await one_turn(ws, wav, start_frame) + + +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 (siehe: arecord -L)") + 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("--language", default=None) + 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() From d15e51eb0d7c7e56ae958c65140076b45319dde6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Wed, 17 Jun 2026 10:37:49 +0200 Subject: [PATCH 014/113] fix(voice_loop): PipeWire-Aufnahme (pw-record) als Standard via --recorder auto MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - arecord scheiterte auf PipeWire-Systemen ("Fehler beim Öffnen des Gerätes") - neue Option --recorder {auto,pw-record,parecord,arecord}; auto bevorzugt pw-record - geraetespezifische Flags je Werkzeug (-D / --target / --device) - Doku: Recorder-Hinweis + Troubleshooting-Eintrag fuer den arecord-Fehler - verifiziert: auto -> pw-record; --file-Round-Trip weiterhin ok Co-Authored-By: Claude Opus 4.8 --- BEDIENUNGSANLEITUNG.md | 11 +++++++-- scripts/voice_loop.py | 56 +++++++++++++++++++++++++++++++++++------- 2 files changed, 56 insertions(+), 11 deletions(-) diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index d363595..082a921 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -106,12 +106,18 @@ Ablauf je Runde: Nützliche Optionen: ```bash -python scripts/voice_loop.py --device hw:1,0 # bestimmtes Mikrofon (siehe: arecord -L) +python scripts/voice_loop.py --recorder pw-record # PipeWire-Aufnahme (Standard bei 'auto') +python scripts/voice_loop.py --recorder arecord --device hw:1,0 # ALSA, bestimmtes Mikrofon python scripts/voice_loop.py --llm-provider openrouter --tts-provider openrouter python scripts/voice_loop.py --token "$TOKEN" # falls AUTH_ENABLED=true python scripts/voice_loop.py --file frage.wav # ohne Mikrofon: WAV senden (Test) ``` +> **Aufnahmewerkzeug:** `--recorder auto` (Standard) bevorzugt **PipeWire** (`pw-record`), +> sonst `parecord`/`arecord`. Gibt `arecord` den Fehler *„Fehler beim Öffnen des +> Gerätes"* aus, nutze `--recorder pw-record` (auf PipeWire-Systemen der Normalfall) +> oder gib mit `--device` ein konkretes Gerät an. + ## A2. Nur tippen → Antwort hören ```bash @@ -371,7 +377,8 @@ curl -s "$URL/api/metrics?format=prometheus" # Prometheus-Text (kein jq) | HTTP **502** bei STT/TTS | Cloud-Fehler/Format | `make smoke` ausführen; Modellnamen in `.env` prüfen | | `VA_PROFILE` wirkt nicht | `DEFAULT_*_PROVIDER` in `.env` überschreibt | diese Zeilen in `.env` auskommentieren | | `Address already in use` | Port belegt | anderen `PORT` setzen | -| Keine Aufnahme/Wiedergabe | `arecord`/Player/Gerät | `arecord -L` / `aplay -L`; Paket `alsa-utils`, `ffmpeg` | +| `arecord: Fehler beim Öffnen des Gerätes` | ALSA-`default` auf PipeWire nicht nutzbar | `--recorder pw-record` (Standard bei `auto`) oder `--device` mit Gerät aus `arecord -L` | +| Keine Aufnahme/Wiedergabe | Werkzeug/Gerät fehlt | `arecord -L` / `aplay -L`; Pakete `pipewire`/`alsa-utils`/`ffmpeg`; Default via `wpctl status` | | Profil greift nicht | `config/voice-assistant.toml` fehlt | aus `*.example.toml` kopieren (Abschnitt 2) | Logs erscheinen im Terminal von `make run`; mehr Details mit `LOG_LEVEL=debug` in `.env`. diff --git a/scripts/voice_loop.py b/scripts/voice_loop.py index ffcc40c..43dc4e7 100644 --- a/scripts/voice_loop.py +++ b/scripts/voice_loop.py @@ -54,21 +54,52 @@ def _first_player() -> list[str] | None: return None -def record_utterance(device: str | None, rate: int) -> bytes: +def resolve_recorder(choice: str) -> str: + """Waehlt das Aufnahmewerkzeug. 'auto' bevorzugt PipeWire (pw-record).""" + if choice != "auto": + if not shutil.which(choice): + sys.exit(f"Fehlt: Aufnahmewerkzeug '{choice}' nicht gefunden.") + return choice + for tool in ("pw-record", "parecord", "arecord"): + if shutil.which(tool): + return tool + sys.exit("Kein Aufnahmewerkzeug gefunden (pw-record / parecord / arecord).") + + +def _record_cmd(recorder: str, device: str | None, rate: int, outfile: str) -> list[str]: + 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.""" - _require("arecord") tmp = tempfile.NamedTemporaryFile(suffix=".wav", delete=False) tmp.close() - cmd = ["arecord", "-q", "-f", "S16_LE", "-r", str(rate), "-c", "1"] - if device: - cmd += ["-D", device] - cmd.append(tmp.name) + cmd = _record_cmd(recorder, device, rate, tmp.name) input("\n[Enter] = Aufnahme START …") proc = subprocess.Popen(cmd) input("[Enter] = Aufnahme STOP …") proc.send_signal(signal.SIGINT) - proc.wait(timeout=5) + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() with open(tmp.name, "rb") as fh: return fh.read() @@ -142,10 +173,13 @@ async def run(args) -> None: print(f"Sende Datei: {args.file}") await one_turn(ws, wav, start_frame) return + recorder = resolve_recorder(args.recorder) + print(f"Aufnahme mit: {recorder}" + + (f" (Gerät: {args.device})" if args.device else " (Standardgerät)")) print("Sprich nach 'START', stoppe mit Enter. Strg+C beendet den Loop.") while True: try: - wav = record_utterance(args.device, args.rate) + wav = record_utterance(recorder, args.device, args.rate) except KeyboardInterrupt: print("\nEnde.") return @@ -160,7 +194,11 @@ def main() -> None: 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 (siehe: arecord -L)") + p.add_argument("--device", default=None, + help="Aufnahmegeraet (arecord: -L; pw-record: --target; parecord: --device)") + p.add_argument("--recorder", default="auto", + choices=["auto", "pw-record", "parecord", "arecord"], + help="Aufnahmewerkzeug; 'auto' bevorzugt PipeWire (pw-record)") 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) From 98706ef3ec5d358a98a527c4fd9e977ec648a825 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Wed, 17 Jun 2026 10:44:48 +0200 Subject: [PATCH 015/113] docs+ux(voice_loop): klarer Hinweis auf ALSA-Direktgeraet bei Aufnahme-Fehler Auf PipeWire-Systemen kann der pw-record/ALSA-default-Pfad scheitern ("pw_context_connect() failed"). Direkter Zugriff via arecord -D plughw:KARTE,0 funktioniert. voice_loop gibt bei leerer Aufnahme jetzt diesen konkreten Tipp aus; Bedienungsanleitung entsprechend ergaenzt (Recorder-Hinweis + Troubleshooting). Co-Authored-By: Claude Opus 4.8 --- BEDIENUNGSANLEITUNG.md | 13 +++++++++---- scripts/voice_loop.py | 6 +++++- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index 082a921..6e11f4e 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -114,9 +114,14 @@ python scripts/voice_loop.py --file frage.wav # ohne Mikrofon: WAV senden ``` > **Aufnahmewerkzeug:** `--recorder auto` (Standard) bevorzugt **PipeWire** (`pw-record`), -> sonst `parecord`/`arecord`. Gibt `arecord` den Fehler *„Fehler beim Öffnen des -> Gerätes"* aus, nutze `--recorder pw-record` (auf PipeWire-Systemen der Normalfall) -> oder gib mit `--device` ein konkretes Gerät an. +> sonst `parecord`/`arecord`. **Falls die Aufnahme scheitert** (z. B. +> *„pw_context_connect() failed"* oder *„Fehler beim Öffnen des Gerätes"*), ist der +> verlässlichste Weg ein **direktes ALSA-Hardware-Gerät**: +> ```bash +> arecord -l # Kartennummern der Mikrofone +> python scripts/voice_loop.py --recorder arecord --device plughw:2,0 +> ``` +> (`plughw:KARTE,GERÄT` aus `arecord -l`; z. B. onboard oft Karte 2, USB-Webcam Karte 4.) ## A2. Nur tippen → Antwort hören @@ -377,7 +382,7 @@ curl -s "$URL/api/metrics?format=prometheus" # Prometheus-Text (kein jq) | HTTP **502** bei STT/TTS | Cloud-Fehler/Format | `make smoke` ausführen; Modellnamen in `.env` prüfen | | `VA_PROFILE` wirkt nicht | `DEFAULT_*_PROVIDER` in `.env` überschreibt | diese Zeilen in `.env` auskommentieren | | `Address already in use` | Port belegt | anderen `PORT` setzen | -| `arecord: Fehler beim Öffnen des Gerätes` | ALSA-`default` auf PipeWire nicht nutzbar | `--recorder pw-record` (Standard bei `auto`) oder `--device` mit Gerät aus `arecord -L` | +| `pw_context_connect() failed` / `arecord: Fehler beim Öffnen des Gerätes` | PipeWire-Client- bzw. ALSA-`default`-Pfad gestört | direktes Gerät nehmen: `arecord -l`, dann `--recorder arecord --device plughw:2,0` | | Keine Aufnahme/Wiedergabe | Werkzeug/Gerät fehlt | `arecord -L` / `aplay -L`; Pakete `pipewire`/`alsa-utils`/`ffmpeg`; Default via `wpctl status` | | Profil greift nicht | `config/voice-assistant.toml` fehlt | aus `*.example.toml` kopieren (Abschnitt 2) | diff --git a/scripts/voice_loop.py b/scripts/voice_loop.py index 43dc4e7..3822552 100644 --- a/scripts/voice_loop.py +++ b/scripts/voice_loop.py @@ -184,7 +184,11 @@ async def run(args) -> None: print("\nEnde.") return if len(wav) < 1000: - print(" (zu kurz/leer – nochmal)") + print(" ⚠ Keine/zu kurze Aufnahme. Moegliche Ursache: Aufnahmewerkzeug" + " oder Geraet nicht nutzbar.") + print(" Verfuegbare Mikrofone: arecord -l") + print(" Direktes ALSA-Geraet verwenden, z. B.:") + print(" python scripts/voice_loop.py --recorder arecord --device plughw:2,0") continue await one_turn(ws, wav, start_frame) From 4254475b73fe6b1522ea278dbe3843c55d9c98da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Wed, 17 Jun 2026 11:00:13 +0200 Subject: [PATCH 016/113] fix(voice_loop): Keepalive-Timeout (1011) bei langer Aufnahme/Wiedergabe behoben Eine Dauerverbindung wurde vom Server geschlossen, wenn Aufnahme/Wiedergabe den Event-Loop blockierten (Keepalive-Ping unbeantwortet) - sichtbar bei langen TTS-Antworten. Jetzt: kurze WebSocket-Verbindung PRO Sprech-Runde; das Audio wird erst NACH dem Schliessen abgespielt. Gespraechsgedaechtnis bleibt serverseitig via session_id erhalten. Verifiziert mit langer Antwort (~12 s Audio) ohne Abbruch. Co-Authored-By: Claude Opus 4.8 --- scripts/voice_loop.py | 69 ++++++++++++++++++++++++++----------------- 1 file changed, 42 insertions(+), 27 deletions(-) diff --git a/scripts/voice_loop.py b/scripts/voice_loop.py index 3822552..e3e6094 100644 --- a/scripts/voice_loop.py +++ b/scripts/voice_loop.py @@ -131,7 +131,9 @@ def _start_frame(args) -> dict: return frame -async def one_turn(ws, wav: bytes, start_frame: dict) -> None: +async def one_turn(ws, wav: bytes, start_frame: dict) -> bytes: + """Sendet eine Aeusserung, gibt das Antwort-Audio (PCM) zurueck. Spielt NICHT ab, + damit die Wiedergabe ausserhalb der offenen Verbindung passieren kann.""" await ws.send(json.dumps(start_frame)) await ws.send(wav) await ws.send(json.dumps({"type": "end"})) @@ -152,9 +154,19 @@ async def one_turn(ws, wav: bytes, start_frame: dict) -> None: print(f" ⚠ NOTFALL erkannt (Kategorie: {event.get('category')})") elif etype == "error": print(f" Fehler {event.get('status','')}: {event.get('detail')}") - return + return b"" elif etype == "done": break + return audio + + +async def _send_and_play(url: str, wav: bytes, start_frame: dict) -> None: + """Oeffnet eine kurze Verbindung pro Runde (Gedaechtnis bleibt serverseitig via + session_id erhalten), empfaengt die Antwort und spielt sie NACH dem Schliessen ab. + So blockiert die (lange) Aufnahme/Wiedergabe nie eine offene WebSocket-Verbindung + (kein Keepalive-Timeout).""" + async with websockets.connect(url, max_size=None) as ws: + audio = await one_turn(ws, wav, start_frame) if audio: play_pcm(audio) @@ -165,32 +177,35 @@ async def run(args) -> None: url += f"&token={args.token}" start_frame = _start_frame(args) - async with websockets.connect(url, max_size=None) as ws: - print(f"Verbunden: {args.url} (Session '{args.session}')") - if args.file: - with open(args.file, "rb") as fh: - wav = fh.read() - print(f"Sende Datei: {args.file}") - await one_turn(ws, wav, start_frame) + 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) + print(f"Ziel: {args.url} (Session '{args.session}')") + print(f"Aufnahme mit: {recorder}" + + (f" (Gerät: {args.device})" if args.device else " (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 - recorder = resolve_recorder(args.recorder) - print(f"Aufnahme mit: {recorder}" - + (f" (Gerät: {args.device})" if args.device else " (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. Moegliche Ursache: Aufnahmewerkzeug" - " oder Geraet nicht nutzbar.") - print(" Verfuegbare Mikrofone: arecord -l") - print(" Direktes ALSA-Geraet verwenden, z. B.:") - print(" python scripts/voice_loop.py --recorder arecord --device plughw:2,0") - continue - await one_turn(ws, wav, start_frame) + if len(wav) < 1000: + print(" ⚠ Keine/zu kurze Aufnahme. Moegliche Ursache: Aufnahmewerkzeug" + " oder Geraet nicht nutzbar.") + print(" Verfuegbare Mikrofone: arecord -l") + print(" Direktes ALSA-Geraet verwenden, z. B.:") + print(" python scripts/voice_loop.py --recorder arecord --device plughw:2,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: From e1167951e18449c2fc25b20409864f150872b436 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Wed, 17 Jun 2026 11:08:08 +0200 Subject: [PATCH 017/113] ux(voice_loop): harmlose arecord-EINTR-Meldung beim Stoppen unterdruecken MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Das Stoppen per SIGINT loest in arecord ein erwartetes EINTR aus ("Unterbrechung während des Betriebssystemaufrufs"). Recorder-stderr wird nun abgefangen und nur noch bei tatsaechlich fehlgeschlagener Aufnahme (leere Datei) angezeigt. Die Aufnahme selbst war/ist korrekt. Co-Authored-By: Claude Opus 4.8 --- scripts/voice_loop.py | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/scripts/voice_loop.py b/scripts/voice_loop.py index e3e6094..9e53f0f 100644 --- a/scripts/voice_loop.py +++ b/scripts/voice_loop.py @@ -87,21 +87,36 @@ def _record_cmd(recorder: str, device: str | None, rate: int, outfile: str) -> l def record_utterance(recorder: str, device: str | None, rate: int) -> bytes: - """Push-to-Talk: Enter startet, Enter stoppt die Aufnahme; gibt WAV-Bytes zurueck.""" + """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 …") - proc = subprocess.Popen(cmd) + 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: - return fh.read() + data = fh.read() + # 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: From c25f081f9f8ae6e7e724b24d06e2d31b85fa872c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Wed, 17 Jun 2026 11:28:20 +0200 Subject: [PATCH 018/113] feat(stt): echtes lokales STT via faster-whisper (optional .[local]) - FasterWhisperProvider implementiert (CTranslate2): Modell prozessweit gecacht (lru_cache), Transkription in asyncio.to_thread; robuster CPU-Fallback wenn GPU/compute_type nicht verfuegbar - Config: FASTER_WHISPER_MODEL/DEVICE/COMPUTE_TYPE (Defaults base/auto/default) - pyproject: optionales Extra [local] = faster-whisper - Test: transcribe-Endpunkt nutzt jetzt einen Stub-STT (kein Platzhalter mehr) - Doku: Voraussetzungen, Hybrid-Beispiel (STT+LLM lokal, TTS remote), Architektur/README Live verifiziert: TTS->WAV->faster-whisper transkribiert korrekt (de); voller Hybrid ueber /ws/voice (faster-whisper + Ollama llama3.2 + OpenRouter-TTS) funktioniert. Co-Authored-By: Claude Opus 4.8 --- .env.example | 5 +++ BEDIENUNGSANLEITUNG.md | 19 ++++++++++++ Docs/voice-assistant-architecture.md | 8 +++-- README.md | 2 +- app/config.py | 3 ++ app/providers/stt/faster_whisper.py | 46 +++++++++++++++++++++++++++- pyproject.toml | 4 +++ tests/test_endpoints_e2e.py | 13 +++++--- 8 files changed, 92 insertions(+), 8 deletions(-) diff --git a/.env.example b/.env.example index e262b9c..8dd2e81 100644 --- a/.env.example +++ b/.env.example @@ -37,6 +37,11 @@ LOCAL_LLM_BASE_URL=http://127.0.0.1:11434/v1 LOCAL_LLM_API_KEY=dummy LOCAL_LLM_MODEL=llama3.1 +# --- 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 + # --- Resilienz: Fallback-Ketten (kommaseparierte Provider-Namen) ------------ # Faellt der primaere Provider aus, uebernimmt der naechste. # STT_FALLBACK=faster-whisper diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index 6e11f4e..b525777 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -27,6 +27,9 @@ Kurzüberblick: [README](README.md). (`ffplay`/`aplay`/`paplay`) — auf den meisten Linux-Desktops vorhanden - **OpenRouter-API-Key** — nötig für Profile mit Cloud-KI (`hybrid`, `cloud`); für rein lokalen Betrieb (`local-dev`) nicht +- Für **lokales STT** (Provider `faster-whisper`): einmalig `pip install -e .[local]` + (lädt beim ersten Lauf ein Whisper-Modell). Für **lokales LLM**: ein laufender + Ollama-Server (`http://127.0.0.1:11434`) mit einem Modell (`ollama pull llama3.2`) - Optional: Docker ## 2. Installation @@ -213,6 +216,22 @@ curl -s -X POST $URL/api/sessions/oma-anna/route \ Profil global umschalten (Entwickler/Admin): `VA_PROFILE=local-dev make run`. +**Hybrid-Beispiel** (Aufnahme + STT + LLM **lokal**, nur TTS **remote**): +```bash +# einmalig: lokales STT installieren +pip install -e .[local] +# Server mit kleinem lokalem Ollama-Modell (muss in 'ollama list' stehen): +echo 'LOCAL_LLM_MODEL=llama3.2:latest' >> .env +make run +# in Terminal 2 — Sprech-Loop mit der Hybrid-Kombi (MOTU = plughw:5,0): +python scripts/voice_loop.py --recorder arecord --device plughw:5,0 --session hybrid \ + --stt-provider faster-whisper \ + --llm-provider local-openai-compatible \ + --tts-provider openrouter +``` +Erster Turn ist langsamer (Whisper- und Ollama-Modell laden), danach zügig. STT-Modell +und Gerät steuern `FASTER_WHISPER_MODEL`/`FASTER_WHISPER_DEVICE` in `.env`. + ## B2. Soundquelle & Ausgabe-Gerät wechseln (Mikrofon, Lautsprecher, Bluetooth, Handy) > **Wichtig — aktueller Stand:** Die Geräte-Endpunkte **im Gateway** diff --git a/Docs/voice-assistant-architecture.md b/Docs/voice-assistant-architecture.md index ca7c72c..b4df551 100644 --- a/Docs/voice-assistant-architecture.md +++ b/Docs/voice-assistant-architecture.md @@ -195,10 +195,14 @@ erkennung und Barge-in (`interrupt`)**; **Resilienz (Fallback-Ketten je Modul, In-Memory-Metriken `/api/metrics`), Tageskontingent pro Nutzer und heuristische Notfall-Eskalation**; automatisierte Tests. +**Echtes lokales STT:** `faster-whisper` (optionale Dependency `.[local]`, +CTranslate2) transkribiert real — damit ist ein Hybrid „STT+LLM lokal, TTS remote" +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. Lokale Provider `faster-whisper`, `piper`, -`chatterbox` sind Stubs. `transport_router.py` (Ebene 4) existiert, ist aber noch +verdrahtet, kein echtes Hardware-I/O. Lokale TTS-Provider `piper`, `chatterbox` +sind noch Stubs. `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 diff --git a/README.md b/README.md index bdbd698..4dcf103 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Praktische Bedienung: [`BEDIENUNGSANLEITUNG.md`](BEDIENUNGSANLEITUNG.md). ## Features - **Pipeline mit getrennter Semantik/Sprache:** STT → Input-Cleaner → LLM → Spoken-Adapter → TTS-Normalizer → TTS -- **Provider austauschbar** über Registry (OpenRouter remote; faster-whisper/piper/chatterbox als lokale Stubs) +- **Provider austauschbar** über Registry (OpenRouter remote; lokales STT via faster-whisper `.[local]`; piper/chatterbox-TTS noch Stubs) - **Geschichtete Konfiguration** mit Profilen (`local-dev` / `hybrid` / `cloud`) - **Routing auf jeder Ebene:** Default → Profil → Nutzer → Session → Request - **Authentifizierung** (Bearer-Token) + persistente Nutzer/Sessions (SQLite) diff --git a/app/config.py b/app/config.py index 8e2e8d7..2a43dd9 100644 --- a/app/config.py +++ b/app/config.py @@ -120,6 +120,9 @@ class Settings(BaseSettings): local_llm_base_url: str = "http://127.0.0.1:11434/v1" local_llm_api_key: str = "dummy" local_llm_model: str = "llama3.1" + 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 db_path: str = str(BASE_DIR / "data" / "voice-assistant.db") admin_api_key: str = "" auth_enabled: bool = True diff --git a/app/providers/stt/faster_whisper.py b/app/providers/stt/faster_whisper.py index 16fd21c..ae9e157 100644 --- a/app/providers/stt/faster_whisper.py +++ b/app/providers/stt/faster_whisper.py @@ -1,5 +1,49 @@ +"""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) -> str: + model = _load_model(self.model_size, self.device, self.compute_type) + segments, _info = model.transcribe(io.BytesIO(audio_bytes), language=language) + return "".join(segment.text for segment in segments).strip() + async def transcribe(self, audio_bytes: bytes, fmt: str, language: str | None = None) -> str: - return "[local transcription placeholder]" + 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/pyproject.toml b/pyproject.toml index e1d220a..49311d0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,10 @@ dependencies = [ test = [ "pytest>=8.0" ] +# Lokale KI-Module (optional, schwergewichtig): lokales STT via faster-whisper. +local = [ + "faster-whisper>=1.0" +] [build-system] requires = ["setuptools>=68", "wheel"] diff --git a/tests/test_endpoints_e2e.py b/tests/test_endpoints_e2e.py index a8efd5b..f8c680a 100644 --- a/tests/test_endpoints_e2e.py +++ b/tests/test_endpoints_e2e.py @@ -75,14 +75,19 @@ def test_chat_per_request_override_and_loopback(monkeypatch): assert loopback_output().chunks[0].data == b"AUDIO" -def test_transcribe_local_provider(): +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": "faster-whisper"} + 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"] == "faster-whisper" - assert body["trace"]["raw_transcript"] == "[local transcription placeholder]" + assert body["route"]["stt_provider"] == "stub-stt" + assert body["trace"]["raw_transcript"] == "erkannter text" def test_config_endpoint_exposes_no_secrets(): From b75382e0551b93363dd7bb3cdcc310525f2aa55d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Wed, 17 Jun 2026 15:53:51 +0200 Subject: [PATCH 019/113] ux(voice_loop): Aufnahmedauer anzeigen + korrekt berechnen; Diagnose-Skript - record_utterance zeigt nach der Aufnahme die Dauer - Dauer aus tatsaechlicher Datenmenge statt WAV-Header (arecord schreibt beim Stoppen per Signal eine falsche Header-Laenge -> sonst absurde Werte wie 67108s) - scripts/diag_record.py: Countdown + gruenes Signal -> Aufnahme -> sofort transkribieren (Standard + vad_filter) zur Pausen-Diagnose Diagnose-Ergebnis: Mehrsatz-Aeusserungen mit Pausen werden vollstaendig erkannt (Aufnahme + faster-whisper verlieren nichts) - im kontrollierten Test und im echten voice_loop-Flow bestaetigt. Co-Authored-By: Claude Opus 4.8 --- scripts/diag_record.py | 73 ++++++++++++++++++++++++++++++++++++++++++ scripts/voice_loop.py | 13 ++++++++ 2 files changed, 86 insertions(+) create mode 100644 scripts/diag_record.py 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/voice_loop.py b/scripts/voice_loop.py index 9e53f0f..610f0c6 100644 --- a/scripts/voice_loop.py +++ b/scripts/voice_loop.py @@ -109,6 +109,19 @@ def record_utterance(recorder: str, device: str | None, rate: int) -> bytes: 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) From 70c7e2ec0ca19d9434789a21de1dfdebf30658d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Wed, 17 Jun 2026 16:10:00 +0200 Subject: [PATCH 020/113] docs: Top-Konstellation (all-cloud OpenRouter) reproduzierbar + grobe Kosten - Bedienungsanleitung Teil C4: bewaehrte Konstellation (whisper-large-v3 + gemini-3.1-flash-lite + gemini-flash-tts/Zephyr, alle remote), exakte .env-Werte und Start-Befehl zum Reproduzieren - grobe Kostenschaetzung pro Sprech-Runde (~1-2 ct; Treiber = Audio/TTS) mit klarem Hinweis: am OpenRouter-Dashboard verifizieren, Preise aendern sich Co-Authored-By: Claude Opus 4.8 --- BEDIENUNGSANLEITUNG.md | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index b525777..6f82b1d 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -341,6 +341,44 @@ curl -s $URL/api/metrics | jq '.timers | to_entries Empfehlung für den Einstieg: **`cloud`** verwenden, Antwortzeiten mit C2 prüfen, dann bei Bedarf einzelne Module umstellen (Teil B1). +### C4. Empfohlene Top-Konstellation (reproduzierbar) + +Bewährte Konstellation mit sehr guter Sprachqualität (beherrscht u. a. **Plattdeutsch**) — +**alles remote über OpenRouter** (Profil `cloud`), nichts lokal: + +| Stufe | Modell | Anbieter | +|------|--------|----------| +| STT | `openai/whisper-large-v3` | OpenRouter (remote) | +| LLM | `google/gemini-3.1-flash-lite` | OpenRouter (remote) | +| TTS | `google/gemini-3.1-flash-tts-preview` (Stimme `Zephyr`) | OpenRouter (remote) | + +So reproduzierst du sie — 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 +``` +Key via Umgebung (`OPENROUTER_API_KEY`). Dann ohne Provider-Overrides starten: +```bash +make run +python scripts/voice_loop.py --recorder arecord --device plughw:5,0 --session test +``` + +**Grobe Kosten (Daumenwert, am OpenRouter-Dashboard verifizieren — Preise ändern sich):** + +| Stufe | Annahme | ~Kosten/Runde | +|------|---------|---------------| +| STT (Whisper) | ~$0,006/Audio-Min, ~15 s | ~0,15 ¢ | +| LLM (Flash-Lite) | ~800 in / ~150 out Tokens | ~0,01 ¢ (vernachlässigbar) | +| TTS (Flash-TTS) | ~$0,01–0,03/Audio-Min, ~30 s | ~0,5–1,5 ¢ | +| **Summe** | | **≈ 1–2 ¢ pro Sprech-Runde** | + +→ grob **~20–40 ¢ pro 10-Minuten-Gespräch**, **~1–2 $/Stunde**. Kostentreiber ist das +**Audio (TTS, dann STT)**; das LLM ist nahezu kostenlos. Verlässliche Zahlen liefert das +**OpenRouter-Dashboard** (Activity/Usage). + --- # Betrieb & Verwaltung From ced63bac4ce876d93f4ee1dc0b3b0a055a7f518c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Wed, 17 Jun 2026 17:16:05 +0200 Subject: [PATCH 021/113] feat(voice_loop): --stream-audio (satzweises Vorlesen waehrend der Generierung) - neuer Flag --stream-audio setzt audio_stream:true im /ws/voice-Start-Frame - durchgehender Roh-PCM-Player (ffplay/aplay/paplay), Haeppchen werden sofort eingespeist -> Antwort beginnt nach dem ersten Satz, nicht erst nach der ganzen - Schreiben via asyncio.to_thread (Event-Loop bleibt frei -> kein Keepalive-Timeout); Player wird nach Verbindungsschluss geleert - ohne Flag unveraendert (komplettes Audio nach Verbindungsschluss) - Doku-Hinweis ergaenzt; live verifiziert (STT lokal + LLM lokal + TTS remote, satzweise) Co-Authored-By: Claude Opus 4.8 --- BEDIENUNGSANLEITUNG.md | 1 + scripts/voice_loop.py | 88 +++++++++++++++++++++++++++++++++++------- 2 files changed, 75 insertions(+), 14 deletions(-) diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index 6f82b1d..7bd2c35 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -109,6 +109,7 @@ Ablauf je Runde: Nützliche Optionen: ```bash +python scripts/voice_loop.py --stream-audio # satzweises Vorlesen: Antwort beginnt frueher python scripts/voice_loop.py --recorder pw-record # PipeWire-Aufnahme (Standard bei 'auto') python scripts/voice_loop.py --recorder arecord --device hw:1,0 # ALSA, bestimmtes Mikrofon python scripts/voice_loop.py --llm-provider openrouter --tts-provider openrouter diff --git a/scripts/voice_loop.py b/scripts/voice_loop.py index 610f0c6..924b6b7 100644 --- a/scripts/voice_loop.py +++ b/scripts/voice_loop.py @@ -150,8 +150,48 @@ def play_pcm(pcm: bytes) -> None: 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) + if shutil.which("ffplay"): + cmd = ["ffplay", "-loglevel", "quiet", "-nodisp", "-autoexit", + "-f", "s16le", "-ar", rate, "-ac", "1", "-i", "pipe:0"] + elif shutil.which("aplay"): + cmd = ["aplay", "-q", "-f", "S16_LE", "-r", rate, "-c", "1"] + elif shutil.which("paplay"): + cmd = ["paplay", "--raw", f"--rate={rate}", "--format=s16le", "--channels=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() + + def _start_frame(args) -> dict: frame = {"type": "start", "format": "wav"} + if getattr(args, "stream_audio", False): + frame["audio_stream"] = True for key in ("stt_provider", "llm_provider", "tts_provider", "language"): value = getattr(args, key, None) if value: @@ -159,9 +199,13 @@ def _start_frame(args) -> dict: return frame -async def one_turn(ws, wav: bytes, start_frame: dict) -> bytes: - """Sendet eine Aeusserung, gibt das Antwort-Audio (PCM) zurueck. Spielt NICHT ab, - damit die Wiedergabe ausserhalb der offenen Verbindung passieren kann.""" +async def one_turn(ws, wav: bytes, start_frame: dict, player=None) -> bytes: + """Sendet eine Aeusserung und verarbeitet die Antwort-Events. + + Ist `player` gesetzt (Stream-Audio): jedes PCM-Haeppchen wird sofort in den Player + geschrieben (gespielt waehrend die KI weiter generiert) und b"" zurueckgegeben. + Sonst 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"})) @@ -170,7 +214,11 @@ async def one_turn(ws, wav: bytes, start_frame: dict) -> bytes: while True: msg = await ws.recv() if isinstance(msg, (bytes, bytearray)): - audio = bytes(msg) + if player is not None: + # Schreiben im Thread, damit der Event-Loop frei bleibt (Keepalive). + await asyncio.to_thread(_feed_player, player, bytes(msg)) + else: + audio = bytes(msg) continue event = json.loads(msg) etype = event.get("type") @@ -188,13 +236,21 @@ async def one_turn(ws, wav: bytes, start_frame: dict) -> bytes: return audio -async def _send_and_play(url: str, wav: bytes, start_frame: dict) -> None: - """Oeffnet eine kurze Verbindung pro Runde (Gedaechtnis bleibt serverseitig via - session_id erhalten), empfaengt die Antwort und spielt sie NACH dem Schliessen ab. - So blockiert die (lange) Aufnahme/Wiedergabe nie eine offene WebSocket-Verbindung - (kein Keepalive-Timeout).""" - async with websockets.connect(url, max_size=None) as ws: - audio = await one_turn(ws, wav, start_frame) +async def _send_and_play(url: str, wav: bytes, start_frame: dict, stream_audio: bool = False) -> None: + """Eine kurze Verbindung pro Runde (Gedaechtnis bleibt serverseitig via session_id). + + Ohne Stream: komplettes Audio sammeln, NACH dem Schliessen abspielen. + Mit Stream-Audio: durchgehenden Player oeffnen, Haeppchen sofort einspeisen; der + Player wird nach dem Schliessen der Verbindung geleert (kein Keepalive-Timeout).""" + player = open_stream_player() if stream_audio else None + if stream_audio and player is None: + print(" (kein Player fuer Stream-Audio gefunden – spiele am Ende komplett ab)") + try: + async with websockets.connect(url, max_size=None) as ws: + audio = await one_turn(ws, wav, start_frame, player) + finally: + if player is not None: + await asyncio.to_thread(_close_player, player) if audio: play_pcm(audio) @@ -209,11 +265,12 @@ async def run(args) -> None: with open(args.file, "rb") as fh: wav = fh.read() print(f"Sende Datei: {args.file}") - await _send_and_play(url, wav, start_frame) + await _send_and_play(url, wav, start_frame, args.stream_audio) return recorder = resolve_recorder(args.recorder) - print(f"Ziel: {args.url} (Session '{args.session}')") + print(f"Ziel: {args.url} (Session '{args.session}')" + + (" [Stream-Audio: satzweises Vorlesen]" if args.stream_audio else "")) print(f"Aufnahme mit: {recorder}" + (f" (Gerät: {args.device})" if args.device else " (Standardgerät)")) print("Sprich nach 'START', stoppe mit Enter. Strg+C beendet den Loop.") @@ -231,7 +288,7 @@ async def run(args) -> None: print(" python scripts/voice_loop.py --recorder arecord --device plughw:2,0") continue try: - await _send_and_play(url, wav, start_frame) + await _send_and_play(url, wav, start_frame, args.stream_audio) except Exception as exc: # noqa: BLE001 - Verbindung pro Runde; Fehler nicht fatal print(f" Verbindungsfehler: {exc}") @@ -251,6 +308,9 @@ def main() -> None: p.add_argument("--llm-provider", dest="llm_provider", default=None) p.add_argument("--tts-provider", dest="tts_provider", default=None) p.add_argument("--language", default=None) + p.add_argument("--stream-audio", dest="stream_audio", action="store_true", + help="Antwort satzweise vorlesen, sobald der erste Satz fertig ist " + "(geringere Zeit bis zum ersten Ton)") p.add_argument("--file", default=None, help="WAV statt Mikrofon senden (Test ohne Aufnahme)") args = p.parse_args() try: From 2d288be630ba46563755a7ad2a830198029f34af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Wed, 17 Jun 2026 17:32:12 +0200 Subject: [PATCH 022/113] fix(ws/voice): start-Frame-Optionen (Provider, audio_stream, language) ehren Bug: /ws/voice nutzte bei explizitem {"type":"end"} die Optionen aus dem END-Frame. voice_loop schickt Provider und audio_stream aber im START-Frame -> diese wurden komplett ignoriert. Folge: --stt/--llm/--tts-provider und --stream-audio hatten ueber /ws/voice KEINE Wirkung (Hybrid lief faktisch ueber die Cloud-Defaults, audio_stream wurde nie aktiv). Fix: start-Frame als start_options merken und beim Turn mit dem end-Frame mergen ({**start_options, **end}); VAD-Auto-Ende nutzt ebenfalls start_options. Verifiziert: Provider-Override aus dem START-Frame greift (neuer Test); mit audio_stream kommen jetzt mehrere Audio-Chunks ~6-7 s VOR dem fertigen Text (satzweises Vorlesen startet frueher). 65 Tests gruen. Co-Authored-By: Claude Opus 4.8 --- app/api/ws.py | 9 +++++---- tests/test_ws.py | 16 ++++++++++++++++ 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/app/api/ws.py b/app/api/ws.py index f11d1a6..17f6282 100644 --- a/app/api/ws.py +++ b/app/api/ws.py @@ -235,7 +235,7 @@ async def ws_voice(websocket: WebSocket, session_id: str | None = None, token: s fmt = "wav" active = None vad = None - vad_options: dict = {} + start_options: dict = {} # Konfig aus dem start-Frame (Provider, audio_stream, language) async def _start_voice(audio: bytes, options: dict): nonlocal active @@ -259,7 +259,7 @@ async def ws_voice(websocket: WebSocket, session_id: str | None = None, token: s audio = bytes(audio_buffer) audio_buffer.clear() vad.reset() - await _start_voice(audio, vad_options) + await _start_voice(audio, start_options) continue raw = message.get("text") @@ -278,6 +278,7 @@ async def ws_voice(websocket: WebSocket, session_id: str | None = None, token: s 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( @@ -285,7 +286,6 @@ async def ws_voice(websocket: WebSocket, session_id: str | None = None, token: s threshold=control.get("vad_threshold", 500.0), silence_ms=control.get("vad_silence_ms", 700.0), ) - vad_options = control else: vad = None continue @@ -300,7 +300,8 @@ async def ws_voice(websocket: WebSocket, session_id: str | None = None, token: s audio_buffer.clear() if vad is not None: vad.reset() - await _start_voice(audio, control) + # 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() diff --git a/tests/test_ws.py b/tests/test_ws.py index 5619aa7..735bdfa 100644 --- a/tests/test_ws.py +++ b/tests/test_ws.py @@ -108,6 +108,22 @@ def test_ws_voice_transcribes_and_answers(monkeypatch): 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_empty_buffer_errors(monkeypatch): opts = _install_voice_stubs(monkeypatch) with client.websocket_connect("/ws/voice") as ws: From 2e6f2efef6d9829120002187a05b5092f3880b17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Wed, 17 Jun 2026 17:43:22 +0200 Subject: [PATCH 023/113] docs: Sprech-Loop-Streaming aktualisieren (--stream-audio, /ws/voice-Optionen) - Hybrid-Beispiel um --stream-audio ergaenzt - erklaert: --stream-audio liest satzweise vor (Ton beginnt nach 1. Satz); Provider-Overrides und audio_stream wirken jetzt auch ueber /ws/voice (start-Frame) Co-Authored-By: Claude Opus 4.8 --- BEDIENUNGSANLEITUNG.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index 7bd2c35..06a55d4 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -175,7 +175,10 @@ curl -s -X POST "$URL/api/chat?debug=true" \ fließt in die nächste Antwort. Ohne `session_id` ist jeder Aufruf eigenständig. Wie viele Nachrichten einfließen, steuert `HISTORY_MAX_MESSAGES` (Standard 10). - **Echtzeit-Streaming:** WebSocket `/ws/chat` mit `{"text":"…","stream":true}` liefert - die Antwort wortweise; `"audio_stream":true` zusätzlich das Audio satzweise. + die Antwort wortweise; `"audio_stream":true` zusätzlich das Audio satzweise. Im + Sprech-Loop aktivierst du das satzweise Vorlesen mit `--stream-audio` (das Vorlesen + beginnt dann schon nach dem ersten Satz, statt erst nach der ganzen Antwort). + Provider-Overrides und `--stream-audio` wirken auch über `/ws/voice` (start-Frame). - **Unterbrechen (Barge-in):** während der Assistent spricht `{"type":"interrupt"}` senden → laufende Antwort wird abgebrochen. - **Automatische Sprechpausen-Erkennung (VAD):** im Start-Frame von `/ws/voice` @@ -226,12 +229,14 @@ echo 'LOCAL_LLM_MODEL=llama3.2:latest' >> .env make run # in Terminal 2 — Sprech-Loop mit der Hybrid-Kombi (MOTU = plughw:5,0): python scripts/voice_loop.py --recorder arecord --device plughw:5,0 --session hybrid \ + --stream-audio \ --stt-provider faster-whisper \ --llm-provider local-openai-compatible \ --tts-provider openrouter ``` Erster Turn ist langsamer (Whisper- und Ollama-Modell laden), danach zügig. STT-Modell -und Gerät steuern `FASTER_WHISPER_MODEL`/`FASTER_WHISPER_DEVICE` in `.env`. +und Gerät steuern `FASTER_WHISPER_MODEL`/`FASTER_WHISPER_DEVICE` in `.env`. `--stream-audio` +lässt das Vorlesen schon nach dem ersten Satz beginnen (geringere Wartezeit bis zum Ton). ## B2. Soundquelle & Ausgabe-Gerät wechseln (Mikrofon, Lautsprecher, Bluetooth, Handy) From 5296459b079058a578b970b6934fba40768a259c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Wed, 17 Jun 2026 17:47:11 +0200 Subject: [PATCH 024/113] feat(voice_loop): --stream-text (Antworttext live am Monitor) - neuer Flag --stream-text setzt stream:true im /ws/voice-Start-Frame; token-Events werden inline ausgegeben (Wort fuer Wort, waehrend die KI generiert) - mit --stream-audio kombinierbar; bei semantic wird die Live-Zeile abgeschlossen - Token-Events sind winzig -> Audio-Startzeit praktisch unveraendert (gemessen: Text 1. Token lokal ~1 s, Cloud ~3 s; erster Ton wie zuvor) - Test: /ws/voice mit stream:true liefert token-Events; 66 Tests gruen - Doku ergaenzt (Optionen + Feature-Hinweis) Co-Authored-By: Claude Opus 4.8 --- BEDIENUNGSANLEITUNG.md | 7 +++++-- scripts/voice_loop.py | 20 +++++++++++++++++++- tests/test_ws.py | 12 ++++++++++++ 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index 06a55d4..98b5c5f 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -110,6 +110,7 @@ Ablauf je Runde: Nützliche Optionen: ```bash python scripts/voice_loop.py --stream-audio # satzweises Vorlesen: Antwort beginnt frueher +python scripts/voice_loop.py --stream-text # Antworttext live anzeigen (kombinierbar mit --stream-audio) python scripts/voice_loop.py --recorder pw-record # PipeWire-Aufnahme (Standard bei 'auto') python scripts/voice_loop.py --recorder arecord --device hw:1,0 # ALSA, bestimmtes Mikrofon python scripts/voice_loop.py --llm-provider openrouter --tts-provider openrouter @@ -177,8 +178,10 @@ curl -s -X POST "$URL/api/chat?debug=true" \ - **Echtzeit-Streaming:** WebSocket `/ws/chat` mit `{"text":"…","stream":true}` liefert die Antwort wortweise; `"audio_stream":true` zusätzlich das Audio satzweise. Im Sprech-Loop aktivierst du das satzweise Vorlesen mit `--stream-audio` (das Vorlesen - beginnt dann schon nach dem ersten Satz, statt erst nach der ganzen Antwort). - Provider-Overrides und `--stream-audio` wirken auch über `/ws/voice` (start-Frame). + beginnt dann schon nach dem ersten Satz, statt erst nach der ganzen Antwort) und die + **Live-Anzeige des Antworttextes** mit `--stream-text` (erscheint Wort für Wort, + während die KI generiert — beides kombinierbar). Provider-Overrides, `--stream-audio` + und `--stream-text` wirken auch über `/ws/voice` (start-Frame). - **Unterbrechen (Barge-in):** während der Assistent spricht `{"type":"interrupt"}` senden → laufende Antwort wird abgebrochen. - **Automatische Sprechpausen-Erkennung (VAD):** im Start-Frame von `/ws/voice` diff --git a/scripts/voice_loop.py b/scripts/voice_loop.py index 924b6b7..5db35dd 100644 --- a/scripts/voice_loop.py +++ b/scripts/voice_loop.py @@ -192,6 +192,8 @@ def _start_frame(args) -> dict: frame = {"type": "start", "format": "wav"} if getattr(args, "stream_audio", False): frame["audio_stream"] = True + if getattr(args, "stream_text", False): + frame["stream"] = True for key in ("stt_provider", "llm_provider", "tts_provider", "language"): value = getattr(args, key, None) if value: @@ -211,6 +213,7 @@ async def one_turn(ws, wav: bytes, start_frame: dict, player=None) -> bytes: await ws.send(json.dumps({"type": "end"})) audio = b"" + token_started = False while True: msg = await ws.recv() if isinstance(msg, (bytes, bytearray)): @@ -224,11 +227,24 @@ async def one_turn(ws, wav: bytes, start_frame: dict, player=None) -> bytes: 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": - print(f" Assistent: {event.get('text','')!r}") + 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": @@ -311,6 +327,8 @@ def main() -> None: p.add_argument("--stream-audio", dest="stream_audio", action="store_true", help="Antwort satzweise vorlesen, sobald der erste Satz fertig ist " "(geringere Zeit bis zum ersten Ton)") + 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: diff --git a/tests/test_ws.py b/tests/test_ws.py index 735bdfa..e738581 100644 --- a/tests/test_ws.py +++ b/tests/test_ws.py @@ -124,6 +124,18 @@ def test_ws_voice_start_frame_options_are_honored(monkeypatch): 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_empty_buffer_errors(monkeypatch): opts = _install_voice_stubs(monkeypatch) with client.websocket_connect("/ws/voice") as ws: From 7c1f00662cb6ead65ebad8b0876fb2312e84db09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Wed, 17 Jun 2026 18:07:36 +0200 Subject: [PATCH 025/113] feat: Audio-Streaming als Default (Admin abschaltbar via AUDIO_STREAM_DEFAULT) - Config audio_stream_default=True; _run_turn nutzt es, wenn die Anfrage audio_stream nicht explizit setzt (explizite Anfrage gewinnt) - voice_loop: immer durchgehender Player (spielt 1 oder N Haeppchen luekenlos); --stream-audio / --no-stream-audio als Tri-State (sonst entscheidet der Server-Default) - conftest: audio_stream_default=False fuer deterministische Tests; neuer Test fuer Default-an-Streaming ueber /ws/voice; 67 Tests gruen - Doku + .env.example aktualisiert (AUDIO_STREAM_DEFAULT, --no-stream-audio) Co-Authored-By: Claude Opus 4.8 --- .env.example | 4 ++++ BEDIENUNGSANLEITUNG.md | 20 ++++++++++---------- app/api/ws.py | 4 +++- app/config.py | 1 + scripts/voice_loop.py | 36 +++++++++++++++++++----------------- tests/conftest.py | 2 ++ tests/test_ws.py | 13 +++++++++++++ 7 files changed, 52 insertions(+), 28 deletions(-) diff --git a/.env.example b/.env.example index 8dd2e81..fa41003 100644 --- a/.env.example +++ b/.env.example @@ -7,6 +7,10 @@ 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 + # --- 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). diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index 98b5c5f..8dc1287 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -109,8 +109,8 @@ Ablauf je Runde: Nützliche Optionen: ```bash -python scripts/voice_loop.py --stream-audio # satzweises Vorlesen: Antwort beginnt frueher -python scripts/voice_loop.py --stream-text # Antworttext live anzeigen (kombinierbar mit --stream-audio) +python scripts/voice_loop.py --stream-text # Antworttext live anzeigen, waehrend die KI generiert +python scripts/voice_loop.py --no-stream-audio # satzweises Vorlesen abschalten (Audio erst komplett) python scripts/voice_loop.py --recorder pw-record # PipeWire-Aufnahme (Standard bei 'auto') python scripts/voice_loop.py --recorder arecord --device hw:1,0 # ALSA, bestimmtes Mikrofon python scripts/voice_loop.py --llm-provider openrouter --tts-provider openrouter @@ -177,11 +177,11 @@ curl -s -X POST "$URL/api/chat?debug=true" \ Wie viele Nachrichten einfließen, steuert `HISTORY_MAX_MESSAGES` (Standard 10). - **Echtzeit-Streaming:** WebSocket `/ws/chat` mit `{"text":"…","stream":true}` liefert die Antwort wortweise; `"audio_stream":true` zusätzlich das Audio satzweise. Im - Sprech-Loop aktivierst du das satzweise Vorlesen mit `--stream-audio` (das Vorlesen - beginnt dann schon nach dem ersten Satz, statt erst nach der ganzen Antwort) und die - **Live-Anzeige des Antworttextes** mit `--stream-text` (erscheint Wort für Wort, - während die KI generiert — beides kombinierbar). Provider-Overrides, `--stream-audio` - und `--stream-text` wirken auch über `/ws/voice` (start-Frame). + **Satzweises Vorlesen ist jetzt Standard** (das Vorlesen beginnt schon nach dem ersten + Satz; abschaltbar serverseitig mit `AUDIO_STREAM_DEFAULT=false` oder pro Aufruf mit + `--no-stream-audio`). Die **Live-Anzeige des Antworttextes** aktivierst du mit + `--stream-text` (erscheint Wort für Wort, während die KI generiert). Provider-Overrides + und diese Schalter wirken auch über `/ws/voice` (start-Frame). - **Unterbrechen (Barge-in):** während der Assistent spricht `{"type":"interrupt"}` senden → laufende Antwort wird abgebrochen. - **Automatische Sprechpausen-Erkennung (VAD):** im Start-Frame von `/ws/voice` @@ -232,14 +232,14 @@ echo 'LOCAL_LLM_MODEL=llama3.2:latest' >> .env make run # in Terminal 2 — Sprech-Loop mit der Hybrid-Kombi (MOTU = plughw:5,0): python scripts/voice_loop.py --recorder arecord --device plughw:5,0 --session hybrid \ - --stream-audio \ + --stream-text \ --stt-provider faster-whisper \ --llm-provider local-openai-compatible \ --tts-provider openrouter ``` Erster Turn ist langsamer (Whisper- und Ollama-Modell laden), danach zügig. STT-Modell -und Gerät steuern `FASTER_WHISPER_MODEL`/`FASTER_WHISPER_DEVICE` in `.env`. `--stream-audio` -lässt das Vorlesen schon nach dem ersten Satz beginnen (geringere Wartezeit bis zum Ton). +und Gerät steuern `FASTER_WHISPER_MODEL`/`FASTER_WHISPER_DEVICE` in `.env`. Satzweises +Vorlesen ist Standard (früher Ton); `--stream-text` zeigt den Text live dazu. ## B2. Soundquelle & Ausgabe-Gerät wechseln (Mikrofon, Lautsprecher, Bluetooth, Handy) diff --git a/app/api/ws.py b/app/api/ws.py index 17f6282..4227131 100644 --- a/app/api/ws.py +++ b/app/api/ws.py @@ -92,7 +92,9 @@ async def _run_turn(websocket, store, user, session_id, route, orchestrator, out voice = options.get("voice") or settings.openrouter_tts_voice stream = bool(options.get("stream")) - audio_stream = bool(options.get("audio_stream")) + # audio_stream: explizite Anfrage gewinnt, sonst der serverseitige Default (Admin). + audio_stream = bool(options["audio_stream"]) if "audio_stream" in options \ + else settings.audio_stream_default on_token = None if stream: diff --git a/app/config.py b/app/config.py index 2a43dd9..6011091 100644 --- a/app/config.py +++ b/app/config.py @@ -127,6 +127,7 @@ class Settings(BaseSettings): admin_api_key: str = "" auth_enabled: bool = True history_max_messages: int = 10 + audio_stream_default: bool = True # satzweises TTS als Default (Admin kann abschalten) stt_fallback: str = "" # kommaseparierte Provider-Namen (Fallback-Kette) llm_fallback: str = "" tts_fallback: str = "" diff --git a/scripts/voice_loop.py b/scripts/voice_loop.py index 5db35dd..0798968 100644 --- a/scripts/voice_loop.py +++ b/scripts/voice_loop.py @@ -190,8 +190,9 @@ def _close_player(player) -> None: def _start_frame(args) -> dict: frame = {"type": "start", "format": "wav"} - if getattr(args, "stream_audio", False): - frame["audio_stream"] = True + # 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"): @@ -252,22 +253,21 @@ async def one_turn(ws, wav: bytes, start_frame: dict, player=None) -> bytes: return audio -async def _send_and_play(url: str, wav: bytes, start_frame: dict, stream_audio: bool = False) -> None: +async def _send_and_play(url: str, wav: bytes, start_frame: dict) -> None: """Eine kurze Verbindung pro Runde (Gedaechtnis bleibt serverseitig via session_id). - Ohne Stream: komplettes Audio sammeln, NACH dem Schliessen abspielen. - Mit Stream-Audio: durchgehenden Player oeffnen, Haeppchen sofort einspeisen; der - Player wird nach dem Schliessen der Verbindung geleert (kein Keepalive-Timeout).""" - player = open_stream_player() if stream_audio else None - if stream_audio and player is None: - print(" (kein Player fuer Stream-Audio gefunden – spiele am Ende komplett ab)") + 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() try: async with websockets.connect(url, max_size=None) as ws: audio = await one_turn(ws, wav, start_frame, player) finally: if player is not None: await asyncio.to_thread(_close_player, player) - if audio: + if audio: # nur wenn kein Streaming-Player verfuegbar war play_pcm(audio) @@ -281,12 +281,11 @@ async def run(args) -> None: with open(args.file, "rb") as fh: wav = fh.read() print(f"Sende Datei: {args.file}") - await _send_and_play(url, wav, start_frame, args.stream_audio) + await _send_and_play(url, wav, start_frame) return recorder = resolve_recorder(args.recorder) - print(f"Ziel: {args.url} (Session '{args.session}')" - + (" [Stream-Audio: satzweises Vorlesen]" if args.stream_audio else "")) + print(f"Ziel: {args.url} (Session '{args.session}')") print(f"Aufnahme mit: {recorder}" + (f" (Gerät: {args.device})" if args.device else " (Standardgerät)")) print("Sprich nach 'START', stoppe mit Enter. Strg+C beendet den Loop.") @@ -304,7 +303,7 @@ async def run(args) -> None: print(" python scripts/voice_loop.py --recorder arecord --device plughw:2,0") continue try: - await _send_and_play(url, wav, start_frame, args.stream_audio) + await _send_and_play(url, wav, start_frame) except Exception as exc: # noqa: BLE001 - Verbindung pro Runde; Fehler nicht fatal print(f" Verbindungsfehler: {exc}") @@ -324,9 +323,12 @@ def main() -> None: p.add_argument("--llm-provider", dest="llm_provider", default=None) p.add_argument("--tts-provider", dest="tts_provider", default=None) p.add_argument("--language", default=None) - p.add_argument("--stream-audio", dest="stream_audio", action="store_true", - help="Antwort satzweise vorlesen, sobald der erste Satz fertig ist " - "(geringere Zeit bis zum ersten Ton)") + # 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)") diff --git a/tests/conftest.py b/tests/conftest.py index cf9e574..ca09f51 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -17,6 +17,8 @@ def reset_state(tmp_path, monkeypatch): 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 diff --git a/tests/test_ws.py b/tests/test_ws.py index e738581..2e3e1f2 100644 --- a/tests/test_ws.py +++ b/tests/test_ws.py @@ -136,6 +136,19 @@ def test_ws_voice_stream_text_emits_token_events(monkeypatch): 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: From 67d807cbcc728b47c8f35345cbef2209a28e47b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Wed, 17 Jun 2026 18:08:58 +0200 Subject: [PATCH 026/113] docs: Hybrid-Konstellation + Kostenvergleich (vs. all-cloud Ideal) Bedienungsanleitung Teil C5: Hybrid (STT+LLM lokal, TTS remote) mit Kostentabelle. Ersparnis nur ~0,15 ct/Runde (~10-15 %), da das remote TTS der Kostentreiber ist und remote bleibt; echter Gewinn = Datenschutz. Fuer echte Kostensenkung muesste auch das TTS lokal laufen (piper, derzeit Stub). Co-Authored-By: Claude Opus 4.8 --- BEDIENUNGSANLEITUNG.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index 8dc1287..c71c33e 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -388,6 +388,25 @@ python scripts/voice_loop.py --recorder arecord --device plughw:5,0 --session te **Audio (TTS, dann STT)**; das LLM ist nahezu kostenlos. Verlässliche Zahlen liefert das **OpenRouter-Dashboard** (Activity/Usage). +### C5. Hybrid-Konstellation (STT + LLM lokal, TTS remote) — Kostenvergleich + +Konstellation: **STT** lokal (`faster-whisper`) → **KI** lokal (Ollama, z. B. `llama3.1:8b`) +→ **TTS** remote (Gemini/Zephyr). Befehl: siehe Hybrid-Beispiel in Teil B1. + +| | STT | LLM | TTS | API-Kosten/Runde | +|---|-----|-----|-----|------------------| +| **Ideal (all-cloud)** | remote ~0,15 ¢ | remote ~0,01 ¢ | remote ~0,5–1,5 ¢ | **~1–2 ¢** | +| **Hybrid** | lokal (nur Strom) | lokal (nur Strom) | remote ~0,5–1,5 ¢ | **~0,5–1,5 ¢** | + +**Ersparnis: nur ~0,15 ¢/Runde (~10–15 %)** — winzig, weil der Kostentreiber das **remote +TTS** ist und remote bleibt; STT und LLM waren in der Cloud ohnehin sehr billig. Der echte +Gewinn des Hybrids ist **Datenschutz** (Spracherkennung + Verständnis bleiben lokal), nicht +die Kosten. Dafür: lokaler **Stromverbrauch** der GPUs, langsamerer erster Turn (Modell-Kaltstart) +und bei Dialekt/Plattdeutsch etwas schwächer als Cloud-Gemini. + +→ **Für echte Kostensenkung** müsste auch das **TTS lokal** laufen (z. B. `piper` — derzeit +noch Platzhalter); dann ~gratis (nur Strom), aber geringere Sprachqualität. + --- # Betrieb & Verwaltung From ece64711a558043f3eac3437f3211a63af0ff973 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Wed, 17 Jun 2026 18:30:56 +0200 Subject: [PATCH 027/113] =?UTF-8?q?docs:=20OS-Audio-Ger=C3=A4te=20wechseln?= =?UTF-8?q?=20(GUI/Bluetooth/ReSpeaker)=20in=20Teil=20B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ReSpeaker-Mikrofon als Eingabe und Bluetooth-Box als Ausgabe ueber die Ubuntu-Toneinstellungen waehlen; Bluetooth-Kopplung, pactl-Aequivalente, weitere Einstellungen (Lautstaerke/Gain, Pro-App-Routing). Erklaert das Zusammenspiel mit voice_loop: Ausgabe folgt dem System-Default automatisch, Eingabe braucht --device plughw:6,0 (ReSpeaker = Karte 6). Co-Authored-By: Claude Opus 4.8 --- BEDIENUNGSANLEITUNG.md | 86 ++++++++++++++++++++++++++++++++++-------- 1 file changed, 70 insertions(+), 16 deletions(-) diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index c71c33e..cfbb516 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -250,27 +250,81 @@ Vorlesen ist Standard (früher Ton); `--stream-text` zeigt den Text live dazu. > Gateway**. Welches Mikrofon/welcher Lautsprecher/welches Bluetooth-Gerät tatsächlich > genutzt wird, steuerst du **heute auf Betriebssystem-Ebene** (bei Aufnahme/Wiedergabe). -**Welche Geräte gibt es?** +### Der einfachste Weg: Ubuntu-Systemeinstellungen (grafisch) — empfohlen + +So hat es der Nutzer erfolgreich gemacht (Eingabe = ReSpeaker-Mikrofon, Ausgabe = +Bose-Bluetooth-Box). Das ist der **bequemste** Weg und gilt systemweit: + +1. **Einstellungen → Ton** öffnen (oben rechts auf das Lautstärke-Symbol → *Toneinstellungen*, + oder *Aktivitäten → „Ton" suchen*). +2. **Ausgabe (Output):** Unter *Ausgabegerät* das gewünschte Gerät wählen — z. B. + die Bluetooth-Box. Bluetooth-Geräte erscheinen hier erst, nachdem sie **gekoppelt** + sind (siehe unten). +3. **Eingabe (Input):** Unter *Eingabegerät* das Mikrofon wählen — z. B. + **„reSpeaker XVF3800 4-Mic Array"**. Der Pegelbalken zeigt, ob das Mikro Schall + empfängt (probehalber sprechen). +4. **Lautstärke/Pegel** lassen sich auf derselben Seite pro Gerät einstellen + (Ausgabe-Lautstärke, Eingabe-Empfindlichkeit/Gain). + +**Bluetooth-Box koppeln (einmalig):** *Einstellungen → Bluetooth → Bluetooth einschalten*, +die Box in den Kopplungsmodus bringen (bei der Bose Revolve SoundLink die Bluetooth-Taste +gedrückt halten, bis der Kopplungston kommt), in der Liste anwählen → *Verbinden*. Danach +taucht sie unter *Ton → Ausgabegerät* auf und wird oft automatisch als Standard gesetzt. + +### Per Kommandozeile (gleicher Effekt, ohne GUI) + ```bash -arecord -L # Eingabegeräte (Mikrofone) -aplay -L # Ausgabegeräte (Lautsprecher/Kopfhörer) +arecord -L # Eingabegeräte (Mikrofone) auflisten +aplay -L # Ausgabegeräte (Lautsprecher/Kopfhörer/Bluetooth) auflisten +arecord -l # Karten-/Geräte-Nummern (hw:X,Y) – hier z. B. Karte 6 = ReSpeaker +pactl info # aktuelle Standard-Quelle/-Senke anzeigen +pactl list short sources # alle Quellen (Mikrofone) +pactl list short sinks # alle Senken (Ausgaben, inkl. Bluetooth) + +# Standard-Gerät systemweit setzen (Apps, die dem Default folgen, nutzen es dann): +pactl set-default-source # z. B. das ReSpeaker +pactl set-default-sink # z. B. die Bluetooth-Box ``` -**Mikrofon (Quelle) wählen:** -```bash -python scripts/voice_loop.py --device hw:1,0 # Helfer mit bestimmtem Mikrofon -arecord -D hw:1,0 -f S16_LE -r 16000 -c 1 frage.wav # manuell -``` +> Hinweis zu diesem Rechner: `wpctl`/`pw-record` melden hier teils +> `pw_context_connect() failed`. **`pactl`** funktioniert dagegen zuverlässig (über +> `pipewire-pulse`). Für feines Routing pro App gibt es grafisch **`pavucontrol`** +> (Reiter *Wiedergabe*/*Aufnahme* → einzelne App auf ein bestimmtes Gerät legen). -**Lautsprecher/Kopfhörer (Ausgabe) wählen:** -```bash -aplay -D hw:0,0 -f S16_LE -r 24000 -c 1 antwort.pcm -``` +### Wie das mit dem Sprech-Loop (`voice_loop.py`) zusammenspielt — wichtig -**Bluetooth / Standardgerät (PipeWire/PulseAudio):** Gerät am System koppeln und als -Standard setzen — dann nutzen `arecord`/`aplay`/`voice_loop.py` automatisch dieses -Gerät. Grafisch mit `pavucontrol`, per Kommandozeile z. B. mit `wpctl status` / -`wpctl set-default `. +- **Ausgabe (Wiedergabe):** `voice_loop.py` spielt über das **System-Standard-Ausgabegerät**. + Sobald die Bluetooth-Box dort als Standard gesetzt ist (Schritt 2 oben), kommt die + gesprochene Antwort **automatisch über die Box** — ohne zusätzliche Option. Das ist der + Grund, warum die Bluetooth-Umleitung „einfach funktioniert". +- **Eingabe (Aufnahme):** Die Aufnahme folgt **nicht** automatisch dem System-Default — + `voice_loop.py` nimmt mit einem fest gewählten Gerät auf. Damit der **ReSpeaker** genutzt + wird, das Gerät explizit angeben (ReSpeaker = **Karte 6** → `plughw:6,0`): + + ```bash + python scripts/voice_loop.py --recorder arecord --device plughw:6,0 + ``` + + (Auf diesem Rechner ist `--recorder arecord` mit `plughw:…` der zuverlässige Weg, weil die + Default-folgenden Recorder `pw-record`/`parecord` hier nicht stabil verbinden. Die richtige + Kartennummer notfalls mit `arecord -l` prüfen.) + +Kurz: **Lautsprecher/Bluetooth umstellen → System-Einstellungen genügen.** +**Mikrofon für den Sprech-Loop → zusätzlich `--device plughw:6,0` mitgeben.** + +### Weitere Einstellungen, die du vornehmen kannst +- **Ausgabe-Lautstärke / Mikrofon-Empfindlichkeit:** *Ton*-Seite oder + `pactl set-sink-volume 80%` / `pactl set-source-volume 80%`. +- **Stummschalten:** *Ton*-Seite oder `pactl set-sink-mute toggle`. +- **Pro-App-Routing:** `pavucontrol` → eine laufende App gezielt auf ein anderes Gerät legen + (z. B. nur den Player auf die Bluetooth-Box, Systemtöne aufs interne Audio). +- **Zurückschalten:** in den *Ton*-Einstellungen wieder das alte Gerät wählen (z. B. zurück + auf die MOTU M2, Karte 5 → `plughw:5,0` im Sprech-Loop). +- **Manuelle Aufnahme/Wiedergabe mit bestimmtem Gerät** (zum Testen ohne Sprech-Loop): + ```bash + arecord -D plughw:6,0 -f S16_LE -r 16000 -c 1 frage.wav # ReSpeaker + aplay -D plughw:5,0 -f S16_LE -r 24000 -c 1 antwort.pcm # bestimmte Ausgabe + ``` **Gateway-Endpunkt-Auswahl (Routing-Ebene, vorbereitet):** ```bash From 84d6ac6e7b78444f44312962a646e2fbbce6753d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Wed, 17 Jun 2026 21:46:19 +0200 Subject: [PATCH 028/113] fix(voice_loop): 1011-Keepalive-Timeout bei langen Antworten beheben Empfang und Echtzeit-Wiedergabe entkoppeln: ein _player_feeder spielt PCM- Haeppchen aus einer asyncio.Queue ab, der Empfangs-Loop legt nur per put_nowait ab und drainiert die WebSocket-Verbindung durchgehend. Vorher blockierte das Schreiben in den Player (Echtzeit, voller Puffer) den Empfangs-Loop -> Frames stauten sich -> websockets pausierte per Backpressure den Reader -> Pings/Pongs unbearbeitet -> 1011 keepalive ping timeout (trat bei langen Antworten auf). Zusaetzlich defensiv: ping_timeout=60, max_queue=None. Co-Authored-By: Claude Opus 4.8 --- scripts/voice_loop.py | 42 +++++++++++++++++++++++++++++++++--------- 1 file changed, 33 insertions(+), 9 deletions(-) diff --git a/scripts/voice_loop.py b/scripts/voice_loop.py index 0798968..f03e08a 100644 --- a/scripts/voice_loop.py +++ b/scripts/voice_loop.py @@ -188,6 +188,22 @@ def _close_player(player) -> None: 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) + + def _start_frame(args) -> dict: frame = {"type": "start", "format": "wav"} # audio_stream ist Tri-State: True/False explizit, None -> Server-Default entscheidet. @@ -202,12 +218,13 @@ def _start_frame(args) -> dict: return frame -async def one_turn(ws, wav: bytes, start_frame: dict, player=None) -> bytes: +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 `player` gesetzt (Stream-Audio): jedes PCM-Haeppchen wird sofort in den Player - geschrieben (gespielt waehrend die KI weiter generiert) und b"" zurueckgegeben. - Sonst wird das komplette Audio gesammelt und zurueckgegeben (Wiedergabe spaeter). + 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) @@ -218,9 +235,9 @@ async def one_turn(ws, wav: bytes, start_frame: dict, player=None) -> bytes: while True: msg = await ws.recv() if isinstance(msg, (bytes, bytearray)): - if player is not None: - # Schreiben im Thread, damit der Event-Loop frei bleibt (Keepalive). - await asyncio.to_thread(_feed_player, player, bytes(msg)) + if queue is not None: + # Nicht blockierend ablegen -> Verbindung wird durchgehend drainiert. + queue.put_nowait(bytes(msg)) else: audio = bytes(msg) continue @@ -261,10 +278,17 @@ async def _send_and_play(url: str, wav: bytes, start_frame: dict) -> None: 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"" try: - async with websockets.connect(url, max_size=None) as ws: - audio = await one_turn(ws, wav, start_frame, player) + # 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: + audio = await one_turn(ws, wav, start_frame, queue) finally: + if feeder is not None: + queue.put_nowait(None) # Feeder beenden, restliche Haeppchen noch abspielen + await feeder if player is not None: await asyncio.to_thread(_close_player, player) if audio: # nur wenn kein Streaming-Player verfuegbar war From cca423dac367f472e8434a113ba4545c7b091708 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Wed, 17 Jun 2026 22:11:20 +0200 Subject: [PATCH 029/113] feat(tts): echtes lokales TTS via piper (kein Stub mehr) PiperTTSProvider ruft das piper-Binary (--output-raw) async auf, liest die native Sample-Rate aus der .onnx.json und resampelt per ffmpeg auf 24000 Hz (Gateway-Norm). Nicht passende Stimmen (z. B. Cloud-Stimme 'Zephyr' aus der Route) fallen auf die konfigurierte Default-Stimme zurueck. Damit ist eine voll-lokale Konstellation (faster-whisper + Ollama + piper) moeglich -> keine API-Kosten, max. Datenschutz. - config: PIPER_BIN/PIPER_VOICES_DIR/PIPER_VOICE/TTS_SAMPLE_RATE (+ .env.example) - dependencies: piper-Factory mit Settings verdrahtet - tests: tests/test_piper_tts.py (offline, Fake-Binary; Resample-Test skippt ohne ffmpeg); e2e/auth-Tests nutzen jetzt einen Stub-TTS statt 'piper' als Pseudo-Stub - docs: README, BEDIENUNGSANLEITUNG (voll-lokal-Beispiel), Architektur-Roadmap Co-Authored-By: Claude Opus 4.8 --- .env.example | 9 ++ BEDIENUNGSANLEITUNG.md | 20 +++++ Docs/voice-assistant-architecture.md | 3 +- README.md | 4 +- app/config.py | 5 ++ app/dependencies.py | 4 +- app/providers/tts/piper.py | 127 ++++++++++++++++++++++++++- tests/test_auth.py | 11 ++- tests/test_endpoints_e2e.py | 25 ++++-- tests/test_piper_tts.py | 76 ++++++++++++++++ 10 files changed, 269 insertions(+), 15 deletions(-) create mode 100644 tests/test_piper_tts.py diff --git a/.env.example b/.env.example index fa41003..326f357 100644 --- a/.env.example +++ b/.env.example @@ -46,6 +46,15 @@ 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. +# Modelle laden: python -m piper.download_voices de_DE-thorsten-high (o. manuell). +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) + # --- Resilienz: Fallback-Ketten (kommaseparierte Provider-Namen) ------------ # Faellt der primaere Provider aus, uebernimmt der naechste. # STT_FALLBACK=faster-whisper diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index cfbb516..bf0d14b 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -241,6 +241,26 @@ Erster Turn ist langsamer (Whisper- und Ollama-Modell laden), danach zügig. STT und Gerät steuern `FASTER_WHISPER_MODEL`/`FASTER_WHISPER_DEVICE` in `.env`. Satzweises Vorlesen ist Standard (früher Ton); `--stream-text` zeigt den Text live dazu. +**Voll-lokal-Beispiel** (STT + LLM + TTS **alles lokal** — kein API-Geld, maximaler +Datenschutz). TTS läuft hier über **piper** (lokales, CPU-freundliches Neural-TTS): +```bash +# einmalig: lokales STT installieren +pip install -e .[local] +# piper-Binary + Stimme bereitstellen: die Stimm-Dateien (.onnx + .onnx.json) +# liegen im PIPER_VOICES_DIR (Default ~/.local/share/piper/voices). Deutsche Stimmen z. B. +# von huggingface 'rhasspy/piper-voices' (de_DE-thorsten-high, de_DE-kerstin-low). +# Verfügbare Stimmen prüfen: ls ~/.local/share/piper/voices/*.onnx +# Sprech-Loop voll-lokal (ReSpeaker = plughw:6,0): +python scripts/voice_loop.py --recorder arecord --device plughw:6,0 --session lokal \ + --stt-provider faster-whisper \ + --llm-provider local-openai-compatible \ + --tts-provider piper +``` +piper-Stimme/Verzeichnis steuern `PIPER_VOICE`/`PIPER_VOICES_DIR` in `.env` (Default +`de_DE-thorsten-high`). Die Stimme klingt etwas synthetischer als das Cloud-TTS, kostet +aber **nichts** und verlässt den Rechner nie. Liefert eine Stimme nicht 24000 Hz (z. B. +`de_DE-thorsten-high` = 22050 Hz), resampelt das Gateway automatisch per `ffmpeg`. + ## B2. Soundquelle & Ausgabe-Gerät wechseln (Mikrofon, Lautsprecher, Bluetooth, Handy) > **Wichtig — aktueller Stand:** Die Geräte-Endpunkte **im Gateway** diff --git a/Docs/voice-assistant-architecture.md b/Docs/voice-assistant-architecture.md index b4df551..fd2c6dd 100644 --- a/Docs/voice-assistant-architecture.md +++ b/Docs/voice-assistant-architecture.md @@ -215,7 +215,8 @@ Reihenfolge der Weiterentwicklung: 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 heuristische Notfall-Eskalation (Erkennung -> Log + optionaler Webhook + Flag/Event). Offen: echte Klassifikation statt Schluesselwort-Heuristik, Telefon-/Angehoerigen-Integration, Abrechnung. -7. **TransportRouter** als eigene lokal/remote-Achse aktivieren. +7. **(weitgehend erledigt)** Lokale Provider: STT via `faster-whisper` (`.[local]`) und **TTS via `piper`** (lokales Neural-TTS, ffmpeg-Resampling auf 24000 Hz) sind echt — eine **voll-lokale Konstellation** (STT+LLM+TTS lokal, keine API-Kosten, max. Datenschutz) ist damit möglich. Offen: `chatterbox`-TTS (noch Stub), höhere Sprachqualität als piper. +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, diff --git a/README.md b/README.md index 4dcf103..f140c19 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Praktische Bedienung: [`BEDIENUNGSANLEITUNG.md`](BEDIENUNGSANLEITUNG.md). ## Features - **Pipeline mit getrennter Semantik/Sprache:** STT → Input-Cleaner → LLM → Spoken-Adapter → TTS-Normalizer → TTS -- **Provider austauschbar** über Registry (OpenRouter remote; lokales STT via faster-whisper `.[local]`; piper/chatterbox-TTS noch Stubs) +- **Provider austauschbar** über Registry (OpenRouter remote; lokales STT via faster-whisper `.[local]`; **lokales TTS via piper**; chatterbox-TTS noch Stub) - **Geschichtete Konfiguration** mit Profilen (`local-dev` / `hybrid` / `cloud`) - **Routing auf jeder Ebene:** Default → Profil → Nutzer → Session → Request - **Authentifizierung** (Bearer-Token) + persistente Nutzer/Sessions (SQLite) @@ -103,7 +103,7 @@ Aktive Konfiguration prüfen: `curl http://localhost:8080/api/config`. | `WS /ws/voice` | Echtzeit-Sprache (Audio rein → Transkript → Antwort) | | `GET /api/metrics` | Metriken (JSON, oder `?format=prometheus`) | -Beispiel (Sprachausgabe an den Test-Loopback, lokaler TTS-Stub): +Beispiel (Sprachausgabe an den Test-Loopback; `piper` = lokales TTS): ```bash curl -X POST http://localhost:8080/api/speak \ diff --git a/app/config.py b/app/config.py index 6011091..44e6198 100644 --- a/app/config.py +++ b/app/config.py @@ -123,6 +123,11 @@ class Settings(BaseSettings): 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) db_path: str = str(BASE_DIR / "data" / "voice-assistant.db") admin_api_key: str = "" auth_enabled: bool = True diff --git a/app/dependencies.py b/app/dependencies.py index c686400..f0050e9 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -64,7 +64,9 @@ TTS_REGISTRY = { s.openrouter_api_key, s.openrouter_tts_model, s.openrouter_tts_voice ), "chatterbox": lambda s: ChatterboxTTSProvider(), - "piper": lambda s: PiperTTSProvider(), + "piper": lambda s: PiperTTSProvider( + s.piper_bin, s.piper_voices_dir, s.piper_voice, s.tts_sample_rate + ), } diff --git a/app/providers/tts/piper.py b/app/providers/tts/piper.py index 7a8e9b9..5cc77ed 100644 --- a/app/providers/tts/piper.py +++ b/app/providers/tts/piper.py @@ -1,5 +1,128 @@ +"""Lokales TTS über das piper-Binary (CPU-freundlich, niedrige Latenz, offline). + +piper synthetisiert rohes s16le-Mono-PCM in der Sample-Rate des Stimmmodells. Das +Gateway erwartet 24000 Hz -> bei Abweichung wird mit ffmpeg resampelt. So bleibt eine +voll-lokale Konstellation (STT + LLM + TTS lokal) möglich (Datenschutz, keine API-Kosten). +""" + +from __future__ import annotations + +import asyncio +import io +import json +import shutil +import wave +from pathlib import Path + from app.providers.tts.base import TTSProvider + +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() + + class PiperTTSProvider(TTSProvider): - async def synthesize(self, text: str, voice: str | None = None, audio_format: str = "pcm") -> bytes: - return b"" + def __init__( + self, + bin_path: str = "piper", + voices_dir: str = "", + voice: str = "", + target_rate: int = 24000, + ): + self.bin_path = (bin_path or "piper").strip() + self.voices_dir = Path(voices_dir).expanduser() if voices_dir else Path.cwd() + self.voice = (voice or "").strip() + self.target_rate = int(target_rate) + + def _model_paths(self, voice: str | None) -> 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", + ) -> bytes: + if not text or not text.strip(): + raise ValueError("TTS input text is empty") + if not (shutil.which(self.bin_path) or Path(self.bin_path).exists()): + raise RuntimeError(f"Piper-Binary nicht gefunden: {self.bin_path}") + + model, config = self._model_paths(voice) + native_rate = self._native_rate(config) + + pcm = await self._run_piper(model, config, text.strip()) + if native_rate != self.target_rate: + pcm = await self._resample(pcm, native_rate, self.target_rate) + + if audio_format == "wav": + return _wrap_wav(pcm, self.target_rate) + return pcm + + async def _run_piper(self, model: Path, config: Path, text: str) -> bytes: + cmd = [self.bin_path, "-m", str(model), "-c", str(config), "--output-raw"] + proc = await asyncio.create_subprocess_exec( + *cmd, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + out, err = await proc.communicate(text.encode("utf-8")) + if proc.returncode != 0: + detail = err.decode("utf-8", "replace").strip()[:300] + raise RuntimeError(f"Piper-Fehler ({proc.returncode}): {detail}") + if not out: + raise RuntimeError("Piper lieferte kein Audio") + return out + + async def _resample(self, pcm: bytes, src_rate: int, dst_rate: int) -> bytes: + if not shutil.which("ffmpeg"): + raise RuntimeError( + f"ffmpeg fehlt: Piper-Stimme liefert {src_rate} Hz, benötigt werden {dst_rate} Hz" + ) + cmd = [ + "ffmpeg", "-loglevel", "quiet", + "-f", "s16le", "-ar", str(src_rate), "-ac", "1", "-i", "pipe:0", + "-f", "s16le", "-ar", str(dst_rate), "-ac", "1", "pipe:1", + ] + proc = await asyncio.create_subprocess_exec( + *cmd, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + out, err = await proc.communicate(pcm) + if proc.returncode != 0 or not out: + detail = err.decode("utf-8", "replace").strip()[:300] + raise RuntimeError(f"ffmpeg-Resampling fehlgeschlagen: {detail}") + return out diff --git a/tests/test_auth.py b/tests/test_auth.py index ba3959b..07e31da 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1,5 +1,6 @@ from fastapi.testclient import TestClient +import app.dependencies as deps from app.main import app from app.config import settings @@ -67,17 +68,23 @@ def test_tenant_isolation_returns_403(monkeypatch): def test_user_prefs_applied_to_route(monkeypatch): _enable_auth(monkeypatch) + + class StubTTS: + async def synthesize(self, text, voice=None, audio_format="pcm"): + 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": "piper", "output_endpoint": "loopback"}, + 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"] == "piper" + assert resp.headers["X-TTS-Provider"] == "stub-tts" assert resp.headers["X-Output-Endpoint"] == "loopback" diff --git a/tests/test_endpoints_e2e.py b/tests/test_endpoints_e2e.py index f8c680a..7cac465 100644 --- a/tests/test_endpoints_e2e.py +++ b/tests/test_endpoints_e2e.py @@ -9,15 +9,25 @@ from tests.conftest import loopback_output client = TestClient(app) -def test_speak_loopback_collects_chunks(): - # piper-Stub liefert b"" -> kein Netzcall; Loopback sammelt den Chunk. +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"): + 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": "piper", "output_endpoint": "loopback"}, + 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"] == "piper" + assert resp.headers["X-TTS-Provider"] == tts assert len(loopback_output().chunks) == 1 @@ -35,15 +45,16 @@ def test_unknown_provider_returns_422(): assert resp.status_code == 422 -def test_session_route_applies(): +def test_session_route_applies(monkeypatch): + tts = _stub_tts(monkeypatch) client.post( "/api/sessions/s1/route", - json={"tts_provider": "piper", "output_endpoint": "loopback"}, + 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"] == "piper" + assert resp.headers["X-TTS-Provider"] == tts def test_chat_per_request_override_and_loopback(monkeypatch): diff --git a/tests/test_piper_tts.py b/tests/test_piper_tts.py new file mode 100644 index 0000000..4860c19 --- /dev/null +++ b/tests/test_piper_tts.py @@ -0,0 +1,76 @@ +"""Tests fuer den lokalen Piper-TTS-Provider (offline, mit Fake-Binary).""" + +import asyncio +import json +import shutil +import stat + +import pytest + +from app.providers.tts.piper import PiperTTSProvider + +# PCM-Nutzlast des Fake-Binaries: gross genug, dass der ffmpeg-Resampler Samples ausgibt +# (bei sehr kurzen Eingaben puffert er und liefert nichts). 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 From c9d8552ec7bc34595a26974d021e6fe0a1835a5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Wed, 17 Jun 2026 23:01:41 +0200 Subject: [PATCH 030/113] feat(tts): Aussprache-Normalisierung vor Piper (Ordinalia/Einheiten/Abk./Lexikon) In-Process-Layer ausgebaut statt neuer Lib/CLI. Leitprinzip: nicht duplizieren, was espeak-ng schon kann (Kardinal-/Dezimalzahlen bleiben unangetastet) -- nur die belegten Luecken fuellen. - german_numbers.py: deutsche Ordinalzahlen 1.-31. (attributiv/adverbial) - tts_normalizer.py: Ordinalia (Datum '1. Mai'->'erster Mai', Folgen '1. 2. 3.'-> 'erstens, zweitens, ...'), Einheiten nach Zahl (kg/km/km-h/...), Abkuerzungen (Dr./z.B./usw.), optionales YAML-Lexikon (config/pronunciation..yaml). Provider-abhaengige Stufen auto|full|light|off (TTS_NORMALIZE_LEVEL): piper=full, Cloud=light (laesst Zahlen/Abk. fuer das Cloud-Modell in Ruhe). - spoken_response_adapter.py: nummerierte Listen -> Ordinalwoerter statt Loeschen. - sentence_chunker.py: trennt nicht mehr nach Ziffer+Punkt, Einzelbuchstabe+Punkt ('z. B.', Initialen) oder bekannten Abkuerzungen -> behebt das Streaming-Symptom ('1.' wurde als eigener 'Satz' zu 'eins'). - orchestrator/dependencies: normalize_level durchgereicht (auto: piper->full). - Tests: tests/test_tts_normalizer.py + Chunker-Faelle (85 gruen). - Doku: BEDIENUNGSANLEITUNG (Aussprache verbessern), .env.example. Co-Authored-By: Claude Opus 4.8 --- .env.example | 6 + BEDIENUNGSANLEITUNG.md | 10 ++ app/config.py | 2 + app/core/orchestrator.py | 14 +- app/dependencies.py | 9 ++ app/pipeline/german_numbers.py | 37 +++++ app/pipeline/sentence_chunker.py | 41 ++++- app/pipeline/spoken_response_adapter.py | 12 +- app/pipeline/tts_normalizer.py | 197 ++++++++++++++++++------ config/pronunciation.de.yaml | 23 +++ config/pronunciation.en.yaml | 12 ++ tests/test_streaming.py | 9 ++ tests/test_tts_normalizer.py | 89 +++++++++++ 13 files changed, 408 insertions(+), 53 deletions(-) create mode 100644 app/pipeline/german_numbers.py create mode 100644 config/pronunciation.de.yaml create mode 100644 config/pronunciation.en.yaml create mode 100644 tests/test_tts_normalizer.py diff --git a/.env.example b/.env.example index 326f357..43ccce8 100644 --- a/.env.example +++ b/.env.example @@ -11,6 +11,12 @@ OPENROUTER_API_KEY= # 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). diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index bf0d14b..0fe9e7c 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -261,6 +261,16 @@ piper-Stimme/Verzeichnis steuern `PIPER_VOICE`/`PIPER_VOICES_DIR` in `.env` (Def aber **nichts** und verlässt den Rechner nie. Liefert eine Stimme nicht 24000 Hz (z. B. `de_DE-thorsten-high` = 22050 Hz), resampelt das Gateway automatisch per `ffmpeg`. +**Aussprache verbessern (nur lokales TTS):** Vor Piper läuft ein Normalizer, der typische +Stolpersteine glättet — Ordinalzahlen („1. Mai" → „erster Mai", „1. 2. 3." → „erstens, +zweitens, drittens"), Einheiten („10 kg" → „… Kilogramm", „km/h" → „Kilometer pro Stunde") +und Abkürzungen („Dr." → „Doktor", „z. B." → „zum Beispiel"). Reine Zahlen („123 Euro", +„3,5") bleiben unangetastet — die spricht espeak-ng in Piper schon korrekt. +- Stärke per `TTS_NORMALIZE_LEVEL` (`auto|full|light|off`): `auto` = Piper bekommt `full`, + Cloud-TTS `light` (Cloud spricht Zahlen/Abkürzungen selbst gut, daher schonend). +- Eigene Begriffe/Fachwörter pflegst du in **`config/pronunciation.de.yaml`** (Abkürzungen, + Einheiten, Terms) — erweitert die eingebauten Defaults, ohne Code zu ändern. + ## B2. Soundquelle & Ausgabe-Gerät wechseln (Mikrofon, Lautsprecher, Bluetooth, Handy) > **Wichtig — aktueller Stand:** Die Geräte-Endpunkte **im Gateway** diff --git a/app/config.py b/app/config.py index 44e6198..3d2cc13 100644 --- a/app/config.py +++ b/app/config.py @@ -133,6 +133,8 @@ class Settings(BaseSettings): auth_enabled: bool = True history_max_messages: int = 10 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 = "" diff --git a/app/core/orchestrator.py b/app/core/orchestrator.py index 10755c6..e561e2a 100644 --- a/app/core/orchestrator.py +++ b/app/core/orchestrator.py @@ -13,13 +13,15 @@ TTS_CHANNELS = 1 class Orchestrator: - def __init__(self, stt, llm, tts, input_cleaner, spoken_adapter, tts_normalizer): + 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. @@ -72,7 +74,9 @@ class Orchestrator: output=None, ): spoken = await self.spoken_adapter.run(text, language=language) - normalized = await self.tts_normalizer.run(spoken, 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) await self._emit_to_output(audio, output) @@ -106,6 +110,7 @@ class Orchestrator: trace.tts_ready_text = await self.tts_normalizer.run( trace.spoken_response, language=language, + level=self.normalize_level, ) with _stage("tts"): @@ -141,7 +146,9 @@ class Orchestrator: async def _emit_sentence(sentence: str) -> None: spoken = await self.spoken_adapter.run(sentence, language=language) - ready = await self.tts_normalizer.run(spoken, language=language) + ready = await self.tts_normalizer.run( + spoken, language=language, level=self.normalize_level + ) if not ready.strip(): return chunk = await self.tts.synthesize(ready, voice=voice) @@ -179,6 +186,7 @@ class Orchestrator: trace.tts_ready_text = await self.tts_normalizer.run( trace.spoken_response, language=language, + level=self.normalize_level, ) if chunker: diff --git a/app/dependencies.py b/app/dependencies.py index f0050e9..1265433 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -226,6 +226,14 @@ def _provider_chain(registry, primary: str, fallback_csv: str, module: str, cfg: 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: Settings = settings) -> Orchestrator: return Orchestrator( stt=_provider_chain(STT_REGISTRY, route.stt_provider, cfg.stt_fallback, "stt", cfg), @@ -234,6 +242,7 @@ def build_orchestrator(route: ResolvedRoute, cfg: Settings = settings) -> Orches input_cleaner=InputCleaner(), spoken_adapter=SpokenResponseAdapter(), tts_normalizer=TTSNormalizer(), + normalize_level=_resolve_normalize_level(route.tts_provider, cfg), ) 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/sentence_chunker.py b/app/pipeline/sentence_chunker.py index 39e0afb..634e711 100644 --- a/app/pipeline/sentence_chunker.py +++ b/app/pipeline/sentence_chunker.py @@ -3,6 +3,18 @@ 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. @@ -11,21 +23,48 @@ class SentenceChunker: `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) + 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 diff --git a/app/pipeline/spoken_response_adapter.py b/app/pipeline/spoken_response_adapter.py index 7df2114..92a369b 100644 --- a/app/pipeline/spoken_response_adapter.py +++ b/app/pipeline/spoken_response_adapter.py @@ -1,5 +1,7 @@ import re +from app.pipeline import german_numbers as gn + class SpokenResponseAdapter: async def run(self, text: str, language: str = "de") -> str: @@ -14,9 +16,15 @@ class SpokenResponseAdapter: text = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", text) # markdown links text = re.sub(r"[*_~#>]+", " ", text) # markdown symbols - # Listen entschärfen + # 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) - text = re.sub(r"(?m)^\s*\d+\.\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() diff --git a/app/pipeline/tts_normalizer.py b/app/pipeline/tts_normalizer.py index 34342e1..d406377 100644 --- a/app/pipeline/tts_normalizer.py +++ b/app/pipeline/tts_normalizer.py @@ -1,59 +1,162 @@ +"""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) -> str: + # Längste Schlüssel zuerst, wortgrenzen-bewusst (Abkürzungen enden oft auf "."). + 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) + 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") -> str: + async def run(self, text: str, language: str = "de", level: str = "full") -> str: if not text: return "" + if level == "off": + return text - normalized = text + t = text - if language == "de": - replacements = { - "24/7": "vierundzwanzig sieben", - "&": " und ", - "%": " Prozent", - "€": " Euro", - "$": " Dollar", - "km/h": " Kilometer pro Stunde", - "z.B.": "zum Beispiel", - "bzw.": "beziehungsweise", - "u.a.": "unter anderem", - "ca.": "circa", - } - else: - replacements = { - "24/7": "twenty four seven", - "&": " and ", - "%": " percent", - "€": " euros", - "$": " dollars", - "km/h": " kilometers per hour", - "e.g.": "for example", - "i.e.": "that is", - } + # --- 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) - for old, new in replacements.items(): - normalized = normalized.replace(old, new) + if level == "full": + abbrevs, units, terms = _load_lexicon(language) - # Slashes zwischen Wörtern/Zahlen sprachfreundlicher machen - normalized = re.sub(r"(\w)/(\w)", r"\1 oder \2", normalized) - - # Datums-/Versions-/Bereichsstriche etwas entschärfen - normalized = normalized.replace("–", " bis ") - normalized = normalized.replace("—", ", ") - normalized = normalized.replace(" - ", ", ") - - # URLs und E-Mails nicht roh vorlesen - normalized = re.sub(r"https?://\S+", "Link", normalized) - normalized = re.sub(r"\b[\w\.-]+@[\w\.-]+\.\w+\b", "E-Mail-Adresse", normalized) - - # Mehrfache Leerzeichen glätten - normalized = re.sub(r"\s+", " ", normalized).strip() - - if normalized and not normalized.endswith((".", "!", "?")): - normalized += "." - - return normalized + 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) + t = _apply_dict(t, _DEFAULT_SYMBOLS_DE) + else: + t = _apply_units(t, units) + t = _apply_dict(t, abbrevs) + t = _apply_dict(t, terms) + 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/config/pronunciation.de.yaml b/config/pronunciation.de.yaml new file mode 100644 index 0000000..c9ac2db --- /dev/null +++ b/config/pronunciation.de.yaml @@ -0,0 +1,23 @@ +# Aussprache-Lexikon (Deutsch) für die TTS-Normalisierung vor Piper. +# +# Dies ist das GRUNDGERÜST – Inhalte werden später schrittweise ergänzt. +# Einträge hier ERWEITERN/ÜBERSCHREIBEN die eingebauten Defaults des Normalizers. +# Wirkt nur bei lokalem TTS (piper, Stufe "full"); Cloud-TTS bleibt unangetastet. +# +# Format: +# abbreviations: ganze Token werden wortgrenzen-sicher ersetzt (Punkt mitschreiben!) +# units: werden nur DIREKT NACH EINER ZAHL ersetzt (z. B. "10 kg") +# terms: Fachwörter / Eigennamen / englische Begriffe +# +# Beispiele (auskommentiert – bei Bedarf aktivieren/ergänzen): + +abbreviations: + # "ggf.": "gegebenenfalls" + # "inkl.": "inklusive" + +units: + # "kWh": "Kilowattstunden" + +terms: + # "Linux": "Linuks" + # "CPU": "C P U" diff --git a/config/pronunciation.en.yaml b/config/pronunciation.en.yaml new file mode 100644 index 0000000..ec6f9e6 --- /dev/null +++ b/config/pronunciation.en.yaml @@ -0,0 +1,12 @@ +# Pronunciation lexicon (English) for TTS normalization before piper. +# Grundgerüst – entries here extend/override the normalizer's built-in defaults. +# Only applied for local TTS (piper, level "full"). + +abbreviations: + # "approx.": "approximately" + +units: + # "kWh": "kilowatt hours" + +terms: + # "SQL": "sequel" diff --git a/tests/test_streaming.py b/tests/test_streaming.py index e5db1fc..fa19c13 100644 --- a/tests/test_streaming.py +++ b/tests/test_streaming.py @@ -20,6 +20,15 @@ def test_sentence_chunker_incremental(): 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 diff --git a/tests/test_tts_normalizer.py b/tests/test_tts_normalizer.py new file mode 100644 index 0000000..12db111 --- /dev/null +++ b/tests/test_tts_normalizer.py @@ -0,0 +1,89 @@ +"""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_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) From 449a62a88e0680b3905490c654fff908d44bd981 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Wed, 17 Jun 2026 23:19:17 +0200 Subject: [PATCH 031/113] =?UTF-8?q?feat(tts):=20Aussprache-Lexikon=20bef?= =?UTF-8?q?=C3=BCllt=20(verifizierte=20Vokall=C3=A4ngen-Fixes)=20+=20case-?= =?UTF-8?q?insensitiv?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit terms-Matching jetzt case-insensitiv (Aussprache ist case-egal -> ein Eintrag deckt auch Satzanfaenge ab). config/pronunciation.de.yaml mit per espeak-ng verifizierten Korrekturen gefuellt: stroemt/stroemte/loest/toent/blaest/buesst/ gruesst/Mond -> lange Vokale (h-Umschreibung). Kontrolle: loescht/waescht/stroemst bleiben korrekt unangetastet. Co-Authored-By: Claude Opus 4.8 --- app/pipeline/tts_normalizer.py | 11 +++++++---- config/pronunciation.de.yaml | 15 +++++++++++++-- tests/test_tts_normalizer.py | 10 ++++++++++ 3 files changed, 30 insertions(+), 6 deletions(-) diff --git a/app/pipeline/tts_normalizer.py b/app/pipeline/tts_normalizer.py index d406377..124b361 100644 --- a/app/pipeline/tts_normalizer.py +++ b/app/pipeline/tts_normalizer.py @@ -112,12 +112,15 @@ def _apply_units(text: str, units: dict) -> str: return text -def _apply_dict(text: str, mapping: dict) -> str: +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) + 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 @@ -143,12 +146,12 @@ class TTSNormalizer: 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) + 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) + t = _apply_dict(t, terms, ignore_case=True) t = _apply_dict(t, _DEFAULT_SYMBOLS_EN) # Slash zwischen Wörtern/Zahlen sprachfreundlich, Striche entschärfen. diff --git a/config/pronunciation.de.yaml b/config/pronunciation.de.yaml index c9ac2db..265b65c 100644 --- a/config/pronunciation.de.yaml +++ b/config/pronunciation.de.yaml @@ -18,6 +18,17 @@ abbreviations: units: # "kWh": "Kilowattstunden" +# Aussprache-Korrekturen: espeak-ng (in Piper) verkürzt bei einigen Wortformen +# fälschlich einen langen Vokal. Umschreibung mit "h" -> langer Vokal. +# Alle Einträge mit `espeak-ng -v de -x ""` verifiziert (Y:/E:/o: = lang). +# Matching ist case-insensitiv (deckt auch Satzanfänge ab). terms: - # "Linux": "Linuks" - # "CPU": "C P U" + "strömt": "ströhmt" + "strömte": "ströhmte" + "löst": "löhst" + "tönt": "töhnt" + "bläst": "blähst" + "büßt": "bühßt" + "grüßt": "grühßt" + "Mond": "Mohnd" + # "Linux": "Linuks" # Beispiel für Eigennamen/IT-Begriffe diff --git a/tests/test_tts_normalizer.py b/tests/test_tts_normalizer.py index 12db111..cd6bf5a 100644 --- a/tests/test_tts_normalizer.py +++ b/tests/test_tts_normalizer.py @@ -83,6 +83,16 @@ def test_adapter_numbered_list_to_ordinal(): 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" From 37b14773f24c60b1a11dabdb55ff68b13f2c5c1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Wed, 17 Jun 2026 23:25:49 +0200 Subject: [PATCH 032/113] feat(tools): scripts/add_pronunciation.py zum bequemen Pflegen des Aussprache-Lexikons Fuegt 'wort:aussprache' in config/pronunciation..yaml ein (Default Sektion terms). Erhaelt Kommentare/Struktur, aktualisiert vorhandene Eintraege statt zu duplizieren, validiert das YAML vor dem Schreiben. Optional --verify zeigt die espeak-Phoneme vorher/nachher und warnt, wenn die Umschreibung nichts aendert. Doku-Hinweis in BEDIENUNGSANLEITUNG ergaenzt. Co-Authored-By: Claude Opus 4.8 --- BEDIENUNGSANLEITUNG.md | 7 ++ scripts/add_pronunciation.py | 142 +++++++++++++++++++++++++++++++++++ 2 files changed, 149 insertions(+) create mode 100644 scripts/add_pronunciation.py diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index 0fe9e7c..be63467 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -270,6 +270,13 @@ und Abkürzungen („Dr." → „Doktor", „z. B." → „zum Beispiel"). Reine Cloud-TTS `light` (Cloud spricht Zahlen/Abkürzungen selbst gut, daher schonend). - Eigene Begriffe/Fachwörter pflegst du in **`config/pronunciation.de.yaml`** (Abkürzungen, Einheiten, Terms) — erweitert die eingebauten Defaults, ohne Code zu ändern. +- **Bequem per Skript** (prüft auf Wunsch gleich die espeak-Phoneme): + ```bash + python scripts/add_pronunciation.py "strömt:ströhmt" # Wort:Aussprache + python scripts/add_pronunciation.py Mond Mohnd --verify # zeigt Phoneme vorher/nachher + python scripts/add_pronunciation.py kWh "Kilowattstunden" --section units + ``` + Danach den Server einmal neu starten. ## B2. Soundquelle & Ausgabe-Gerät wechseln (Mikrofon, Lautsprecher, Bluetooth, Handy) 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() From bef02a663a4093903043b23044931ee98a628cbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Wed, 17 Jun 2026 23:28:52 +0200 Subject: [PATCH 033/113] docs: Aussprache-Pipeline aktualisieren (Normalizer/Chunker/Lexikon, piper echt) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Architektur §5: Spoken-Adapter (Listen->Ordinalwoerter), Sentence-Chunker (keine Trennung nach Ziffer+Punkt/Abk.), TTS-Normalizer (Ordinalia/Einheiten/ Abk./YAML-Lexikon, provider-abhaengige Stufen) + german_numbers im Baum. - Architektur: piper als echtes lokales TTS (nicht mehr Stub); nur chatterbox Stub. - README: Feature-Bullet Aussprache-Normalisierung + add_pronunciation. Co-Authored-By: Claude Opus 4.8 --- Docs/voice-assistant-architecture.md | 23 +++++++++++++---------- README.md | 1 + 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/Docs/voice-assistant-architecture.md b/Docs/voice-assistant-architecture.md index fd2c6dd..b987e4f 100644 --- a/Docs/voice-assistant-architecture.md +++ b/Docs/voice-assistant-architecture.md @@ -133,11 +133,13 @@ Stufen: `raw_transcript → cleaned_transcript → semantic_response → spoken_ - **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, Listen → Sätze, Uhrzeiten erhalten). -- **TTS Normalizer** (`pipeline/tts_normalizer.py`) – Zahlen/Abkürzungen/Einheiten verbal ausformulieren, sprachabhängig (de/en). +- **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 und +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 @@ -195,15 +197,16 @@ erkennung und Barge-in (`interrupt`)**; **Resilienz (Fallback-Ketten je Modul, In-Memory-Metriken `/api/metrics`), Tageskontingent pro Nutzer und heuristische Notfall-Eskalation**; automatisierte Tests. -**Echtes lokales STT:** `faster-whisper` (optionale Dependency `.[local]`, -CTranslate2) transkribiert real — damit ist ein Hybrid „STT+LLM lokal, TTS remote" -möglich (live verifiziert). +**Echtes lokales STT & TTS:** `faster-whisper` (optionale Dependency `.[local]`, +CTranslate2) transkribiert real; `piper` (Binary + Stimmmodell, ffmpeg-Resampling +auf 24000 Hz) synthetisiert real. 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. Lokale TTS-Provider `piper`, `chatterbox` -sind noch Stubs. `transport_router.py` (Ebene 4) existiert, ist aber noch -nicht aktiv (lokal/remote trägt vorerst der Provider-Name). +verdrahtet, kein echtes Hardware-I/O. Der TTS-Provider `chatterbox` ist noch ein +Stub. `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 @@ -240,7 +243,7 @@ voice-assistant-scaffold/ │ ├── api/ # health, chat, speak, transcribe, devices, sessions, config, admin, me, ws │ ├── core/ # orchestrator │ ├── audio/ # router, transport_router, vad, endpoints/input|output/* -│ ├── pipeline/ # input_cleaner, spoken_response_adapter, tts_normalizer, sentence_chunker +│ ├── pipeline/ # input_cleaner, spoken_response_adapter, tts_normalizer, sentence_chunker, german_numbers │ └── providers/ # stt/ llm/ tts/ (openrouter + lokale Stubs) + fallback.py ├── config/ # voice-assistant.example.toml (+ lokale .toml, gitignored) ├── data/ # SQLite-DB (gitignored) diff --git a/README.md b/README.md index f140c19..d9004d8 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ Praktische Bedienung: [`BEDIENUNGSANLEITUNG.md`](BEDIENUNGSANLEITUNG.md). - **Pipeline mit getrennter Semantik/Sprache:** STT → Input-Cleaner → LLM → Spoken-Adapter → TTS-Normalizer → TTS - **Provider austauschbar** über Registry (OpenRouter remote; lokales STT via faster-whisper `.[local]`; **lokales TTS via piper**; chatterbox-TTS noch Stub) +- **Aussprache-Normalisierung** vor dem TTS (Ordinalia/Einheiten/Abkürzungen + YAML-Lexikon, provider-abhängig `TTS_NORMALIZE_LEVEL`); Pflege per `scripts/add_pronunciation.py` - **Geschichtete Konfiguration** mit Profilen (`local-dev` / `hybrid` / `cloud`) - **Routing auf jeder Ebene:** Default → Profil → Nutzer → Session → Request - **Authentifizierung** (Bearer-Token) + persistente Nutzer/Sessions (SQLite) From d4de9ba5b069060c0bedefbd00f368fb94949830 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Wed, 17 Jun 2026 23:31:58 +0200 Subject: [PATCH 034/113] docs: installierte Piper-Stimmen auflisten + download-Hinweis korrigieren BEDIENUNGSANLEITUNG: Tabelle der aktuell installierten Stimmen (DE/EN/ES/FR, Qualitaet, Default thorsten-high), Hinweis auf fehlende weibliche DE-high-Stimme und dass chatterbox noch Stub ist. .env.example: nicht funktionierenden 'python -m piper.download_voices'-Befehl durch ls + huggingface-Hinweis ersetzt. Co-Authored-By: Claude Opus 4.8 --- .env.example | 3 ++- BEDIENUNGSANLEITUNG.md | 16 ++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 43ccce8..4b86bec 100644 --- a/.env.example +++ b/.env.example @@ -55,7 +55,8 @@ 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. -# Modelle laden: python -m piper.download_voices de_DE-thorsten-high (o. manuell). +# 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 diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index be63467..0ca8c8b 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -261,6 +261,22 @@ piper-Stimme/Verzeichnis steuern `PIPER_VOICE`/`PIPER_VOICES_DIR` in `.env` (Def aber **nichts** und verlässt den Rechner nie. Liefert eine Stimme nicht 24000 Hz (z. B. `de_DE-thorsten-high` = 22050 Hz), resampelt das Gateway automatisch per `ffmpeg`. +**Aktuell installierte Stimmen** (jederzeit prüfen mit `ls ~/.local/share/piper/voices/*.onnx`): + +| `PIPER_VOICE` | Sprache | Qualität | Hinweis | +|---|---|---|---| +| `de_DE-thorsten-high` | Deutsch | high (22050 Hz) | **Default**, männlich | +| `de_DE-kerstin-low` | Deutsch | low (16000 Hz) | weiblich, hörbar gröber | +| `en_US-ryan-high` | English | high | | +| `es_ES-davefx-medium` | Español | medium | | +| `fr_FR-gilles-low` | Français | low | | + +Für Deutsch gibt es bisher nur diese zwei Stimmen; eine **weibliche** Stimme in +`high`/`medium`-Qualität fehlt. Weitere Stimmen von huggingface `rhasspy/piper-voices` +laden (je `.onnx` + `.onnx.json` nach `PIPER_VOICES_DIR`), dann `PIPER_VOICE` +setzen und den Server neu starten. **Chatterbox** (ResembleAI) ist im Gateway noch ein +Stub und damit nicht als TTS nutzbar. + **Aussprache verbessern (nur lokales TTS):** Vor Piper läuft ein Normalizer, der typische Stolpersteine glättet — Ordinalzahlen („1. Mai" → „erster Mai", „1. 2. 3." → „erstens, zweitens, drittens"), Einheiten („10 kg" → „… Kilogramm", „km/h" → „Kilometer pro Stunde") From a40ae75c5f92cfe110c21d6a46f5a1b680af16ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Wed, 17 Jun 2026 23:36:13 +0200 Subject: [PATCH 035/113] docs: Cloud-TTS-Stimmen (OpenRouter) dokumentieren + Umstellen BEDIENUNGSANLEITUNG (B1): neuer Abschnitt 'Stimme des Cloud-TTS (OpenRouter) waehlen' -- Gateway pflegt keine eigene Liste, reicht OPENROUTER_TTS_VOICE durch; Stimmen haengen vom Modell ab (Gemini-Familie ~30 Stimmen, OpenAI gpt-4o-mini-tts), Umstellen global (.env) und pro Aufruf (voice-Feld in /api/speak,/api/chat), Hinweis auf fehlende --voice-Option in voice_loop. .env.example: Verweis bei OPENROUTER_TTS_VOICE. Co-Authored-By: Claude Opus 4.8 --- .env.example | 2 ++ BEDIENUNGSANLEITUNG.md | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/.env.example b/.env.example index 4b86bec..59d30ad 100644 --- a/.env.example +++ b/.env.example @@ -34,6 +34,8 @@ VA_PROFILE= # 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 diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index 0ca8c8b..7042101 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -294,6 +294,38 @@ und Abkürzungen („Dr." → „Doktor", „z. B." → „zum Beispiel"). Reine ``` Danach den Server einmal neu starten. +**Stimme des Cloud-TTS (OpenRouter) wählen:** Das Gateway pflegt **keine** eigene +Stimmenliste — es reicht den Namen aus `OPENROUTER_TTS_VOICE` unverändert an OpenRouter +weiter. Welche Stimmen gültig sind, bestimmt das gewählte **TTS-Modell** +(`OPENROUTER_TTS_MODEL`). Aktuell aktiv: Modell `google/gemini-3.1-flash-tts-preview`, +Stimme `Zephyr`. + +Verfügbare Stimmen je Modell (laut Anbieter-Doku — Preview, im Zweifel ausprobieren): +- **Gemini-TTS** (aktiv) — ~30 mehrsprachige Stimmen, u. a. `Zephyr`, `Puck`, `Charon`, + `Kore`, `Fenrir`, `Leda`, `Orus`, `Aoede`, `Callirrhoe`, `Autonoe`, `Enceladus`, + `Iapetus`, `Umbriel`, `Algieba`, `Despina`, `Erinome`, `Algenib`, `Rasalgethi`, + `Laomedeia`, `Achernar`, `Alnilam`, `Schedar`, `Gacrux`, `Pulcherrima`, `Achird`, + `Zubenelgenubi`, `Vindemiatrix`, `Sadachbia`, `Sadaltager`, `Sulafat`. +- **OpenAI `gpt-4o-mini-tts`** (Code-/TOML-Default) — `alloy`, `ash`, `ballad`, `coral`, + `echo`, `fable`, `nova`, `onyx`, `sage`, `shimmer`, `verse`. + +> Es gibt keinen Endpoint, der TTS-Stimmen auflistet, und die Modelle sind Preview. +> **Authentischster Test:** Stimme setzen und probieren — ein ungültiger Name liefert +> einen OpenRouter-Fehler (HTTP 502 mit Klartext, der oft die gültigen Stimmen nennt). + +Umstellen: +```bash +# global (dann Server neu starten): +echo 'OPENROUTER_TTS_VOICE=Puck' >> .env +# pro Aufruf (überschreibt den Default für genau diesen Request): +curl -s -X POST "$URL/api/speak" -H 'Content-Type: application/json' \ + -d '{"text":"Probe","voice":"Kore","tts_provider":"openrouter"}' --output probe.pcm +# anderes TTS-Modell (andere Stimmenfamilie): +echo 'OPENROUTER_TTS_MODEL=openai/gpt-4o-mini-tts' >> .env +``` +Das Feld `voice` gibt es im Body von `/api/speak` und `/api/chat`. Hinweis: +`scripts/voice_loop.py` hat **keine** `--voice`-Option — dort gilt der Server-Default. + ## B2. Soundquelle & Ausgabe-Gerät wechseln (Mikrofon, Lautsprecher, Bluetooth, Handy) > **Wichtig — aktueller Stand:** Die Geräte-Endpunkte **im Gateway** From 9d0019274a6420312c57c950b96b3080d37027ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Wed, 17 Jun 2026 23:42:23 +0200 Subject: [PATCH 036/113] feat(voice_loop): --voice-Option + saubere Default-Stimme je TTS-Provider - voice_loop.py: --voice reicht die Stimme im Start-Frame durch (nur wenn gesetzt), sonst greift der Provider-Default. Beispiele in Docstring/Help. - ws.py/speak.py/chat.py: ohne explizite Stimme None durchreichen statt die OpenRouter-Stimme zu erzwingen -> jeder Provider nimmt SEINEN Default (OPENROUTER_TTS_VOICE bzw. PIPER_VOICE). Ungenutzten settings-Import in speak.py entfernt. - Doku: BEDIENUNGSANLEITUNG (--voice + Default-Stimme je Provider). Co-Authored-By: Claude Opus 4.8 --- BEDIENUNGSANLEITUNG.md | 12 ++++++++++-- app/api/chat.py | 3 ++- app/api/speak.py | 4 ++-- app/api/ws.py | 4 +++- scripts/voice_loop.py | 6 +++++- 5 files changed, 22 insertions(+), 7 deletions(-) diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index 7042101..dec938f 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -323,8 +323,16 @@ curl -s -X POST "$URL/api/speak" -H 'Content-Type: application/json' \ # anderes TTS-Modell (andere Stimmenfamilie): echo 'OPENROUTER_TTS_MODEL=openai/gpt-4o-mini-tts' >> .env ``` -Das Feld `voice` gibt es im Body von `/api/speak` und `/api/chat`. Hinweis: -`scripts/voice_loop.py` hat **keine** `--voice`-Option — dort gilt der Server-Default. +Das Feld `voice` gibt es im Body von `/api/speak` und `/api/chat`. Im Sprech-Loop +direkt durchprobieren mit `--voice` (ohne Angabe gilt der Provider-Default): +```bash +python scripts/voice_loop.py --tts-provider openrouter --voice Puck # Cloud-Stimme +python scripts/voice_loop.py --tts-provider piper --voice de_DE-kerstin-low # lokale Stimme +``` +**Default-Stimme je Provider:** Wird keine Stimme angefragt, nimmt jeder TTS-Provider +seinen eigenen Default — OpenRouter `OPENROUTER_TTS_VOICE`, piper `PIPER_VOICE`. `--voice` +ist provider-spezifisch: ein Gemini-/OpenAI-Stimmenname für `openrouter`, ein Modellname +für `piper` (ein unpassender Name fällt bei piper auf `PIPER_VOICE` zurück). ## B2. Soundquelle & Ausgabe-Gerät wechseln (Mikrofon, Lautsprecher, Bluetooth, Handy) diff --git a/app/api/chat.py b/app/api/chat.py index 79572b8..df8ffbd 100644 --- a/app/api/chat.py +++ b/app/api/chat.py @@ -51,7 +51,8 @@ async def chat( "llm_provider": payload.llm_provider, "tts_provider": payload.tts_provider, } - voice = payload.voice or settings.openrouter_tts_voice + # None -> der gewaehlte TTS-Provider nimmt seinen eigenen Default. + voice = payload.voice store = get_store() diff --git a/app/api/speak.py b/app/api/speak.py index c4c7cfb..7092a23 100644 --- a/app/api/speak.py +++ b/app/api/speak.py @@ -3,7 +3,6 @@ from io import BytesIO from fastapi import APIRouter, Depends, HTTPException, Query from fastapi.responses import StreamingResponse -from app.config import settings from app.errors import RoutingError from app.auth import require_user from app.store import User, SessionOwnershipError @@ -33,7 +32,8 @@ async def speak( "language": payload.language, "tts_provider": payload.tts_provider, } - voice = payload.voice or settings.openrouter_tts_voice + # None -> der gewaehlte TTS-Provider nimmt seinen eigenen Default. + voice = payload.voice try: route = resolve_route(user, session_id, overrides) diff --git a/app/api/ws.py b/app/api/ws.py index 4227131..86c6b22 100644 --- a/app/api/ws.py +++ b/app/api/ws.py @@ -90,7 +90,9 @@ async def _run_turn(websocket, store, user, session_id, route, orchestrator, out await websocket.send_json({"type": "ack", "route": route.as_dict()}) - voice = options.get("voice") or settings.openrouter_tts_voice + # Ohne explizite Stimme None lassen -> jeder TTS-Provider nimmt SEINEN Default + # (OpenRouter: OPENROUTER_TTS_VOICE, piper: PIPER_VOICE). + voice = options.get("voice") stream = bool(options.get("stream")) # audio_stream: explizite Anfrage gewinnt, sonst der serverseitige Default (Admin). audio_stream = bool(options["audio_stream"]) if "audio_stream" in options \ diff --git a/scripts/voice_loop.py b/scripts/voice_loop.py index f03e08a..46331d9 100644 --- a/scripts/voice_loop.py +++ b/scripts/voice_loop.py @@ -10,6 +10,7 @@ Beispiele: python scripts/voice_loop.py --url ws://localhost:8003/ws/voice --session oma-anna python scripts/voice_loop.py --device hw:1,0 # bestimmtes Mikrofon (arecord -L) 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, `arecord` (Aufnahme), ein Player @@ -211,7 +212,7 @@ def _start_frame(args) -> dict: 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"): + for key in ("stt_provider", "llm_provider", "tts_provider", "language", "voice"): value = getattr(args, key, None) if value: frame[key] = value @@ -346,6 +347,9 @@ def main() -> None: 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() From 295f066b6a709dc10c3d00da44956bebe51c82a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Wed, 17 Jun 2026 23:54:45 +0200 Subject: [PATCH 037/113] feat(voice_loop): per Default System-Standardgeraete + Probe gegen tote Geraete 'auto'-Recorder folgt jetzt dem System-Standard-Mikrofon und prueft per Kurztest (~0,6 s), dass das Werkzeug WIRKLICH Audio liefert -> nie ein totes Geraet. Kandidaten ffmpeg(-f pulse) -> parecord -> arecord -> pw-record; auf PipeWire- Systemen ohne aktive Session gewinnt ffmpeg (gemessen: pw-record/arecord-default/ parecord liefern hier nichts). Neuer Recorder 'ffmpeg'. Player: paplay vor aplay (folgt System-Default-Sink). --device nur noch, wenn man bewusst abweichen will. Doku (Bedienung + B2 + Fehlerbehebung) entsprechend aktualisiert. Co-Authored-By: Claude Opus 4.8 --- BEDIENUNGSANLEITUNG.md | 43 +++++++++--------- scripts/voice_loop.py | 99 ++++++++++++++++++++++++++++++++---------- 2 files changed, 99 insertions(+), 43 deletions(-) diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index dec938f..f1c9b4a 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -111,22 +111,21 @@ Nützliche Optionen: ```bash python scripts/voice_loop.py --stream-text # Antworttext live anzeigen, waehrend die KI generiert python scripts/voice_loop.py --no-stream-audio # satzweises Vorlesen abschalten (Audio erst komplett) -python scripts/voice_loop.py --recorder pw-record # PipeWire-Aufnahme (Standard bei 'auto') -python scripts/voice_loop.py --recorder arecord --device hw:1,0 # ALSA, bestimmtes Mikrofon +python scripts/voice_loop.py --recorder arecord --device plughw:6,0 # bestimmtes Mikrofon erzwingen python scripts/voice_loop.py --llm-provider openrouter --tts-provider openrouter python scripts/voice_loop.py --token "$TOKEN" # falls AUTH_ENABLED=true python scripts/voice_loop.py --file frage.wav # ohne Mikrofon: WAV senden (Test) ``` -> **Aufnahmewerkzeug:** `--recorder auto` (Standard) bevorzugt **PipeWire** (`pw-record`), -> sonst `parecord`/`arecord`. **Falls die Aufnahme scheitert** (z. B. -> *„pw_context_connect() failed"* oder *„Fehler beim Öffnen des Gerätes"*), ist der -> verlässlichste Weg ein **direktes ALSA-Hardware-Gerät**: -> ```bash -> arecord -l # Kartennummern der Mikrofone -> python scripts/voice_loop.py --recorder arecord --device plughw:2,0 -> ``` -> (`plughw:KARTE,GERÄT` aus `arecord -l`; z. B. onboard oft Karte 2, USB-Webcam Karte 4.) +> **Geräte = System-Standard (automatisch):** Ohne `--device` folgt der Loop dem +> **am System eingestellten Standard-Mikrofon und -Lautsprecher** (inkl. Bluetooth — +> umstellbar über *Ubuntu → Einstellungen → Ton*, siehe Teil B2). `--recorder auto` +> (Standard) wählt selbsttätig ein Aufnahmewerkzeug, das dem Default folgt **und** im +> **Kurztest wirklich Audio liefert** (Reihenfolge `ffmpeg` → `parecord` → `arecord` → +> `pw-record`) — so wird nie ein totes Gerät gewählt. Beim Start erscheint kurz +> „Prüfe Standard-Aufnahmegerät …". +> **Ein bestimmtes Mikrofon** nur bei Bedarf erzwingen, z. B. `--recorder arecord +> --device plughw:6,0` (`arecord -l` zeigt die Kartennummer). ## A2. Nur tippen → Antwort hören @@ -390,20 +389,24 @@ pactl set-default-sink # z. B. die Bluetooth-Box Sobald die Bluetooth-Box dort als Standard gesetzt ist (Schritt 2 oben), kommt die gesprochene Antwort **automatisch über die Box** — ohne zusätzliche Option. Das ist der Grund, warum die Bluetooth-Umleitung „einfach funktioniert". -- **Eingabe (Aufnahme):** Die Aufnahme folgt **nicht** automatisch dem System-Default — - `voice_loop.py` nimmt mit einem fest gewählten Gerät auf. Damit der **ReSpeaker** genutzt - wird, das Gerät explizit angeben (ReSpeaker = **Karte 6** → `plughw:6,0`): +- **Eingabe (Aufnahme):** `voice_loop.py` folgt mit `--recorder auto` (Standard) ebenfalls + dem **System-Standard-Mikrofon** — es probiert beim Start automatisch ein Werkzeug, das + dem Default folgt und im Kurztest wirklich Audio liefert (`ffmpeg` → `parecord` → `arecord` + → `pw-record`). Stellst du also das Eingabegerät in *Einstellungen → Ton* um (z. B. auf den + ReSpeaker), nutzt der Loop es ohne weitere Option. +- **Bestimmtes Mikrofon erzwingen** (statt System-Default), z. B. den ReSpeaker fix als ALSA- + Gerät (Karte 6): ```bash python scripts/voice_loop.py --recorder arecord --device plughw:6,0 ``` - (Auf diesem Rechner ist `--recorder arecord` mit `plughw:…` der zuverlässige Weg, weil die - Default-folgenden Recorder `pw-record`/`parecord` hier nicht stabil verbinden. Die richtige - Kartennummer notfalls mit `arecord -l` prüfen.) + (Hilfreich, wenn du gezielt ein anderes als das Standard-Mikrofon willst; Kartennummer mit + `arecord -l`. Auf diesem Rechner scheitern `pw-record`/`arecord default` — die `auto`-Probe + überspringt sie automatisch und nimmt `ffmpeg -f pulse`.) -Kurz: **Lautsprecher/Bluetooth umstellen → System-Einstellungen genügen.** -**Mikrofon für den Sprech-Loop → zusätzlich `--device plughw:6,0` mitgeben.** +Kurz: **Mikrofon UND Lautsprecher/Bluetooth umstellen → die System-Einstellungen genügen; +der Sprech-Loop folgt dem Standard automatisch.** `--device` nur, wenn du bewusst abweichen willst. ### Weitere Einstellungen, die du vornehmen kannst - **Ausgabe-Lautstärke / Mikrofon-Empfindlichkeit:** *Ton*-Seite oder @@ -614,7 +617,7 @@ curl -s "$URL/api/metrics?format=prometheus" # Prometheus-Text (kein jq) | HTTP **502** bei STT/TTS | Cloud-Fehler/Format | `make smoke` ausführen; Modellnamen in `.env` prüfen | | `VA_PROFILE` wirkt nicht | `DEFAULT_*_PROVIDER` in `.env` überschreibt | diese Zeilen in `.env` auskommentieren | | `Address already in use` | Port belegt | anderen `PORT` setzen | -| `pw_context_connect() failed` / `arecord: Fehler beim Öffnen des Gerätes` | PipeWire-Client- bzw. ALSA-`default`-Pfad gestört | direktes Gerät nehmen: `arecord -l`, dann `--recorder arecord --device plughw:2,0` | +| `pw_context_connect() failed` / `arecord: Fehler beim Öffnen des Gerätes` | PipeWire-Client- bzw. ALSA-`default`-Pfad gestört | `--recorder auto` (Standard) überspringt tote Werkzeuge automatisch (nutzt `ffmpeg -f pulse`); notfalls direktes Gerät: `arecord -l`, dann `--recorder arecord --device plughw:6,0` | | Keine Aufnahme/Wiedergabe | Werkzeug/Gerät fehlt | `arecord -L` / `aplay -L`; Pakete `pipewire`/`alsa-utils`/`ffmpeg`; Default via `wpctl status` | | Profil greift nicht | `config/voice-assistant.toml` fehlt | aus `*.example.toml` kopieren (Abschnitt 2) | diff --git a/scripts/voice_loop.py b/scripts/voice_loop.py index 46331d9..23cd9b8 100644 --- a/scripts/voice_loop.py +++ b/scripts/voice_loop.py @@ -5,16 +5,19 @@ 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 + 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 --device hw:1,0 # bestimmtes Mikrofon (arecord -L) + 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, `arecord` (Aufnahme), ein Player -(`ffplay`/`aplay`/`paplay`), Python-Paket `websockets`. +Voraussetzungen: laufendes Gateway, ein Aufnahmewerkzeug (`ffmpeg`/`parecord`/`arecord`), +ein Player (`ffplay`/`paplay`/`aplay`), Python-Paket `websockets`. """ from __future__ import annotations @@ -23,11 +26,13 @@ import argparse import asyncio import io import json +import os import shutil import signal import subprocess import sys import tempfile +import time import wave try: @@ -46,28 +51,73 @@ def _require(tool: str) -> str: 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("aplay"): - return ["aplay", "-q"] if shutil.which("paplay"): return ["paplay"] + if shutil.which("aplay"): + return ["aplay", "-q"] return None -def resolve_recorder(choice: str) -> str: - """Waehlt das Aufnahmewerkzeug. 'auto' bevorzugt PipeWire (pw-record).""" +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 - for tool in ("pw-record", "parecord", "arecord"): - if shutil.which(tool): + 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 - sys.exit("Kein Aufnahmewerkzeug gefunden (pw-record / parecord / arecord).") + 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: @@ -158,13 +208,14 @@ def open_stream_player(): 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("aplay"): - cmd = ["aplay", "-q", "-f", "S16_LE", "-r", rate, "-c", "1"] 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) @@ -309,10 +360,10 @@ async def run(args) -> None: await _send_and_play(url, wav, start_frame) return - recorder = resolve_recorder(args.recorder) + 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 " (Standardgerät)")) + + (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: @@ -321,11 +372,10 @@ async def run(args) -> None: print("\nEnde.") return if len(wav) < 1000: - print(" ⚠ Keine/zu kurze Aufnahme. Moegliche Ursache: Aufnahmewerkzeug" - " oder Geraet nicht nutzbar.") - print(" Verfuegbare Mikrofone: arecord -l") - print(" Direktes ALSA-Geraet verwenden, z. B.:") - print(" python scripts/voice_loop.py --recorder arecord --device plughw:2,0") + 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) @@ -339,10 +389,13 @@ def main() -> None: 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 (arecord: -L; pw-record: --target; parecord: --device)") + 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", "pw-record", "parecord", "arecord"], - help="Aufnahmewerkzeug; 'auto' bevorzugt PipeWire (pw-record)") + 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) From 03892463a563c5e4c599bc32b2f03108cf58f44e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Thu, 18 Jun 2026 00:06:47 +0200 Subject: [PATCH 038/113] fix(tts): OpenRouter-TTS bei transient leerer/5xx-Antwort wiederholen Preview-Modelle liefern gelegentlich HTTP 200 mit leerem Body (oder 5xx) -> bisher brach das die ganze Sprech-Runde ab ('empty audio content', 502). Jetzt bis zu 3 Versuche mit linearem Backoff; 4xx (z. B. ungueltige Stimme) wird sofort gemeldet (kein Retry). Damit klappt das Durchprobieren von Stimmen zuverlaessig (Live-Test: alle 20 getesteten Gemini-Stimmen inkl. Leda liefern Audio). Tests: tests/test_openrouter_tts.py (leer->ok, 4xx sofort, alles-leer, 5xx->ok). Doku-Hinweis in BEDIENUNGSANLEITUNG. Co-Authored-By: Claude Opus 4.8 --- BEDIENUNGSANLEITUNG.md | 7 +++- app/providers/tts/openrouter.py | 59 +++++++++++++++++---------- tests/test_openrouter_tts.py | 70 +++++++++++++++++++++++++++++++++ 3 files changed, 113 insertions(+), 23 deletions(-) create mode 100644 tests/test_openrouter_tts.py diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index f1c9b4a..27aaefc 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -309,8 +309,11 @@ Verfügbare Stimmen je Modell (laut Anbieter-Doku — Preview, im Zweifel auspro `echo`, `fable`, `nova`, `onyx`, `sage`, `shimmer`, `verse`. > Es gibt keinen Endpoint, der TTS-Stimmen auflistet, und die Modelle sind Preview. -> **Authentischster Test:** Stimme setzen und probieren — ein ungültiger Name liefert -> einen OpenRouter-Fehler (HTTP 502 mit Klartext, der oft die gültigen Stimmen nennt). +> **Authentischster Test:** Stimme setzen und probieren — ein **wirklich** ungültiger Name +> liefert einen OpenRouter-Fehler (HTTP 502 mit Klartext, der oft die gültigen Stimmen nennt). +> Preview-Modelle antworten gelegentlich transient **leer** (HTTP 200, kein Audio) — das +> wiederholt der TTS-Provider automatisch (bis zu 3 Versuche), bevor ein Fehler kommt. Eine +> einzelne „empty audio content"-Meldung war also meist nur ein Aussetzer; einfach erneut versuchen. Umstellen: ```bash diff --git a/app/providers/tts/openrouter.py b/app/providers/tts/openrouter.py index 28186d1..d12fe9f 100644 --- a/app/providers/tts/openrouter.py +++ b/app/providers/tts/openrouter.py @@ -1,7 +1,14 @@ +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): @@ -36,27 +43,37 @@ class OpenRouterTTSProvider(TTSProvider): 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/speech", - 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 TTS error {exc.response.status_code}: {exc.response.text}" - ) from exc - except httpx.TimeoutException as exc: - raise RuntimeError("OpenRouter TTS timeout") from exc - except httpx.HTTPError as exc: - raise RuntimeError(f"OpenRouter TTS transport error: {exc}") from exc + 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 not response.content: - raise RuntimeError("OpenRouter TTS returned empty audio content") + if attempt < _MAX_ATTEMPTS - 1: + await asyncio.sleep(_RETRY_BACKOFF * (attempt + 1)) - return response.content + raise RuntimeError(last_error) 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 From cd7aef852d51faceffd5dd91cb2b319ccca09530 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Thu, 18 Jun 2026 00:29:53 +0200 Subject: [PATCH 039/113] docs: live verifizierte OpenRouter-TTS-Stimmen kennzeichnen (2026-06-18) 20 Gemini-Stimmen gegen den laufenden Server getestet -> liefern Audio (Zephyr..Sulafat). Als 'live verifiziert' im Stimmen-Abschnitt markiert. Co-Authored-By: Claude Opus 4.8 --- BEDIENUNGSANLEITUNG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index 27aaefc..5e8452b 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -305,6 +305,9 @@ Verfügbare Stimmen je Modell (laut Anbieter-Doku — Preview, im Zweifel auspro `Iapetus`, `Umbriel`, `Algieba`, `Despina`, `Erinome`, `Algenib`, `Rasalgethi`, `Laomedeia`, `Achernar`, `Alnilam`, `Schedar`, `Gacrux`, `Pulcherrima`, `Achird`, `Zubenelgenubi`, `Vindemiatrix`, `Sadachbia`, `Sadaltager`, `Sulafat`. + - **Live verifiziert (2026-06-18, liefern Audio):** `Zephyr`, `Puck`, `Charon`, `Kore`, + `Fenrir`, `Leda`, `Orus`, `Aoede`, `Callirrhoe`, `Enceladus`, `Iapetus`, `Umbriel`, + `Algieba`, `Despina`, `Erinome`, `Algenib`, `Achernar`, `Schedar`, `Gacrux`, `Sulafat`. - **OpenAI `gpt-4o-mini-tts`** (Code-/TOML-Default) — `alloy`, `ash`, `ballad`, `coral`, `echo`, `fable`, `nova`, `onyx`, `sage`, `shimmer`, `verse`. From 28c729f1d4b07cdd43221e8c88ca29b4e0c5a770 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Thu, 18 Jun 2026 02:57:57 +0200 Subject: [PATCH 040/113] feat(llm): lokales llama.cpp-Modell (va_llm) als zentrale, sprachoptimierte KI - scripts/llm-server/: start/stop/status fuer llama.cpp-Server (Port 8001, GPU 1, Modell Qwen3.6-35B-A3B-Uncensored, Alias va_llm) - alles per ENV ueberschreibbar - Defaults auf den lokalen Server umgestellt (config.py, .example-Configs, .env.example) - Provider local-openai-compatible sprachoptimiert: Reasoning aus (chat_template_kwargs.enable_thinking=false) + knapper Sprach-System-Prompt, optional max_tokens/temperature - Antwort ~9x schneller, kurze vorlesbare Texte - Makefile-Targets llm-up/llm-down/llm-status - Doku (README, BEDIENUNGSANLEITUNG) auf llama.cpp statt Ollama aktualisiert Co-Authored-By: Claude Opus 4.8 --- .env.example | 11 ++- BEDIENUNGSANLEITUNG.md | 20 +++-- Makefile | 13 ++- README.md | 88 +++++++++++++++++++ app/config.py | 14 ++- app/dependencies.py | 8 +- app/providers/llm/local_openai_compatible.py | 53 ++++++++---- config/voice-assistant.example.toml | 5 +- scripts/llm-server/start-llm-server.sh | 90 ++++++++++++++++++++ scripts/llm-server/status-llm-server.sh | 38 +++++++++ scripts/llm-server/stop-llm-server.sh | 14 +++ 11 files changed, 323 insertions(+), 31 deletions(-) create mode 100755 scripts/llm-server/start-llm-server.sh create mode 100755 scripts/llm-server/status-llm-server.sh create mode 100755 scripts/llm-server/stop-llm-server.sh diff --git a/.env.example b/.env.example index 59d30ad..11df940 100644 --- a/.env.example +++ b/.env.example @@ -45,9 +45,16 @@ DEFAULT_OUTPUT_ENDPOINT=local-default # DEFAULT_STT_PROVIDER=openrouter # DEFAULT_LLM_PROVIDER=local-openai-compatible # DEFAULT_TTS_PROVIDER=openrouter -LOCAL_LLM_BASE_URL=http://127.0.0.1:11434/v1 +# 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=llama3.1 +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 diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index 5e8452b..28533cd 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -29,8 +29,9 @@ Kurzüberblick: [README](README.md). für rein lokalen Betrieb (`local-dev`) nicht - Für **lokales STT** (Provider `faster-whisper`): einmalig `pip install -e .[local]` (lädt beim ersten Lauf ein Whisper-Modell). Für **lokales LLM**: ein laufender - Ollama-Server (`http://127.0.0.1:11434`) mit einem Modell (`ollama pull llama3.2`) -- Optional: Docker + llama.cpp-Server (`http://127.0.0.1:8001/v1`) — starten mit `make llm-up` + (siehe README, Abschnitt „Lokales LLM"). Großes, unzensiertes Modell, Default-Alias `va_llm`. +- **Docker** (für den lokalen llama.cpp-Server) und eine NVIDIA-GPU ## 2. Installation @@ -226,8 +227,8 @@ Profil global umschalten (Entwickler/Admin): `VA_PROFILE=local-dev make run`. ```bash # einmalig: lokales STT installieren pip install -e .[local] -# Server mit kleinem lokalem Ollama-Modell (muss in 'ollama list' stehen): -echo 'LOCAL_LLM_MODEL=llama3.2:latest' >> .env +# lokalen llama.cpp-Server starten (Default: Port 8001, GPU 1, Alias va_llm): +make llm-up # mit make llm-status auf "HTTP OK" warten make run # in Terminal 2 — Sprech-Loop mit der Hybrid-Kombi (MOTU = plughw:5,0): python scripts/voice_loop.py --recorder arecord --device plughw:5,0 --session hybrid \ @@ -236,7 +237,8 @@ python scripts/voice_loop.py --recorder arecord --device plughw:5,0 --session hy --llm-provider local-openai-compatible \ --tts-provider openrouter ``` -Erster Turn ist langsamer (Whisper- und Ollama-Modell laden), danach zügig. STT-Modell +Erster Turn ist langsamer (Whisper-Modell lädt; das LLM-Modell ist nach `make llm-up` +bereits geladen), danach zügig. STT-Modell und Gerät steuern `FASTER_WHISPER_MODEL`/`FASTER_WHISPER_DEVICE` in `.env`. Satzweises Vorlesen ist Standard (früher Ton); `--stream-text` zeigt den Text live dazu. @@ -245,6 +247,8 @@ Datenschutz). TTS läuft hier über **piper** (lokales, CPU-freundliches Neural- ```bash # einmalig: lokales STT installieren pip install -e .[local] +# lokalen llama.cpp-Server starten (großes, unzensiertes Modell): +make llm-up # mit make llm-status auf "HTTP OK" warten # piper-Binary + Stimme bereitstellen: die Stimm-Dateien (.onnx + .onnx.json) # liegen im PIPER_VOICES_DIR (Default ~/.local/share/piper/voices). Deutsche Stimmen z. B. # von huggingface 'rhasspy/piper-voices' (de_DE-thorsten-high, de_DE-kerstin-low). @@ -500,7 +504,7 @@ curl -s $URL/api/metrics | jq '.timers | to_entries | Use-Case | Empfehlung | Begründung | |----------|------------|------------| | Senioren-Standard (kein KI-Rechner zuhause) | **Profil `cloud`** | beste Qualität/Latenz ohne lokale Hardware (~4 s Round-Trip) | -| Datenschutz / offline | `local-dev` | alles lokal — benötigt echte lokale Modelle (heute Platzhalter) | +| Datenschutz / offline | `local-dev` | alles lokal: faster-whisper + llama.cpp (`va_llm`, unzensiert) + piper — benötigt GPU + `make llm-up` | | Kosten/Ausfallsicherheit | `hybrid` + `*_FALLBACK` | teure Teile lokal, Rest Cloud; automatischer Fallback | Empfehlung für den Einstieg: **`cloud`** verwenden, Antwortzeiten mit C2 prüfen, dann @@ -546,8 +550,8 @@ python scripts/voice_loop.py --recorder arecord --device plughw:5,0 --session te ### C5. Hybrid-Konstellation (STT + LLM lokal, TTS remote) — Kostenvergleich -Konstellation: **STT** lokal (`faster-whisper`) → **KI** lokal (Ollama, z. B. `llama3.1:8b`) -→ **TTS** remote (Gemini/Zephyr). Befehl: siehe Hybrid-Beispiel in Teil B1. +Konstellation: **STT** lokal (`faster-whisper`) → **KI** lokal (llama.cpp, großes +unzensiertes Modell `va_llm`) → **TTS** remote (Gemini/Zephyr). Befehl: siehe Hybrid-Beispiel in Teil B1. | | STT | LLM | TTS | API-Kosten/Runde | |---|-----|-----|-----|------------------| diff --git a/Makefile b/Makefile index c44e1c3..175cf50 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,7 @@ endif PORT ?= 8080 HOST ?= 0.0.0.0 -.PHONY: ensure-env install run test smoke docker-build +.PHONY: ensure-env install run test smoke docker-build llm-up llm-down llm-status ensure-env: @if [ ! -f .env ] && [ -f .env.example ]; then \ @@ -30,3 +30,14 @@ smoke: ensure-env docker-build: docker build -t voice-assistant-gateway . + +# --- 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 diff --git a/README.md b/README.md index d9004d8..0f4f025 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,94 @@ VA_PROFILE=cloud make run # alles über OpenRouter Aktive Konfiguration prüfen: `curl http://localhost:8080/api/config`. +## Lokales LLM (llama.cpp, unzensiert) + +Die zentrale KI kann statt OpenRouter ein **lokales, unzensiertes Modell** über einen +llama.cpp-Server (OpenAI-kompatibel) sein. Der Provider `local-openai-compatible` +spricht direkt dagegen — kein Code, nur Server starten + Profil wählen. + +```bash +make llm-up # startet den llama.cpp-Container (Default: Port 8001, GPU 1) +make llm-status # Container- + HTTP-Status +make llm-down # stoppt den Container +``` + +Das Modell wird über das `--alias va_llm` angesprochen; die Defaults zeigen bereits +auf `http://127.0.0.1:8001/v1` mit Modell `va_llm`. Danach genügt ein lokales Profil: + +```bash +VA_PROFILE=hybrid make run # STT/TTS remote, Haupt-LLM lokal (unzensiert) +VA_PROFILE=local-dev make run # komplett lokal (faster-whisper / llama.cpp / piper) +``` + +Alle Server-Parameter sind per ENV überschreibbar (Defaults in Klammern): + +| Variable | Bedeutung | Default | +|------------------|---------------------------------------------|---------| +| `HOST_PORT` | Host-Port des Servers | `8001` | +| `GPU_DEVICE` | GPU-Index (von 3 GPUs) | `1` | +| `MODEL_REL_PATH` | Modellpfad relativ zu `HF_HOME` | `models/qwen3/Qwen3.6-35B-A3B-Uncensored-HauhauCS-Aggressive-Q4_K_M.gguf` | +| `HF_HOME` | Wurzel der Modell-Sammlung | `~/nvme2n1p7_home/huggingface` | +| `MODEL_ALIAS` | API-Modellname (= `LOCAL_LLM_MODEL`) | `va_llm` | +| `CONTAINER_NAME` | Docker-Containername | `va_llm` | + +Beispiel (andere GPU/Port/Modell): + +```bash +GPU_DEVICE=2 HOST_PORT=8101 MODEL_REL_PATH=models/qwen3/Qwopus3.6-35B-A3B-v1-Q4_K_M.gguf \ + bash scripts/llm-server/start-llm-server.sh +``` + +> Wird `HOST_PORT`/`MODEL_ALIAS` geändert, müssen `LOCAL_LLM_BASE_URL`/`LOCAL_LLM_MODEL` +> im Gateway (`.env`) entsprechend angepasst werden. + +### Tempo im Sprach-Loop + +Ein Reasoning-Modell (Qwen3) „denkt" per Default lang und antwortet ausführlich mit +Markdown/Emojis — schlecht zum Vorlesen und spürbar träge. Der Provider +`local-openai-compatible` stellt daher für **gesprochene** Antworten um: + +| Setting | Default | Wirkung | +|---------|---------|---------| +| `LOCAL_LLM_DISABLE_REASONING` | `true` | schaltet die Qwen3-Denkphase ab (Time-to-first-word ~9× schneller) | +| `LOCAL_LLM_SYSTEM_PROMPT` | knapper Sprach-Prompt | kurze, vorlesbare Antworten in Fließtext (kein Markdown/Emoji) | +| `LOCAL_LLM_MAX_TOKENS` | `0` (Server-Limit) | optionaler harter Deckel, z. B. `256` | +| `LOCAL_LLM_TEMPERATURE` | `0.3` | Sampling-Temperatur | + +> Gemessen am Modell `va_llm`: dieselbe Frage fällt von **5,5 s / 1433 Zeichen** +> (Reasoning an, ausführlich) auf **0,7 s / ~190 Zeichen** (Reasoning aus + Sprach-Prompt). +> Für unzensierte „freie" Gespräche bleibt der Prompt rein formal (nur Kürze/Format, +> keine inhaltlichen Einschränkungen); per `LOCAL_LLM_SYSTEM_PROMPT=` leerbar. + +**Zweiter Hebel — STT:** `faster-whisper` läuft per Default auf `auto` (oft CPU) mit +Modell `base`. Auf einer RTX 3090 lohnt `FASTER_WHISPER_DEVICE=cuda` + +`FASTER_WHISPER_COMPUTE_TYPE=float16`; das verkürzt die Transkriptionszeit pro Turn. + +### Komplett lokal: Profil `local-dev` + +`VA_PROFILE=local-dev` betreibt **alle** KI-Module ohne Cloud. Die Route löst auf zu: + +| Modul | Provider | Quelle | +|-------|----------|--------| +| STT | `faster-whisper` | lokales Whisper-Modell | +| **LLM** | `local-openai-compatible` | llama.cpp-Server `http://127.0.0.1:8001/v1`, Modell `va_llm` | +| TTS | `piper` | lokales Stimmmodell | + +**Voraussetzungen:** +- **LLM:** llama.cpp-Container läuft (`make llm-up`) +- **STT:** faster-whisper installiert (`pip install -e .[local]`) +- **TTS:** piper-Binary + Stimme vorhanden (siehe `PIPER_*` in `.env.example`) + +**Start (Reihenfolge):** + +```bash +make llm-up # 35B-Modell laden; mit make llm-status auf "HTTP OK" warten +make run # Gateway nutzt jetzt das lokale, unzensierte Modell als zentrale KI +``` + +> `VA_PROFILE` ist in `.env` dauerhaft setzbar (aktuell `local-dev`) oder pro Lauf +> voranstellbar (`VA_PROFILE=hybrid make run`). Prüfen: `curl http://localhost:8080/api/config`. + ## API-Überblick | Methode & Pfad | Zweck | diff --git a/app/config.py b/app/config.py index 3d2cc13..d995fe2 100644 --- a/app/config.py +++ b/app/config.py @@ -117,9 +117,19 @@ class Settings(BaseSettings): default_stt_provider: str = "openrouter" default_llm_provider: str = "local-openai-compatible" default_tts_provider: str = "openrouter" - local_llm_base_url: str = "http://127.0.0.1:11434/v1" + # 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 = "llama3.1" + 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 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 diff --git a/app/dependencies.py b/app/dependencies.py index 1265433..4c8b0ed 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -55,7 +55,13 @@ STT_REGISTRY = { 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 + 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, ), } diff --git a/app/providers/llm/local_openai_compatible.py b/app/providers/llm/local_openai_compatible.py index d0bbad1..5985869 100644 --- a/app/providers/llm/local_openai_compatible.py +++ b/app/providers/llm/local_openai_compatible.py @@ -6,32 +6,61 @@ from app.providers.llm.base import LLMProvider, sse_delta class LocalOpenAICompatibleLLM(LLMProvider): - def __init__(self, base_url: str, api_key: str, model: str): + 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, + ): 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 def _build_messages(self, text: str, history: list[dict] | None) -> list[dict]: - messages = list(history) if history else [] + messages: list[dict] = [] + # Sprach-System-Prompt zuerst (knappe, vorlesbare Antworten). Etwaige + # System-Nachrichten aus der History (z. B. Nutzer-Erinnerungen) bleiben erhalten. + if self.system_prompt: + messages.append({"role": "system", "content": self.system_prompt}) + if history: + messages.extend(history) messages.append({"role": "user", "content": text}) return messages + def _payload(self, text: str, history: list[dict] | None, stream: bool) -> dict: + payload: dict = { + "model": self.model, + "messages": self._build_messages(text, history), + "temperature": self.temperature, + } + 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, ) -> str: - payload = { - "model": self.model, - "messages": self._build_messages(text, history), - "temperature": 0.3, - } 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=payload, + json=self._payload(text, history, stream=False), ) response.raise_for_status() data = response.json() @@ -43,18 +72,12 @@ class LocalOpenAICompatibleLLM(LLMProvider): history: list[dict] | None = None, session_id: str | None = None, ) -> AsyncIterator[str]: - payload = { - "model": self.model, - "messages": self._build_messages(text, history), - "temperature": 0.3, - "stream": True, - } 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=payload, + json=self._payload(text, history, stream=True), ) as response: if response.status_code >= 400: body = await response.aread() diff --git a/config/voice-assistant.example.toml b/config/voice-assistant.example.toml index 3a5937c..1bfb4b9 100644 --- a/config/voice-assistant.example.toml +++ b/config/voice-assistant.example.toml @@ -22,8 +22,9 @@ openrouter_tts_model = "openai/gpt-4o-mini-tts" openrouter_tts_voice = "alloy" openrouter_llm_model = "openai/gpt-4.1-mini" -local_llm_base_url = "http://127.0.0.1:11434/v1" -local_llm_model = "llama3.1" +# 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] 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 From aa64ccf5854c2391128e2634608ae9af8b87764e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Thu, 18 Jun 2026 03:15:08 +0200 Subject: [PATCH 041/113] feat(memory): automatische Erinnerungs-Extraktion aus Gespraechen - app/core/memory_extractor.py: LLM destilliert nach je N Turns dauerhafte Fakten/Vorlieben aus dem Verlauf, dedupliziert gegen vorhandene Erinnerungen und legt sie ab - best-effort, nicht-blockierend (Hintergrund-Task), eigener Extraktions-Prompt (JSON, Reasoning aus), Cap-Begrenzung - Trigger in /api/chat und /ws/voice nach dem Persistieren des Turns - Konfig: MEMORY_EXTRACTION_ENABLED/_EVERY_N_TURNS/_MAX/_PROVIDER - Tests: Extraktion, Dedup, kaputtes JSON, Cap, leeres Gespraech, Scheduling - Doku: README + Architektur-Roadmap (Punkt 3 erledigt) Co-Authored-By: Claude Opus 4.8 --- .env.example | 8 ++ Docs/voice-assistant-architecture.md | 2 +- README.md | 7 ++ app/api/chat.py | 2 + app/api/ws.py | 2 + app/config.py | 7 ++ app/core/memory_extractor.py | 164 +++++++++++++++++++++++++++ tests/test_memory_extraction.py | 135 ++++++++++++++++++++++ 8 files changed, 326 insertions(+), 1 deletion(-) create mode 100644 app/core/memory_extractor.py create mode 100644 tests/test_memory_extraction.py diff --git a/.env.example b/.env.example index 11df940..c1af288 100644 --- a/.env.example +++ b/.env.example @@ -77,6 +77,14 @@ TTS_SAMPLE_RATE=24000 # Ziel-Sample-Rate (ffmpeg # 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 diff --git a/Docs/voice-assistant-architecture.md b/Docs/voice-assistant-architecture.md index b987e4f..b76ccce 100644 --- a/Docs/voice-assistant-architecture.md +++ b/Docs/voice-assistant-architecture.md @@ -214,7 +214,7 @@ 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 gepflegt, als LLM-Kontext). Offen: **automatische** Extraktion/Zusammenfassung von Erinnerungen aus Gesprächen. +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 heuristische Notfall-Eskalation (Erkennung -> Log + optionaler Webhook + Flag/Event). Offen: echte Klassifikation statt Schluesselwort-Heuristik, Telefon-/Angehoerigen-Integration, Abrechnung. diff --git a/README.md b/README.md index 0f4f025..277bfde 100644 --- a/README.md +++ b/README.md @@ -226,6 +226,13 @@ curl -X POST http://localhost:8080/api/me/memories \ -H 'Content-Type: application/json' -d '{"content":"Mag morgens Kamillentee."}' ``` +**Automatische Erinnerungen:** Zusätzlich zur manuellen Pflege destilliert das LLM +nach je N Turns (Default 3) dauerhafte Fakten/Vorlieben aus dem Gespräch und legt sie +dedupliziert als Erinnerungen ab — best-effort und **nicht-blockierend** (Hintergrund-Task, +erhöht die Antwortlatenz nicht). Steuerung über `MEMORY_EXTRACTION_*` (siehe `.env.example`); +`MEMORY_EXTRACTION_ENABLED=false` schaltet es ab. Sinnvoll mit einem lokalen LLM, da pro +Turn ein zusätzlicher (kostenloser) Modellaufruf anfällt. + ## Echtzeit-Chat (WebSocket) `/ws/chat` bietet einen dauerhaften, bidirektionalen Kanal. Der Client sendet pro diff --git a/app/api/chat.py b/app/api/chat.py index df8ffbd..6deb509 100644 --- a/app/api/chat.py +++ b/app/api/chat.py @@ -13,6 +13,7 @@ from app.dependencies import ( resolve_output_endpoint, get_store, ) +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 from app.schemas import ChatRequest @@ -106,6 +107,7 @@ async def chat( 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( diff --git a/app/api/ws.py b/app/api/ws.py index 86c6b22..01ce254 100644 --- a/app/api/ws.py +++ b/app/api/ws.py @@ -29,6 +29,7 @@ from app.dependencies import ( ) from app.store import 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.safety.emergency import handle_emergency @@ -141,6 +142,7 @@ async def _run_turn(websocket, store, user, session_id, route, orchestrator, out 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} diff --git a/app/config.py b/app/config.py index d995fe2..bed84ac 100644 --- a/app/config.py +++ b/app/config.py @@ -142,6 +142,13 @@ class Settings(BaseSettings): admin_api_key: str = "" auth_enabled: bool = True 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" 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/tests/test_memory_extraction.py b/tests/test_memory_extraction.py new file mode 100644 index 0000000..2c5cae0 --- /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): + 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 From 855fc71a1e0765ba3f8236b5ad43bbe6bda212e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Thu, 18 Jun 2026 03:24:25 +0200 Subject: [PATCH 042/113] feat(safety): zweistufige Notfall-Erkennung mit LLM-Klassifikation - app/safety/llm_classifier.py: LLM klassifiziert Aeusserungen (medical/self_harm/ help/none) mit Konfidenz-Schwelle, eigener JSON-Prompt, Reasoning aus, temp 0 - emergency.py: Eskalation in _escalate() gebuendelt (Log/Metrik/Webhook, source-Label); schedule_llm_emergency_check() faehrt Stufe 2 als Hintergrund-Task NUR wenn die Stichwort-Heuristik nichts fand -> faengt verpasste Formulierungen ohne Mehrlatenz - /ws/voice schickt bei LLM-Treffer ein nachgelagertes emergency-Event (source=llm) - Konfig: EMERGENCY_LLM_ENABLED/_PROVIDER/_MIN_CONFIDENCE - Tests: Parsing, Konfidenz-Schwelle, Skip-bei-Keyword-Treffer, Eskalation+Callback, disabled, kaputtes JSON - Doku: README + Architektur-Roadmap (Punkt 6) Co-Authored-By: Claude Opus 4.8 --- .env.example | 5 ++ Docs/voice-assistant-architecture.md | 2 +- README.md | 21 +++-- app/api/chat.py | 4 +- app/api/ws.py | 16 +++- app/config.py | 6 ++ app/safety/emergency.py | 88 ++++++++++++++---- app/safety/llm_classifier.py | 96 ++++++++++++++++++++ tests/test_emergency_llm.py | 128 +++++++++++++++++++++++++++ 9 files changed, 341 insertions(+), 25 deletions(-) create mode 100644 app/safety/llm_classifier.py create mode 100644 tests/test_emergency_llm.py diff --git a/.env.example b/.env.example index c1af288..d4b6c00 100644 --- a/.env.example +++ b/.env.example @@ -88,3 +88,8 @@ TTS_SAMPLE_RATE=24000 # Ziel-Sample-Rate (ffmpeg # --- 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/Docs/voice-assistant-architecture.md b/Docs/voice-assistant-architecture.md index b76ccce..20c6fa7 100644 --- a/Docs/voice-assistant-architecture.md +++ b/Docs/voice-assistant-architecture.md @@ -217,7 +217,7 @@ Reihenfolge der Weiterentwicklung: 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 heuristische Notfall-Eskalation (Erkennung -> Log + optionaler Webhook + Flag/Event). Offen: echte Klassifikation statt Schluesselwort-Heuristik, Telefon-/Angehoerigen-Integration, Abrechnung. +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, ffmpeg-Resampling auf 24000 Hz) sind echt — eine **voll-lokale Konstellation** (STT+LLM+TTS lokal, keine API-Kosten, max. Datenschutz) ist damit möglich. Offen: `chatterbox`-TTS (noch Stub), höhere Sprachqualität als piper. 8. **TransportRouter** als eigene lokal/remote-Achse aktivieren; echte Geräte-Endpunkte (PipeWire/Bluetooth) — heute OS-Ebene. diff --git a/README.md b/README.md index 277bfde..9296074 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ Praktische Bedienung: [`BEDIENUNGSANLEITUNG.md`](BEDIENUNGSANLEITUNG.md). - **Routing auf jeder Ebene:** Default → Profil → Nutzer → Session → Request - **Authentifizierung** (Bearer-Token) + persistente Nutzer/Sessions (SQLite) - **Resilienz:** Fallback-Ketten je Modul (Provider fällt aus → nächster) + Metriken -- **Betrieb:** Tageskontingent pro Nutzer (`429`) + heuristische Notfall-Eskalation +- **Betrieb:** Tageskontingent pro Nutzer (`429`) + zweistufige Notfall-Eskalation (Stichwörter + LLM) - **Gesprächsgedächtnis pro Session:** Verlauf wird gespeichert und fließt ins LLM - **Langzeit-Erinnerungen pro Nutzer:** dauerhafte Fakten/Vorlieben als LLM-Kontext - **WebSocket-Streaming-Chat** (`/ws/chat`) als Echtzeit-Transport @@ -304,20 +304,29 @@ DAILY_REQUEST_LIMIT=200 # 0 = unbegrenzt; pro Nutzer/Tag ``` Pro Nutzer übersteuerbar via `prefs.daily_request_limit` (siehe `PUT /api/me/prefs`). -**Notfall-Eskalation:** `/api/chat` und `/ws/chat` prüfen die Nutzereingabe heuristisch +**Notfall-Eskalation (zweistufig):** `/api/chat` und `/ws/chat` prüfen die Nutzereingabe auf Notlagen-Signale (medizinisch, Selbstgefährdung, Hilferuf — de/en). Bei Treffer wird der Vorfall protokolliert, optional ein Webhook ausgelöst und das Signal sichtbar gemacht (`X-Emergency`-Header / `emergency`-Feld / WebSocket-`emergency`-Event). Eine Notfall-Eingabe umgeht das Kontingent (wird nie geblockt). +1. **Stichwort-Heuristik** im Hot-Path — sofort, ohne Latenz. +2. **LLM-Klassifikation** als **Hintergrund-Task**, der nur läuft, wenn die Stichwörter + nichts fanden. Fängt verpasste Formulierungen (z. B. metaphorisch geäußerte + Suizidalität oder Schlaganfall-Symptome ohne Schlüsselwort) mit Konfidenz-Schwelle — + **ohne** die Antwortlatenz zu erhöhen. Eskaliert genauso (Log/Webhook), beim WebSocket + zusätzlich ein nachgelagertes `emergency`-Event (`source: "llm"`). + ```bash EMERGENCY_WEBHOOK_URL=https://example.org/alert # optional, Benachrichtigung +EMERGENCY_LLM_ENABLED=true # Stufe 2 (Default an); false = nur Stichwörter +EMERGENCY_LLM_MIN_CONFIDENCE=0.6 # Schwelle gegen Fehlalarme ``` -> ⚠️ Die Erkennung ist eine **Schlüsselwort-Heuristik** — kein verlässlicher -> Lebensretter und kein Ersatz für einen echten Notruf. Sie kann Notlagen verpassen -> oder Fehlalarme auslösen. Erkannte Texte sind hochsensibel (DSGVO: Einwilligung, -> Aufbewahrung, Zugriff beachten). +> ⚠️ Die Erkennung (Heuristik **und** LLM) ist **kein verlässlicher Lebensretter** +> und kein Ersatz für einen echten Notruf. Sie kann Notlagen verpassen oder Fehlalarme +> auslösen. Erkannte Texte sind hochsensibel (DSGVO: Einwilligung, Aufbewahrung, +> Zugriff beachten). ## Authentifizierung diff --git a/app/api/chat.py b/app/api/chat.py index 6deb509..c1e3189 100644 --- a/app/api/chat.py +++ b/app/api/chat.py @@ -15,7 +15,7 @@ from app.dependencies import ( ) 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 +from app.safety.emergency import handle_emergency, schedule_llm_emergency_check from app.schemas import ChatRequest router = APIRouter() @@ -83,6 +83,8 @@ async def chat( # 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: diff --git a/app/api/ws.py b/app/api/ws.py index 01ce254..0f75111 100644 --- a/app/api/ws.py +++ b/app/api/ws.py @@ -31,7 +31,7 @@ from app.store import 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.safety.emergency import handle_emergency +from app.safety.emergency import handle_emergency, schedule_llm_emergency_check router = APIRouter() @@ -80,6 +80,20 @@ async def _run_turn(websocket, store, user, session_id, route, orchestrator, out # 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: diff --git a/app/config.py b/app/config.py index bed84ac..717a383 100644 --- a/app/config.py +++ b/app/config.py @@ -157,6 +157,12 @@ class Settings(BaseSettings): 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" ) diff --git a/app/safety/emergency.py b/app/safety/emergency.py index fbef7a9..2862df0 100644 --- a/app/safety/emergency.py +++ b/app/safety/emergency.py @@ -1,18 +1,28 @@ -"""Heuristische Notfall-Erkennung und Eskalation (Senioren-Kontext). +"""Notfall-Erkennung und Eskalation (Senioren-Kontext). -WICHTIG: Schluesselwort-Heuristik, KEIN Ersatz fuer eine echte Klassifikation. -Sie kann Notlagen verpassen oder Fehlalarme ausloesen. Die erkannten Textauszuege -sind hochsensibel und werden bewusst protokolliert (DSGVO beachten: Einwilligung, -Aufbewahrung, Zugriff). +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": [ @@ -61,8 +71,21 @@ async def _fire_webhook(url: str, user, category: str, snippet: str) -> None: 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): - """Erkennt, protokolliert und eskaliert ein Notfall-Signal. + """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. @@ -71,14 +94,47 @@ def handle_emergency(user, text: str, store, cfg: Settings = settings): if not match: return None category, phrase = match - snippet = text[:500] - store.log_emergency(user.id, category, snippet) - metrics.inc("emergency_total", {"category": category}) - 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 + _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/tests/test_emergency_llm.py b/tests/test_emergency_llm.py new file mode 100644 index 0000000..b463519 --- /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): + 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 == [] From 76df6951119e544441c948c68b29a3107288eda7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Thu, 18 Jun 2026 04:37:16 +0200 Subject: [PATCH 043/113] feat(web): Remote-Web-UI mit Mikrofon + Forward-Auth (YunoHost-SSO) - Minimale Web-UI (app/web/, vanilla, same-origin -> kein CORS): Text-Prompt + Mikrofon-Button (Aufnahme im Browser -> /ws/voice -> Antwort wird vorgelesen), Token-Streaming, PCM-Wiedergabe, Identitaet/Logout/Admin im Menue - Forward-/Trusted-Header-Auth (app/auth.py): Identitaet aus SSO-Header, nur von TRUSTED_PROXY_IPS akzeptiert; sonst Token/Anonymous-Fallback. Auto-Provisioning via store.get_or_create_user_by_external_id (+ external_id-Spalte/Migration) - /api/me um is_admin + sso_logout_url erweitert; GET /api/admin/users (Liste) und GET /api/admin/request-headers (SSO-Header-Discovery), Admin-gated - StaticFiles-Mount; Config: TRUSTED_AUTH_HEADER/_PROXY_IPS, ADMIN_USERS, SSO_LOGOUT_URL - WS-Auth liest Identitaet aus dem Handshake-Header - Deploy: nginx-Vorlage (WS-Upgrade!) + deploy/README.md (HTTPS/SSO/Firewall/Discovery) - Tests: Forward-Auth (Provisioning, Admin-Flag, Proxy-IP-Trust, 401/403, Static) Co-Authored-By: Claude Opus 4.8 --- .env.example | 7 + README.md | 15 ++ app/api/admin.py | 31 +++- app/api/me.py | 12 +- app/api/ws.py | 17 +-- app/auth.py | 81 +++++++++-- app/config.py | 8 + app/main.py | 8 + app/store.py | 67 ++++++++- app/web/app.js | 210 +++++++++++++++++++++++++++ app/web/index.html | 36 +++++ app/web/style.css | 52 +++++++ deploy/README.md | 53 +++++++ deploy/assistent.linix.de.nginx.conf | 42 ++++++ deploy/voice-assistant.env.example | 16 +- tests/test_forward_auth.py | 77 ++++++++++ 16 files changed, 703 insertions(+), 29 deletions(-) create mode 100644 app/web/app.js create mode 100644 app/web/index.html create mode 100644 app/web/style.css create mode 100644 deploy/README.md create mode 100644 deploy/assistent.linix.de.nginx.conf create mode 100644 tests/test_forward_auth.py diff --git a/.env.example b/.env.example index d4b6c00..c97974f 100644 --- a/.env.example +++ b/.env.example @@ -24,6 +24,13 @@ AUTH_ENABLED=true # Schluessel fuer die Nutzerverwaltung (POST /api/admin/users). Nur ueber die Umgebung. ADMIN_API_KEY= +# Forward-/Trusted-Header-Auth via Reverse-Proxy/SSO (z. B. YunoHost). Nur fuer +# Remote-Betrieb - siehe deploy/README.md. Lokal leer lassen. +# TRUSTED_AUTH_HEADER=X-Remote-User +# 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. diff --git a/README.md b/README.md index 9296074..dd3a751 100644 --- a/README.md +++ b/README.md @@ -377,10 +377,25 @@ sed -i 's/^PORT=.*/PORT=8003/' .env # dauerhaft PORT=8003 docker compose up # mit Docker ``` +## Web-UI & Remote-Zugang + +Das Gateway liefert unter `/` eine minimale **Web-Oberfläche** aus (`app/web/`, vanilla, +kein Build): Text-Eingabe + **Mikrofon-Button** (Aufnahme im Browser → `/ws/voice` → +Antwort wird vorgelesen) und links ein Menü mit Identität/Logout/Admin. + +Für den **Remote-Betrieb** (Handy/Browser von unterwegs) hinter einem Reverse-Proxy mit +HTTPS + SSO (z. B. YunoHost): siehe **`deploy/README.md`**. Kernpunkte: +- **HTTPS ist Pflicht** — Browser geben das Mikrofon nur im „secure context" frei. +- **Forward-/Trusted-Header-Auth**: der Proxy/SSO authentifiziert, reicht die Identität + per Header durch (`TRUSTED_AUTH_HEADER`); das Gateway legt Nutzer automatisch an. + Akzeptiert wird der Header nur von der Proxy-Quell-IP (`TRUSTED_PROXY_IPS`). +- **WebSocket-Upgrade** im nginx nicht vergessen (sonst kein Mikrofon). + ## Deployment - **Docker:** `docker compose up --build` (reicht `OPENROUTER_API_KEY` aus der Shell durch) - **systemd:** Vorlagen unter `deploy/` (`voice-assistant.service`, `voice-assistant.env.example`) +- **Remote über YunoHost/Reverse-Proxy:** `deploy/README.md` (HTTPS, SSO, nginx, Firewall) ## Projektstruktur (Kurzform) diff --git a/app/api/admin.py b/app/api/admin.py index c69ee72..bcafae4 100644 --- a/app/api/admin.py +++ b/app/api/admin.py @@ -1,14 +1,41 @@ -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, Request -from app.auth import require_admin +from app.auth import require_admin, require_admin_or_user from app.dependencies import get_store from app.schemas import UserCreate, UserCreated router = APIRouter() +@router.get("/admin/request-headers", dependencies=[Depends(require_admin_or_user)]) +async def request_headers(request: Request): + """Discovery: zeigt die eingehenden HTTP-Header + Quell-IP. + + Hilft, hinter dem Reverse-Proxy/SSO den richtigen Identitaets-Header + (TRUSTED_AUTH_HEADER) festzustellen. Nur fuer Admins. + """ + return { + "client": request.client.host if request.client else None, + "headers": dict(request.headers), + } + + @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.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() + ] diff --git a/app/api/me.py b/app/api/me.py index 95855e8..0e87f7a 100644 --- a/app/api/me.py +++ b/app/api/me.py @@ -1,6 +1,7 @@ from fastapi import APIRouter, Depends, HTTPException -from app.auth import require_user +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 @@ -10,7 +11,14 @@ router = APIRouter() @router.get("/me") async def get_me(user: User = Depends(require_user)): - return {"user_id": user.id, "display_name": user.display_name, "prefs": user.prefs} + 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") diff --git a/app/api/ws.py b/app/api/ws.py index 0f75111..48aeb13 100644 --- a/app/api/ws.py +++ b/app/api/ws.py @@ -31,6 +31,7 @@ from app.store import 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() @@ -45,13 +46,11 @@ _OVERRIDE_KEYS = ( ) -def _authenticate(token: str | None): - store = get_store() - if not settings.auth_enabled: - return store.ensure_anonymous_user() - if not token: - return None - return store.get_user_by_token(token) +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): @@ -213,7 +212,7 @@ async def _voice_turn(websocket, store, user, session_id, audio, fmt, options): @router.websocket("/ws/chat") async def ws_chat(websocket: WebSocket, session_id: str | None = None, token: str | None = None): - user = _authenticate(token) + user = _authenticate(websocket, token) if user is None: await websocket.close(code=1008) return @@ -244,7 +243,7 @@ async def ws_chat(websocket: WebSocket, session_id: str | None = None, token: st @router.websocket("/ws/voice") async def ws_voice(websocket: WebSocket, session_id: str | None = None, token: str | None = None): - user = _authenticate(token) + user = _authenticate(websocket, token) if user is None: await websocket.close(code=1008) return diff --git a/app/auth.py b/app/auth.py index 93407d3..bb55720 100644 --- a/app/auth.py +++ b/app/auth.py @@ -1,27 +1,69 @@ -from fastapi import Header, HTTPException +from fastapi import Header, HTTPException, Request -from app.config import settings +from app.config import settings, Settings from app.dependencies import get_store from app.store import User -def require_user(authorization: str | None = Header(default=None)) -> User: - """FastAPI-Dependency: liefert den authentifizierten Nutzer. +def _csv_set(value: str) -> set[str]: + return {item.strip() for item in (value or "").split(",") if item.strip()} - Bei AUTH_ENABLED=false gilt ein anonymer Standardnutzer (dev/Test). Sonst ist - ein gueltiges Bearer-Token noetig (sonst 401). + +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() - if not settings.auth_enabled: + + # 1. Forward-/Trusted-Header-Auth (nur von der Proxy-Quell-IP akzeptiert). + if cfg.trusted_auth_header: + ips = _csv_set(cfg.trusted_proxy_ips) + if ips and client_host in ips: + external = (headers.get(cfg.trusted_auth_header) or "").strip() + if not external: + return None # SSO sollte den Header immer setzen -> 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 -> Header ignorieren, normale Auth unten. + + # 2. Auth abgeschaltet (dev/Test). + if not cfg.auth_enabled: return store.ensure_anonymous_user() - if not authorization or not authorization.lower().startswith("bearer "): - raise HTTPException(status_code=401, detail="Bearer token required") + # 3. Bearer-Token. + if not token: + return None + return store.get_user_by_token(token) - token = authorization.split(" ", 1)[1].strip() - user = 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="Invalid token") + raise HTTPException(status_code=401, detail="Authentication required") return user @@ -34,3 +76,18 @@ def require_admin(x_admin_key: str | None = Header(default=None)) -> None: ) if not x_admin_key or x_admin_key.strip() != expected: raise HTTPException(status_code=401, detail="Invalid admin key") + + +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 index 717a383..92cf500 100644 --- a/app/config.py +++ b/app/config.py @@ -141,6 +141,14 @@ class Settings(BaseSettings): 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 = "" + 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) 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, diff --git a/app/main.py b/app/main.py index 0b12281..fb6fcbf 100644 --- a/app/main.py +++ b/app/main.py @@ -1,6 +1,8 @@ import time +from pathlib import Path from fastapi import FastAPI, Request +from fastapi.staticfiles import StaticFiles from app.metrics import metrics from app.api.health import router as health_router @@ -44,3 +46,9 @@ 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. +_WEB_DIR = Path(__file__).resolve().parent / "web" +if _WEB_DIR.is_dir(): + app.mount("/", StaticFiles(directory=str(_WEB_DIR), html=True), name="web") diff --git a/app/store.py b/app/store.py index a06b2da..ead900b 100644 --- a/app/store.py +++ b/app/store.py @@ -33,6 +33,8 @@ class User: 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 @@ -70,6 +72,17 @@ class Store(ABC): @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: ... @@ -132,7 +145,8 @@ class SQLiteStore(Store): display_name TEXT NOT NULL, token_hash TEXT NOT NULL UNIQUE, prefs_json TEXT NOT NULL DEFAULT '{}', - created_at TEXT NOT NULL + created_at TEXT NOT NULL, + external_id TEXT ); CREATE TABLE IF NOT EXISTS sessions ( id TEXT PRIMARY KEY, @@ -174,14 +188,25 @@ class SQLiteStore(Store): ); """ ) + # 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]: @@ -230,6 +255,46 @@ class SQLiteStore(Store): ) 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: diff --git a/app/web/app.js b/app/web/app.js new file mode 100644 index 0000000..75ad3f1 --- /dev/null +++ b/app/web/app.js @@ -0,0 +1,210 @@ +"use strict"; + +const SESSION_ID = "web-main"; +const $ = (sel) => document.querySelector(sel); + +const messagesEl = $("#messages"); +const statusEl = $("#status"); +const promptEl = $("#prompt"); +const formEl = $("#prompt-form"); +const micBtn = $("#mic"); + +let busy = false; + +// ---------- Identitaet / Menue ---------- +async function loadMe() { + try { + const res = await fetch("/api/me", { headers: { Accept: "application/json" } }); + if (!res.ok) { + $("#identity").textContent = "Nicht angemeldet"; + return; + } + const me = await res.json(); + $("#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").classList.remove("hidden"); + } + } catch (e) { + $("#identity").textContent = "Verbindung fehlgeschlagen"; + } +} + +async function loadUsers() { + const ul = $("#user-list"); + ul.innerHTML = "
  • lade …
  • "; + try { + const res = await fetch("/api/admin/users"); + if (!res.ok) { ul.innerHTML = "
  • kein Zugriff
  • "; return; } + const users = await res.json(); + ul.innerHTML = ""; + for (const u of users) { + const li = document.createElement("li"); + li.textContent = (u.external_id || u.display_name) + " (" + u.user_id.slice(0, 8) + ")"; + ul.appendChild(li); + } + if (!users.length) ul.innerHTML = "
  • keine Nutzer
  • "; + } catch (e) { + ul.innerHTML = "
  • Fehler
  • "; + } +} + +// ---------- Nachrichten-UI ---------- +function addMessage(role, text) { + const div = document.createElement("div"); + div.className = "msg " + role; + div.textContent = text; + messagesEl.appendChild(div); + messagesEl.scrollTop = messagesEl.scrollHeight; + return div; +} + +// ---------- Audio-Wiedergabe (PCM s16le) ---------- +let audioCtx = null; +function playPcm(chunks, sampleRate) { + if (!chunks.length) 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)(); + 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); + 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(SESSION_ID)}`; +} + +// 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)); + ws.binaryType = "arraybuffer"; + const pcm = []; + let answerEl = null; + let sampleRate = 24000; + + ws.onopen = () => onopen(ws); + ws.onerror = () => { statusEl.textContent = "Verbindungsfehler"; resolve(); }; + ws.onclose = () => 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 "transcript": + if (msg.text) addMessage("user", msg.text); + break; + case "token": + if (!answerEl) answerEl = addMessage("assistant", ""); + answerEl.textContent += msg.text || ""; + messagesEl.scrollTop = messagesEl.scrollHeight; + break; + case "semantic": + if (!answerEl) answerEl = addMessage("assistant", ""); + if (msg.text) answerEl.textContent = msg.text; + break; + case "emergency": + addMessage("emergency", "⚠ Notfall erkannt (" + (msg.category || "?") + ")"); + break; + case "done": + sampleRate = msg.sample_rate || 24000; + playPcm(pcm, sampleRate); + statusEl.textContent = ""; + ws.close(); + break; + case "error": + addMessage("system", "Fehler: " + (msg.detail || msg.status || "unbekannt")); + ws.close(); + break; + } + }; + }); +} + +// ---------- Text senden ---------- +async function sendText(text) { + if (busy || !text.trim()) return; + busy = true; + addMessage("user", text); + statusEl.textContent = "denkt …"; + await runTurn("/ws/chat", (ws) => { + ws.send(JSON.stringify({ text, stream: true, audio_stream: true })); + }); + busy = false; +} + +formEl.addEventListener("submit", (e) => { + e.preventDefault(); + const text = promptEl.value; + promptEl.value = ""; + sendText(text); +}); + +// ---------- Mikrofon (Push-to-Talk) ---------- +let mediaRecorder = null; +let recChunks = []; + +async function startRecording() { + if (busy) return; + let stream; + try { + stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + } catch (e) { + addMessage("system", "Mikrofon nicht verfuegbar (HTTPS noetig?). " + e.message); + return; + } + recChunks = []; + mediaRecorder = new MediaRecorder(stream); + mediaRecorder.ondataavailable = (e) => { if (e.data.size) recChunks.push(e.data); }; + mediaRecorder.onstop = async () => { + stream.getTracks().forEach((t) => t.stop()); + const blob = new Blob(recChunks, { type: mediaRecorder.mimeType || "audio/webm" }); + const bytes = await blob.arrayBuffer(); + sendVoice(bytes); + }; + mediaRecorder.start(); + micBtn.classList.add("recording"); + statusEl.textContent = "Aufnahme … (zum Stoppen erneut tippen)"; +} + +function stopRecording() { + if (mediaRecorder && mediaRecorder.state !== "inactive") mediaRecorder.stop(); + micBtn.classList.remove("recording"); +} + +async function sendVoice(bytes) { + if (busy) return; + busy = true; + statusEl.textContent = "verarbeite Sprache …"; + await runTurn("/ws/voice", (ws) => { + ws.send(JSON.stringify({ type: "start", format: "webm", stream: true, audio_stream: true })); + ws.send(bytes); + ws.send(JSON.stringify({ type: "end" })); + }); + busy = false; +} + +micBtn.addEventListener("click", () => { + if (mediaRecorder && mediaRecorder.state === "recording") stopRecording(); + else startRecording(); +}); + +$("#reload-users").addEventListener("click", loadUsers); + +loadMe(); diff --git a/app/web/index.html b/app/web/index.html new file mode 100644 index 0000000..c287c68 --- /dev/null +++ b/app/web/index.html @@ -0,0 +1,36 @@ + + + + + + Voice Assistant + + + +
    + + +
    +
    + +
    + + + +
    +
    +
    +
    + + + diff --git a/app/web/style.css b/app/web/style.css new file mode 100644 index 0000000..d7e191a --- /dev/null +++ b/app/web/style.css @@ -0,0 +1,52 @@ +* { box-sizing: border-box; } +body { + margin: 0; + font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + color: #1c1c1c; + background: #f6f7f9; +} +#layout { display: flex; min-height: 100vh; } + +#menu { + width: 240px; + flex-shrink: 0; + background: #1f2933; + color: #e4e7eb; + padding: 1rem; + display: flex; + flex-direction: column; + gap: 1rem; +} +#menu h2 { font-size: 1.1rem; margin: 0; } +#menu h3 { font-size: 0.9rem; margin: 0 0 0.5rem; text-transform: uppercase; letter-spacing: 0.05em; } +#menu a { color: #9fd3ff; } +#identity { font-size: 0.9rem; } +.muted { color: #7b8794; font-size: 0.85rem; } +.hidden { display: none; } + +#user-list { list-style: none; padding: 0; margin: 0 0 0.5rem; font-size: 0.85rem; } +#user-list li { padding: 0.15rem 0; border-bottom: 1px solid #323f4b; } + +#chat { flex: 1; display: flex; flex-direction: column; padding: 1rem; max-width: 820px; margin: 0 auto; width: 100%; } +#messages { flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: 0.6rem; padding-bottom: 1rem; } + +.msg { padding: 0.6rem 0.8rem; border-radius: 0.8rem; max-width: 80%; white-space: pre-wrap; line-height: 1.4; } +.msg.user { align-self: flex-end; background: #2b6cb0; color: #fff; } +.msg.assistant { align-self: flex-start; background: #fff; border: 1px solid #e1e4e8; } +.msg.system { align-self: center; background: #fdf3d7; border: 1px solid #f0d98c; font-size: 0.85rem; } +.msg.emergency { align-self: center; background: #fde0e0; border: 1px solid #f5a3a3; font-weight: 600; } + +#prompt-form { display: flex; gap: 0.5rem; align-items: center; } +#prompt { flex: 1; padding: 0.7rem; border: 1px solid #cbd2d9; border-radius: 0.6rem; font-size: 1rem; } +button { padding: 0.7rem 1rem; border: none; border-radius: 0.6rem; background: #2b6cb0; color: #fff; font-size: 1rem; cursor: pointer; } +button:disabled { opacity: 0.5; cursor: default; } +#mic { background: #2f855a; } +#mic.recording { background: #c53030; animation: pulse 1s infinite; } +@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.6; } } +#status { min-height: 1.2rem; margin-top: 0.4rem; } + +@media (max-width: 640px) { + #layout { flex-direction: column; } + #menu { width: 100%; flex-direction: row; flex-wrap: wrap; align-items: center; } + #menu h2 { flex: 1; } +} diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..ea45337 --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,53 @@ +# Remote-Betrieb über YunoHost (assistent.linix.de) + +Web-UI + Mikrofon vom Handy/Browser, abgesichert per HTTPS und YunoHost-SSO. +Topologie: `https://assistent.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. + +## 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/assistent.linix.de.nginx.conf` als Vorlage → auf dem YunoHost-Server unter +`/etc/nginx/conf.d/assistent.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 `assistent.linix.de` in YunoHost +anlegen (Let's Encrypt) und per SSO schützen (nur erlaubte Tester/Gruppe). + +## 4. Discovery: richtigen Identitäts-Header bestimmen +Als Admin **hinter dem SSO** aufrufen: +``` +https://assistent.linix.de/api/admin/request-headers +``` +Dort den Header finden, der den eingeloggten SSO-Usernamen trägt (z. B. `X-Remote-User`, +`Remote-User`, `Auth-User` …). Diesen Namen in `TRUSTED_AUTH_HEADER` **und** in der +nginx-`proxy_set_header …`-Zeile eintragen, Gateway + nginx neu laden. + +## 5. Test +- `https://assistent.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). diff --git a/deploy/assistent.linix.de.nginx.conf b/deploy/assistent.linix.de.nginx.conf new file mode 100644 index 0000000..eafa7bc --- /dev/null +++ b/deploy/assistent.linix.de.nginx.conf @@ -0,0 +1,42 @@ +# Reverse-Proxy fuer die Voice-Assistant-Web-UI auf einem YunoHost-Server. +# +# Ziel: https://assistent.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/assistent.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; + + # Identitaets-Header: SSOwat setzt die Nutzeridentitaet. Nach der Discovery + # (GET /api/admin/request-headers hinter dem SSO) den richtigen Namen hier + # FEST setzen und Client-Spoofing verwerfen. Beispiel, wenn SSO $remote_user + # bereitstellt (Name ggf. anpassen): + # + # proxy_set_header X-Remote-User $remote_user; + # + # In der Gateway-Konfiguration dann: TRUSTED_AUTH_HEADER=X-Remote-User +} diff --git a/deploy/voice-assistant.env.example b/deploy/voice-assistant.env.example index 578603b..21341af 100644 --- a/deploy/voice-assistant.env.example +++ b/deploy/voice-assistant.env.example @@ -1,12 +1,22 @@ -HOST=0.0.0.0 -PORT=8080 +# 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. +# 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/tests/test_forward_auth.py b/tests/test_forward_auth.py new file mode 100644 index 0000000..15b6d2a --- /dev/null +++ b/tests/test_forward_auth.py @@ -0,0 +1,77 @@ +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(): + r = client.get("/") + assert r.status_code == 200 + assert "Voice Assistant" in r.text From d59afa4dd9f1345cfd9b15d88be89691d808cc7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Thu, 18 Jun 2026 04:45:21 +0200 Subject: [PATCH 044/113] fix(llm): System-Nachrichten zu einer fuehrenden zusammenfuehren (Qwen3-Template) Qwen3 verlangt genau eine System-Nachricht ganz am Anfang. Sobald ein Nutzer Erinnerungen hatte, injizierte das Gateway eine zweite System-Nachricht zusaetzlich zum Sprach-System-Prompt -> 400 "System message must be at the beginning". Der lokale Provider fuehrt jetzt Sprach-Prompt + System-Inhalte aus der History (z. B. Nutzer-Erinnerungen) zu einer einzigen System-Nachricht zusammen. Co-Authored-By: Claude Opus 4.8 --- app/providers/llm/local_openai_compatible.py | 25 ++++++++++++---- tests/test_local_llm_messages.py | 30 ++++++++++++++++++++ 2 files changed, 49 insertions(+), 6 deletions(-) create mode 100644 tests/test_local_llm_messages.py diff --git a/app/providers/llm/local_openai_compatible.py b/app/providers/llm/local_openai_compatible.py index 5985869..c550aed 100644 --- a/app/providers/llm/local_openai_compatible.py +++ b/app/providers/llm/local_openai_compatible.py @@ -25,13 +25,26 @@ class LocalOpenAICompatibleLLM(LLMProvider): self.temperature = temperature def _build_messages(self, text: str, history: list[dict] | None) -> list[dict]: - messages: list[dict] = [] - # Sprach-System-Prompt zuerst (knappe, vorlesbare Antworten). Etwaige - # System-Nachrichten aus der History (z. B. Nutzer-Erinnerungen) bleiben erhalten. + # 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: - messages.append({"role": "system", "content": self.system_prompt}) - if history: - messages.extend(history) + 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) + + messages: list[dict] = [] + if system_parts: + messages.append({"role": "system", "content": "\n\n".join(system_parts)}) + messages.extend(rest) messages.append({"role": "user", "content": text}) return messages 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"}] From 434a154af2b61782e579958d2f95879d9d3b015e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Thu, 18 Jun 2026 05:12:14 +0200 Subject: [PATCH 045/113] docs: LAN-Zugriff auf die Web-UI dokumentieren (Firewall, IP, Mic-Caveat) Hinweis, dass der Server bereits auf 0.0.0.0 lauscht und fuer LAN-Zugriff nur die Firewall geoeffnet werden muss; Mikrofon braucht HTTPS/localhost (secure context). Co-Authored-By: Claude Opus 4.8 --- README.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/README.md b/README.md index dd3a751..b8da58f 100644 --- a/README.md +++ b/README.md @@ -383,6 +383,27 @@ Das Gateway liefert unter `/` eine minimale **Web-Oberfläche** aus (`app/web/`, kein Build): Text-Eingabe + **Mikrofon-Button** (Aufnahme im Browser → `/ws/voice` → Antwort wird vorgelesen) und links ein Menü mit Identität/Logout/Admin. +### Zugriff aus dem lokalen Netz (LAN) + +Der Server lauscht standardmäßig auf `0.0.0.0` (alle Interfaces). Für den Zugriff von +anderen Rechnern/Handys im LAN reichen zwei Dinge: + +1. **Firewall öffnen** für den Port (Beispiel ufw, auf die eigenen LAN-Subnetze beschränkt): + ```bash + sudo ufw allow from 192.168.179.0/24 to any port 8003 proto tcp comment 'voice-assistant LAN' + ``` +2. Im Browser des anderen Geräts die **LAN-IP** des Servers aufrufen: `http://:8003/`. + +> ⚠️ **Mikrofon nur über HTTPS/localhost:** Browser geben das Mikrofon nur in einem +> „secure context" frei. Über `http://:8003` funktioniert daher der **Text-Chat**, +> aber **nicht** der Mic-Button. Für Sprache von anderen Geräten den HTTPS-Weg nutzen +> (siehe unten) — am lokalen Rechner via `http://localhost:8003` geht das Mikrofon. + +> ⚠️ Bei `AUTH_ENABLED=false` kann **jeder im LAN** den Dienst anonym nutzen. Für mehr +> als vertrautes Testen Auth aktivieren bzw. den SSO-Weg wählen. + +### Remote von unterwegs (HTTPS + SSO) + Für den **Remote-Betrieb** (Handy/Browser von unterwegs) hinter einem Reverse-Proxy mit HTTPS + SSO (z. B. YunoHost): siehe **`deploy/README.md`**. Kernpunkte: - **HTTPS ist Pflicht** — Browser geben das Mikrofon nur im „secure context" frei. From 887faa315e5de1639eedf8c1bd416f809dcea81e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Thu, 18 Jun 2026 05:19:41 +0200 Subject: [PATCH 046/113] feat(admin): Header-Discovery per ?key= durchs SSO aufrufbar Beim SSO-Setup kennt das Gateway den Admin-Nutzer noch nicht (Henne-Ei). Der Discovery-Endpoint /api/admin/request-headers akzeptiert daher zusaetzlich den ADMIN_API_KEY als Query (?key=) oder X-Admin-Key-Header, damit man den von SSOwat injizierten Identitaets-Header im Browser bestimmen kann. Doku entsprechend. Co-Authored-By: Claude Opus 4.8 --- app/api/admin.py | 29 ++++++++++++++++++++++++----- deploy/README.md | 14 +++++++++----- 2 files changed, 33 insertions(+), 10 deletions(-) diff --git a/app/api/admin.py b/app/api/admin.py index bcafae4..7dac54e 100644 --- a/app/api/admin.py +++ b/app/api/admin.py @@ -1,25 +1,44 @@ -from fastapi import APIRouter, Depends, Request +from fastapi import APIRouter, Depends, HTTPException, Request -from app.auth import require_admin, require_admin_or_user +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.schemas import UserCreate, UserCreated router = APIRouter() -@router.get("/admin/request-headers", dependencies=[Depends(require_admin_or_user)]) -async def request_headers(request: Request): +@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. Nur fuer Admins. + (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.""" diff --git a/deploy/README.md b/deploy/README.md index ea45337..f874b08 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -39,13 +39,17 @@ eintragen, die `map $http_upgrade …` einmalig im http{}-Kontext anlegen, dann anlegen (Let's Encrypt) und per SSO schützen (nur erlaubte Tester/Gruppe). ## 4. Discovery: richtigen Identitäts-Header bestimmen -Als Admin **hinter dem SSO** aufrufen: +**Hinter dem SSO** aufrufen (durchs SSO-Portal einloggen, dann diese URL). Da das +Gateway den SSO-Admin zu diesem Zeitpunkt noch nicht kennt (Henne-Ei), den Admin-Key +als Query mitgeben: ``` -https://assistent.linix.de/api/admin/request-headers +https://assistent.linix.de/api/admin/request-headers?key= ``` -Dort den Header finden, der den eingeloggten SSO-Usernamen trägt (z. B. `X-Remote-User`, -`Remote-User`, `Auth-User` …). Diesen Namen in `TRUSTED_AUTH_HEADER` **und** in der -nginx-`proxy_set_header …`-Zeile eintragen, Gateway + nginx neu laden. +In der Ausgabe den Header finden, der den eingeloggten SSO-Usernamen trägt (z. B. +`X-Remote-User`, `Remote-User`, `Auth-User`, oder `Authorization: Basic …` mit dem +Usernamen). Diesen Namen in `TRUSTED_AUTH_HEADER` **und** in der nginx-`proxy_set_header +…`-Zeile eintragen, Gateway + nginx neu laden. Danach `?key=` nicht mehr nutzen +(landet in Proxy-Logs) bzw. den Key rotieren. ## 5. Test - `https://assistent.linix.de/` lädt die UI, links „Angemeldet als ". From 959f0ff8f84df7880dd92acf2a4c33049ab95d20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Thu, 18 Jun 2026 05:35:16 +0200 Subject: [PATCH 047/113] docs: Subdomain assistent.linix.de -> va.linix.de umbenannt Kuerzer und ohne die Verwechslungsgefahr assistent/assistant (de/en). nginx-Vorlage umbenannt + alle Referenzen in deploy/README.md angepasst. Co-Authored-By: Claude Opus 4.8 --- deploy/README.md | 14 +++++++------- ....linix.de.nginx.conf => va.linix.de.nginx.conf} | 4 ++-- 2 files changed, 9 insertions(+), 9 deletions(-) rename deploy/{assistent.linix.de.nginx.conf => va.linix.de.nginx.conf} (91%) diff --git a/deploy/README.md b/deploy/README.md index f874b08..159f0cb 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -1,7 +1,7 @@ -# Remote-Betrieb über YunoHost (assistent.linix.de) +# Remote-Betrieb über YunoHost (va.linix.de) Web-UI + Mikrofon vom Handy/Browser, abgesichert per HTTPS und YunoHost-SSO. -Topologie: `https://assistent.linix.de` → nginx@YunoHost (TLS, SSO) → über LAN → +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). @@ -32,10 +32,10 @@ Topologie: `https://assistent.linix.de` → nginx@YunoHost (TLS, SSO) → über werden verworfen (siehe nginx-Conf). App-seitig zusätzlich der Proxy-IP-Check. ## 3. nginx auf YunoHost -`deploy/assistent.linix.de.nginx.conf` als Vorlage → auf dem YunoHost-Server unter -`/etc/nginx/conf.d/assistent.linix.de.d/assistant.conf` ablegen, `GPU_BOX_LAN_IP` +`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 `assistent.linix.de` in YunoHost +`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. Discovery: richtigen Identitäts-Header bestimmen @@ -43,7 +43,7 @@ anlegen (Let's Encrypt) und per SSO schützen (nur erlaubte Tester/Gruppe). Gateway den SSO-Admin zu diesem Zeitpunkt noch nicht kennt (Henne-Ei), den Admin-Key als Query mitgeben: ``` -https://assistent.linix.de/api/admin/request-headers?key= +https://va.linix.de/api/admin/request-headers?key= ``` In der Ausgabe den Header finden, der den eingeloggten SSO-Usernamen trägt (z. B. `X-Remote-User`, `Remote-User`, `Auth-User`, oder `Authorization: Basic …` mit dem @@ -52,6 +52,6 @@ Usernamen). Diesen Namen in `TRUSTED_AUTH_HEADER` **und** in der nginx-`proxy_se (landet in Proxy-Logs) bzw. den Key rotieren. ## 5. Test -- `https://assistent.linix.de/` lädt die UI, links „Angemeldet als ". +- `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). diff --git a/deploy/assistent.linix.de.nginx.conf b/deploy/va.linix.de.nginx.conf similarity index 91% rename from deploy/assistent.linix.de.nginx.conf rename to deploy/va.linix.de.nginx.conf index eafa7bc..5df5b95 100644 --- a/deploy/assistent.linix.de.nginx.conf +++ b/deploy/va.linix.de.nginx.conf @@ -1,10 +1,10 @@ # Reverse-Proxy fuer die Voice-Assistant-Web-UI auf einem YunoHost-Server. # -# Ziel: https://assistent.linix.de -> http://:8003 (Gateway im LAN) +# 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/assistent.linix.de.d/assistant.conf +# /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 From a62c49d6b1255047eeab02c567c0a776e8dffc52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Thu, 18 Jun 2026 07:39:40 +0200 Subject: [PATCH 048/113] fix(web): responsives Layout + zuverlaessiges Auto-Scrollen (mobil) - Layout auf echte Viewport-Hoehe (100dvh, vh-Fallback); nur der Nachrichten- bereich scrollt (min-height:0), Eingabe + Mic-Button bleiben immer sichtbar - Mobile: Menue als kompakte Kopfleiste, Chat fuellt den Rest - Auto-Scroll zentralisiert (scrollToBottom via rAF) und bei token/semantic/done/ neuer Nachricht ausgeloest; reagiert auf Tastatur (visualViewport) + Resize/Focus -> die neueste Antwort ist immer vollstaendig sichtbar Co-Authored-By: Claude Opus 4.8 --- app/web/app.js | 18 ++++++++++++++++-- app/web/style.css | 42 +++++++++++++++++++++++++++++++++++------- 2 files changed, 51 insertions(+), 9 deletions(-) diff --git a/app/web/app.js b/app/web/app.js index 75ad3f1..a1ca26a 100644 --- a/app/web/app.js +++ b/app/web/app.js @@ -54,12 +54,17 @@ async function loadUsers() { } // ---------- Nachrichten-UI ---------- +// Nach dem naechsten Layout ans Ende scrollen -> die neueste Antwort ist sichtbar. +function scrollToBottom() { + requestAnimationFrame(() => { messagesEl.scrollTop = messagesEl.scrollHeight; }); +} + function addMessage(role, text) { const div = document.createElement("div"); div.className = "msg " + role; div.textContent = text; messagesEl.appendChild(div); - messagesEl.scrollTop = messagesEl.scrollHeight; + scrollToBottom(); return div; } @@ -113,11 +118,12 @@ function runTurn(path, onopen) { case "token": if (!answerEl) answerEl = addMessage("assistant", ""); answerEl.textContent += msg.text || ""; - messagesEl.scrollTop = messagesEl.scrollHeight; + scrollToBottom(); break; case "semantic": if (!answerEl) answerEl = addMessage("assistant", ""); if (msg.text) answerEl.textContent = msg.text; + scrollToBottom(); break; case "emergency": addMessage("emergency", "⚠ Notfall erkannt (" + (msg.category || "?") + ")"); @@ -126,6 +132,7 @@ function runTurn(path, onopen) { sampleRate = msg.sample_rate || 24000; playPcm(pcm, sampleRate); statusEl.textContent = ""; + scrollToBottom(); ws.close(); break; case "error": @@ -207,4 +214,11 @@ micBtn.addEventListener("click", () => { $("#reload-users").addEventListener("click", loadUsers); +// 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)); + loadMe(); diff --git a/app/web/style.css b/app/web/style.css index d7e191a..e25ef46 100644 --- a/app/web/style.css +++ b/app/web/style.css @@ -1,15 +1,19 @@ * { box-sizing: border-box; } +html, body { height: 100%; } body { margin: 0; font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; color: #1c1c1c; background: #f6f7f9; } -#layout { display: flex; min-height: 100vh; } +/* Echte (dynamische) Viewport-Hoehe: auf iOS beruecksichtigt 100dvh die + ein-/ausblendende Browserleiste; 100vh als Fallback fuer aeltere Browser. */ +#layout { display: flex; height: 100vh; height: 100dvh; overflow: hidden; } #menu { width: 240px; flex-shrink: 0; + overflow-y: auto; background: #1f2933; color: #e4e7eb; padding: 1rem; @@ -27,8 +31,19 @@ body { #user-list { list-style: none; padding: 0; margin: 0 0 0.5rem; font-size: 0.85rem; } #user-list li { padding: 0.15rem 0; border-bottom: 1px solid #323f4b; } -#chat { flex: 1; display: flex; flex-direction: column; padding: 1rem; max-width: 820px; margin: 0 auto; width: 100%; } -#messages { flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: 0.6rem; padding-bottom: 1rem; } +#chat { flex: 1; min-height: 0; display: flex; flex-direction: column; padding: 1rem; max-width: 820px; margin: 0 auto; width: 100%; } +/* min-height:0 erlaubt dem Flex-Kind zu schrumpfen -> nur DIESER Bereich scrollt. */ +#messages { + flex: 1; + min-height: 0; + overflow-y: auto; + -webkit-overflow-scrolling: touch; + overscroll-behavior: contain; + display: flex; + flex-direction: column; + gap: 0.6rem; + padding-bottom: 0.5rem; +} .msg { padding: 0.6rem 0.8rem; border-radius: 0.8rem; max-width: 80%; white-space: pre-wrap; line-height: 1.4; } .msg.user { align-self: flex-end; background: #2b6cb0; color: #fff; } @@ -36,17 +51,30 @@ body { .msg.system { align-self: center; background: #fdf3d7; border: 1px solid #f0d98c; font-size: 0.85rem; } .msg.emergency { align-self: center; background: #fde0e0; border: 1px solid #f5a3a3; font-weight: 600; } -#prompt-form { display: flex; gap: 0.5rem; align-items: center; } +/* Eingabe + Mic bleiben immer sichtbar (fester Fuss des Chat-Bereichs). */ +#prompt-form { display: flex; gap: 0.5rem; align-items: center; flex-shrink: 0; padding-top: 0.5rem; } #prompt { flex: 1; padding: 0.7rem; border: 1px solid #cbd2d9; border-radius: 0.6rem; font-size: 1rem; } button { padding: 0.7rem 1rem; border: none; border-radius: 0.6rem; background: #2b6cb0; color: #fff; font-size: 1rem; cursor: pointer; } button:disabled { opacity: 0.5; cursor: default; } #mic { background: #2f855a; } #mic.recording { background: #c53030; animation: pulse 1s infinite; } @keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.6; } } -#status { min-height: 1.2rem; margin-top: 0.4rem; } +#status { min-height: 1.2rem; margin-top: 0.4rem; flex-shrink: 0; } @media (max-width: 640px) { #layout { flex-direction: column; } - #menu { width: 100%; flex-direction: row; flex-wrap: wrap; align-items: center; } - #menu h2 { flex: 1; } + /* Menue als kompakte, nicht scrollende Kopfleiste; der Chat fuellt den Rest. */ + #menu { + width: 100%; + flex-shrink: 0; + flex-direction: row; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; + padding: 0.6rem 1rem; + } + #menu h2 { flex: 1; font-size: 1rem; } + #menu h3 { display: none; } + #admin { width: 100%; order: 3; } + #chat { flex: 1; min-height: 0; padding: 0.6rem; } } From b3c8114469052035237552cb4dd19febd7ca70c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Thu, 18 Jun 2026 07:49:55 +0200 Subject: [PATCH 049/113] fix(web): kein Caching der UI + versionierte Assets Safari cachte CSS/JS aggressiv -> Aenderungen wurden nicht sichtbar. StaticFiles liefert die UI nun mit Cache-Control: no-cache aus; Asset-Links zusaetzlich versioniert (?v=3), damit die neue Version sicher geladen wird. Co-Authored-By: Claude Opus 4.8 --- app/main.py | 11 ++++++++++- app/web/index.html | 4 ++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/app/main.py b/app/main.py index fb6fcbf..6f1e587 100644 --- a/app/main.py +++ b/app/main.py @@ -49,6 +49,15 @@ 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("/", StaticFiles(directory=str(_WEB_DIR), html=True), name="web") + app.mount("/", _NoCacheStaticFiles(directory=str(_WEB_DIR), html=True), name="web") diff --git a/app/web/index.html b/app/web/index.html index c287c68..deafe81 100644 --- a/app/web/index.html +++ b/app/web/index.html @@ -4,7 +4,7 @@ Voice Assistant - +
    @@ -31,6 +31,6 @@
    - + From 7ca69e10499584d34b4c3c73a8997b3f690054cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Thu, 18 Jun 2026 08:22:04 +0200 Subject: [PATCH 050/113] perf(tts): piper in-process mit gecachtem Modell statt Subprozess pro Satz Profiling zeigte ~2,2 s Fixkosten pro Satz durch Modell-Start (piper-Binary je Aufruf). Der Provider nutzt jetzt die piper-Python-API: Stimmmodell wird einmal geladen (lru_cache), Synthese laeuft im Thread. Resampling in-process (audioop, numpy-Fallback fuer Py3.13) statt ffmpeg-Subprozess. Binary bleibt als Fallback. Messung (lokales Setup): erster Ton 5,84 s -> 2,66 s, Turn-Ende 9,82 s -> 4,04 s. - pyproject: piper-tts zum [local]-Extra - Tests pruefen weiter den Binary-Fallback (erzwingen _PIPER_LIB=False) Co-Authored-By: Claude Opus 4.8 --- app/providers/tts/piper.py | 117 ++++++++++++++++++++++--------------- pyproject.toml | 6 +- tests/test_piper_tts.py | 12 +++- 3 files changed, 85 insertions(+), 50 deletions(-) diff --git a/app/providers/tts/piper.py b/app/providers/tts/piper.py index 5cc77ed..a9b8b0e 100644 --- a/app/providers/tts/piper.py +++ b/app/providers/tts/piper.py @@ -1,8 +1,12 @@ -"""Lokales TTS über das piper-Binary (CPU-freundlich, niedrige Latenz, offline). +"""Lokales TTS über piper (CPU-freundlich, offline). -piper synthetisiert rohes s16le-Mono-PCM in der Sample-Rate des Stimmmodells. Das -Gateway erwartet 24000 Hz -> bei Abweichung wird mit ffmpeg resampelt. So bleibt eine -voll-lokale Konstellation (STT + LLM + TTS lokal) möglich (Datenschutz, keine API-Kosten). +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 @@ -12,10 +16,19 @@ 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() @@ -27,6 +40,30 @@ def _wrap_wav(pcm: bytes, sample_rate: int) -> bytes: 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=" bytes: if not text or not text.strip(): raise ValueError("TTS input text is empty") - if not (shutil.which(self.bin_path) or Path(self.bin_path).exists()): - raise RuntimeError(f"Piper-Binary nicht gefunden: {self.bin_path}") model, config = self._model_paths(voice) - native_rate = self._native_rate(config) - - pcm = await self._run_piper(model, config, text.strip()) + pcm, native_rate = await asyncio.to_thread( + self._synthesize_sync, str(model), str(config), text.strip() + ) if native_rate != self.target_rate: - pcm = await self._resample(pcm, 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 - async def _run_piper(self, model: Path, config: Path, text: str) -> bytes: - cmd = [self.bin_path, "-m", str(model), "-c", str(config), "--output-raw"] - proc = await asyncio.create_subprocess_exec( - *cmd, - stdin=asyncio.subprocess.PIPE, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - out, err = await proc.communicate(text.encode("utf-8")) - if proc.returncode != 0: - detail = err.decode("utf-8", "replace").strip()[:300] - raise RuntimeError(f"Piper-Fehler ({proc.returncode}): {detail}") - if not out: - raise RuntimeError("Piper lieferte kein Audio") - return out + 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) - async def _resample(self, pcm: bytes, src_rate: int, dst_rate: int) -> bytes: - if not shutil.which("ffmpeg"): - raise RuntimeError( - f"ffmpeg fehlt: Piper-Stimme liefert {src_rate} Hz, benötigt werden {dst_rate} Hz" - ) - cmd = [ - "ffmpeg", "-loglevel", "quiet", - "-f", "s16le", "-ar", str(src_rate), "-ac", "1", "-i", "pipe:0", - "-f", "s16le", "-ar", str(dst_rate), "-ac", "1", "pipe:1", - ] - proc = await asyncio.create_subprocess_exec( - *cmd, - stdin=asyncio.subprocess.PIPE, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - out, err = await proc.communicate(pcm) - if proc.returncode != 0 or not out: - detail = err.decode("utf-8", "replace").strip()[:300] - raise RuntimeError(f"ffmpeg-Resampling fehlgeschlagen: {detail}") - return out + 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/pyproject.toml b/pyproject.toml index 49311d0..b1ec74f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,9 +17,11 @@ dependencies = [ test = [ "pytest>=8.0" ] -# Lokale KI-Module (optional, schwergewichtig): lokales STT via faster-whisper. +# 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" + "faster-whisper>=1.0", + "piper-tts>=1.2" ] [build-system] diff --git a/tests/test_piper_tts.py b/tests/test_piper_tts.py index 4860c19..7e841e7 100644 --- a/tests/test_piper_tts.py +++ b/tests/test_piper_tts.py @@ -7,10 +7,18 @@ import stat import pytest +import app.providers.tts.piper as piper_mod from app.providers.tts.piper import PiperTTSProvider -# PCM-Nutzlast des Fake-Binaries: gross genug, dass der ffmpeg-Resampler Samples ausgibt -# (bei sehr kurzen Eingaben puffert er und liefert nichts). 4000 Bytes = 2000 s16le-Samples. + +@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 From 111ecd8edf336c2bdc0118b6b84facaffc3b463b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Thu, 18 Jun 2026 08:27:37 +0200 Subject: [PATCH 051/113] perf(startup): lokale Modelle beim Start vorladen (Warm-up) Lifespan-Hintergrundtask laedt profilabhaengig piper-Stimme + faster-whisper-Modell vor (nur was die aktive Konfiguration nutzt; Cloud-Profile = No-op). Server ist sofort verfuegbar; der erste Nutzer zahlt nicht mehr den Kaltstart. Messung: erster Turn direkt nach Start first_audio 1,62 s (statt ~4,5 s mit Kaltstart). Co-Authored-By: Claude Opus 4.8 --- app/core/warmup.py | 45 ++++++++++++++++++++++++++++++++++++++++++++ app/main.py | 15 ++++++++++++++- tests/test_warmup.py | 25 ++++++++++++++++++++++++ 3 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 app/core/warmup.py create mode 100644 tests/test_warmup.py 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/main.py b/app/main.py index 6f1e587..6d37576 100644 --- a/app/main.py +++ b/app/main.py @@ -1,9 +1,12 @@ +import asyncio import time +from contextlib import asynccontextmanager from pathlib import Path from fastapi import FastAPI, Request from fastapi.staticfiles import StaticFiles +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 @@ -17,7 +20,17 @@ 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 -app = FastAPI(title="Voice Assistant Gateway") +@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") diff --git a/tests/test_warmup.py b/tests/test_warmup.py new file mode 100644 index 0000000..cdc51af --- /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"): + 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 From 1899663308c8f2938f16b4b86caa536bcf787d6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Thu, 18 Jun 2026 08:32:05 +0200 Subject: [PATCH 052/113] docs: lokales TTS (piper in-process) + Start-Warm-up dokumentieren README/BEDIENUNGSANLEITUNG/Architektur: piper laeuft in-process (gecachtes Stimmmodell, kein Subprozess pro Satz), In-Process-Resampling, lokale Modelle werden beim Start vorgeladen. .[local] installiert jetzt faster-whisper UND piper-tts. Latenz-Hinweis erster Ton 5,8 s -> ~1,6 s. Co-Authored-By: Claude Opus 4.8 --- BEDIENUNGSANLEITUNG.md | 11 ++++++----- Docs/voice-assistant-architecture.md | 7 ++++--- README.md | 10 ++++++++-- 3 files changed, 18 insertions(+), 10 deletions(-) diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index 28533cd..0047442 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -237,19 +237,20 @@ python scripts/voice_loop.py --recorder arecord --device plughw:5,0 --session hy --llm-provider local-openai-compatible \ --tts-provider openrouter ``` -Erster Turn ist langsamer (Whisper-Modell lädt; das LLM-Modell ist nach `make llm-up` -bereits geladen), danach zügig. STT-Modell +Die lokalen Modelle (faster-whisper, piper) werden **beim Serverstart vorgeladen** +(Warm-up im Hintergrund) — der erste Turn ist daher nicht mehr spürbar langsamer. STT-Modell und Gerät steuern `FASTER_WHISPER_MODEL`/`FASTER_WHISPER_DEVICE` in `.env`. Satzweises Vorlesen ist Standard (früher Ton); `--stream-text` zeigt den Text live dazu. **Voll-lokal-Beispiel** (STT + LLM + TTS **alles lokal** — kein API-Geld, maximaler -Datenschutz). TTS läuft hier über **piper** (lokales, CPU-freundliches Neural-TTS): +Datenschutz). TTS läuft hier über **piper** (lokales Neural-TTS, **in-process**: das +Stimmmodell wird einmal geladen und gecacht, kein Subprozess-Start pro Satz): ```bash -# einmalig: lokales STT installieren +# einmalig: lokales STT + TTS installieren (faster-whisper + piper-tts) pip install -e .[local] # lokalen llama.cpp-Server starten (großes, unzensiertes Modell): make llm-up # mit make llm-status auf "HTTP OK" warten -# piper-Binary + Stimme bereitstellen: die Stimm-Dateien (.onnx + .onnx.json) +# piper-Stimme bereitstellen: die Stimm-Dateien (.onnx + .onnx.json) # liegen im PIPER_VOICES_DIR (Default ~/.local/share/piper/voices). Deutsche Stimmen z. B. # von huggingface 'rhasspy/piper-voices' (de_DE-thorsten-high, de_DE-kerstin-low). # Verfügbare Stimmen prüfen: ls ~/.local/share/piper/voices/*.onnx diff --git a/Docs/voice-assistant-architecture.md b/Docs/voice-assistant-architecture.md index 20c6fa7..4ccb46f 100644 --- a/Docs/voice-assistant-architecture.md +++ b/Docs/voice-assistant-architecture.md @@ -198,8 +198,9 @@ In-Memory-Metriken `/api/metrics`), Tageskontingent pro Nutzer und heuristische Notfall-Eskalation**; automatisierte Tests. **Echtes lokales STT & TTS:** `faster-whisper` (optionale Dependency `.[local]`, -CTranslate2) transkribiert real; `piper` (Binary + Stimmmodell, ffmpeg-Resampling -auf 24000 Hz) synthetisiert real. Damit ist sowohl ein Hybrid „STT+LLM lokal, TTS +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`, @@ -218,7 +219,7 @@ Reihenfolge der Weiterentwicklung: 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, ffmpeg-Resampling auf 24000 Hz) sind echt — eine **voll-lokale Konstellation** (STT+LLM+TTS lokal, keine API-Kosten, max. Datenschutz) ist damit möglich. Offen: `chatterbox`-TTS (noch Stub), höhere Sprachqualität als piper. +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. Offen: `chatterbox`-TTS (noch Stub), höhere Sprachqualität als piper. 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 diff --git a/README.md b/README.md index b8da58f..d7cafc5 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,12 @@ Markdown/Emojis — schlecht zum Vorlesen und spürbar träge. Der Provider Modell `base`. Auf einer RTX 3090 lohnt `FASTER_WHISPER_DEVICE=cuda` + `FASTER_WHISPER_COMPUTE_TYPE=float16`; das verkürzt die Transkriptionszeit pro Turn. +**Dritter Hebel — lokales TTS (piper):** piper läuft **in-process** über die piper-Python-API +(im `.[local]`-Extra). Das Stimmmodell wird **einmal** geladen und prozessweit gecacht — +früher startete piper als Subprozess **pro Satz** und zahlte jedes Mal ~2 s Modell-Ladezeit. +Zusätzlich werden lokale Modelle **beim Serverstart vorgeladen** (Warm-up), sodass auch der +erste Nutzer keinen Kaltstart spürt. Messung (lokales Setup): erster Ton **5,8 s → ~1,6 s**. + ### Komplett lokal: Profil `local-dev` `VA_PROFILE=local-dev` betreibt **alle** KI-Module ohne Cloud. Die Route löst auf zu: @@ -160,8 +166,8 @@ Modell `base`. Auf einer RTX 3090 lohnt `FASTER_WHISPER_DEVICE=cuda` + **Voraussetzungen:** - **LLM:** llama.cpp-Container läuft (`make llm-up`) -- **STT:** faster-whisper installiert (`pip install -e .[local]`) -- **TTS:** piper-Binary + Stimme vorhanden (siehe `PIPER_*` in `.env.example`) +- **STT + TTS:** `pip install -e .[local]` (installiert faster-whisper **und** piper-tts); + ein piper-Stimmmodell (`.onnx` + `.onnx.json`) im `PIPER_VOICES_DIR` (siehe `PIPER_*`) **Start (Reihenfolge):** From 881a5ac2de2089e655ae4bee4a01c2e013ab613d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Thu, 18 Jun 2026 08:44:33 +0200 Subject: [PATCH 053/113] feat(auth): Forward-Auth via JWT-Cookie (YunoHost yunohost.portal) Discovery zeigte: YunoHost reicht den Usernamen nicht als Header durch, sondern im signierten Cookie "yunohost.portal" (JWT, Claim "user"). Die Forward-Auth liest jetzt die Identitaet aus Header ODER Cookie - nur von der Proxy-Quell-IP akzeptiert. HS256-Signaturpruefung optional via TRUSTED_AUTH_JWT_SECRET (stdlib hmac, kein Dep). - config: trusted_auth_cookie / _cookie_claim / _jwt_secret - nginx-Vorlage + deploy/README: keine Identitaets-Header-Zeile mehr noetig; SSO-Schutz der Subdomain ist Pflicht (sonst Spoofing) - Tests: Cookie-Extraktion, Signaturpruefung, Proxy-IP-Trust Co-Authored-By: Claude Opus 4.8 --- .env.example | 9 +++-- app/auth.py | 65 ++++++++++++++++++++++++++++--- app/config.py | 6 +++ deploy/README.md | 32 ++++++++++------ deploy/va.linix.de.nginx.conf | 12 ++---- tests/test_cookie_auth.py | 72 +++++++++++++++++++++++++++++++++++ 6 files changed, 169 insertions(+), 27 deletions(-) create mode 100644 tests/test_cookie_auth.py diff --git a/.env.example b/.env.example index c97974f..0ed5059 100644 --- a/.env.example +++ b/.env.example @@ -24,9 +24,12 @@ AUTH_ENABLED=true # Schluessel fuer die Nutzerverwaltung (POST /api/admin/users). Nur ueber die Umgebung. ADMIN_API_KEY= -# Forward-/Trusted-Header-Auth via Reverse-Proxy/SSO (z. B. YunoHost). Nur fuer -# Remote-Betrieb - siehe deploy/README.md. Lokal leer lassen. -# TRUSTED_AUTH_HEADER=X-Remote-User +# 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 diff --git a/app/auth.py b/app/auth.py index bb55720..b8ff45d 100644 --- a/app/auth.py +++ b/app/auth.py @@ -1,3 +1,8 @@ +import base64 +import hashlib +import hmac +import json + from fastapi import Header, HTTPException, Request from app.config import settings, Settings @@ -9,6 +14,51 @@ 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: @@ -29,17 +79,22 @@ def authenticate(headers, client_host: str, token: str | None, """ store = get_store() - # 1. Forward-/Trusted-Header-Auth (nur von der Proxy-Quell-IP akzeptiert). - if cfg.trusted_auth_header: + # 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 = (headers.get(cfg.trusted_auth_header) or "").strip() + 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 den Header immer setzen -> 401 + 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 -> Header ignorieren, normale Auth unten. + # Nicht von der Proxy-IP -> ignorieren, normale Auth unten. # 2. Auth abgeschaltet (dev/Test). if not cfg.auth_enabled: diff --git a/app/config.py b/app/config.py index 92cf500..66afebb 100644 --- a/app/config.py +++ b/app/config.py @@ -146,6 +146,12 @@ class Settings(BaseSettings): # 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) diff --git a/deploy/README.md b/deploy/README.md index 159f0cb..5422260 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -38,18 +38,28 @@ 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. Discovery: richtigen Identitäts-Header bestimmen -**Hinter dem SSO** aufrufen (durchs SSO-Portal einloggen, dann diese URL). Da das -Gateway den SSO-Admin zu diesem Zeitpunkt noch nicht kennt (Henne-Ei), den Admin-Key -als Query mitgeben: +## 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=... ``` -https://va.linix.de/api/admin/request-headers?key= -``` -In der Ausgabe den Header finden, der den eingeloggten SSO-Usernamen trägt (z. B. -`X-Remote-User`, `Remote-User`, `Auth-User`, oder `Authorization: Basic …` mit dem -Usernamen). Diesen Namen in `TRUSTED_AUTH_HEADER` **und** in der nginx-`proxy_set_header -…`-Zeile eintragen, Gateway + nginx neu laden. Danach `?key=` nicht mehr nutzen -(landet in Proxy-Logs) bzw. den Key rotieren. + +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 ". diff --git a/deploy/va.linix.de.nginx.conf b/deploy/va.linix.de.nginx.conf index 5df5b95..91bcf4f 100644 --- a/deploy/va.linix.de.nginx.conf +++ b/deploy/va.linix.de.nginx.conf @@ -31,12 +31,8 @@ location / { proxy_read_timeout 3600s; proxy_send_timeout 3600s; - # Identitaets-Header: SSOwat setzt die Nutzeridentitaet. Nach der Discovery - # (GET /api/admin/request-headers hinter dem SSO) den richtigen Namen hier - # FEST setzen und Client-Spoofing verwerfen. Beispiel, wenn SSO $remote_user - # bereitstellt (Name ggf. anpassen): - # - # proxy_set_header X-Remote-User $remote_user; - # - # In der Gateway-Konfiguration dann: TRUSTED_AUTH_HEADER=X-Remote-User + # 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/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" From 6073ba113336e754ba6c00da376dbab72175cc65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Thu, 18 Jun 2026 08:50:30 +0200 Subject: [PATCH 054/113] fix(web): Session-ID pro Nutzer statt globalem web-main Die feste Session "web-main" gehoerte dem ersten Nutzer (lokal: anonymous) -> SSO-Nutzer bekam "Session gehoert einem anderen Nutzer" (403). Die Web-UI leitet die Session jetzt aus /api/me ab (web-); ensureSession() wartet vor dem ersten Turn auf /api/me. Asset-Version v4. Co-Authored-By: Claude Opus 4.8 --- app/web/app.js | 59 ++++++++++++++++++++++++++++++---------------- app/web/index.html | 4 ++-- 2 files changed, 41 insertions(+), 22 deletions(-) diff --git a/app/web/app.js b/app/web/app.js index a1ca26a..8d6d5dd 100644 --- a/app/web/app.js +++ b/app/web/app.js @@ -1,6 +1,5 @@ "use strict"; -const SESSION_ID = "web-main"; const $ = (sel) => document.querySelector(sel); const messagesEl = $("#messages"); @@ -10,28 +9,46 @@ const formEl = $("#prompt-form"); const micBtn = $("#mic"); let busy = false; +// 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; // ---------- Identitaet / Menue ---------- -async function loadMe() { - try { - const res = await fetch("/api/me", { headers: { Accept: "application/json" } }); - if (!res.ok) { - $("#identity").textContent = "Nicht angemeldet"; - return; +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(); + sessionId = "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").classList.remove("hidden"); + } + return me; + } catch (e) { + $("#identity").textContent = "Verbindung fehlgeschlagen"; + return null; } - const me = await res.json(); - $("#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").classList.remove("hidden"); - } - } catch (e) { - $("#identity").textContent = "Verbindung fehlgeschlagen"; + })(); + 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"; } async function loadUsers() { @@ -91,7 +108,7 @@ function playPcm(chunks, sampleRate) { // ---------- 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(SESSION_ID)}`; + return `${proto}://${location.host}${path}?session_id=${encodeURIComponent(sessionId)}`; } // onopen: (ws) => sendet die Eingabe. Liefert ein Promise, das beim done-Event endet. @@ -148,6 +165,7 @@ function runTurn(path, onopen) { async function sendText(text) { if (busy || !text.trim()) return; busy = true; + await ensureSession(); addMessage("user", text); statusEl.textContent = "denkt …"; await runTurn("/ws/chat", (ws) => { @@ -198,6 +216,7 @@ function stopRecording() { async function sendVoice(bytes) { if (busy) return; busy = true; + await ensureSession(); statusEl.textContent = "verarbeite Sprache …"; await runTurn("/ws/voice", (ws) => { ws.send(JSON.stringify({ type: "start", format: "webm", stream: true, audio_stream: true })); diff --git a/app/web/index.html b/app/web/index.html index deafe81..bc210fa 100644 --- a/app/web/index.html +++ b/app/web/index.html @@ -4,7 +4,7 @@ Voice Assistant - +
    @@ -31,6 +31,6 @@
    - + From 77026bff1b1482384e63b79f215f1d2676ec9152 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Thu, 18 Jun 2026 09:09:23 +0200 Subject: [PATCH 055/113] deploy: Gateway als systemd-User-Dienst (Vorlage + Anleitung) Vorlage deploy/voice-assistant.user.service + Anleitung (loginctl enable-linger, systemctl --user). Laeuft ohne root und ueberlebt Logout/Reboot; .env wird aus dem WorkingDirectory gelesen. va_llm-Container kommt via Docker restart-policy hoch. Co-Authored-By: Claude Opus 4.8 --- deploy/README.md | 13 +++++++++++++ deploy/voice-assistant.user.service | 27 +++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 deploy/voice-assistant.user.service diff --git a/deploy/README.md b/deploy/README.md index 5422260..e252038 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -21,6 +21,19 @@ Topologie: `https://va.linix.de` → nginx@YunoHost (TLS, SSO) → über LAN → > **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.: diff --git a/deploy/voice-assistant.user.service b/deploy/voice-assistant.user.service new file mode 100644 index 0000000..2fab452 --- /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_v2/voice-assistant-scaffold +ExecStart=/home/dschlueter/my_voice_assistant_v2/voice-assistant-scaffold/.venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8003 +Restart=always +RestartSec=2 + +[Install] +WantedBy=default.target From 5f01607b790bd769a500b071332598f1d04200dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Thu, 18 Jun 2026 09:44:31 +0200 Subject: [PATCH 056/113] feat(tts): Chatterbox-Provider (hohe Qualitaet + Voice-Cloning) anbinden Loest #7. Der chatterbox-Stub wird durch eine echte Anbindung an den lokalen Chatterbox-HTTP-Dienst ersetzt (POST /speak -> /status pollen -> GET /audio, WAV). no_playback=true -> der Dienst spielt nicht lokal ab, liefert nur Bytes. WAV->PCM (24 kHz, Resampling bei Bedarf). Waehlbar via tts_provider=chatterbox; piper bleibt der schnelle Default (chatterbox ist ~echtzeit-langsam, dafuer klonbare Stimme). - config: CHATTERBOX_BASE_URL/_VOICE/_LANG/_SPEED/_TIMEOUT; Registry verdrahtet - Tests: gemockter httpx (Synthese, WAV/Resample, Job-Fehler, Stimmenwahl) - Doku: README, Architektur (#7), deploy/README (GPU-Pinning per UUID, no_playback) Co-Authored-By: Claude Opus 4.8 --- .env.example | 7 ++ Docs/voice-assistant-architecture.md | 5 +- README.md | 9 ++- app/config.py | 6 ++ app/dependencies.py | 9 ++- app/providers/tts/chatterbox.py | 112 ++++++++++++++++++++++++++- tests/test_chatterbox_tts.py | 82 ++++++++++++++++++++ 7 files changed, 223 insertions(+), 7 deletions(-) create mode 100644 tests/test_chatterbox_tts.py diff --git a/.env.example b/.env.example index 0ed5059..ce07d4d 100644 --- a/.env.example +++ b/.env.example @@ -81,6 +81,13 @@ PIPER_VOICES_DIR=~/.local/share/piper/voices # Verzeichnis der .onnx-St 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 diff --git a/Docs/voice-assistant-architecture.md b/Docs/voice-assistant-architecture.md index 4ccb46f..66b5315 100644 --- a/Docs/voice-assistant-architecture.md +++ b/Docs/voice-assistant-architecture.md @@ -205,8 +205,7 @@ 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. Der TTS-Provider `chatterbox` ist noch ein -Stub. `transport_router.py` (Ebene 4) existiert, ist aber noch nicht aktiv +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 @@ -219,7 +218,7 @@ Reihenfolge der Weiterentwicklung: 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. Offen: `chatterbox`-TTS (noch Stub), höhere Sprachqualität als piper. +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 diff --git a/README.md b/README.md index d7cafc5..bff10b8 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Praktische Bedienung: [`BEDIENUNGSANLEITUNG.md`](BEDIENUNGSANLEITUNG.md). ## Features - **Pipeline mit getrennter Semantik/Sprache:** STT → Input-Cleaner → LLM → Spoken-Adapter → TTS-Normalizer → TTS -- **Provider austauschbar** über Registry (OpenRouter remote; lokales STT via faster-whisper `.[local]`; **lokales TTS via piper**; chatterbox-TTS noch Stub) +- **Provider austauschbar** über Registry (OpenRouter remote; lokales STT via faster-whisper `.[local]`; **lokales TTS via piper** (schnell) **und chatterbox** (hohe Qualität + Voice-Cloning, eigener Dienst)) - **Aussprache-Normalisierung** vor dem TTS (Ordinalia/Einheiten/Abkürzungen + YAML-Lexikon, provider-abhängig `TTS_NORMALIZE_LEVEL`); Pflege per `scripts/add_pronunciation.py` - **Geschichtete Konfiguration** mit Profilen (`local-dev` / `hybrid` / `cloud`) - **Routing auf jeder Ebene:** Default → Profil → Nutzer → Session → Request @@ -154,6 +154,13 @@ früher startete piper als Subprozess **pro Satz** und zahlte jedes Mal ~2 s Mod Zusätzlich werden lokale Modelle **beim Serverstart vorgeladen** (Warm-up), sodass auch der erste Nutzer keinen Kaltstart spürt. Messung (lokales Setup): erster Ton **5,8 s → ~1,6 s**. +**Höhere Sprachqualität — chatterbox (optional):** Für deutlich natürlichere, **klonbare** +Stimmen gibt es den Provider `chatterbox` (Resemble AI, eigener HTTP-Dienst auf GPU, siehe +`deploy/README.md`). Wählbar pro Request/Session via `tts_provider=chatterbox` (`piper` bleibt +der schnelle Default). Chatterbox ist neural und ~echtzeit-langsam → besser für Qualität als +für minimale Latenz. Konfig: `CHATTERBOX_BASE_URL`, `CHATTERBOX_VOICE` (Referenz-WAV fürs +Cloning), `CHATTERBOX_SPEED`. + ### Komplett lokal: Profil `local-dev` `VA_PROFILE=local-dev` betreibt **alle** KI-Module ohne Cloud. Die Route löst auf zu: diff --git a/app/config.py b/app/config.py index 66afebb..42de5e2 100644 --- a/app/config.py +++ b/app/config.py @@ -138,6 +138,12 @@ class Settings(BaseSettings): 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 db_path: str = str(BASE_DIR / "data" / "voice-assistant.db") admin_api_key: str = "" auth_enabled: bool = True diff --git a/app/dependencies.py b/app/dependencies.py index 4c8b0ed..7da78ed 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -69,7 +69,14 @@ TTS_REGISTRY = { "openrouter": lambda s: OpenRouterTTSProvider( s.openrouter_api_key, s.openrouter_tts_model, s.openrouter_tts_voice ), - "chatterbox": lambda s: ChatterboxTTSProvider(), + "chatterbox": lambda s: ChatterboxTTSProvider( + s.chatterbox_base_url, + s.chatterbox_voice, + s.chatterbox_lang, + s.chatterbox_speed, + s.tts_sample_rate, + s.chatterbox_timeout, + ), "piper": lambda s: PiperTTSProvider( s.piper_bin, s.piper_voices_dir, s.piper_voice, s.tts_sample_rate ), diff --git a/app/providers/tts/chatterbox.py b/app/providers/tts/chatterbox.py index abdf298..84d2156 100644 --- a/app/providers/tts/chatterbox.py +++ b/app/providers/tts/chatterbox.py @@ -1,5 +1,113 @@ +"""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 + +import httpx + from app.providers.tts.base import TTSProvider +from app.providers.tts.piper import _resample, _wrap_wav + class ChatterboxTTSProvider(TTSProvider): - async def synthesize(self, text: str, voice: str | None = None, audio_format: str = "pcm") -> bytes: - return b"" + 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, + ): + 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 + + def _ref_voice(self, voice: str | None) -> str | None: + # Eine angefragte Stimme gilt nur, wenn sie wie ein WAV-Pfad aussieht + # (Cloud-Stimmennamen wie "Zephyr"/"alloy" ignorieren -> Default nehmen). + cand = (voice or "").strip() + if cand.endswith(".wav"): + return cand + return self.voice or None + + async def synthesize( + self, + text: str, + voice: str | None = None, + audio_format: str = "pcm", + ) -> bytes: + if not text or not text.strip(): + raise ValueError("TTS input text is empty") + + payload = { + "text": text.strip(), + "lang": self.lang, + "speed": self.speed, + "keep_audio": True, + "no_playback": True, + } + ref = self._ref_voice(voice) + 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/tests/test_chatterbox_tts.py b/tests/test_chatterbox_tts.py new file mode 100644 index 0000000..a134cd2 --- /dev/null +++ b/tests/test_chatterbox_tts.py @@ -0,0 +1,82 @@ +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) == "/default.wav" + assert p._ref_voice("Zephyr") == "/default.wav" # Cloud-Name -> Default + assert p._ref_voice("/custom.wav") == "/custom.wav" # Pfad -> uebernommen From b7c59d8c8558a46a52b1fe57d4d4a74e5059a7d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Thu, 18 Jun 2026 09:49:25 +0200 Subject: [PATCH 057/113] feat(web): Stimmen-Umschalter (piper/chatterbox/cloud) in der UI Dropdown im Menue setzt tts_provider als Request-Override fuer Text- und Sprach-Turns -> live umschaltbar zwischen schnellem piper, hochwertigem chatterbox und Cloud. Asset-Version v5. Co-Authored-By: Claude Opus 4.8 --- app/web/app.js | 11 +++++++++-- app/web/index.html | 11 +++++++++-- app/web/style.css | 2 ++ 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/app/web/app.js b/app/web/app.js index 8d6d5dd..8416444 100644 --- a/app/web/app.js +++ b/app/web/app.js @@ -7,6 +7,13 @@ const statusEl = $("#status"); const promptEl = $("#prompt"); const formEl = $("#prompt-form"); const micBtn = $("#mic"); +const ttsSel = $("#tts"); + +// Gewählter TTS-Provider (leer = Default/piper) als Request-Override. +function overrides() { + const t = ttsSel && ttsSel.value; + return t ? { tts_provider: t } : {}; +} let busy = false; // Session-ID pro Nutzer (sonst "gehoert einem anderen Nutzer"-Konflikt). Wird aus @@ -169,7 +176,7 @@ async function sendText(text) { addMessage("user", text); statusEl.textContent = "denkt …"; await runTurn("/ws/chat", (ws) => { - ws.send(JSON.stringify({ text, stream: true, audio_stream: true })); + ws.send(JSON.stringify({ text, stream: true, audio_stream: true, ...overrides() })); }); busy = false; } @@ -219,7 +226,7 @@ async function sendVoice(bytes) { await ensureSession(); statusEl.textContent = "verarbeite Sprache …"; await runTurn("/ws/voice", (ws) => { - ws.send(JSON.stringify({ type: "start", format: "webm", stream: true, audio_stream: true })); + ws.send(JSON.stringify({ type: "start", format: "webm", stream: true, audio_stream: true, ...overrides() })); ws.send(bytes); ws.send(JSON.stringify({ type: "end" })); }); diff --git a/app/web/index.html b/app/web/index.html index bc210fa..316c7eb 100644 --- a/app/web/index.html +++ b/app/web/index.html @@ -4,13 +4,20 @@ Voice Assistant - +
    + + + + + + @@ -162,6 +202,6 @@
    - + From ea923ffc6188a6520a1b37c0993804583ff02471 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Fri, 19 Jun 2026 01:24:04 +0200 Subject: [PATCH 077/113] fix(auth): require_admin akzeptiert jetzt auch SSO-Admin-Nutzer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vorher pruefte require_admin nur den X-Admin-Key-Header — SSO-Admins (aus ADMIN_USERS) bekamen 401. Das brach alle Admin-Panel-Tabs ausser der Nutzerliste im Browser. Co-Authored-By: Claude Sonnet 4.6 --- app/auth.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/app/auth.py b/app/auth.py index b8ff45d..8b7cad7 100644 --- a/app/auth.py +++ b/app/auth.py @@ -122,15 +122,21 @@ def require_user(request: Request) -> User: return user -def require_admin(x_admin_key: str | None = Header(default=None)) -> None: - """Schuetzt die Nutzerverwaltung ueber ADMIN_API_KEY (aus der Umgebung).""" +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)" - ) - if not x_admin_key or x_admin_key.strip() != expected: - raise HTTPException(status_code=401, detail="Invalid admin key") + 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( From 296138ac1ed8386a360eb8d75b65a78a078722ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Fri, 19 Jun 2026 01:33:49 +0200 Subject: [PATCH 078/113] fix(web): escHtml verarbeitet Zahlen korrekt, Status-Tab Fehlertext verbessert MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit escHtml() rief .replace() auf Number-Werten auf (httpTotal aus Metriken) → TypeError. Fix: immer String(str) konvertieren. Außerdem console.error im Status-Tab-catch für einfachere Fehlerdiagnose. Co-Authored-By: Claude Sonnet 4.6 --- app/web/app.js | 5 +++-- app/web/index.html | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/app/web/app.js b/app/web/app.js index 448a2d3..bfe5f9c 100644 --- a/app/web/app.js +++ b/app/web/app.js @@ -329,8 +329,8 @@ loadMe(); // ---------- Hilfsfunktionen ---------- function escHtml(str) { - if (!str) return ""; - return str.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); + if (str === null || str === undefined || str === "") return ""; + return String(str).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); } function fmtDate(iso) { @@ -760,6 +760,7 @@ async function loadStatus() { `; } catch (e) { + console.error("loadStatus error:", e); container.innerHTML = '

    Fehler beim Laden.

    '; } } diff --git a/app/web/index.html b/app/web/index.html index 828977c..c2681c7 100644 --- a/app/web/index.html +++ b/app/web/index.html @@ -202,6 +202,6 @@
    - + From 5d022aaf6367e673a910f177776647b913d8987f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Fri, 19 Jun 2026 01:39:36 +0200 Subject: [PATCH 079/113] =?UTF-8?q?feat(admin):=20Phase=203=20=E2=80=94=20?= =?UTF-8?q?Laufzeit-Konfiguration=20ohne=20Server-Neustart?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neue Tabelle config_overrides in SQLite; RuntimeSettings-Wrapper liest überschreibbare Felder mit 30s TTL-Cache aus der DB und fällt auf .env-Werte zurück. dependencies.py und quota.py nutzen runtime_settings als Default statt des statischen Settings-Singletons. 16 Felder überschreibbar: STT/LLM/TTS-Provider, LLM-Modelle, Stimmen, Systemprompt, Temperatur, Tageskontingent, Normalisierung u.a. Backend: GET/PUT/DELETE /api/admin/config/{key} Admin-UI: neuer Tab "⚙ Einstellungen" mit Inline-Edit und Reset. Co-Authored-By: Claude Sonnet 4.6 --- app/api/admin.py | 50 ++++++++++++++++++++++ app/dependencies.py | 16 ++++--- app/quota.py | 8 ++-- app/runtime_config.py | 97 +++++++++++++++++++++++++++++++++++++++++++ app/store.py | 32 ++++++++++++++ app/web/app.js | 73 ++++++++++++++++++++++++++++++++ app/web/index.html | 18 +++++++- 7 files changed, 285 insertions(+), 9 deletions(-) create mode 100644 app/runtime_config.py diff --git a/app/api/admin.py b/app/api/admin.py index b8fc3ed..89b4ac7 100644 --- a/app/api/admin.py +++ b/app/api/admin.py @@ -9,6 +9,7 @@ from pydantic import BaseModel 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() @@ -249,6 +250,55 @@ async def delete_pronunciation(lang: str, section: str, key: str): 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): + """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}") + get_store().set_config_override(key, body.value.strip()) + invalidate_cache() + return {"key": key, "value": body.value.strip()} + + +@router.delete("/admin/config/{key}", dependencies=[Depends(require_admin)]) +async def delete_runtime_config(key: str): + """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.") + return {"deleted": key} + + # ── Live-Log (journalctl → WebSocket) ────────────────────────────────────── @router.websocket("/admin/log") diff --git a/app/dependencies.py b/app/dependencies.py index 7da78ed..bfeec70 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -1,6 +1,7 @@ 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 @@ -93,15 +94,18 @@ def _from_registry(registry: dict, name: str, kind: str, cfg: Settings): return factory(cfg) -def get_stt_provider(name: str | None = None, cfg: Settings = settings): +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: Settings = settings): +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: Settings = settings): +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) @@ -190,8 +194,9 @@ def resolve_route( user: User | None = None, session_id: str | None = None, overrides: dict | None = None, - cfg: Settings = settings, + cfg=None, ) -> ResolvedRoute: + cfg = cfg or runtime_settings """Loest die effektive Route auf. Praezedenz (hoeher gewinnt): Defaults < Nutzer-Prefs < Session-Route < Request. @@ -247,7 +252,8 @@ def _resolve_normalize_level(tts_provider: str, cfg: Settings) -> str: return level -def build_orchestrator(route: ResolvedRoute, cfg: Settings = settings) -> Orchestrator: +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), diff --git a/app/quota.py b/app/quota.py index 06cae8d..720ff0d 100644 --- a/app/quota.py +++ b/app/quota.py @@ -4,7 +4,6 @@ Limit aus Settings (`daily_request_limit`), pro Nutzer ueber `prefs.daily_reques ueberschreibbar. 0 bedeutet unbegrenzt. """ -from app.config import settings, Settings from app.metrics import metrics @@ -15,7 +14,10 @@ class QuotaExceededError(Exception): super().__init__(f"Daily request limit reached ({count}/{limit})") -def effective_limit(user, cfg: Settings = settings) -> int: +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: @@ -25,7 +27,7 @@ def effective_limit(user, cfg: Settings = settings) -> int: return cfg.daily_request_limit -def enforce_quota(user, store, cfg: Settings = settings) -> None: +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: diff --git a/app/runtime_config.py b/app/runtime_config.py new file mode 100644 index 0000000..79b0c47 --- /dev/null +++ b/app/runtime_config.py @@ -0,0 +1,97 @@ +"""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"), + "default_language": ("Sprache (Standard)", "str", "de | en | …"), + "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"), + "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/store.py b/app/store.py index 4c50f11..d7624ba 100644 --- a/app/store.py +++ b/app/store.py @@ -152,6 +152,15 @@ class Store(ABC): @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): @@ -216,6 +225,11 @@ class SQLiteStore(Store): 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). @@ -551,3 +565,21 @@ class SQLiteStore(Store): " 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/web/app.js b/app/web/app.js index bfe5f9c..ed8af5f 100644 --- a/app/web/app.js +++ b/app/web/app.js @@ -385,6 +385,7 @@ function switchAdminTab(name) { if (name === "metrics") loadMetrics(); if (name === "words") loadWoerterbuch(); if (name === "log") { /* User muss manuell verbinden */ } + if (name === "settings") loadSettings(); } document.querySelectorAll(".admin-tab").forEach((btn) => @@ -968,3 +969,75 @@ function disconnectLog() { $("#log-connect").addEventListener("click", connectLog); $("#log-disconnect").addEventListener("click", disconnectLog); $("#log-clear").addEventListener("click", () => { $("#log-output").innerHTML = ""; }); + +// ════════════════════════════════════════ +// TAB: EINSTELLUNGEN +// ════════════════════════════════════════ +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; } + container.innerHTML = ""; + data.forEach((item) => container.appendChild(buildSettingRow(item))); +} + +function buildSettingRow(item) { + const wrap = document.createElement("div"); + const overridden = item.is_overridden; + wrap.className = "bg-white dark:bg-slate-800 rounded-xl border " + + (overridden + ? "border-amber-300 dark:border-amber-600" + : "border-slate-200 dark:border-slate-700") + + " p-4"; + + const inputId = "cfg-" + item.key; + const isLong = item.type === "str" && item.base_value.length > 60; + + wrap.innerHTML = ` +
    +
    + + ${escHtml(item.key)} + ${overridden ? 'überschrieben' : ""} +
    + ${overridden ? `` : ""} +
    +
    + ${isLong + ? `` + : `` + } + +
    +

    + .env: ${escHtml(item.base_value) || "—"} + · + ${escHtml(item.hint)} +

    + `; + + const inp = wrap.querySelector(`#${inputId}`); + + wrap.querySelector(".save-btn").addEventListener("click", async () => { + const val = inp.value ?? inp.textContent ?? ""; + const res = await adminFetch(`/api/admin/config/${item.key}`, "PUT", { value: val }); + if (res !== null) loadSettings(); + }); + + const resetBtn = wrap.querySelector(".reset-btn"); + if (resetBtn) { + resetBtn.addEventListener("click", async () => { + 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(); + }); + } + + return wrap; +} + +$("#load-settings").addEventListener("click", loadSettings); diff --git a/app/web/index.html b/app/web/index.html index c2681c7..d451c05 100644 --- a/app/web/index.html +++ b/app/web/index.html @@ -38,6 +38,7 @@ + @@ -164,6 +165,21 @@ + + + @@ -202,6 +218,6 @@
    - + From d8b861db2662465dcbc3b43ed65b43079568ae6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Fri, 19 Jun 2026 02:35:40 +0200 Subject: [PATCH 080/113] =?UTF-8?q?feat(web):=20Einstellungen-Tab=20?= =?UTF-8?q?=E2=80=93=20typisierte=20Inputs,=20Gruppen,=20Test-Buttons=20(v?= =?UTF-8?q?13)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Dropdown-Auswahl für Provider, Bool- und Enum-Felder - Combo-Widget (Dropdown + Textfeld, synchronisiert) für Modelle/Stimmen - Range-Slider für Temperature, Number-Input für Token/Limit-Felder - Textarea für System-Prompt - Test-Buttons: 🔊 TTS via WebAudio, 💬 LLM via /api/chat?debug=true, 🎤 STT-Verfügbarkeitscheck - Inline-Save-Feedback (grün ✓ / rot ✗), kein Full-Reload mehr - ENV-Variablenname in UPPERCASE angezeigt - Sieben Gruppen-Sektionen statt flacher Liste - Piper-Stimmentabelle in Doku korrigiert (nur installierte: thorsten/kerstin/karlsson) Co-Authored-By: Claude Sonnet 4.6 --- BEDIENUNGSANLEITUNG.md | 4 +- app/web/app.js | 283 +++++++++++++++++++++++++++++++++++------ app/web/index.html | 2 +- 3 files changed, 247 insertions(+), 42 deletions(-) diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index fe200cd..982293e 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -785,9 +785,7 @@ Aktuell installierte Stimmen: |---|---|---|---| | `de_DE-thorsten-high` | Deutsch | high (22050 Hz) | **Default**, männlich | | `de_DE-kerstin-low` | Deutsch | low (16000 Hz) | weiblich, hörbar gröber | -| `en_US-ryan-high` | Englisch | high | | -| `es_ES-davefx-medium` | Spanisch | medium | | -| `fr_FR-gilles-low` | Französisch | low | | +| `de_DE-karlsson-low` | Deutsch | low (16000 Hz) | männlich, hörbar gröber | Stimme wechseln (in `.env`): ```bash diff --git a/app/web/app.js b/app/web/app.js index ed8af5f..0c0d6b5 100644 --- a/app/web/app.js +++ b/app/web/app.js @@ -973,67 +973,274 @@ $("#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: "select", opts: ["openrouter","faster-whisper"], test: "stt" }, + default_llm_provider: { ui: "select", opts: ["openrouter","local-openai-compatible"], test: "llm" }, + default_tts_provider: { ui: "select", opts: ["openrouter","piper","chatterbox"], test: "tts" }, + default_language: { ui: "select", 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" }, + openrouter_tts_voice: { ui: "combo", opts: ["Zephyr","Puck","Charon","Kore","Fenrir","Leda","Orus","Aoede","Callirrhoe","Enceladus","Algieba","Despina","Achernar"], test: "tts" }, + piper_voice: { ui: "combo", opts: ["de_DE-thorsten-high","de_DE-kerstin-low","de_DE-karlsson-low"], test: "tts" }, + local_llm_system_prompt: { ui: "textarea", test: "llm" }, + local_llm_temperature: { ui: "range", min: 0, max: 2, step: 0.1, 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_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 = ""; - data.forEach((item) => container.appendChild(buildSettingRow(item))); + 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 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 wrap = document.createElement("div"); + const meta = FIELD_META[item.key] || { ui: "text" }; const overridden = item.is_overridden; - wrap.className = "bg-white dark:bg-slate-800 rounded-xl border " + - (overridden - ? "border-amber-300 dark:border-amber-600" - : "border-slate-200 dark:border-slate-700") + - " p-4"; + const envName = item.key.toUpperCase(); + const testType = meta.test; - const inputId = "cfg-" + item.key; - const isLong = item.type === "str" && item.base_value.length > 60; + 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.key)} - ${overridden ? 'überschrieben' : ""} +
    + ${escHtml(item.label)} + ${overridden ? 'überschrieben' : ""} + ${resetBtn} +
    +
    + ${_buildControl(item, meta)} +
    + + ${testBtn}
    - ${overridden ? `` : ""}
    -
    - ${isLong - ? `` - : `` - } - +
    + ${envName} + · + .env: ${escHtml(item.base_value) || "—"} + · + ${escHtml(item.hint)}
    -

    - .env: ${escHtml(item.base_value) || "—"} - · - ${escHtml(item.hint)} -

    + + `; - const inp = wrap.querySelector(`#${inputId}`); + // 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 = inp.value ?? inp.textContent ?? ""; + const val = getCurrentValue(); const res = await adminFetch(`/api/admin/config/${item.key}`, "PUT", { value: val }); - if (res !== null) loadSettings(); + 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"); + } }); - const resetBtn = wrap.querySelector(".reset-btn"); - if (resetBtn) { - resetBtn.addEventListener("click", async () => { - 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(); + // 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 r = await fetch("/api/speak", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ text: "Hallo, ich bin der Sprachassistent." }), + }); + 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; }); } diff --git a/app/web/index.html b/app/web/index.html index d451c05..c41531c 100644 --- a/app/web/index.html +++ b/app/web/index.html @@ -218,6 +218,6 @@
    - + From d9cb5ffc7e04ebfbda46d19101f7d12f7477f993 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Fri, 19 Jun 2026 11:59:04 +0200 Subject: [PATCH 081/113] =?UTF-8?q?docs:=20Startup-Kommandos=20vollst?= =?UTF-8?q?=C3=A4ndig=20dokumentieren=20(llama.cpp,=20Ollama,=20Gateway)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit README: neuer Abschnitt „Starten — alle Szenarien" mit allen Optionen für cloud/hybrid/local-dev, llama.cpp-ENV-Tabelle, Ollama-Sequenz und make-Targets. BEDIENUNGSANLEITUNG: § 4.5 (llama.cpp) um direkten Script-Aufruf, docker logs, vollständige ENV-Tabelle erweitert; neues § 4.6 (Ollama) mit systemctl/serve, pull/list/ps, .env-Snippet und Reasoning-Hinweis. TOC und Sachregister aktualisiert. Co-Authored-By: Claude Sonnet 4.6 --- BEDIENUNGSANLEITUNG.md | 124 +++++++++++++++++++++++++++++++++++------ README.md | 88 +++++++++++++++++++++++++++++ 2 files changed, 195 insertions(+), 17 deletions(-) diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index 982293e..3e50498 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -35,7 +35,7 @@ funktioniert trotzdem, die Ausgabe ist dann unformatiert. 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. [Starten und Stoppen](#4-starten-und-stoppen) — [4.5 llama.cpp](#45-llamacpp-server-für-profil-hybridlocal-dev) · [4.6 Ollama](#46-ollama-alternative-zu-llamacpp-kein-docker-nötig) **Bedienung** 5. [Das System benutzen](#5-das-system-benutzen) @@ -416,32 +416,121 @@ Port ändern: `PORT=8005 make run` (einmalig) oder `PORT=8005` in `.env` (dauerh ### 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 -make llm-up # startet Docker-Container (Default: GPU 1, Port 8001, Alias va_llm) -make llm-status # Container- + HTTP-Status prüfen -make llm-down # stoppen +# 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 ``` -Parameter überschreibbar per ENV: +**Mit anderen Parametern** — ENV-Variable vor dem Befehl setzen: -| Variable | Default | Bedeutung | -|----------|---------|-----------| -| `HOST_PORT` | `8001` | Host-Port | -| `GPU_DEVICE` | `1` | GPU-Index | -| `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-Sammlung | -| `MODEL_ALIAS` | `va_llm` | API-Modellname | -| `CONTAINER_NAME` | `va_llm` | Docker-Containername | - -Beispiel (andere GPU + anderes Modell): ```bash -GPU_DEVICE=2 MODEL_REL_PATH=models/qwen3/anderes-modell.gguf \ +# 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`). + --- ## 5. Das System benutzen @@ -1804,10 +1893,11 @@ in `app/dependencies.py` + Implementierung in `app/providers/`. → [Architektur | Kontingent (Kosten-Bremse) | § 9.3 | | llama.cpp | § 4.5, § 3.2, § 3.3 | | local-dev-Profil | § 3.3 | +| Ollama starten | § 4.6 | | Metriken / Monitoring | § 9.2, Anhang B.1 | | Mikrofon → Audio-Geräte | § 6.7 | | Notfall-Erkennung | § 10 | -| Ollama | § 3.3, § 3.2 | +| Ollama | § 3.2, § 3.3, **§ 4.6** | | piper (TTS) | § 6.5.2, Anhang C | | Pipeline (Architektur) | § 1.3 | | Profile (cloud/hybrid/local-dev) | § 3 | diff --git a/README.md b/README.md index 0c2df26..983cd44 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,94 @@ 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 +``` + +### Alle `make`-Targets im Überblick + +```bash +make install # venv anlegen + Abhängigkeiten installieren +make run # Gateway starten (uvicorn --reload, Port aus .env) +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 | From a892896d326319b3d4e4fdeba85fd5dd59ca45a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Fri, 19 Jun 2026 12:12:29 +0200 Subject: [PATCH 082/113] =?UTF-8?q?docs:=20Backend-Wechsel=20dokumentieren?= =?UTF-8?q?=20+=20Konzept-Datei=20Mobile=20TTS=20hinzuf=C3=BCgen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit README + BEDIENUNGSANLEITUNG § 4.7: vollständige Kommandos für den Wechsel zwischen llama.cpp und Ollama (inkl. Stopp-Befehle für den jeweils anderen Backend-Prozess). TOC und Sachregister aktualisiert. Docs/Neues_Konzept_mit_TTS_auf_Mobil_Geraet.md: neue Recherche-/Konzept-Datei zu On-Device-TTS auf iOS/Android hinzugefügt. Co-Authored-By: Claude Sonnet 4.6 --- BEDIENUNGSANLEITUNG.md | 46 +- .../Neues_Konzept_mit_TTS_auf_Mobil_Geraet.md | 556 ++++++++++++++++++ README.md | 14 + 3 files changed, 615 insertions(+), 1 deletion(-) create mode 100644 Docs/Neues_Konzept_mit_TTS_auf_Mobil_Geraet.md diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index 3e50498..3640eec 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -35,7 +35,7 @@ funktioniert trotzdem, die Ausgabe ist dann unformatiert. 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.5 llama.cpp](#45-llamacpp-server-für-profil-hybridlocal-dev) · [4.6 Ollama](#46-ollama-alternative-zu-llamacpp-kein-docker-nötig) +4. [Starten und Stoppen](#4-starten-und-stoppen) — [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) **Bedienung** 5. [Das System benutzen](#5-das-system-benutzen) @@ -533,6 +533,49 @@ VA_PROFILE=local-dev make run # alles lokal (STT/TTS in-process, LLM via Ollam --- +### 4.7 Zwischen llama.cpp und Ollama wechseln + +Beide Server können nicht gleichzeitig auf demselben GPU-Speicher laufen. Vor dem +Wechsel muss der jeweils andere Backend-Prozess beendet werden. + +**Merkhilfe:** +- llama.cpp = Docker-Container `va_llm` → stoppen mit `make llm-down` +- Ollama = systemd-Dienst → stoppen mit `sudo systemctl stop ollama` + +#### Von Ollama → llama.cpp wechseln + +```bash +# 1) Ollama stoppen +sudo systemctl stop ollama +# falls manuell gestartet (ollama serve im Vordergrund): +pkill -f "ollama serve" 2>/dev/null || true + +# 2) llama.cpp starten und warten +make llm-up +make llm-status # warten bis „Modell bereit" + HTTP OK erscheint + +# 3) Gateway starten +VA_PROFILE=hybrid make run +``` + +#### Von llama.cpp → Ollama wechseln + +```bash +# 1) llama.cpp stoppen +make llm-down +# alternativ direkt: +docker rm -f va_llm + +# 2) Ollama starten +sudo systemctl start ollama +ollama ps # prüfen ob Modell aktiv (oder leer — wird beim ersten Request geladen) + +# 3) Gateway starten +VA_PROFILE=hybrid make run +``` + +--- + ## 5. Das System benutzen > 👤 Endnutzer @@ -1894,6 +1937,7 @@ in `app/dependencies.py` + Implementierung in `app/providers/`. → [Architektur | llama.cpp | § 4.5, § 3.2, § 3.3 | | local-dev-Profil | § 3.3 | | Ollama starten | § 4.6 | +| Ollama ↔ llama.cpp wechseln | § 4.7 | | Metriken / Monitoring | § 9.2, Anhang B.1 | | Mikrofon → Audio-Geräte | § 6.7 | | Notfall-Erkennung | § 10 | 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..1bf7932 --- /dev/null +++ b/Docs/Neues_Konzept_mit_TTS_auf_Mobil_Geraet.md @@ -0,0 +1,556 @@ + + +# Voice Assistant, Ubuntu 24.04, Python, iPhone, Andoid-Handsy: + +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/README.md b/README.md index 983cd44..1ea1f3f 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,20 @@ VA_PROFILE=hybrid make run VA_PROFILE=local-dev make run ``` +### 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 From 4a4cbe643d42374bb557674f40b453004582370a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Fri, 19 Jun 2026 12:30:52 +0200 Subject: [PATCH 083/113] =?UTF-8?q?feat(admin):=20Provider=20&=20Sprache?= =?UTF-8?q?=20=E2=80=94=20Eigener-Wert-Eingabe=20+=20Tests=20(v14)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit app.js: default_stt/llm/tts_provider und default_language von 'select' auf 'combo' umgestellt, damit neben den vordefinierten Optionen jederzeit ein beliebiger eigener Wert eingegeben werden kann (Zukunftssicherheit). combo-Renderer unterstützt jetzt auch meta.labels (Sprachkürzel → Klarname). tests/test_admin_settings.py: 28 Tests für GET/PUT/DELETE /api/admin/config — Auth, bekannte Werte, Custom-Values, Persistenz, Runtime-Wirkung. Co-Authored-By: Claude Sonnet 4.6 --- .../Neues_Konzept_mit_TTS_auf_Mobil_Geraet.md | 4 +- app/web/app.js | 11 +- app/web/index.html | 2 +- tests/test_admin_settings.py | 191 ++++++++++++++++++ 4 files changed, 199 insertions(+), 9 deletions(-) create mode 100644 tests/test_admin_settings.py diff --git a/Docs/Neues_Konzept_mit_TTS_auf_Mobil_Geraet.md b/Docs/Neues_Konzept_mit_TTS_auf_Mobil_Geraet.md index 1bf7932..134f411 100644 --- a/Docs/Neues_Konzept_mit_TTS_auf_Mobil_Geraet.md +++ b/Docs/Neues_Konzept_mit_TTS_auf_Mobil_Geraet.md @@ -1,6 +1,4 @@ - - -# Voice Assistant, Ubuntu 24.04, Python, iPhone, Andoid-Handsy: +# 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. diff --git a/app/web/app.js b/app/web/app.js index 0c0d6b5..b7d6d87 100644 --- a/app/web/app.js +++ b/app/web/app.js @@ -976,10 +976,10 @@ $("#log-clear").addEventListener("click", () => { $("#log-output").innerHTML = " // Metadaten für jedes Feld: UI-Typ, Optionen, Test-Typ const FIELD_META = { - default_stt_provider: { ui: "select", opts: ["openrouter","faster-whisper"], test: "stt" }, - default_llm_provider: { ui: "select", opts: ["openrouter","local-openai-compatible"], test: "llm" }, - default_tts_provider: { ui: "select", opts: ["openrouter","piper","chatterbox"], test: "tts" }, - default_language: { ui: "select", opts: ["de","en","fr","es","it","nl"], + 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" }, @@ -1050,8 +1050,9 @@ function _buildControl(item, meta) { 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 ` diff --git a/app/web/index.html b/app/web/index.html index c41531c..a2b4da4 100644 --- a/app/web/index.html +++ b/app/web/index.html @@ -218,6 +218,6 @@
    - + 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 From 11ae66f5ae43d514b0d551f069619915800989a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Fri, 19 Jun 2026 12:39:14 +0200 Subject: [PATCH 084/113] fix(admin): TTS-Test-Button nutzt jetzt richtigen Provider (v15) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit piper_voice, openrouter_tts_model und openrouter_tts_voice bekommen 'testProvider' in FIELD_META. Der Test-Handler schickt diesen Provider als tts_provider im Request mit — statt immer den System-Default zu nutzen, der oft openrouter ist und dann mit 502 scheitert. Co-Authored-By: Claude Sonnet 4.6 --- app/web/app.js | 10 ++++++---- app/web/index.html | 2 +- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/app/web/app.js b/app/web/app.js index b7d6d87..c1980b5 100644 --- a/app/web/app.js +++ b/app/web/app.js @@ -982,9 +982,9 @@ const FIELD_META = { 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" }, - openrouter_tts_voice: { ui: "combo", opts: ["Zephyr","Puck","Charon","Kore","Fenrir","Leda","Orus","Aoede","Callirrhoe","Enceladus","Algieba","Despina","Achernar"], test: "tts" }, - piper_voice: { ui: "combo", opts: ["de_DE-thorsten-high","de_DE-kerstin-low","de_DE-karlsson-low"], test: "tts" }, + 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","de_DE-kerstin-low","de_DE-karlsson-low"], 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_max_tokens: { ui: "number", min: 0, test: "llm" }, @@ -1207,9 +1207,11 @@ function buildSettingRow(item) { 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({ text: "Hallo, ich bin der Sprachassistent." }), + body: JSON.stringify(body), }); if (!r.ok) throw new Error("HTTP " + r.status); const buf = await r.arrayBuffer(); diff --git a/app/web/index.html b/app/web/index.html index a2b4da4..ab3f082 100644 --- a/app/web/index.html +++ b/app/web/index.html @@ -218,6 +218,6 @@
    - + From a54daecbc890f466e650f08cb565e7e18c1627e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Fri, 19 Jun 2026 12:58:54 +0200 Subject: [PATCH 085/113] feat: make stop/start/restart + Doku servicefreundlicher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Makefile: neue Targets 'stop' (Gateway + llama.cpp), 'start' (llm-up + run), 'restart' (stop + start). CONTAINER_NAME als überschreibbare Variable. BEDIENUNGSANLEITUNG § 4: neuer Schnellbefehle-Block (§ 4.0) mit Tabellen für alle Start-/Stop-Situationen; § 4.8 Stoppen und § 4.9 Neustart als dedizierte Abschnitte. README: Stoppen, Neu starten und make-Targets aktualisiert. Co-Authored-By: Claude Sonnet 4.6 --- BEDIENUNGSANLEITUNG.md | 96 +++++++++++++++++++++++++++++++++++++++++- Makefile | 24 ++++++++++- README.md | 23 ++++++++++ 3 files changed, 141 insertions(+), 2 deletions(-) diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index 3640eec..a5a9c2c 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -35,7 +35,7 @@ funktioniert trotzdem, die Ausgabe ist dann unformatiert. 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.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. [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) @@ -368,6 +368,41 @@ curl -s $URL/api/config | jq '{profile, default_route}' > 🔧 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` | +| 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 @@ -576,6 +611,63 @@ VA_PROFILE=hybrid make run --- +### 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. + +--- + ## 5. Das System benutzen > 👤 Endnutzer @@ -1938,6 +2030,8 @@ in `app/dependencies.py` + Implementierung in `app/providers/`. → [Architektur | 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 | diff --git a/Makefile b/Makefile index 175cf50..e26955e 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,9 @@ endif PORT ?= 8080 HOST ?= 0.0.0.0 -.PHONY: ensure-env install run test smoke docker-build llm-up llm-down llm-status +CONTAINER_NAME ?= va_llm + +.PHONY: ensure-env install run start stop restart test smoke docker-build llm-up llm-down llm-status ensure-env: @if [ ! -f .env ] && [ -f .env.example ]; then \ @@ -31,6 +33,26 @@ smoke: ensure-env 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: diff --git a/README.md b/README.md index 1ea1f3f..fc1fc9f 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,26 @@ 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). @@ -142,6 +162,9 @@ sudo systemctl start ollama ```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 From b91be9b9fe1edd47dd91283a60fec6e28cd5aec1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Fri, 19 Jun 2026 13:04:49 +0200 Subject: [PATCH 086/113] =?UTF-8?q?fix:=20Piper-Stimmliste=20korrigieren?= =?UTF-8?q?=20=E2=80=94=20karlsson-low=20nicht=20installiert=20(v16)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit app.js: de_DE-karlsson-low aus Dropdown entfernt (nicht vorhanden); stattdessen alle tatsächlich installierten Stimmen aufgeführt (de_DE-thorsten-high, de_DE-kerstin-low, en_US-ryan-high, es_ES-davefx-medium, fr_FR-gilles-low). BEDIENUNGSANLEITUNG § 6.5.2: Tabelle auf tatsächlichen Bestand korrigiert. Co-Authored-By: Claude Sonnet 4.6 --- BEDIENUNGSANLEITUNG.md | 6 +++++- app/web/app.js | 2 +- app/web/index.html | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index a5a9c2c..194b32a 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -1009,7 +1009,11 @@ Aktuell installierte Stimmen: |---|---|---|---| | `de_DE-thorsten-high` | Deutsch | high (22050 Hz) | **Default**, männlich | | `de_DE-kerstin-low` | Deutsch | low (16000 Hz) | weiblich, hörbar gröber | -| `de_DE-karlsson-low` | Deutsch | low (16000 Hz) | männlich, hörbar gröber | +| `en_US-ryan-high` | Englisch | high (22050 Hz) | männlich | +| `es_ES-davefx-medium` | Spanisch | medium | männlich | +| `fr_FR-gilles-low` | Französisch | low (16000 Hz) | männlich, hörbar gröber | + +`de_DE-karlsson-low` ist **nicht installiert** — Herunterladen (→ unten) oder `de_DE-thorsten-high` verwenden. Stimme wechseln (in `.env`): ```bash diff --git a/app/web/app.js b/app/web/app.js index c1980b5..d3951b6 100644 --- a/app/web/app.js +++ b/app/web/app.js @@ -984,7 +984,7 @@ const FIELD_META = { 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","de_DE-kerstin-low","de_DE-karlsson-low"], test: "tts", testProvider: "piper" }, + piper_voice: { ui: "combo", opts: ["de_DE-thorsten-high","de_DE-kerstin-low","en_US-ryan-high","es_ES-davefx-medium","fr_FR-gilles-low"], 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_max_tokens: { ui: "number", min: 0, test: "llm" }, diff --git a/app/web/index.html b/app/web/index.html index ab3f082..591843b 100644 --- a/app/web/index.html +++ b/app/web/index.html @@ -218,6 +218,6 @@
    - + From da561cec0997ccbe2fea949651e69ac11837d7c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Fri, 19 Jun 2026 13:07:24 +0200 Subject: [PATCH 087/113] =?UTF-8?q?fix:=20de=5FDE-karlsson-low=20installie?= =?UTF-8?q?rt=20=E2=80=94=20Dropdown=20+=20Doku=20aktualisiert=20(v17)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- BEDIENUNGSANLEITUNG.md | 3 ++- app/web/app.js | 2 +- app/web/index.html | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index 194b32a..6f54390 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -1009,11 +1009,12 @@ Aktuell installierte Stimmen: |---|---|---|---| | `de_DE-thorsten-high` | Deutsch | high (22050 Hz) | **Default**, männlich | | `de_DE-kerstin-low` | Deutsch | low (16000 Hz) | weiblich, hörbar gröber | +| `de_DE-karlsson-low` | Deutsch | low (16000 Hz) | männlich, hörbar gröber | | `en_US-ryan-high` | Englisch | high (22050 Hz) | männlich | | `es_ES-davefx-medium` | Spanisch | medium | männlich | | `fr_FR-gilles-low` | Französisch | low (16000 Hz) | männlich, hörbar gröber | -`de_DE-karlsson-low` ist **nicht installiert** — Herunterladen (→ unten) oder `de_DE-thorsten-high` verwenden. +`de_DE-karlsson-low` ist installiert (heruntergeladen 2026-06-19). Stimme wechseln (in `.env`): ```bash diff --git a/app/web/app.js b/app/web/app.js index d3951b6..afe2c8e 100644 --- a/app/web/app.js +++ b/app/web/app.js @@ -984,7 +984,7 @@ const FIELD_META = { 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","de_DE-kerstin-low","en_US-ryan-high","es_ES-davefx-medium","fr_FR-gilles-low"], test: "tts", testProvider: "piper" }, + piper_voice: { ui: "combo", opts: ["de_DE-thorsten-high","de_DE-kerstin-low","de_DE-karlsson-low","en_US-ryan-high","es_ES-davefx-medium","fr_FR-gilles-low"], 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_max_tokens: { ui: "number", min: 0, test: "llm" }, diff --git a/app/web/index.html b/app/web/index.html index 591843b..8dc5391 100644 --- a/app/web/index.html +++ b/app/web/index.html @@ -218,6 +218,6 @@
    - + From 389cd5a4171f189a7ca38214f8f3db3c5900d09d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Fri, 19 Jun 2026 13:23:42 +0200 Subject: [PATCH 088/113] =?UTF-8?q?fix:=20fr=5FFR-siwis-medium=20installie?= =?UTF-8?q?rt=20=E2=80=94=20Missing-phoneme-Warning=20behoben=20(v18)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fr_FR-gilles-low (low, 130 Phoneme) fehlt Kombinations-Tilde U+0303 → Nasalvokale (ã, ɔ̃) werden nicht korrekt ausgesprochen. fr_FR-siwis-medium (medium, 154 Phoneme) enthält alle nötigen Phoneme. Dropdown und Doku aktualisiert. espeak-ng-Daten waren bereits vollständig in piper gebündelt — kein separater Download nötig. Co-Authored-By: Claude Sonnet 4.6 --- BEDIENUNGSANLEITUNG.md | 5 +++-- app/web/app.js | 2 +- app/web/index.html | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index 6f54390..f38a4b8 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -1012,9 +1012,10 @@ Aktuell installierte Stimmen: | `de_DE-karlsson-low` | Deutsch | low (16000 Hz) | männlich, hörbar gröber | | `en_US-ryan-high` | Englisch | high (22050 Hz) | männlich | | `es_ES-davefx-medium` | Spanisch | medium | männlich | -| `fr_FR-gilles-low` | Französisch | low (16000 Hz) | männlich, hörbar gröber | +| `fr_FR-siwis-medium` | Französisch | medium (22050 Hz) | weiblich, **empfohlen** (154 Phoneme, korrekte Nasalvokale) | +| `fr_FR-gilles-low` | Französisch | low (16000 Hz) | männlich, 130 Phoneme (Nasalvokale unvollständig) | -`de_DE-karlsson-low` ist installiert (heruntergeladen 2026-06-19). +`de_DE-karlsson-low` und `fr_FR-siwis-medium` nachinstalliert (2026-06-19). Stimme wechseln (in `.env`): ```bash diff --git a/app/web/app.js b/app/web/app.js index afe2c8e..e67dd57 100644 --- a/app/web/app.js +++ b/app/web/app.js @@ -984,7 +984,7 @@ const FIELD_META = { 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","de_DE-kerstin-low","de_DE-karlsson-low","en_US-ryan-high","es_ES-davefx-medium","fr_FR-gilles-low"], test: "tts", testProvider: "piper" }, + piper_voice: { ui: "combo", opts: ["de_DE-thorsten-high","de_DE-kerstin-low","de_DE-karlsson-low","en_US-ryan-high","es_ES-davefx-medium","fr_FR-siwis-medium","fr_FR-gilles-low"], 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_max_tokens: { ui: "number", min: 0, test: "llm" }, diff --git a/app/web/index.html b/app/web/index.html index 8dc5391..0f9dc14 100644 --- a/app/web/index.html +++ b/app/web/index.html @@ -218,6 +218,6 @@
    - + From c3f6206d0cc0fbf041f3c1a67d3281d1e2b098cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Fri, 19 Jun 2026 13:41:03 +0200 Subject: [PATCH 089/113] =?UTF-8?q?feat(piper):=20beste=20Stimmen=20f?= =?UTF-8?q?=C3=BCr=2010=20Sprachen=20installiert=20+=20low/medium=20aufger?= =?UTF-8?q?=C3=A4umt=20(v19)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neu installiert: en_US-lessac-high, en_GB-cori-high, es_ES-sharvard-medium, ru_RU-irina-medium, zh_CN-huayan-medium, it_IT-paola-medium, nl_NL-mls-medium. Gelöscht: de_DE-kerstin-low, de_DE-karlsson-low, fr_FR-gilles-low, es_ES-davefx-medium. Alle verbleibenden Stimmen haben 130–159 Phoneme; es/fr/it/nl/ru/zh haben kein high — medium ist das Maximum bei piper. Dropdown und Doku aktualisiert. Co-Authored-By: Claude Sonnet 4.6 --- BEDIENUNGSANLEITUNG.md | 25 ++++++++++++++----------- app/web/app.js | 2 +- app/web/index.html | 2 +- 3 files changed, 16 insertions(+), 13 deletions(-) diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index f38a4b8..41dbe73 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -1003,19 +1003,22 @@ Stimmmodell-Dateien (`.onnx` + `.onnx.json`) liegen im `PIPER_VOICES `rhasspy/piper-voices` → die zwei Dateien in das Verzeichnis kopieren, dann `PIPER_VOICE` setzen und Server neu starten. -Aktuell installierte Stimmen: +Aktuell installierte Stimmen (Stand 2026-06-19): -| `PIPER_VOICE` | Sprache | Qualität | Hinweis | -|---|---|---|---| -| `de_DE-thorsten-high` | Deutsch | high (22050 Hz) | **Default**, männlich | -| `de_DE-kerstin-low` | Deutsch | low (16000 Hz) | weiblich, hörbar gröber | -| `de_DE-karlsson-low` | Deutsch | low (16000 Hz) | männlich, hörbar gröber | -| `en_US-ryan-high` | Englisch | high (22050 Hz) | männlich | -| `es_ES-davefx-medium` | Spanisch | medium | männlich | -| `fr_FR-siwis-medium` | Französisch | medium (22050 Hz) | weiblich, **empfohlen** (154 Phoneme, korrekte Nasalvokale) | -| `fr_FR-gilles-low` | Französisch | low (16000 Hz) | männlich, 130 Phoneme (Nasalvokale unvollständig) | +| `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 | -`de_DE-karlsson-low` und `fr_FR-siwis-medium` nachinstalliert (2026-06-19). +\* Für diese Sprachen existiert keine `high`-Variante in piper — `medium` ist das Maximum. Stimme wechseln (in `.env`): ```bash diff --git a/app/web/app.js b/app/web/app.js index e67dd57..bd4318c 100644 --- a/app/web/app.js +++ b/app/web/app.js @@ -984,7 +984,7 @@ const FIELD_META = { 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","de_DE-kerstin-low","de_DE-karlsson-low","en_US-ryan-high","es_ES-davefx-medium","fr_FR-siwis-medium","fr_FR-gilles-low"], test: "tts", testProvider: "piper" }, + 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_max_tokens: { ui: "number", min: 0, test: "llm" }, diff --git a/app/web/index.html b/app/web/index.html index 0f9dc14..e27ab54 100644 --- a/app/web/index.html +++ b/app/web/index.html @@ -218,6 +218,6 @@
    - + From 81d4cf6fd8f94c024d78dd47917f6bbd922e11d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Fri, 19 Jun 2026 14:12:16 +0200 Subject: [PATCH 090/113] fix(llm): LLM antwortet jetzt in der konfigurierten Systemsprache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bisher wurde `language` zwar an STT/TTS übergeben, aber nie an den LLM. Lokales Modell antwortete deshalb immer auf Englisch/Deutsch, egal ob Französisch, Spanisch o.a. eingestellt war. Lösung: `language`-Parameter durch die gesamte LLM-Schicht gezogen: - base.py: `lang_instruction()` helper + Signatur erweitert - local_openai_compatible.py: Sprachanweisung ("Respond in Français.") wird als letzter System-Part in den Message-Stack eingefügt - openrouter.py: Explizite Sprachanweisung ergänzt den bestehenden "Answer in the same language as the user"-Prompt - fallback.py: FallbackLLMProvider leitet `language` durch - orchestrator.py: alle 3 LLM-Aufrufstellen übergeben `language` - Tests: alle Stub-LLMs um `language=None` ergänzt Co-Authored-By: Claude Sonnet 4.6 --- app/core/orchestrator.py | 5 ++-- app/providers/fallback.py | 12 ++++++---- app/providers/llm/base.py | 24 +++++++++++++++++++- app/providers/llm/local_openai_compatible.py | 21 ++++++++++++----- app/providers/llm/openrouter.py | 19 ++++++++++++---- tests/test_emergency_llm.py | 2 +- tests/test_endpoints_e2e.py | 2 +- tests/test_memory.py | 2 +- tests/test_memory_extraction.py | 2 +- tests/test_quota_safety.py | 2 +- tests/test_realtime.py | 6 ++--- tests/test_resilience.py | 20 ++++++++-------- tests/test_streaming.py | 12 +++++----- tests/test_ws.py | 4 ++-- 14 files changed, 89 insertions(+), 44 deletions(-) diff --git a/app/core/orchestrator.py b/app/core/orchestrator.py index e561e2a..d5ca8b3 100644 --- a/app/core/orchestrator.py +++ b/app/core/orchestrator.py @@ -99,6 +99,7 @@ class Orchestrator: trace.semantic_response = await self.llm.complete( trace.cleaned_transcript or "", history=history, + language=language, ) if not trace.semantic_response: raise RuntimeError("LLM returned an empty response") @@ -158,7 +159,7 @@ class Orchestrator: 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): + async for delta in stream_fn(trace.cleaned_transcript or "", history=history, language=language): parts.append(delta) if on_token: await on_token(delta) @@ -167,7 +168,7 @@ class Orchestrator: await _emit_sentence(sentence) else: # Provider ohne Streaming -> komplette Antwort als ein Token. - result = await self.llm.complete(trace.cleaned_transcript or "", history=history) + result = await self.llm.complete(trace.cleaned_transcript or "", history=history, language=language) parts.append(result) if on_token: await on_token(result) diff --git a/app/providers/fallback.py b/app/providers/fallback.py index 3e7e71d..3cfb122 100644 --- a/app/providers/fallback.py +++ b/app/providers/fallback.py @@ -37,11 +37,13 @@ class FallbackSTTProvider(_Chain): class FallbackLLMProvider(_Chain): - async def complete(self, text, history=None, session_id=None) -> str: + 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) + result = await provider.complete( + text, history=history, session_id=session_id, language=language + ) if index > 0: self._on_fallback() return result @@ -50,12 +52,14 @@ class FallbackLLMProvider(_Chain): self._on_error(name) raise last_exc - async def stream(self, text, history=None, session_id=None) -> AsyncIterator[str]: + 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): + async for delta in provider.stream( + text, history=history, session_id=session_id, language=language + ): produced = True yield delta if index > 0: diff --git a/app/providers/llm/base.py b/app/providers/llm/base.py index 2721b00..bf566c5 100644 --- a/app/providers/llm/base.py +++ b/app/providers/llm/base.py @@ -2,6 +2,26 @@ 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: + """'Respond in X.' instruction for the given ISO language code, or None.""" + if not language: + return None + name = _LANG_NAMES.get(language, language) + return f"Respond in {name}." + def sse_delta(line: str) -> str | None: """Extrahiert das Token-Delta aus einer OpenAI-kompatiblen SSE-Zeile (oder None).""" @@ -24,6 +44,7 @@ class LLMProvider(ABC): text: str, history: list[dict] | None = None, session_id: str | None = None, + language: str | None = None, ) -> str: ... async def stream( @@ -31,9 +52,10 @@ class LLMProvider(ABC): 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) + 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 index c550aed..713d825 100644 --- a/app/providers/llm/local_openai_compatible.py +++ b/app/providers/llm/local_openai_compatible.py @@ -2,7 +2,7 @@ from collections.abc import AsyncIterator import httpx -from app.providers.llm.base import LLMProvider, sse_delta +from app.providers.llm.base import LLMProvider, lang_instruction, sse_delta class LocalOpenAICompatibleLLM(LLMProvider): @@ -24,7 +24,9 @@ class LocalOpenAICompatibleLLM(LLMProvider): self.max_tokens = max_tokens self.temperature = temperature - def _build_messages(self, text: str, history: list[dict] | None) -> list[dict]: + 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 @@ -40,6 +42,9 @@ class LocalOpenAICompatibleLLM(LLMProvider): system_parts.append(content) else: rest.append(msg) + instr = lang_instruction(language) + if instr: + system_parts.append(instr) messages: list[dict] = [] if system_parts: @@ -48,10 +53,12 @@ class LocalOpenAICompatibleLLM(LLMProvider): messages.append({"role": "user", "content": text}) return messages - def _payload(self, text: str, history: list[dict] | None, stream: bool) -> dict: + 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), + "messages": self._build_messages(text, history, language=language), "temperature": self.temperature, } if stream: @@ -68,12 +75,13 @@ class LocalOpenAICompatibleLLM(LLMProvider): 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), + json=self._payload(text, history, stream=False, language=language), ) response.raise_for_status() data = response.json() @@ -84,13 +92,14 @@ class LocalOpenAICompatibleLLM(LLMProvider): 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), + json=self._payload(text, history, stream=True, language=language), ) as response: if response.status_code >= 400: body = await response.aread() diff --git a/app/providers/llm/openrouter.py b/app/providers/llm/openrouter.py index 369140e..ab8116d 100644 --- a/app/providers/llm/openrouter.py +++ b/app/providers/llm/openrouter.py @@ -2,7 +2,7 @@ from collections.abc import AsyncIterator import httpx -from app.providers.llm.base import LLMProvider, sse_delta +from app.providers.llm.base import LLMProvider, lang_instruction, sse_delta SYSTEM_PROMPT = """ @@ -51,7 +51,9 @@ class OpenRouterLLMProvider(LLMProvider): self.api_key = (api_key or "").strip() self.model = (model or "").strip() - def _build_messages(self, text: str, history: list[dict] | None) -> list[dict]: + 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: @@ -59,7 +61,12 @@ class OpenRouterLLMProvider(LLMProvider): if not text or not text.strip(): raise ValueError("LLM input text is empty") - messages = [{"role": "system", "content": SYSTEM_PROMPT}] + 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) messages.append({"role": "user", "content": text.strip()}) @@ -70,10 +77,11 @@ class OpenRouterLLMProvider(LLMProvider): 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), + "messages": self._build_messages(text, history, language=language), } timeout = httpx.Timeout(connect=10.0, read=120.0, write=30.0, pool=10.0) @@ -115,10 +123,11 @@ class OpenRouterLLMProvider(LLMProvider): 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), + "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) diff --git a/tests/test_emergency_llm.py b/tests/test_emergency_llm.py index b463519..5fefaf0 100644 --- a/tests/test_emergency_llm.py +++ b/tests/test_emergency_llm.py @@ -12,7 +12,7 @@ class StubLLM: self.raw = raw self.calls = 0 - async def complete(self, text, history=None, session_id=None): + async def complete(self, text, history=None, session_id=None, language=None): self.calls += 1 return self.raw diff --git a/tests/test_endpoints_e2e.py b/tests/test_endpoints_e2e.py index 7cac465..699717d 100644 --- a/tests/test_endpoints_e2e.py +++ b/tests/test_endpoints_e2e.py @@ -59,7 +59,7 @@ def test_session_route_applies(monkeypatch): def test_chat_per_request_override_and_loopback(monkeypatch): class StubLLM: - async def complete(self, text, history=None, session_id=None): + async def complete(self, text, history=None, session_id=None, language=None): return "Mir geht es gut, danke." class StubTTS: diff --git a/tests/test_memory.py b/tests/test_memory.py index d29d260..fceb038 100644 --- a/tests/test_memory.py +++ b/tests/test_memory.py @@ -8,7 +8,7 @@ client = TestClient(app) def _install_stubs(monkeypatch, captured): class MemLLM: - async def complete(self, text, history=None, session_id=None): + async def complete(self, text, history=None, session_id=None, language=None): captured["history"] = list(history or []) return f"Antwort auf: {text}" diff --git a/tests/test_memory_extraction.py b/tests/test_memory_extraction.py index 2c5cae0..850dfa2 100644 --- a/tests/test_memory_extraction.py +++ b/tests/test_memory_extraction.py @@ -12,7 +12,7 @@ class StubLLM: self.raw = raw self.calls = 0 - async def complete(self, text, history=None, session_id=None): + async def complete(self, text, history=None, session_id=None, language=None): self.calls += 1 return self.raw diff --git a/tests/test_quota_safety.py b/tests/test_quota_safety.py index 928cb7a..fcc2cd5 100644 --- a/tests/test_quota_safety.py +++ b/tests/test_quota_safety.py @@ -10,7 +10,7 @@ client = TestClient(app) def _stub_chat(monkeypatch, answer="ok"): class StubLLM: - async def complete(self, text, history=None, session_id=None): + async def complete(self, text, history=None, session_id=None, language=None): return answer class StubTTS: diff --git a/tests/test_realtime.py b/tests/test_realtime.py index 8df0b21..a1c4b1b 100644 --- a/tests/test_realtime.py +++ b/tests/test_realtime.py @@ -42,10 +42,10 @@ def test_energy_vad_ignores_silence_without_speech(): def _install_slow_stream(monkeypatch): class SlowLLM: - async def complete(self, text, history=None, session_id=None): + async def complete(self, text, history=None, session_id=None, language=None): return "fertig" - async def stream(self, text, history=None, session_id=None): + 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} " @@ -89,7 +89,7 @@ def _install_voice_stubs(monkeypatch): return "ok" class LLM: - async def complete(self, text, history=None, session_id=None): + async def complete(self, text, history=None, session_id=None, language=None): return "antwort" class TTS: diff --git a/tests/test_resilience.py b/tests/test_resilience.py index 7d25933..5af6cfd 100644 --- a/tests/test_resilience.py +++ b/tests/test_resilience.py @@ -20,11 +20,11 @@ def _run(coro): def test_llm_fallback_uses_second_on_error(): class BadLLM: - async def complete(self, text, history=None, session_id=None): + 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): + async def complete(self, text, history=None, session_id=None, language=None): return "ok" chain = FallbackLLMProvider("llm", [("bad", BadLLM()), ("good", GoodLLM())]) @@ -37,7 +37,7 @@ def test_llm_fallback_uses_second_on_error(): def test_llm_fallback_all_fail_raises(): class BadLLM: - async def complete(self, text, history=None, session_id=None): + async def complete(self, text, history=None, session_id=None, language=None): raise RuntimeError("x") chain = FallbackLLMProvider("llm", [("a", BadLLM()), ("b", BadLLM())]) @@ -47,18 +47,18 @@ def test_llm_fallback_all_fail_raises(): def test_llm_stream_fallback_before_first_token(): class BadStream: - async def complete(self, text, history=None, session_id=None): + async def complete(self, text, history=None, session_id=None, language=None): return "x" - async def stream(self, text, history=None, session_id=None): + 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): + async def complete(self, text, history=None, session_id=None, language=None): return "ok" - async def stream(self, text, history=None, session_id=None): + async def stream(self, text, history=None, session_id=None, language=None): yield "he" yield "llo" @@ -74,11 +74,11 @@ def test_llm_stream_fallback_before_first_token(): def test_config_llm_fallback_applied(monkeypatch): class BadLLM: - async def complete(self, text, history=None, session_id=None): + 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): + async def complete(self, text, history=None, session_id=None, language=None): return "rescued" class StubTTS: @@ -102,7 +102,7 @@ def test_config_llm_fallback_applied(monkeypatch): def test_metrics_endpoint_records_requests_and_stages(monkeypatch): class StubLLM: - async def complete(self, text, history=None, session_id=None): + async def complete(self, text, history=None, session_id=None, language=None): return "hi" class StubTTS: diff --git a/tests/test_streaming.py b/tests/test_streaming.py index fa19c13..b388c98 100644 --- a/tests/test_streaming.py +++ b/tests/test_streaming.py @@ -40,7 +40,7 @@ def test_sse_delta_parsing(): def test_base_stream_default_yields_full_completion(): class P(LLMProvider): - async def complete(self, text, history=None, session_id=None): + async def complete(self, text, history=None, session_id=None, language=None): return "ganze Antwort" async def run(): @@ -51,10 +51,10 @@ def test_base_stream_default_yields_full_completion(): def _install_streaming(monkeypatch, tokens): class StreamLLM: - async def complete(self, text, history=None, session_id=None): + 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): + async def stream(self, text, history=None, session_id=None, language=None): for tok in tokens: yield tok @@ -95,7 +95,7 @@ def test_ws_without_stream_flag_has_no_tokens(monkeypatch): def test_ws_stream_fallback_for_nonstreaming_llm(monkeypatch): class OnlyComplete: - async def complete(self, text, history=None, session_id=None): + async def complete(self, text, history=None, session_id=None, language=None): return "komplett" class StubTTS: @@ -118,10 +118,10 @@ def test_ws_audio_stream_sends_chunks_per_sentence(monkeypatch): tts_calls = [] class StreamLLM: - async def complete(self, text, history=None, session_id=None): + 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): + async def stream(self, text, history=None, session_id=None, language=None): for tok in ["Satz ", "eins. ", "Satz ", "zwei."]: yield tok diff --git a/tests/test_ws.py b/tests/test_ws.py index 2e3e1f2..5a81041 100644 --- a/tests/test_ws.py +++ b/tests/test_ws.py @@ -11,7 +11,7 @@ client = TestClient(app) def _install_stubs(monkeypatch, captured): class MemLLM: - async def complete(self, text, history=None, session_id=None): + async def complete(self, text, history=None, session_id=None, language=None): captured["history"] = list(history or []) return f"Echo: {text}" @@ -74,7 +74,7 @@ def _install_voice_stubs(monkeypatch): return f"erkannt({len(audio_bytes)})" class StubLLM: - async def complete(self, text, history=None, session_id=None): + async def complete(self, text, history=None, session_id=None, language=None): return f"Antwort zu {text}" class StubTTS: From 007459300295247d0f105334f37e931329585917 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Fri, 19 Jun 2026 14:29:22 +0200 Subject: [PATCH 091/113] =?UTF-8?q?feat(tts):=20Aussprache-Lexika=20f?= =?UTF-8?q?=C3=BCr=20alle=208=20Piper-Sprachen=20=E2=80=94=20Eigenname=20D?= =?UTF-8?q?ieter=20Schl=C3=BCter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neue Dateien: pronunciation.fr/es/it/nl/ru/zh.yaml Erweitert: pronunciation.en.yaml Strategie: Textersetzung in der Zielsprach-Orthographie (kein IPA nötig). - EN: "Deeter Schlueter" (ie→/aɪ/ umgehen, ü als ue) - FR: "Chluteur" (ch=/ʃ/, u=/y/=ü, eur=/œʁ/ → /ʃlytœʁ/) - NL: "Schluuter" (uu=/yː/=ü, sch=/sx/) - ES/IT: "Schlueter" (kein /ʃ/ verfügbar, beste Näherung) - RU: Kyrillisch "Дитер Шлютер" (espeak-ru liest Latein schlecht) - ZH: Hanzi "迪特 施吕特" (施=/ʃɨ/, 吕=lǚ=/ly/=ü — trifft Klang genau) Co-Authored-By: Claude Sonnet 4.6 --- config/pronunciation.en.yaml | 5 +++++ config/pronunciation.es.yaml | 12 ++++++++++++ config/pronunciation.fr.yaml | 16 ++++++++++++++++ config/pronunciation.it.yaml | 12 ++++++++++++ config/pronunciation.nl.yaml | 12 ++++++++++++ config/pronunciation.ru.yaml | 14 ++++++++++++++ config/pronunciation.zh.yaml | 14 ++++++++++++++ 7 files changed, 85 insertions(+) create mode 100644 config/pronunciation.es.yaml create mode 100644 config/pronunciation.fr.yaml create mode 100644 config/pronunciation.it.yaml create mode 100644 config/pronunciation.nl.yaml create mode 100644 config/pronunciation.ru.yaml create mode 100644 config/pronunciation.zh.yaml diff --git a/config/pronunciation.en.yaml b/config/pronunciation.en.yaml index ec6f9e6..7403e2e 100644 --- a/config/pronunciation.en.yaml +++ b/config/pronunciation.en.yaml @@ -10,3 +10,8 @@ units: terms: # "SQL": "sequel" + # Name des Systembetreibers: phonetische Anpassung für englische Aussprache. + # "ie" in EN → /aɪ/ ("die"); "Deeter" ergibt /ˈdiːtər/. + # "Schlueter" = eingedeutschte Schreibung, EN-espeak: /ˈʃluːtər/. + "Dieter": "Deeter" + "Schlüter": "Schlueter" 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..6f421ca --- /dev/null +++ b/config/pronunciation.fr.yaml @@ -0,0 +1,16 @@ +# Lexique de prononciation (Français) pour la normalisation TTS avant Piper. +# Format identique à pronunciation.de.yaml. +# Wirkt nur bei lokalem TTS (piper, Stufe "full"). + +abbreviations: + # "etc.": "et cetera" + +units: + # "km": "kilomètres" + +terms: + # Nom du responsable système — approximation phonétique pour espeak-ng fr. + # FR: "ch"=/ʃ/, "u"=/y/ (= Umlaut ü!), "eur"=/œʁ/ → /ʃlytœʁ/ ≈ allemand /ʃlyːtɐ/. + "Schlüter": "Chluteur" + # "Dieter" → FR espeak donne /djɛtɛʁ/, acceptable; décommenter si trop éloigné. + # "Dieter": "Diéter" 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": "施吕特" From dc8ac8050543bfaa29df235dc794e647f47f7d51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Fri, 19 Jun 2026 14:34:08 +0200 Subject: [PATCH 092/113] =?UTF-8?q?docs:=20=C2=A7=206.5.4=20Aussprache-Lex?= =?UTF-8?q?ika=20vollst=C3=A4ndig=20dokumentiert=20(alle=208=20Sprachen)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - YAML-Dateistruktur für alle Sprachen erklärt (de/en/fr/es/it/nl/ru/zh) - Drei Sektionen (abbreviations/units/terms) mit Matching-Regeln - Anleitung "Eigennamen in Fremdsprachen" — Textersetzungs-Prinzip statt IPA mit Klangäquivalent-Tabelle (ʃ, y/ü, x, ts in je 6 Sprachen) - Sonderfall RU/ZH: Kyrillisch/Hanzi notwendig, Lateinschrift unzuverlässig - Drei Wege dokumentiert: Web-UI (de/en), REST-API (alle Sprachen, sofort), direkte YAML-Bearbeitung (alle Sprachen, Neustart nötig) - Test-Befehle: Admin-Test-Button, curl, Normalizer-Skript - § 7.5 Wörterbuch-Tab: Hinweis auf de/en-Beschränkung + Verweis auf § 6.5.4 - API-Referenz: lang-Parameter auf alle Sprachcodes erweitert - Stichwortverzeichnis: zwei neue Einträge Co-Authored-By: Claude Sonnet 4.6 --- BEDIENUNGSANLEITUNG.md | 170 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 165 insertions(+), 5 deletions(-) diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index 41dbe73..82c34a4 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -1060,12 +1060,12 @@ 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 in `config/pronunciation.de.yaml` +- **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 — **zwei Wege:** +##### Eigene Aussprache hinzufügen (Deutsch / Englisch) **Web-UI (empfohlen):** Admin-Panel → Tab „🔤 Wörterbuch" (→ § 7.5). Kein Neustart nötig. @@ -1077,6 +1077,159 @@ 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 @@ -1456,12 +1609,17 @@ und CSS-Balkendiagramm. Aussprache-Lexikon direkt im Browser bearbeiten — kein Kommandozeilen-Skript nötig: -1. Sprache wählen (Deutsch / Englisch). +1. Sprache wählen (Dropdown: **Deutsch** / **Englisch**). 2. Sektion wählen: **Abkürzungen**, **Einheiten**, **Begriffe / Aussprache**. 3. Vorhandene Einträge: Maus drüber → **✕** erscheint → löschen. 4. Neuer Eintrag: Schlüssel + Ersetzung eingeben → **+ Hinzufügen**. Die Änderung greift sofort (Server-Cache wird automatisch geleert). +> **Andere Sprachen (fr, es, it, nl, ru, zh):** Das Wörterbuch-Tab zeigt aktuell nur +> Deutsch und Englisch. Für andere Sprachen die YAML-Datei direkt editieren +> (`config/pronunciation..yaml`) oder die REST-API nutzen — beides ohne Neustart +> möglich (API) bzw. mit Neustart (YAML direkt). Vollständige Anleitung → § 6.5.4. + #### Log Zeigt den systemd-Journal-Log des `voice-assistant.service` live im Browser: @@ -1960,8 +2118,8 @@ Body-Felder: `input_endpoint`, `output_endpoint`, `stt_provider`, `llm_provider` | `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`) | -| `POST` | `/api/admin/pronunciation/{lang}` | Admin | Eintrag hinzufügen/überschreiben (`{"section":"…","key":"…","value":"…"}`) | +| `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) | @@ -2020,6 +2178,8 @@ in `app/dependencies.py` + Implementierung in `app/providers/`. → [Architektur | 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 | From 878bf785dd92dcc183b8ac8a381f8a42b24358e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Sat, 20 Jun 2026 13:12:04 +0200 Subject: [PATCH 093/113] =?UTF-8?q?feat:=20Ger=C3=A4te-TTS,=20native=20Sti?= =?UTF-8?q?mmen,=20Favicon,=20Auth-Gate,=20UI-Fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Web-UI / TTS: - Geräte-TTS ("📱 Gerät"): Antwort wird on-device vorgelesen (Web Speech API), Server sendet nur Text (text_only) -> spart Bandbreite/Kosten. Mobil-Default, geräte-lokale Speicherung, iOS-Autoplay-Freischaltung. - Vorlese-Symbol (🔊) je Bubble: Hybrid-Replay (Assistent-PCM gecacht, Eingabe via /api/speak); SVG-Icon mit kontrastreicher Farbe. - Kombiniertes Sprachmenü (Flex + feste Sprachen) statt separatem Modus-Menü. - "Neues Gespräch"-Button (frische Session gegen Sprach-Trägheit). - Dark-Mode: lesbare
    + ${renderLlmCard(llm)} +

    Laufzeit-Metriken

    diff --git a/app/web/index.html b/app/web/index.html index f17d0f3..f715f87 100644 --- a/app/web/index.html +++ b/app/web/index.html @@ -250,6 +250,6 @@
    - + diff --git a/tests/test_admin_llm_status.py b/tests/test_admin_llm_status.py new file mode 100644 index 0000000..fd7843c --- /dev/null +++ b/tests/test_admin_llm_status.py @@ -0,0 +1,41 @@ +"""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") From 5b6f0f8ef2eda63270e59939f60ed7c029ef2103 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Sat, 20 Jun 2026 18:39:59 +0200 Subject: [PATCH 097/113] =?UTF-8?q?feat(admin):=20Backend-Wechsel=20+=20Ga?= =?UTF-8?q?teway-Neustart=20aus=20dem=20Admin-Panel=20=E2=80=94=20Plan-Sch?= =?UTF-8?q?ritt=203?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - admin_llm: switch_backend() mit strikter Allowlist (backend ∈ {ollama,llamacpp}, Modell gegen 'ollama list' + Format-Regex), detached (Self-Restart-sicher), niemals shell=True. restart_gateway_detached() für systemd-User-Dienst. - switch-llm.sh: flock-Lock gegen parallele Backend-Wechsel (Exit 75). - Endpunkte POST /api/admin/llm/backend (422 bei ungültig) und POST /api/admin/gateway/restart (require_admin). - Status-Tab: Steuerung (Backend-Dropdown + Modell, Wechseln/Neustart) mit Poll bis das Gateway wieder antwortet; Hinweis auf systemd-Voraussetzung. - Tests: Auth + Allowlist (Shell-Metazeichen/unbekanntes Modell -> 422). 165 grün. - Doku §7.5. Co-Authored-By: Claude Opus 4.8 --- BEDIENUNGSANLEITUNG.md | 17 ++++++-- app/admin_llm.py | 74 ++++++++++++++++++++++++++++++++ app/api/admin.py | 28 +++++++++++- app/web/app.js | 58 +++++++++++++++++++++++++ app/web/index.html | 2 +- scripts/llm-server/switch-llm.sh | 10 +++++ tests/test_admin_llm_status.py | 36 ++++++++++++++++ 7 files changed, 219 insertions(+), 6 deletions(-) diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index ccfef77..89cdf38 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -1755,10 +1755,19 @@ Kategorie, Textausschnitt). #### Status Zeigt aktives Profil, Provider-Konfiguration, Laufzeit-Metriken und verfügbare Provider. -Zusätzlich eine **LLM-Backend-Karte** (read-only): aktives Backend (Ollama/llama.cpp), -Modell, ob die Backends laufen, geladene Ollama-Modelle und die **GPU-Auslastung** je -Karte als Balken. Am Ende: **⬇ voice-assistant.db herunterladen** — lädt die -SQLite-Datenbank als Backup. +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. #### Metriken diff --git a/app/admin_llm.py b/app/admin_llm.py index 8a5acf1..8cb21cc 100644 --- a/app/admin_llm.py +++ b/app/admin_llm.py @@ -8,10 +8,18 @@ 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.""" @@ -113,3 +121,69 @@ async def llm_status() -> dict: "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/admin.py b/app/api/admin.py index a862d5a..5dfd3fc 100644 --- a/app/api/admin.py +++ b/app/api/admin.py @@ -6,7 +6,12 @@ from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSo from fastapi.responses import FileResponse from pydantic import BaseModel -from app.admin_llm import llm_status +from app.admin_llm import ( + LlmControlError, + llm_status, + restart_gateway_detached, + switch_backend, +) from app.auth import is_admin_user, require_admin, require_admin_or_user from app.config import settings from app.dependencies import get_store @@ -196,6 +201,27 @@ async def get_llm_status(): 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): + """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: + return await switch_backend(payload.backend, payload.model) + except LlmControlError as exc: + raise HTTPException(status_code=422, detail=str(exc)) + + +@router.post("/admin/gateway/restart", dependencies=[Depends(require_admin)]) +async def post_gateway_restart(): + """Startet das Gateway (systemd-User-Dienst) neu — losgelöst, Self-Restart-sicher.""" + return restart_gateway_detached() + + # ── Aussprache-Lexikon CRUD ───────────────────────────────────────────────── def _read_pronunciation(lang: str) -> dict: diff --git a/app/web/app.js b/app/web/app.js index f330bf6..3807454 100644 --- a/app/web/app.js +++ b/app/web/app.js @@ -995,9 +995,66 @@ function renderLlmCard(llm) {
    ${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 …

    '; @@ -1074,6 +1131,7 @@ async function loadStatus() {
    `; + wireLlmControls(); // Buttons der LLM-Karte verdrahten (nach dem Rendern) } catch (e) { console.error("loadStatus error:", e); container.innerHTML = '

    Fehler beim Laden.

    '; diff --git a/app/web/index.html b/app/web/index.html index f715f87..5a86973 100644 --- a/app/web/index.html +++ b/app/web/index.html @@ -250,6 +250,6 @@
    - + diff --git a/scripts/llm-server/switch-llm.sh b/scripts/llm-server/switch-llm.sh index 3df4c45..9e08d09 100755 --- a/scripts/llm-server/switch-llm.sh +++ b/scripts/llm-server/switch-llm.sh @@ -17,6 +17,16 @@ 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}" diff --git a/tests/test_admin_llm_status.py b/tests/test_admin_llm_status.py index fd7843c..c327228 100644 --- a/tests/test_admin_llm_status.py +++ b/tests/test_admin_llm_status.py @@ -39,3 +39,39 @@ def test_status_schema(): 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 From b2576fd465c6afbed634a900094daddf9788a98e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Sat, 20 Jun 2026 18:46:33 +0200 Subject: [PATCH 098/113] =?UTF-8?q?feat(llm):=20Live-Parameter=20top=5Fp?= =?UTF-8?q?=20+=20Live/Restart-Kennzeichnung=20=E2=80=94=20Plan-Schritt=20?= =?UTF-8?q?4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - local_llm_top_p (Default 0.9) durch Config -> runtime_config (RUNTIME_SETTABLE, live ohne Neustart) -> local-openai-compatible Payload (top_p). - Admin-Einstellungen: top_p als Slider (0–1); Hints "wirkt sofort (kein Neustart)" für temperature/top_p. - Doku: top_p + Klarstellung Live-Parameter (temp/top_p/max_tokens) vs. Startup-Wert Kontextfenster (OLLAMA_CONTEXT_LENGTH / llama.cpp -c). Verifiziert: top_p im Payload, Live-Config-Round-Trip (PUT/DELETE). 165 grün. Co-Authored-By: Claude Opus 4.8 --- BEDIENUNGSANLEITUNG.md | 6 ++++++ app/config.py | 1 + app/dependencies.py | 1 + app/providers/llm/local_openai_compatible.py | 3 +++ app/runtime_config.py | 3 ++- app/web/app.js | 3 ++- app/web/index.html | 2 +- 7 files changed, 16 insertions(+), 3 deletions(-) diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index 89cdf38..78f03a6 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -1027,6 +1027,12 @@ Diese Settings gelten nur für den Provider `local-openai-compatible`. | `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**. diff --git a/app/config.py b/app/config.py index 3070375..14ce23f 100644 --- a/app/config.py +++ b/app/config.py @@ -131,6 +131,7 @@ class Settings(BaseSettings): 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 diff --git a/app/dependencies.py b/app/dependencies.py index f8cc8a7..9179ae5 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -63,6 +63,7 @@ LLM_REGISTRY = { 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, ), } diff --git a/app/providers/llm/local_openai_compatible.py b/app/providers/llm/local_openai_compatible.py index cef4352..bb6e15d 100644 --- a/app/providers/llm/local_openai_compatible.py +++ b/app/providers/llm/local_openai_compatible.py @@ -20,6 +20,7 @@ class LocalOpenAICompatibleLLM(LLMProvider): 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 @@ -28,6 +29,7 @@ class LocalOpenAICompatibleLLM(LLMProvider): 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 @@ -66,6 +68,7 @@ class LocalOpenAICompatibleLLM(LLMProvider): "model": self.model, "messages": self._build_messages(text, history, language=language), "temperature": self.temperature, + "top_p": self.top_p, } if stream: payload["stream"] = True diff --git a/app/runtime_config.py b/app/runtime_config.py index 712472d..231e413 100644 --- a/app/runtime_config.py +++ b/app/runtime_config.py @@ -26,7 +26,8 @@ RUNTIME_SETTABLE: dict[str, tuple[str, str, str]] = { "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"), + "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"), diff --git a/app/web/app.js b/app/web/app.js index 3807454..56dbeb3 100644 --- a/app/web/app.js +++ b/app/web/app.js @@ -1384,6 +1384,7 @@ const FIELD_META = { 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"], @@ -1398,7 +1399,7 @@ 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_max_tokens"] }, + { 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"] }, diff --git a/app/web/index.html b/app/web/index.html index 5a86973..6f0601c 100644 --- a/app/web/index.html +++ b/app/web/index.html @@ -250,6 +250,6 @@
    - + From 33d7189418d4eff755f6b9ee6d735b92c4952663 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Sat, 20 Jun 2026 18:50:55 +0200 Subject: [PATCH 099/113] =?UTF-8?q?feat(admin):=20Audit-Logging=20schreibe?= =?UTF-8?q?nder=20Admin-Aktionen=20=E2=80=94=20Plan-Schritt=205?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - app/audit.py: eigener Logger "va.audit" (eigener stdout-Handler -> Journal/Log-Tab). log_admin_action() protokolliert Aktion + Auslöser (SSO-Name oder admin-key). - Verdrahtet in: config_set/config_reset (PUT/DELETE /admin/config), llm_backend_switch (+ _rejected), gateway_restart. - Tests: caplog prüft Audit-Zeilen für config_set und abgelehnten Backend-Switch. - Doku §7.5: Audit-Format im Log-Tab. Schließt den Plan (Admin-gesteuerte LLM-/Gateway-Verwaltung, Schritte 1–5) ab. 167 grün. Co-Authored-By: Claude Opus 4.8 --- BEDIENUNGSANLEITUNG.md | 14 ++++++++++++++ app/api/admin.py | 24 ++++++++++++++++------- app/audit.py | 44 ++++++++++++++++++++++++++++++++++++++++++ tests/test_audit.py | 44 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 119 insertions(+), 7 deletions(-) create mode 100644 app/audit.py create mode 100644 tests/test_audit.py diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index 78f03a6..c78be73 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -1805,6 +1805,20 @@ Zeigt den systemd-Journal-Log des `voice-assistant.service` live im Browser: 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 diff --git a/app/api/admin.py b/app/api/admin.py index 5dfd3fc..2aa500e 100644 --- a/app/api/admin.py +++ b/app/api/admin.py @@ -12,6 +12,7 @@ from app.admin_llm import ( 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 @@ -207,18 +208,24 @@ class BackendSwitch(BaseModel): @router.post("/admin/llm/backend", dependencies=[Depends(require_admin)]) -async def post_llm_backend(payload: BackendSwitch): +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: - return await switch_backend(payload.backend, payload.model) + 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(): +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() @@ -317,17 +324,19 @@ async def get_runtime_config(): @router.put("/admin/config/{key}", dependencies=[Depends(require_admin)]) -async def set_runtime_config(key: str, body: ConfigValue): +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}") - get_store().set_config_override(key, body.value.strip()) + value = body.value.strip() + get_store().set_config_override(key, value) invalidate_cache() - return {"key": key, "value": body.value.strip()} + 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): +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}") @@ -335,6 +344,7 @@ async def delete_runtime_config(key: str): 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} 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/tests/test_audit.py b/tests/test_audit.py new file mode 100644 index 0000000..06abb0a --- /dev/null +++ b/tests/test_audit.py @@ -0,0 +1,44 @@ +"""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) From ab9c4f4938fa9bfc6f57374985282d7d78c8e401 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Sat, 20 Jun 2026 20:08:10 +0200 Subject: [PATCH 100/113] =?UTF-8?q?feat(stt):=20Ger=C3=A4te-STT=20auf=20Mo?= =?UTF-8?q?bilger=C3=A4ten=20(Web=20Speech=20API)=20=E2=80=94=20getrennt,?= =?UTF-8?q?=20Datenschutz-Linie=20C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Geräte-STT erkennt Sprache lokal und sendet nur Text über den Text-Turn; spart Audio-Upload + Server-STT. Getrennter Schalter (STT ▾) unabhängig vom TTS. - Linie C: nur bei nachweislich lokaler Erkennung (iOS / Chrome on-device); Cloud (z. B. Chrome-Desktop -> Google) nur mit Admin-Flag ALLOW_CLOUD_STT. -> config.allow_cloud_stt, RUNTIME_SETTABLE, /api/me, Admin-Toggle. - Fix-only (SpeechRecognition braucht Sprach-Hint); Flex -> Server-STT-Fallback. Kein/instabiles SpeechRecognition (z. B. Firefox) -> Server-STT. Live-Interim im Eingabefeld. Terminal/Desktop/Laptop unverändert serverseitig (Option nur sichtbar, wenn das Gerät lokale Erkennung bietet). - Tests: /api/me-Flag + runtime-setzbar (169 grün). Doku §5.1.2 + §6.3.1. Co-Authored-By: Claude Opus 4.8 --- BEDIENUNGSANLEITUNG.md | 24 ++++++++++- app/api/me.py | 2 + app/config.py | 4 ++ app/runtime_config.py | 1 + app/web/app.js | 98 +++++++++++++++++++++++++++++++++++++++++- app/web/index.html | 9 +++- tests/test_audit.py | 16 +++++++ 7 files changed, 150 insertions(+), 4 deletions(-) diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index c78be73..de7ee82 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -792,7 +792,8 @@ https://va.beispiel.de/ ← remote über Reverse-Proxy (alles, inkl. Mikr | **Texteingabe + Senden** | Text tippen, dann Enter oder „Senden" | | **🎤 Mikrofon-Button** | **Idle (grün 🎤):** Tippen → Aufnahme startet · **Aufnahme (rot pulsierend 🎤):** Tippen → Aufnahme stoppt und wird gesendet · **KI antwortet (amber ⏹):** Tippen → Antwort sofort unterbrechen (Barge-in) | | **Sprache ▾** | Antwortsprache wählen (→ § 6.6): `🔄 Flex` = folgt automatisch der gesprochenen Sprache · feste Sprache (🇩🇪/🇬🇧/…) = Eingabe wird in diese Sprache übersetzt. Die vorlesende Stimme folgt der Auswahl. | -| **Qualität ▾** | Vorlese-Quelle wählen: `📱 Gerät` = das Handy/der Browser liest selbst vor (Web Speech API, kein Server-Audio → spart Daten/Kosten) · `Schnell` = piper (lokal) · `Hoch` = chatterbox (neuronal) · `Cloud` = openrouter | +| **Qualität ▾** (Ausgabe/TTS) | Vorlese-Quelle: `📱 Gerät` = das Handy/der Browser liest selbst vor (Web Speech API, kein Server-Audio → spart Daten/Kosten) · `Schnell` = piper (lokal) · `Hoch` = chatterbox (neuronal) · `Cloud` = openrouter | +| **STT ▾** (Eingabe) | *Nur sichtbar auf Geräten mit lokaler Spracherkennung* (iOS/Android): `🎙 Gerät` = Spracherkennung im Gerät (nur Text wird gesendet) · `☁ Server` = Audio-Upload + Whisper (wie am PC/Laptop). Getrennt von der Ausgabe wählbar (→ § 6.3.1). | | **☀️ / 🌙** | Tag-/Nacht-Modus; folgt sonst automatisch dem Betriebssystem | | **⚙️** (Admin) | Öffnet das Admin-Panel — nur für Admin-Nutzer sichtbar (→ § 7.5) | | **Angemeldet als …** | SSO-Identität; „Gast" wenn AUTH deaktiviert oder kein SSO-Cookie | @@ -1012,6 +1013,27 @@ FASTER_WHISPER_DEVICE=cuda FASTER_WHISPER_COMPUTE_TYPE=float16 ``` +#### 6.3.1 Geräte-STT (Web Speech API) — „🎙 Gerät" + +Auf **Mobilgeräten** (iOS/Android) kann die Spracherkennung **im Gerät** laufen: Das +Handy wandelt Sprache in Text um und sendet nur den **Text** an den Server — kein +Audio-Upload, keine Server-STT-Last. Gegenstück zum Geräte-TTS (§ 6.5.0), aber +**getrennt** wählbar (STT-Dropdown in der Kopfzeile). + +- **Erscheint nur**, wenn das Gerät lokale Spracherkennung bietet. **Terminal, Desktop- + Browser und Laptop** zeigen die Option nicht — dort läuft STT wie bisher über den + Server (Whisper). +- **Datenschutz (Linie C):** Standardmäßig nur bei **nachweislich lokaler** Erkennung + (iOS-Safari; Chrome mit On-Device-Modus). **Chrome am Desktop** würde das Audio zur + Erkennung an Google schicken — das ist nur erlaubt, wenn ein Admin + `ALLOW_CLOUD_STT=true` setzt (Admin → Einstellungen → „Geräte-STT (mobil)"). +- **Nur Fix-Sprache:** Geräte-STT braucht einen Sprach-Hint → es nutzt die im + Sprach-Dropdown gewählte feste Sprache. Im **Flex-Modus** fällt es automatisch auf + Server-STT (Whisper-Auto-Erkennung) zurück. +- **Fallback:** Ohne Unterstützung (z. B. Firefox) oder bei Erkennungsfehlern wird der + Server-STT-Pfad genutzt. Während der Erkennung erscheint der Text **live** im + Eingabefeld; erneutes Tippen aufs Mikrofon beendet und sendet. + --- ### 6.4 LLM-Einstellungen (Sprachmodell, lokal) diff --git a/app/api/me.py b/app/api/me.py index f270820..de0c35a 100644 --- a/app/api/me.py +++ b/app/api/me.py @@ -3,6 +3,7 @@ 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.runtime_config import runtime_settings from app.schemas import UserPrefs, MemoryCreate, MemoryOut from app.store import User @@ -18,6 +19,7 @@ async def get_me(user: User = Depends(require_user)): "is_admin": user.is_admin or is_admin_user(user), "prefs": user.prefs, "sso_logout_url": settings.sso_logout_url, + "allow_cloud_stt": bool(runtime_settings.allow_cloud_stt), } diff --git a/app/config.py b/app/config.py index 14ce23f..56397f7 100644 --- a/app/config.py +++ b/app/config.py @@ -135,6 +135,10 @@ class Settings(BaseSettings): 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 + # Geräte-STT (Web Speech API) nur bei nachweislich lokaler Erkennung (iOS, Chrome + # on-device). True erlaubt es auch bei Cloud-Erkennung (z. B. Chrome-Desktop -> + # Audio geht zu Google). Default False = strenger Datenschutz (Linie C). + allow_cloud_stt: bool = False # --- Lokales TTS (piper) ------------------------------------------------- piper_bin: str = "piper" # Pfad/Name des piper-Binaries piper_voices_dir: str = str(Path.home() / ".local" / "share" / "piper" / "voices") diff --git a/app/runtime_config.py b/app/runtime_config.py index 231e413..cb809bc 100644 --- a/app/runtime_config.py +++ b/app/runtime_config.py @@ -34,6 +34,7 @@ RUNTIME_SETTABLE: dict[str, tuple[str, str, str]] = { "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"), + "allow_cloud_stt": ("Geräte-STT via Cloud erlauben", "bool", "true = auch Cloud-Erkennung (z.B. Chrome-Desktop -> Audio zu Google); false = nur on-device"), } _TTL = 30.0 diff --git a/app/web/app.js b/app/web/app.js index 56dbeb3..135587a 100644 --- a/app/web/app.js +++ b/app/web/app.js @@ -9,6 +9,7 @@ const formEl = $("#prompt-form"); const micBtn = $("#mic"); const ttsSel = $("#tts"); const langSel = $("#lang-sel"); +const sttSel = $("#stt-sel"); // "Flex" ist im Sprachmenü nur ein weiterer Wert: keine feste Sprache, sondern // automatische Erkennung. Eine konkrete Sprache = Fix-Modus (Eingabe wird übersetzt). @@ -103,6 +104,90 @@ function unlockTTS() { try { speechSynthesis.speak(new SpeechSynthesisUtterance("")); _ttsUnlocked = true; } catch (e) {} } +// ---------- Geräte-STT (Web Speech API SpeechRecognition) ---------- +// Gegenstück zum Geräte-TTS: das Gerät erkennt Sprache lokal und schickt nur Text. +// Linie C: standardmäßig nur bei nachweislich lokaler Erkennung (iOS, Chrome on-device); +// Cloud-Erkennung (z. B. Chrome-Desktop -> Audio zu Google) nur, wenn der Admin +// allow_cloud_stt aktiviert hat. Sonst greift weiter der Server-STT (Whisper). +const SR = window.SpeechRecognition || window.webkitSpeechRecognition; +const STT_SUPPORTED = !!SR; +const STT_KEY = "va-stt"; // geräte-lokal: "device" wenn Geräte-STT gewählt +let meAllowCloudStt = false; // aus /api/me (Admin-Flag) + +function isIOS() { + const ua = navigator.userAgent || ""; + return /iPhone|iPad|iPod/i.test(ua) || + (navigator.maxTouchPoints > 1 && /Macintosh/.test(ua)); +} +// Heuristik: iOS erkennt on-device. Chrome mit processLocally-Unterstützung gilt +// ebenfalls als lokal. Sonst (Cloud) nur erlaubt, wenn der Admin es freigibt. +function sttIsLocal() { + if (isIOS()) return true; + try { return SR && "processLocally" in SR.prototype; } catch (e) { return false; } +} +function sttDeviceAllowed() { + return STT_SUPPORTED && (sttIsLocal() || meAllowCloudStt); +} +function isSttDeviceMode() { return !!sttSel && sttSel.value === "device"; } + +// Geräte-STT für DIESEN Turn nutzen? Nur Fix-Sprache (SpeechRecognition braucht einen +// Sprach-Hint); im Flex-Modus auf Server-STT (Whisper-Auto-Erkennung) zurückfallen. +function useDeviceStt() { + return isSttDeviceMode() && sttDeviceAllowed() && (langSel && langSel.value !== "flex"); +} + +// Stellt das STT-Dropdown beim Laden ein: Option ausblenden wenn nicht erlaubt; +// sonst lokale Wahl bzw. Mobil-Default. +function applySttChoice() { + if (!sttSel) return; + if (!sttDeviceAllowed()) { + sttSel.classList.add("hidden"); // kein Geräte-STT -> Server wie bisher + sttSel.value = "server"; + return; + } + sttSel.classList.remove("hidden"); + const local = localStorage.getItem(STT_KEY); + if (local === "device") sttSel.value = "device"; + else if (local === "server") sttSel.value = "server"; + else if (isMobile()) { sttSel.value = "device"; localStorage.setItem(STT_KEY, "device"); } +} + +let recognition = null; +function startDeviceStt() { + const rec = new SR(); + rec.lang = LANG_BCP47[langSel.value] || "de-DE"; + rec.interimResults = true; + rec.continuous = false; + try { if (sttIsLocal() && "processLocally" in rec) rec.processLocally = true; } catch (e) {} + recognition = rec; + let finalText = ""; + setMicRecording(); + statusEl.textContent = "höre zu … (zum Stoppen erneut tippen)"; + rec.onresult = (e) => { + let interim = ""; + for (let i = e.resultIndex; i < e.results.length; i++) { + const t = e.results[i][0].transcript; + if (e.results[i].isFinal) finalText += t; else interim += t; + } + promptEl.value = (finalText + interim).trim(); // Live-Feedback im Eingabefeld + }; + rec.onerror = (e) => { + recognition = null; + setMicIdle(); + statusEl.textContent = e.error === "no-speech" ? "nichts gehört — bitte erneut" + : "Spracherkennung fehlgeschlagen (" + (e.error || "?") + ")"; + }; + rec.onend = () => { + recognition = null; + setMicIdle(); + const text = (promptEl.value || "").trim(); + promptEl.value = ""; + if (text) sendText(text); // -> vorhandener Text-Turn (inkl. Geräte-/Server-TTS) + }; + try { rec.start(); } catch (e) { recognition = null; setMicIdle(); statusEl.textContent = "STT-Start fehlgeschlagen"; } +} +function stopDeviceStt() { if (recognition) { try { recognition.stop(); } catch (e) {} } } + const PLAYBACK_KEY = "va-playback"; // geräte-lokal: "device" wenn Geräte-TTS gewählt // Setzt das Qualität-Dropdown beim Laden. Vorrang: @@ -167,6 +252,8 @@ function loadMe() { : (prefs.language || "flex"); } applyPlaybackChoice(prefs.tts_provider); + meAllowCloudStt = !!me.allow_cloud_stt; + applySttChoice(); return me; } catch (e) { $("#identity").textContent = "Verbindung fehlgeschlagen"; @@ -525,10 +612,14 @@ micBtn.addEventListener("click", () => { stopAudio(); statusEl.textContent = "Unterbrochen"; // busy + Button-Reset erfolgen sobald die WS schliesst (-> runTurn resolve -> sendVoice/sendText) + } else if (recognition) { + stopDeviceStt(); // Geräte-STT läuft -> stoppen (onend sendet den Text) } else if (mediaRecorder && mediaRecorder.state === "recording") { stopRecording(); + } else if (useDeviceStt()) { + startDeviceStt(); // lokale Spracherkennung (nur Fix + erlaubt) } else { - startRecording(); + startRecording(); // Server-STT (Upload) — wie bisher, auch am Desktop } }); @@ -589,6 +680,8 @@ if (ttsSel) { } }); } +// STT-Wahl ist geräte-lokal (nicht server-seitig) -> nur in localStorage. +if (sttSel) sttSel.addEventListener("change", () => localStorage.setItem(STT_KEY, sttSel.value)); loadMe(); @@ -1393,6 +1486,8 @@ const FIELD_META = { labels: { "true":"Aktiviert","false":"Deaktiviert" } }, memory_extraction_every_n_turns: { ui: "number", min: 1, max: 20 }, daily_request_limit: { ui: "number", min: 0 }, + allow_cloud_stt: { ui: "select", opts: ["true","false"], + labels: { "true":"Erlaubt (auch Cloud-Erkennung)","false":"Nur on-device (Datenschutz)" } }, }; const FIELD_GROUPS = [ @@ -1403,6 +1498,7 @@ const FIELD_GROUPS = [ { 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"] }, + { label: "Geräte-STT (mobil)", keys: ["allow_cloud_stt"] }, ]; let _settingsData = []; diff --git a/app/web/index.html b/app/web/index.html index 6f0601c..4224c85 100644 --- a/app/web/index.html +++ b/app/web/index.html @@ -214,13 +214,18 @@ - + - - - + -
    - lade … - + + + + + +
    @@ -257,6 +300,6 @@
    - + diff --git a/tests/test_audit.py b/tests/test_audit.py index 972f85d..b5415e8 100644 --- a/tests/test_audit.py +++ b/tests/test_audit.py @@ -44,17 +44,3 @@ def test_backend_switch_rejection_is_audited(caplog): assert "action=llm_backend_switch_rejected" in "\n".join(caplog.messages) -def test_me_exposes_allow_cloud_stt(): - # /api/me liefert das Datenschutz-Flag (Client-Gate für Geräte-STT). - d = client.get("/api/me").json() - assert "allow_cloud_stt" in d - assert isinstance(d["allow_cloud_stt"], bool) - - -def test_allow_cloud_stt_runtime_settable(): - from app.runtime_config import RUNTIME_SETTABLE - assert "allow_cloud_stt" in RUNTIME_SETTABLE - r = client.put("/api/admin/config/allow_cloud_stt", json={"value": "true"}, headers=ADM_HDR) - assert r.status_code == 200 - assert client.get("/api/me").json()["allow_cloud_stt"] is True - client.delete("/api/admin/config/allow_cloud_stt", headers=ADM_HDR) From 35fd3a5d157627f4352fcec6f93883426e4ea823 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Sat, 20 Jun 2026 23:04:35 +0200 Subject: [PATCH 104/113] =?UTF-8?q?fix(ui):=20Senden=20als=20runder=20Icon?= =?UTF-8?q?-Button=20(Fu=C3=9Fzeile=20=C3=BCberlaufsicher)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Senden ➤ als runder Icon-Button (gleich groß wie Mikrofon) statt Text -> passt auch im Querformat/schmalen Geräten, kein Abschneiden mehr. - Eingabefeld min-w-0, Buttons shrink-0 -> Reihe kann nicht überlaufen. Co-Authored-By: Claude Opus 4.8 --- app/web/index.html | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/app/web/index.html b/app/web/index.html index 44634b0..da699ae 100644 --- a/app/web/index.html +++ b/app/web/index.html @@ -290,16 +290,20 @@
    - - + class="flex-1 min-w-0 rounded-xl border border-slate-300 dark:border-slate-600 bg-transparent px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500" /> +
    - + From 487c8aa3c143032e733cfd3b41277c5dd2d314ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Sat, 20 Jun 2026 23:11:57 +0200 Subject: [PATCH 105/113] =?UTF-8?q?feat(admin):=20Redesign=20=E2=80=94=205?= =?UTF-8?q?=20Bereiche=20+=20=C3=9Cbersicht-Dashboard=20+=20Sub-Navigation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Von 8 gemischten Tabs auf 5 logische Bereiche verdichtet: - 📊 Übersicht (neu, Start): Kennzahlen (Nutzer, Anfragen, Notfälle) + LLM-Backend/GPU + Schnell-Sprünge. - 👥 Nutzer: Sub-Tabs Verwalten + Gespräche. - 🚨 Notfälle. - 🖥 System: Sub-Tabs Status (inkl. LLM-Steuerung) + Metriken + Log. - ⚙ Konfiguration: Sub-Tabs Einstellungen + Wörterbuch. - Sub-Navigation (Segmented-Buttons) für Bereiche mit mehreren Ansichten. - Bestehende Content-Blöcke + Loader unverändert wiederverwendet (nur neu gebündelt) -> geringes Risiko. loadOverview neu (vorhandene Endpunkte). - Doku §7.5 auf 5-Bereiche-Struktur aktualisiert; Wörterbuch-Hinweis (alle 8 Sprachen, Zeile-anklicken-bearbeiten) korrigiert. 167 grün, JS-Syntax ok. Co-Authored-By: Claude Opus 4.8 --- BEDIENUNGSANLEITUNG.md | 43 ++++++++----- app/web/app.js | 136 ++++++++++++++++++++++++++++++++++------- app/web/index.html | 32 +++++++--- 3 files changed, 165 insertions(+), 46 deletions(-) diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index 4c7630b..1c5be23 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -1761,15 +1761,29 @@ Vollständige Anleitung: [deploy/README.md](deploy/README.md). > 🔧 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. Es enthält sieben Tabs: +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: -#### Nutzer +| 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. -#### Gespräche +#### Nutzer › Gespräche Nutzerliste links → Session auswählen → Gesprächs-Transkript als Chat-Bubbles ansehen. @@ -1778,7 +1792,7 @@ Nutzerliste links → Session auswählen → Gesprächs-Transkript als Chat-Bubb Tabellarische Übersicht aller protokollierten Notfall-Ereignisse (Zeitpunkt, Nutzer, Kategorie, Textausschnitt). -#### Status +#### 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 @@ -1795,27 +1809,24 @@ wechseln"** und **„Gateway neu starten"**. Am Ende: **⬇ voice-assistant.db herunterladen** — lädt die SQLite-Datenbank als Backup. -#### Metriken +#### System › Metriken Nutzungsstatistik je Nutzer (Anfragen, Einheiten, letzte Aktivität) als Tabelle und CSS-Balkendiagramm. -#### Wörterbuch +#### Konfiguration › Wörterbuch Aussprache-Lexikon direkt im Browser bearbeiten — kein Kommandozeilen-Skript nötig: -1. Sprache wählen (Dropdown: **Deutsch** / **Englisch**). +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. Vorhandene Einträge: Maus drüber → **✕** erscheint → löschen. -4. Neuer Eintrag: Schlüssel + Ersetzung eingeben → **+ Hinzufügen**. - Die Änderung greift sofort (Server-Cache wird automatisch geleert). +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. -> **Andere Sprachen (fr, es, it, nl, ru, zh):** Das Wörterbuch-Tab zeigt aktuell nur -> Deutsch und Englisch. Für andere Sprachen die YAML-Datei direkt editieren -> (`config/pronunciation..yaml`) oder die REST-API nutzen — beides ohne Neustart -> möglich (API) bzw. mit Neustart (YAML direkt). Vollständige Anleitung → § 6.5.4. - -#### Log +#### System › Log Zeigt den systemd-Journal-Log des `voice-assistant.service` live im Browser: diff --git a/app/web/app.js b/app/web/app.js index 999fa3d..483d548 100644 --- a/app/web/app.js +++ b/app/web/app.js @@ -673,43 +673,137 @@ async function adminFetch(url, method = "GET", body = null) { // ---------- Panel öffnen / schließen ---------- function openAdminPanel() { $("#admin-panel").classList.remove("hidden"); - switchAdminTab("users"); + switchAdminTab("overview"); } $("#admin-close").addEventListener("click", () => $("#admin-panel").classList.add("hidden")); -// ---------- Tab-Umschalter ---------- -function switchAdminTab(name) { +// ---------- 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")); - $("#admin-tab-" + name).classList.remove("hidden"); - - document.querySelectorAll(".admin-tab").forEach((btn) => { - const active = btn.dataset.tab === name; - 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); + 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(); +} - if (name === "users") loadAdminUsers(); - if (name === "chat") loadChatUsers(); - if (name === "emergency") loadEmergencyEvents(); - if (name === "status") loadStatus(); - if (name === "metrics") loadMetrics(); - if (name === "words") loadWoerterbuch(); - if (name === "log") { /* User muss manuell verbinden */ } - if (name === "settings") loadSettings(); +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 stat = (label, value, sub) => ` +
    +

    ${escHtml(label)}

    +

    ${escHtml(value)}

    + ${sub ? `

    ${escHtml(sub)}

    ` : ""} +
    `; + + const jump = (to, label) => + ``; + + c.innerHTML = ` +
    + ${stat("Nutzer", userCount)} + ${stat("Anfragen gesamt", reqTotal)} + ${stat("Notfälle", emCount, (typeof emCount === "number" && emCount > 0) ? "protokolliert" : "")} + ${stat("LLM-Backend", backend, llm ? llm.model : "")} +
    +
    +

    GPU-Auslastung

    +

    ${escHtml(gpu)}

    +
    +
    + ${jump("users", "→ Nutzer")} ${jump("system", "→ System")} ${jump("emergency", "→ Notfälle")} +
    `; + c.querySelectorAll(".ov-jump").forEach((b) => + b.addEventListener("click", () => switchAdminTab(b.dataset.jump))); +} + // ════════════════════════════════════════ // TAB: NUTZER // ════════════════════════════════════════ diff --git a/app/web/index.html b/app/web/index.html index da699ae..2916416 100644 --- a/app/web/index.html +++ b/app/web/index.html @@ -38,21 +38,35 @@ class="h-9 w-9 grid place-items-center rounded-lg text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-700 text-xl leading-none transition-colors">✕ - + + + +
    + + +
    @@ -304,6 +318,6 @@
    - + From 7bac2952c54f5b9a5bd5ed9ba2e1c7b1368f6a7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Sun, 21 Jun 2026 00:32:52 +0200 Subject: [PATCH 106/113] =?UTF-8?q?polish(admin):=20=C3=9Cbersicht-Kacheln?= =?UTF-8?q?=20klickbar=20+=20Notfall-Signal,=20Notfall-Text=20umbricht?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Übersicht: Kacheln (Nutzer/Anfragen/Notfälle/LLM) springen per Klick in den Bereich; Notfall-Kachel wird rot mit "ansehen", wenn Ereignisse vorliegen, sonst "keine". - Notfall-Tabelle: Textausschnitt umbricht statt abzuschneiden -> auf Mobil lesbar. Co-Authored-By: Claude Opus 4.8 --- app/web/app.js | 26 ++++++++++++++++---------- app/web/index.html | 2 +- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/app/web/app.js b/app/web/app.js index 483d548..1fbd409 100644 --- a/app/web/app.js +++ b/app/web/app.js @@ -776,22 +776,28 @@ async function loadOverview() { const gpu = (llm && llm.gpus && llm.gpus.length) ? llm.gpus.map((g) => `GPU ${g.index}: ${g.percent}%`).join(" · ") : "—"; - const stat = (label, value, sub) => ` -
    + 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)}

    +

    ${escHtml(value)}

    ${sub ? `

    ${escHtml(sub)}

    ` : ""} -
    `; + `; const jump = (to, label) => ``; c.innerHTML = `
    - ${stat("Nutzer", userCount)} - ${stat("Anfragen gesamt", reqTotal)} - ${stat("Notfälle", emCount, (typeof emCount === "number" && emCount > 0) ? "protokolliert" : "")} - ${stat("LLM-Backend", backend, llm ? llm.model : "")} + ${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

    @@ -800,7 +806,7 @@ async function loadOverview() {
    ${jump("users", "→ Nutzer")} ${jump("system", "→ System")} ${jump("emergency", "→ Notfälle")}
    `; - c.querySelectorAll(".ov-jump").forEach((b) => + c.querySelectorAll(".ov-jump, .ov-stat[data-jump]").forEach((b) => b.addEventListener("click", () => switchAdminTab(b.dataset.jump))); } @@ -1086,7 +1092,7 @@ async function loadEmergencyEvents() { ${escHtml(ev.category)} - ${escHtml(ev.snippet)} + ${escHtml(ev.snippet)} `).join("")} diff --git a/app/web/index.html b/app/web/index.html index 2916416..18f2b28 100644 --- a/app/web/index.html +++ b/app/web/index.html @@ -318,6 +318,6 @@
    - + From d39fc40e94835ac626581acd834d76ffcbb5b417 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Sun, 21 Jun 2026 01:02:03 +0200 Subject: [PATCH 107/113] =?UTF-8?q?fix(tts):=20Ger=C3=A4te-TTS=20f=C3=A4ll?= =?UTF-8?q?t=20auf=20Server-Audio=20zur=C3=BCck,=20wenn=20keine=20Stimmen?= =?UTF-8?q?=20vorhanden?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Linux-Desktop-Browser haben oft keine speechSynthesis-Stimmen -> "Im Gerät" blieb stumm (auto + Replay). deviceVoicesReady() prüft getVoices(); fehlen Stimmen, holt serverSpeakFallback() Audio via /api/speak (Server-Default, nie "device") und spielt es. Co-Authored-By: Claude Opus 4.8 --- app/web/app.js | 38 ++++++++++++++++++++++++++++++++++---- app/web/index.html | 2 +- 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/app/web/app.js b/app/web/app.js index 1fbd409..95c7cd3 100644 --- a/app/web/app.js +++ b/app/web/app.js @@ -79,16 +79,46 @@ function _makeUtterance(text, lang) { 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 (!TTS_SUPPORTED || !text) return; - speechSynthesis.cancel(); - speechSynthesis.speak(_makeUtterance(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 (!TTS_SUPPORTED || !text) { setReplayPlaying(btn, false); return; } + 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); diff --git a/app/web/index.html b/app/web/index.html index 18f2b28..002d30f 100644 --- a/app/web/index.html +++ b/app/web/index.html @@ -318,6 +318,6 @@
    - + From 7d30fe9e7c818978ac9803b7077ec79e7d53d774 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Sun, 21 Jun 2026 01:10:51 +0200 Subject: [PATCH 108/113] =?UTF-8?q?ui(tts):=20Ton-Presets=20=E2=80=94=20Sc?= =?UTF-8?q?hnell=20(piper),=20Hohe=20Qualit=C3=A4t=20(chatterbox),=20Cloud?= =?UTF-8?q?=20(openrouter)=20zur=C3=BCck?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Vorlesen-Presets wieder nach Tempo/Qualität benannt: 📱 Im Gerät · ⚡ Schnell · ✨ Hohe Qualität · ☁ Cloud. Cloud (OpenRouter) wieder als sichtbarer Knopf. - syncPresetUI: exakte Markierung (openrouter hat eigenen Knopf, kein Sonderfall mehr). - Doku §5.1.2 angepasst. Co-Authored-By: Claude Opus 4.8 --- BEDIENUNGSANLEITUNG.md | 2 +- app/web/app.js | 3 +-- app/web/index.html | 19 +++++++++++++------ 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index 1c5be23..de4ae5b 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -800,7 +800,7 @@ https://va.beispiel.de/ ← remote über Reverse-Proxy (alles, inkl. Mikr | Element | Funktion | |---------|----------| -| **Vorlesen** | Wie wird die Antwort vorgelesen? `📱 Im Gerät` = das Handy liest selbst vor (kein Server-Audio → spart Daten) · `☁ Server` = piper (lokal, zuverlässig) · `✨ Beste Qualität` = chatterbox (natürliche Stimme). „Im Gerät" erscheint nur, wo der Browser es unterstützt. | +| **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 | | **Konto** | „Angemeldet als …", **⚙ Admin** (nur Admins → § 7.5), **Abmelden** | diff --git a/app/web/app.js b/app/web/app.js index 95c7cd3..d3d6ae5 100644 --- a/app/web/app.js +++ b/app/web/app.js @@ -164,8 +164,7 @@ function applyPlaybackChoice(serverProvider) { function syncPresetUI() { const val = ttsSel ? ttsSel.value : ""; document.querySelectorAll(".tts-preset").forEach((btn) => { - const active = btn.dataset.tts === val || - (val === "openrouter" && btn.dataset.tts === "chatterbox"); // Cloud -> "Beste Qualität" + 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); diff --git a/app/web/index.html b/app/web/index.html index 002d30f..7750b84 100644 --- a/app/web/index.html +++ b/app/web/index.html @@ -260,16 +260,23 @@ +
    @@ -318,6 +325,6 @@
    - + From 53ec2d7ec701ec026269dc897c007ecb65a2967a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Sun, 21 Jun 2026 01:28:21 +0200 Subject: [PATCH 109/113] =?UTF-8?q?ui:=20"Angemeldet=20als=20=E2=80=A6"=20?= =?UTF-8?q?+=20"Abmelden"=20zur=C3=BCck=20in=20eine=20Leiste=20unter=20die?= =?UTF-8?q?=20Kopfzeile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Identitätsleiste (Nutzer links, Abmelden-Link rechts) wieder direkt unter dem Header statt im ⋮-Menü. Logout-Link kommt wie bisher aus SSO_LOGOUT_URL. - Admin-Button bleibt im ⋮-Menü (als "⚙ Admin-Bereich"); IDs unverändert. - Doku §5.1.2 angepasst. Co-Authored-By: Claude Opus 4.8 --- BEDIENUNGSANLEITUNG.md | 19 +++++++++++-------- app/web/index.html | 23 ++++++++++++----------- 2 files changed, 23 insertions(+), 19 deletions(-) diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index de4ae5b..f03cbb0 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -776,16 +776,16 @@ https://va.beispiel.de/ ← remote über Reverse-Proxy (alles, inkl. Mikr ┌─────────────────────────────────────────────────────────┐ │ 👄 Voice Assistant ............... [🇩🇪 ▾] [ ⋮ ] │ ├─────────────────────────────────────────────────────────┤ ┌─ Menü (⋮) ──────────────┐ -│ │ │ VORLESEN │ -│ Nachrichtenverlauf │ │ ▣ 📱 Im Gerät │ -│ (eigene Nachrichten: blaue Blase rechts) │ │ ▢ ☁ Server │ -│ (Assistent: graue Blase links) │ │ ▢ ✨ Beste Qualität │ +│ 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 │ -└─────────────────────────────┴───────────────────────────┘ │ ────────────────────── │ - Statuszeile: „denkt …" / „verarbeite Sprache …" / leer │ Angemeldet · Admin · Ab │ - └──────────────────────────┘ +└─────────────────────────────┴───────────────────────────┘ │ ⚙ Admin-Bereich │ + Statuszeile: „denkt …" / „verarbeite Sprache …" / leer └──────────────────────────┘ ``` **Kopfzeile (immer schlank):** @@ -796,6 +796,9 @@ https://va.beispiel.de/ ← remote über Reverse-Proxy (alles, inkl. Mikr | **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 | @@ -803,7 +806,7 @@ https://va.beispiel.de/ ← remote über Reverse-Proxy (alles, inkl. Mikr | **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 | -| **Konto** | „Angemeldet als …", **⚙ Admin** (nur Admins → § 7.5), **Abmelden** | +| **⚙ Admin-Bereich** | Öffnet das Admin-Panel — nur für Admin-Nutzer sichtbar (→ § 7.5) | **Unten:** diff --git a/app/web/index.html b/app/web/index.html index 7750b84..f0c5a89 100644 --- a/app/web/index.html +++ b/app/web/index.html @@ -233,6 +233,12 @@ class="h-9 w-9 grid place-items-center rounded-lg border border-slate-300 dark:border-slate-600 hover:bg-slate-100 dark:hover:bg-slate-700 text-xl leading-none transition-colors">⋮ + +
    + lade … + +
    +