Compare commits

..
147 changed files with 22 additions and 15475 deletions

View file

@ -1,112 +0,0 @@
# Port bei Bedarf anpassen
APP_ENV=dev
HOST=0.0.0.0
PORT=8080
LOG_LEVEL=info
# Secret nur ueber die Umgebung setzen (nicht hier eintragen), z. B. export in ~/.bashrc
OPENROUTER_API_KEY=
# Satzweises Vorlesen (Audio-Streaming) als Default fuer WebSocket-Turns.
# false = Antwort erst komplett synthetisieren, dann abspielen.
AUDIO_STREAM_DEFAULT=true
# Text-Normalisierung vor dem TTS (Aussprache): auto|full|light|off.
# auto = piper bekommt 'full' (Ordinalia/Einheiten/Abk./Lexikon), Cloud-TTS 'light'
# (nur Glaettung; Cloud spricht Zahlen/Abkuerzungen selbst gut). Lexikon:
# config/pronunciation.de.yaml (erweitert die eingebauten Defaults).
TTS_NORMALIZE_LEVEL=auto
# --- Authentifizierung -----------------------------------------------------
# AUTH_ENABLED=true (Standard) schuetzt chat/speak/transcribe/sessions per Bearer-Token.
# Fuer lokale Entwicklung/Tests auf false setzen (dann gilt ein anonymer Nutzer).
AUTH_ENABLED=true
# Schluessel fuer die Nutzerverwaltung (POST /api/admin/users). Nur ueber die Umgebung.
ADMIN_API_KEY=
# Forward-Auth via Reverse-Proxy/SSO (z. B. YunoHost). Nur fuer Remote-Betrieb -
# siehe deploy/README.md. Lokal leer lassen. Identitaet per Header ODER Cookie:
# TRUSTED_AUTH_HEADER=X-Remote-User # falls der Proxy einen Header setzt
# TRUSTED_AUTH_COOKIE=yunohost.portal # YunoHost: Username im JWT-Cookie
# TRUSTED_AUTH_COOKIE_CLAIM=user
# TRUSTED_AUTH_JWT_SECRET= # optional: HS256-Signatur pruefen
# TRUSTED_PROXY_IPS=192.168.0.10
# ADMIN_USERS=atoor,dieterschlueter,dschlueter
# SSO_LOGOUT_URL=https://linix.de/yunohost/sso/?action=logout
# --- Zentrale Konfiguration / Profile -------------------------------------
# Aktives Profil aus config/voice-assistant.toml waehlen: local-dev | hybrid | cloud
# (leer lassen = nur Defaults/ENV). Eigener Pfad via VA_CONFIG_FILE.
VA_PROFILE=
# VA_CONFIG_FILE=config/voice-assistant.toml
# Hinweis zur Praezedenz: ENV gewinnt ueber die TOML-Datei. Die DEFAULT_*_PROVIDER-
# Zeilen unten ueberschreiben daher ein gesetztes VA_PROFILE. Wer profilbasiert
# umschalten will, sollte sie auskommentiert lassen.
OPENROUTER_STT_MODEL=openai/whisper-large-v3
# Stimme haengt vom Modell ab (Gemini: Zephyr/Puck/Kore/...; OpenAI: alloy/echo/nova/...).
# Stimmenliste + Umstellen pro Aufruf: siehe BEDIENUNGSANLEITUNG ("Stimme des Cloud-TTS").
OPENROUTER_TTS_MODEL=openai/gpt-4o-mini-tts
OPENROUTER_TTS_VOICE=alloy
OPENROUTER_LLM_MODEL=openai/gpt-4.1-mini
DEFAULT_LANGUAGE=de
DEFAULT_INPUT_ENDPOINT=local-default
DEFAULT_OUTPUT_ENDPOINT=local-default
# DEFAULT_STT_PROVIDER=openrouter
# DEFAULT_LLM_PROVIDER=local-openai-compatible
# DEFAULT_TTS_PROVIDER=openrouter
# Lokaler llama.cpp-Server (zentrale, unzensierte KI). Start: scripts/llm-server/start-llm-server.sh
# LOCAL_LLM_MODEL muss dem --alias des Servers entsprechen (Default: va_llm).
LOCAL_LLM_BASE_URL=http://127.0.0.1:8001/v1
LOCAL_LLM_API_KEY=dummy
LOCAL_LLM_MODEL=va_llm
# Tempo-Hebel fuer den Sprach-Loop: Reasoning aus + knappe, vorlesbare Antworten.
# LOCAL_LLM_DISABLE_REASONING=true # Qwen3-Denkphase abschalten (deutlich schneller)
# LOCAL_LLM_MAX_TOKENS=0 # 0 = serverseitiges Limit (-n); z. B. 256 kappt lange Antworten
# LOCAL_LLM_TEMPERATURE=0.3
# LOCAL_LLM_SYSTEM_PROMPT=Du bist ein gesprochener Sprachassistent. Antworte kurz ...
# --- Lokales STT (faster-whisper; nur mit pip install -e .[local] ) ---------
FASTER_WHISPER_MODEL=base # tiny|base|small|medium|large-v3
FASTER_WHISPER_DEVICE=auto # auto|cpu|cuda
FASTER_WHISPER_COMPUTE_TYPE=default # default|int8|float16|int8_float16
# --- Lokales TTS (piper; Binary + Stimmmodell noetig) ------------------------
# Aktivieren z. B. mit DEFAULT_TTS_PROVIDER=piper (oder --tts-provider piper).
# Stimmen liegen als <name>.onnx (+ .onnx.json) im Voices-Verzeichnis.
# Installierte Stimmen anzeigen: ls ~/.local/share/piper/voices/*.onnx
# Weitere laden: von huggingface 'rhasspy/piper-voices' nach PIPER_VOICES_DIR kopieren.
PIPER_BIN=piper # Pfad/Name des piper-Binaries
PIPER_VOICES_DIR=~/.local/share/piper/voices # Verzeichnis der .onnx-Stimmen
PIPER_VOICE=de_DE-thorsten-high # Stimmmodell (ohne .onnx) oder voller Pfad
TTS_SAMPLE_RATE=24000 # Ziel-Sample-Rate (ffmpeg resampelt bei Bedarf)
# --- Chatterbox-TTS (hohe Qualitaet + Voice-Cloning; eigener Dienst) ---------
# Wählbar via tts_provider=chatterbox. Dienst: deploy/README.md. Langsamer als piper.
# CHATTERBOX_BASE_URL=http://127.0.0.1:9999
# CHATTERBOX_VOICE=/pfad/zu/referenz_stimme.wav # leer = Chatterbox-Standardstimme
# CHATTERBOX_LANG=de
# CHATTERBOX_SPEED=1.0
# --- Resilienz: Fallback-Ketten (kommaseparierte Provider-Namen) ------------
# Faellt der primaere Provider aus, uebernimmt der naechste.
# STT_FALLBACK=faster-whisper
# LLM_FALLBACK=local-openai-compatible
# TTS_FALLBACK=piper
# --- Automatische Erinnerungs-Extraktion -----------------------------------
# Das LLM destilliert nach je N Turns dauerhafte Fakten/Vorlieben aus dem Gespraech
# und legt sie als Nutzer-Erinnerungen ab (best-effort, nicht-blockierend).
# MEMORY_EXTRACTION_ENABLED=true
# MEMORY_EXTRACTION_EVERY_N_TURNS=3 # wie oft extrahiert wird
# MEMORY_EXTRACTION_MAX=50 # Obergrenze gespeicherter Erinnerungen
# MEMORY_EXTRACTION_PROVIDER= # leer = Default-LLM; sonst Registry-Name
# --- Betrieb: Kontingent & Notfall -----------------------------------------
DAILY_REQUEST_LIMIT=0 # Anfragen pro Nutzer/Tag (0 = unbegrenzt)
# EMERGENCY_WEBHOOK_URL=https://example.org/alert # optionale Eskalation
# LLM-Notfall-Klassifikation (Stufe 2): faengt im Hintergrund Notlagen, die die
# Stichwort-Heuristik verpasst -> keine zusaetzliche Antwortlatenz.
# EMERGENCY_LLM_ENABLED=true
# EMERGENCY_LLM_PROVIDER= # leer = Default-LLM; sonst Registry-Name
# EMERGENCY_LLM_MIN_CONFIDENCE=0.6 # Schwelle gegen Fehlalarme

34
.gitignore vendored
View file

@ -1,34 +0,0 @@
# Secrets / lokale Konfiguration
.env
# Lokale/instanzspezifische Konfiguration (nur die *.example.toml wird versioniert)
config/voice-assistant.toml
# Persistente Daten (SQLite-DB etc.)
data/
# Generierte Audio-Ausgaben (z. B. chat_client.py)
*.wav
# Ausnahme: native Referenz-Stimmen je Sprache (versioniert, Feature-Asset)
!config/voices/*.wav
# Python
__pycache__/
*.py[cod]
*.egg-info/
.venv/
venv/
.pytest_cache/
# Lokale Tool-/Editor-Konfiguration
.claude/
# Editor-/Backup-Reste
*.bak
*.patch
*.orig
# Dateien
3
TODO.md

File diff suppressed because it is too large Load diff

View file

@ -1,7 +0,0 @@
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}"]

View file

@ -1,554 +0,0 @@
# Voice Assistant, Ubuntu 24.04, Python, iPhone, Android-Handy:
Ich habe mit Python unter Linux einen Voice Assistenten Programmiert, mit dem sich der User unterhalten kann. Das System soll für Senioren stundenlangen Smalltalk mit KI ermöglichen (gegen Einsamkeit, multiuserfähig, mit Gedächtnis). Es funktioniert vereinfacht so: Das System wandelt Sprache in Text um (STT), gibt den Text an ein LLM-KI-Tool weiter. Das LLM formuliert eine adäquate Text-Antwort. Die wird angezeigt und in Sprache umgewandelt (TTS) und abgespielt.
Das funktioniert am Desktop und auf Handys sowie auf Tablets im Browser, am Server auch im Terminal. Aber die Umwandlung von Text to Speech (TTS) in sehr guter Sprachqualität (also nicht mit Piper oder chatterbox via Python, die auch implementiert sind) ist einerseits teuer (Google-TTS-Tool via OpenRouter kostet ca. 2 Cent pro Minute Sound) , andererseits kommt der Ton verzögert auf dem Handy an, weil im Vergleich zum reinen Text große Sounddateien übers Netz zum Handy geschickt werden müssen.
Nun stellt sich die Frage, ob es für IOS- und Android-Geräte die Möglichkeit gibt, den gesendeten Text erst auf dem Handy in Sprache mit professioneller Qualität umwandeln zu lassen. Das könnte unmittelbar passieren (Siri oder Ängliches) oder über Flat-Rate-Apps, die Cloud-Anbindung haben.
1. Geht das?
2. Wie teuer wäre das?
3. Wie schnell sind die Tools?
4. Könnte man eventuell auch sogar die TTS-Umwandlung auf dem Handy laufen lassen?
Ja, **das geht** sehr gut – und für Ihr Szenario ist „Text vom Server ans Handy schicken, TTS lokal auf dem Handy ausführen“ wahrscheinlich die beste Architektur, zumindest als Standardpfad für iPhone und viele Android-Geräte. iPhone und Android bringen eigene TTS-Engines mit, die direkt auf dem Gerät sprechen können; dadurch entfallen große Audiodateien über das Netz, die Latenz sinkt stark, und die laufenden Kosten für TTS können auf nahezu null fallen.[^1_1][^1_2]
## Machbarkeit
Auf dem iPhone kann eine App oder eine Weboberfläche Text an die systemeigene Sprachausgabe übergeben; Apple stellt dafür `AVSpeechSynthesizer` bereit, und Nutzer können in den Bedienungshilfen Stimmen auswählen, inklusive herunterladbarer „Enhanced Quality“-Stimmen. Diese erweiterten Stimmen sind lokal auf dem Gerät nutzbar und laut Apple oft 100 MB oder größer, was klar darauf hindeutet, dass die hochwertige Ausgabe zumindest nach dem Download lokal erfolgt.[^1_3][^1_1]
Auf Android gibt es ebenfalls eine eingebaute `TextToSpeech`-API mit `speak()`, also genau den Mechanismus, den man für eine lokale oder systemnahe Ausgabe braucht. In der Praxis hängt die Qualität dort stärker vom installierten TTS-Engine-Provider ab, etwa Google Speech Services oder Samsung, aber grundsätzlich ist On-Device-TTS auf Android ein Standard-Use-Case.[^1_4][^1_5][^1_2]
## Qualität und Geschwindigkeit
Für **Geschwindigkeit** ist lokales TTS auf dem Handy fast immer besser als serverseitig erzeugtes Audio, weil Sie nur Text übertragen statt MP3/Opus/WAV-Dateien. Das spart Netzlast und vermeidet den zusätzlichen Schritt „Audio generieren → speichern → übertragen → puffern → abspielen“; bei lokaler TTS beginnt die Wiedergabe oft quasi sofort nach Erhalt des Textes.[^1_2][^1_3]
Bei der **Qualität** ist iPhone derzeit meist der dankbarere Pfad, weil Apple hochwertige Systemstimmen und Enhanced-Varianten anbietet. Android kann ebenfalls gut klingen, aber dort ist die Qualität weniger homogen, weil sie vom Hersteller, der Android-Version und der installierten Engine abhängt.[^1_5][^1_1][^1_2]
## Kosten
Wenn Sie die System-TTS des Handys nutzen, sind die direkten TTS-Kosten typischerweise **null**, abgesehen von der einmaligen Datenmenge für den Download hochwertiger Stimmen und eventuell App-Entwicklung. Das ist für ein Senioren-Smalltalk-System wirtschaftlich sehr attraktiv, weil lange Gespräche nicht linear TTS-Kosten erzeugen.[^1_1][^1_2]
Zum Vergleich: Cloud-TTS kostet je nach Anbieter und Qualitätsstufe weiter spürbar Geld. Google Cloud verlangt zum Beispiel für Neural2-Stimmen 16 US-Dollar pro 1 Mio. Zeichen, für WaveNet 4 US-Dollar pro 1 Mio. Zeichen und für HD-Stimmen 30 US-Dollar pro 1 Mio. Zeichen; OpenAI listet für GPT-Realtime-Translate 0,034 US-Dollar pro Minute und für Realtime-Whisper 0,017 US-Dollar pro Minute, während die Realtime-Audioausgabe tokenbasiert abgerechnet wird. Ihr genannter Wert von etwa 2 Cent pro Minute liegt also durchaus in einer realistischen Größenordnung für hochwertige Cloud-Audio-Pipelines, besonders wenn noch ein Vermittler wie OpenRouter dazwischenliegt.[^1_6][^1_7]
## Sinnvolle Architektur
Für Ihr Projekt würde ich drei Betriebsmodi unterscheiden:
| Modus | Technik | Kosten | Latenz | Qualität | Eignung |
| :-- | :-- | --: | --: | :-- | :-- |
| Browser/App mit lokaler System-TTS | Text an iPhone/Android, dort sprechen | Nahe 0 [^1_1][^1_2] | Sehr gut [^1_2][^1_3] | iPhone meist gut bis sehr gut, Android geräteabhängig [^1_1][^1_2] | **Standardfall** |
| Cloud-TTS als Premium-Fallback | Server erzeugt Audio | Laufend nutzungsabhängig [^1_6][^1_7] | Mittel bis schwankend | Sehr hoch | Für Spezialstimmen |
| Komplett lokal auf Handy | Native App mit OS-TTS | Nahe 0 [^1_1][^1_2] | Sehr gut [^1_2][^1_3] | Gut, aber nicht völlig frei wählbar [^1_1][^1_2] | Beste mobile UX |
Der Knackpunkt ist: Im **Browser** ist das auf Android meist einfacher, weil Web Speech / systemnahe TTS dort oft besser nutzbar ist; auf iOS ist Browser-TTS funktional möglich, aber Apple ist restriktiver, und für eine robuste Produktlösung ist eine kleine native App oder ein WebView-Wrapper oft verlässlicher. Wenn Sie Multiuser, Gedächtnis und lange Sessions sauber betreiben wollen, wäre daher ein Server für STT/LLM/State plus mobile App für Playback eine sehr saubere Trennung.[^1_2][^1_3]
## Konkrete Antwort auf Ihre 4 Fragen
1. **Geht das?**
Ja. Sowohl iPhone als auch Android können empfangenen Text lokal in Sprache umwandeln; iPhone über Apples Sprachsynthese und Android über `TextToSpeech`.[^1_3][^1_1][^1_2]
2. **Wie teuer wäre das?**
Mit lokaler Geräte-TTS meist praktisch kostenlos im Betrieb. Cloud-TTS dagegen kostet je nach Stimme und Anbieter etwa von wenigen Dollar bis deutlich höheren Beträgen pro 1 Mio. Zeichen oder minutenbasiert bei Realtime-Angeboten.[^1_7][^1_6]
3. **Wie schnell sind die Tools?**
Lokale TTS ist in der Regel schneller wahrnehmbar, weil nur Text übertragen wird und keine Audiodatei gepuffert werden muss. Für Ihr Problem „Ton kommt verzögert aufs Handy“ ist das genau der Hauptvorteil.[^1_2][^1_3]
4. **Kann die TTS-Umwandlung auf dem Handy laufen?**
Ja, und genau das würde ich empfehlen. Auf iPhone ist das mit den Apple-Stimmen besonders interessant, auf Android ebenfalls gut machbar, aber mit etwas mehr Varianz bei der Stimmqualität.[^1_1][^1_2]
Für Ihr Seniorensystem würde ich praktisch so bauen: Server liefert nur Text, Metadaten und Steuerkommandos; die mobile App spricht lokal mit System-TTS; Cloud-TTS nur optional für „Premium-Stimme“ oder wenn eine ganz bestimmte Persona gebraucht wird. Soll ich Ihnen als Nächstes eine konkrete Zielarchitektur skizzieren – einmal als reine Browserlösung und einmal als robuste native iPhone/Android-App?[^1_7][^1_1][^1_2]
<span style="display:none">[^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]</span>
<div align="center">⁂</div>
[^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.
<span style="display:none">[^2_19][^2_20][^2_21][^2_22][^2_23][^2_24]</span>
<div align="center">⁂</div>
[^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?
<span style="display:none">[^3_13][^3_14][^3_15][^3_16][^3_17]</span>
<div align="center">⁂</div>
[^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?
<span style="display:none">[^4_10][^4_11][^4_12][^4_13][^4_14][^4_15]</span>
<div align="center">⁂</div>
[^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

View file

@ -1,293 +0,0 @@
# Architektur: Modularer Voice-Assistent
> Stand: aktueller Implementierungsstand des Gateways. Dieses Dokument beschreibt
> das Konzept, den umgesetzten Stand und die geplanten nächsten Schritte.
## 1. Ziel & Leitidee
Ein modularer, **cloud-first, aber hybrid betreibbarer** Sprachassistent für
Senioren — ein „digitaler Vertrauter", erreichbar von zuhause und unterwegs.
Der Hauptbetrieb läuft in der Cloud / auf einem vHost (Senioren sollen keinen
teuren KI-Rechner zuhause brauchen). Gleichzeitig muss jede Achse frei wählbar
bleiben, je nach Installation und Entwicklungs-/Testbedarf:
- **Hardware** — Audio-Eingabe und -Ausgabe (lokal, Bluetooth, Handy, Netzwerk)
- **Betrieb** — lokal, Cloud oder hybrid
- **Software** — lokale oder entfernte KI (STT / LLM / TTS), ganz oder teilweise
Designziele: geringe Latenz, robuste Fallbacks, gute Debugbarkeit, klare Trennung
von **semantischer** und **gesprochener** Antwort, und vor allem **Austauschbarkeit**:
einzelne Module gegen andere tauschen, ohne das Programm umzuschreiben.
## 2. Architekturprinzip — fünf Ebenen
1. **Audio Endpoints** – konkrete Quellen/Ziele (lokal, Bluetooth, Handy, WebRTC)
2. **Device Router** – wählt passende Input-/Output-Endpunkte
3. **Speech Pipeline** – STT, Input-Cleaner, Dialog-LLM, Spoken-Response-Adapter, TTS-Normalizer, TTS
4. **Transport Router** – lokale vs. entfernte Ausführung einzelner Module
5. **Orchestrator** – Session, Turn-Taking, Fallbacks, Metrik
Jede Ebene kommuniziert über wohldefinierte Interfaces (ABCs + Pydantic-Schemas),
nicht über konkrete Bibliotheken.
## 3. Konfigurations- & Routing-Modell (umgesetzt)
Das Herzstück der Austauschbarkeit. Jede Achse ist auf mehreren Ebenen
einstellbar; höhere Ebene gewinnt:
```
eingebaute Defaults < config/voice-assistant.toml (inkl. aktivem Profil)
< ENV / .env < Nutzer-Prefs < Session-Route < Request
```
### 3.1 Zentrale Config + Profile
Eine geschichtete zentrale Datei (`config/voice-assistant.toml`, Vorlage
`config/voice-assistant.example.toml`), gelesen über die stdlib (`tomllib`).
**Profile** bündeln Betriebsarten und werden per ENV `VA_PROFILE` aktiviert:
| Profil | STT | LLM | TTS |
|-------------|----------------|--------------------------|------------|
| `local-dev` | faster-whisper | local-openai-compatible | piper |
| `hybrid` | openrouter | local-openai-compatible | openrouter |
| `cloud` | openrouter | openrouter | openrouter |
Umgesetzt in `app/config.py`: `load_profile_config()` merged `[defaults]` +
`[profiles.<aktiv>]`; `TomlProfileSource` hängt diese Werte als Settings-Quelle
**unter** ENV ein (`settings_customise_sources`). `VA_PROFILE`/`VA_CONFIG_FILE`
werden aus echter Umgebung **oder** `.env` gelesen.
> **Secrets gehören nie in die Config-Datei** — nur in die Umgebung
> (z. B. `OPENROUTER_API_KEY`). Die TOML-Datei darf versioniert/geteilt werden.
### 3.2 Einheitliche Route-Auflösung
`app/dependencies.py` löst pro Aufruf eine `ResolvedRoute` auf
(`resolve_route(user, session_id, overrides)`): Defaults < Nutzer-Prefs <
Session-Route < Request. Die Route umfasst `input_endpoint`, `output_endpoint`,
`stt_provider`, `llm_provider`, `tts_provider`, `language`. Gehört eine Session
einem anderen Nutzer, wird `SessionOwnershipError` (→ 403) ausgelöst.
### 3.3 Registry-Pattern (Provider austauschbar)
`STT_REGISTRY` / `LLM_REGISTRY` / `TTS_REGISTRY` bilden `name -> factory(settings)`.
Ein neuer Provider = ein Eintrag, ohne Kern-Code zu ändern. Unbekannter Name →
`UnknownComponentError` → HTTP 422.
### 3.4 Device Router
`app/audio/router.py` wählt Endpunkte per `id`- oder `kind`-Match. Ein angefragter,
aber unbekannter Endpunkt wirft `UnknownEndpointError` (→ 422) — **kein** stiller
Default-Fallback. Ohne Wunsch greift der als `default` markierte Endpunkt. Der
Router ist ein Singleton (stabiler Zustand, z. B. `LoopbackOutput`).
## 4. Datenmodelle & Interfaces
Schemas in `app/schemas.py`, Interfaces als ABCs in den jeweiligen `base.py`.
```python
class EndpointCapabilities(BaseModel):
id: str; kind: str
direction: Literal["input", "output"]
sample_rate: int = 16000; channels: int = 1
latency_class: Literal["low", "medium", "high"] = "medium"
supports_aec: bool = False; supports_barge_in: bool = False
networked: bool = False; bluetooth: bool = False
mobile: bool = False; default: bool = False
class AudioChunk(BaseModel):
data: bytes; sample_rate: int = 16000; channels: int = 1
format: str = "wav"; timestamp_ms: int = 0
class PipelineTrace(BaseModel):
raw_transcript: str | None = None
cleaned_transcript: str | None = None
semantic_response: str | None = None
spoken_response: str | None = None
tts_ready_text: str | None = None
```
Provider-Interfaces:
```python
class STTProvider(ABC):
async def transcribe(self, audio_bytes: bytes, fmt: str, language: str | None = None) -> str: ...
class LLMProvider(ABC):
async def complete(self, text: str, session_id: str | None = None) -> str: ...
class TTSProvider(ABC):
async def synthesize(self, text: str, voice: str | None = None, audio_format: str = "pcm") -> bytes: ...
```
Audio-Endpunkt-Interfaces: `AudioInputEndpoint` (`capabilities/open/read_chunk/close`),
`AudioOutputEndpoint` (`capabilities/open/write_chunk/flush/close`) — alle async.
## 5. Speech Pipeline
Trennung von **semantischer** und **gesprochener** Antwort ist zentral: eine
inhaltlich gute Antwort ist nicht automatisch gut hörbar.
Stufen: `raw_transcript → cleaned_transcript → semantic_response → spoken_response → tts_ready_text`.
- **Input Cleaner** (`pipeline/input_cleaner.py`) – konservative Bereinigung des STT-Texts (Füllwörter, Whitespace). Verändert die Nutzerintention nicht.
- **Dialog-LLM** – semantische Antwort; Persona/Sicherheitsregeln im System-Prompt (`providers/llm/openrouter.py`).
- **Spoken Response Adapter** (`pipeline/spoken_response_adapter.py`) – macht die Antwort sprechbar/seniorengerecht (Markdown raus, Aufzählungspunkte weg, **nummerierte Listen → Ordinalwörter** „1." → „erstens", Uhrzeiten/Verhältnisse erhalten).
- **Sentence Chunker** (`pipeline/sentence_chunker.py`) – inkrementelle Satzsegmentierung für satzweises Streaming-TTS. Trennt bewusst **nicht** nach Ziffer+Punkt („1. Mai"), Einzelbuchstabe+Punkt („z. B.", Initialen) oder bekannten Abkürzungen.
- **TTS Normalizer** (`pipeline/tts_normalizer.py`) – füllt gezielt die Lücken des Phonemizers (espeak-ng in piper), **ohne** zu duplizieren, was der schon gut kann (Kardinal-/Dezimalzahlen bleiben unangetastet): **Ordinalia** (Datum „1. Mai" → „erster Mai", Folgen „1. 2. 3." → „erstens, zweitens …"), **Einheiten nach Zahl** (kg/km/km-h/…), **Abkürzungen** (Dr./z. B./usw.) und ein **YAML-Aussprache-Lexikon** (`config/pronunciation.<lang>.yaml`, erweitert die eingebauten Defaults; case-insensitive Wort-Umschreibungen wie „strömt" → „ströhmt"). Stufe **provider-abhängig** (`TTS_NORMALIZE_LEVEL=auto|full|light|off`): piper → `full`, Cloud-TTS → `light` (Cloud spricht Zahlen/Abkürzungen selbst gut). Ordinalzahlen 1.–31. in `pipeline/german_numbers.py`.
Empfehlung: nicht jede Zwischenstufe braucht ein großes LLM — Cleaner, Chunker und
Normalizer überwiegend regelbasiert (so heute umgesetzt), Adapter promptbasiert.
Lexikon pflegen: `python scripts/add_pronunciation.py "wort:aussprache" [--verify]`.
## 6. Orchestrator
`app/core/orchestrator.py` verbindet Provider, Pipeline und Output-Endpunkt.
- `chat_text(text, language, voice, output, history)` → `(trace, audio_bytes)`
- `speak_only(text, voice, language, output)` → `audio_bytes`
- `transcribe_only(audio_bytes, fmt, language, input)` → `trace`
Bei `/api/chat` mit `session_id` lädt die API-Schicht den letzten Gesprächsverlauf
aus dem Store (`messages`-Tabelle, letzte `HISTORY_MAX_MESSAGES`), gibt ihn als
`history` an das LLM und speichert nach der Antwort User- und Assistant-Turn.
Ohne `session_id` bleibt der Aufruf zustandslos.
Das synthetisierte Audio wird **zusätzlich** durch den gewählten Output-Endpunkt
geschrieben (`open → write_chunk → flush → close`) und **gleichzeitig** als
HTTP-Stream zurückgegeben (additiv). Bei lokalen Geräten ist `write_chunk` heute
ein No-op; `LoopbackOutput` sammelt die Chunks (testbar ohne Hardware).
## 7. FastAPI-Endpunkte (umgesetzt)
| Methode & Pfad | Zweck |
|--------------------------------------|-------|
| `GET /health` | Liveness |
| `POST /api/chat` | Text rein → Audio raus (`?debug=true` → JSON-Trace) |
| `POST /api/speak` | Text rein → TTS-Audio raus |
| `POST /api/transcribe` | Audio-Upload → Transkript |
| `GET /api/devices` | verfügbare Audio-Endpunkte + Capabilities |
| `POST /api/sessions/{id}/route` | bevorzugte Geräte/Provider/Sprache je Session |
| `GET /api/config` | aktives Profil + aufgelöste Route (ohne Secrets) |
| `GET /api/me` · `PUT /api/me/prefs` | aktueller Nutzer + dauerhafte Präferenzen |
| `GET/POST/DELETE /api/me/memories` | Langzeit-Erinnerungen des Nutzers |
| `GET /api/metrics` | Metriken (JSON / Prometheus) |
| `WS /ws/chat` | Echtzeit-Chat (Text rein, Streaming-Events) |
| `WS /ws/voice` | Echtzeit-Sprache (Audio rein → STT → Antwort) |
Endpunkt-/Provider-Auswahl ist über **Request-Body** (pro Aufruf), **Session**
(`?session_id=…`) und **Defaults/Profil** steuerbar. Verwendete Route erscheint als
`X-*`-Header bzw. im `?debug`-JSON.
### 7.1 Admin-API (alle hinter `require_admin`, Audit-geloggt)
Trägt das Admin-Web-Panel (5 Bereiche + Übersicht-Dashboard). Schreibende Aktionen
werden ins Audit-Log geschrieben.
| Methode & Pfad | Zweck |
|--------------------------------------------------|-------|
| `POST /api/admin/users` | Nutzer anlegen → Token einmalig |
| `GET /api/admin/users` | Nutzerliste |
| `PUT/DELETE /api/admin/users/{id}` | Nutzer ändern/löschen |
| `POST /api/admin/users/{id}/token` | Token neu ausstellen |
| `GET/POST/DELETE /api/admin/users/{id}/memories` | Erinnerungen je Nutzer |
| `GET /api/admin/users/{id}/sessions`·`/usage` | Sessions / Kontingent-Nutzung |
| `GET /api/admin/sessions/{id}/messages` | Gesprächsverlauf einsehen |
| `GET/PUT/DELETE /api/admin/config[/{key}]` | Live-Config lesen/setzen (z. B. `top_p`) |
| `GET /api/admin/llm/status` | LLM-/GPU-Status (read-only) |
| `POST /api/admin/llm/backend` | Backend wechseln (Ollama ↔ llama.cpp) |
| `POST /api/admin/gateway/restart` | Gateway aus dem Panel neu starten |
| `GET/POST/DELETE /api/admin/pronunciation/{lang}`| Aussprache-Lexika pflegen |
| `GET /api/admin/usage`·`/emergency-events` | Gesamt-Nutzung / Notfall-Ereignisse |
| `GET /api/admin/db-export` | SQLite-Export |
| `WS /api/admin/log` | Live-Log-Stream |
Config-Änderungen sind als **Live** (sofort wirksam) oder **Restart** (Neustart nötig)
gekennzeichnet; der Backend-Wechsel und Live-Parameter wie `top_p` laufen ohne Neustart.
## 8. Stand der Implementierung
**Umgesetzt:** FastAPI-Gateway, alle o. g. REST-Endpunkte; OpenRouter-Adapter für
STT (JSON/base64), LLM und TTS; lokaler OpenAI-kompatibler LLM-Adapter; regelbasierte
Pipeline; geschichtete Config + Profile; Registry + einheitliche Route-Auflösung;
Device Router (strikt, Singleton); Output-Lifecycle; **Authentifizierung
(Bearer-Token) + persistenter SQLite-Store für Nutzer/Sessions + Mandanten-Trennung
+ dauerhafte Nutzer-Präferenzen**; **Gesprächsgedächtnis pro Session (Verlauf im
Store, fließt ins LLM)**; **Langzeit-Erinnerungen pro Nutzer (als LLM-Kontext)**;
**WebSocket-Streaming-Chat (`/ws/chat`) inkl. Token-Level-LLM-Streaming (SSE,
`stream:true`) und satzweisem Audio-Streaming (chunked TTS, `audio_stream:true`)**; **Sprach-Eingang
über WebSocket (`/ws/voice`: Audio rein → STT → Antwort-Pipeline) mit VAD-Aeusserungs-
erkennung und Barge-in (`interrupt`)**; **Resilienz (Fallback-Ketten je Modul,
In-Memory-Metriken `/api/metrics`), Tageskontingent pro Nutzer und heuristische
Notfall-Eskalation**; **Admin-Web-Panel (5 Bereiche + Übersicht-Dashboard) über die
Admin-API (§7.1) inkl. Backend-Wechsel, Gateway-Neustart, Live-Config-Parameter,
LLM-/GPU-Status, Aussprache-Lexika, Live-Log und Audit-Logging schreibender
Aktionen**; automatisierte Tests.
**Web-/Mobil-Frontend:** schlankes Web-Interface unter `/` (Tailwind, kein Build),
mit **Geräte-TTS** (Browser-SpeechSynthesis auf Mobilgeräten; fällt auf Server-Audio
zurück, wenn keine lokalen Stimmen vorhanden) und **Ton-Presets** (Schnell → piper,
Hohe Qualität → chatterbox, Cloud → openrouter). Auth-Gate (Bearer-Token).
**Echtes lokales STT & TTS:** `faster-whisper` (optionale Dependency `.[local]`,
CTranslate2) transkribiert real; `piper` (in-process via piper-Python-API, Stimmmodell
einmal geladen + gecacht, In-Process-Resampling auf 24000 Hz) synthetisiert real. Lokale
Modelle werden beim Serverstart vorgeladen (Warm-up). Damit ist sowohl ein Hybrid „STT+LLM lokal, TTS
remote" als auch eine **voll-lokale** Konstellation möglich (live verifiziert).
**Platzhalter (Gerüst):** Audio-Endpunkte (`local-default`, `bluetooth`,
`mobile-ws`, `mobile-webrtc`) liefern leere Chunks — nur Auswahl/Lifecycle sind
verdrahtet, kein echtes Hardware-I/O. `transport_router.py` (Ebene 4) existiert, ist aber noch nicht aktiv
(lokal/remote trägt vorerst der Provider-Name).
## 9. Roadmap / bewusste nächste Schritte
Reihenfolge der Weiterentwicklung:
1. **(erledigt)** Konfig- & Routing-Fundament: Profile, Device Router, Registry, Pro-Request-Override.
2. **(erledigt)** Cloud-Fundament: Bearer-Token-Auth, Mehrbenutzer, persistenter SQLite-Store, Mandanten-Trennung, dauerhafte Nutzer-Präferenzen. Offen: Skalierung auf gemeinsamen Store (Postgres/Redis) für mehrere Instanzen.
3. **(erledigt)** Konversationsgedächtnis: Kurzzeit-Gesprächsverlauf pro Session + Langzeit-Erinnerungen pro Nutzer (manuell **und automatisch** gepflegt, als LLM-Kontext). **Automatische Extraktion** (`app/core/memory_extractor.py`): nach je N Turns destilliert ein LLM dauerhafte Fakten/Vorlieben aus dem Verlauf und legt sie dedupliziert als Erinnerungen ab — best-effort, nicht-blockierend (Hintergrund-Task), konfigurierbar (`MEMORY_EXTRACTION_*`). Offen: periodische Verdichtung/Zusammenfassung wachsender Erinnerungslisten.
4. **(weitgehend erledigt)** Echtzeit: WebSocket-Streaming-Chat (`/ws/chat`), **Token-Level-LLM-Streaming (SSE, `stream:true`)**, **Audio-Streaming (chunked TTS satzweise, `audio_stream:true`)**, **Audio-Eingang (`/ws/voice`)**, **Barge-in/Turn-Manager (`interrupt` bricht laufende Antwort ab)** und **VAD-Aeusserungserkennung (energie-basiert, opt-in)** sind umgesetzt. Offen: **echte partielle Live-Transkripte (Streaming-STT-Dienst, wortweise)** und **WebRTC (aiortc)** — beide brauchen schwere Abhaengigkeiten/Dienste. Heute laeuft STT pro Aeusserung.
5. **(weitgehend erledigt)** Resilienz: Fallback-Ketten je Modul (`*_FALLBACK`, Provider faellt aus → naechster) und In-Memory-Metriken (`/api/metrics`: Request/Latenz, Pipeline-Stufen, Fallback/Fehler; JSON + Prometheus). Offen: verteiltes Tracing, Alerting.
6. **(weitgehend erledigt)** Betrieb: Tageskontingent pro Nutzer (`DAILY_REQUEST_LIMIT`, 429) und **zweistufige** Notfall-Eskalation: (1) schnelle Stichwort-Heuristik im Hot-Path (0 Latenz) + (2) **LLM-Klassifikation** (`app/safety/llm_classifier.py`) als Hintergrund-Task, der laeuft, wenn die Heuristik nichts fand — faengt verpasste Formulierungen (z. B. metaphorisch geaeusserte Suizidalitaet, Schlaganfall-Symptome ohne Stichwort) mit Konfidenz-Schwelle, ohne die Antwortlatenz zu erhoehen. Eskalation jeweils -> Log + Metrik (`source`: keyword/llm) + optionaler Webhook + Event. Offen: Telefon-/Angehoerigen-Integration, Abrechnung.
7. **(weitgehend erledigt)** Lokale Provider: STT via `faster-whisper` (`.[local]`) und **TTS via `piper`** (lokales Neural-TTS, **in-process** mit gecachtem Stimmmodell, In-Process-Resampling auf 24000 Hz; lokale Modelle werden beim Start vorgeladen) sind echt — eine **voll-lokale Konstellation** (STT+LLM+TTS lokal, keine API-Kosten, max. Datenschutz) ist damit möglich. **`chatterbox`-TTS** (Resemble AI, eigener GPU-HTTP-Dienst, hohe Qualität + Voice-Cloning) ist als **wählbarer** Provider angebunden (job-basiert: `/speak`→`/status`→`/audio`, `no_playback`-Modus liefert nur Bytes). Offen: Streaming-Synthese für niedrigere Latenz.
8. **TransportRouter** als eigene lokal/remote-Achse aktivieren; echte Geräte-Endpunkte (PipeWire/Bluetooth) — heute OS-Ebene.
**Datenschutz (querschnittlich, ab sofort mitdenken):** Senioren-Sprachdaten sind
hochsensibel (oft gesundheitsbezogen → DSGVO Art. 9). EU-Datenresidenz,
Verschlüsselung at-rest/in-transit, Löschkonzept, Einwilligung — „privacy by design".
## 10. Verzeichnisstruktur (Ist)
```text
my_voice_assistant_v3/
├── app/
│ ├── main.py # FastAPI-App + Router-Registrierung
│ ├── config.py # Settings, TOML-Profile, Präzedenz
│ ├── dependencies.py # Registries, ResolvedRoute, resolve_route, Store-/Router-Singleton
│ ├── store.py # Persistenz: Store-Interface + SQLiteStore (Nutzer/Sessions/Verlauf)
│ ├── auth.py # Bearer-Token-Auth (require_user) + Admin-Schutz
│ ├── audit.py # Audit-Log schreibender Admin-Aktionen
│ ├── admin_llm.py # LLM-/GPU-Status + Backend-Wechsel (Ollama ↔ llama.cpp)
│ ├── runtime_config.py # Live-Config (zur Laufzeit setzbare Parameter)
│ ├── metrics.py # In-Memory-Metriken (Counter/Timer, JSON + Prometheus)
│ ├── quota.py # Tageskontingent pro Nutzer (Kostenkontrolle)
│ ├── safety/ # emergency.py (Heuristik) + llm_classifier.py (Notfall-Klassifikation)
│ ├── errors.py # RoutingError -> HTTP 422
│ ├── schemas.py # Pydantic-Modelle
│ ├── api/ # health, chat, speak, transcribe, devices, sessions, config, admin, me, ws
│ ├── core/ # orchestrator, memory_extractor (Auto-Erinnerungen), warmup (Modell-Vorladen)
│ ├── audio/ # router, transport_router, vad, endpoints/input|output/*
│ ├── pipeline/ # input_cleaner, spoken_response_adapter, tts_normalizer, sentence_chunker, german_numbers
│ ├── providers/ # stt/ llm/ tts/ (openrouter + lokale + chatterbox) + fallback.py
│ ├── utils/ # Hilfsfunktionen
│ └── web/ # Web-/Admin-Frontend (Tailwind, kein Build)
├── config/ # voice-assistant.example.toml (+ lokale .toml, gitignored)
├── data/ # SQLite-DB (gitignored)
├── deploy/ # systemd unit + env-Beispiel
├── tests/ # config-profile, routing, audio-router, e2e
├── Docs/ # dieses Dokument
├── Dockerfile, docker-compose.yml, Makefile, pyproject.toml
└── README.md, BEDIENUNGSANLEITUNG.md
```

18
LICENSE Normal file
View file

@ -0,0 +1,18 @@
MIT License
Copyright (c) 2026 dschlueter
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
associated documentation files (the "Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial
portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
USE OR OTHER DEALINGS IN THE SOFTWARE.

View file

@ -1,28 +0,0 @@
# Proprietäre Lizenz – Alle Rechte vorbehalten
Copyright (c) 2026 Dieter Schlüter. Alle Rechte vorbehalten.
Diese Software und die zugehörige Dokumentation (das „Werk") sind urheberrechtlich
geschützt und vertraulich. Sie sind **kein** Open-Source- oder freies Werk.
Ohne vorherige ausdrückliche schriftliche Genehmigung des Rechteinhabers ist es
untersagt, das Werk ganz oder teilweise zu:
- nutzen, ausführen oder bereitzustellen (außer durch den Rechteinhaber oder
ausdrücklich autorisierte Personen),
- kopieren, vervielfältigen oder speichern (außer technisch notwendige Kopien für
eine autorisierte Nutzung),
- verändern, übersetzen, bearbeiten oder davon abgeleitete Werke zu erstellen,
- weiterzugeben, zu veröffentlichen, zu verbreiten, zu vermieten, zu verkaufen,
zu unterlizenzieren oder anderweitig Dritten zugänglich zu machen,
- zu dekompilieren, zu disassemblieren oder zurückzuentwickeln, soweit nicht
zwingendes Recht dies ausdrücklich erlaubt.
Es werden keine Lizenz- oder sonstigen Rechte stillschweigend oder anderweitig
eingeräumt. Alle nicht ausdrücklich gewährten Rechte verbleiben beim Rechteinhaber.
DAS WERK WIRD „WIE BESEHEN" OHNE JEGLICHE GEWÄHRLEISTUNG ODER GARANTIE
BEREITGESTELLT. DER RECHTEINHABER HAFTET NICHT FÜR SCHÄDEN, DIE AUS DER NUTZUNG
ODER UNMÖGLICHKEIT DER NUTZUNG DES WERKS ENTSTEHEN, SOWEIT GESETZLICH ZULÄSSIG.
Anfragen zur Lizenzierung oder Nutzung: Dieter Schlüter <dieter.schlueter@linix.de>.

View file

@ -1,75 +0,0 @@
ifneq (,$(wildcard ./.env))
include .env
export
endif
PORT ?= 8080
HOST ?= 0.0.0.0
CONTAINER_NAME ?= va_llm
.PHONY: ensure-env install run start stop restart test smoke docker-build llm-up llm-down llm-status llm-ollama llm-llamacpp
ensure-env:
@if [ ! -f .env ] && [ -f .env.example ]; then \
cp .env.example .env; \
echo "Created .env from .env.example"; \
fi
install: ensure-env
python3 -m venv .venv
. .venv/bin/activate && pip install -U pip && pip install -e .[test]
run: ensure-env
. .venv/bin/activate && uvicorn app.main:app --host $(HOST) --port $(PORT) --reload
test: ensure-env
. .venv/bin/activate && pytest tests/
# Echter End-to-End-Test gegen OpenRouter (macht Netz-Aufrufe, kostet wenig).
smoke: ensure-env
. .venv/bin/activate && python scripts/smoke_e2e.py
docker-build:
docker build -t voice-assistant-gateway .
# --- Komplett-Befehle (Start / Stop / Restart) --------------------------------
# Stoppt alle Voice-Assistant-Komponenten: Gateway (Vordergrund + systemd) und
# llama.cpp-Container. Ollama (systemd) separat: sudo systemctl stop ollama
stop:
-pkill -f "uvicorn app.main:app" 2>/dev/null; true
-systemctl --user stop voice-assistant 2>/dev/null; true
-docker rm -f "$(CONTAINER_NAME)" 2>/dev/null; true
@echo "[*] Voice-Assistant gestoppt (Gateway + llama.cpp)."
# Startet llama.cpp-LLM-Server und danach das Gateway (Profil hybrid/local-dev).
# Fuer Profil cloud (kein lokales LLM): einfach 'make run'.
start: ensure-env
$(MAKE) llm-up
$(MAKE) run
# Faehrt alles herunter und startet neu (llama.cpp + Gateway).
restart: stop
$(MAKE) start
# --- Lokales LLM (llama.cpp-Server, zentrale unzensierte KI) -----------------
# Konfig per ENV ueberschreibbar, z. B.: GPU_DEVICE=2 HOST_PORT=8101 make llm-up
llm-up:
bash scripts/llm-server/start-llm-server.sh
llm-down:
bash scripts/llm-server/stop-llm-server.sh
llm-status:
bash scripts/llm-server/status-llm-server.sh
# --- LLM-Backend wechseln (Ollama <-> llama.cpp) -----------------------------
# Passt die LOCAL_LLM_*-Zeilen in .env an, gibt den GPU-Speicher des anderen
# Backends frei, startet das gewuenschte und startet das Gateway (falls Dienst) neu.
# Modell ueberschreibbar: OLLAMA_MODEL=qwen2.5:latest make llm-ollama
llm-ollama:
bash scripts/llm-server/switch-llm.sh ollama
llm-llamacpp:
bash scripts/llm-server/switch-llm.sh llamacpp

209
README.md
View file

@ -1,210 +1,5 @@
# Voice Assistant Gateway
# my_voice_assistant_v3
Modulares FastAPI-Gateway für einen **seniorengerechten Sprachassistenten** —
Voice Assistant Gateway --- Modulares FastAPI-Gateway für einen **seniorengerechten Sprachassistenten** —
cloud-first, aber hybrid/lokal betreibbar, mit austauschbaren STT-/LLM-/TTS-Providern.
Jede Achse — Hardware, Betrieb, Software — ist frei konfigurierbar, ohne Code zu ändern.
---
## Features
- **Sprach-Pipeline:** STT → Input-Cleaner → LLM → Spoken-Adapter → TTS-Normalizer → TTS
- **Provider austauschbar** über Registry: OpenRouter (Cloud), faster-whisper (STT lokal), piper (TTS lokal, schnell), chatterbox (TTS lokal, hohe Qualität + Voice-Cloning)
- **Geschichtete Konfiguration** mit Profilen (`cloud` / `hybrid` / `local-dev`)
- **Routing auf jeder Ebene:** Global → Profil → Nutzer → Session → Request
- **Authentifizierung** (Bearer-Token) + persistente Nutzer/Sessions (SQLite)
- **Resilienz:** Fallback-Ketten je Modul + In-Memory-Metriken (JSON + Prometheus)
- **Gesprächsgedächtnis** pro Session (Verlauf) + Langzeit-Erinnerungen pro Nutzer (manuell + automatisch)
- **WebSocket-Streaming:** Token-Streaming (LLM), Audio-Streaming (satzweises TTS), Sprach-Eingang, VAD, Barge-in
- **Notfall-Eskalation:** zweistufig (Stichwörter + LLM-Klassifikation im Hintergrund)
- **Web-Interface** unter `/` (Tailwind, kein Build, mobiltauglich) mit Geräte-TTS (Browser-Sprachausgabe) und Ton-Presets (Schnell/Hohe Qualität/Cloud)
- **Admin-Panel** (5 Bereiche + Übersicht-Dashboard): Nutzer/Sessions, LLM-/GPU-Status, Backend-Wechsel + Gateway-Neustart, Live-Config, Aussprache-Lexika, Live-Log, Audit-Logging
- **Keine Secrets im Code** — API-Keys nur über die Umgebung
---
## Schnellstart (30 Sekunden)
```bash
python3 -m venv .venv && source .venv/bin/activate
pip install -U pip && pip install -e .[test]
cp config/voice-assistant.example.toml config/voice-assistant.toml
export OPENROUTER_API_KEY=sk-or-v1-... # für Cloud/Hybrid; bei local-dev nicht nötig
make run
```
Fehlt `.env`, wird sie aus `.env.example` erzeugt. Gateway läuft auf `http://localhost:8080`
(oder dem in `.env` gesetzten `PORT`).
```bash
curl http://localhost:8080/health # {"status":"ok"}
curl http://localhost:8080/api/config # aktives Profil + aufgelöste Provider
```
Sprechen → Antwort hören (CLI-Loop):
```bash
python scripts/voice_loop.py --session mein-gespraech
```
Web-Interface: Browser → `http://localhost:8080/`
---
## Starten — alle Szenarien
### Profil `cloud` (nur Gateway, alles via OpenRouter)
```bash
make run # Gateway auf PORT aus .env (Standard: 8080)
VA_PROFILE=cloud make run # Profil explizit setzen (überschreibt .env)
PORT=8003 make run # anderen Port für diesen Start
LOG_LEVEL=debug make run # ausführlichere Logs
```
### Profil `hybrid` / `local-dev` mit llama.cpp (Docker + GPU)
```bash
# 1) LLM-Server starten
make llm-up # Default: GPU 1, Port 8001, Modell qwen3-35B-Uncensored
make llm-status # warten bis „Modell bereit" + HTTP 200 erscheint
make llm-down # stoppen
# Mit anderen Parametern (via ENV):
GPU_DEVICE=0 make llm-up
HOST_PORT=8101 GPU_DEVICE=2 make llm-up
GPU_DEVICE=0 MODEL_REL_PATH="models/qwen3/anderes-modell.gguf" make llm-up
# Direkt (ohne make):
bash scripts/llm-server/start-llm-server.sh
GPU_DEVICE=0 bash scripts/llm-server/start-llm-server.sh
# 2) Gateway starten
VA_PROFILE=hybrid make run # STT/TTS cloud, LLM lokal
VA_PROFILE=local-dev make run # alles lokal (STT/TTS in-process)
```
ENV-Optionen für `make llm-up` / `start-llm-server.sh`:
| Variable | Default | Bedeutung |
|----------|---------|-----------|
| `GPU_DEVICE` | `1` | GPU-Index (0-basiert) |
| `HOST_PORT` | `8001` | Host-Port des LLM-Servers |
| `MODEL_REL_PATH` | `models/qwen3/Qwen3.6-35B-...Q4_K_M.gguf` | Modellpfad relativ zu `HF_HOME` |
| `HF_HOME` | `~/nvme2n1p7_home/huggingface` | Modell-Basisverzeichnis |
| `MODEL_ALIAS` | `va_llm` | OpenAI-API-Modellname (→ `LOCAL_LLM_MODEL` in `.env`) |
| `CONTAINER_NAME` | `va_llm` | Docker-Containername |
> Wird `HOST_PORT` oder `MODEL_ALIAS` geändert, müssen `LOCAL_LLM_BASE_URL` und
> `LOCAL_LLM_MODEL` in `.env` entsprechend angepasst werden.
### Profil `hybrid` / `local-dev` mit Ollama
```bash
# 1) Ollama-Dienst starten
sudo systemctl start ollama # empfohlen (bei systemd-Installation)
# oder im Vordergrund:
ollama serve
# 2) Modell herunterladen (einmalig, ~20 GB):
ollama pull qwen3:30b-a3b # Thinking deaktiviert (empfohlen)
# kleinere Alternative (CPU-tauglich, ~5 GB):
ollama pull qwen3:8b
# Status prüfen:
ollama list # installierte Modelle
ollama ps # gerade aktive Modelle (mit VRAM-Verbrauch)
# 3) .env anpassen (einmalig):
# LOCAL_LLM_BASE_URL=http://127.0.0.1:11434/v1
# LOCAL_LLM_API_KEY=ollama
# LOCAL_LLM_MODEL=qwen3:30b-a3b ← exakter Name aus 'ollama list'
# 4) Gateway starten:
VA_PROFILE=hybrid make run
VA_PROFILE=local-dev make run
```
### Stoppen
```bash
make stop # Gateway (alle Varianten) + llama.cpp-Container
# Einzeln:
Strg + C # Gateway im Vordergrund
pkill -f "uvicorn app.main:app" # Gateway im Hintergrund
systemctl --user stop voice-assistant # Gateway als systemd-Dienst
make llm-down # nur llama.cpp
sudo systemctl stop ollama # nur Ollama
```
### Neu starten (alles)
```bash
make restart # make stop + make start (llama.cpp + Gateway)
# Nur Gateway neu (llama.cpp läuft weiter):
systemctl --user restart voice-assistant
```
### Backend wechseln (llama.cpp ↔ Ollama)
Beide Server können nicht gleichzeitig laufen (geteilter GPU-Speicher).
```bash
# → zu llama.cpp wechseln (Ollama vorher killen):
sudo systemctl stop ollama && pkill -f "ollama serve" 2>/dev/null || true
make llm-up && make llm-status
# → zu Ollama wechseln (llama.cpp vorher killen):
make llm-down # oder: docker rm -f va_llm
sudo systemctl start ollama
```
### Alle `make`-Targets im Überblick
```bash
make install # venv anlegen + Abhängigkeiten installieren
make run # Gateway starten (uvicorn --reload, Port aus .env)
make start # llama.cpp + Gateway starten (hybrid/local-dev)
make stop # alles stoppen (Gateway + llama.cpp)
make restart # make stop + make start
make test # Pytest-Suite (offline, kostenlos)
make smoke # Live-End-to-End-Test gegen OpenRouter (geringe Kosten)
make llm-up # llama.cpp-Docker-Container starten
make llm-down # llama.cpp-Container stoppen
make llm-status # Container- und HTTP-Status prüfen
```
---
## Dokumentation
| Dokument | Zielgruppe | Inhalt |
|----------|-----------|--------|
| **[BEDIENUNGSANLEITUNG.md](BEDIENUNGSANLEITUNG.md)** | alle | Installation, Betriebsprofile, Bedienung, Konfiguration, Admin, Deployment, Fehlerbehebung, Referenz |
| **[Docs/voice-assistant-architecture.md](Docs/voice-assistant-architecture.md)** | Entwickler | Architekturprinzipien, Interfaces, Pipeline, Roadmap, Verzeichnisstruktur |
| **[deploy/README.md](deploy/README.md)** | Admin | Remote-Betrieb: nginx, YunoHost-SSO, systemd, Firewall, Chatterbox |
**Einstiegspunkte je Zielgruppe:**
- 👤 **Endnutzer** → BEDIENUNGSANLEITUNG § 5 (Bedienung)
- 🔧 **Admin/Betreiber** → BEDIENUNGSANLEITUNG § 2–4 (Installation + Profile), § 7–11 (Betrieb)
- 💻 **Entwickler** → BEDIENUNGSANLEITUNG § 2 + Architektur-Dokument
---
## Projektstruktur (Kurzform)
```text
app/ Gateway: config, api/, core/, audio/, pipeline/, providers/, web/ (Frontend + Admin)
config/ voice-assistant.example.toml (lokale .toml ist gitignored)
deploy/ systemd-Unit, nginx-Vorlage, env-Beispiel
scripts/ voice_loop.py, chat_client.py, smoke_e2e.py, add_pronunciation.py
scripts/llm-server/ start/stop/status-llm-server.sh (llama.cpp-Docker)
tests/ Pytest-Suite (offline + smoke)
Docs/ Architektur-Dokument
```
---
## Lizenz
**Proprietär — alle Rechte vorbehalten.** Siehe [LICENSE.md](LICENSE.md).

View file

View file

@ -1,189 +0,0 @@
"""Read-only Statusabfragen rund um das lokale LLM-Backend (fuer das Admin-Panel).
Alles nur lesend, ohne sudo: `docker ps`, `ollama ps`, `nvidia-smi`, `systemctl --user`.
Fehlende Tools oder Fehler fuehren zu sicheren Defaults (None/[]), nie zu Exceptions.
"""
from __future__ import annotations
import asyncio
import os
import re
import shutil
import subprocess
from pathlib import Path
from app.config import settings
ALLOWED_BACKENDS = {"ollama", "llamacpp"}
_SCRIPT = Path(__file__).resolve().parent.parent / "scripts" / "llm-server" / "switch-llm.sh"
# Erlaubtes Format fuer Ollama-Modellnamen (zusaetzlich zur Pruefung gegen 'ollama list').
_MODEL_RE = re.compile(r"^[A-Za-z0-9._:/-]{1,100}$")
async def _run(cmd: list[str], timeout: float = 6.0) -> str | None:
"""Fuehrt ein Kommando aus (shell=False) und liefert stdout, oder None bei Fehler."""
if not shutil.which(cmd[0]):
return None
try:
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.DEVNULL,
env={**os.environ, "XDG_RUNTIME_DIR": os.environ.get(
"XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}")},
)
out, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout)
if proc.returncode != 0:
return None
return out.decode("utf-8", "replace")
except (asyncio.TimeoutError, OSError):
return None
def _detect_backend(base_url: str) -> str:
if ":11434" in base_url:
return "ollama"
if ":8001" in base_url:
return "llamacpp"
return "unknown"
async def _llamacpp_running(container: str = "va_llm") -> bool:
out = await _run(["docker", "ps", "--filter", f"name=^{container}$", "--format", "{{.Names}}"])
return bool(out and container in out)
async def _ollama_loaded() -> tuple[bool, list[dict]]:
"""(dienst_erreichbar, [geladene Modelle])."""
out = await _run(["ollama", "ps"])
if out is None:
return False, []
models: list[dict] = []
lines = [ln for ln in out.splitlines() if ln.strip()]
for ln in lines[1:]: # Kopfzeile ueberspringen
# Spalten sind durch 2+ Leerzeichen getrennt: NAME ID SIZE PROCESSOR CONTEXT UNTIL
import re
cols = re.split(r"\s{2,}", ln.strip())
if cols:
models.append({
"name": cols[0],
"size": cols[2] if len(cols) > 2 else "",
"processor": cols[3] if len(cols) > 3 else "",
})
return True, models
async def _gpus() -> list[dict]:
out = await _run([
"nvidia-smi",
"--query-gpu=index,memory.used,memory.total",
"--format=csv,noheader,nounits",
])
if out is None:
return []
gpus: list[dict] = []
for ln in out.splitlines():
parts = [p.strip() for p in ln.split(",")]
if len(parts) == 3 and parts[0].isdigit():
used, total = int(parts[1]), int(parts[2])
gpus.append({
"index": int(parts[0]),
"used_mib": used,
"total_mib": total,
"percent": round(used / total * 100) if total else 0,
})
return gpus
async def _gateway_service_active() -> bool:
out = await _run(["systemctl", "--user", "is-active", "voice-assistant.service"], timeout=4.0)
return bool(out and out.strip() == "active")
async def llm_status() -> dict:
"""Aggregierter, read-only LLM-/System-Status fuer das Admin-Panel."""
base_url = settings.local_llm_base_url
backend = _detect_backend(base_url)
llamacpp, (ollama_reachable, ollama_models), gpus, gw = await asyncio.gather(
_llamacpp_running(),
_ollama_loaded(),
_gpus(),
_gateway_service_active(),
)
return {
"backend": backend,
"model": settings.local_llm_model,
"base_url": base_url,
"llamacpp_running": llamacpp,
"ollama_reachable": ollama_reachable,
"ollama_loaded": ollama_models,
"gpus": gpus,
"gateway_service_active": gw,
}
# ── Schreibende Steuerung (Backend-Wechsel / Gateway-Neustart) ───────────────
class LlmControlError(ValueError):
"""Validierungs-/Steuerfehler -> wird vom Endpoint als 400/422 gemeldet."""
async def available_ollama_models() -> list[str]:
"""Modellnamen aus `ollama list` (erste Spalte), oder []."""
out = await _run(["ollama", "list"])
if out is None:
return []
names: list[str] = []
for ln in out.splitlines()[1:]:
ln = ln.strip()
if ln:
names.append(ln.split()[0])
return names
async def switch_backend(backend: str, model: str | None = None) -> dict:
"""Validiert streng und startet den Backend-Wechsel als losgelösten Prozess.
Allowlist: backend ∈ {ollama, llamacpp}. Bei ollama muss `model` (falls gesetzt)
exakt in `ollama list` vorkommen. Nie freie Strings an die Shell — Aufruf mit
fester Argumentliste (shell=False); das Modell geht ausschließlich als Env-Var.
Der Wechsel läuft detached weiter (er startet ggf. das Gateway neu).
"""
if backend not in ALLOWED_BACKENDS:
raise LlmControlError(f"Unbekanntes Backend: {backend!r}")
if not _SCRIPT.exists():
raise LlmControlError("switch-llm.sh nicht gefunden")
env = {**os.environ}
if backend == "ollama" and model:
if not _MODEL_RE.match(model):
raise LlmControlError("Ungültiger Modellname")
available = await available_ollama_models()
if available and model not in available:
raise LlmControlError(f"Modell nicht in 'ollama list': {model!r}")
env["OLLAMA_MODEL"] = model
# Detached starten: der Wechsel kann das Gateway neu starten -> wir würden uns
# sonst selbst killen, bevor die HTTP-Antwort raus ist.
subprocess.Popen(
["bash", str(_SCRIPT), backend],
env=env,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
)
return {"status": "switching", "backend": backend, "model": model}
def restart_gateway_detached() -> dict:
"""Startet das Gateway als systemd-User-Dienst neu (losgelöst, Self-Restart-sicher)."""
xdg = os.environ.get("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}")
subprocess.Popen(
["bash", "-c",
f"sleep 1; XDG_RUNTIME_DIR={xdg} systemctl --user restart voice-assistant.service"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
)
return {"status": "restarting"}

View file

View file

@ -1,404 +0,0 @@
import asyncio
from pathlib import Path
import yaml
from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSocketDisconnect
from fastapi.responses import FileResponse
from pydantic import BaseModel
from app.admin_llm import (
LlmControlError,
llm_status,
restart_gateway_detached,
switch_backend,
)
from app.audit import log_admin_action
from app.auth import is_admin_user, require_admin, require_admin_or_user
from app.config import settings
from app.dependencies import get_store
from app.runtime_config import RUNTIME_SETTABLE, invalidate_cache, runtime_settings
from app.schemas import MemoryCreate, MemoryOut, UserCreate, UserCreated, UserUpdate
router = APIRouter()
_CONFIG_DIR = Path(__file__).resolve().parents[2] / "config"
_ALLOWED_SECTIONS = {"abbreviations", "units", "terms"}
_ALLOWED_LANGS = {"de", "en", "fr", "es", "it", "nl", "ru", "zh"}
class PronunciationEntry(BaseModel):
section: str
key: str
value: str
@router.get("/admin/request-headers")
async def request_headers(request: Request, key: str | None = None):
"""Discovery: zeigt die eingehenden HTTP-Header + Quell-IP.
Hilft, hinter dem Reverse-Proxy/SSO den richtigen Identitaets-Header
(TRUSTED_AUTH_HEADER) festzustellen.
Henne-Ei: Beim Einrichten kennt das Gateway den SSO-Admin noch nicht. Daher
ist der Endpoint auch per Admin-Key aufrufbar - als Query (`?key=...`, im Browser
durchs SSO bequem) oder X-Admin-Key-Header. Nach dem Setup wieder meiden bzw.
den Key rotieren (er landet sonst in Proxy-Logs).
"""
expected = settings.admin_api_key.strip()
provided = key or request.headers.get("x-admin-key")
ok = bool(expected) and provided is not None and provided.strip() == expected
if not ok and not is_admin_user(_current_user_or_none(request)):
raise HTTPException(status_code=403, detail="Admin privileges required")
return {
"client": request.client.host if request.client else None,
"headers": dict(request.headers),
}
def _current_user_or_none(request: Request):
from app.auth import authenticate, _bearer_token
client_host = request.client.host if request.client else ""
token = _bearer_token(request.headers.get("authorization"))
return authenticate(request.headers, client_host, token)
@router.post("/admin/users", response_model=UserCreated, dependencies=[Depends(require_admin)])
async def create_user(payload: UserCreate):
"""Legt einen Nutzer an und gibt das Bearer-Token EINMALIG zurueck."""
user, token = get_store().create_user(payload.display_name)
return UserCreated(user_id=user.id, display_name=user.display_name, token=token)
@router.delete("/admin/users/{user_id}", dependencies=[Depends(require_admin)])
async def delete_user(user_id: str):
"""Loescht einen Nutzer und alle seine Daten (Sessions, Nachrichten, Erinnerungen,
Nutzungsdaten). Der anonyme Nutzer kann nicht geloescht werden."""
try:
deleted = get_store().delete_user(user_id)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
if not deleted:
raise HTTPException(status_code=404, detail=f"Nutzer {user_id!r} nicht gefunden.")
return {"deleted": user_id}
@router.post("/admin/users/{user_id}/token", response_model=UserCreated, dependencies=[Depends(require_admin)])
async def reset_token(user_id: str):
"""Stellt einen neuen Bearer-Token aus; der alte wird sofort ungueltig.
Der neue Token wird EINMALIG zurueckgegeben und danach nicht mehr angezeigt."""
result = get_store().reset_token(user_id)
if result is None:
raise HTTPException(status_code=404, detail=f"Nutzer {user_id!r} nicht gefunden.")
user, token = result
return UserCreated(user_id=user.id, display_name=user.display_name, token=token)
@router.put("/admin/users/{user_id}", dependencies=[Depends(require_admin)])
async def update_user(user_id: str, payload: UserUpdate):
"""Aktualisiert den Anzeigenamen eines Nutzers (z. B. nach erstem SSO-Login)."""
user = get_store().update_display_name(user_id, payload.display_name)
if user is None:
raise HTTPException(status_code=404, detail=f"Nutzer {user_id!r} nicht gefunden.")
return {"user_id": user.id, "display_name": user.display_name}
@router.post("/admin/users/{user_id}/memories", response_model=MemoryOut, dependencies=[Depends(require_admin)])
async def add_user_memory(user_id: str, payload: MemoryCreate):
"""Legt eine Erinnerung fuer einen Nutzer an (Admin kann Kontext vorbelegen)."""
store = get_store()
if store.get_user(user_id) is None:
raise HTTPException(status_code=404, detail=f"Nutzer {user_id!r} nicht gefunden.")
memory = store.add_memory(user_id, payload.content)
return MemoryOut(id=memory.id, content=memory.content, created_at=memory.created_at)
@router.get("/admin/users", dependencies=[Depends(require_admin_or_user)])
async def list_users():
"""Listet die Nutzer (ohne Secrets). Fuer Admins (SSO/ADMIN_USERS) oder ADMIN_API_KEY."""
return [
{
"user_id": u.id,
"display_name": u.display_name,
"external_id": u.external_id,
"created_at": u.created_at,
}
for u in get_store().list_users()
]
@router.get("/admin/users/{user_id}/memories", dependencies=[Depends(require_admin)])
async def get_user_memories(user_id: str):
"""Gibt alle Erinnerungen eines Nutzers zurueck."""
store = get_store()
if store.get_user(user_id) is None:
raise HTTPException(status_code=404, detail=f"Nutzer {user_id!r} nicht gefunden.")
return [
{"id": m.id, "content": m.content, "created_at": m.created_at}
for m in store.get_memories(user_id)
]
@router.delete("/admin/users/{user_id}/memories/{memory_id}", dependencies=[Depends(require_admin)])
async def delete_user_memory(user_id: str, memory_id: int):
"""Loescht eine einzelne Erinnerung eines Nutzers."""
deleted = get_store().delete_memory(user_id, memory_id)
if not deleted:
raise HTTPException(status_code=404, detail="Erinnerung nicht gefunden.")
return {"deleted": memory_id}
@router.get("/admin/users/{user_id}/sessions", dependencies=[Depends(require_admin)])
async def list_user_sessions(user_id: str):
"""Listet alle Sessions eines Nutzers (neueste zuerst)."""
return get_store().list_sessions_for_user(user_id)
@router.get("/admin/sessions/{session_id}/messages", dependencies=[Depends(require_admin)])
async def get_session_messages(session_id: str, limit: int = 200):
"""Gibt alle Nachrichten einer Session zurueck (Gespraechs-Browser)."""
return get_store().get_messages_for_session(session_id, limit)
@router.get("/admin/emergency-events", dependencies=[Depends(require_admin)])
async def list_emergency_events(limit: int = 50):
"""Listet alle protokollierten Notfall-Ereignisse (neueste zuerst)."""
return get_store().list_emergency_events(limit)
@router.get("/admin/users/{user_id}/usage", dependencies=[Depends(require_admin)])
async def get_user_usage(user_id: str):
"""Nutzungsstatistik eines Nutzers (letzte 30 Tage)."""
return get_store().get_usage_for_user(user_id)
@router.get("/admin/usage", dependencies=[Depends(require_admin)])
async def get_all_usage():
"""Aggregierte Nutzungsstatistik aller Nutzer."""
return get_store().get_all_usage()
# ── Datenbank-Export ────────────────────────────────────────────────────────
@router.get("/admin/db-export", dependencies=[Depends(require_admin)])
async def export_db():
"""Laed die SQLite-Datenbank als Datei herunter (Backup)."""
path = Path(settings.db_path)
if not path.exists():
raise HTTPException(status_code=404, detail="Datenbank nicht gefunden.")
return FileResponse(
path,
media_type="application/octet-stream",
filename="voice-assistant.db",
headers={"Content-Disposition": 'attachment; filename="voice-assistant.db"'},
)
# ── LLM-/System-Status (read-only) ──────────────────────────────────────────
@router.get("/admin/llm/status", dependencies=[Depends(require_admin)])
async def get_llm_status():
"""Read-only Status: aktives Backend, Modell, GPU-Auslastung, Dienste."""
return await llm_status()
class BackendSwitch(BaseModel):
backend: str
model: str | None = None
@router.post("/admin/llm/backend", dependencies=[Depends(require_admin)])
async def post_llm_backend(payload: BackendSwitch, request: Request):
"""Wechselt das LLM-Backend (Allowlist-validiert, detached). Greift voll erst,
wenn das Gateway als systemd-Dienst läuft; sonst muss es manuell neu starten."""
try:
result = await switch_backend(payload.backend, payload.model)
except LlmControlError as exc:
log_admin_action(request, "llm_backend_switch_rejected",
backend=payload.backend, model=payload.model, error=str(exc))
raise HTTPException(status_code=422, detail=str(exc))
log_admin_action(request, "llm_backend_switch",
backend=payload.backend, model=payload.model)
return result
@router.post("/admin/gateway/restart", dependencies=[Depends(require_admin)])
async def post_gateway_restart(request: Request):
"""Startet das Gateway (systemd-User-Dienst) neu — losgelöst, Self-Restart-sicher."""
log_admin_action(request, "gateway_restart")
return restart_gateway_detached()
# ── Aussprache-Lexikon CRUD ─────────────────────────────────────────────────
def _read_pronunciation(lang: str) -> dict:
path = _CONFIG_DIR / f"pronunciation.{lang}.yaml"
if not path.exists():
return {"abbreviations": {}, "units": {}, "terms": {}}
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
return {
"abbreviations": dict(data.get("abbreviations") or {}),
"units": dict(data.get("units") or {}),
"terms": dict(data.get("terms") or {}),
}
def _write_pronunciation(lang: str, data: dict) -> None:
path = _CONFIG_DIR / f"pronunciation.{lang}.yaml"
path.write_text(
yaml.dump(data, allow_unicode=True, default_flow_style=False, sort_keys=False),
encoding="utf-8",
)
# LRU-Cache des Normalizers invalidieren, damit die Aenderung sofort greift.
from app.pipeline.tts_normalizer import _load_lexicon
_load_lexicon.cache_clear()
@router.get("/admin/pronunciation/{lang}", dependencies=[Depends(require_admin)])
async def get_pronunciation(lang: str):
"""Gibt alle Eintraege des Aussprache-Lexikons zurueck."""
if lang not in _ALLOWED_LANGS:
raise HTTPException(status_code=400, detail=f"Sprache muss eine von {_ALLOWED_LANGS} sein.")
return _read_pronunciation(lang)
@router.post("/admin/pronunciation/{lang}", dependencies=[Depends(require_admin)])
async def add_pronunciation(lang: str, entry: PronunciationEntry):
"""Fuegt einen Eintrag zum Aussprache-Lexikon hinzu oder ueberschreibt ihn."""
if lang not in _ALLOWED_LANGS:
raise HTTPException(status_code=400, detail=f"Sprache muss eine von {_ALLOWED_LANGS} sein.")
if entry.section not in _ALLOWED_SECTIONS:
raise HTTPException(status_code=400, detail=f"Section muss eine von {_ALLOWED_SECTIONS} sein.")
if not entry.key.strip() or not entry.value.strip():
raise HTTPException(status_code=422, detail="key und value duerfen nicht leer sein.")
data = _read_pronunciation(lang)
data[entry.section][entry.key.strip()] = entry.value.strip()
# Nach jedem Einfügen: Sektion alphabetisch aufsteigend nach Schlüssel sortieren.
data[entry.section] = dict(
sorted(data[entry.section].items(), key=lambda kv: kv[0].lower())
)
_write_pronunciation(lang, data)
return {"section": entry.section, "key": entry.key.strip(), "value": entry.value.strip()}
@router.delete("/admin/pronunciation/{lang}/{section}/{key:path}", dependencies=[Depends(require_admin)])
async def delete_pronunciation(lang: str, section: str, key: str):
"""Loescht einen Eintrag aus dem Aussprache-Lexikon."""
if lang not in _ALLOWED_LANGS:
raise HTTPException(status_code=400, detail="Unbekannte Sprache.")
if section not in _ALLOWED_SECTIONS:
raise HTTPException(status_code=400, detail="Unbekannte Section.")
data = _read_pronunciation(lang)
if key not in data[section]:
raise HTTPException(status_code=404, detail=f"Eintrag '{key}' nicht gefunden.")
del data[section][key]
_write_pronunciation(lang, data)
return {"deleted": key}
# ── Laufzeit-Konfiguration ─────────────────────────────────────────────────
class ConfigValue(BaseModel):
value: str
@router.get("/admin/config", dependencies=[Depends(require_admin)])
async def get_runtime_config():
"""Gibt alle überschreibbaren Einstellungen mit aktuellem Wert zurück."""
overrides = get_store().get_config_overrides()
result = []
for key, (label, type_str, hint) in RUNTIME_SETTABLE.items():
base_val = getattr(settings, key, None)
effective_val = getattr(runtime_settings, key, base_val)
result.append({
"key": key,
"label": label,
"type": type_str,
"hint": hint,
"base_value": str(base_val) if base_val is not None else "",
"override_value": overrides.get(key),
"effective_value": str(effective_val) if effective_val is not None else "",
"is_overridden": key in overrides,
})
return result
@router.put("/admin/config/{key}", dependencies=[Depends(require_admin)])
async def set_runtime_config(key: str, body: ConfigValue, request: Request):
"""Setzt eine Laufzeit-Einstellung (wirkt sofort, kein Neustart nötig)."""
if key not in RUNTIME_SETTABLE:
raise HTTPException(status_code=400, detail=f"Nicht überschreibbar: {key!r}")
value = body.value.strip()
get_store().set_config_override(key, value)
invalidate_cache()
log_admin_action(request, "config_set", key=key, value=value)
return {"key": key, "value": value}
@router.delete("/admin/config/{key}", dependencies=[Depends(require_admin)])
async def delete_runtime_config(key: str, request: Request):
"""Entfernt eine Laufzeit-Einstellung (fällt auf .env-Wert zurück)."""
if key not in RUNTIME_SETTABLE:
raise HTTPException(status_code=400, detail=f"Nicht überschreibbar: {key!r}")
deleted = get_store().delete_config_override(key)
invalidate_cache()
if not deleted:
raise HTTPException(status_code=404, detail=f"Kein Override für {key!r} gesetzt.")
log_admin_action(request, "config_reset", key=key)
return {"deleted": key}
# ── Live-Log (journalctl → WebSocket) ──────────────────────────────────────
@router.websocket("/admin/log")
async def admin_log_ws(websocket: WebSocket, key: str | None = None):
"""Streamt den systemd-Journal-Log des Voice-Assistant-Service live."""
# Auth: Admin-Key als Query-Param ODER SSO-Identitaet via Cookie/Header.
from app.auth import authenticate, _bearer_token
client_host = websocket.client.host if websocket.client else ""
token = _bearer_token(websocket.headers.get("authorization")) or key
user = authenticate(websocket.headers, client_host, token)
if not is_admin_user(user):
await websocket.close(code=1008)
return
await websocket.accept()
# Hinweis, falls die laufende Instanz NICHT der systemd-Dienst ist (z. B. manueller
# `uvicorn --reload`-Start). Dann hat das Journal dieser Unit keine aktuellen Zeilen,
# und der Log-Tab bliebe sonst kommentarlos leer.
try:
check = await asyncio.create_subprocess_exec(
"systemctl", "--user", "is-active", "voice-assistant.service",
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL,
)
out, _ = await check.communicate()
if out.decode().strip() != "active":
await websocket.send_text(
"⚠ Der systemd-Dienst 'voice-assistant.service' ist nicht aktiv — "
"die laufende Instanz wurde vermutlich manuell gestartet (uvicorn --reload). "
"Live-Logs erscheinen hier nur, wenn das Gateway als Dienst läuft "
"(systemctl --user start voice-assistant.service). Manuelle Starts loggen ins Terminal."
)
except Exception:
pass
proc = await asyncio.create_subprocess_exec(
"journalctl", "--user", "-f", "-u", "voice-assistant.service",
"-n", "100", "--no-pager", "-o", "short",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
)
try:
while True:
line = await proc.stdout.readline()
if not line:
break
await websocket.send_text(line.decode("utf-8", "replace").rstrip())
except (WebSocketDisconnect, Exception):
pass
finally:
try:
proc.terminate()
except Exception:
pass

View file

@ -1,151 +0,0 @@
from io import BytesIO
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import JSONResponse, StreamingResponse
from app.config import settings
from app.errors import RoutingError
from app.auth import require_user
from app.store import ANONYMOUS_USER_ID, User, SessionOwnershipError
from app.dependencies import (
resolve_route,
build_orchestrator,
resolve_output_endpoint,
get_store,
piper_voice_for_language,
)
from app.core.memory_extractor import maybe_schedule_extraction
from app.quota import enforce_quota, record_usage, QuotaExceededError
from app.safety.emergency import handle_emergency, schedule_llm_emergency_check
from app.schemas import ChatRequest
router = APIRouter()
def _route_headers(route) -> dict:
return {
"X-Input-Endpoint": route.input_endpoint,
"X-Output-Endpoint": route.output_endpoint,
"X-STT-Provider": route.stt_provider,
"X-LLM-Provider": route.llm_provider,
"X-TTS-Provider": route.tts_provider,
}
@router.post("/chat")
async def chat(
payload: ChatRequest,
debug: bool = Query(
default=False,
description="Return JSON trace instead of audio response",
),
session_id: str | None = Query(
default=None,
description="Optional session id to apply a stored route",
),
user: User = Depends(require_user),
):
overrides = {
"input_endpoint": payload.input_endpoint,
"output_endpoint": payload.output_endpoint,
"language": payload.language,
"language_mode": payload.language_mode,
"stt_provider": payload.stt_provider,
"llm_provider": payload.llm_provider,
"tts_provider": payload.tts_provider,
}
store = get_store()
try:
route = resolve_route(user, session_id, overrides)
orchestrator = build_orchestrator(route)
output = await resolve_output_endpoint(route)
# Gespraechsverlauf laden (nur bei gesetzter session_id -> sonst zustandslos).
conversation = (
store.get_recent_messages(session_id, settings.history_max_messages)
if session_id
else []
)
# Langzeit-Erinnerungen sind nutzerbezogen und gelten auch ohne Session.
memories = store.get_memories(user.id)
except SessionOwnershipError as exc:
raise HTTPException(status_code=403, detail=str(exc))
except RoutingError as exc:
raise HTTPException(status_code=422, detail=str(exc))
llm_context = list(conversation)
user_context_parts = []
if user.id != ANONYMOUS_USER_ID:
user_context_parts.append(f"Du sprichst mit {user.display_name}.")
if memories:
user_context_parts.append(
"Was du ueber den Nutzer weisst:\n" + "\n".join(f"- {m.content}" for m in memories)
)
if user_context_parts:
llm_context = [{"role": "system", "content": "\n".join(user_context_parts)}] + llm_context
# Notfall-Erkennung zuerst (immer eskalieren, auch bei Quota-Limit).
emergency = handle_emergency(user, payload.text, store)
# Stufe 2: LLM-Klassifikation als Hintergrund-Task (nur wenn Stichwoerter nichts fanden).
schedule_llm_emergency_check(user, payload.text, store, emergency)
if emergency is None:
try:
enforce_quota(user, store)
except QuotaExceededError as exc:
raise HTTPException(status_code=429, detail=str(exc))
# Explizit angeforderte Stimme gewinnt; sonst folgt sie der Routensprache.
voice = payload.voice or piper_voice_for_language(route.tts_provider, route.language)
try:
trace, audio = await orchestrator.chat_text(
payload.text,
language=route.language,
voice=voice,
output=output,
history=llm_context,
language_mode=route.language_mode,
text_only=bool(payload.text_only),
)
except Exception as exc:
raise HTTPException(status_code=502, detail=str(exc))
record_usage(user, store, len(payload.text) + len(trace.semantic_response or ""))
# Turn persistieren (User-Eingabe + semantische Antwort) fuer das Gedaechtnis.
if session_id:
store.append_message(session_id, user.id, "user", payload.text)
store.append_message(session_id, user.id, "assistant", trace.semantic_response)
maybe_schedule_extraction(store, user.id, session_id)
if debug:
return JSONResponse(
content={
"ok": True,
"voice": voice,
"route": route.as_dict(),
"history_len": len(conversation),
"memories_len": len(memories),
"emergency": emergency,
"trace": {
"raw_transcript": trace.raw_transcript,
"cleaned_transcript": trace.cleaned_transcript,
"semantic_response": trace.semantic_response,
"spoken_response": trace.spoken_response,
"tts_ready_text": trace.tts_ready_text,
},
}
)
headers = {
"Content-Language": route.language,
"X-Audio-Format": "pcm",
"X-Audio-Sample-Rate": "24000",
"X-Audio-Channels": "1",
"X-Audio-Sample-Width": "16",
**_route_headers(route),
}
if emergency:
headers["X-Emergency"] = emergency["category"]
return StreamingResponse(BytesIO(audio), media_type="audio/pcm", headers=headers)

View file

@ -1,37 +0,0 @@
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()),
},
}

View file

@ -1,12 +0,0 @@
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()],
}

View file

@ -1,7 +0,0 @@
from fastapi import APIRouter
router = APIRouter()
@router.get("/health")
async def health():
return {"status": "ok"}

View file

@ -1,48 +0,0 @@
from fastapi import APIRouter, Depends, HTTPException
from app.auth import require_user, is_admin_user
from app.config import settings
from app.dependencies import get_store
from app.schemas import UserPrefs, MemoryCreate, MemoryOut
from app.store import User
router = APIRouter()
@router.get("/me")
async def get_me(user: User = Depends(require_user)):
return {
"user_id": user.id,
"display_name": user.display_name,
"external_id": user.external_id,
"is_admin": user.is_admin or is_admin_user(user),
"prefs": user.prefs,
"sso_logout_url": settings.sso_logout_url,
}
@router.put("/me/prefs")
async def set_my_prefs(payload: UserPrefs, user: User = Depends(require_user)):
"""Setzt dauerhafter Routing-Präferenzen des Nutzers. Merge mit bestehenden Prefs."""
new = {k: v for k, v in payload.model_dump().items() if v is not None}
merged = {**user.prefs, **new}
updated = get_store().set_user_prefs(user.id, merged)
return {"user_id": updated.id, "prefs": updated.prefs}
@router.get("/me/memories", response_model=list[MemoryOut])
async def list_memories(user: User = Depends(require_user)):
return get_store().get_memories(user.id)
@router.post("/me/memories", response_model=MemoryOut)
async def add_memory(payload: MemoryCreate, user: User = Depends(require_user)):
"""Speichert einen dauerhaften Fakt/eine Vorliebe ueber den Nutzer."""
return get_store().add_memory(user.id, payload.content.strip())
@router.delete("/me/memories/{memory_id}")
async def delete_memory(memory_id: int, user: User = Depends(require_user)):
if not get_store().delete_memory(user.id, memory_id):
raise HTTPException(status_code=404, detail="Memory not found")
return {"ok": True, "deleted": memory_id}

View file

@ -1,13 +0,0 @@
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()

Some files were not shown because too many files have changed in this diff Show more