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