Compare commits
147 changed files with 15475 additions and 22 deletions
112
.env.example
Normal file
112
.env.example
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
# Port bei Bedarf anpassen
|
||||||
|
APP_ENV=dev
|
||||||
|
HOST=0.0.0.0
|
||||||
|
PORT=8080
|
||||||
|
LOG_LEVEL=info
|
||||||
|
|
||||||
|
# Secret nur ueber die Umgebung setzen (nicht hier eintragen), z. B. export in ~/.bashrc
|
||||||
|
OPENROUTER_API_KEY=
|
||||||
|
|
||||||
|
# Satzweises Vorlesen (Audio-Streaming) als Default fuer WebSocket-Turns.
|
||||||
|
# false = Antwort erst komplett synthetisieren, dann abspielen.
|
||||||
|
AUDIO_STREAM_DEFAULT=true
|
||||||
|
|
||||||
|
# Text-Normalisierung vor dem TTS (Aussprache): auto|full|light|off.
|
||||||
|
# auto = piper bekommt 'full' (Ordinalia/Einheiten/Abk./Lexikon), Cloud-TTS 'light'
|
||||||
|
# (nur Glaettung; Cloud spricht Zahlen/Abkuerzungen selbst gut). Lexikon:
|
||||||
|
# config/pronunciation.de.yaml (erweitert die eingebauten Defaults).
|
||||||
|
TTS_NORMALIZE_LEVEL=auto
|
||||||
|
|
||||||
|
# --- Authentifizierung -----------------------------------------------------
|
||||||
|
# AUTH_ENABLED=true (Standard) schuetzt chat/speak/transcribe/sessions per Bearer-Token.
|
||||||
|
# Fuer lokale Entwicklung/Tests auf false setzen (dann gilt ein anonymer Nutzer).
|
||||||
|
AUTH_ENABLED=true
|
||||||
|
# Schluessel fuer die Nutzerverwaltung (POST /api/admin/users). Nur ueber die Umgebung.
|
||||||
|
ADMIN_API_KEY=
|
||||||
|
|
||||||
|
# Forward-Auth via Reverse-Proxy/SSO (z. B. YunoHost). Nur fuer Remote-Betrieb -
|
||||||
|
# siehe deploy/README.md. Lokal leer lassen. Identitaet per Header ODER Cookie:
|
||||||
|
# TRUSTED_AUTH_HEADER=X-Remote-User # falls der Proxy einen Header setzt
|
||||||
|
# TRUSTED_AUTH_COOKIE=yunohost.portal # YunoHost: Username im JWT-Cookie
|
||||||
|
# TRUSTED_AUTH_COOKIE_CLAIM=user
|
||||||
|
# TRUSTED_AUTH_JWT_SECRET= # optional: HS256-Signatur pruefen
|
||||||
|
# TRUSTED_PROXY_IPS=192.168.0.10
|
||||||
|
# ADMIN_USERS=atoor,dieterschlueter,dschlueter
|
||||||
|
# SSO_LOGOUT_URL=https://linix.de/yunohost/sso/?action=logout
|
||||||
|
|
||||||
|
# --- Zentrale Konfiguration / Profile -------------------------------------
|
||||||
|
# Aktives Profil aus config/voice-assistant.toml waehlen: local-dev | hybrid | cloud
|
||||||
|
# (leer lassen = nur Defaults/ENV). Eigener Pfad via VA_CONFIG_FILE.
|
||||||
|
VA_PROFILE=
|
||||||
|
# VA_CONFIG_FILE=config/voice-assistant.toml
|
||||||
|
|
||||||
|
# Hinweis zur Praezedenz: ENV gewinnt ueber die TOML-Datei. Die DEFAULT_*_PROVIDER-
|
||||||
|
# Zeilen unten ueberschreiben daher ein gesetztes VA_PROFILE. Wer profilbasiert
|
||||||
|
# umschalten will, sollte sie auskommentiert lassen.
|
||||||
|
OPENROUTER_STT_MODEL=openai/whisper-large-v3
|
||||||
|
# Stimme haengt vom Modell ab (Gemini: Zephyr/Puck/Kore/...; OpenAI: alloy/echo/nova/...).
|
||||||
|
# Stimmenliste + Umstellen pro Aufruf: siehe BEDIENUNGSANLEITUNG ("Stimme des Cloud-TTS").
|
||||||
|
OPENROUTER_TTS_MODEL=openai/gpt-4o-mini-tts
|
||||||
|
OPENROUTER_TTS_VOICE=alloy
|
||||||
|
OPENROUTER_LLM_MODEL=openai/gpt-4.1-mini
|
||||||
|
DEFAULT_LANGUAGE=de
|
||||||
|
DEFAULT_INPUT_ENDPOINT=local-default
|
||||||
|
DEFAULT_OUTPUT_ENDPOINT=local-default
|
||||||
|
# DEFAULT_STT_PROVIDER=openrouter
|
||||||
|
# DEFAULT_LLM_PROVIDER=local-openai-compatible
|
||||||
|
# DEFAULT_TTS_PROVIDER=openrouter
|
||||||
|
# Lokaler llama.cpp-Server (zentrale, unzensierte KI). Start: scripts/llm-server/start-llm-server.sh
|
||||||
|
# LOCAL_LLM_MODEL muss dem --alias des Servers entsprechen (Default: va_llm).
|
||||||
|
LOCAL_LLM_BASE_URL=http://127.0.0.1:8001/v1
|
||||||
|
LOCAL_LLM_API_KEY=dummy
|
||||||
|
LOCAL_LLM_MODEL=va_llm
|
||||||
|
# Tempo-Hebel fuer den Sprach-Loop: Reasoning aus + knappe, vorlesbare Antworten.
|
||||||
|
# LOCAL_LLM_DISABLE_REASONING=true # Qwen3-Denkphase abschalten (deutlich schneller)
|
||||||
|
# LOCAL_LLM_MAX_TOKENS=0 # 0 = serverseitiges Limit (-n); z. B. 256 kappt lange Antworten
|
||||||
|
# LOCAL_LLM_TEMPERATURE=0.3
|
||||||
|
# LOCAL_LLM_SYSTEM_PROMPT=Du bist ein gesprochener Sprachassistent. Antworte kurz ...
|
||||||
|
|
||||||
|
# --- Lokales STT (faster-whisper; nur mit pip install -e .[local] ) ---------
|
||||||
|
FASTER_WHISPER_MODEL=base # tiny|base|small|medium|large-v3
|
||||||
|
FASTER_WHISPER_DEVICE=auto # auto|cpu|cuda
|
||||||
|
FASTER_WHISPER_COMPUTE_TYPE=default # default|int8|float16|int8_float16
|
||||||
|
|
||||||
|
# --- Lokales TTS (piper; Binary + Stimmmodell noetig) ------------------------
|
||||||
|
# Aktivieren z. B. mit DEFAULT_TTS_PROVIDER=piper (oder --tts-provider piper).
|
||||||
|
# Stimmen liegen als <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
Normal file
34
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
# Secrets / lokale Konfiguration
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Lokale/instanzspezifische Konfiguration (nur die *.example.toml wird versioniert)
|
||||||
|
config/voice-assistant.toml
|
||||||
|
|
||||||
|
# Persistente Daten (SQLite-DB etc.)
|
||||||
|
data/
|
||||||
|
|
||||||
|
# Generierte Audio-Ausgaben (z. B. chat_client.py)
|
||||||
|
*.wav
|
||||||
|
# Ausnahme: native Referenz-Stimmen je Sprache (versioniert, Feature-Asset)
|
||||||
|
!config/voices/*.wav
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.egg-info/
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
.pytest_cache/
|
||||||
|
|
||||||
|
# Lokale Tool-/Editor-Konfiguration
|
||||||
|
.claude/
|
||||||
|
|
||||||
|
# Editor-/Backup-Reste
|
||||||
|
*.bak
|
||||||
|
*.patch
|
||||||
|
*.orig
|
||||||
|
|
||||||
|
# Dateien
|
||||||
|
3
|
||||||
|
TODO.md
|
||||||
|
|
||||||
2455
BEDIENUNGSANLEITUNG.md
Normal file
2455
BEDIENUNGSANLEITUNG.md
Normal file
File diff suppressed because it is too large
Load diff
7
Dockerfile
Normal file
7
Dockerfile
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
FROM python:3.12-slim
|
||||||
|
WORKDIR /app
|
||||||
|
COPY pyproject.toml README.md ./
|
||||||
|
COPY app ./app
|
||||||
|
RUN pip install --no-cache-dir -U pip && pip install --no-cache-dir .[test]
|
||||||
|
EXPOSE 8080
|
||||||
|
CMD ["sh", "-c", "uvicorn app.main:app --host ${HOST:-0.0.0.0} --port ${PORT:-8080}"]
|
||||||
554
Docs/Neues_Konzept_mit_TTS_auf_Mobil_Geraet.md
Normal file
554
Docs/Neues_Konzept_mit_TTS_auf_Mobil_Geraet.md
Normal file
|
|
@ -0,0 +1,554 @@
|
||||||
|
# Voice Assistant, Ubuntu 24.04, Python, iPhone, Android-Handy:
|
||||||
|
|
||||||
|
Ich habe mit Python unter Linux einen Voice Assistenten Programmiert, mit dem sich der User unterhalten kann. Das System soll für Senioren stundenlangen Smalltalk mit KI ermöglichen (gegen Einsamkeit, multiuserfähig, mit Gedächtnis). Es funktioniert vereinfacht so: Das System wandelt Sprache in Text um (STT), gibt den Text an ein LLM-KI-Tool weiter. Das LLM formuliert eine adäquate Text-Antwort. Die wird angezeigt und in Sprache umgewandelt (TTS) und abgespielt.
|
||||||
|
|
||||||
|
Das funktioniert am Desktop und auf Handys sowie auf Tablets im Browser, am Server auch im Terminal. Aber die Umwandlung von Text to Speech (TTS) in sehr guter Sprachqualität (also nicht mit Piper oder chatterbox via Python, die auch implementiert sind) ist einerseits teuer (Google-TTS-Tool via OpenRouter kostet ca. 2 Cent pro Minute Sound) , andererseits kommt der Ton verzögert auf dem Handy an, weil im Vergleich zum reinen Text große Sounddateien übers Netz zum Handy geschickt werden müssen.
|
||||||
|
|
||||||
|
Nun stellt sich die Frage, ob es für IOS- und Android-Geräte die Möglichkeit gibt, den gesendeten Text erst auf dem Handy in Sprache mit professioneller Qualität umwandeln zu lassen. Das könnte unmittelbar passieren (Siri oder Ängliches) oder über Flat-Rate-Apps, die Cloud-Anbindung haben.
|
||||||
|
|
||||||
|
1. Geht das?
|
||||||
|
2. Wie teuer wäre das?
|
||||||
|
3. Wie schnell sind die Tools?
|
||||||
|
4. Könnte man eventuell auch sogar die TTS-Umwandlung auf dem Handy laufen lassen?
|
||||||
|
|
||||||
|
Ja, **das geht** sehr gut – und für Ihr Szenario ist „Text vom Server ans Handy schicken, TTS lokal auf dem Handy ausführen“ wahrscheinlich die beste Architektur, zumindest als Standardpfad für iPhone und viele Android-Geräte. iPhone und Android bringen eigene TTS-Engines mit, die direkt auf dem Gerät sprechen können; dadurch entfallen große Audiodateien über das Netz, die Latenz sinkt stark, und die laufenden Kosten für TTS können auf nahezu null fallen.[^1_1][^1_2]
|
||||||
|
|
||||||
|
## Machbarkeit
|
||||||
|
|
||||||
|
Auf dem iPhone kann eine App oder eine Weboberfläche Text an die systemeigene Sprachausgabe übergeben; Apple stellt dafür `AVSpeechSynthesizer` bereit, und Nutzer können in den Bedienungshilfen Stimmen auswählen, inklusive herunterladbarer „Enhanced Quality“-Stimmen. Diese erweiterten Stimmen sind lokal auf dem Gerät nutzbar und laut Apple oft 100 MB oder größer, was klar darauf hindeutet, dass die hochwertige Ausgabe zumindest nach dem Download lokal erfolgt.[^1_3][^1_1]
|
||||||
|
|
||||||
|
Auf Android gibt es ebenfalls eine eingebaute `TextToSpeech`-API mit `speak()`, also genau den Mechanismus, den man für eine lokale oder systemnahe Ausgabe braucht. In der Praxis hängt die Qualität dort stärker vom installierten TTS-Engine-Provider ab, etwa Google Speech Services oder Samsung, aber grundsätzlich ist On-Device-TTS auf Android ein Standard-Use-Case.[^1_4][^1_5][^1_2]
|
||||||
|
|
||||||
|
## Qualität und Geschwindigkeit
|
||||||
|
|
||||||
|
Für **Geschwindigkeit** ist lokales TTS auf dem Handy fast immer besser als serverseitig erzeugtes Audio, weil Sie nur Text übertragen statt MP3/Opus/WAV-Dateien. Das spart Netzlast und vermeidet den zusätzlichen Schritt „Audio generieren → speichern → übertragen → puffern → abspielen“; bei lokaler TTS beginnt die Wiedergabe oft quasi sofort nach Erhalt des Textes.[^1_2][^1_3]
|
||||||
|
|
||||||
|
Bei der **Qualität** ist iPhone derzeit meist der dankbarere Pfad, weil Apple hochwertige Systemstimmen und Enhanced-Varianten anbietet. Android kann ebenfalls gut klingen, aber dort ist die Qualität weniger homogen, weil sie vom Hersteller, der Android-Version und der installierten Engine abhängt.[^1_5][^1_1][^1_2]
|
||||||
|
|
||||||
|
## Kosten
|
||||||
|
|
||||||
|
Wenn Sie die System-TTS des Handys nutzen, sind die direkten TTS-Kosten typischerweise **null**, abgesehen von der einmaligen Datenmenge für den Download hochwertiger Stimmen und eventuell App-Entwicklung. Das ist für ein Senioren-Smalltalk-System wirtschaftlich sehr attraktiv, weil lange Gespräche nicht linear TTS-Kosten erzeugen.[^1_1][^1_2]
|
||||||
|
|
||||||
|
Zum Vergleich: Cloud-TTS kostet je nach Anbieter und Qualitätsstufe weiter spürbar Geld. Google Cloud verlangt zum Beispiel für Neural2-Stimmen 16 US-Dollar pro 1 Mio. Zeichen, für WaveNet 4 US-Dollar pro 1 Mio. Zeichen und für HD-Stimmen 30 US-Dollar pro 1 Mio. Zeichen; OpenAI listet für GPT-Realtime-Translate 0,034 US-Dollar pro Minute und für Realtime-Whisper 0,017 US-Dollar pro Minute, während die Realtime-Audioausgabe tokenbasiert abgerechnet wird. Ihr genannter Wert von etwa 2 Cent pro Minute liegt also durchaus in einer realistischen Größenordnung für hochwertige Cloud-Audio-Pipelines, besonders wenn noch ein Vermittler wie OpenRouter dazwischenliegt.[^1_6][^1_7]
|
||||||
|
|
||||||
|
## Sinnvolle Architektur
|
||||||
|
|
||||||
|
Für Ihr Projekt würde ich drei Betriebsmodi unterscheiden:
|
||||||
|
|
||||||
|
|
||||||
|
| Modus | Technik | Kosten | Latenz | Qualität | Eignung |
|
||||||
|
| :-- | :-- | --: | --: | :-- | :-- |
|
||||||
|
| Browser/App mit lokaler System-TTS | Text an iPhone/Android, dort sprechen | Nahe 0 [^1_1][^1_2] | Sehr gut [^1_2][^1_3] | iPhone meist gut bis sehr gut, Android geräteabhängig [^1_1][^1_2] | **Standardfall** |
|
||||||
|
| Cloud-TTS als Premium-Fallback | Server erzeugt Audio | Laufend nutzungsabhängig [^1_6][^1_7] | Mittel bis schwankend | Sehr hoch | Für Spezialstimmen |
|
||||||
|
| Komplett lokal auf Handy | Native App mit OS-TTS | Nahe 0 [^1_1][^1_2] | Sehr gut [^1_2][^1_3] | Gut, aber nicht völlig frei wählbar [^1_1][^1_2] | Beste mobile UX |
|
||||||
|
|
||||||
|
Der Knackpunkt ist: Im **Browser** ist das auf Android meist einfacher, weil Web Speech / systemnahe TTS dort oft besser nutzbar ist; auf iOS ist Browser-TTS funktional möglich, aber Apple ist restriktiver, und für eine robuste Produktlösung ist eine kleine native App oder ein WebView-Wrapper oft verlässlicher. Wenn Sie Multiuser, Gedächtnis und lange Sessions sauber betreiben wollen, wäre daher ein Server für STT/LLM/State plus mobile App für Playback eine sehr saubere Trennung.[^1_2][^1_3]
|
||||||
|
|
||||||
|
## Konkrete Antwort auf Ihre 4 Fragen
|
||||||
|
|
||||||
|
1. **Geht das?**
|
||||||
|
Ja. Sowohl iPhone als auch Android können empfangenen Text lokal in Sprache umwandeln; iPhone über Apples Sprachsynthese und Android über `TextToSpeech`.[^1_3][^1_1][^1_2]
|
||||||
|
2. **Wie teuer wäre das?**
|
||||||
|
Mit lokaler Geräte-TTS meist praktisch kostenlos im Betrieb. Cloud-TTS dagegen kostet je nach Stimme und Anbieter etwa von wenigen Dollar bis deutlich höheren Beträgen pro 1 Mio. Zeichen oder minutenbasiert bei Realtime-Angeboten.[^1_7][^1_6]
|
||||||
|
3. **Wie schnell sind die Tools?**
|
||||||
|
Lokale TTS ist in der Regel schneller wahrnehmbar, weil nur Text übertragen wird und keine Audiodatei gepuffert werden muss. Für Ihr Problem „Ton kommt verzögert aufs Handy“ ist das genau der Hauptvorteil.[^1_2][^1_3]
|
||||||
|
4. **Kann die TTS-Umwandlung auf dem Handy laufen?**
|
||||||
|
Ja, und genau das würde ich empfehlen. Auf iPhone ist das mit den Apple-Stimmen besonders interessant, auf Android ebenfalls gut machbar, aber mit etwas mehr Varianz bei der Stimmqualität.[^1_1][^1_2]
|
||||||
|
|
||||||
|
Für Ihr Seniorensystem würde ich praktisch so bauen: Server liefert nur Text, Metadaten und Steuerkommandos; die mobile App spricht lokal mit System-TTS; Cloud-TTS nur optional für „Premium-Stimme“ oder wenn eine ganz bestimmte Persona gebraucht wird. Soll ich Ihnen als Nächstes eine konkrete Zielarchitektur skizzieren – einmal als reine Browserlösung und einmal als robuste native iPhone/Android-App?[^1_7][^1_1][^1_2]
|
||||||
|
<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
|
||||||
|
|
||||||
293
Docs/voice-assistant-architecture.md
Normal file
293
Docs/voice-assistant-architecture.md
Normal file
|
|
@ -0,0 +1,293 @@
|
||||||
|
# Architektur: Modularer Voice-Assistent
|
||||||
|
|
||||||
|
> Stand: aktueller Implementierungsstand des Gateways. Dieses Dokument beschreibt
|
||||||
|
> das Konzept, den umgesetzten Stand und die geplanten nächsten Schritte.
|
||||||
|
|
||||||
|
## 1. Ziel & Leitidee
|
||||||
|
|
||||||
|
Ein modularer, **cloud-first, aber hybrid betreibbarer** Sprachassistent für
|
||||||
|
Senioren — ein „digitaler Vertrauter", erreichbar von zuhause und unterwegs.
|
||||||
|
|
||||||
|
Der Hauptbetrieb läuft in der Cloud / auf einem vHost (Senioren sollen keinen
|
||||||
|
teuren KI-Rechner zuhause brauchen). Gleichzeitig muss jede Achse frei wählbar
|
||||||
|
bleiben, je nach Installation und Entwicklungs-/Testbedarf:
|
||||||
|
|
||||||
|
- **Hardware** — Audio-Eingabe und -Ausgabe (lokal, Bluetooth, Handy, Netzwerk)
|
||||||
|
- **Betrieb** — lokal, Cloud oder hybrid
|
||||||
|
- **Software** — lokale oder entfernte KI (STT / LLM / TTS), ganz oder teilweise
|
||||||
|
|
||||||
|
Designziele: geringe Latenz, robuste Fallbacks, gute Debugbarkeit, klare Trennung
|
||||||
|
von **semantischer** und **gesprochener** Antwort, und vor allem **Austauschbarkeit**:
|
||||||
|
einzelne Module gegen andere tauschen, ohne das Programm umzuschreiben.
|
||||||
|
|
||||||
|
## 2. Architekturprinzip — fünf Ebenen
|
||||||
|
|
||||||
|
1. **Audio Endpoints** – konkrete Quellen/Ziele (lokal, Bluetooth, Handy, WebRTC)
|
||||||
|
2. **Device Router** – wählt passende Input-/Output-Endpunkte
|
||||||
|
3. **Speech Pipeline** – STT, Input-Cleaner, Dialog-LLM, Spoken-Response-Adapter, TTS-Normalizer, TTS
|
||||||
|
4. **Transport Router** – lokale vs. entfernte Ausführung einzelner Module
|
||||||
|
5. **Orchestrator** – Session, Turn-Taking, Fallbacks, Metrik
|
||||||
|
|
||||||
|
Jede Ebene kommuniziert über wohldefinierte Interfaces (ABCs + Pydantic-Schemas),
|
||||||
|
nicht über konkrete Bibliotheken.
|
||||||
|
|
||||||
|
## 3. Konfigurations- & Routing-Modell (umgesetzt)
|
||||||
|
|
||||||
|
Das Herzstück der Austauschbarkeit. Jede Achse ist auf mehreren Ebenen
|
||||||
|
einstellbar; höhere Ebene gewinnt:
|
||||||
|
|
||||||
|
```
|
||||||
|
eingebaute Defaults < config/voice-assistant.toml (inkl. aktivem Profil)
|
||||||
|
< ENV / .env < Nutzer-Prefs < Session-Route < Request
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.1 Zentrale Config + Profile
|
||||||
|
|
||||||
|
Eine geschichtete zentrale Datei (`config/voice-assistant.toml`, Vorlage
|
||||||
|
`config/voice-assistant.example.toml`), gelesen über die stdlib (`tomllib`).
|
||||||
|
**Profile** bündeln Betriebsarten und werden per ENV `VA_PROFILE` aktiviert:
|
||||||
|
|
||||||
|
| Profil | STT | LLM | TTS |
|
||||||
|
|-------------|----------------|--------------------------|------------|
|
||||||
|
| `local-dev` | faster-whisper | local-openai-compatible | piper |
|
||||||
|
| `hybrid` | openrouter | local-openai-compatible | openrouter |
|
||||||
|
| `cloud` | openrouter | openrouter | openrouter |
|
||||||
|
|
||||||
|
Umgesetzt in `app/config.py`: `load_profile_config()` merged `[defaults]` +
|
||||||
|
`[profiles.<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
18
LICENSE
|
|
@ -1,18 +0,0 @@
|
||||||
MIT License
|
|
||||||
|
|
||||||
Copyright (c) 2026 dschlueter
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
|
|
||||||
associated documentation files (the "Software"), to deal in the Software without restriction, including
|
|
||||||
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
|
|
||||||
following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all copies or substantial
|
|
||||||
portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
|
|
||||||
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
|
|
||||||
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
|
||||||
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
|
|
||||||
USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
||||||
28
LICENSE.md
Normal file
28
LICENSE.md
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
# Proprietäre Lizenz – Alle Rechte vorbehalten
|
||||||
|
|
||||||
|
Copyright (c) 2026 Dieter Schlüter. Alle Rechte vorbehalten.
|
||||||
|
|
||||||
|
Diese Software und die zugehörige Dokumentation (das „Werk") sind urheberrechtlich
|
||||||
|
geschützt und vertraulich. Sie sind **kein** Open-Source- oder freies Werk.
|
||||||
|
|
||||||
|
Ohne vorherige ausdrückliche schriftliche Genehmigung des Rechteinhabers ist es
|
||||||
|
untersagt, das Werk ganz oder teilweise zu:
|
||||||
|
|
||||||
|
- nutzen, ausführen oder bereitzustellen (außer durch den Rechteinhaber oder
|
||||||
|
ausdrücklich autorisierte Personen),
|
||||||
|
- kopieren, vervielfältigen oder speichern (außer technisch notwendige Kopien für
|
||||||
|
eine autorisierte Nutzung),
|
||||||
|
- verändern, übersetzen, bearbeiten oder davon abgeleitete Werke zu erstellen,
|
||||||
|
- weiterzugeben, zu veröffentlichen, zu verbreiten, zu vermieten, zu verkaufen,
|
||||||
|
zu unterlizenzieren oder anderweitig Dritten zugänglich zu machen,
|
||||||
|
- zu dekompilieren, zu disassemblieren oder zurückzuentwickeln, soweit nicht
|
||||||
|
zwingendes Recht dies ausdrücklich erlaubt.
|
||||||
|
|
||||||
|
Es werden keine Lizenz- oder sonstigen Rechte stillschweigend oder anderweitig
|
||||||
|
eingeräumt. Alle nicht ausdrücklich gewährten Rechte verbleiben beim Rechteinhaber.
|
||||||
|
|
||||||
|
DAS WERK WIRD „WIE BESEHEN" OHNE JEGLICHE GEWÄHRLEISTUNG ODER GARANTIE
|
||||||
|
BEREITGESTELLT. DER RECHTEINHABER HAFTET NICHT FÜR SCHÄDEN, DIE AUS DER NUTZUNG
|
||||||
|
ODER UNMÖGLICHKEIT DER NUTZUNG DES WERKS ENTSTEHEN, SOWEIT GESETZLICH ZULÄSSIG.
|
||||||
|
|
||||||
|
Anfragen zur Lizenzierung oder Nutzung: Dieter Schlüter <dieter.schlueter@linix.de>.
|
||||||
75
Makefile
Normal file
75
Makefile
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
ifneq (,$(wildcard ./.env))
|
||||||
|
include .env
|
||||||
|
export
|
||||||
|
endif
|
||||||
|
|
||||||
|
PORT ?= 8080
|
||||||
|
HOST ?= 0.0.0.0
|
||||||
|
|
||||||
|
CONTAINER_NAME ?= va_llm
|
||||||
|
|
||||||
|
.PHONY: ensure-env install run start stop restart test smoke docker-build llm-up llm-down llm-status llm-ollama llm-llamacpp
|
||||||
|
|
||||||
|
ensure-env:
|
||||||
|
@if [ ! -f .env ] && [ -f .env.example ]; then \
|
||||||
|
cp .env.example .env; \
|
||||||
|
echo "Created .env from .env.example"; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
install: ensure-env
|
||||||
|
python3 -m venv .venv
|
||||||
|
. .venv/bin/activate && pip install -U pip && pip install -e .[test]
|
||||||
|
|
||||||
|
run: ensure-env
|
||||||
|
. .venv/bin/activate && uvicorn app.main:app --host $(HOST) --port $(PORT) --reload
|
||||||
|
|
||||||
|
test: ensure-env
|
||||||
|
. .venv/bin/activate && pytest tests/
|
||||||
|
|
||||||
|
# Echter End-to-End-Test gegen OpenRouter (macht Netz-Aufrufe, kostet wenig).
|
||||||
|
smoke: ensure-env
|
||||||
|
. .venv/bin/activate && python scripts/smoke_e2e.py
|
||||||
|
|
||||||
|
docker-build:
|
||||||
|
docker build -t voice-assistant-gateway .
|
||||||
|
|
||||||
|
# --- Komplett-Befehle (Start / Stop / Restart) --------------------------------
|
||||||
|
|
||||||
|
# Stoppt alle Voice-Assistant-Komponenten: Gateway (Vordergrund + systemd) und
|
||||||
|
# llama.cpp-Container. Ollama (systemd) separat: sudo systemctl stop ollama
|
||||||
|
stop:
|
||||||
|
-pkill -f "uvicorn app.main:app" 2>/dev/null; true
|
||||||
|
-systemctl --user stop voice-assistant 2>/dev/null; true
|
||||||
|
-docker rm -f "$(CONTAINER_NAME)" 2>/dev/null; true
|
||||||
|
@echo "[*] Voice-Assistant gestoppt (Gateway + llama.cpp)."
|
||||||
|
|
||||||
|
# Startet llama.cpp-LLM-Server und danach das Gateway (Profil hybrid/local-dev).
|
||||||
|
# Fuer Profil cloud (kein lokales LLM): einfach 'make run'.
|
||||||
|
start: ensure-env
|
||||||
|
$(MAKE) llm-up
|
||||||
|
$(MAKE) run
|
||||||
|
|
||||||
|
# Faehrt alles herunter und startet neu (llama.cpp + Gateway).
|
||||||
|
restart: stop
|
||||||
|
$(MAKE) start
|
||||||
|
|
||||||
|
# --- Lokales LLM (llama.cpp-Server, zentrale unzensierte KI) -----------------
|
||||||
|
# Konfig per ENV ueberschreibbar, z. B.: GPU_DEVICE=2 HOST_PORT=8101 make llm-up
|
||||||
|
llm-up:
|
||||||
|
bash scripts/llm-server/start-llm-server.sh
|
||||||
|
|
||||||
|
llm-down:
|
||||||
|
bash scripts/llm-server/stop-llm-server.sh
|
||||||
|
|
||||||
|
llm-status:
|
||||||
|
bash scripts/llm-server/status-llm-server.sh
|
||||||
|
|
||||||
|
# --- LLM-Backend wechseln (Ollama <-> llama.cpp) -----------------------------
|
||||||
|
# Passt die LOCAL_LLM_*-Zeilen in .env an, gibt den GPU-Speicher des anderen
|
||||||
|
# Backends frei, startet das gewuenschte und startet das Gateway (falls Dienst) neu.
|
||||||
|
# Modell ueberschreibbar: OLLAMA_MODEL=qwen2.5:latest make llm-ollama
|
||||||
|
llm-ollama:
|
||||||
|
bash scripts/llm-server/switch-llm.sh ollama
|
||||||
|
|
||||||
|
llm-llamacpp:
|
||||||
|
bash scripts/llm-server/switch-llm.sh llamacpp
|
||||||
209
README.md
209
README.md
|
|
@ -1,5 +1,210 @@
|
||||||
# my_voice_assistant_v3
|
# Voice Assistant Gateway
|
||||||
|
|
||||||
Voice Assistant Gateway --- Modulares FastAPI-Gateway für einen **seniorengerechten Sprachassistenten** —
|
Modulares FastAPI-Gateway für einen **seniorengerechten Sprachassistenten** —
|
||||||
cloud-first, aber hybrid/lokal betreibbar, mit austauschbaren STT-/LLM-/TTS-Providern.
|
cloud-first, aber hybrid/lokal betreibbar, mit austauschbaren STT-/LLM-/TTS-Providern.
|
||||||
Jede Achse — Hardware, Betrieb, Software — ist frei konfigurierbar, ohne Code zu ändern.
|
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).
|
||||||
|
|
|
||||||
0
app/__init__.py
Normal file
0
app/__init__.py
Normal file
189
app/admin_llm.py
Normal file
189
app/admin_llm.py
Normal file
|
|
@ -0,0 +1,189 @@
|
||||||
|
"""Read-only Statusabfragen rund um das lokale LLM-Backend (fuer das Admin-Panel).
|
||||||
|
|
||||||
|
Alles nur lesend, ohne sudo: `docker ps`, `ollama ps`, `nvidia-smi`, `systemctl --user`.
|
||||||
|
Fehlende Tools oder Fehler fuehren zu sicheren Defaults (None/[]), nie zu Exceptions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
ALLOWED_BACKENDS = {"ollama", "llamacpp"}
|
||||||
|
_SCRIPT = Path(__file__).resolve().parent.parent / "scripts" / "llm-server" / "switch-llm.sh"
|
||||||
|
# Erlaubtes Format fuer Ollama-Modellnamen (zusaetzlich zur Pruefung gegen 'ollama list').
|
||||||
|
_MODEL_RE = re.compile(r"^[A-Za-z0-9._:/-]{1,100}$")
|
||||||
|
|
||||||
|
|
||||||
|
async def _run(cmd: list[str], timeout: float = 6.0) -> str | None:
|
||||||
|
"""Fuehrt ein Kommando aus (shell=False) und liefert stdout, oder None bei Fehler."""
|
||||||
|
if not shutil.which(cmd[0]):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
*cmd,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.DEVNULL,
|
||||||
|
env={**os.environ, "XDG_RUNTIME_DIR": os.environ.get(
|
||||||
|
"XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}")},
|
||||||
|
)
|
||||||
|
out, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
||||||
|
if proc.returncode != 0:
|
||||||
|
return None
|
||||||
|
return out.decode("utf-8", "replace")
|
||||||
|
except (asyncio.TimeoutError, OSError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_backend(base_url: str) -> str:
|
||||||
|
if ":11434" in base_url:
|
||||||
|
return "ollama"
|
||||||
|
if ":8001" in base_url:
|
||||||
|
return "llamacpp"
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
async def _llamacpp_running(container: str = "va_llm") -> bool:
|
||||||
|
out = await _run(["docker", "ps", "--filter", f"name=^{container}$", "--format", "{{.Names}}"])
|
||||||
|
return bool(out and container in out)
|
||||||
|
|
||||||
|
|
||||||
|
async def _ollama_loaded() -> tuple[bool, list[dict]]:
|
||||||
|
"""(dienst_erreichbar, [geladene Modelle])."""
|
||||||
|
out = await _run(["ollama", "ps"])
|
||||||
|
if out is None:
|
||||||
|
return False, []
|
||||||
|
models: list[dict] = []
|
||||||
|
lines = [ln for ln in out.splitlines() if ln.strip()]
|
||||||
|
for ln in lines[1:]: # Kopfzeile ueberspringen
|
||||||
|
# Spalten sind durch 2+ Leerzeichen getrennt: NAME ID SIZE PROCESSOR CONTEXT UNTIL
|
||||||
|
import re
|
||||||
|
cols = re.split(r"\s{2,}", ln.strip())
|
||||||
|
if cols:
|
||||||
|
models.append({
|
||||||
|
"name": cols[0],
|
||||||
|
"size": cols[2] if len(cols) > 2 else "",
|
||||||
|
"processor": cols[3] if len(cols) > 3 else "",
|
||||||
|
})
|
||||||
|
return True, models
|
||||||
|
|
||||||
|
|
||||||
|
async def _gpus() -> list[dict]:
|
||||||
|
out = await _run([
|
||||||
|
"nvidia-smi",
|
||||||
|
"--query-gpu=index,memory.used,memory.total",
|
||||||
|
"--format=csv,noheader,nounits",
|
||||||
|
])
|
||||||
|
if out is None:
|
||||||
|
return []
|
||||||
|
gpus: list[dict] = []
|
||||||
|
for ln in out.splitlines():
|
||||||
|
parts = [p.strip() for p in ln.split(",")]
|
||||||
|
if len(parts) == 3 and parts[0].isdigit():
|
||||||
|
used, total = int(parts[1]), int(parts[2])
|
||||||
|
gpus.append({
|
||||||
|
"index": int(parts[0]),
|
||||||
|
"used_mib": used,
|
||||||
|
"total_mib": total,
|
||||||
|
"percent": round(used / total * 100) if total else 0,
|
||||||
|
})
|
||||||
|
return gpus
|
||||||
|
|
||||||
|
|
||||||
|
async def _gateway_service_active() -> bool:
|
||||||
|
out = await _run(["systemctl", "--user", "is-active", "voice-assistant.service"], timeout=4.0)
|
||||||
|
return bool(out and out.strip() == "active")
|
||||||
|
|
||||||
|
|
||||||
|
async def llm_status() -> dict:
|
||||||
|
"""Aggregierter, read-only LLM-/System-Status fuer das Admin-Panel."""
|
||||||
|
base_url = settings.local_llm_base_url
|
||||||
|
backend = _detect_backend(base_url)
|
||||||
|
llamacpp, (ollama_reachable, ollama_models), gpus, gw = await asyncio.gather(
|
||||||
|
_llamacpp_running(),
|
||||||
|
_ollama_loaded(),
|
||||||
|
_gpus(),
|
||||||
|
_gateway_service_active(),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"backend": backend,
|
||||||
|
"model": settings.local_llm_model,
|
||||||
|
"base_url": base_url,
|
||||||
|
"llamacpp_running": llamacpp,
|
||||||
|
"ollama_reachable": ollama_reachable,
|
||||||
|
"ollama_loaded": ollama_models,
|
||||||
|
"gpus": gpus,
|
||||||
|
"gateway_service_active": gw,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Schreibende Steuerung (Backend-Wechsel / Gateway-Neustart) ───────────────
|
||||||
|
|
||||||
|
class LlmControlError(ValueError):
|
||||||
|
"""Validierungs-/Steuerfehler -> wird vom Endpoint als 400/422 gemeldet."""
|
||||||
|
|
||||||
|
|
||||||
|
async def available_ollama_models() -> list[str]:
|
||||||
|
"""Modellnamen aus `ollama list` (erste Spalte), oder []."""
|
||||||
|
out = await _run(["ollama", "list"])
|
||||||
|
if out is None:
|
||||||
|
return []
|
||||||
|
names: list[str] = []
|
||||||
|
for ln in out.splitlines()[1:]:
|
||||||
|
ln = ln.strip()
|
||||||
|
if ln:
|
||||||
|
names.append(ln.split()[0])
|
||||||
|
return names
|
||||||
|
|
||||||
|
|
||||||
|
async def switch_backend(backend: str, model: str | None = None) -> dict:
|
||||||
|
"""Validiert streng und startet den Backend-Wechsel als losgelösten Prozess.
|
||||||
|
|
||||||
|
Allowlist: backend ∈ {ollama, llamacpp}. Bei ollama muss `model` (falls gesetzt)
|
||||||
|
exakt in `ollama list` vorkommen. Nie freie Strings an die Shell — Aufruf mit
|
||||||
|
fester Argumentliste (shell=False); das Modell geht ausschließlich als Env-Var.
|
||||||
|
Der Wechsel läuft detached weiter (er startet ggf. das Gateway neu).
|
||||||
|
"""
|
||||||
|
if backend not in ALLOWED_BACKENDS:
|
||||||
|
raise LlmControlError(f"Unbekanntes Backend: {backend!r}")
|
||||||
|
if not _SCRIPT.exists():
|
||||||
|
raise LlmControlError("switch-llm.sh nicht gefunden")
|
||||||
|
|
||||||
|
env = {**os.environ}
|
||||||
|
if backend == "ollama" and model:
|
||||||
|
if not _MODEL_RE.match(model):
|
||||||
|
raise LlmControlError("Ungültiger Modellname")
|
||||||
|
available = await available_ollama_models()
|
||||||
|
if available and model not in available:
|
||||||
|
raise LlmControlError(f"Modell nicht in 'ollama list': {model!r}")
|
||||||
|
env["OLLAMA_MODEL"] = model
|
||||||
|
|
||||||
|
# Detached starten: der Wechsel kann das Gateway neu starten -> wir würden uns
|
||||||
|
# sonst selbst killen, bevor die HTTP-Antwort raus ist.
|
||||||
|
subprocess.Popen(
|
||||||
|
["bash", str(_SCRIPT), backend],
|
||||||
|
env=env,
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
start_new_session=True,
|
||||||
|
)
|
||||||
|
return {"status": "switching", "backend": backend, "model": model}
|
||||||
|
|
||||||
|
|
||||||
|
def restart_gateway_detached() -> dict:
|
||||||
|
"""Startet das Gateway als systemd-User-Dienst neu (losgelöst, Self-Restart-sicher)."""
|
||||||
|
xdg = os.environ.get("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}")
|
||||||
|
subprocess.Popen(
|
||||||
|
["bash", "-c",
|
||||||
|
f"sleep 1; XDG_RUNTIME_DIR={xdg} systemctl --user restart voice-assistant.service"],
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
start_new_session=True,
|
||||||
|
)
|
||||||
|
return {"status": "restarting"}
|
||||||
0
app/api/__init__.py
Normal file
0
app/api/__init__.py
Normal file
404
app/api/admin.py
Normal file
404
app/api/admin.py
Normal file
|
|
@ -0,0 +1,404 @@
|
||||||
|
import asyncio
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSocketDisconnect
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from app.admin_llm import (
|
||||||
|
LlmControlError,
|
||||||
|
llm_status,
|
||||||
|
restart_gateway_detached,
|
||||||
|
switch_backend,
|
||||||
|
)
|
||||||
|
from app.audit import log_admin_action
|
||||||
|
from app.auth import is_admin_user, require_admin, require_admin_or_user
|
||||||
|
from app.config import settings
|
||||||
|
from app.dependencies import get_store
|
||||||
|
from app.runtime_config import RUNTIME_SETTABLE, invalidate_cache, runtime_settings
|
||||||
|
from app.schemas import MemoryCreate, MemoryOut, UserCreate, UserCreated, UserUpdate
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
_CONFIG_DIR = Path(__file__).resolve().parents[2] / "config"
|
||||||
|
_ALLOWED_SECTIONS = {"abbreviations", "units", "terms"}
|
||||||
|
_ALLOWED_LANGS = {"de", "en", "fr", "es", "it", "nl", "ru", "zh"}
|
||||||
|
|
||||||
|
|
||||||
|
class PronunciationEntry(BaseModel):
|
||||||
|
section: str
|
||||||
|
key: str
|
||||||
|
value: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/admin/request-headers")
|
||||||
|
async def request_headers(request: Request, key: str | None = None):
|
||||||
|
"""Discovery: zeigt die eingehenden HTTP-Header + Quell-IP.
|
||||||
|
|
||||||
|
Hilft, hinter dem Reverse-Proxy/SSO den richtigen Identitaets-Header
|
||||||
|
(TRUSTED_AUTH_HEADER) festzustellen.
|
||||||
|
|
||||||
|
Henne-Ei: Beim Einrichten kennt das Gateway den SSO-Admin noch nicht. Daher
|
||||||
|
ist der Endpoint auch per Admin-Key aufrufbar - als Query (`?key=...`, im Browser
|
||||||
|
durchs SSO bequem) oder X-Admin-Key-Header. Nach dem Setup wieder meiden bzw.
|
||||||
|
den Key rotieren (er landet sonst in Proxy-Logs).
|
||||||
|
"""
|
||||||
|
expected = settings.admin_api_key.strip()
|
||||||
|
provided = key or request.headers.get("x-admin-key")
|
||||||
|
ok = bool(expected) and provided is not None and provided.strip() == expected
|
||||||
|
if not ok and not is_admin_user(_current_user_or_none(request)):
|
||||||
|
raise HTTPException(status_code=403, detail="Admin privileges required")
|
||||||
|
return {
|
||||||
|
"client": request.client.host if request.client else None,
|
||||||
|
"headers": dict(request.headers),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _current_user_or_none(request: Request):
|
||||||
|
from app.auth import authenticate, _bearer_token
|
||||||
|
|
||||||
|
client_host = request.client.host if request.client else ""
|
||||||
|
token = _bearer_token(request.headers.get("authorization"))
|
||||||
|
return authenticate(request.headers, client_host, token)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/admin/users", response_model=UserCreated, dependencies=[Depends(require_admin)])
|
||||||
|
async def create_user(payload: UserCreate):
|
||||||
|
"""Legt einen Nutzer an und gibt das Bearer-Token EINMALIG zurueck."""
|
||||||
|
user, token = get_store().create_user(payload.display_name)
|
||||||
|
return UserCreated(user_id=user.id, display_name=user.display_name, token=token)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/admin/users/{user_id}", dependencies=[Depends(require_admin)])
|
||||||
|
async def delete_user(user_id: str):
|
||||||
|
"""Loescht einen Nutzer und alle seine Daten (Sessions, Nachrichten, Erinnerungen,
|
||||||
|
Nutzungsdaten). Der anonyme Nutzer kann nicht geloescht werden."""
|
||||||
|
try:
|
||||||
|
deleted = get_store().delete_user(user_id)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
if not deleted:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Nutzer {user_id!r} nicht gefunden.")
|
||||||
|
return {"deleted": user_id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/admin/users/{user_id}/token", response_model=UserCreated, dependencies=[Depends(require_admin)])
|
||||||
|
async def reset_token(user_id: str):
|
||||||
|
"""Stellt einen neuen Bearer-Token aus; der alte wird sofort ungueltig.
|
||||||
|
Der neue Token wird EINMALIG zurueckgegeben und danach nicht mehr angezeigt."""
|
||||||
|
result = get_store().reset_token(user_id)
|
||||||
|
if result is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Nutzer {user_id!r} nicht gefunden.")
|
||||||
|
user, token = result
|
||||||
|
return UserCreated(user_id=user.id, display_name=user.display_name, token=token)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/admin/users/{user_id}", dependencies=[Depends(require_admin)])
|
||||||
|
async def update_user(user_id: str, payload: UserUpdate):
|
||||||
|
"""Aktualisiert den Anzeigenamen eines Nutzers (z. B. nach erstem SSO-Login)."""
|
||||||
|
user = get_store().update_display_name(user_id, payload.display_name)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Nutzer {user_id!r} nicht gefunden.")
|
||||||
|
return {"user_id": user.id, "display_name": user.display_name}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/admin/users/{user_id}/memories", response_model=MemoryOut, dependencies=[Depends(require_admin)])
|
||||||
|
async def add_user_memory(user_id: str, payload: MemoryCreate):
|
||||||
|
"""Legt eine Erinnerung fuer einen Nutzer an (Admin kann Kontext vorbelegen)."""
|
||||||
|
store = get_store()
|
||||||
|
if store.get_user(user_id) is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Nutzer {user_id!r} nicht gefunden.")
|
||||||
|
memory = store.add_memory(user_id, payload.content)
|
||||||
|
return MemoryOut(id=memory.id, content=memory.content, created_at=memory.created_at)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/admin/users", dependencies=[Depends(require_admin_or_user)])
|
||||||
|
async def list_users():
|
||||||
|
"""Listet die Nutzer (ohne Secrets). Fuer Admins (SSO/ADMIN_USERS) oder ADMIN_API_KEY."""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"user_id": u.id,
|
||||||
|
"display_name": u.display_name,
|
||||||
|
"external_id": u.external_id,
|
||||||
|
"created_at": u.created_at,
|
||||||
|
}
|
||||||
|
for u in get_store().list_users()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/admin/users/{user_id}/memories", dependencies=[Depends(require_admin)])
|
||||||
|
async def get_user_memories(user_id: str):
|
||||||
|
"""Gibt alle Erinnerungen eines Nutzers zurueck."""
|
||||||
|
store = get_store()
|
||||||
|
if store.get_user(user_id) is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Nutzer {user_id!r} nicht gefunden.")
|
||||||
|
return [
|
||||||
|
{"id": m.id, "content": m.content, "created_at": m.created_at}
|
||||||
|
for m in store.get_memories(user_id)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/admin/users/{user_id}/memories/{memory_id}", dependencies=[Depends(require_admin)])
|
||||||
|
async def delete_user_memory(user_id: str, memory_id: int):
|
||||||
|
"""Loescht eine einzelne Erinnerung eines Nutzers."""
|
||||||
|
deleted = get_store().delete_memory(user_id, memory_id)
|
||||||
|
if not deleted:
|
||||||
|
raise HTTPException(status_code=404, detail="Erinnerung nicht gefunden.")
|
||||||
|
return {"deleted": memory_id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/admin/users/{user_id}/sessions", dependencies=[Depends(require_admin)])
|
||||||
|
async def list_user_sessions(user_id: str):
|
||||||
|
"""Listet alle Sessions eines Nutzers (neueste zuerst)."""
|
||||||
|
return get_store().list_sessions_for_user(user_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/admin/sessions/{session_id}/messages", dependencies=[Depends(require_admin)])
|
||||||
|
async def get_session_messages(session_id: str, limit: int = 200):
|
||||||
|
"""Gibt alle Nachrichten einer Session zurueck (Gespraechs-Browser)."""
|
||||||
|
return get_store().get_messages_for_session(session_id, limit)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/admin/emergency-events", dependencies=[Depends(require_admin)])
|
||||||
|
async def list_emergency_events(limit: int = 50):
|
||||||
|
"""Listet alle protokollierten Notfall-Ereignisse (neueste zuerst)."""
|
||||||
|
return get_store().list_emergency_events(limit)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/admin/users/{user_id}/usage", dependencies=[Depends(require_admin)])
|
||||||
|
async def get_user_usage(user_id: str):
|
||||||
|
"""Nutzungsstatistik eines Nutzers (letzte 30 Tage)."""
|
||||||
|
return get_store().get_usage_for_user(user_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/admin/usage", dependencies=[Depends(require_admin)])
|
||||||
|
async def get_all_usage():
|
||||||
|
"""Aggregierte Nutzungsstatistik aller Nutzer."""
|
||||||
|
return get_store().get_all_usage()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Datenbank-Export ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/admin/db-export", dependencies=[Depends(require_admin)])
|
||||||
|
async def export_db():
|
||||||
|
"""Laed die SQLite-Datenbank als Datei herunter (Backup)."""
|
||||||
|
path = Path(settings.db_path)
|
||||||
|
if not path.exists():
|
||||||
|
raise HTTPException(status_code=404, detail="Datenbank nicht gefunden.")
|
||||||
|
return FileResponse(
|
||||||
|
path,
|
||||||
|
media_type="application/octet-stream",
|
||||||
|
filename="voice-assistant.db",
|
||||||
|
headers={"Content-Disposition": 'attachment; filename="voice-assistant.db"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── LLM-/System-Status (read-only) ──────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/admin/llm/status", dependencies=[Depends(require_admin)])
|
||||||
|
async def get_llm_status():
|
||||||
|
"""Read-only Status: aktives Backend, Modell, GPU-Auslastung, Dienste."""
|
||||||
|
return await llm_status()
|
||||||
|
|
||||||
|
|
||||||
|
class BackendSwitch(BaseModel):
|
||||||
|
backend: str
|
||||||
|
model: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/admin/llm/backend", dependencies=[Depends(require_admin)])
|
||||||
|
async def post_llm_backend(payload: BackendSwitch, request: Request):
|
||||||
|
"""Wechselt das LLM-Backend (Allowlist-validiert, detached). Greift voll erst,
|
||||||
|
wenn das Gateway als systemd-Dienst läuft; sonst muss es manuell neu starten."""
|
||||||
|
try:
|
||||||
|
result = await switch_backend(payload.backend, payload.model)
|
||||||
|
except LlmControlError as exc:
|
||||||
|
log_admin_action(request, "llm_backend_switch_rejected",
|
||||||
|
backend=payload.backend, model=payload.model, error=str(exc))
|
||||||
|
raise HTTPException(status_code=422, detail=str(exc))
|
||||||
|
log_admin_action(request, "llm_backend_switch",
|
||||||
|
backend=payload.backend, model=payload.model)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/admin/gateway/restart", dependencies=[Depends(require_admin)])
|
||||||
|
async def post_gateway_restart(request: Request):
|
||||||
|
"""Startet das Gateway (systemd-User-Dienst) neu — losgelöst, Self-Restart-sicher."""
|
||||||
|
log_admin_action(request, "gateway_restart")
|
||||||
|
return restart_gateway_detached()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Aussprache-Lexikon CRUD ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _read_pronunciation(lang: str) -> dict:
|
||||||
|
path = _CONFIG_DIR / f"pronunciation.{lang}.yaml"
|
||||||
|
if not path.exists():
|
||||||
|
return {"abbreviations": {}, "units": {}, "terms": {}}
|
||||||
|
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||||
|
return {
|
||||||
|
"abbreviations": dict(data.get("abbreviations") or {}),
|
||||||
|
"units": dict(data.get("units") or {}),
|
||||||
|
"terms": dict(data.get("terms") or {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _write_pronunciation(lang: str, data: dict) -> None:
|
||||||
|
path = _CONFIG_DIR / f"pronunciation.{lang}.yaml"
|
||||||
|
path.write_text(
|
||||||
|
yaml.dump(data, allow_unicode=True, default_flow_style=False, sort_keys=False),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
# LRU-Cache des Normalizers invalidieren, damit die Aenderung sofort greift.
|
||||||
|
from app.pipeline.tts_normalizer import _load_lexicon
|
||||||
|
_load_lexicon.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/admin/pronunciation/{lang}", dependencies=[Depends(require_admin)])
|
||||||
|
async def get_pronunciation(lang: str):
|
||||||
|
"""Gibt alle Eintraege des Aussprache-Lexikons zurueck."""
|
||||||
|
if lang not in _ALLOWED_LANGS:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Sprache muss eine von {_ALLOWED_LANGS} sein.")
|
||||||
|
return _read_pronunciation(lang)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/admin/pronunciation/{lang}", dependencies=[Depends(require_admin)])
|
||||||
|
async def add_pronunciation(lang: str, entry: PronunciationEntry):
|
||||||
|
"""Fuegt einen Eintrag zum Aussprache-Lexikon hinzu oder ueberschreibt ihn."""
|
||||||
|
if lang not in _ALLOWED_LANGS:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Sprache muss eine von {_ALLOWED_LANGS} sein.")
|
||||||
|
if entry.section not in _ALLOWED_SECTIONS:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Section muss eine von {_ALLOWED_SECTIONS} sein.")
|
||||||
|
if not entry.key.strip() or not entry.value.strip():
|
||||||
|
raise HTTPException(status_code=422, detail="key und value duerfen nicht leer sein.")
|
||||||
|
data = _read_pronunciation(lang)
|
||||||
|
data[entry.section][entry.key.strip()] = entry.value.strip()
|
||||||
|
# Nach jedem Einfügen: Sektion alphabetisch aufsteigend nach Schlüssel sortieren.
|
||||||
|
data[entry.section] = dict(
|
||||||
|
sorted(data[entry.section].items(), key=lambda kv: kv[0].lower())
|
||||||
|
)
|
||||||
|
_write_pronunciation(lang, data)
|
||||||
|
return {"section": entry.section, "key": entry.key.strip(), "value": entry.value.strip()}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/admin/pronunciation/{lang}/{section}/{key:path}", dependencies=[Depends(require_admin)])
|
||||||
|
async def delete_pronunciation(lang: str, section: str, key: str):
|
||||||
|
"""Loescht einen Eintrag aus dem Aussprache-Lexikon."""
|
||||||
|
if lang not in _ALLOWED_LANGS:
|
||||||
|
raise HTTPException(status_code=400, detail="Unbekannte Sprache.")
|
||||||
|
if section not in _ALLOWED_SECTIONS:
|
||||||
|
raise HTTPException(status_code=400, detail="Unbekannte Section.")
|
||||||
|
data = _read_pronunciation(lang)
|
||||||
|
if key not in data[section]:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Eintrag '{key}' nicht gefunden.")
|
||||||
|
del data[section][key]
|
||||||
|
_write_pronunciation(lang, data)
|
||||||
|
return {"deleted": key}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Laufzeit-Konfiguration ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class ConfigValue(BaseModel):
|
||||||
|
value: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/admin/config", dependencies=[Depends(require_admin)])
|
||||||
|
async def get_runtime_config():
|
||||||
|
"""Gibt alle überschreibbaren Einstellungen mit aktuellem Wert zurück."""
|
||||||
|
overrides = get_store().get_config_overrides()
|
||||||
|
result = []
|
||||||
|
for key, (label, type_str, hint) in RUNTIME_SETTABLE.items():
|
||||||
|
base_val = getattr(settings, key, None)
|
||||||
|
effective_val = getattr(runtime_settings, key, base_val)
|
||||||
|
result.append({
|
||||||
|
"key": key,
|
||||||
|
"label": label,
|
||||||
|
"type": type_str,
|
||||||
|
"hint": hint,
|
||||||
|
"base_value": str(base_val) if base_val is not None else "",
|
||||||
|
"override_value": overrides.get(key),
|
||||||
|
"effective_value": str(effective_val) if effective_val is not None else "",
|
||||||
|
"is_overridden": key in overrides,
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/admin/config/{key}", dependencies=[Depends(require_admin)])
|
||||||
|
async def set_runtime_config(key: str, body: ConfigValue, request: Request):
|
||||||
|
"""Setzt eine Laufzeit-Einstellung (wirkt sofort, kein Neustart nötig)."""
|
||||||
|
if key not in RUNTIME_SETTABLE:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Nicht überschreibbar: {key!r}")
|
||||||
|
value = body.value.strip()
|
||||||
|
get_store().set_config_override(key, value)
|
||||||
|
invalidate_cache()
|
||||||
|
log_admin_action(request, "config_set", key=key, value=value)
|
||||||
|
return {"key": key, "value": value}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/admin/config/{key}", dependencies=[Depends(require_admin)])
|
||||||
|
async def delete_runtime_config(key: str, request: Request):
|
||||||
|
"""Entfernt eine Laufzeit-Einstellung (fällt auf .env-Wert zurück)."""
|
||||||
|
if key not in RUNTIME_SETTABLE:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Nicht überschreibbar: {key!r}")
|
||||||
|
deleted = get_store().delete_config_override(key)
|
||||||
|
invalidate_cache()
|
||||||
|
if not deleted:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Kein Override für {key!r} gesetzt.")
|
||||||
|
log_admin_action(request, "config_reset", key=key)
|
||||||
|
return {"deleted": key}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Live-Log (journalctl → WebSocket) ──────────────────────────────────────
|
||||||
|
|
||||||
|
@router.websocket("/admin/log")
|
||||||
|
async def admin_log_ws(websocket: WebSocket, key: str | None = None):
|
||||||
|
"""Streamt den systemd-Journal-Log des Voice-Assistant-Service live."""
|
||||||
|
# Auth: Admin-Key als Query-Param ODER SSO-Identitaet via Cookie/Header.
|
||||||
|
from app.auth import authenticate, _bearer_token
|
||||||
|
client_host = websocket.client.host if websocket.client else ""
|
||||||
|
token = _bearer_token(websocket.headers.get("authorization")) or key
|
||||||
|
user = authenticate(websocket.headers, client_host, token)
|
||||||
|
if not is_admin_user(user):
|
||||||
|
await websocket.close(code=1008)
|
||||||
|
return
|
||||||
|
|
||||||
|
await websocket.accept()
|
||||||
|
|
||||||
|
# Hinweis, falls die laufende Instanz NICHT der systemd-Dienst ist (z. B. manueller
|
||||||
|
# `uvicorn --reload`-Start). Dann hat das Journal dieser Unit keine aktuellen Zeilen,
|
||||||
|
# und der Log-Tab bliebe sonst kommentarlos leer.
|
||||||
|
try:
|
||||||
|
check = await asyncio.create_subprocess_exec(
|
||||||
|
"systemctl", "--user", "is-active", "voice-assistant.service",
|
||||||
|
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
out, _ = await check.communicate()
|
||||||
|
if out.decode().strip() != "active":
|
||||||
|
await websocket.send_text(
|
||||||
|
"⚠ Der systemd-Dienst 'voice-assistant.service' ist nicht aktiv — "
|
||||||
|
"die laufende Instanz wurde vermutlich manuell gestartet (uvicorn --reload). "
|
||||||
|
"Live-Logs erscheinen hier nur, wenn das Gateway als Dienst läuft "
|
||||||
|
"(systemctl --user start voice-assistant.service). Manuelle Starts loggen ins Terminal."
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
"journalctl", "--user", "-f", "-u", "voice-assistant.service",
|
||||||
|
"-n", "100", "--no-pager", "-o", "short",
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.STDOUT,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
line = await proc.stdout.readline()
|
||||||
|
if not line:
|
||||||
|
break
|
||||||
|
await websocket.send_text(line.decode("utf-8", "replace").rstrip())
|
||||||
|
except (WebSocketDisconnect, Exception):
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
proc.terminate()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
151
app/api/chat.py
Normal file
151
app/api/chat.py
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from fastapi.responses import JSONResponse, StreamingResponse
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.errors import RoutingError
|
||||||
|
from app.auth import require_user
|
||||||
|
from app.store import ANONYMOUS_USER_ID, User, SessionOwnershipError
|
||||||
|
from app.dependencies import (
|
||||||
|
resolve_route,
|
||||||
|
build_orchestrator,
|
||||||
|
resolve_output_endpoint,
|
||||||
|
get_store,
|
||||||
|
piper_voice_for_language,
|
||||||
|
)
|
||||||
|
from app.core.memory_extractor import maybe_schedule_extraction
|
||||||
|
from app.quota import enforce_quota, record_usage, QuotaExceededError
|
||||||
|
from app.safety.emergency import handle_emergency, schedule_llm_emergency_check
|
||||||
|
from app.schemas import ChatRequest
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _route_headers(route) -> dict:
|
||||||
|
return {
|
||||||
|
"X-Input-Endpoint": route.input_endpoint,
|
||||||
|
"X-Output-Endpoint": route.output_endpoint,
|
||||||
|
"X-STT-Provider": route.stt_provider,
|
||||||
|
"X-LLM-Provider": route.llm_provider,
|
||||||
|
"X-TTS-Provider": route.tts_provider,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/chat")
|
||||||
|
async def chat(
|
||||||
|
payload: ChatRequest,
|
||||||
|
debug: bool = Query(
|
||||||
|
default=False,
|
||||||
|
description="Return JSON trace instead of audio response",
|
||||||
|
),
|
||||||
|
session_id: str | None = Query(
|
||||||
|
default=None,
|
||||||
|
description="Optional session id to apply a stored route",
|
||||||
|
),
|
||||||
|
user: User = Depends(require_user),
|
||||||
|
):
|
||||||
|
overrides = {
|
||||||
|
"input_endpoint": payload.input_endpoint,
|
||||||
|
"output_endpoint": payload.output_endpoint,
|
||||||
|
"language": payload.language,
|
||||||
|
"language_mode": payload.language_mode,
|
||||||
|
"stt_provider": payload.stt_provider,
|
||||||
|
"llm_provider": payload.llm_provider,
|
||||||
|
"tts_provider": payload.tts_provider,
|
||||||
|
}
|
||||||
|
store = get_store()
|
||||||
|
|
||||||
|
try:
|
||||||
|
route = resolve_route(user, session_id, overrides)
|
||||||
|
orchestrator = build_orchestrator(route)
|
||||||
|
output = await resolve_output_endpoint(route)
|
||||||
|
# Gespraechsverlauf laden (nur bei gesetzter session_id -> sonst zustandslos).
|
||||||
|
conversation = (
|
||||||
|
store.get_recent_messages(session_id, settings.history_max_messages)
|
||||||
|
if session_id
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
# Langzeit-Erinnerungen sind nutzerbezogen und gelten auch ohne Session.
|
||||||
|
memories = store.get_memories(user.id)
|
||||||
|
except SessionOwnershipError as exc:
|
||||||
|
raise HTTPException(status_code=403, detail=str(exc))
|
||||||
|
except RoutingError as exc:
|
||||||
|
raise HTTPException(status_code=422, detail=str(exc))
|
||||||
|
|
||||||
|
llm_context = list(conversation)
|
||||||
|
user_context_parts = []
|
||||||
|
if user.id != ANONYMOUS_USER_ID:
|
||||||
|
user_context_parts.append(f"Du sprichst mit {user.display_name}.")
|
||||||
|
if memories:
|
||||||
|
user_context_parts.append(
|
||||||
|
"Was du ueber den Nutzer weisst:\n" + "\n".join(f"- {m.content}" for m in memories)
|
||||||
|
)
|
||||||
|
if user_context_parts:
|
||||||
|
llm_context = [{"role": "system", "content": "\n".join(user_context_parts)}] + llm_context
|
||||||
|
|
||||||
|
# Notfall-Erkennung zuerst (immer eskalieren, auch bei Quota-Limit).
|
||||||
|
emergency = handle_emergency(user, payload.text, store)
|
||||||
|
# Stufe 2: LLM-Klassifikation als Hintergrund-Task (nur wenn Stichwoerter nichts fanden).
|
||||||
|
schedule_llm_emergency_check(user, payload.text, store, emergency)
|
||||||
|
|
||||||
|
if emergency is None:
|
||||||
|
try:
|
||||||
|
enforce_quota(user, store)
|
||||||
|
except QuotaExceededError as exc:
|
||||||
|
raise HTTPException(status_code=429, detail=str(exc))
|
||||||
|
|
||||||
|
# Explizit angeforderte Stimme gewinnt; sonst folgt sie der Routensprache.
|
||||||
|
voice = payload.voice or piper_voice_for_language(route.tts_provider, route.language)
|
||||||
|
|
||||||
|
try:
|
||||||
|
trace, audio = await orchestrator.chat_text(
|
||||||
|
payload.text,
|
||||||
|
language=route.language,
|
||||||
|
voice=voice,
|
||||||
|
output=output,
|
||||||
|
history=llm_context,
|
||||||
|
language_mode=route.language_mode,
|
||||||
|
text_only=bool(payload.text_only),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=502, detail=str(exc))
|
||||||
|
|
||||||
|
record_usage(user, store, len(payload.text) + len(trace.semantic_response or ""))
|
||||||
|
|
||||||
|
# Turn persistieren (User-Eingabe + semantische Antwort) fuer das Gedaechtnis.
|
||||||
|
if session_id:
|
||||||
|
store.append_message(session_id, user.id, "user", payload.text)
|
||||||
|
store.append_message(session_id, user.id, "assistant", trace.semantic_response)
|
||||||
|
maybe_schedule_extraction(store, user.id, session_id)
|
||||||
|
|
||||||
|
if debug:
|
||||||
|
return JSONResponse(
|
||||||
|
content={
|
||||||
|
"ok": True,
|
||||||
|
"voice": voice,
|
||||||
|
"route": route.as_dict(),
|
||||||
|
"history_len": len(conversation),
|
||||||
|
"memories_len": len(memories),
|
||||||
|
"emergency": emergency,
|
||||||
|
"trace": {
|
||||||
|
"raw_transcript": trace.raw_transcript,
|
||||||
|
"cleaned_transcript": trace.cleaned_transcript,
|
||||||
|
"semantic_response": trace.semantic_response,
|
||||||
|
"spoken_response": trace.spoken_response,
|
||||||
|
"tts_ready_text": trace.tts_ready_text,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Content-Language": route.language,
|
||||||
|
"X-Audio-Format": "pcm",
|
||||||
|
"X-Audio-Sample-Rate": "24000",
|
||||||
|
"X-Audio-Channels": "1",
|
||||||
|
"X-Audio-Sample-Width": "16",
|
||||||
|
**_route_headers(route),
|
||||||
|
}
|
||||||
|
if emergency:
|
||||||
|
headers["X-Emergency"] = emergency["category"]
|
||||||
|
return StreamingResponse(BytesIO(audio), media_type="audio/pcm", headers=headers)
|
||||||
37
app/api/config.py
Normal file
37
app/api/config.py
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from app.config import settings, active_profile
|
||||||
|
from app.dependencies import (
|
||||||
|
resolve_route,
|
||||||
|
get_audio_router,
|
||||||
|
STT_REGISTRY,
|
||||||
|
LLM_REGISTRY,
|
||||||
|
TTS_REGISTRY,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/config")
|
||||||
|
async def get_config():
|
||||||
|
"""Zeigt aktives Profil, aufgeloeste Default-Route und verfuegbare Bausteine.
|
||||||
|
|
||||||
|
Bewusst OHNE Secrets - API-Keys werden nur als 'gesetzt/nicht gesetzt' gemeldet.
|
||||||
|
"""
|
||||||
|
route = resolve_route()
|
||||||
|
audio_router = get_audio_router()
|
||||||
|
return {
|
||||||
|
"profile": active_profile(),
|
||||||
|
"app_env": settings.app_env,
|
||||||
|
"default_route": route.as_dict(),
|
||||||
|
"available": {
|
||||||
|
"stt_providers": sorted(STT_REGISTRY),
|
||||||
|
"llm_providers": sorted(LLM_REGISTRY),
|
||||||
|
"tts_providers": sorted(TTS_REGISTRY),
|
||||||
|
"input_endpoints": [c.model_dump() for c in await audio_router.list_inputs()],
|
||||||
|
"output_endpoints": [c.model_dump() for c in await audio_router.list_outputs()],
|
||||||
|
},
|
||||||
|
"secrets": {
|
||||||
|
"openrouter_api_key_set": bool(settings.openrouter_api_key.strip()),
|
||||||
|
},
|
||||||
|
}
|
||||||
12
app/api/devices.py
Normal file
12
app/api/devices.py
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from app.dependencies import get_audio_router
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
@router.get("/devices")
|
||||||
|
async def list_devices():
|
||||||
|
audio_router = get_audio_router()
|
||||||
|
return {
|
||||||
|
"inputs": [item.model_dump() for item in await audio_router.list_inputs()],
|
||||||
|
"outputs": [item.model_dump() for item in await audio_router.list_outputs()],
|
||||||
|
}
|
||||||
7
app/api/health.py
Normal file
7
app/api/health.py
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
@router.get("/health")
|
||||||
|
async def health():
|
||||||
|
return {"status": "ok"}
|
||||||
48
app/api/me.py
Normal file
48
app/api/me.py
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
|
||||||
|
from app.auth import require_user, is_admin_user
|
||||||
|
from app.config import settings
|
||||||
|
from app.dependencies import get_store
|
||||||
|
from app.schemas import UserPrefs, MemoryCreate, MemoryOut
|
||||||
|
from app.store import User
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me")
|
||||||
|
async def get_me(user: User = Depends(require_user)):
|
||||||
|
return {
|
||||||
|
"user_id": user.id,
|
||||||
|
"display_name": user.display_name,
|
||||||
|
"external_id": user.external_id,
|
||||||
|
"is_admin": user.is_admin or is_admin_user(user),
|
||||||
|
"prefs": user.prefs,
|
||||||
|
"sso_logout_url": settings.sso_logout_url,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/me/prefs")
|
||||||
|
async def set_my_prefs(payload: UserPrefs, user: User = Depends(require_user)):
|
||||||
|
"""Setzt dauerhafter Routing-Präferenzen des Nutzers. Merge mit bestehenden Prefs."""
|
||||||
|
new = {k: v for k, v in payload.model_dump().items() if v is not None}
|
||||||
|
merged = {**user.prefs, **new}
|
||||||
|
updated = get_store().set_user_prefs(user.id, merged)
|
||||||
|
return {"user_id": updated.id, "prefs": updated.prefs}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me/memories", response_model=list[MemoryOut])
|
||||||
|
async def list_memories(user: User = Depends(require_user)):
|
||||||
|
return get_store().get_memories(user.id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/me/memories", response_model=MemoryOut)
|
||||||
|
async def add_memory(payload: MemoryCreate, user: User = Depends(require_user)):
|
||||||
|
"""Speichert einen dauerhaften Fakt/eine Vorliebe ueber den Nutzer."""
|
||||||
|
return get_store().add_memory(user.id, payload.content.strip())
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/me/memories/{memory_id}")
|
||||||
|
async def delete_memory(memory_id: int, user: User = Depends(require_user)):
|
||||||
|
if not get_store().delete_memory(user.id, memory_id):
|
||||||
|
raise HTTPException(status_code=404, detail="Memory not found")
|
||||||
|
return {"ok": True, "deleted": memory_id}
|
||||||
13
app/api/metrics.py
Normal file
13
app/api/metrics.py
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
from fastapi import APIRouter, Query
|
||||||
|
from fastapi.responses import PlainTextResponse
|
||||||
|
|
||||||
|
from app.metrics import metrics
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/metrics")
|
||||||
|
async def get_metrics(format: str = Query(default="json", description="json | prometheus")):
|
||||||
|
if format == "prometheus":
|
||||||
|
return PlainTextResponse(metrics.prometheus(), media_type="text/plain; version=0.0.4")
|
||||||
|
return metrics.snapshot()
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue