Compare commits

..

No commits in common. "ebabc1e51be5cb37a1f978c536302cbd0ef8da05" and "789e3510fc6b14a470a7f3a041989d009e67bc26" have entirely different histories.

147 changed files with 15475 additions and 22 deletions

112
.env.example Normal file
View 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
View 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

File diff suppressed because it is too large Load diff

7
Dockerfile Normal file
View 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}"]

View 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 BrowserLösung (Web Speech / SystemTTS)
### Grundidee
- Dein PythonBackend liefert nur Text (LLMAntwort + Metadaten).
- Im Browser rufst du per JavaScript die **Web Speech / SpeechSynthesis API** auf, die wiederum die **jeweilige GeräteTTS** nutzt (auf Android typischerweise Google TTS, auf iOS die lokalen AppleStimmen).[^2_1][^2_2]
- Du überträgst also keinen Audiostream mehr, sondern nur Text → minimale Latenz, praktisch keine TTSKosten.[^2_2][^2_1]
### BrowserSupport und Stolpersteine
- Die SpeechSynthesisAPI ist in modernen DesktopBrowsern und auf iOS Safari, Chrome/Firefox Android weitgehend unterstützt.[^2_3][^2_4][^2_1]
- Android:
- Chrome für Android hat volle SpeechSynthesisUnterstützung, Firefox Android ebenfalls.[^2_4][^2_3]
- Die Stimmen kommen i.d.R. vom GoogleTTSDienst bzw. vom OSTTS; 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 UserEvent (ButtonKlick o.ä.) kommen, Hintergrundwiedergabe ist eingeschränkt und es gibt Bugs beim VoiceWechsel.[^2_8][^2_9][^2_10]
- Praktisch heißt das: Du brauchst am Anfang der Session einen expliziten „Audio aktivieren“-Button, der einmalig eine DummyUtterance abspielt, danach kannst du programmatisch sprechen.[^2_11][^2_10]
### Bewertung für dein Use Case
**Vorteile**
- Kein AppStoreDeployment nötig.
- Minimaler NetzwerkTraffic, TTS praktisch kostenlos.[^2_1][^2_2]
- Für AndroidTablets/Phones mit Chrome läuft das erstaunlich robust und performant.[^2_3][^2_4]
**Nachteile**
- iOS Safari ist launisch: kein BackgroundAudio, TTS stoppt teils beim Wechsel in andere Apps und erfordert Workarounds.[^2_12][^2_10][^2_8]
- Du hast relativ wenig Kontrolle über VoiceAuswahl, Lautstärke, AudioRouting 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 DisplayLock oder AppWechsel noch halbwegs stabil reagiert, kommst du auf iOS mit einer **App** deutlich entspannter ans Ziel.
***
## 2. Architektur mit nativer iOS und AndroidApp
Hier nutzt du dein Linux/PythonBackend 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 TextNachrichten.
- SessionManagement (UserID, 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: „SSMLMarken“ für Pausen, Betonung, etc. (einige mobile TTSEngines verstehen das).[^2_7][^2_13]
### iOSApp
- 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, HintergrundAudio lässt sich sauber konfigurieren (AVAudioSession, Background Modes).[^2_12][^2_14]
- Du umgehst alle Safari/WebSpeechEigenheiten komplett die App kontrolliert Wiedergabe, Lautstärke, Unterbrechung durch Telefonate etc.
### AndroidApp
- Nutzt die `TextToSpeech`API:
- `TextToSpeech(context, OnInitListener)`, danach `tts.speak(text, QUEUE_ADD, params, utteranceId)`.[^2_13][^2_7]
- TTSEngine: Google Speech Services (Standard) oder andere Engines, die Nutzer installieren.[^2_6][^2_5]
- Ab AndroidSeite kannst du Geschwindigkeit, Tonhöhe und ggf. Voice auswählen.[^2_7]
- Vorteile:
- Gute Latenz, da komplett lokal.[^2_7]
- HintergrundSpeech ist in nativen Apps deutlich besser handhabbar als im Browser.[^2_7]
### NetzwerkProtokoll 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` (LLMAntwort),
- 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 TTSQueue gestellt.
### Kosten, Geschwindigkeit, Qualität im Vergleich
| Variante | TTSOrt | Laufende TTSKosten | Latenz bis Ton | QualitätsKontrolle | Bemerkung |
| :-- | :-- | :-- | :-- | :-- | :-- |
| Heute (CloudTTS über OpenRouter) | Server | Cents pro Minute, summiert sich [^2_17][^2_18] | Audio über Netz → spürbare Verzögerung | Sehr hoch, aber Providerabhängig [^2_17][^2_18] | Problem: Kosten + Latenz |
| Webonly mit SpeechSynthesis | Handy / Browser | Praktisch 0 [^2_1][^2_2] | Sehr gut, nur Text übertragen [^2_1][^2_2] | gut, aber Browserabhängig [^2_3][^2_4] | Android sehr gut, iOS okay mit Workarounds |
| Native App + SystemTTS | 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 TTSEngines auf dem Handy (ohne Cloud)
Deine Frage 4 zielte noch einmal extra darauf ab, ob die gesamte TTSUmwandlung 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 TTSDienst ansprechen.[^2_14]
- Android:
- Google Speech Services („Speech Recognition \& Synthesis“) liefern lokale TextzuSpracheFunktionalität für Apps.[^2_5][^2_6]
- Die `TextToSpeech`API arbeitet i.d.R. auch offline, solange die entsprechende SprachEngine und Sprachpakete installiert sind.[^2_13][^2_7]
Damit hast du eine **vollständig lokale TTSPipeline 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:innenAssistenten würde ich:
1. **Kurzfristig**:
- In der bestehenden WebUI Web Speech / SpeechSynthesis integrieren (also im Browser TTS starten statt Audio vom Server zu spielen).
- Sicheren „Audio aktivieren“-Button implementieren (wegen iOSRestrictions).[^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 AndroidApp bauen:
- UI im Wesentlichen ein WebView/eine im Browser bereits bestehende Oberfläche,
- aber TTS und ggf. STT per nativen APIs (`AVSpeechSynthesizer` / `TextToSpeech`).
- Dein PythonBackend bleibt unverändert, liefert nur Text.
Wenn du mir sagst, ob du eher **WebOnly** bleiben willst oder mittelfristig **echte Apps** für iOS/Android planst, kann ich dir im nächsten Schritt sehr konkret Pseudocode/ArchitekturSnippets (inkl. BeispielWebSocketProtokoll und Swift/KotlinSkizzen) formulieren.
<span style="display:none">[^2_19][^2_20][^2_21][^2_22][^2_23][^2_24]</span>
<div align="center">⁂</div>
[^2_1]: https://wiki.selfhtml.org/wiki/Web_Speech
[^2_2]: https://developer.mozilla.org/en-US/docs/Web/API/Web_Speech_API
[^2_3]: https://www.lambdatest.com/web-technologies/speech-synthesis
[^2_4]: https://caniuse.com/speech-synthesis
[^2_5]: https://support.google.com/accessibility/android/answer/6006983?hl=en
[^2_6]: https://play.google.com/store/apps/details?id=com.google.android.tts
[^2_7]: https://developer.android.com/reference/android/speech/tts/TextToSpeech
[^2_8]: https://weboutloud.io/bulletin/speech_synthesis_in_safari/
[^2_9]: https://elliotbentley.com/blog/html5-speech-synthesis-api/
[^2_10]: https://stackoverflow.com/questions/67655133/when-will-speechsynthesis-speak-work-on-ios-safari/72199291
[^2_11]: https://www.reddit.com/r/learnjavascript/comments/1smimrx/help_mobile_safari_web_speech_api_silent_failure/
[^2_12]: https://developer.apple.com/forums/thread/771040
[^2_13]: https://android-developers.googleblog.com/2009/09/introduction-to-text-to-speech-in.html
[^2_14]: https://a11y-guidelines.orange.com/en/mobile/ios/wwdc/2018/236/
[^2_15]: https://github.com/NSHipster/articles/blob/master/2014-03-31-avspeechsynthesizer.md
[^2_16]: https://support.apple.com/en-lb/111798
[^2_17]: https://medium.com/@john.goodstadt/artificial-intelligence-from-an-ios-app-1-a880f3dd4323
[^2_18]: https://www.oreateai.com/blog/unlocking-androids-voice-a-deep-dive-into-texttospeech-api/7b5fcae54518ca663c26d61df106c6df
[^2_19]: https://developer.mozilla.org/fr/docs/Web/API/Web_Speech_API/Using_the_Web_Speech_API
[^2_20]: https://developer.chrome.com/blog/web-apps-that-talk-introduction-to-the-speech-synthesis-api
[^2_21]: https://dzone.com/articles/the-developers-guide-to-web-speech-api-what-is-it
[^2_22]: http://devdoc.net/web/developer.mozilla.org/en-US/docs/Web/API/Web_Speech_API/Using_the_Web_Speech_API.html
[^2_23]: https://developer.chrome.com/blog/voice-driven-web-apps-introduction-to-the-web-speech-api
[^2_24]: https://blog.addpipe.com/a-deep-dive-into-the-web-speech-api/
---
# Könnte man eventuell auch sogar die STT-Umwandlung auf dem Handy laufen lassen?
Ja **STT auf dem Handy** laufen zu lassen ist grundsätzlich möglich, und technisch sogar sehr attraktiv für dein System. Sowohl iPhone als auch Android bieten Wege für lokale bzw. gerätenahe Spracherkennung; im Browser ist das aber deutlich unzuverlässiger als in nativen Apps.[^3_1][^3_2][^3_3]
## Native Apps
Auf iPhone unterstützt Apples Speech-Framework On-Device-Erkennung; seit iOS 13 gibt es dafür die Option `requiresOnDeviceRecognition`, also echte lokale Transkription ohne Serverpflicht, sofern Sprache und Gerät das unterstützen. Zusätzlich kann Apple auch Diktat- und Siri-nahe Funktionen teilweise offline ausführen, was zeigt, dass Sprachverarbeitung auf dem Gerät inzwischen ein normaler Pfad ist.[^3_4][^3_5][^3_6][^3_1]
Auf Android gibt es mit `SpeechRecognizer` die systemeigene API für Spracherkennung. Offline-Erkennung ist dort grundsätzlich möglich, wenn die passende Engine und die Offline-Sprachpakete installiert sind; die praktische Qualität hängt aber stärker als bei iOS von Hersteller, Android-Version und installierter Sprach-Engine ab.[^3_7][^3_8][^3_3]
## Browser
Im **mobilen Browser** ist STT die deutlich schwierigere Baustelle als TTS. Die Web Speech API unterstützt Spracherkennung zwar grundsätzlich, aber der Support ist laut aktuellen Kompatibilitätsübersichten nur partiell: Chrome auf Android und Safari auf iOS teilweise, Firefox Android gar nicht. Für ein Seniorenprodukt mit langen Gesprächen und Multiuser-Betrieb ist das zu fragil, wenn du eine verlässliche Hauptlösung willst.[^3_9][^3_10]
Es gibt zwar inzwischen in der Web Speech API auch einen Pfad für **On-Device-Speech-Recognition** über `processLocally = true`, inklusive Sprachpaket-Download via `SpeechRecognition.available()` und `SpeechRecognition.install()`. Aber das ist browserabhängig und noch weit davon entfernt, plattformübergreifend so robust zu sein wie native APIs.[^3_2][^3_9]
## Architekturfolgen
Wenn du STT aufs Handy verlagerst, sendet das Gerät nur noch **Text** oder kleine Transkriptions-Events an deinen Python-Server, statt kontinuierlich Audiodaten hochzuladen. Das senkt Bandbreite, reduziert Datenschutzrisiken und kann die wahrgenommene Reaktionszeit verbessern, weil das erste erkannte Textstück früher beim Backend ankommt.[^3_3][^3_1][^3_2]
Für deine Architektur ergeben sich damit drei sinnvolle Varianten:
- **STT lokal, TTS lokal, LLM am Server** wahrscheinlich der beste Zielzustand für mobile Apps.[^3_5][^3_1][^3_3]
- **STT serverseitig, TTS lokal** guter Zwischenschritt, wenn du am Backend wenig ändern willst.[^3_11][^3_12]
- **STT und TTS im Browser lokal** machbar als Experiment oder Android-first-Weblösung, aber nicht robust genug als alleinige Senioren-Produktionslösung.[^3_2][^3_9]
## Empfehlung
Für dein Szenario würde ich STT auf dem Handy **ja, aber vorzugsweise nativ** einplanen. iPhone ist dafür besonders interessant, weil Apple On-Device-Recognition offiziell unterstützt. Android geht ebenfalls gut, braucht aber mehr Tests pro Gerätekategorie und Sprachpaket-Setup.[^3_1][^3_7][^3_5][^3_3]
Die pragmatische Reihenfolge wäre:
- Erst **TTS lokal aufs Handy** verlagern, weil das sofort Kosten und Latenz spart.[^3_12][^3_11]
- Danach **STT nativ mobil** ergänzen.
- Browser-STT nur als optionalen Fallback oder Prototyp betrachten.[^3_9][^3_2]
Für deine Produktidee wäre also eine sehr plausible Endarchitektur:
**Mobiles Gerät:** STT lokal + TTS lokal.[^3_3][^3_1]
**Server unter Ubuntu/Python:** Session-Logik, Memory, Benutzerverwaltung, LLM-Orchestrierung.[^3_1][^3_3]
Soll ich dir als Nächstes eine konkrete **Soll-Architektur mit Komponentenliste** skizzieren getrennt nach Web-only, Android-App und iPhone-App?
<span style="display:none">[^3_13][^3_14][^3_15][^3_16][^3_17]</span>
<div align="center">⁂</div>
[^3_1]: https://stackoverflow.com/questions/42900254/is-there-a-way-to-use-ios-speech-recognition-in-offline-mode/57047665
[^3_2]: https://developer.mozilla.org/en-US/docs/Web/API/Web_Speech_API/Using_the_Web_Speech_API
[^3_3]: https://developer.android.com/reference/android/speech/SpeechRecognizer
[^3_4]: https://plymouth.libguides.com/tech/voicerecognition
[^3_5]: https://9to5mac.com/2019/11/07/new-dictation-app-brings-advanced-offline-transcription-capabilities-to-iphone-and-ipad/
[^3_6]: https://www.idownloadblog.com/2021/06/08/ios-15-siri-offline-features/
[^3_7]: https://stackoverflow.com/questions/31562229/offline-speech-recognition-in-android
[^3_8]: https://www.oreateai.com/blog/whispers-in-your-pocket-making-android-speechtotext-work-offline/7a0284926c7c184dd6483638a755f963
[^3_9]: https://www.lambdatest.com/web-technologies/speech-recognition
[^3_10]: https://blog.addpipe.com/a-deep-dive-into-the-web-speech-api/
[^3_11]: https://support.apple.com/en-lb/111798
[^3_12]: https://developer.android.com/reference/android/speech/tts/TextToSpeech
[^3_13]: https://soniqo.audio/getting-started/android
[^3_14]: https://devdoc.net/web/developer.mozilla.org/en-US/docs/Web/API/Web_Speech_API/Using_the_Web_Speech_API.html
[^3_15]: https://apps.apple.com/us/app/speech-to-text-offline/id6756486902
[^3_16]: https://www.reddit.com/r/androiddev/comments/w3lm92/offline_streaming_speech_to_text/
[^3_17]: https://towardsdatascience.com/web-speech-api-what-works-what-doesnt-and-how-to-improve-it-by-linking-it-to-a-gpt-language-dc1afde54ced/
---
# ja
Ja. Für dein System würde ich drei Soll-Architekturen unterscheiden: **Web-only**, **Android nativ zuerst**, und **iPhone nativ**. Die technische Kernidee ist in allen Fällen gleich: STT und TTS möglichst nahe ans Endgerät, während dein Ubuntu/Python-Server nur noch Orchestrierung, Gedächtnis, Benutzerverwaltung und LLM-Antworten übernimmt.[^4_1][^4_2][^4_3]
## Web-only
Bei einer reinen Weblösung läuft TTS über `speechSynthesis`, und STT über die Web Speech API. Neuere Web-API-Dokumentation beschreibt dafür sogar `processLocally = true` sowie Sprachpaket-Prüfung per `SpeechRecognition.available()` und Installation per `SpeechRecognition.install()`, also grundsätzlich einen Pfad zu lokaler Erkennung im Browser.[^4_2][^4_4][^4_5]
Für Produktion wäre das aber nur als **Best-Effort**-Variante sinnvoll, weil Browser-Support und Verhalten je nach Plattform stark schwanken. Deshalb sollte dein Server in dieser Variante immer auch einen Fallback haben: Browser-STT lokal, sonst Browser-/Server-STT; TTS lokal im Browser, sonst notfalls Audio vom Server.[^4_4][^4_5][^4_2]
### Komponenten
- Browser-UI: Aufnahme, Push-to-talk oder VAD, Textanzeige, lokale TTS/STT.[^4_2][^4_4]
- Python-Backend: Session-State, Multiuser, Gedächtnis, LLM, WebSocket-Transport.
- Fallback-Logik: erkennt, ob lokales STT/TTS verfügbar ist, und schaltet sonst auf Serverpfade um.[^4_5]
### Datenfluss
1. Browser startet lokale Erkennung, wenn verfügbar.[^4_2]
2. Browser sendet nur Text-Teilresultate oder Final-Text zum Server.
3. Server antwortet mit Text.
4. Browser spricht den Text lokal aus.
## Android nativ
Android ist als erster nativer Schritt besonders sinnvoll, weil `SpeechRecognizer` offiziell für App-seitige Spracherkennung vorgesehen ist und Offline-Betrieb mit installierten Sprachpaketen möglich ist. Für vollständig lokale Alternativen gibt es zudem erprobte Bibliotheken wie Vosk für Android, falls du dich nicht an die jeweilige System-Engine binden willst.[^4_6][^4_7][^4_3]
Damit könntest du auf Android **STT lokal + TTS lokal + LLM am Server** umsetzen. Das reduziert Netztraffic stark, verbessert Privatsphäre und senkt die laufenden Sprachkosten praktisch auf null.[^4_7][^4_3][^4_6]
### Komponenten
- Android-App:
- `SpeechRecognizer` für STT, primär lokal/offline wenn Sprachpakete vorhanden sind.[^4_3][^4_6]
- `TextToSpeech` für TTS.
- WebSocket-Client für Serveranbindung.
- Lokale Audio-/Sessionsteuerung.
- Python-Backend:
- Auth, Multiuser, Gedächtnis, Gesprächslogik, LLM.
### Datenfluss
1. Mikrofon geht an, Android-App transkribiert lokal.[^4_3]
2. Partials und Final-Text gehen per WebSocket an den Server.
3. Server generiert Antworttext.
4. Android-App spielt Antwort über lokale TTS.
### Praktische Empfehlung
- **Phase 1:** `SpeechRecognizer` + System-TTS.
- **Phase 2:** optional Vosk/ähnlich für vollständige Unabhängigkeit von Google-Services.[^4_7]
## iPhone nativ
Auf iPhone ist native STT ebenfalls möglich, und Apple dokumentiert dafür ausdrücklich `requiresOnDeviceRecognition`. Wenn diese Eigenschaft auf `true` gesetzt wird, verhindert das Senden der Audiodaten übers Netz; Apple weist aber darauf hin, dass On-Device-Erkennung weniger genau sein kann als Servererkennung. Apple empfiehlt außerdem, vorab zu prüfen, ob `supportsOnDeviceRecognition` vorhanden ist, und dann gezielt On-Device zu aktivieren.[^4_8][^4_9][^4_1]
Damit ist die iPhone-Zielarchitektur sehr klar: **Speech Framework lokal**, **AVSpeechSynthesizer lokal**, **LLM und Memory am Server**. Für dein Senioren-Szenario ist das attraktiv, weil du eine kontrollierte UX bekommst, ohne Safari-Eigenheiten im Browser.[^4_9][^4_1][^4_8]
### Komponenten
- iOS-App:
- `SFSpeechRecognizer` + Request mit `requiresOnDeviceRecognition = true`.[^4_1]
- Prüfung auf `supportsOnDeviceRecognition` vor Sessionstart.[^4_8][^4_9]
- `AVSpeechSynthesizer` für lokale Sprachausgabe.
- WebSocket-Client für Texttransport.
- Python-Backend:
- wie bei Android.
### Datenfluss
1. App nimmt Audio auf und transkribiert lokal, wenn unterstützt.[^4_9][^4_1]
2. Nur Text geht an den Server.
3. Server antwortet mit Text.
4. iPhone spricht lokal.
## Gemeinsame Serverarchitektur
Dein Ubuntu-24.04-/Python-Backend kann in allen drei Varianten nahezu gleich bleiben. Es sollte nur noch diese Kernrollen übernehmen:
- Benutzer- und Sitzungsverwaltung.
- Gedächtnis / Langzeitkontext.
- LLM-Orchestrierung.
- WebSocket- oder SSE-Schnittstelle für Text-Events.
- Optionaler Fallback-STT/TTS, wenn ein Client lokales Speech nicht kann.[^4_1][^4_3][^4_2]
Ein sinnvolles Nachrichtenmodell wäre:
- Client → Server:
- `session_start`
- `partial_transcript`
- `final_transcript`
- `barge_in`
- `tts_stop`
- Server → Client:
- `assistant_text_partial`
- `assistant_text_final`
- `memory_hint`
- `fallback_required`
Das passt gut zu langen Smalltalk-Sessions, weil du Unterbrechungen, Zwischenresultate und Streaming-Antworten sauber behandeln kannst.
## Empfehlung
Für dein Projekt würde ich die Reihenfolge so setzen:
| Stufe | Ziel | Warum |
| :-- | :-- | :-- |
| 1 | TTS lokal auf Handy | Sofort weniger Kosten und weniger Audio-Latenz. [^4_1][^4_3] |
| 2 | Android nativ mit lokalem STT | Technisch pragmatisch, gute Testbasis. [^4_3][^4_6] |
| 3 | iPhone nativ mit `requiresOnDeviceRecognition` | Sehr sauberer Apple-Pfad, gute UX. [^4_1][^4_9] |
| 4 | Browser-STT nur ergänzend | Nützlich als Fallback, aber nicht deine Hauptplattform. [^4_2][^4_5] |
Wenn du möglichst effizient vorgehen willst, wäre mein Rat: **Android nativ zuerst**, mit lokalem STT/TTS und deinem bestehenden Python-Server dahinter. Danach iPhone nativ mit Apples On-Device-Speech-Framework.[^4_7][^4_3][^4_9][^4_1]
Soll ich dir im nächsten Schritt eine **konkrete Referenzarchitektur mit Modulen, WebSocket-Events und Zustandsautomat** für dein Python-Backend plus Android-Client aufschreiben?
<span style="display:none">[^4_10][^4_11][^4_12][^4_13][^4_14][^4_15]</span>
<div align="center">⁂</div>
[^4_1]: https://developer.apple.com/documentation/speech/sfspeechrecognitionrequest/requiresondevicerecognition
[^4_2]: https://developer.mozilla.org/en-US/docs/Web/API/SpeechRecognition/processLocally
[^4_3]: https://developer.android.com/reference/android/speech/SpeechRecognizer
[^4_4]: https://developer.mozilla.org/en-US/docs/Web/API/SpeechRecognition
[^4_5]: https://developer.mozilla.org/ja/docs/Web/API/SpeechRecognition/available_static
[^4_6]: https://stackoverflow.com/questions/31562229/offline-speech-recognition-in-android
[^4_7]: https://github.com/alphacep/vosk-android-demo
[^4_8]: https://devstreaming-cdn.apple.com/videos/wwdc/2019/256p7m9z4yst71ai/256/256_advances_in_speech_recognition.pdf
[^4_9]: https://developer.apple.com/videos/play/wwdc2019/256/
[^4_10]: https://stackoverflow.com/questions/17003620/can-i-make-android-offline-voice-recognition-api-public
[^4_11]: https://developer.mozilla.org/ja/docs/Web/API/SpeechRecognition/processLocally
[^4_12]: https://stackoverflow.com/questions/38729105/how-to-make-ios-speech-to-text-persistent/38729106
[^4_13]: https://qiita.com/m-naoki-m/items/38eb7199a07e844d3f49
[^4_14]: https://www.geeksforgeeks.org/android/offline-speech-to-text-without-any-popup-dialog-in-android/
[^4_15]: https://chromium.googlesource.com/external/github.com/web-platform-tests/wpt/+/refs/tags/merge_pr_53592/speech-api/SpeechRecognition-phrases-manual.https.html

View file

@ -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
View file

@ -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
View 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
View 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

213
README.md
View file

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

189
app/admin_llm.py Normal file
View 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
View file

404
app/api/admin.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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()

21
app/api/sessions.py Normal file
View file

@ -0,0 +1,21 @@
from fastapi import APIRouter, Depends, HTTPException
from app.schemas import SessionRouteRequest
from app.auth import require_user
from app.dependencies import get_store
from app.store import User, SessionOwnershipError
router = APIRouter()
@router.post("/sessions/{session_id}/route")
async def set_session_route(
session_id: str,
payload: SessionRouteRequest,
user: User = Depends(require_user),
):
try:
session = get_store().update_session(session_id, user.id, payload.model_dump())
except SessionOwnershipError as exc:
raise HTTPException(status_code=403, detail=str(exc))
return {"session_id": session_id, "route": session.data}

74
app/api/speak.py Normal file
View file

@ -0,0 +1,74 @@
from io import BytesIO
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import StreamingResponse
from app.errors import RoutingError
from app.auth import require_user
from app.store import User, SessionOwnershipError
from app.dependencies import (
resolve_route,
build_orchestrator,
resolve_output_endpoint,
get_store,
piper_voice_for_language,
)
from app.quota import enforce_quota, record_usage, QuotaExceededError
from app.schemas import SpeakRequest
router = APIRouter()
@router.post("/speak")
async def speak(
payload: SpeakRequest,
session_id: str | None = Query(
default=None,
description="Optional session id to apply a stored route",
),
user: User = Depends(require_user),
):
overrides = {
"output_endpoint": payload.output_endpoint,
"language": payload.language,
"tts_provider": payload.tts_provider,
}
try:
route = resolve_route(user, session_id, overrides)
# Explizit angefragte Stimme gewinnt; sonst folgt sie der Sprache (Piper-Parität zu /chat).
voice = payload.voice or piper_voice_for_language(route.tts_provider, route.language)
orchestrator = build_orchestrator(route)
output = await resolve_output_endpoint(route)
except SessionOwnershipError as exc:
raise HTTPException(status_code=403, detail=str(exc))
except RoutingError as exc:
raise HTTPException(status_code=422, detail=str(exc))
store = get_store()
try:
enforce_quota(user, store)
except QuotaExceededError as exc:
raise HTTPException(status_code=429, detail=str(exc))
try:
audio = await orchestrator.speak_only(
payload.text,
voice=voice,
language=route.language,
output=output,
)
record_usage(user, store, len(payload.text))
headers = {
"Content-Language": route.language,
"X-Audio-Format": "pcm",
"X-Audio-Sample-Rate": "24000",
"X-Audio-Channels": "1",
"X-Audio-Sample-Width": "16",
"X-Output-Endpoint": route.output_endpoint,
"X-TTS-Provider": route.tts_provider,
}
return StreamingResponse(BytesIO(audio), media_type="audio/pcm", headers=headers)
except Exception as exc:
raise HTTPException(status_code=502, detail=str(exc))

65
app/api/transcribe.py Normal file
View file

@ -0,0 +1,65 @@
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile
from app.errors import RoutingError
from app.auth import require_user
from app.store import User, SessionOwnershipError
from app.dependencies import (
resolve_route,
build_orchestrator,
resolve_input_endpoint,
get_store,
)
from app.quota import enforce_quota, record_usage, QuotaExceededError
router = APIRouter()
@router.post("/transcribe")
async def transcribe(
file: UploadFile = File(...),
language: str | None = Form(default=None),
input_endpoint: str | None = Form(default=None),
stt_provider: str | None = Form(default=None),
session_id: str | None = Query(
default=None,
description="Optional session id to apply a stored route",
),
user: User = Depends(require_user),
):
overrides = {
"input_endpoint": input_endpoint,
"language": language,
"stt_provider": stt_provider,
}
try:
route = resolve_route(user, session_id, overrides)
orchestrator = build_orchestrator(route)
source = await resolve_input_endpoint(route)
except SessionOwnershipError as exc:
raise HTTPException(status_code=403, detail=str(exc))
except RoutingError as exc:
raise HTTPException(status_code=422, detail=str(exc))
store = get_store()
try:
enforce_quota(user, store)
except QuotaExceededError as exc:
raise HTTPException(status_code=429, detail=str(exc))
content = await file.read()
suffix = (file.filename or "audio.wav").rsplit(".", 1)[-1].lower()
try:
trace = await orchestrator.transcribe_only(
content,
fmt=suffix,
language=route.language,
input=source,
)
except Exception as exc:
raise HTTPException(status_code=502, detail=str(exc))
record_usage(user, store, len(trace.raw_transcript or ""))
return {"route": route.as_dict(), "trace": trace.model_dump()}

380
app/api/ws.py Normal file
View file

@ -0,0 +1,380 @@
"""WebSocket-Echtzeit-Chat und -Sprache.
- /ws/chat : Text rein (JSON pro Turn), Antwort als Event-Folge zurueck.
- /ws/voice: Audio rein (binaere Chunks + Control), Transkription -> selbe Pipeline.
Antwort-Events: ack -> [token*] -> [audio*] -> semantic -> done.
Mit {"stream":true} kommen LLM-Token live, mit {"audio_stream":true} das Audio
satzweise (chunked TTS). /ws/voice sendet zuvor ein transcript-Event.
Barge-in: Ein {"type":"interrupt"}-Frame oder eine neue Eingabe bricht eine laufende
Antwort ab (-> interrupted-Event). Der Antwort-Turn laeuft als abbrechbarer Task.
Spaeter (eigene Increments): echte partielle Live-Transkripte (Streaming-STT-Dienst),
WebRTC.
"""
import asyncio
import json
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from app.config import settings
from app.errors import RoutingError
from app.dependencies import (
get_store,
resolve_route,
build_orchestrator,
resolve_output_endpoint,
piper_voice_for_language,
)
from app.store import ANONYMOUS_USER_ID, SessionOwnershipError
from app.audio.vad import EnergyVAD
from app.core.memory_extractor import maybe_schedule_extraction
from app.quota import enforce_quota, record_usage, QuotaExceededError
from app.auth import authenticate
from app.safety.emergency import handle_emergency, schedule_llm_emergency_check
router = APIRouter()
_OVERRIDE_KEYS = (
"input_endpoint",
"output_endpoint",
"language",
"language_mode",
"stt_provider",
"llm_provider",
"tts_provider",
)
def _authenticate(websocket: WebSocket, token: str | None):
# Forward-Auth (SSO) greift auch beim WS-Handshake: SSOwat injiziert den
# Identitaets-Header in den Upgrade-Request -> aus websocket.headers lesbar.
client_host = websocket.client.host if websocket.client else ""
return authenticate(websocket.headers, client_host, token)
async def _resolve(user, session_id, options):
"""Loest Route + Orchestrator + Output-Endpunkt auf (kann RoutingError/Ownership werfen)."""
overrides = {key: options.get(key) for key in _OVERRIDE_KEYS}
route = resolve_route(user, session_id, overrides)
orchestrator = build_orchestrator(route)
output = await resolve_output_endpoint(route)
return route, orchestrator, output
async def _run_turn(
websocket, store, user, session_id, route, orchestrator, output, text, options,
detected_language: str | None = None, effective_voice: str | None = None,
):
"""Faehrt einen Antwort-Turn und streamt die Events an den Client."""
conversation = (
store.get_recent_messages(session_id, settings.history_max_messages)
if session_id
else []
)
memories = store.get_memories(user.id)
llm_context = list(conversation)
user_context_parts = []
if user.id != ANONYMOUS_USER_ID:
user_context_parts.append(f"Du sprichst mit {user.display_name}.")
if memories:
user_context_parts.append(
"Was du ueber den Nutzer weisst:\n" + "\n".join(f"- {m.content}" for m in memories)
)
if user_context_parts:
llm_context = [{"role": "system", "content": "\n".join(user_context_parts)}] + llm_context
# Notfall-Erkennung zuerst (immer eskalieren, auch bei Quota-Limit).
emergency = handle_emergency(user, text, store)
async def _on_llm_emergency(category):
try:
await websocket.send_json(
{"type": "emergency", "category": category, "source": "llm"}
)
except Exception: # Socket evtl. geschlossen -> ignorieren
pass
# Stufe 2: LLM-Klassifikation als Hintergrund-Task (nur wenn Stichwoerter nichts fanden).
schedule_llm_emergency_check(
user, text, store, emergency, on_emergency=_on_llm_emergency
)
if emergency:
await websocket.send_json({"type": "emergency", "category": emergency["category"]})
else:
try:
enforce_quota(user, store)
except QuotaExceededError as exc:
await websocket.send_json({"type": "error", "status": 429, "detail": str(exc)})
return
await websocket.send_json({"type": "ack", "route": route.as_dict()})
# Stimme: explizit angeforderte gewinnt, sonst die vom Caller (Sprach-Turn)
# vorberechnete sprachpassende Stimme, sonst folgt sie der Routensprache
# (greift v. a. beim Text-Chat ohne Spracherkennung).
explicit_voice = options.get("voice")
if explicit_voice:
voice = explicit_voice
elif effective_voice is not None:
voice = effective_voice
else:
voice = piper_voice_for_language(route.tts_provider, route.language)
stream = bool(options.get("stream"))
# text_only: Geräte-TTS (Web Speech API) spricht selbst -> kein Server-Audio erzeugen/senden.
text_only = bool(options.get("text_only"))
# audio_stream: explizite Anfrage gewinnt, sonst der serverseitige Default (Admin).
audio_stream = False if text_only else (
bool(options["audio_stream"]) if "audio_stream" in options
else settings.audio_stream_default
)
on_token = None
if stream:
async def on_token(delta):
await websocket.send_json({"type": "token", "text": delta})
on_audio = None
if audio_stream:
audio_seq = 0
async def on_audio(chunk):
nonlocal audio_seq
await websocket.send_json({"type": "audio", "seq": audio_seq})
audio_seq += 1
await websocket.send_bytes(chunk)
try:
if stream or audio_stream:
trace, audio = await orchestrator.chat_stream(
text,
language=route.language,
voice=voice,
output=output,
history=llm_context,
on_token=on_token,
on_audio=on_audio,
language_mode=route.language_mode,
detected_language=detected_language,
text_only=text_only,
)
else:
trace, audio = await orchestrator.chat_text(
text,
language=route.language,
voice=voice,
output=output,
history=llm_context,
language_mode=route.language_mode,
detected_language=detected_language,
text_only=text_only,
)
except Exception as exc:
await websocket.send_json({"type": "error", "status": 502, "detail": str(exc)})
return
record_usage(user, store, len(text) + len(trace.semantic_response or ""))
if session_id:
store.append_message(session_id, user.id, "user", text)
store.append_message(session_id, user.id, "assistant", trace.semantic_response)
maybe_schedule_extraction(store, user.id, session_id)
await websocket.send_json(
{"type": "semantic", "text": trace.semantic_response, "spoken": trace.spoken_response}
)
# Im text_only-Modus kommt kein Audio (Gerät spricht selbst).
if not audio_stream and not text_only:
await websocket.send_bytes(audio)
await websocket.send_json({"type": "done", "audio_format": "pcm", "sample_rate": 24000})
async def _cancel_active(task, websocket) -> None:
"""Bricht einen laufenden Antwort-Turn ab (Barge-in) und meldet 'interrupted'."""
if task is None or task.done():
return
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
await websocket.send_json({"type": "interrupted"})
async def _chat_turn(websocket, store, user, session_id, text, options):
try:
route, orchestrator, output = await _resolve(user, session_id, options)
except SessionOwnershipError as exc:
await websocket.send_json({"type": "error", "status": 403, "detail": str(exc)})
return
except RoutingError as exc:
await websocket.send_json({"type": "error", "status": 422, "detail": str(exc)})
return
await _run_turn(websocket, store, user, session_id, route, orchestrator, output, text, options)
async def _voice_turn(websocket, store, user, session_id, audio, fmt, options):
try:
route, orchestrator, output = await _resolve(user, session_id, options)
except SessionOwnershipError as exc:
await websocket.send_json({"type": "error", "status": 403, "detail": str(exc)})
return
except RoutingError as exc:
await websocket.send_json({"type": "error", "status": 422, "detail": str(exc)})
return
try:
if route.language_mode == "flex":
# Flex: Sprache auto-erkennen, kein Übersetzen durch Whisper.
transcript, detected_language = await orchestrator.stt.transcribe_detect(
audio, fmt=fmt, language=None
)
# Stimme folgt der erkannten Sprache (Fallback: Routensprache).
effective_voice = piper_voice_for_language(
route.tts_provider, detected_language or route.language
)
else:
# Fix: konfigurierte Sprache an Whisper → Whisper übersetzt automatisch.
transcript = await orchestrator.stt.transcribe(audio, fmt=fmt, language=route.language)
detected_language = None
# Stimme folgt der konfigurierten Sprache.
effective_voice = piper_voice_for_language(route.tts_provider, route.language)
except Exception as exc:
await websocket.send_json({"type": "error", "status": 502, "detail": str(exc)})
return
await websocket.send_json({
"type": "transcript",
"text": transcript,
"detected_language": detected_language,
})
if not transcript or not transcript.strip():
await websocket.send_json({
"type": "error",
"detail": "Keine Sprache erkannt — bitte erneut sprechen.",
})
return
await _run_turn(
websocket, store, user, session_id, route, orchestrator, output, transcript, options,
detected_language=detected_language, effective_voice=effective_voice,
)
@router.websocket("/ws/chat")
async def ws_chat(websocket: WebSocket, session_id: str | None = None, token: str | None = None):
user = _authenticate(websocket, token)
if user is None:
await websocket.close(code=1008)
return
await websocket.accept()
store = get_store()
active = None
try:
while True:
msg = await websocket.receive_json()
if msg.get("type") == "interrupt":
await _cancel_active(active, websocket)
active = None
continue
text = (msg.get("text") or "").strip()
if not text:
await websocket.send_json({"type": "error", "detail": "empty text"})
continue
await _cancel_active(active, websocket) # Barge-in bei neuer Eingabe
active = asyncio.create_task(
_chat_turn(websocket, store, user, session_id, text, msg)
)
except WebSocketDisconnect:
if active and not active.done():
active.cancel()
return
@router.websocket("/ws/voice")
async def ws_voice(websocket: WebSocket, session_id: str | None = None, token: str | None = None):
user = _authenticate(websocket, token)
if user is None:
await websocket.close(code=1008)
return
await websocket.accept()
store = get_store()
audio_buffer = bytearray()
fmt = "wav"
active = None
vad = None
start_options: dict = {} # Konfig aus dem start-Frame (Provider, audio_stream, language)
async def _start_voice(audio: bytes, options: dict):
nonlocal active
await _cancel_active(active, websocket) # Barge-in bei neuer Aeusserung
active = asyncio.create_task(
_voice_turn(websocket, store, user, session_id, audio, fmt, options)
)
try:
while True:
message = await websocket.receive()
if message["type"] == "websocket.disconnect":
if active and not active.done():
active.cancel()
return
if message.get("bytes") is not None:
audio_buffer.extend(message["bytes"])
# VAD: Aeusserungsende automatisch erkennen (opt-in via start-Frame).
if vad is not None and vad.feed(message["bytes"]):
audio = bytes(audio_buffer)
audio_buffer.clear()
vad.reset()
await _start_voice(audio, start_options)
continue
raw = message.get("text")
if raw is None:
continue
try:
control = json.loads(raw)
except ValueError:
await websocket.send_json({"type": "error", "detail": "invalid control frame"})
continue
ctype = control.get("type")
if ctype == "interrupt":
await _cancel_active(active, websocket)
active = None
continue
if ctype == "start":
audio_buffer.clear()
start_options = control # Provider/audio_stream/language fuer den Turn merken
fmt = control.get("format", "wav")
if control.get("vad"):
vad = EnergyVAD(
sample_rate=control.get("sample_rate", 16000),
threshold=control.get("vad_threshold", 500.0),
silence_ms=control.get("vad_silence_ms", 700.0),
)
else:
vad = None
continue
if ctype != "end":
continue
if not audio_buffer:
await websocket.send_json({"type": "error", "detail": "no audio received"})
continue
audio = bytes(audio_buffer)
audio_buffer.clear()
if vad is not None:
vad.reset()
# start-Frame-Konfig + end-Frame zusammenfuehren (end kann ueberschreiben)
await _start_voice(audio, {**start_options, **control})
except WebSocketDisconnect:
if active and not active.done():
active.cancel()
return

0
app/audio/__init__.py Normal file
View file

View file

View file

View file

@ -0,0 +1,17 @@
from abc import ABC, abstractmethod
from app.schemas import AudioChunk, EndpointCapabilities
class AudioInputEndpoint(ABC):
endpoint_id: str
@abstractmethod
async def capabilities(self) -> EndpointCapabilities: ...
@abstractmethod
async def open(self) -> None: ...
@abstractmethod
async def read_chunk(self) -> AudioChunk: ...
@abstractmethod
async def close(self) -> None: ...

View file

@ -0,0 +1,23 @@
from app.audio.endpoints.input.base import AudioInputEndpoint
from app.schemas import AudioChunk, EndpointCapabilities
class BluetoothInput(AudioInputEndpoint):
endpoint_id = "bt-headset-01"
async def capabilities(self) -> EndpointCapabilities:
return EndpointCapabilities(
id=self.endpoint_id,
kind="bluetooth",
direction="input",
latency_class="medium",
bluetooth=True,
)
async def open(self) -> None:
return None
async def read_chunk(self) -> AudioChunk:
return AudioChunk(data=b"", format="wav", timestamp_ms=0)
async def close(self) -> None:
return None

View file

@ -0,0 +1,24 @@
from app.audio.endpoints.input.base import AudioInputEndpoint
from app.schemas import AudioChunk, EndpointCapabilities
class LocalDefaultInput(AudioInputEndpoint):
endpoint_id = "local-default-mic"
async def capabilities(self) -> EndpointCapabilities:
return EndpointCapabilities(
id=self.endpoint_id,
kind="local-default",
direction="input",
latency_class="low",
supports_barge_in=True,
default=True,
)
async def open(self) -> None:
return None
async def read_chunk(self) -> AudioChunk:
return AudioChunk(data=b"", format="wav", timestamp_ms=0)
async def close(self) -> None:
return None

View file

@ -0,0 +1,25 @@
from app.audio.endpoints.input.base import AudioInputEndpoint
from app.schemas import AudioChunk, EndpointCapabilities
class MobileWebRTCInput(AudioInputEndpoint):
endpoint_id = "mobile-webrtc-client"
async def capabilities(self) -> EndpointCapabilities:
return EndpointCapabilities(
id=self.endpoint_id,
kind="mobile-webrtc",
direction="input",
latency_class="low",
networked=True,
mobile=True,
supports_barge_in=True,
)
async def open(self) -> None:
return None
async def read_chunk(self) -> AudioChunk:
return AudioChunk(data=b"", format="wav", timestamp_ms=0)
async def close(self) -> None:
return None

View file

@ -0,0 +1,24 @@
from app.audio.endpoints.input.base import AudioInputEndpoint
from app.schemas import AudioChunk, EndpointCapabilities
class MobileWebSocketInput(AudioInputEndpoint):
endpoint_id = "mobile-ws-client"
async def capabilities(self) -> EndpointCapabilities:
return EndpointCapabilities(
id=self.endpoint_id,
kind="mobile-ws",
direction="input",
latency_class="medium",
networked=True,
mobile=True,
)
async def open(self) -> None:
return None
async def read_chunk(self) -> AudioChunk:
return AudioChunk(data=b"", format="wav", timestamp_ms=0)
async def close(self) -> None:
return None

View file

View file

@ -0,0 +1,20 @@
from abc import ABC, abstractmethod
from app.schemas import AudioChunk, EndpointCapabilities
class AudioOutputEndpoint(ABC):
endpoint_id: str
@abstractmethod
async def capabilities(self) -> EndpointCapabilities: ...
@abstractmethod
async def open(self) -> None: ...
@abstractmethod
async def write_chunk(self, chunk: AudioChunk) -> None: ...
@abstractmethod
async def flush(self) -> None: ...
@abstractmethod
async def close(self) -> None: ...

View file

@ -0,0 +1,26 @@
from app.audio.endpoints.output.base import AudioOutputEndpoint
from app.schemas import AudioChunk, EndpointCapabilities
class BluetoothOutput(AudioOutputEndpoint):
endpoint_id = "bt-speaker-01"
async def capabilities(self) -> EndpointCapabilities:
return EndpointCapabilities(
id=self.endpoint_id,
kind="bluetooth",
direction="output",
latency_class="medium",
bluetooth=True,
)
async def open(self) -> None:
return None
async def write_chunk(self, chunk: AudioChunk) -> None:
return None
async def flush(self) -> None:
return None
async def close(self) -> None:
return None

View file

@ -0,0 +1,26 @@
from app.audio.endpoints.output.base import AudioOutputEndpoint
from app.schemas import AudioChunk, EndpointCapabilities
class LocalDefaultOutput(AudioOutputEndpoint):
endpoint_id = "local-default-speaker"
async def capabilities(self) -> EndpointCapabilities:
return EndpointCapabilities(
id=self.endpoint_id,
kind="local-default",
direction="output",
latency_class="low",
default=True,
)
async def open(self) -> None:
return None
async def write_chunk(self, chunk: AudioChunk) -> None:
return None
async def flush(self) -> None:
return None
async def close(self) -> None:
return None

View file

@ -0,0 +1,28 @@
from app.audio.endpoints.output.base import AudioOutputEndpoint
from app.schemas import AudioChunk, EndpointCapabilities
class LoopbackOutput(AudioOutputEndpoint):
endpoint_id = "loopback-output"
def __init__(self):
self.chunks = []
async def capabilities(self) -> EndpointCapabilities:
return EndpointCapabilities(
id=self.endpoint_id,
kind="loopback",
direction="output",
latency_class="low",
)
async def open(self) -> None:
return None
async def write_chunk(self, chunk: AudioChunk) -> None:
self.chunks.append(chunk)
async def flush(self) -> None:
return None
async def close(self) -> None:
return None

View file

@ -0,0 +1,27 @@
from app.audio.endpoints.output.base import AudioOutputEndpoint
from app.schemas import AudioChunk, EndpointCapabilities
class MobileWebRTCOutput(AudioOutputEndpoint):
endpoint_id = "mobile-webrtc-client"
async def capabilities(self) -> EndpointCapabilities:
return EndpointCapabilities(
id=self.endpoint_id,
kind="mobile-webrtc",
direction="output",
latency_class="low",
networked=True,
mobile=True,
)
async def open(self) -> None:
return None
async def write_chunk(self, chunk: AudioChunk) -> None:
return None
async def flush(self) -> None:
return None
async def close(self) -> None:
return None

View file

@ -0,0 +1,27 @@
from app.audio.endpoints.output.base import AudioOutputEndpoint
from app.schemas import AudioChunk, EndpointCapabilities
class MobileWebSocketOutput(AudioOutputEndpoint):
endpoint_id = "mobile-ws-client"
async def capabilities(self) -> EndpointCapabilities:
return EndpointCapabilities(
id=self.endpoint_id,
kind="mobile-ws",
direction="output",
latency_class="medium",
networked=True,
mobile=True,
)
async def open(self) -> None:
return None
async def write_chunk(self, chunk: AudioChunk) -> None:
return None
async def flush(self) -> None:
return None
async def close(self) -> None:
return None

38
app/audio/router.py Normal file
View file

@ -0,0 +1,38 @@
from app.errors import UnknownEndpointError
class AudioRouter:
def __init__(self, inputs, outputs):
self.inputs = inputs
self.outputs = outputs
async def list_inputs(self):
return [await endpoint.capabilities() for endpoint in self.inputs]
async def list_outputs(self):
return [await endpoint.capabilities() for endpoint in self.outputs]
async def select_input(self, preferred: str | None = None):
return await self._select(self.inputs, preferred, direction="input")
async def select_output(self, preferred: str | None = None):
return await self._select(self.outputs, preferred, direction="output")
async def _select(self, endpoints, preferred: str | None, direction: str):
# Capabilities einmal sammeln (Basis fuer spaetere capability-basierte Auswahl).
pairs = [(endpoint, await endpoint.capabilities()) for endpoint in endpoints]
if preferred:
for endpoint, caps in pairs:
if caps.id == preferred or caps.kind == preferred:
return endpoint
# Angefragter Endpunkt existiert nicht -> KEIN stiller Default-Fallback.
available = sorted({caps.id for _, caps in pairs} | {caps.kind for _, caps in pairs})
raise UnknownEndpointError(
f"Unbekannter {direction}-Endpunkt {preferred!r}. Verfuegbar: {available}"
)
for endpoint, caps in pairs:
if caps.default:
return endpoint
raise RuntimeError(f"Kein {direction}-Standardendpunkt verfuegbar")

View file

@ -0,0 +1,11 @@
class TransportRouter:
def __init__(self, local_registry: dict, remote_registry: dict):
self.local_registry = local_registry
self.remote_registry = remote_registry
def resolve(self, module_type: str, provider_name: str):
if provider_name in self.local_registry.get(module_type, {}):
return self.local_registry[module_type][provider_name]
if provider_name in self.remote_registry.get(module_type, {}):
return self.remote_registry[module_type][provider_name]
raise KeyError(f"Unknown provider: {module_type}/{provider_name}")

59
app/audio/vad.py Normal file
View file

@ -0,0 +1,59 @@
"""Einfache energie-basierte Sprachaktivitaetserkennung (VAD).
Reines Python (stdlib `array`), arbeitet auf s16le-PCM (mono). Erkennt das Ende
einer Aeusserung anhand andauernder Stille nach erkannter Sprache. Damit kann der
Server in /ws/voice Aeusserungen automatisch segmentieren, ohne dass der Client
ein explizites Ende-Signal schickt.
Hinweis: Das ersetzt keinen echten Streaming-STT-Dienst (keine wortweisen
Teil-Transkripte) - es bestimmt nur die Aeusserungsgrenzen.
"""
import array
import math
def rms(pcm: bytes) -> float:
"""Lautstaerke (RMS) eines s16le-PCM-Puffers; 0.0 bei leerem Puffer."""
usable = len(pcm) - (len(pcm) % 2)
if usable <= 0:
return 0.0
samples = array.array("h")
samples.frombytes(pcm[:usable])
if not samples:
return 0.0
return math.sqrt(sum(s * s for s in samples) / len(samples))
class EnergyVAD:
def __init__(self, sample_rate: int = 16000, threshold: float = 500.0, silence_ms: float = 700.0):
self.sample_rate = sample_rate
self.threshold = threshold
self.silence_ms = silence_ms
self._speech_started = False
self._silence_ms = 0.0
def feed(self, pcm: bytes) -> bool:
"""Verarbeitet einen Audio-Chunk.
Liefert True, sobald nach erkannter Sprache genug Stille (silence_ms)
vergangen ist - die Aeusserung gilt dann als beendet.
"""
level = rms(pcm)
n_samples = len(pcm) // 2
chunk_ms = (n_samples / self.sample_rate) * 1000.0 if self.sample_rate else 0.0
if level >= self.threshold:
self._speech_started = True
self._silence_ms = 0.0
return False
if self._speech_started:
self._silence_ms += chunk_ms
if self._silence_ms >= self.silence_ms:
return True
return False
def reset(self) -> None:
self._speech_started = False
self._silence_ms = 0.0

44
app/audit.py Normal file
View file

@ -0,0 +1,44 @@
"""Strukturiertes Audit-Logging für Admin-Aktionen.
Schreibt eine Zeile pro schreibender Admin-Aktion (Backend-Wechsel, Config-Änderung,
Gateway-Neustart ) mit der Identität des Auslösers. Die Zeilen landen über stdout im
systemd-Journal und sind damit live im Admin-Log-Tab sichtbar.
"""
from __future__ import annotations
import logging
import sys
# Eigener Logger mit eigenem Handler -> unabhängig von der uvicorn-Logging-Config,
# erscheint zuverlässig auf stdout (= Journal im Dienst-Betrieb).
logger = logging.getLogger("va.audit")
if not logger.handlers:
_h = logging.StreamHandler(sys.stdout)
_h.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s"))
logger.addHandler(_h)
logger.setLevel(logging.INFO)
logger.propagate = False
def admin_identity(request) -> str:
"""Ermittelt, wer die Admin-Aktion ausführt (SSO-Username oder 'admin-key')."""
try:
from app.auth import authenticate, _bearer_token
client_host = request.client.host if request.client else ""
token = _bearer_token(request.headers.get("authorization"))
user = authenticate(request.headers, client_host, token)
if user is not None and getattr(user, "external_id", None):
return user.external_id
except Exception: # pragma: no cover - Auth-Fehler nie fatal fürs Logging
pass
if request.headers.get("x-admin-key"):
return "admin-key"
return "unknown"
def log_admin_action(request, action: str, **fields) -> None:
"""Loggt eine Admin-Aktion strukturiert: action, user + freie Felder."""
who = admin_identity(request)
extra = " ".join(f"{k}={v!r}" for k, v in fields.items() if v is not None)
logger.info("ADMIN action=%s user=%s %s", action, who, extra)

154
app/auth.py Normal file
View file

@ -0,0 +1,154 @@
import base64
import hashlib
import hmac
import json
from fastapi import Header, HTTPException, Request
from app.config import settings, Settings
from app.dependencies import get_store
from app.store import User
def _csv_set(value: str) -> set[str]:
return {item.strip() for item in (value or "").split(",") if item.strip()}
def _b64url_decode(data: str) -> bytes:
return base64.urlsafe_b64decode(data + "=" * (-len(data) % 4))
def _cookie_value(cookie_header: str | None, name: str) -> str | None:
"""Liest einen Cookie-Wert robust aus dem Cookie-Header (ohne SimpleCookie)."""
if not cookie_header or not name:
return None
for part in cookie_header.split(";"):
part = part.strip()
if part.startswith(name + "="):
return part[len(name) + 1:]
return None
def _username_from_cookie(headers, cfg: Settings) -> str | None:
"""Extrahiert den Usernamen aus einem JWT-Cookie (z. B. YunoHost 'yunohost.portal').
Mit gesetztem `trusted_auth_jwt_secret` wird die HS256-Signatur geprueft. Ohne
Secret wird die Payload ungeprueft gelesen - das ist nur sicher, weil (a) nur die
Proxy-Quell-IP akzeptiert wird und (b) das SSO unauthentifizierte Anfragen gar nicht
erst durchlaesst (also nur vom SSO validierte Cookies hier ankommen).
"""
token = _cookie_value(headers.get("cookie"), cfg.trusted_auth_cookie)
if not token or token.count(".") != 2:
return None
header_b64, payload_b64, sig_b64 = token.split(".")
secret = cfg.trusted_auth_jwt_secret.strip()
if secret:
expected = hmac.new(
secret.encode(), f"{header_b64}.{payload_b64}".encode(), hashlib.sha256
).digest()
try:
if not hmac.compare_digest(expected, _b64url_decode(sig_b64)):
return None
except (ValueError, TypeError):
return None
try:
payload = json.loads(_b64url_decode(payload_b64))
except (ValueError, TypeError):
return None
value = payload.get(cfg.trusted_auth_cookie_claim)
return value.strip() if isinstance(value, str) and value.strip() else None
def is_admin_user(user: User | None, cfg: Settings = settings) -> bool:
"""True, wenn der Nutzer (per SSO-Identitaet) in ADMIN_USERS steht."""
if user is None or not user.external_id:
return False
return user.external_id in _csv_set(cfg.admin_users)
def authenticate(headers, client_host: str, token: str | None,
cfg: Settings = settings) -> User | None:
"""Gemeinsame Auth-Logik fuer HTTP und WebSocket.
Praezedenz:
1. Forward-Auth: trusted_auth_header gesetzt UND Request von einer Proxy-Quell-IP
-> Identitaet aus dem Header, interner Nutzer wird ggf. angelegt.
2. AUTH_ENABLED=false -> anonymer Standardnutzer (dev/Test).
3. Bearer-Token.
Liefert den Nutzer oder None (nicht authentifiziert).
"""
store = get_store()
# 1. Forward-Auth: Identitaet aus Header ODER (signiertem) Cookie - nur von der
# Proxy-Quell-IP akzeptiert.
if cfg.trusted_auth_header or cfg.trusted_auth_cookie:
ips = _csv_set(cfg.trusted_proxy_ips)
if ips and client_host in ips:
external = None
if cfg.trusted_auth_header:
external = (headers.get(cfg.trusted_auth_header) or "").strip() or None
if not external and cfg.trusted_auth_cookie:
external = _username_from_cookie(headers, cfg)
if not external:
return None # SSO sollte die Identitaet immer liefern -> 401
user = store.get_or_create_user_by_external_id(external, display_name=external)
user.is_admin = is_admin_user(user, cfg)
return user
# Nicht von der Proxy-IP -> ignorieren, normale Auth unten.
# 2. Auth abgeschaltet (dev/Test).
if not cfg.auth_enabled:
return store.ensure_anonymous_user()
# 3. Bearer-Token.
if not token:
return None
return store.get_user_by_token(token)
def _bearer_token(authorization: str | None) -> str | None:
if authorization and authorization.lower().startswith("bearer "):
return authorization.split(" ", 1)[1].strip()
return None
def require_user(request: Request) -> User:
"""FastAPI-Dependency: liefert den authentifizierten Nutzer (sonst 401)."""
client_host = request.client.host if request.client else ""
token = _bearer_token(request.headers.get("authorization"))
user = authenticate(request.headers, client_host, token)
if user is None:
raise HTTPException(status_code=401, detail="Authentication required")
return user
def require_admin(
request: Request, x_admin_key: str | None = Header(default=None)
) -> None:
"""Schuetzt Admin-Endpunkte: ADMIN_API_KEY-Header ODER SSO-Admin-Nutzer."""
expected = settings.admin_api_key.strip()
if expected and x_admin_key and x_admin_key.strip() == expected:
return
client_host = request.client.host if request.client else ""
token = _bearer_token(request.headers.get("authorization"))
user = authenticate(request.headers, client_host, token)
if is_admin_user(user):
return
if not expected:
raise HTTPException(status_code=503, detail="Admin API not configured (ADMIN_API_KEY unset)")
raise HTTPException(status_code=401, detail="Admin privileges required")
def require_admin_or_user(
request: Request, x_admin_key: str | None = Header(default=None)
) -> User | None:
"""Erlaubt Zugriff fuer Admin-Nutzer (SSO/ADMIN_USERS) ODER gueltigen ADMIN_API_KEY."""
expected = settings.admin_api_key.strip()
if expected and x_admin_key and x_admin_key.strip() == expected:
return None
client_host = request.client.host if request.client else ""
token = _bearer_token(request.headers.get("authorization"))
user = authenticate(request.headers, client_host, token)
if user is not None and is_admin_user(user):
return user
raise HTTPException(status_code=403, detail="Admin privileges required")

217
app/config.py Normal file
View file

@ -0,0 +1,217 @@
import os
from pathlib import Path
try:
import tomllib # Python >= 3.11 (stdlib)
except ModuleNotFoundError: # pragma: no cover - Fallback fuer aeltere Interpreter
import tomli as tomllib # type: ignore
from pydantic.fields import FieldInfo
from pydantic_settings import (
BaseSettings,
PydanticBaseSettingsSource,
SettingsConfigDict,
)
BASE_DIR = Path(__file__).resolve().parent.parent
ENV_FILE = BASE_DIR / ".env"
DEFAULT_CONFIG_FILE = BASE_DIR / "config" / "voice-assistant.toml"
def _setting_lookup(key: str) -> str | None:
"""Liest einen Steuer-Schluessel: echte Umgebung zuerst, dann die .env-Datei.
Noetig fuer VA_PROFILE/VA_CONFIG_FILE, weil diese gebraucht werden, BEVOR
pydantic-settings die .env laedt - und .env-Werte sonst nicht in os.environ stehen.
"""
value = os.getenv(key)
if value is not None:
return value
try:
from dotenv import dotenv_values
except ModuleNotFoundError: # pragma: no cover
return None
if ENV_FILE.is_file():
return dotenv_values(ENV_FILE).get(key)
return None
def _config_file_path() -> Path:
return Path(_setting_lookup("VA_CONFIG_FILE") or str(DEFAULT_CONFIG_FILE))
def active_profile() -> str | None:
"""Name des aktiven Profils (VA_PROFILE) aus Umgebung oder .env, falls gesetzt."""
profile = _setting_lookup("VA_PROFILE")
return profile.strip() or None if profile else None
def load_profile_config() -> dict:
"""Liest die zentrale TOML-Config und merged [defaults] + [profiles.<VA_PROFILE>].
- Fehlt die Datei, gilt ein leeres dict (nur ENV/Defaults greifen) - kein Fehler,
damit reine Cloud-Deployments ohne Datei (nur ENV) funktionieren.
- Ein gesetztes, aber unbekanntes VA_PROFILE ist ein Konfigurationsfehler.
"""
path = _config_file_path()
if not path.is_file():
return {}
with path.open("rb") as handle:
data = tomllib.load(handle)
merged: dict = dict(data.get("defaults", {}))
profile = active_profile()
if profile:
profiles = data.get("profiles", {})
if profile not in profiles:
raise ValueError(
f"Unbekanntes VA_PROFILE {profile!r}. "
f"Verfuegbar: {sorted(profiles)}"
)
merged.update(profiles[profile])
return merged
class TomlProfileSource(PydanticBaseSettingsSource):
"""Settings-Quelle aus der zentralen TOML-Config (inkl. aktivem Profil).
Liegt in der Praezedenz unter ENV/.env, aber ueber den eingebauten Defaults.
Es werden nur Schluessel durchgereicht, die auch als Settings-Feld existieren.
"""
def __init__(self, settings_cls):
super().__init__(settings_cls)
raw = load_profile_config()
known = set(settings_cls.model_fields)
self._values = {
key.lower(): value
for key, value in raw.items()
if key.lower() in known
}
def get_field_value(self, field: FieldInfo, field_name: str):
if field_name in self._values:
return self._values[field_name], field_name, False
return None, field_name, False
def __call__(self) -> dict:
return dict(self._values)
class Settings(BaseSettings):
app_env: str = "dev"
host: str = "0.0.0.0"
port: int = 8080
log_level: str = "info"
openrouter_api_key: str = ""
openrouter_stt_model: str = "openai/whisper-large-v3"
openrouter_tts_model: str = "openai/gpt-4o-mini-tts"
openrouter_tts_voice: str = "alloy"
openrouter_llm_model: str = "openai/gpt-4.1-mini"
default_language: str = "de"
default_language_mode: str = "fix"
default_input_endpoint: str = "local-default"
default_output_endpoint: str = "local-default"
default_stt_provider: str = "openrouter"
default_llm_provider: str = "local-openai-compatible"
default_tts_provider: str = "openrouter"
# Lokaler llama.cpp-Server (OpenAI-kompatibel), siehe scripts/llm-server/.
local_llm_base_url: str = "http://127.0.0.1:8001/v1"
local_llm_api_key: str = "dummy"
local_llm_model: str = "va_llm" # = --alias des llama.cpp-Servers
# Sprach-Assistent: knappe, vorlesbare Antworten + Reasoning aus = deutlich schneller.
local_llm_system_prompt: str = (
"Du bist ein gesprochener Sprachassistent. Antworte kurz und natuerlich "
"(in der Regel 1-3 Saetze), in reinem Fliesstext ohne Markdown, ohne "
"Aufzaehlungen, ohne Emojis. Formuliere so, wie man es laut vorliest."
)
local_llm_disable_reasoning: bool = True # Qwen3 /no_think: spart die Denkphase
local_llm_max_tokens: int = 0 # 0 = serverseitiges Limit (-n)
local_llm_temperature: float = 0.3
local_llm_top_p: float = 0.9 # Nucleus-Sampling (0.01.0)
faster_whisper_model: str = "base" # tiny|base|small|medium|large-v3
faster_whisper_device: str = "auto" # auto|cpu|cuda
faster_whisper_compute_type: str = "default" # default|int8|float16|int8_float16
# --- Lokales TTS (piper) -------------------------------------------------
piper_bin: str = "piper" # Pfad/Name des piper-Binaries
piper_voices_dir: str = str(Path.home() / ".local" / "share" / "piper" / "voices")
piper_voice: str = "de_DE-thorsten-high" # Stimmmodell-Name (ohne .onnx) oder voller Pfad
tts_sample_rate: int = 24000 # Ziel-Sample-Rate (das Gateway erwartet 24000 Hz)
# --- Chatterbox-TTS (hohe Qualitaet + Voice-Cloning, eigener HTTP-Dienst) -
chatterbox_base_url: str = "http://127.0.0.1:9999"
chatterbox_voice: str = "" # Pfad zu Referenz-WAV (Voice-Cloning) oder leer
chatterbox_lang: str = "de"
chatterbox_speed: float = 1.0
chatterbox_timeout: int = 180
# Verzeichnis mit nativen Referenz-WAVs je Sprache (Konvention <lang>.wav, z. B. fr.wav).
# Greift cross-lingual: pro Sprache eine muttersprachliche Stimme. Leer -> nur chatterbox_voice.
chatterbox_voices_dir: str = str(BASE_DIR / "config" / "voices")
db_path: str = str(BASE_DIR / "data" / "voice-assistant.db")
admin_api_key: str = ""
auth_enabled: bool = True
# --- Forward-/Trusted-Header-Auth (Reverse-Proxy / YunoHost-SSO) ---------
# Ist trusted_auth_header gesetzt UND die Quell-IP in trusted_proxy_ips, wird die
# Identitaet aus diesem Header gelesen (SSO-User) und ein interner Nutzer
# automatisch angelegt. Sonst gilt die normale Token-/Anonymous-Auth.
trusted_auth_header: str = ""
# Alternativ zur Header-Variante: Identitaet aus einem (signierten) JWT-Cookie lesen.
# YunoHost reicht den Usernamen nicht als Header durch, sondern im Cookie
# "yunohost.portal" (JWT, Claim "user"). Nur von der Proxy-IP akzeptiert.
trusted_auth_cookie: str = "" # Cookie-Name (z. B. yunohost.portal)
trusted_auth_cookie_claim: str = "user" # JWT-Claim mit dem Usernamen
trusted_auth_jwt_secret: str = "" # optional: HS256-Secret -> Signatur pruefen
trusted_proxy_ips: str = "" # kommasepariert; IP(s) des Reverse-Proxys
admin_users: str = "" # kommaseparierte SSO-Usernamen mit Admin-Rechten
sso_logout_url: str = "" # Logout-Link fuers Frontend (SSO-Portal)
# Login-/Portal-URL: unauthentifizierte Seitenaufrufe werden hierhin umgeleitet
# (Defense-in-Depth zusaetzlich zu SSOwat). Leer -> stattdessen HTTP 401.
sso_login_url: str = ""
history_max_messages: int = 10
# Automatische Erinnerungs-Extraktion: das LLM destilliert dauerhafte Fakten
# aus dem Gespraech und legt sie als Nutzer-Erinnerungen ab (best-effort,
# nicht-blockierend). Leerer Provider = Default-LLM-Provider.
memory_extraction_enabled: bool = True
memory_extraction_every_n_turns: int = 3
memory_extraction_max: int = 50
memory_extraction_provider: str = ""
audio_stream_default: bool = True # satzweises TTS als Default (Admin kann abschalten)
# TTS-Text-Normalisierung: auto|full|light|off. "auto" = piper -> full, Cloud -> light.
tts_normalize_level: str = "auto"
stt_fallback: str = "" # kommaseparierte Provider-Namen (Fallback-Kette)
llm_fallback: str = ""
tts_fallback: str = ""
daily_request_limit: int = 0 # 0 = unbegrenzt; Anfragen pro Nutzer pro Tag
emergency_webhook_url: str = "" # optionaler Eskalations-Webhook
# LLM-Notfall-Klassifikation (zweite Stufe, faengt was die Stichwoerter verpassen).
# Laeuft als Hintergrund-Task NUR wenn der Keyword-Filter nichts fand -> keine
# zusaetzliche Antwortlatenz. Leerer Provider = Default-LLM-Provider.
emergency_llm_enabled: bool = True
emergency_llm_provider: str = ""
emergency_llm_min_confidence: float = 0.6
model_config = SettingsConfigDict(
env_file=ENV_FILE, case_sensitive=False, extra="ignore"
)
@classmethod
def settings_customise_sources(
cls,
settings_cls,
init_settings,
env_settings,
dotenv_settings,
file_secret_settings,
):
# Praezedenz (frueher = hoeher): init > ENV > .env > TOML/Profil > Defaults
return (
init_settings,
env_settings,
dotenv_settings,
TomlProfileSource(settings_cls),
file_secret_settings,
)
settings = Settings()

0
app/core/__init__.py Normal file
View file

View file

@ -0,0 +1,164 @@
"""Automatische Erinnerungs-Extraktion.
Nach einigen Gespraechsturns destilliert ein LLM dauerhafte Fakten/Vorlieben
ueber den Nutzer aus dem Verlauf und legt sie als Nutzer-Erinnerungen ab.
Bewusst **best-effort und nicht-blockierend**: Die Extraktion laeuft als
Hintergrund-Task und darf die Antwortlatenz nie erhoehen. Schlaegt sie fehl
(LLM-Fehler, kaputtes JSON), ist die Folge nur "kein neuer Fakt" - niemals ein
Fehler im Antwort-Turn.
"""
import asyncio
import json
import logging
import re
from app.config import Settings, settings
logger = logging.getLogger(__name__)
# Turn-Zaehler pro Session (in-memory, bewusst kein DB-Schema-Eingriff).
_turn_counts: dict[str, int] = {}
# Referenzen auf laufende Tasks halten, damit sie nicht vorzeitig vom GC kassiert werden.
_pending: set[asyncio.Task] = set()
_EXTRACTION_SYSTEM_PROMPT = (
"Du extrahierst dauerhafte, langfristig relevante Fakten und Vorlieben ueber den "
"Nutzer aus einem Gespraech (z. B. Name, Wohnort, Familie, Gesundheit, Hobbys, "
"Vorlieben, Abneigungen, feste Routinen). Gib AUSSCHLIESSLICH ein JSON-Array "
"kurzer deutscher Strings zurueck, ohne Erklaerung und ohne Markdown. Nimm nur "
"NEUE Fakten auf, die nicht bereits bekannt sind. Ignoriere fluechtige oder rein "
"situative Aussagen. Gibt es nichts Neues, antworte mit []."
)
def _build_extractor_llm(cfg: Settings):
"""Baut eine eigene LLM-Instanz fuer die Extraktion (nicht der Sprach-Provider).
Fuer den lokalen Provider wird der Extraktions-System-Prompt direkt gesetzt
(der Chat-Provider ist auf kurze, vorlesbare Saetze getrimmt und taugt nicht
fuer JSON). Fuer andere Provider wird der generische Provider verwendet; die
Anweisung steckt dann zusaetzlich in der Nachricht selbst.
"""
provider = cfg.memory_extraction_provider or cfg.default_llm_provider
if provider == "local-openai-compatible":
from app.providers.llm.local_openai_compatible import LocalOpenAICompatibleLLM
return LocalOpenAICompatibleLLM(
cfg.local_llm_base_url,
cfg.local_llm_api_key,
cfg.local_llm_model,
system_prompt=_EXTRACTION_SYSTEM_PROMPT,
disable_reasoning=True,
max_tokens=512,
temperature=0.1,
)
from app.dependencies import get_llm_provider
return get_llm_provider(provider, cfg)
def _format_conversation(messages: list[dict]) -> str:
lines = []
for msg in messages:
role = "Nutzer" if msg.get("role") == "user" else "Assistent"
content = (msg.get("content") or "").strip()
if content:
lines.append(f"{role}: {content}")
return "\n".join(lines)
def parse_facts(raw: str) -> list[str]:
"""Liest ein JSON-Array von Fakt-Strings aus der (evtl. verrauschten) LLM-Antwort."""
if not raw:
return []
match = re.search(r"\[.*\]", raw, re.DOTALL)
if not match:
return []
try:
data = json.loads(match.group(0))
except ValueError:
return []
if not isinstance(data, list):
return []
facts = []
for item in data:
if isinstance(item, str):
fact = item.strip()
if fact:
facts.append(fact)
return facts
def _norm(text: str) -> str:
return " ".join(text.lower().split())
async def extract_and_store(store, user_id: str, session_id: str, cfg: Settings = settings) -> int:
"""Extrahiert neue Fakten und speichert sie. Liefert die Anzahl neu gespeicherter."""
messages = store.get_recent_messages(session_id, cfg.history_max_messages)
conversation = _format_conversation(messages)
if not conversation:
return 0
existing = store.get_memories(user_id)
if len(existing) >= cfg.memory_extraction_max:
return 0
known = [m.content for m in existing]
known_text = "\n".join(f"- {k}" for k in known) if known else "(noch nichts bekannt)"
user_prompt = (
f"Bereits bekannt:\n{known_text}\n\n"
f"Gespraech:\n{conversation}\n\n"
"Neue Fakten als JSON-Array:"
)
llm = _build_extractor_llm(cfg)
raw = await llm.complete(user_prompt)
facts = parse_facts(raw)
if not facts:
return 0
seen = {_norm(k) for k in known}
added = 0
for fact in facts:
if len(existing) + added >= cfg.memory_extraction_max:
break
key = _norm(fact)
if key in seen:
continue
seen.add(key)
store.add_memory(user_id, fact)
added += 1
if added:
logger.info("memory-extraction: %d neue Erinnerung(en) fuer %s", added, user_id)
return added
async def _run_safe(store, user_id: str, session_id: str, cfg: Settings) -> None:
try:
await extract_and_store(store, user_id, session_id, cfg)
except Exception: # best-effort: niemals den Turn beeintraechtigen
logger.exception("memory-extraction fehlgeschlagen (ignoriert)")
def maybe_schedule_extraction(store, user_id: str, session_id: str | None,
cfg: Settings = settings) -> asyncio.Task | None:
"""Plant die Extraktion als Hintergrund-Task, sofern aktiviert und N Turns erreicht.
Gibt den geplanten Task zurueck (oder None) - blockiert nie.
"""
if not cfg.memory_extraction_enabled or not session_id:
return None
every = max(1, cfg.memory_extraction_every_n_turns)
count = _turn_counts.get(session_id, 0) + 1
_turn_counts[session_id] = count
if count % every != 0:
return None
task = asyncio.create_task(_run_safe(store, user_id, session_id, cfg))
_pending.add(task)
task.add_done_callback(_pending.discard)
return task

227
app/core/orchestrator.py Normal file
View file

@ -0,0 +1,227 @@
from app.schemas import AudioChunk, PipelineTrace
from app.pipeline.sentence_chunker import SentenceChunker
from app.metrics import timer, metrics
def _stage(name: str):
return timer("stage_duration_seconds", {"stage": name})
# Festes Ausgabeformat der TTS-Stufe (s16le PCM, 24 kHz, mono).
TTS_AUDIO_FORMAT = "pcm"
TTS_SAMPLE_RATE = 24000
TTS_CHANNELS = 1
class Orchestrator:
def __init__(self, stt, llm, tts, input_cleaner, spoken_adapter, tts_normalizer,
normalize_level: str = "full"):
self.stt = stt
self.llm = llm
self.tts = tts
self.input_cleaner = input_cleaner
self.spoken_adapter = spoken_adapter
self.tts_normalizer = tts_normalizer
self.normalize_level = normalize_level
async def _emit_to_output(self, audio: bytes, output) -> None:
"""Schreibt das synthetisierte Audio durch den gewaehlten Output-Endpunkt.
Der HTTP-Stream bleibt davon unberuehrt (additiv). Bei lokalen Geraeten
ist write_chunk heute ein no-op; LoopbackOutput sammelt die Chunks.
"""
if output is None:
return
chunk = AudioChunk(
data=audio,
sample_rate=TTS_SAMPLE_RATE,
channels=TTS_CHANNELS,
format=TTS_AUDIO_FORMAT,
)
await output.open()
try:
await output.write_chunk(chunk)
await output.flush()
finally:
await output.close()
async def transcribe_only(
self,
audio_bytes: bytes,
fmt: str,
language: str | None = None,
input=None,
):
trace = PipelineTrace()
# input dient hier nur der Validierung/Metadaten; das Audio kommt per Upload.
if input is not None:
await input.capabilities()
with _stage("stt"):
trace.raw_transcript = await self.stt.transcribe(
audio_bytes,
fmt=fmt,
language=language,
)
trace.cleaned_transcript = await self.input_cleaner.run(
trace.raw_transcript or ""
)
return trace
async def speak_only(
self,
text: str,
voice: str | None = None,
language: str | None = None,
output=None,
):
spoken = await self.spoken_adapter.run(text, language=language)
normalized = await self.tts_normalizer.run(
spoken, language=language, level=self.normalize_level
)
with _stage("tts"):
audio = await self.tts.synthesize(normalized, voice=voice, language=language)
await self._emit_to_output(audio, output)
return audio
async def chat_text(
self,
text: str,
language: str | None = None,
voice: str | None = None,
output=None,
history: list[dict] | None = None,
language_mode: str = "fix",
detected_language: str | None = None,
text_only: bool = False,
):
trace = PipelineTrace()
trace.raw_transcript = text
trace.cleaned_transcript = await self.input_cleaner.run(text or "")
# Flex: die erkannte Sprache bestimmt die Ausgabe. Gibt es keine (z. B. Text-Chat
# ohne Audio), bleibt sie None -> das LLM antwortet von selbst in der Eingabesprache.
# Fix: immer die konfigurierte Sprache (Whisper hat die Eingabe übersetzt).
effective_language = detected_language if language_mode == "flex" else language
with _stage("llm"):
trace.semantic_response = await self.llm.complete(
trace.cleaned_transcript or "",
history=history,
language=effective_language,
)
if not trace.semantic_response:
raise RuntimeError("LLM returned an empty response")
trace.spoken_response = await self.spoken_adapter.run(
trace.semantic_response,
language=effective_language,
)
trace.tts_ready_text = await self.tts_normalizer.run(
trace.spoken_response,
language=effective_language,
level=self.normalize_level,
)
# text_only: Geräte-TTS (Web Speech API) übernimmt das Sprechen -> kein Server-Audio.
if text_only:
return trace, b""
with _stage("tts"):
audio = await self.tts.synthesize(
trace.tts_ready_text,
voice=voice,
language=effective_language,
)
await self._emit_to_output(audio, output)
return trace, audio
async def chat_stream(
self,
text: str,
language: str | None = None,
voice: str | None = None,
output=None,
history: list[dict] | None = None,
on_token=None,
on_audio=None,
language_mode: str = "fix",
detected_language: str | None = None,
text_only: bool = False,
):
"""Wie chat_text, aber gestreamt.
`on_token(delta)` (async) wird pro LLM-Token-Delta aufgerufen.
Ist `on_audio(chunk)` gesetzt, wird das Audio satzweise erzeugt (chunked TTS)
und pro fertigem Satz ausgeliefert, statt erst am Ende komplett.
"""
trace = PipelineTrace()
trace.raw_transcript = text
trace.cleaned_transcript = await self.input_cleaner.run(text or "")
# Siehe chat_text: Flex folgt der erkannten Sprache (None -> LLM spiegelt Eingabe),
# Fix nutzt die konfigurierte Sprache.
effective_language = detected_language if language_mode == "flex" else language
# text_only: kein Server-Audio (Geräte-TTS spricht selbst) -> kein Chunked-TTS.
chunker = SentenceChunker() if (on_audio and not text_only) else None
audio_parts: list[bytes] = []
async def _emit_sentence(sentence: str) -> None:
spoken = await self.spoken_adapter.run(sentence, language=effective_language)
ready = await self.tts_normalizer.run(
spoken, language=effective_language, level=self.normalize_level
)
if not ready.strip():
return
chunk = await self.tts.synthesize(ready, voice=voice, language=effective_language)
audio_parts.append(chunk)
await on_audio(chunk)
parts: list[str] = []
stream_fn = getattr(self.llm, "stream", None)
if stream_fn is not None:
async for delta in stream_fn(trace.cleaned_transcript or "", history=history, language=effective_language):
parts.append(delta)
if on_token:
await on_token(delta)
if chunker:
for sentence in chunker.feed(delta):
await _emit_sentence(sentence)
else:
result = await self.llm.complete(trace.cleaned_transcript or "", history=history, language=effective_language)
parts.append(result)
if on_token:
await on_token(result)
if chunker:
for sentence in chunker.feed(result):
await _emit_sentence(sentence)
trace.semantic_response = "".join(parts)
if not trace.semantic_response:
raise RuntimeError("LLM returned an empty response")
trace.spoken_response = await self.spoken_adapter.run(
trace.semantic_response,
language=effective_language,
)
trace.tts_ready_text = await self.tts_normalizer.run(
trace.spoken_response,
language=effective_language,
level=self.normalize_level,
)
if text_only:
return trace, b""
if chunker:
tail = chunker.flush()
if tail:
await _emit_sentence(tail)
audio = b"".join(audio_parts)
else:
audio = await self.tts.synthesize(
trace.tts_ready_text, voice=voice, language=effective_language
)
await self._emit_to_output(audio, output)
return trace, audio

45
app/core/warmup.py Normal file
View file

@ -0,0 +1,45 @@
"""Laedt lokale KI-Modelle beim Start vor.
So zahlt nicht der erste Nutzer den Kaltstart (piper ~2 s Modell-Load, faster-whisper
Modell-Load). Es wird nur vorgeladen, was die aktive Konfiguration tatsaechlich nutzt
(Default-Provider) - bei reinen Cloud-Profilen passiert nichts. Best-effort: Fehler
werden geloggt, brechen den Start nie ab.
"""
import io
import logging
import wave
from app.config import settings
logger = logging.getLogger(__name__)
def _silence_wav(seconds: float = 0.1, rate: int = 16000) -> bytes:
buf = io.BytesIO()
with wave.open(buf, "wb") as w:
w.setnchannels(1)
w.setsampwidth(2)
w.setframerate(rate)
w.writeframes(b"\x00\x00" * int(rate * seconds))
return buf.getvalue()
async def warmup_local_models() -> None:
from app.dependencies import get_stt_provider, get_tts_provider
try:
tts = get_tts_provider()
if type(tts).__name__ == "PiperTTSProvider":
await tts.synthesize("Hallo.", audio_format="pcm")
logger.info("warmup: piper-Stimmmodell geladen")
except Exception:
logger.exception("warmup: TTS-Vorladen fehlgeschlagen (ignoriert)")
try:
stt = get_stt_provider()
if type(stt).__name__ == "FasterWhisperProvider":
await stt.transcribe(_silence_wav(), fmt="wav", language=settings.default_language)
logger.info("warmup: faster-whisper-Modell geladen")
except Exception:
logger.exception("warmup: STT-Vorladen fehlgeschlagen (ignoriert)")

305
app/dependencies.py Normal file
View file

@ -0,0 +1,305 @@
from dataclasses import dataclass
from app.config import Settings, settings
from app.runtime_config import runtime_settings
from app.errors import UnknownComponentError
from app.audio.router import AudioRouter
from app.audio.endpoints.input.local_default import LocalDefaultInput
from app.audio.endpoints.input.bluetooth import BluetoothInput
from app.audio.endpoints.input.mobile_ws import MobileWebSocketInput
from app.audio.endpoints.input.mobile_webrtc import MobileWebRTCInput
from app.audio.endpoints.output.local_default import LocalDefaultOutput
from app.audio.endpoints.output.bluetooth import BluetoothOutput
from app.audio.endpoints.output.mobile_ws import MobileWebSocketOutput
from app.audio.endpoints.output.mobile_webrtc import MobileWebRTCOutput
from app.audio.endpoints.output.loopback import LoopbackOutput
from app.providers.stt.openrouter import OpenRouterSTTProvider
from app.providers.stt.faster_whisper import FasterWhisperProvider
from app.providers.llm.local_openai_compatible import LocalOpenAICompatibleLLM
from app.providers.llm.openrouter import OpenRouterLLMProvider
from app.providers.tts.openrouter import OpenRouterTTSProvider
from app.providers.tts.chatterbox import ChatterboxTTSProvider
from app.providers.tts.piper import PiperTTSProvider
from app.providers.fallback import (
FallbackSTTProvider,
FallbackLLMProvider,
FallbackTTSProvider,
)
from app.pipeline.input_cleaner import InputCleaner
from app.pipeline.spoken_response_adapter import SpokenResponseAdapter
from app.pipeline.tts_normalizer import TTSNormalizer
from app.core.orchestrator import Orchestrator
from app.store import SQLiteStore, Store, User
# ---------------------------------------------------------------------------
# Persistenz-Store: Modul-Singleton (SQLite). Spaetere Backends implementieren
# dasselbe Store-Interface, ohne die App zu aendern.
# ---------------------------------------------------------------------------
_store: Store | None = None
def get_store() -> Store:
global _store
if _store is None:
_store = SQLiteStore(settings.db_path)
return _store
# ---------------------------------------------------------------------------
# Provider-Registries: Modul austauschbar via Name, ohne Kern-Code zu aendern.
# Ein neuer Provider = ein Eintrag. Unbekannter Name -> UnknownComponentError.
# ---------------------------------------------------------------------------
STT_REGISTRY = {
"openrouter": lambda s: OpenRouterSTTProvider(s.openrouter_api_key, s.openrouter_stt_model),
"faster-whisper": lambda s: FasterWhisperProvider(),
}
LLM_REGISTRY = {
"openrouter": lambda s: OpenRouterLLMProvider(s.openrouter_api_key, s.openrouter_llm_model),
"local-openai-compatible": lambda s: LocalOpenAICompatibleLLM(
s.local_llm_base_url,
s.local_llm_api_key,
s.local_llm_model,
system_prompt=s.local_llm_system_prompt,
disable_reasoning=s.local_llm_disable_reasoning,
max_tokens=s.local_llm_max_tokens,
temperature=s.local_llm_temperature,
top_p=s.local_llm_top_p,
),
}
TTS_REGISTRY = {
"openrouter": lambda s: OpenRouterTTSProvider(
s.openrouter_api_key, s.openrouter_tts_model, s.openrouter_tts_voice
),
"chatterbox": lambda s: ChatterboxTTSProvider(
s.chatterbox_base_url,
s.chatterbox_voice,
s.chatterbox_lang,
s.chatterbox_speed,
s.tts_sample_rate,
s.chatterbox_timeout,
voices_dir=s.chatterbox_voices_dir,
),
"piper": lambda s: PiperTTSProvider(
s.piper_bin, s.piper_voices_dir, s.piper_voice, s.tts_sample_rate
),
}
def _from_registry(registry: dict, name: str, kind: str, cfg: Settings):
try:
factory = registry[name]
except KeyError as exc:
raise UnknownComponentError(
f"Unbekannter {kind}-Provider {name!r}. Verfuegbar: {sorted(registry)}"
) from exc
return factory(cfg)
def get_stt_provider(name: str | None = None, cfg=None):
cfg = cfg or runtime_settings
return _from_registry(STT_REGISTRY, name or cfg.default_stt_provider, "STT", cfg)
def get_llm_provider(name: str | None = None, cfg=None):
cfg = cfg or runtime_settings
return _from_registry(LLM_REGISTRY, name or cfg.default_llm_provider, "LLM", cfg)
def get_tts_provider(name: str | None = None, cfg=None):
cfg = cfg or runtime_settings
return _from_registry(TTS_REGISTRY, name or cfg.default_tts_provider, "TTS", cfg)
# ---------------------------------------------------------------------------
# Audio-Router: Modul-Singleton, damit zustandsbehaftete Endpunkte
# (z. B. LoopbackOutput.chunks) ueber Requests hinweg stabil bleiben.
# ---------------------------------------------------------------------------
_audio_router: AudioRouter | None = None
def get_audio_router() -> AudioRouter:
global _audio_router
if _audio_router is None:
_audio_router = AudioRouter(
inputs=[
LocalDefaultInput(),
BluetoothInput(),
MobileWebSocketInput(),
MobileWebRTCInput(),
],
outputs=[
LocalDefaultOutput(),
BluetoothOutput(),
MobileWebSocketOutput(),
MobileWebRTCOutput(),
LoopbackOutput(),
],
)
return _audio_router
# ---------------------------------------------------------------------------
# Session-Routing und einheitliche Route-Aufloesung ueber alle Achsen.
# Praezedenz: Settings-Defaults < Session-Route < Request-Overrides.
# ---------------------------------------------------------------------------
ROUTE_KEYS = (
"input_endpoint",
"output_endpoint",
"stt_provider",
"llm_provider",
"tts_provider",
"language",
"language_mode",
)
# Stimm-Auswahl nach Sprache: (effektive) Sprache → beste Piper-Stimme.
# Gilt in BEIDEN Modi — die gesprochene Stimme folgt immer der Antwortsprache.
LANG_TO_PIPER_VOICE: dict[str, str] = {
"de": "de_DE-thorsten-high",
"en": "en_US-lessac-high",
"fr": "fr_FR-siwis-medium",
"es": "es_ES-sharvard-medium",
"it": "it_IT-paola-medium",
"nl": "nl_NL-mls-medium",
"ru": "ru_RU-irina-medium",
"zh": "zh_CN-huayan-medium",
"cmn": "zh_CN-huayan-medium",
}
def piper_voice_for_language(tts_provider: str, language: str | None) -> str | None:
"""Liefert die zur Sprache passende Piper-Stimme (sonst None → Provider-Default).
Nur für Piper sinnvoll: Cloud-TTS (OpenRouter) und Chatterbox haben eigene
Stimmnamen (z. B. "Zephyr") und steuern die Sprache anders. Bei None nimmt der
jeweilige Provider seine konfigurierte Default-Stimme.
"""
if tts_provider == "piper" and language:
return LANG_TO_PIPER_VOICE.get(language)
return None
@dataclass
class ResolvedRoute:
input_endpoint: str
output_endpoint: str
stt_provider: str
llm_provider: str
tts_provider: str
language: str
language_mode: str = "fix"
def as_dict(self) -> dict:
return {
"input_endpoint": self.input_endpoint,
"output_endpoint": self.output_endpoint,
"stt_provider": self.stt_provider,
"llm_provider": self.llm_provider,
"tts_provider": self.tts_provider,
"language": self.language,
"language_mode": self.language_mode,
}
def get_session_route(session_id: str | None, user: User | None = None) -> dict:
"""Liefert die gespeicherte Route einer Session des Nutzers (leeres dict sonst).
Gehoert die Session einem anderen Nutzer, wird SessionOwnershipError ausgeloest.
"""
if not session_id:
return {}
session = get_store().get_session(session_id)
if session is None:
return {}
if user is not None and session.user_id != user.id:
from app.store import SessionOwnershipError
raise SessionOwnershipError(
f"Session {session_id!r} gehoert einem anderen Nutzer"
)
return session.data
def resolve_route(
user: User | None = None,
session_id: str | None = None,
overrides: dict | None = None,
cfg=None,
) -> ResolvedRoute:
cfg = cfg or runtime_settings
"""Loest die effektive Route auf.
Praezedenz (hoeher gewinnt): Defaults < Nutzer-Prefs < Session-Route < Request.
"""
resolved = {
"input_endpoint": cfg.default_input_endpoint,
"output_endpoint": cfg.default_output_endpoint,
"stt_provider": cfg.default_stt_provider,
"llm_provider": cfg.default_llm_provider,
"tts_provider": cfg.default_tts_provider,
"language": cfg.default_language,
"language_mode": cfg.default_language_mode,
}
user_prefs = user.prefs if user is not None else {}
session_route = get_session_route(session_id, user)
request_overrides = overrides or {}
for layer in (user_prefs, session_route, request_overrides):
for key in ROUTE_KEYS:
value = layer.get(key)
if value is not None:
resolved[key] = value
return ResolvedRoute(**resolved)
_FALLBACK_CLASS = {
"stt": FallbackSTTProvider,
"llm": FallbackLLMProvider,
"tts": FallbackTTSProvider,
}
def _provider_chain(registry, primary: str, fallback_csv: str, module: str, cfg: Settings):
"""Baut primaeren Provider + optionale Fallback-Kette (dedupliziert, Reihenfolge erhalten)."""
names = [primary] + [n.strip() for n in (fallback_csv or "").split(",") if n.strip()]
seen, ordered = set(), []
for name in names:
if name not in seen:
seen.add(name)
ordered.append(name)
entries = [(name, _from_registry(registry, name, module.upper(), cfg)) for name in ordered]
if len(entries) == 1:
return entries[0][1]
return _FALLBACK_CLASS[module](module, entries)
def _resolve_normalize_level(tts_provider: str, cfg: Settings) -> str:
"""auto -> piper bekommt 'full', Cloud-TTS 'light' (macht Zahlen/Abk. selbst gut)."""
level = (cfg.tts_normalize_level or "auto").lower()
if level == "auto":
return "full" if tts_provider == "piper" else "light"
return level
def build_orchestrator(route: ResolvedRoute, cfg=None) -> Orchestrator:
cfg = cfg or runtime_settings
return Orchestrator(
stt=_provider_chain(STT_REGISTRY, route.stt_provider, cfg.stt_fallback, "stt", cfg),
llm=_provider_chain(LLM_REGISTRY, route.llm_provider, cfg.llm_fallback, "llm", cfg),
tts=_provider_chain(TTS_REGISTRY, route.tts_provider, cfg.tts_fallback, "tts", cfg),
input_cleaner=InputCleaner(),
spoken_adapter=SpokenResponseAdapter(),
tts_normalizer=TTSNormalizer(),
normalize_level=_resolve_normalize_level(route.tts_provider, cfg),
)
async def resolve_output_endpoint(route: ResolvedRoute):
return await get_audio_router().select_output(route.output_endpoint)
async def resolve_input_endpoint(route: ResolvedRoute):
return await get_audio_router().select_input(route.input_endpoint)

13
app/errors.py Normal file
View file

@ -0,0 +1,13 @@
class RoutingError(Exception):
"""Basis fuer Fehler bei der Routing-/Komponentenauswahl.
Wird in der API-Schicht zu HTTP 422 uebersetzt (Client-Konfigurationsfehler).
"""
class UnknownComponentError(RoutingError):
"""Unbekannter Provider-Name fuer STT, LLM oder TTS."""
class UnknownEndpointError(RoutingError):
"""Ein angefragter Audio-Endpunkt (input/output) existiert nicht."""

105
app/main.py Normal file
View file

@ -0,0 +1,105 @@
import asyncio
import time
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI, Request
from fastapi.staticfiles import StaticFiles
from starlette.responses import RedirectResponse, JSONResponse
from app.config import settings
from app.auth import authenticate, _bearer_token
from app.core.warmup import warmup_local_models
from app.metrics import metrics
from app.api.health import router as health_router
from app.api.chat import router as chat_router
from app.api.transcribe import router as transcribe_router
from app.api.speak import router as speak_router
from app.api.devices import router as devices_router
from app.api.sessions import router as sessions_router
from app.api.config import router as config_router
from app.api.admin import router as admin_router
from app.api.me import router as me_router
from app.api.metrics import router as metrics_router
from app.api.ws import router as ws_router
@asynccontextmanager
async def _lifespan(app: FastAPI):
# Lokale Modelle im Hintergrund vorladen -> Server ist sofort verfuegbar,
# der erste Nutzer zahlt nicht den Kaltstart.
task = asyncio.create_task(warmup_local_models())
yield
if not task.done():
task.cancel()
app = FastAPI(title="Voice Assistant Gateway", lifespan=_lifespan)
@app.middleware("http")
async def record_metrics(request: Request, call_next):
start = time.perf_counter()
response = await call_next(request)
duration = time.perf_counter() - start
# Route-Template (z. B. /api/sessions/{session_id}/route) statt konkreter URL,
# um die Label-Kardinalitaet niedrig zu halten.
route = request.scope.get("route")
path = getattr(route, "path", request.url.path)
labels = {"method": request.method, "path": path}
metrics.inc("http_requests_total", {**labels, "status": response.status_code})
metrics.observe("http_request_duration_seconds", duration, labels)
return response
# Pfade mit eigener Auth, Health-Checks und Favicons: nicht gaten.
_PUBLIC_PREFIXES = ("/api", "/ws", "/health", "/favicon", "/apple-touch-icon")
@app.middleware("http")
async def gate_web_ui(request: Request, call_next):
"""Schuetzt die statische Web-UI: unauthentifizierte Seitenaufrufe -> SSO-Login (sonst 401).
Defense-in-Depth zusaetzlich zu SSOwat: Selbst wenn der Reverse-Proxy einen
unauthentifizierten Request durchliesse (oder jemand den Port direkt trifft),
bekommt er die Seite nicht ausgeliefert. API/WS haben ihre eigene Auth (require_user
-> 401/JSON), Health-Checks bleiben offen. Bei abgeschalteter Auth (LAN-Dev) liefert
authenticate() einen anonymen Nutzer -> die Seite wird wie bisher ausgeliefert.
"""
path = request.url.path
if not path.startswith(_PUBLIC_PREFIXES):
client_host = request.client.host if request.client else ""
token = _bearer_token(request.headers.get("authorization"))
if authenticate(request.headers, client_host, token) is None:
login = settings.sso_login_url.strip()
if login:
return RedirectResponse(login, status_code=302)
return JSONResponse({"detail": "Authentication required"}, status_code=401)
return await call_next(request)
app.include_router(health_router)
app.include_router(chat_router, prefix="/api")
app.include_router(transcribe_router, prefix="/api")
app.include_router(speak_router, prefix="/api")
app.include_router(devices_router, prefix="/api")
app.include_router(sessions_router, prefix="/api")
app.include_router(config_router, prefix="/api")
app.include_router(admin_router, prefix="/api")
app.include_router(me_router, prefix="/api")
app.include_router(metrics_router, prefix="/api")
app.include_router(ws_router)
# Statische Web-UI (same-origin -> kein CORS). Muss NACH allen API-/WS-Routen
# gemountet werden, damit "/" nur die uebrigen Pfade abfaengt.
class _NoCacheStaticFiles(StaticFiles):
"""Liefert die UI ohne Caching aus -> Aenderungen sind sofort sichtbar (kein Safari-Cache)."""
def file_response(self, *args, **kwargs):
response = super().file_response(*args, **kwargs)
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
return response
_WEB_DIR = Path(__file__).resolve().parent / "web"
if _WEB_DIR.is_dir():
app.mount("/", _NoCacheStaticFiles(directory=str(_WEB_DIR), html=True), name="web")

82
app/metrics.py Normal file
View file

@ -0,0 +1,82 @@
"""Schlanke In-Memory-Metriken (Counter + Timer) fuer einen Prozess.
Bewusst ohne externe Dependency. Fuer mehrere Instanzen/Prozesse spaeter durch
einen gemeinsamen Backend (z. B. Prometheus-Exporter) ersetzbar.
"""
import threading
import time
from collections import defaultdict
class Metrics:
def __init__(self):
self._lock = threading.Lock()
self._counters: dict[str, float] = defaultdict(float)
self._timers: dict[str, list] = defaultdict(lambda: [0.0, 0]) # [sum, count]
@staticmethod
def _key(name: str, labels: dict | None) -> str:
if not labels:
return name
rendered = ",".join(f'{k}="{v}"' for k, v in sorted(labels.items()))
return f"{name}{{{rendered}}}"
def inc(self, name: str, labels: dict | None = None, value: float = 1.0) -> None:
with self._lock:
self._counters[self._key(name, labels)] += value
def observe(self, name: str, seconds: float, labels: dict | None = None) -> None:
with self._lock:
agg = self._timers[self._key(name, labels)]
agg[0] += seconds
agg[1] += 1
def snapshot(self) -> dict:
with self._lock:
counters = dict(self._counters)
timers = {
key: {
"sum": agg[0],
"count": agg[1],
"avg": (agg[0] / agg[1] if agg[1] else 0.0),
}
for key, agg in self._timers.items()
}
return {"counters": counters, "timers": timers}
def prometheus(self) -> str:
snap = self.snapshot()
lines = []
for key, value in sorted(snap["counters"].items()):
lines.append(f"{key} {value}")
for key, agg in sorted(snap["timers"].items()):
base, _, labels = key.partition("{")
suffix = ("{" + labels) if labels else ""
lines.append(f"{base}_sum{suffix} {agg['sum']}")
lines.append(f"{base}_count{suffix} {agg['count']}")
return "\n".join(lines) + "\n"
def reset(self) -> None:
with self._lock:
self._counters.clear()
self._timers.clear()
metrics = Metrics()
class timer:
"""Context-Manager: misst die Dauer und schreibt sie als Timer-Beobachtung."""
def __init__(self, name: str, labels: dict | None = None):
self.name = name
self.labels = labels
def __enter__(self):
self._start = time.perf_counter()
return self
def __exit__(self, *exc):
metrics.observe(self.name, time.perf_counter() - self._start, self.labels)
return False

0
app/pipeline/__init__.py Normal file
View file

View file

@ -0,0 +1,37 @@
"""Deutsche Ordinalzahlen 1.31. (für Datums- und Aufzählungs-Aussprache).
Bewusst eine kleine handgepflegte Tabelle statt `num2words`: die deutsche
Ordinal-Flexion ist mit num2words nicht sauber abbildbar, und der Bereich 131
deckt Datumsangaben und Listenpositionen vollständig ab.
"""
from __future__ import annotations
# Stamm der Ordinalzahl (ohne Endung). attributiv = Stamm + "er" ("erster"),
# adverbial = Stamm + "ens" ("erstens").
_STEMS: dict[int, str] = {
1: "erst", 2: "zweit", 3: "dritt", 4: "viert", 5: "fünft",
6: "sechst", 7: "siebt", 8: "acht", 9: "neunt", 10: "zehnt",
11: "elft", 12: "zwölft", 13: "dreizehnt", 14: "vierzehnt", 15: "fünfzehnt",
16: "sechzehnt", 17: "siebzehnt", 18: "achtzehnt", 19: "neunzehnt", 20: "zwanzigst",
21: "einundzwanzigst", 22: "zweiundzwanzigst", 23: "dreiundzwanzigst",
24: "vierundzwanzigst", 25: "fünfundzwanzigst", 26: "sechsundzwanzigst",
27: "siebenundzwanzigst", 28: "achtundzwanzigst", 29: "neunundzwanzigst",
30: "dreißigst", 31: "einunddreißigst",
}
MIN, MAX = 1, 31
def has_ordinal(n: int) -> bool:
return n in _STEMS
def ordinal_attributive(n: int) -> str:
"""'1' -> 'erster' (z. B. 'erster Mai')."""
return _STEMS[n] + "er"
def ordinal_adverbial(n: int) -> str:
"""'1' -> 'erstens' (Aufzählungen)."""
return _STEMS[n] + "ens"

View file

@ -0,0 +1,4 @@
class InputCleaner:
async def run(self, text: str) -> str:
cleaned = " ".join(text.strip().split())
return cleaned.replace(" äh ", " ").replace(" hm ", " ")

View file

@ -0,0 +1,75 @@
import re
# Satzende: . ! ? … gefolgt von Whitespace (oder Stringende beim flush).
_SENTENCE_END = re.compile(r"[.!?…]+(?=\s)")
# Abkürzungen, nach denen NICHT getrennt werden darf (sonst zerschneidet der
# Streaming-Chunker mitten in "z. | B." und die Normalisierung greift nicht mehr).
_ABBREVS = (
"z.b.", "z. b.", "d.h.", "d. h.", "u.a.", "u. a.", "bzw.", "ca.", "usw.",
"etc.", "dr.", "prof.", "nr.", "str.", "evtl.", "inkl.", "ggf.", "max.",
"min.", "vgl.", "sog.", "u.ä.", "o.ä.", "bspw.",
)
_ABBR_END = re.compile(
r"(?:^|[\s(„\"'])(" + "|".join(re.escape(a) for a in _ABBREVS) + r")$",
re.IGNORECASE,
)
class SentenceChunker:
"""Inkrementelle Satzsegmentierung fuer gestreamte LLM-Token.
`feed(delta)` liefert die seit dem letzten Aufruf fertig gewordenen Saetze,
`flush()` den verbleibenden Rest (z. B. der letzte Satz ohne abschliessendes
Leerzeichen). Damit kann pro Satz schon TTS erzeugt werden, waehrend das LLM
noch weiterschreibt.
Es wird NICHT getrennt, wenn der Punkt zu einer Ordinal-/Datumszahl
("1. Mai") oder einer bekannten Abkuerzung ("z. B.") gehoert.
"""
def __init__(self):
self._buffer = ""
def _is_real_end(self, match) -> bool:
start, end = match.start(), match.end()
punct = match.group()
dot_only = set(punct) <= {".", ""}
if dot_only and start > 0:
prev = self._buffer[start - 1]
# Ziffer + Punkt ("1.") = Ordinal-/Listenmarker, kein Satzende.
if prev.isdigit():
return False
# Einzelner Buchstabe + Punkt ("z. B.", Initialen "A.") -> kein Satzende.
if prev.isalpha():
before = self._buffer[start - 2] if start >= 2 else ""
if before == "" or not before.isalpha():
return False
# Bekannte (mehrbuchstabige) Abkuerzung vor dem Punkt -> kein Satzende.
if _ABBR_END.search(self._buffer[:end]):
return False
return True
def feed(self, text: str) -> list[str]:
self._buffer += text
sentences: list[str] = []
search_start = 0
while True:
match = _SENTENCE_END.search(self._buffer, search_start)
if not match:
break
end = match.end()
if not self._is_real_end(match):
search_start = end # diese Stelle nicht trennen, weitersuchen
continue
sentence = self._buffer[:end].strip()
self._buffer = self._buffer[end:]
search_start = 0
if sentence:
sentences.append(sentence)
return sentences
def flush(self) -> str:
rest = self._buffer.strip()
self._buffer = ""
return rest

View file

@ -0,0 +1,62 @@
import re
from app.pipeline import german_numbers as gn
# Emojis/Piktogramme/Symbole: stoeren das Vorlesen (TTS spricht sie aus oder verschluckt
# sich). Deckt die gaengigen Unicode-Bloecke ab (Emoticons, Symbole, Transport, Flaggen,
# Dingbats, Pfeile, Variationsselektoren, ZWJ).
_EMOJI_RE = re.compile(
"["
"\U0001F300-\U0001FAFF" # Symbole/Emoticons/Transport/Erweiterungen
"\U00002600-\U000027BF" # Diverse Symbole + Dingbats (☀ ⚠ ✅ ❤ …)
"\U0001F1E6-\U0001F1FF" # Regional-Indikatoren (Flaggen)
"\U00002B00-\U00002BFF" # Symbole/Pfeile (⭐ …)
"\U00002190-\U000021FF" # Pfeile (→ ← …)
"\U00002300-\U000023FF" # Technische Symbole (⌚ ⏰ …)
"\U0000FE00-\U0000FE0F" # Variationsselektoren
"\U0000200D" # Zero-Width-Joiner
"]+"
)
class SpokenResponseAdapter:
async def run(self, text: str, language: str = "de") -> str:
if not text:
return ""
text = text.strip()
# Markdown / Formatierung entfernen
text = re.sub(r"```[\s\S]*?```", " ", text) # code blocks
text = re.sub(r"`([^`]*)`", r"\1", text) # inline code
text = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", text) # markdown links
text = re.sub(r"[*_~#>]+", " ", text) # markdown symbols
text = _EMOJI_RE.sub("", text) # Emojis/Symbole
# Listen entschärfen: Aufzählungspunkte weg, nummerierte Listen zu
# Ordinalwörtern ("1. " -> "erstens, "), damit Piper nicht "eins" sagt.
text = re.sub(r"(?m)^\s*[-•]\s+", "", text)
def _numbered(m):
n = int(m.group(1))
return f"{gn.ordinal_adverbial(n)}, " if gn.has_ordinal(n) else ""
text = re.sub(r"(?m)^\s*(\d{1,2})\.\s+", _numbered, text)
# Mehrfache Leerzeichen / Zeilenumbrüche glätten
text = re.sub(r"\s+", " ", text).strip()
# Für Voice natürlicher machen: Doppelpunkte/Semikolons etwas beruhigen,
# aber Uhrzeiten/Verhältnisse (10:30) nicht zerstören -> nur am Wortende ersetzen.
text = re.sub(r"[:;](?=\s|$)", ",", text)
# Klammern meist nicht gut für TTS
text = text.replace("(", ", ")
text = text.replace(")", " ")
# Abschlusspunktion sicherstellen
if text and not text.endswith((".", "!", "?")):
text += "."
return text

View file

@ -0,0 +1,165 @@
"""Text-Normalisierung vor dem TTS.
Leitprinzip: NICHT duplizieren, was espeak-ng (in piper) bereits gut kann
(Kardinal-/Dezimalzahlen). Nur die belegten Lücken füllen: Ordinalzahlen,
Einheiten-Abkürzungen, gängige Abkürzungen und ein optionales YAML-Lexikon.
Stufen (`level`):
- "off": keine Änderung (Text unverändert durchreichen).
- "light": nur harmlose Glättung (URLs/E-Mails, Whitespace, Abschlusspunkt)
für Cloud-TTS, das Zahlen/Abkürzungen selbst gut spricht.
- "full": zusätzlich Ordinalia, Einheiten, Abkürzungen, Lexikon, Symbole
für lokales TTS (piper).
"""
from __future__ import annotations
import re
from functools import lru_cache
from pathlib import Path
from app.pipeline import german_numbers as gn
try:
import yaml
except ModuleNotFoundError: # pragma: no cover - YAML optional
yaml = None
_CONFIG_DIR = Path(__file__).resolve().parents[2] / "config"
_MONTHS = (
"Januar|Februar|März|April|Mai|Juni|Juli|August|"
"September|Oktober|November|Dezember"
)
_MONTH_ORDINAL = re.compile(rf"\b(\d{{1,2}})\.\s+(?=(?:{_MONTHS})\b)")
# Aufzählungsmarker wie "1)" oder "1.)" -> adverbiale Ordinalzahl.
_ENUM_MARKER = re.compile(r"\b(\d{1,2})\.?\)")
# Folge von >=2 Zahl-Punkt-Markern ("1. 2. 3.") -> jeweils adverbial.
_ENUM_SEQ = re.compile(r"(?:(?<![\d,])\d{1,2}\.\s*){2,}")
_NUM_DOT = re.compile(r"(\d{1,2})\.")
# Eingebaute Defaults (auch ohne YAML aktiv). YAML erweitert/überschreibt sie.
_DEFAULT_ABBREVS_DE = {
"z.B.": "zum Beispiel", "z. B.": "zum Beispiel",
"d.h.": "das heißt", "d. h.": "das heißt",
"u.a.": "unter anderem", "bzw.": "beziehungsweise",
"ca.": "circa", "usw.": "und so weiter", "etc.": "et cetera",
"Dr.": "Doktor", "Prof.": "Professor", "Nr.": "Nummer", "Str.": "Straße",
}
_DEFAULT_UNITS_DE = {
"kg": "Kilogramm", "g": "Gramm", "mg": "Milligramm",
"km": "Kilometer", "m": "Meter", "cm": "Zentimeter", "mm": "Millimeter",
"l": "Liter", "ml": "Milliliter", "h": "Stunden", "min": "Minuten",
"km/h": "Kilometer pro Stunde",
}
_DEFAULT_SYMBOLS_DE = {
"24/7": "vierundzwanzig sieben", "&": " und ", "%": " Prozent",
"": " Euro", "$": " Dollar",
}
_DEFAULT_SYMBOLS_EN = {
"24/7": "twenty four seven", "&": " and ", "%": " percent",
"": " euros", "$": " dollars", "e.g.": "for example", "i.e.": "that is",
}
@lru_cache(maxsize=8)
def _load_lexicon(language: str) -> tuple:
"""Lädt config/pronunciation.<lang>.yaml; gibt (abbrevs, units, terms) zurück."""
abbrevs, units, terms = {}, {}, {}
path = _CONFIG_DIR / f"pronunciation.{language}.yaml"
if yaml is not None and path.exists():
try:
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
abbrevs = {str(k): str(v) for k, v in (data.get("abbreviations") or {}).items()}
units = {str(k): str(v) for k, v in (data.get("units") or {}).items()}
terms = {str(k): str(v) for k, v in (data.get("terms") or {}).items()}
except (OSError, ValueError, AttributeError):
pass # defektes YAML -> nur Defaults
return abbrevs, units, terms
def _apply_ordinals(text: str) -> str:
# Datum: "1. Mai" -> "erster Mai"
def _date(m):
n = int(m.group(1))
return f"{gn.ordinal_attributive(n)} " if gn.has_ordinal(n) else m.group(0)
text = _MONTH_ORDINAL.sub(_date, text)
# Aufzählungs-Folge "1. 2. 3." -> "erstens zweitens drittens" (alle in der Folge).
def _num(m):
n = int(m.group(1))
return f"{gn.ordinal_adverbial(n)} " if gn.has_ordinal(n) else m.group(0)
text = _ENUM_SEQ.sub(lambda m: _NUM_DOT.sub(_num, m.group(0)), text)
# Marker "1)" / "1.)" -> "erstens"
def _marker(m):
n = int(m.group(1))
return gn.ordinal_adverbial(n) if gn.has_ordinal(n) else m.group(0)
text = _ENUM_MARKER.sub(_marker, text)
return text
def _apply_units(text: str, units: dict) -> str:
# Nur DIREKT nach einer Zahl ersetzen, damit "Meter" nicht jedes "m" trifft.
# Längere Einheitenkürzel zuerst (km/h vor km, mm vor m).
for unit in sorted(units, key=len, reverse=True):
spoken = units[unit]
pattern = rf"(?<=\d)\s*{re.escape(unit)}\b"
text = re.sub(pattern, f" {spoken}", text)
return text
def _apply_dict(text: str, mapping: dict, ignore_case: bool = False) -> str:
# Längste Schlüssel zuerst, wortgrenzen-bewusst (Abkürzungen enden oft auf ".").
flags = re.IGNORECASE if ignore_case else 0
for key in sorted(mapping, key=len, reverse=True):
repl = mapping[key]
if key.isalnum(): # reines Wort/Akronym -> mit Wortgrenzen
text = re.sub(rf"\b{re.escape(key)}\b", repl, text, flags=flags)
elif ignore_case:
text = re.sub(re.escape(key), repl, text, flags=re.IGNORECASE)
else: # enthält Punkte/Sonderzeichen -> direkte Ersetzung
text = text.replace(key, repl)
return text
class TTSNormalizer:
async def run(self, text: str, language: str = "de", level: str = "full") -> str:
if not text:
return ""
if level == "off":
return text
t = text
# --- immer (light + full): harmlose Glättung ---
t = re.sub(r"https?://\S+", "Link", t)
t = re.sub(r"\b[\w\.-]+@[\w\.-]+\.\w+\b", "E-Mail-Adresse", t)
if level == "full":
abbrevs, units, terms = _load_lexicon(language)
if language == "de":
t = _apply_ordinals(t)
t = _apply_units(t, {**_DEFAULT_UNITS_DE, **units})
t = _apply_dict(t, {**_DEFAULT_ABBREVS_DE, **abbrevs})
t = _apply_dict(t, terms, ignore_case=True)
t = _apply_dict(t, _DEFAULT_SYMBOLS_DE)
else:
t = _apply_units(t, units)
t = _apply_dict(t, abbrevs)
t = _apply_dict(t, terms, ignore_case=True)
t = _apply_dict(t, _DEFAULT_SYMBOLS_EN)
# Slash zwischen Wörtern/Zahlen sprachfreundlich, Striche entschärfen.
t = re.sub(r"(\w)/(\w)", r"\1 oder \2", t)
t = t.replace("", " bis ").replace("", ", ").replace(" - ", ", ")
# Mehrfache Leerzeichen glätten, Abschlusspunkt sicherstellen.
t = re.sub(r"\s+", " ", t).strip()
if t and not t.endswith((".", "!", "?")):
t += "."
return t

View file

104
app/providers/fallback.py Normal file
View file

@ -0,0 +1,104 @@
"""Fallback-Ketten: versuchen mehrere Provider der Reihe nach.
Faellt der primaere Provider aus (Timeout/Fehler), wird transparent der naechste
versucht. Erfolgreicher Fallback und Provider-Fehler werden als Metrik erfasst.
"""
from collections.abc import AsyncIterator
from app.metrics import metrics
class _Chain:
def __init__(self, module: str, entries: list[tuple[str, object]]):
self.module = module
self.entries = entries # [(provider_name, provider), ...]
def _on_error(self, name: str) -> None:
metrics.inc("provider_error_total", {"module": self.module, "provider": name})
def _on_fallback(self) -> None:
metrics.inc("provider_fallback_total", {"module": self.module})
class FallbackSTTProvider(_Chain):
async def transcribe(self, audio_bytes, fmt, language=None) -> str:
last_exc = None
for index, (name, provider) in enumerate(self.entries):
try:
result = await provider.transcribe(audio_bytes, fmt, language=language)
if index > 0:
self._on_fallback()
return result
except Exception as exc: # noqa: BLE001 - bewusst breit fuer Resilienz
last_exc = exc
self._on_error(name)
raise last_exc
async def transcribe_detect(self, audio_bytes, fmt, language=None) -> tuple[str, str | None]:
last_exc = None
for index, (name, provider) in enumerate(self.entries):
try:
result = await provider.transcribe_detect(audio_bytes, fmt, language=language)
if index > 0:
self._on_fallback()
return result
except Exception as exc: # noqa: BLE001
last_exc = exc
self._on_error(name)
raise last_exc
class FallbackLLMProvider(_Chain):
async def complete(self, text, history=None, session_id=None, language=None) -> str:
last_exc = None
for index, (name, provider) in enumerate(self.entries):
try:
result = await provider.complete(
text, history=history, session_id=session_id, language=language
)
if index > 0:
self._on_fallback()
return result
except Exception as exc: # noqa: BLE001
last_exc = exc
self._on_error(name)
raise last_exc
async def stream(self, text, history=None, session_id=None, language=None) -> AsyncIterator[str]:
last_exc = None
for index, (name, provider) in enumerate(self.entries):
produced = False
try:
async for delta in provider.stream(
text, history=history, session_id=session_id, language=language
):
produced = True
yield delta
if index > 0:
self._on_fallback()
return
except Exception as exc: # noqa: BLE001
last_exc = exc
self._on_error(name)
if produced:
# Schon Token gesendet -> kein Fallback mehr moeglich.
raise
raise last_exc
class FallbackTTSProvider(_Chain):
async def synthesize(self, text, voice=None, audio_format="pcm", language=None) -> bytes:
last_exc = None
for index, (name, provider) in enumerate(self.entries):
try:
result = await provider.synthesize(
text, voice=voice, audio_format=audio_format, language=language
)
if index > 0:
self._on_fallback()
return result
except Exception as exc: # noqa: BLE001
last_exc = exc
self._on_error(name)
raise last_exc

View file

78
app/providers/llm/base.py Normal file
View file

@ -0,0 +1,78 @@
import json
from abc import ABC, abstractmethod
from collections.abc import AsyncIterator
_LANG_NAMES: dict[str, str] = {
"de": "Deutsch",
"en": "English",
"fr": "Français",
"es": "Español",
"it": "Italiano",
"nl": "Nederlands",
"ru": "Русский",
"zh": "中文",
"cmn": "中文",
}
def lang_instruction(language: str | None) -> str | None:
"""Sprach-Anweisung für den ISO-Code, oder None.
Bewusst nachdrücklich formuliert: Eine lange anderssprachige History (z. B. der
Nutzer hat zwischendurch englisch gesprochen) soll die gewünschte Sprache NICHT
überstimmen. Wird zusätzlich nah an die Generierung gesetzt (siehe with_lang_reminder).
"""
if not language:
return None
name = _LANG_NAMES.get(language, language)
return f"Respond ONLY in {name}, regardless of the language used earlier in this conversation."
def with_lang_reminder(text: str, language: str | None) -> str:
"""Hängt eine knappe Sprach-Erinnerung an die letzte Nutzer-Nachricht.
Direkt vor der Generierung platziert wirkt sie stärker als der (weit zurückliegende)
System-Prompt, wenn die bisherige Unterhaltung in einer anderen Sprache lief.
"""
if not language:
return text
name = _LANG_NAMES.get(language, language)
return f"{text}\n\n[Bitte ausschließlich auf {name} antworten.]"
def sse_delta(line: str) -> str | None:
"""Extrahiert das Token-Delta aus einer OpenAI-kompatiblen SSE-Zeile (oder None)."""
if not line.startswith("data:"):
return None
data = line[len("data:"):].strip()
if not data or data == "[DONE]":
return None
try:
obj = json.loads(data)
return obj["choices"][0]["delta"].get("content")
except (ValueError, KeyError, IndexError, TypeError):
return None
class LLMProvider(ABC):
@abstractmethod
async def complete(
self,
text: str,
history: list[dict] | None = None,
session_id: str | None = None,
language: str | None = None,
) -> str: ...
async def stream(
self,
text: str,
history: list[dict] | None = None,
session_id: str | None = None,
language: str | None = None,
) -> AsyncIterator[str]:
"""Token-Stream. Default: kein echtes Streaming -> komplette Antwort als ein Chunk.
Provider mit SSE-Unterstuetzung ueberschreiben diese Methode.
"""
yield await self.complete(text, history=history, session_id=session_id, language=language)

View file

@ -0,0 +1,122 @@
from collections.abc import AsyncIterator
import httpx
from app.providers.llm.base import (
LLMProvider,
lang_instruction,
with_lang_reminder,
sse_delta,
)
class LocalOpenAICompatibleLLM(LLMProvider):
def __init__(
self,
base_url: str,
api_key: str,
model: str,
system_prompt: str = "",
disable_reasoning: bool = True,
max_tokens: int = 0,
temperature: float = 0.3,
top_p: float = 0.9,
):
self.base_url = base_url.rstrip("/")
self.api_key = api_key
self.model = model
self.system_prompt = (system_prompt or "").strip()
self.disable_reasoning = disable_reasoning
self.max_tokens = max_tokens
self.temperature = temperature
self.top_p = top_p
def _build_messages(
self, text: str, history: list[dict] | None, language: str | None = None
) -> list[dict]:
# Manche Chat-Templates (z. B. Qwen3) erlauben nur EINE System-Nachricht,
# ganz am Anfang. Daher den Sprach-System-Prompt und etwaige System-
# Nachrichten aus der History (z. B. Nutzer-Erinnerungen) zu einer einzigen
# fuehrenden System-Nachricht zusammenfuehren.
system_parts: list[str] = []
if self.system_prompt:
system_parts.append(self.system_prompt)
rest: list[dict] = []
for msg in history or []:
if msg.get("role") == "system":
content = (msg.get("content") or "").strip()
if content:
system_parts.append(content)
else:
rest.append(msg)
instr = lang_instruction(language)
if instr:
system_parts.append(instr)
messages: list[dict] = []
if system_parts:
messages.append({"role": "system", "content": "\n\n".join(system_parts)})
messages.extend(rest)
# Sprach-Erinnerung direkt an der letzten Nutzer-Nachricht (schlägt History-Trägheit).
messages.append({"role": "user", "content": with_lang_reminder(text, language)})
return messages
def _payload(
self, text: str, history: list[dict] | None, stream: bool, language: str | None = None
) -> dict:
payload: dict = {
"model": self.model,
"messages": self._build_messages(text, history, language=language),
"temperature": self.temperature,
"top_p": self.top_p,
}
if stream:
payload["stream"] = True
if self.max_tokens > 0:
payload["max_tokens"] = self.max_tokens
if self.disable_reasoning:
# Qwen3/llama.cpp: Denkphase abschalten -> schnellere erste Antwort.
payload["chat_template_kwargs"] = {"enable_thinking": False}
return payload
async def complete(
self,
text: str,
history: list[dict] | None = None,
session_id: str | None = None,
language: str | None = None,
) -> str:
async with httpx.AsyncClient(timeout=120) as client:
response = await client.post(
f"{self.base_url}/chat/completions",
headers={"Authorization": f"Bearer {self.api_key}"},
json=self._payload(text, history, stream=False, language=language),
)
response.raise_for_status()
data = response.json()
return data["choices"][0]["message"]["content"]
async def stream(
self,
text: str,
history: list[dict] | None = None,
session_id: str | None = None,
language: str | None = None,
) -> AsyncIterator[str]:
async with httpx.AsyncClient(timeout=120) as client:
async with client.stream(
"POST",
f"{self.base_url}/chat/completions",
headers={"Authorization": f"Bearer {self.api_key}"},
json=self._payload(text, history, stream=True, language=language),
) as response:
if response.status_code >= 400:
body = await response.aread()
raise RuntimeError(
f"Local LLM error {response.status_code}: "
f"{body.decode(errors='replace')}"
)
async for line in response.aiter_lines():
delta = sse_delta(line)
if delta:
yield delta

View file

@ -0,0 +1,166 @@
from collections.abc import AsyncIterator
import httpx
from app.providers.llm.base import (
LLMProvider,
lang_instruction,
with_lang_reminder,
sse_delta,
)
SYSTEM_PROMPT = """
You are a voice assistant for spoken conversations with older adults.
Speak naturally, clearly, and calmly.
Use short, simple sentences.
Prefer plain everyday language over technical wording.
Answer in the same language as the user, unless the user asks to switch languages.
Important response rules:
- Output plain text only.
- No markdown.
- No bullet points.
- No numbered lists.
- No tables.
- No code.
- No emojis.
- No URLs unless the user explicitly asks for one.
- Do not use asterisks, hashtags, or formatting symbols.
- Do not write headings.
- Do not use long disclaimers.
Voice style rules:
- Sound helpful, warm, and patient.
- Keep answers brief by default: 1 to 3 short sentences.
- If more detail is needed, explain step by step in natural spoken sentences.
- Ask at most one follow-up question at a time.
- If the answer contains several items, present them as natural speech, not as a list.
- Use wording that sounds good when spoken aloud.
- Avoid abbreviations when possible.
- Avoid symbols when words are better.
- Prefer complete spoken forms for dates, times, and numbers when useful.
Safety and honesty rules:
- If you are unsure, say so briefly and clearly.
- Do not invent facts.
- If current real-world information is needed and unavailable, say that clearly.
Always optimize your answer for listening, not for reading.
""".strip()
class OpenRouterLLMProvider(LLMProvider):
def __init__(self, api_key: str, model: str):
self.api_key = (api_key or "").strip()
self.model = (model or "").strip()
def _build_messages(
self, text: str, history: list[dict] | None, language: str | None = None
) -> list[dict]:
if not self.api_key:
raise ValueError("OPENROUTER_API_KEY is empty")
if not self.model:
raise ValueError("OPENROUTER_LLM_MODEL is empty")
if not text or not text.strip():
raise ValueError("LLM input text is empty")
system_content = SYSTEM_PROMPT
instr = lang_instruction(language)
if instr:
system_content = f"{SYSTEM_PROMPT}\n\n{instr}"
messages = [{"role": "system", "content": system_content}]
if history:
messages.extend(history)
# Sprach-Erinnerung direkt an der letzten Nutzer-Nachricht (schlägt History-Trägheit).
messages.append({"role": "user", "content": with_lang_reminder(text.strip(), language)})
return messages
async def complete(
self,
text: str,
history: list[dict] | None = None,
session_id: str | None = None,
language: str | None = None,
) -> str:
payload = {
"model": self.model,
"messages": self._build_messages(text, history, language=language),
}
timeout = httpx.Timeout(connect=10.0, read=120.0, write=30.0, pool=10.0)
async with httpx.AsyncClient(timeout=timeout) as client:
try:
response = await client.post(
"https://openrouter.ai/api/v1/chat/completions",
headers={
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
},
json=payload,
)
response.raise_for_status()
except httpx.HTTPStatusError as exc:
raise RuntimeError(
f"OpenRouter LLM error {exc.response.status_code}: {exc.response.text}"
) from exc
except httpx.TimeoutException as exc:
raise RuntimeError("OpenRouter LLM timeout") from exc
except httpx.HTTPError as exc:
raise RuntimeError(f"OpenRouter LLM transport error: {exc}") from exc
data = response.json()
try:
content = data["choices"][0]["message"]["content"]
except (KeyError, IndexError, TypeError) as exc:
raise RuntimeError(f"Unexpected OpenRouter LLM response: {data}") from exc
if not content or not str(content).strip():
raise RuntimeError("OpenRouter LLM returned empty content")
return str(content).strip()
async def stream(
self,
text: str,
history: list[dict] | None = None,
session_id: str | None = None,
language: str | None = None,
) -> AsyncIterator[str]:
payload = {
"model": self.model,
"messages": self._build_messages(text, history, language=language),
"stream": True,
}
timeout = httpx.Timeout(connect=10.0, read=120.0, write=30.0, pool=10.0)
async with httpx.AsyncClient(timeout=timeout) as client:
try:
async with client.stream(
"POST",
"https://openrouter.ai/api/v1/chat/completions",
headers={
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
},
json=payload,
) as response:
if response.status_code >= 400:
body = await response.aread()
raise RuntimeError(
f"OpenRouter LLM error {response.status_code}: "
f"{body.decode(errors='replace')}"
)
async for line in response.aiter_lines():
delta = sse_delta(line)
if delta:
yield delta
except httpx.TimeoutException as exc:
raise RuntimeError("OpenRouter LLM timeout") from exc
except httpx.HTTPError as exc:
raise RuntimeError(f"OpenRouter LLM transport error: {exc}") from exc

View file

17
app/providers/stt/base.py Normal file
View file

@ -0,0 +1,17 @@
from abc import ABC, abstractmethod
class STTProvider(ABC):
@abstractmethod
async def transcribe(self, audio_bytes: bytes, fmt: str, language: str | None = None) -> str: ...
async def transcribe_detect(
self, audio_bytes: bytes, fmt: str, language: str | None = None
) -> tuple[str, str | None]:
"""Transkribiert und liefert (text, detected_language).
Standardimplementierung delegiert an transcribe(); detected_language=None.
Provider mit Spracherkennung überschreiben diese Methode.
"""
text = await self.transcribe(audio_bytes, fmt, language=language)
return text, None

View file

@ -0,0 +1,61 @@
"""Lokaler STT-Provider auf Basis von faster-whisper (CTranslate2).
Optionale Dependency: `pip install -e .[local]`. Das Whisper-Modell wird beim
ersten Aufruf geladen (und ggf. heruntergeladen) und prozessweit zwischengespeichert.
Die Transkription ist CPU/GPU-lastig und laeuft daher in einem Thread, damit der
Event-Loop frei bleibt.
"""
import asyncio
import io
from functools import lru_cache
from app.config import settings
from app.providers.stt.base import STTProvider
@lru_cache(maxsize=2)
def _load_model(model_size: str, device: str, compute_type: str):
try:
from faster_whisper import WhisperModel
except ModuleNotFoundError as exc: # pragma: no cover - haengt von Installation ab
raise RuntimeError(
"faster-whisper ist nicht installiert. Installieren mit: pip install -e .[local]"
) from exc
try:
return WhisperModel(model_size, device=device, compute_type=compute_type)
except Exception:
# GPU/Compute-Type nicht verfuegbar -> robuster CPU-Fallback (int8).
if device != "cpu":
return WhisperModel(model_size, device="cpu", compute_type="int8")
raise
class FasterWhisperProvider(STTProvider):
def __init__(self, model_size: str | None = None, device: str | None = None,
compute_type: str | None = None):
self.model_size = model_size or settings.faster_whisper_model
self.device = device or settings.faster_whisper_device
self.compute_type = compute_type or settings.faster_whisper_compute_type
def _transcribe_sync(
self, audio_bytes: bytes, language: str | None
) -> tuple[str, str | None]:
model = _load_model(self.model_size, self.device, self.compute_type)
segments, info = model.transcribe(io.BytesIO(audio_bytes), language=language)
text = "".join(segment.text for segment in segments).strip()
detected = getattr(info, "language", None)
return text, detected
async def transcribe(self, audio_bytes: bytes, fmt: str, language: str | None = None) -> str:
if not audio_bytes:
raise ValueError("STT input audio is empty")
text, _ = await asyncio.to_thread(self._transcribe_sync, audio_bytes, language)
return text
async def transcribe_detect(
self, audio_bytes: bytes, fmt: str, language: str | None = None
) -> tuple[str, str | None]:
if not audio_bytes:
raise ValueError("STT input audio is empty")
return await asyncio.to_thread(self._transcribe_sync, audio_bytes, language)

View file

@ -0,0 +1,99 @@
import base64
import httpx
from app.providers.stt.base import STTProvider
class OpenRouterSTTProvider(STTProvider):
def __init__(self, api_key: str, model: str):
self.api_key = (api_key or "").strip()
self.model = (model or "").strip()
async def transcribe(self, audio_bytes: bytes, fmt: str, language: str | None = None) -> str:
if not self.api_key:
raise ValueError("OPENROUTER_API_KEY is empty")
if not self.model:
raise ValueError("OPENROUTER_STT_MODEL is empty")
if not audio_bytes:
raise ValueError("STT input audio is empty")
# OpenRouter /audio/transcriptions erwartet JSON mit base64-Audio
# (NICHT multipart/form-data). Quelle: OpenRouter-Doku (STT).
payload = {
"model": self.model,
"input_audio": {
"data": base64.b64encode(audio_bytes).decode("utf-8"),
"format": fmt,
},
}
if language:
payload["language"] = language
timeout = httpx.Timeout(connect=10.0, read=120.0, write=30.0, pool=10.0)
async with httpx.AsyncClient(timeout=timeout) as client:
try:
response = await client.post(
"https://openrouter.ai/api/v1/audio/transcriptions",
headers={
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
},
json=payload,
)
response.raise_for_status()
except httpx.HTTPStatusError as exc:
raise RuntimeError(
f"OpenRouter STT error {exc.response.status_code}: {exc.response.text}"
) from exc
except httpx.TimeoutException as exc:
raise RuntimeError("OpenRouter STT timeout") from exc
except httpx.HTTPError as exc:
raise RuntimeError(f"OpenRouter STT transport error: {exc}") from exc
data = response.json()
return data.get("text", "")
async def transcribe_detect(
self, audio_bytes: bytes, fmt: str, language: str | None = None
) -> tuple[str, str | None]:
if not self.api_key:
raise ValueError("OPENROUTER_API_KEY is empty")
if not self.model:
raise ValueError("OPENROUTER_STT_MODEL is empty")
if not audio_bytes:
raise ValueError("STT input audio is empty")
payload = {
"model": self.model,
"input_audio": {
"data": base64.b64encode(audio_bytes).decode("utf-8"),
"format": fmt,
},
}
if language:
payload["language"] = language
timeout = httpx.Timeout(connect=10.0, read=120.0, write=30.0, pool=10.0)
async with httpx.AsyncClient(timeout=timeout) as client:
try:
response = await client.post(
"https://openrouter.ai/api/v1/audio/transcriptions",
headers={
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
},
json=payload,
)
response.raise_for_status()
except httpx.HTTPStatusError as exc:
raise RuntimeError(
f"OpenRouter STT error {exc.response.status_code}: {exc.response.text}"
) from exc
except httpx.TimeoutException as exc:
raise RuntimeError("OpenRouter STT timeout") from exc
except httpx.HTTPError as exc:
raise RuntimeError(f"OpenRouter STT transport error: {exc}") from exc
data = response.json()
return data.get("text", ""), data.get("language")

View file

11
app/providers/tts/base.py Normal file
View file

@ -0,0 +1,11 @@
from abc import ABC, abstractmethod
class TTSProvider(ABC):
@abstractmethod
async def synthesize(
self,
text: str,
voice: str | None = None,
audio_format: str = "pcm",
language: str | None = None,
) -> bytes: ...

View file

@ -0,0 +1,134 @@
"""TTS über den lokalen Chatterbox-HTTP-Service (Resemble AI, hohe Qualitaet + Voice-Cloning).
Architektur wie beim llama.cpp-LLM: ein separater Dienst (eigene Conda-Env, GPU) haelt
das Modell geladen; dieser Provider ruft ihn per HTTP auf. Der Dienst ist job-basiert:
POST /speak -> /status pollen -> GET /audio/{id} (WAV).
Wichtig: `no_playback=true` -> der Dienst spielt NICHT lokal ab, sondern liefert nur die
WAV (fuer Remote-/Gateway-Nutzung). Chatterbox ist neural und ~Echtzeit langsam -> als
QUALITAETS-Provider gedacht (piper bleibt der schnelle Default).
"""
from __future__ import annotations
import asyncio
import io
import time
import wave
from pathlib import Path
import httpx
from app.providers.tts.base import TTSProvider
from app.providers.tts.piper import _resample, _wrap_wav
class ChatterboxTTSProvider(TTSProvider):
def __init__(
self,
base_url: str = "http://127.0.0.1:9999",
voice: str = "",
lang: str = "de",
speed: float = 1.0,
target_rate: int = 24000,
timeout: int = 180,
voices_dir: str = "",
):
self.base_url = base_url.rstrip("/")
self.voice = (voice or "").strip() # Pfad zu Referenz-WAV (Voice-Cloning) oder leer
self.lang = lang
self.speed = speed
self.target_rate = int(target_rate)
self.timeout = timeout
# Absolut auflösen: der Chatterbox-Dienst liest den Pfad mit eigenem CWD.
self.voices_dir = Path(voices_dir).expanduser().resolve() if voices_dir else None
def _lang_ref(self, lang: str) -> str | None:
"""Native Referenz-WAV der Sprache (Konvention <voices_dir>/<lang>.wav), falls vorhanden."""
if not self.voices_dir:
return None
path = self.voices_dir / f"{lang}.wav"
return str(path) if path.exists() else None
def _ref_voice(self, voice: str | None, lang: str) -> str | None:
# 1. Explizit angefragte Stimme nur, wenn sie wie ein WAV-Pfad aussieht
# (Cloud-Stimmennamen wie "Zephyr"/"alloy" ignorieren).
cand = (voice or "").strip()
if cand.endswith(".wav"):
return cand
# 2. Native Stimme der jeweiligen Sprache, sonst die konfigurierte Default-Stimme.
return self._lang_ref(lang) or self.voice or None
# Chatterbox ist mehrsprachig und klont die Stimme cross-lingual: die Referenz-WAV
# liefert das Timbre, `lang` die Aussprache. So spricht die Klon-Stimme jede Sprache.
_LANG_ALIASES = {"cmn": "zh"}
async def synthesize(
self,
text: str,
voice: str | None = None,
audio_format: str = "pcm",
language: str | None = None,
) -> bytes:
if not text or not text.strip():
raise ValueError("TTS input text is empty")
# Gesprächssprache bevorzugen; ohne Angabe der konfigurierte Default.
lang = (language or self.lang or "de").strip()
lang = self._LANG_ALIASES.get(lang, lang)
payload = {
"text": text.strip(),
"lang": lang,
"speed": self.speed,
"keep_audio": True,
"no_playback": True,
}
ref = self._ref_voice(voice, lang)
if ref:
payload["voice"] = ref
async with httpx.AsyncClient(timeout=self.timeout) as client:
resp = await client.post(f"{self.base_url}/speak", json=payload)
resp.raise_for_status()
job_id = resp.json()["job_id"]
wav_bytes = await self._await_audio(client, job_id)
pcm, rate = self._wav_to_pcm(wav_bytes)
if rate != self.target_rate:
pcm = await asyncio.to_thread(_resample, pcm, rate, self.target_rate)
if audio_format == "wav":
return _wrap_wav(pcm, self.target_rate)
return pcm
async def _await_audio(self, client: httpx.AsyncClient, job_id: str) -> bytes:
"""Wartet (via /status) bis der Job fertig ist und laedt dann die WAV."""
deadline = time.monotonic() + self.timeout
while True:
status = await client.get(f"{self.base_url}/status")
status.raise_for_status()
match = next(
(j for j in status.json().get("recent_jobs", []) if j["id"] == job_id),
None,
)
if match:
if match["status"] == "done":
break
raise RuntimeError(
f"Chatterbox-Job {match['status']}: {match.get('error') or ''}"
)
if time.monotonic() > deadline:
raise RuntimeError("Chatterbox-Timeout (Job nicht rechtzeitig fertig)")
await asyncio.sleep(0.3)
audio = await client.get(f"{self.base_url}/audio/{job_id}")
if audio.status_code != 200 or not audio.content:
raise RuntimeError(f"Chatterbox-Audio {audio.status_code}: {audio.text[:200]}")
return audio.content
@staticmethod
def _wav_to_pcm(wav_bytes: bytes) -> tuple[bytes, int]:
with wave.open(io.BytesIO(wav_bytes)) as w:
rate = w.getframerate()
frames = w.readframes(w.getnframes())
return frames, rate

View file

@ -0,0 +1,80 @@
import asyncio
import httpx
from app.providers.tts.base import TTSProvider
# Preview-TTS-Modelle liefern gelegentlich HTTP 200 mit LEEREM Body oder ein
# transientes 5xx. Solche Aussetzer kurz wiederholen, statt die Runde abzubrechen.
_MAX_ATTEMPTS = 3
_RETRY_BACKOFF = 0.6 # Sekunden, linear ansteigend
class OpenRouterTTSProvider(TTSProvider):
def __init__(self, api_key: str, model: str, voice: str):
self.api_key = (api_key or "").strip()
self.model = (model or "").strip()
self.voice = (voice or "").strip()
async def synthesize(
self,
text: str,
voice: str | None = None,
audio_format: str = "pcm",
language: str | None = None, # Cloud-TTS steuert Sprache über Stimme/Modell
) -> bytes:
if not self.api_key:
raise ValueError("OPENROUTER_API_KEY is empty")
if not self.model:
raise ValueError("OPENROUTER_TTS_MODEL is empty")
if not text or not text.strip():
raise ValueError("TTS input text is empty")
effective_voice = (voice or self.voice).strip()
if not effective_voice:
raise ValueError("TTS voice is required for OpenRouter TTS")
payload = {
"model": self.model,
"input": text.strip(),
"voice": effective_voice,
"response_format": audio_format,
}
timeout = httpx.Timeout(connect=10.0, read=120.0, write=30.0, pool=10.0)
async with httpx.AsyncClient(timeout=timeout) as client:
last_error = "OpenRouter TTS returned empty audio content"
for attempt in range(_MAX_ATTEMPTS):
try:
response = await client.post(
"https://openrouter.ai/api/v1/audio/speech",
headers={
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
},
json=payload,
)
response.raise_for_status()
except httpx.HTTPStatusError as exc:
status = exc.response.status_code
# 4xx (z. B. ungueltige Stimme) ist nicht transient -> sofort melden.
if status < 500:
raise RuntimeError(
f"OpenRouter TTS error {status}: {exc.response.text}"
) from exc
last_error = f"OpenRouter TTS error {status}: {exc.response.text}"
except httpx.TimeoutException:
last_error = "OpenRouter TTS timeout"
except httpx.HTTPError as exc:
raise RuntimeError(f"OpenRouter TTS transport error: {exc}") from exc
else:
if response.content:
return response.content
# HTTP 200 mit leerem Body -> transienter Aussetzer, erneut versuchen.
if attempt < _MAX_ATTEMPTS - 1:
await asyncio.sleep(_RETRY_BACKOFF * (attempt + 1))
raise RuntimeError(last_error)

154
app/providers/tts/piper.py Normal file
View file

@ -0,0 +1,154 @@
"""Lokales TTS über piper (CPU-freundlich, offline).
Bevorzugt die **in-process** piper-Python-API: Das Stimmmodell wird EINMAL geladen
und prozessweit zwischengespeichert (lru_cache) - so entfaellt der teure Modell-Start
pro Satz (~2 s), der bei einem Subprozess-pro-Aufruf anfiel. Fehlt das Paket
(`pip install -e .[local]`), wird automatisch auf das piper-Binary zurueckgefallen.
piper liefert s16le-Mono-PCM in der Sample-Rate des Modells; das Gateway erwartet
24000 Hz -> bei Abweichung wird resampelt (in-process via audioop, sonst ffmpeg).
"""
from __future__ import annotations
import asyncio
import io
import json
import shutil
import wave
from functools import lru_cache
from pathlib import Path
from app.providers.tts.base import TTSProvider
try: # bevorzugter Pfad: in-process, Modell bleibt geladen
from piper import PiperVoice # type: ignore
_PIPER_LIB = True
except Exception: # pragma: no cover - Paket optional
PiperVoice = None # type: ignore
_PIPER_LIB = False
def _wrap_wav(pcm: bytes, sample_rate: int) -> bytes:
buf = io.BytesIO()
with wave.open(buf, "wb") as w:
w.setnchannels(1)
w.setsampwidth(2)
w.setframerate(sample_rate)
w.writeframes(pcm)
return buf.getvalue()
@lru_cache(maxsize=4)
def _load_voice(model_path: str):
"""Laedt ein piper-Stimmmodell einmalig (prozessweit gecacht)."""
return PiperVoice.load(model_path)
def _resample(pcm: bytes, src_rate: int, dst_rate: int) -> bytes:
"""Resampelt s16le-Mono in-process. audioop (stdlib) bevorzugt, sonst numpy."""
try:
import audioop # in Python 3.13 entfernt -> Fallback unten
converted, _ = audioop.ratecv(pcm, 2, 1, src_rate, dst_rate, None)
return converted
except Exception:
import numpy as np
src = np.frombuffer(pcm, dtype="<i2").astype(np.float32)
n_out = max(1, round(len(src) * dst_rate / src_rate))
x_old = np.arange(len(src))
x_new = np.linspace(0, len(src) - 1, n_out)
out = np.interp(x_new, x_old, src)
return np.clip(out, -32768, 32767).astype("<i2").tobytes()
class PiperTTSProvider(TTSProvider):
def __init__(
self,
bin_path: str = "piper",
voices_dir: str = "",
voice: str = "",
target_rate: int = 24000,
):
self.bin_path = (bin_path or "piper").strip()
self.voices_dir = Path(voices_dir).expanduser() if voices_dir else Path.cwd()
self.voice = (voice or "").strip()
self.target_rate = int(target_rate)
def _model_paths(self, voice: str | None) -> tuple[Path, Path]:
# Angeforderte Stimme zuerst; passt sie nicht (z. B. eine Cloud-Stimme wie
# "Zephyr"/"alloy" aus der Route), auf die konfigurierte Default-Stimme zurueckfallen.
if not (voice or self.voice or "").strip():
raise ValueError("Piper-Stimme ist nicht gesetzt (PIPER_VOICE)")
tried: list[str] = []
for candidate in (voice, self.voice):
name = (candidate or "").strip()
if not name or name in tried:
continue
tried.append(name)
model = Path(name).expanduser()
if not model.suffix: # nur ein Name -> im Voices-Verzeichnis suchen
model = self.voices_dir / f"{name}.onnx"
if model.exists():
return model, Path(f"{model}.json")
raise RuntimeError(
f"Piper-Stimmmodell nicht gefunden (versucht: {', '.join(tried)}) in {self.voices_dir}"
)
def _native_rate(self, config: Path) -> int:
try:
with open(config) as fh:
return int(json.load(fh)["audio"]["sample_rate"])
except (OSError, KeyError, ValueError, TypeError):
return self.target_rate # Konfig unlesbar -> Resampling überspringen
async def synthesize(
self,
text: str,
voice: str | None = None,
audio_format: str = "pcm",
language: str | None = None, # ignoriert: die Piper-Stimme kodiert die Sprache
) -> bytes:
if not text or not text.strip():
raise ValueError("TTS input text is empty")
model, config = self._model_paths(voice)
pcm, native_rate = await asyncio.to_thread(
self._synthesize_sync, str(model), str(config), text.strip()
)
if native_rate != self.target_rate:
pcm = await asyncio.to_thread(_resample, pcm, native_rate, self.target_rate)
if audio_format == "wav":
return _wrap_wav(pcm, self.target_rate)
return pcm
def _synthesize_sync(self, model: str, config: str, text: str) -> tuple[bytes, int]:
if _PIPER_LIB:
voice = _load_voice(model)
pcm = bytearray()
rate = self.target_rate
for chunk in voice.synthesize(text):
pcm += chunk.audio_int16_bytes
rate = chunk.sample_rate
if not pcm:
raise RuntimeError("Piper lieferte kein Audio")
return bytes(pcm), rate
# Fallback: piper-Binary (Subprozess pro Aufruf, langsamer).
return self._run_piper_binary(model, config, text)
def _run_piper_binary(self, model: str, config: str, text: str) -> tuple[bytes, int]:
import subprocess
if not (shutil.which(self.bin_path) or Path(self.bin_path).exists()):
raise RuntimeError(f"Piper-Binary nicht gefunden: {self.bin_path}")
cmd = [self.bin_path, "-m", model, "-c", config, "--output-raw"]
proc = subprocess.run(cmd, input=text.encode("utf-8"), capture_output=True)
if proc.returncode != 0:
detail = proc.stderr.decode("utf-8", "replace").strip()[:300]
raise RuntimeError(f"Piper-Fehler ({proc.returncode}): {detail}")
if not proc.stdout:
raise RuntimeError("Piper lieferte kein Audio")
return proc.stdout, self._native_rate(Path(config))

42
app/quota.py Normal file
View file

@ -0,0 +1,42 @@
"""Pro-Nutzer-Tageskontingent (Kostenkontrolle).
Limit aus Settings (`daily_request_limit`), pro Nutzer ueber `prefs.daily_request_limit`
ueberschreibbar. 0 bedeutet unbegrenzt.
"""
from app.metrics import metrics
class QuotaExceededError(Exception):
def __init__(self, limit: int, count: int):
self.limit = limit
self.count = count
super().__init__(f"Daily request limit reached ({count}/{limit})")
def effective_limit(user, cfg=None) -> int:
if cfg is None:
from app.runtime_config import runtime_settings
cfg = runtime_settings
pref = user.prefs.get("daily_request_limit") if user and user.prefs else None
if pref is not None:
try:
return int(pref)
except (TypeError, ValueError):
pass
return cfg.daily_request_limit
def enforce_quota(user, store, cfg=None) -> None:
"""Wirft QuotaExceededError, wenn das Tageslimit erreicht ist."""
limit = effective_limit(user, cfg)
if limit and limit > 0:
count = store.get_request_count(user.id)
if count >= limit:
metrics.inc("quota_exceeded_total")
raise QuotaExceededError(limit, count)
def record_usage(user, store, units: int = 0) -> None:
store.add_usage(user.id, units)
metrics.inc("turns_total")

99
app/runtime_config.py Normal file
View file

@ -0,0 +1,99 @@
"""Laufzeit-Konfigurationsüberschreibungen aus der Datenbank.
Einzelne Settings-Felder können zur Laufzeit via Admin-UI geändert werden,
ohne den Server neu zu starten. Die Werte liegen in der Tabelle
`config_overrides` und werden mit 30s TTL gecacht.
Nur Felder aus RUNTIME_SETTABLE sind überschreibbar alle anderen
kommen weiterhin aus .env / TOML / pydantic-settings.
"""
import threading
import time
from typing import Any
from app.config import Settings, settings as _base
# (label, type_str, hint)
RUNTIME_SETTABLE: dict[str, tuple[str, str, str]] = {
"default_stt_provider": ("STT-Provider (Standard)", "str", "openrouter | faster-whisper"),
"default_llm_provider": ("LLM-Provider (Standard)", "str", "openrouter | local-openai-compatible"),
"default_tts_provider": ("TTS-Provider (Standard)", "str", "openrouter | piper | chatterbox — Standard, pro Nutzer überschreibbar"),
"default_language": ("Sprache (Standard)", "str", "de | en | … — Standard, pro Nutzer überschreibbar"),
"default_language_mode": ("Sprachmodus (Standard)", "str", "fix | flex — Standard, pro Nutzer überschreibbar"),
"openrouter_llm_model": ("LLM-Modell (OpenRouter)", "str", "z.B. google/gemini-3.1-flash-lite"),
"openrouter_tts_model": ("TTS-Modell (OpenRouter)", "str", "z.B. google/gemini-3.1-flash-tts-preview"),
"openrouter_tts_voice": ("TTS-Stimme (OpenRouter)", "str", "z.B. Zephyr, Puck, Kore"),
"piper_voice": ("TTS-Stimme (piper)", "str", "z.B. de_DE-thorsten-high"),
"local_llm_system_prompt": ("Systemprompt (lokal)", "str", "Freier Text"),
"local_llm_temperature": ("Temperatur (lokal)", "float", "0.02.0 — wirkt sofort (kein Neustart)"),
"local_llm_top_p": ("Top-p (lokal)", "float", "0.01.0 — wirkt sofort (kein Neustart)"),
"local_llm_max_tokens": ("Max. Tokens (lokal)", "int", "0 = kein Limit"),
"tts_normalize_level": ("TTS-Normalisierung", "str", "auto | full | light | off"),
"audio_stream_default": ("Audio-Streaming Standard", "bool", "true | false"),
"memory_extraction_enabled": ("Erinnerungs-Extraktion", "bool", "true | false"),
"memory_extraction_every_n_turns": ("Extraktion alle N Turns", "int", "z.B. 3"),
"daily_request_limit": ("Tageskontingent (global)", "int", "0 = unbegrenzt"),
}
_TTL = 30.0
_cache: dict[str, str] = {}
_cache_time: float = 0.0
_lock = threading.Lock()
def _coerce(key: str, raw: str) -> Any:
_, type_str, _ = RUNTIME_SETTABLE[key]
try:
if type_str == "bool":
return raw.strip().lower() in ("1", "true", "yes")
if type_str == "int":
return int(raw)
if type_str == "float":
return float(raw)
except (ValueError, AttributeError):
pass
return raw
def _get_cache() -> dict[str, str]:
global _cache, _cache_time
now = time.monotonic()
with _lock:
if now - _cache_time < _TTL:
return _cache
try:
from app.dependencies import get_store
_cache = get_store().get_config_overrides()
_cache_time = now
except Exception:
pass
return _cache
def invalidate_cache() -> None:
global _cache_time
with _lock:
_cache_time = 0.0
class RuntimeSettings:
"""Wraps Settings; liest überschreibbare Felder aus der DB (30s TTL)."""
def __init__(self, base: Settings):
object.__setattr__(self, "_base", base)
def __getattr__(self, name: str) -> Any:
if name in RUNTIME_SETTABLE:
overrides = _get_cache()
if name in overrides:
return _coerce(name, overrides[name])
return getattr(object.__getattribute__(self, "_base"), name)
# Delegiere Pydantic-Metadaten ans Basis-Objekt.
@property
def model_fields(self):
return self._base.model_fields
runtime_settings = RuntimeSettings(_base)

0
app/safety/__init__.py Normal file
View file

140
app/safety/emergency.py Normal file
View file

@ -0,0 +1,140 @@
"""Notfall-Erkennung und Eskalation (Senioren-Kontext).
Zweistufig:
1. Schnelle Stichwort-Heuristik (`detect` / `handle_emergency`) im Hot-Path -> 0 Latenz.
2. LLM-Klassifikation (`schedule_llm_emergency_check`) als Hintergrund-Task, der NUR
laeuft, wenn die Heuristik nichts fand -> faengt verpasste Formulierungen, ohne die
Antwortlatenz zu erhoehen.
Die erkannten Textauszuege sind hochsensibel und werden bewusst protokolliert
(DSGVO beachten: Einwilligung, Aufbewahrung, Zugriff).
"""
import asyncio
import logging
import httpx
from app.config import settings, Settings
from app.metrics import metrics
logger = logging.getLogger(__name__)
# Referenzen auf laufende Hintergrund-Tasks halten (sonst GC-gefaehrdet).
_pending: set[asyncio.Task] = set()
# Phrasen je Kategorie (de/en), bewusst eher spezifisch gegen Fehlalarme.
_PATTERNS: dict[str, list[str]] = {
"medical": [
"brustschmerz", "schmerzen in der brust", "kann nicht atmen", "keine luft",
"atemnot", "herzinfarkt", "schlaganfall", "bewusstlos", "gestuerzt", "gestürzt",
"gefallen und komme nicht hoch", "starke blutung",
"chest pain", "can't breathe", "cannot breathe", "heart attack", "stroke",
"i fell and can't", "bleeding badly",
],
"self_harm": [
"nicht mehr leben", "mich umbringen", "selbstmord", "suizid", "will sterben",
"kill myself", "end my life", "suicide", "want to die",
],
"help": [
"notruf", "notarzt", "krankenwagen", "ruf einen arzt", "es brennt",
"call an ambulance", "call 911", "call 112",
],
}
def detect(text: str):
"""Liefert (category, matched_phrase) oder None."""
if not text:
return None
low = text.lower()
for category, phrases in _PATTERNS.items():
for phrase in phrases:
if phrase in low:
return category, phrase
return None
async def _fire_webhook(url: str, user, category: str, snippet: str) -> None:
try:
async with httpx.AsyncClient(timeout=5) as client:
await client.post(
url,
json={
"user_id": user.id,
"display_name": user.display_name,
"category": category,
"text": snippet,
},
)
except Exception: # noqa: BLE001 - best effort, darf den Chat nicht brechen
metrics.inc("emergency_webhook_error_total")
def _escalate(user, category: str, snippet: str, store, cfg: Settings, source: str) -> None:
"""Protokolliert + eskaliert einen erkannten Notfall (Log, Metrik, Webhook)."""
store.log_emergency(user.id, category, snippet)
metrics.inc("emergency_total", {"category": category, "source": source})
if cfg.emergency_webhook_url:
try:
asyncio.get_running_loop().create_task(
_fire_webhook(cfg.emergency_webhook_url, user, category, snippet)
)
except RuntimeError:
pass # kein laufender Event-Loop (z. B. im Test) -> Webhook ueberspringen
def handle_emergency(user, text: str, store, cfg: Settings = settings):
"""Stufe 1: Stichwort-Heuristik. Erkennt, protokolliert und eskaliert sofort.
Gibt {"category", "matched"} zurueck, wenn etwas erkannt wurde, sonst None.
Der Webhook (falls konfiguriert) wird nicht-blockierend ausgeloest.
"""
match = detect(text)
if not match:
return None
category, phrase = match
_escalate(user, category, text[:500], store, cfg, source="keyword")
return {"category": category, "matched": phrase}
async def _llm_check(user, text: str, store, cfg: Settings, on_emergency) -> None:
"""Hintergrund: LLM-Klassifikation + Eskalation (best-effort)."""
try:
from app.safety.llm_classifier import classify_emergency
result = await classify_emergency(text, cfg)
if not result:
return
category = result["category"]
_escalate(user, category, text[:500], store, cfg, source="llm")
logger.info(
"llm-emergency: %s (conf=%.2f) fuer %s", category, result["confidence"], user.id
)
if on_emergency is not None:
await on_emergency(category)
except Exception: # best-effort: darf den Turn nie brechen
logger.exception("llm-emergency-check fehlgeschlagen (ignoriert)")
def schedule_llm_emergency_check(user, text: str, store, keyword_hit,
cfg: Settings = settings, on_emergency=None):
"""Stufe 2: plant die LLM-Klassifikation als Hintergrund-Task.
Laeuft NUR, wenn die Stichwort-Heuristik nichts fand (`keyword_hit` ist None) und
die LLM-Stufe aktiviert ist. `on_emergency(category)` (async) wird bei Treffer
aufgerufen (z. B. WS-Event). Gibt den Task zurueck oder None - blockiert nie.
"""
if keyword_hit is not None or not cfg.emergency_llm_enabled:
return None
if not text or not text.strip():
return None
try:
task = asyncio.get_running_loop().create_task(
_llm_check(user, text, store, cfg, on_emergency)
)
except RuntimeError:
return None # kein laufender Event-Loop (z. B. Test) -> ueberspringen
_pending.add(task)
task.add_done_callback(_pending.discard)
return task

View file

@ -0,0 +1,96 @@
"""LLM-Notfall-Klassifikation (zweite Stufe der Notfall-Erkennung).
Ergaenzt die schnelle Stichwort-Heuristik (`app.safety.emergency.detect`) um einen
LLM-Klassifikator, der Formulierungen erkennt, die keine Stichwoerter treffen.
Bewusst **best-effort** und mit Konfidenz-Schwelle (sensibler Senioren-Kontext):
Ein LLM-Fehler oder kaputtes JSON fuehrt nie zu einem Alarm und nie zu einem Fehler
im Antwort-Turn.
"""
import json
import logging
import re
from app.config import Settings, settings
logger = logging.getLogger(__name__)
# Gueltige Notfall-Kategorien (deckungsgleich mit der Stichwort-Heuristik).
VALID_CATEGORIES = {"medical", "self_harm", "help"}
_SYSTEM_PROMPT = (
"Du bist ein Sicherheits-Klassifikator fuer einen Senioren-Sprachassistenten. "
"Beurteile, ob die Nutzeraeusserung einen akuten Notfall beschreibt. Kategorien: "
"'medical' (akute medizinische Notlage, z. B. Brustschmerz, Atemnot, Sturz, "
"Schlaganfall), 'self_harm' (Suizidalitaet/Selbstgefaehrdung), 'help' (akuter "
"Hilferuf, z. B. Feuer, Notruf), 'none' (kein Notfall). Antworte AUSSCHLIESSLICH "
"mit JSON: {\"category\": \"medical|self_harm|help|none\", \"confidence\": 0.0-1.0, "
"\"reason\": \"kurze Begruendung\"}. Sei zurueckhaltend: nur echte, akute Notlagen "
"sind ein Notfall, keine beilaeufigen Erwaehnungen oder Vergangenes."
)
def _build_classifier_llm(cfg: Settings):
"""Baut eine eigene LLM-Instanz fuer die Klassifikation (eigener JSON-Prompt)."""
provider = cfg.emergency_llm_provider or cfg.default_llm_provider
if provider == "local-openai-compatible":
from app.providers.llm.local_openai_compatible import LocalOpenAICompatibleLLM
return LocalOpenAICompatibleLLM(
cfg.local_llm_base_url,
cfg.local_llm_api_key,
cfg.local_llm_model,
system_prompt=_SYSTEM_PROMPT,
disable_reasoning=True,
max_tokens=128,
temperature=0.0,
)
from app.dependencies import get_llm_provider
return get_llm_provider(provider, cfg)
def parse_classification(raw: str) -> dict | None:
"""Liest {category, confidence, reason} aus der (evtl. verrauschten) LLM-Antwort."""
if not raw:
return None
match = re.search(r"\{.*\}", raw, re.DOTALL)
if not match:
return None
try:
data = json.loads(match.group(0))
except ValueError:
return None
if not isinstance(data, dict):
return None
category = data.get("category")
if category not in VALID_CATEGORIES:
return None
try:
confidence = float(data.get("confidence", 0.0))
except (TypeError, ValueError):
confidence = 0.0
return {
"category": category,
"confidence": confidence,
"reason": str(data.get("reason", "")),
}
async def classify_emergency(text: str, cfg: Settings = settings) -> dict | None:
"""Klassifiziert eine Aeusserung. Liefert {category, confidence, reason} oder None.
None bedeutet: kein Notfall (bzw. unter der Konfidenz-Schwelle / nicht parsebar).
"""
if not text or not text.strip():
return None
llm = _build_classifier_llm(cfg)
raw = await llm.complete(text)
result = parse_classification(raw)
if result is None:
return None
if result["confidence"] < cfg.emergency_llm_min_confidence:
return None
return result

107
app/schemas.py Normal file
View file

@ -0,0 +1,107 @@
from typing import Literal
from pydantic import BaseModel, Field
class EndpointCapabilities(BaseModel):
id: str
kind: str
direction: Literal["input", "output"]
sample_rate: int = 16000
channels: int = 1
latency_class: Literal["low", "medium", "high"] = "medium"
supports_aec: bool = False
supports_barge_in: bool = False
networked: bool = False
bluetooth: bool = False
mobile: bool = False
default: bool = False
class AudioChunk(BaseModel):
data: bytes
sample_rate: int = 16000
channels: int = 1
format: str = "wav"
timestamp_ms: int = 0
class PipelineTrace(BaseModel):
raw_transcript: str | None = None
cleaned_transcript: str | None = None
semantic_response: str | None = None
spoken_response: str | None = None
tts_ready_text: str | None = None
class SpeakRequest(BaseModel):
text: str = Field(min_length=1)
voice: str | None = None
language: str | None = None
output_endpoint: str | None = None
tts_provider: str | None = None
class ChatRequest(BaseModel):
text: str = Field(min_length=1)
input_endpoint: str | None = None
output_endpoint: str | None = None
language: str | None = None
language_mode: Literal["fix", "flex"] | None = None
voice: str | None = None
stt_provider: str | None = None
llm_provider: str | None = None
tts_provider: str | None = None
text_only: bool | None = None # True -> kein Server-Audio (Geräte-TTS spricht selbst)
class SessionRouteRequest(BaseModel):
input_endpoint: str | None = None
output_endpoint: str | None = None
stt_provider: str | None = None
llm_provider: str | None = None
tts_provider: str | None = None
language: str | None = None
class RouteInfo(BaseModel):
input_endpoint: str
output_endpoint: str
stt_provider: str
llm_provider: str
tts_provider: str
language: str
class UserCreate(BaseModel):
display_name: str = Field(min_length=1)
class UserCreated(BaseModel):
user_id: str
display_name: str
token: str # nur bei Erstellung sichtbar
class UserUpdate(BaseModel):
display_name: str = Field(min_length=1)
class UserPrefs(BaseModel):
input_endpoint: str | None = None
output_endpoint: str | None = None
stt_provider: str | None = None
llm_provider: str | None = None
tts_provider: str | None = None
language: str | None = None
language_mode: Literal["fix", "flex"] | None = None
class MemoryCreate(BaseModel):
content: str = Field(min_length=1)
class MemoryOut(BaseModel):
id: int
content: str
created_at: str

585
app/store.py Normal file
View file

@ -0,0 +1,585 @@
"""Persistenzschicht: Nutzer und Sessions.
Ein abstraktes Store-Interface mit SQLite-Default (stdlib). Spaetere Backends
(Postgres/Redis) koennen dasselbe Interface implementieren, ohne die App zu aendern.
"""
from __future__ import annotations
import json
import hashlib
import secrets
import sqlite3
import uuid
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
ANONYMOUS_USER_ID = "anonymous"
def hash_token(raw_token: str) -> str:
return hashlib.sha256(raw_token.encode("utf-8")).hexdigest()
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
@dataclass
class User:
id: str
display_name: str
prefs: dict = field(default_factory=dict)
created_at: str = ""
external_id: str | None = None # SSO-/Proxy-Identitaet (Forward-Auth)
is_admin: bool = False # transient, aus ADMIN_USERS abgeleitet
@dataclass
class Session:
id: str
user_id: str
data: dict = field(default_factory=dict)
@dataclass
class Memory:
id: int
content: str
created_at: str = ""
class SessionOwnershipError(Exception):
"""Eine Session gehoert einem anderen Nutzer (-> HTTP 403)."""
class Store(ABC):
@abstractmethod
def create_user(self, display_name: str) -> tuple[User, str]:
"""Legt einen Nutzer an und liefert (User, Klartext-Token). Token nur hier sichtbar."""
@abstractmethod
def get_user_by_token(self, raw_token: str) -> User | None: ...
@abstractmethod
def get_user(self, user_id: str) -> User | None: ...
@abstractmethod
def set_user_prefs(self, user_id: str, prefs: dict) -> User: ...
@abstractmethod
def ensure_anonymous_user(self) -> User: ...
@abstractmethod
def list_users(self) -> list[User]: ...
@abstractmethod
def get_user_by_external_id(self, external_id: str) -> User | None: ...
@abstractmethod
def get_or_create_user_by_external_id(
self, external_id: str, display_name: str | None = None
) -> User: ...
@abstractmethod
def get_session(self, session_id: str) -> Session | None: ...
@abstractmethod
def update_session(self, session_id: str, user_id: str, values: dict) -> Session:
"""Erstellt/aktualisiert eine Session des Nutzers. Fremde Session -> SessionOwnershipError."""
@abstractmethod
def append_message(self, session_id: str, user_id: str, role: str, content: str) -> None:
"""Haengt eine Nachricht an die Session an. Fremde Session -> SessionOwnershipError."""
@abstractmethod
def get_recent_messages(self, session_id: str, limit: int) -> list[dict]:
"""Liefert die letzten `limit` Nachrichten chronologisch ([{'role','content'}, ...])."""
@abstractmethod
def add_memory(self, user_id: str, content: str) -> Memory:
"""Speichert eine dauerhafte Erinnerung (Fakt/Vorliebe) zum Nutzer."""
@abstractmethod
def get_memories(self, user_id: str) -> list[Memory]:
"""Liefert alle Erinnerungen des Nutzers (chronologisch)."""
@abstractmethod
def delete_memory(self, user_id: str, memory_id: int) -> bool:
"""Loescht eine Erinnerung des Nutzers. True, wenn etwas geloescht wurde."""
@abstractmethod
def get_request_count(self, user_id: str, day: str | None = None) -> int:
"""Anzahl der Anfragen des Nutzers am angegebenen Tag (Default: heute, UTC)."""
@abstractmethod
def add_usage(self, user_id: str, units: int = 0, day: str | None = None) -> int:
"""Zaehlt eine Anfrage (+units) und liefert die neue Tages-Anfragezahl."""
@abstractmethod
def delete_user(self, user_id: str) -> bool:
"""Loescht einen Nutzer und alle seine Daten (Sessions, Nachrichten, Erinnerungen,
Nutzungsdaten). Anonymer Nutzer kann nicht geloescht werden.
Liefert True, wenn der Nutzer existierte und geloescht wurde."""
@abstractmethod
def reset_token(self, user_id: str) -> tuple[User, str] | None:
"""Generiert einen neuen Token fuer den Nutzer; der alte wird sofort ungueltig.
Liefert (User, Klartext-Token) oder None, wenn der Nutzer nicht existiert."""
@abstractmethod
def update_display_name(self, user_id: str, display_name: str) -> User | None:
"""Aktualisiert den Anzeigenamen. None wenn nicht gefunden."""
@abstractmethod
def log_emergency(self, user_id: str, category: str, snippet: str) -> None:
"""Protokolliert ein erkanntes Notfall-Signal (sensibel!)."""
@abstractmethod
def list_sessions_for_user(self, user_id: str) -> list[dict]: ...
@abstractmethod
def get_messages_for_session(self, session_id: str, limit: int = 200) -> list[dict]: ...
@abstractmethod
def list_emergency_events(self, limit: int = 50) -> list[dict]: ...
@abstractmethod
def get_usage_for_user(self, user_id: str) -> list[dict]: ...
@abstractmethod
def get_all_usage(self) -> list[dict]: ...
@abstractmethod
def get_config_overrides(self) -> dict[str, str]: ...
@abstractmethod
def set_config_override(self, key: str, value: str) -> None: ...
@abstractmethod
def delete_config_override(self, key: str) -> bool: ...
class SQLiteStore(Store):
def __init__(self, db_path: str):
self.db_path = db_path
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
self._init_schema()
def _connect(self) -> sqlite3.Connection:
conn = sqlite3.connect(self.db_path)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=ON")
return conn
def _init_schema(self) -> None:
with self._connect() as conn:
conn.executescript(
"""
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
display_name TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
prefs_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL,
external_id TEXT
);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
data_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_messages_session
ON messages(session_id, id);
CREATE TABLE IF NOT EXISTS memories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
content TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_memories_user
ON memories(user_id, id);
CREATE TABLE IF NOT EXISTS usage (
user_id TEXT NOT NULL,
day TEXT NOT NULL,
requests INTEGER NOT NULL DEFAULT 0,
units INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (user_id, day)
);
CREATE TABLE IF NOT EXISTS emergency_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
category TEXT NOT NULL,
snippet TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS config_overrides (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL
);
"""
)
# Migration fuer bestehende DBs: external_id ergaenzen (falls noch nicht da).
cols = {row["name"] for row in conn.execute("PRAGMA table_info(users)")}
if "external_id" not in cols:
conn.execute("ALTER TABLE users ADD COLUMN external_id TEXT")
# NULLs gelten in SQLite als verschieden -> Alt-Nutzer ohne external_id ok.
conn.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_users_external"
" ON users(external_id)"
)
# ----- Nutzer -----------------------------------------------------------
def _row_to_user(self, row: sqlite3.Row) -> User:
keys = row.keys()
return User(
id=row["id"],
display_name=row["display_name"],
prefs=json.loads(row["prefs_json"] or "{}"),
created_at=row["created_at"],
external_id=row["external_id"] if "external_id" in keys else None,
)
def create_user(self, display_name: str) -> tuple[User, str]:
raw_token = secrets.token_urlsafe(32)
user = User(id=uuid.uuid4().hex, display_name=display_name, prefs={}, created_at=_now())
with self._connect() as conn:
conn.execute(
"INSERT INTO users (id, display_name, token_hash, prefs_json, created_at)"
" VALUES (?, ?, ?, ?, ?)",
(user.id, user.display_name, hash_token(raw_token), "{}", user.created_at),
)
return user, raw_token
def get_user_by_token(self, raw_token: str) -> User | None:
with self._connect() as conn:
row = conn.execute(
"SELECT * FROM users WHERE token_hash = ?", (hash_token(raw_token),)
).fetchone()
return self._row_to_user(row) if row else None
def get_user(self, user_id: str) -> User | None:
with self._connect() as conn:
row = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone()
return self._row_to_user(row) if row else None
def set_user_prefs(self, user_id: str, prefs: dict) -> User:
with self._connect() as conn:
conn.execute(
"UPDATE users SET prefs_json = ? WHERE id = ?",
(json.dumps(prefs), user_id),
)
row = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone()
if row is None:
raise KeyError(f"Unbekannter Nutzer: {user_id}")
return self._row_to_user(row)
def ensure_anonymous_user(self) -> User:
existing = self.get_user(ANONYMOUS_USER_ID)
if existing:
return existing
with self._connect() as conn:
conn.execute(
"INSERT OR IGNORE INTO users (id, display_name, token_hash, prefs_json, created_at)"
" VALUES (?, ?, ?, ?, ?)",
(ANONYMOUS_USER_ID, "Anonymous", f"anon-{ANONYMOUS_USER_ID}", "{}", _now()),
)
return self.get_user(ANONYMOUS_USER_ID)
def list_users(self) -> list[User]:
with self._connect() as conn:
rows = conn.execute(
"SELECT * FROM users WHERE id != ? ORDER BY created_at",
(ANONYMOUS_USER_ID,),
).fetchall()
return [self._row_to_user(row) for row in rows]
def get_user_by_external_id(self, external_id: str) -> User | None:
with self._connect() as conn:
row = conn.execute(
"SELECT * FROM users WHERE external_id = ?", (external_id,)
).fetchone()
return self._row_to_user(row) if row else None
def get_or_create_user_by_external_id(
self, external_id: str, display_name: str | None = None
) -> User:
"""Findet den Nutzer zur SSO-/Proxy-Identitaet oder legt ihn an (Forward-Auth)."""
existing = self.get_user_by_external_id(external_id)
if existing:
return existing
user = User(
id=uuid.uuid4().hex,
display_name=display_name or external_id,
prefs={},
created_at=_now(),
external_id=external_id,
)
with self._connect() as conn:
conn.execute(
"INSERT INTO users (id, display_name, token_hash, prefs_json, created_at,"
" external_id) VALUES (?, ?, ?, ?, ?, ?)",
# token_hash ist NOT NULL UNIQUE -> synthetischer, kollisionsfreier Platzhalter
# (SSO-Nutzer authentifizieren sich nicht ueber ein Token).
(user.id, user.display_name, f"ext:{external_id}", "{}",
user.created_at, external_id),
)
return user
# ----- Sessions ---------------------------------------------------------
def get_session(self, session_id: str) -> Session | None:
with self._connect() as conn:
row = conn.execute(
"SELECT * FROM sessions WHERE id = ?", (session_id,)
).fetchone()
if row is None:
return None
return Session(id=row["id"], user_id=row["user_id"], data=json.loads(row["data_json"] or "{}"))
def update_session(self, session_id: str, user_id: str, values: dict) -> Session:
existing = self.get_session(session_id)
if existing and existing.user_id != user_id:
raise SessionOwnershipError(
f"Session {session_id!r} gehoert einem anderen Nutzer"
)
data = dict(existing.data) if existing else {}
data.update({k: v for k, v in values.items() if v is not None})
payload = json.dumps(data)
now = _now()
with self._connect() as conn:
if existing:
conn.execute(
"UPDATE sessions SET data_json = ?, updated_at = ? WHERE id = ?",
(payload, now, session_id),
)
else:
conn.execute(
"INSERT INTO sessions (id, user_id, data_json, created_at, updated_at)"
" VALUES (?, ?, ?, ?, ?)",
(session_id, user_id, payload, now, now),
)
return Session(id=session_id, user_id=user_id, data=data)
# ----- Nachrichten / Gespraechsverlauf ----------------------------------
def append_message(self, session_id: str, user_id: str, role: str, content: str) -> None:
existing = self.get_session(session_id)
if existing and existing.user_id != user_id:
raise SessionOwnershipError(
f"Session {session_id!r} gehoert einem anderen Nutzer"
)
now = _now()
with self._connect() as conn:
if not existing:
conn.execute(
"INSERT INTO sessions (id, user_id, data_json, created_at, updated_at)"
" VALUES (?, ?, ?, ?, ?)",
(session_id, user_id, "{}", now, now),
)
conn.execute(
"INSERT INTO messages (session_id, role, content, created_at)"
" VALUES (?, ?, ?, ?)",
(session_id, role, content, now),
)
def get_recent_messages(self, session_id: str, limit: int) -> list[dict]:
if limit <= 0:
return []
with self._connect() as conn:
rows = conn.execute(
"SELECT role, content FROM messages WHERE session_id = ?"
" ORDER BY id DESC LIMIT ?",
(session_id, limit),
).fetchall()
return [{"role": row["role"], "content": row["content"]} for row in reversed(rows)]
# ----- Langzeit-Erinnerungen --------------------------------------------
def add_memory(self, user_id: str, content: str) -> Memory:
now = _now()
with self._connect() as conn:
cur = conn.execute(
"INSERT INTO memories (user_id, content, created_at) VALUES (?, ?, ?)",
(user_id, content, now),
)
memory_id = cur.lastrowid
return Memory(id=memory_id, content=content, created_at=now)
def get_memories(self, user_id: str) -> list[Memory]:
with self._connect() as conn:
rows = conn.execute(
"SELECT id, content, created_at FROM memories WHERE user_id = ? ORDER BY id",
(user_id,),
).fetchall()
return [
Memory(id=row["id"], content=row["content"], created_at=row["created_at"])
for row in rows
]
def delete_memory(self, user_id: str, memory_id: int) -> bool:
with self._connect() as conn:
cur = conn.execute(
"DELETE FROM memories WHERE id = ? AND user_id = ?",
(memory_id, user_id),
)
return cur.rowcount > 0
# ----- Nutzung / Quota --------------------------------------------------
@staticmethod
def _today() -> str:
return datetime.now(timezone.utc).date().isoformat()
def get_request_count(self, user_id: str, day: str | None = None) -> int:
day = day or self._today()
with self._connect() as conn:
row = conn.execute(
"SELECT requests FROM usage WHERE user_id = ? AND day = ?",
(user_id, day),
).fetchone()
return int(row["requests"]) if row else 0
def add_usage(self, user_id: str, units: int = 0, day: str | None = None) -> int:
day = day or self._today()
with self._connect() as conn:
conn.execute(
"INSERT INTO usage (user_id, day, requests, units) VALUES (?, ?, 1, ?)"
" ON CONFLICT(user_id, day) DO UPDATE SET"
" requests = requests + 1, units = units + excluded.units",
(user_id, day, units),
)
row = conn.execute(
"SELECT requests FROM usage WHERE user_id = ? AND day = ?",
(user_id, day),
).fetchone()
return int(row["requests"])
def delete_user(self, user_id: str) -> bool:
if user_id == ANONYMOUS_USER_ID:
raise ValueError("Der anonyme Nutzer kann nicht geloescht werden.")
with self._connect() as conn:
if not conn.execute("SELECT 1 FROM users WHERE id = ?", (user_id,)).fetchone():
return False
conn.execute(
"DELETE FROM messages WHERE session_id IN"
" (SELECT id FROM sessions WHERE user_id = ?)",
(user_id,),
)
conn.execute("DELETE FROM sessions WHERE user_id = ?", (user_id,))
conn.execute("DELETE FROM memories WHERE user_id = ?", (user_id,))
conn.execute("DELETE FROM usage WHERE user_id = ?", (user_id,))
conn.execute("DELETE FROM users WHERE id = ?", (user_id,))
return True
def reset_token(self, user_id: str) -> tuple[User, str] | None:
with self._connect() as conn:
row = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone()
if not row:
return None
raw_token = secrets.token_urlsafe(32)
conn.execute(
"UPDATE users SET token_hash = ? WHERE id = ?",
(hash_token(raw_token), user_id),
)
user = self._row_to_user(conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone())
return user, raw_token
def update_display_name(self, user_id: str, display_name: str) -> User | None:
with self._connect() as conn:
conn.execute(
"UPDATE users SET display_name = ? WHERE id = ?",
(display_name, user_id),
)
row = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone()
return self._row_to_user(row) if row else None
# ----- Notfall-Protokoll ------------------------------------------------
def log_emergency(self, user_id: str, category: str, snippet: str) -> None:
with self._connect() as conn:
conn.execute(
"INSERT INTO emergency_events (user_id, category, snippet, created_at)"
" VALUES (?, ?, ?, ?)",
(user_id, category, snippet, _now()),
)
# ----- Admin-Abfragen ---------------------------------------------------
def list_sessions_for_user(self, user_id: str) -> list[dict]:
with self._connect() as conn:
rows = conn.execute(
"SELECT s.id, s.created_at, s.updated_at,"
" (SELECT COUNT(*) FROM messages m WHERE m.session_id = s.id) AS msg_count"
" FROM sessions s WHERE s.user_id = ?"
" ORDER BY s.updated_at DESC LIMIT 50",
(user_id,),
).fetchall()
return [dict(r) for r in rows]
def get_messages_for_session(self, session_id: str, limit: int = 200) -> list[dict]:
with self._connect() as conn:
rows = conn.execute(
"SELECT role, content, created_at FROM messages"
" WHERE session_id = ? ORDER BY id LIMIT ?",
(session_id, limit),
).fetchall()
return [dict(r) for r in rows]
def list_emergency_events(self, limit: int = 50) -> list[dict]:
with self._connect() as conn:
rows = conn.execute(
"SELECT e.id, e.user_id, e.category, e.snippet, e.created_at,"
" COALESCE(u.display_name, e.user_id) AS display_name"
" FROM emergency_events e LEFT JOIN users u ON u.id = e.user_id"
" ORDER BY e.id DESC LIMIT ?",
(limit,),
).fetchall()
return [dict(r) for r in rows]
def get_usage_for_user(self, user_id: str) -> list[dict]:
with self._connect() as conn:
rows = conn.execute(
"SELECT day, requests, units FROM usage"
" WHERE user_id = ? ORDER BY day DESC LIMIT 30",
(user_id,),
).fetchall()
return [dict(r) for r in rows]
def get_all_usage(self) -> list[dict]:
with self._connect() as conn:
rows = conn.execute(
"SELECT u.user_id, COALESCE(usr.display_name, u.user_id) AS display_name,"
" SUM(u.requests) AS total_requests, SUM(u.units) AS total_units,"
" MAX(u.day) AS last_active"
" FROM usage u LEFT JOIN users usr ON usr.id = u.user_id"
" GROUP BY u.user_id ORDER BY total_requests DESC",
).fetchall()
return [dict(r) for r in rows]
def get_config_overrides(self) -> dict[str, str]:
with self._connect() as conn:
rows = conn.execute("SELECT key, value FROM config_overrides").fetchall()
return {r["key"]: r["value"] for r in rows}
def set_config_override(self, key: str, value: str) -> None:
with self._connect() as conn:
conn.execute(
"INSERT INTO config_overrides(key, value, updated_at) VALUES(?,?,?)"
" ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at",
(key, value, _now()),
)
def delete_config_override(self, key: str) -> bool:
with self._connect() as conn:
cur = conn.execute("DELETE FROM config_overrides WHERE key=?", (key,))
return cur.rowcount > 0

0
app/utils/__init__.py Normal file
View file

1845
app/web/app.js Normal file

File diff suppressed because it is too large Load diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
app/web/favicon-32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

3
app/web/favicon.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 44 KiB

331
app/web/index.html Normal file
View file

@ -0,0 +1,331 @@
<!doctype html>
<html lang="de" class="h-full">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>Voice Assistant</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg?v=1" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png?v=1" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png?v=1" />
<script src="https://cdn.tailwindcss.com"></script>
<script>tailwind.config = { darkMode: "class" };</script>
<style>
/* Dropdown-Popup (<option>) hat sonst Browser-Default-Weiß -> im Dark-Mode
helle Schrift auf hellem Grund = unlesbar. Farben explizit setzen. */
select option { background-color: #ffffff; color: #1e293b; } /* slate-800 */
.dark select option { background-color: #1e293b; color: #f1f5f9; } /* slate-100 */
</style>
<script>
(function () {
try {
var t = localStorage.getItem("theme");
if (t === "dark" || (!t && matchMedia("(prefers-color-scheme: dark)").matches))
document.documentElement.classList.add("dark");
} catch (e) {}
})();
</script>
</head>
<body class="h-[100dvh] flex flex-col bg-slate-50 text-slate-800 dark:bg-slate-900 dark:text-slate-100">
<!-- ═══════════════════ ADMIN PANEL OVERLAY ═══════════════════ -->
<div id="admin-panel" class="hidden fixed inset-0 z-50 flex flex-col bg-slate-50 dark:bg-slate-900">
<!-- Admin Header -->
<header class="shrink-0 h-14 flex items-center gap-3 px-4 border-b border-slate-200 dark:border-slate-700 bg-white/90 dark:bg-slate-800/90 backdrop-blur">
<span class="text-lg">⚙️</span>
<h2 class="flex-1 font-semibold text-base">Admin-Bereich</h2>
<button id="admin-close" title="Schließen"
class="h-9 w-9 grid place-items-center rounded-lg text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-700 text-xl leading-none transition-colors">✕</button>
</header>
<!-- Tab Navigation (5 logische Bereiche) -->
<nav class="shrink-0 flex border-b border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 overflow-x-auto">
<button class="admin-tab px-4 py-3 text-sm font-medium whitespace-nowrap border-b-2 border-transparent text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200 transition-colors" data-tab="overview">📊 Übersicht</button>
<button class="admin-tab px-4 py-3 text-sm font-medium whitespace-nowrap border-b-2 border-transparent text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200 transition-colors" data-tab="users">👥 Nutzer</button>
<button class="admin-tab px-4 py-3 text-sm font-medium whitespace-nowrap border-b-2 border-transparent text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200 transition-colors" data-tab="emergency">🚨 Notfälle</button>
<button class="admin-tab px-4 py-3 text-sm font-medium whitespace-nowrap border-b-2 border-transparent text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200 transition-colors" data-tab="system">🖥 System</button>
<button class="admin-tab px-4 py-3 text-sm font-medium whitespace-nowrap border-b-2 border-transparent text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200 transition-colors" data-tab="config">⚙ Konfiguration</button>
</nav>
<!-- Sub-Navigation (dynamisch, nur bei mehreren Abschnitten) -->
<div id="admin-subnav" class="hidden shrink-0 flex gap-1.5 px-3 py-2 border-b border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/40 overflow-x-auto"></div>
<!-- Tab Content Container -->
<div class="flex-1 overflow-hidden">
<!-- ── Übersicht (Dashboard) ── -->
<div id="admin-tab-overview" class="hidden h-full overflow-y-auto p-4">
<div class="max-w-3xl mx-auto">
<div class="flex items-center justify-between mb-4">
<h3 class="font-semibold text-base">Übersicht</h3>
<button id="load-overview"
class="rounded-lg border border-slate-300 dark:border-slate-600 text-sm px-3 py-1.5 hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors">Aktualisieren</button>
</div>
<div id="overview-content" class="space-y-4">
<p class="text-sm text-slate-400">lade …</p>
</div>
</div>
</div>
<!-- ── Tab: Nutzer ── -->
<div id="admin-tab-users" class="h-full overflow-y-auto p-4">
<div class="max-w-3xl mx-auto space-y-4">
<!-- Nutzer anlegen -->
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4">
<h3 class="text-sm font-medium text-slate-700 dark:text-slate-300 mb-3">Nutzer anlegen</h3>
<form id="create-user-form" class="flex gap-2">
<input id="new-user-name" type="text" placeholder="Anzeigename" required
class="flex-1 rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" />
<button type="submit"
class="rounded-lg bg-blue-600 hover:bg-blue-700 text-white text-sm px-4 py-2 font-medium transition-colors">Anlegen</button>
</form>
<div id="new-token-box" class="hidden mt-3 rounded-lg bg-emerald-50 dark:bg-emerald-900/30 border border-emerald-200 dark:border-emerald-800 p-3">
<p class="text-xs text-emerald-700 dark:text-emerald-400 mb-1 font-medium">Token (nur einmal sichtbar — jetzt kopieren!):</p>
<code id="new-token-value" class="text-xs font-mono break-all text-emerald-800 dark:text-emerald-200 select-all"></code>
</div>
</div>
<!-- Nutzerliste -->
<div id="admin-user-cards" class="space-y-3">
<p class="text-sm text-slate-400 text-center py-4">lade …</p>
</div>
</div>
</div>
<!-- ── Tab: Gespräche ── -->
<div id="admin-tab-chat" class="hidden h-full flex overflow-hidden">
<!-- Linke Spalte: Nutzerliste -->
<div class="w-48 md:w-60 shrink-0 border-r border-slate-200 dark:border-slate-700 flex flex-col">
<div class="px-3 py-2.5 border-b border-slate-200 dark:border-slate-700 bg-slate-100/60 dark:bg-slate-800/60">
<p class="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide">Nutzer</p>
</div>
<ul id="chat-user-list" class="flex-1 overflow-y-auto divide-y divide-slate-100 dark:divide-slate-800/60"></ul>
</div>
<!-- Rechte Spalte: Sessions & Transkript -->
<div id="chat-right-panel" class="flex-1 overflow-y-auto">
<p class="p-6 text-sm text-slate-400">← Nutzer auswählen</p>
</div>
</div>
<!-- ── Tab: Notfälle ── -->
<div id="admin-tab-emergency" class="hidden h-full overflow-y-auto p-4">
<div class="max-w-4xl mx-auto">
<div class="flex items-center justify-between mb-4">
<h3 class="font-semibold text-base">Notfall-Ereignisse</h3>
<button id="load-emergency"
class="rounded-lg border border-slate-300 dark:border-slate-600 text-sm px-3 py-1.5 hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors">Aktualisieren</button>
</div>
<div id="emergency-content">
<p class="text-sm text-slate-400">lade …</p>
</div>
</div>
</div>
<!-- ── Tab: Status ── -->
<div id="admin-tab-status" class="hidden h-full overflow-y-auto p-4">
<div class="max-w-3xl mx-auto">
<div class="flex items-center justify-between mb-4">
<h3 class="font-semibold text-base">System-Status</h3>
<button id="load-status"
class="rounded-lg border border-slate-300 dark:border-slate-600 text-sm px-3 py-1.5 hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors">Aktualisieren</button>
</div>
<div id="status-content" class="space-y-4">
<p class="text-sm text-slate-400">lade …</p>
</div>
</div>
</div>
<!-- ── Tab: Metriken ── -->
<div id="admin-tab-metrics" class="hidden h-full overflow-y-auto p-4">
<div class="max-w-3xl mx-auto">
<div class="flex items-center justify-between mb-4">
<h3 class="font-semibold text-base">Nutzungsstatistik</h3>
<button id="load-metrics"
class="rounded-lg border border-slate-300 dark:border-slate-600 text-sm px-3 py-1.5 hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors">Aktualisieren</button>
</div>
<div id="metrics-content">
<p class="text-sm text-slate-400">lade …</p>
</div>
</div>
</div>
<!-- ── Tab: Wörterbuch ── -->
<div id="admin-tab-words" class="hidden h-full overflow-y-auto p-4">
<div class="max-w-3xl mx-auto">
<div class="flex items-center justify-between mb-4 flex-wrap gap-2">
<h3 class="font-semibold text-base">Aussprache-Lexikon</h3>
<div class="flex items-center gap-2">
<select id="words-lang"
class="text-sm rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-2 py-1.5">
<option value="de">Deutsch (de)</option>
<option value="en">Englisch (en)</option>
<option value="fr">Französisch (fr)</option>
<option value="es">Spanisch (es)</option>
<option value="it">Italienisch (it)</option>
<option value="nl">Niederländisch (nl)</option>
<option value="ru">Russisch (ru)</option>
<option value="zh">Chinesisch (zh)</option>
</select>
<button id="load-words"
class="rounded-lg border border-slate-300 dark:border-slate-600 text-sm px-3 py-1.5 hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors">Aktualisieren</button>
</div>
</div>
<p class="text-xs text-slate-400 mb-4">Wirkt nur bei lokalem TTS (Piper, Stufe „full"). <strong>Abkürzungen</strong> ersetzen ganze Token. <strong>Einheiten</strong> wirken nur direkt nach einer Zahl. <strong>Begriffe</strong> für Eigennamen, Fachwörter, Aussprache-Korrekturen.</p>
<div id="words-content" class="space-y-6">
<p class="text-sm text-slate-400">lade …</p>
</div>
</div>
</div>
<!-- ── Tab: Log ── -->
<div id="admin-tab-log" class="hidden h-full flex flex-col">
<div class="shrink-0 flex items-center gap-3 px-4 py-2.5 border-b border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/60">
<button id="log-connect"
class="rounded-lg bg-emerald-600 hover:bg-emerald-700 text-white text-xs px-3 py-1.5 font-medium transition-colors">▶ Verbinden</button>
<button id="log-disconnect" disabled
class="rounded-lg border border-slate-300 dark:border-slate-600 text-xs px-3 py-1.5 opacity-40 transition-colors">■ Trennen</button>
<span id="log-status" class="text-xs text-slate-400">getrennt</span>
<button id="log-clear" class="ml-auto text-xs text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 transition-colors">Leeren</button>
</div>
<div id="log-output"
class="flex-1 overflow-y-auto bg-slate-950 text-emerald-300 font-mono text-xs p-4 leading-relaxed whitespace-pre-wrap">
<span class="text-slate-500">— Verbinden um Log zu starten —</span>
</div>
</div>
<!-- ── Tab: Einstellungen ── -->
<div id="admin-tab-settings" class="hidden h-full overflow-y-auto p-4">
<div class="max-w-3xl mx-auto">
<div class="flex items-center justify-between mb-2 flex-wrap gap-2">
<h3 class="font-semibold text-base">Laufzeit-Einstellungen</h3>
<button id="load-settings"
class="rounded-lg border border-slate-300 dark:border-slate-600 text-sm px-3 py-1.5 hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors">Aktualisieren</button>
</div>
<p class="text-xs text-slate-400 mb-4">Änderungen wirken sofort — kein Server-Neustart nötig. „Zurücksetzen" entfernt den Override und der .env-Wert gilt wieder.</p>
<div id="settings-content" class="space-y-2">
<p class="text-sm text-slate-400">lade …</p>
</div>
</div>
</div>
</div><!-- /Tab Content Container -->
</div><!-- /Admin Panel -->
<!-- ═══════════════════ CHAT INTERFACE ═══════════════════ -->
<header class="shrink-0 h-14 flex items-center gap-2 px-3 border-b border-slate-200 dark:border-slate-700 bg-white/80 dark:bg-slate-800/80 backdrop-blur">
<img src="/favicon.svg?v=1" alt="" class="w-7 h-7 shrink-0" />
<h1 class="flex-1 truncate font-semibold text-base">Voice&nbsp;Assistant</h1>
<select id="lang-sel" title="Sprache — feste Sprache oder 🔄 Flex (folgt der gesprochenen Sprache)"
class="text-sm rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-2 py-1.5">
<option value="flex">🔄 Flex</option>
<option value="de">🇩🇪 DE</option>
<option value="en">🇬🇧 EN</option>
<option value="fr">🇫🇷 FR</option>
<option value="es">🇪🇸 ES</option>
<option value="it">🇮🇹 IT</option>
<option value="nl">🇳🇱 NL</option>
<option value="ru">🇷🇺 RU</option>
<option value="zh">🇨🇳 ZH</option>
</select>
<button id="menu-toggle" title="Menü" aria-label="Menü"
class="h-9 w-9 grid place-items-center rounded-lg border border-slate-300 dark:border-slate-600 hover:bg-slate-100 dark:hover:bg-slate-700 text-xl leading-none transition-colors">⋮</button>
</header>
<!-- Identitätsleiste: angemeldeter Nutzer (links) + Abmelden (rechts) -->
<div class="shrink-0 flex items-center justify-between px-4 py-1 text-xs text-slate-500 dark:text-slate-400 border-b border-slate-200 dark:border-slate-700">
<span id="identity">lade …</span>
<a id="logout" class="hidden hover:underline" href="#">Abmelden</a>
</div>
<!-- Verstecktes Quell-Select: speist die Ton-Presets (bewahrt die bestehende Logik) -->
<select id="tts" class="hidden">
<option value="device">Im Gerät</option>
<option value="piper">Server</option>
<option value="chatterbox">Beste Qualität</option>
<option value="openrouter">Cloud</option>
</select>
<!-- ═══════════════════ EINSTELLUNGS-SHEET ═══════════════════ -->
<div id="settings-backdrop" class="hidden fixed inset-0 z-40 bg-black/30"></div>
<div id="settings-sheet"
class="hidden fixed top-14 right-2 z-50 w-[min(20rem,calc(100vw-1rem))] max-h-[80vh] overflow-y-auto
rounded-xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 shadow-xl p-3 space-y-3">
<!-- Vorlesen (TTS-Presets) -->
<div>
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400 mb-1.5 px-1">Vorlesen</p>
<div class="space-y-1.5">
<button type="button" data-tts="device"
class="tts-preset w-full flex items-center gap-3 rounded-lg border border-slate-200 dark:border-slate-700 px-3 py-2 text-left hover:bg-slate-50 dark:hover:bg-slate-700/50 transition-colors">
<span class="text-lg shrink-0">📱</span>
<span class="flex-1 min-w-0"><span class="block text-sm font-medium">Im Gerät</span>
<span class="tts-sub block text-xs text-slate-500 dark:text-slate-400">Das Gerät liest vor · spart Daten</span></span>
<span class="check text-blue-600 dark:text-blue-400 font-bold opacity-0"></span>
</button>
<button type="button" data-tts="piper"
class="tts-preset w-full flex items-center gap-3 rounded-lg border border-slate-200 dark:border-slate-700 px-3 py-2 text-left hover:bg-slate-50 dark:hover:bg-slate-700/50 transition-colors">
<span class="text-lg shrink-0"></span>
<span class="flex-1 min-w-0"><span class="block text-sm font-medium">Schnell</span>
<span class="block text-xs text-slate-500 dark:text-slate-400">Piper · lokal, zuverlässig</span></span>
<span class="check text-blue-600 dark:text-blue-400 font-bold opacity-0"></span>
</button>
<button type="button" data-tts="chatterbox"
class="tts-preset w-full flex items-center gap-3 rounded-lg border border-slate-200 dark:border-slate-700 px-3 py-2 text-left hover:bg-slate-50 dark:hover:bg-slate-700/50 transition-colors">
<span class="text-lg shrink-0"></span>
<span class="flex-1 min-w-0"><span class="block text-sm font-medium">Hohe Qualität</span>
<span class="block text-xs text-slate-500 dark:text-slate-400">Chatterbox · natürliche Stimme</span></span>
<span class="check text-blue-600 dark:text-blue-400 font-bold opacity-0"></span>
</button>
<button type="button" data-tts="openrouter"
class="tts-preset w-full flex items-center gap-3 rounded-lg border border-slate-200 dark:border-slate-700 px-3 py-2 text-left hover:bg-slate-50 dark:hover:bg-slate-700/50 transition-colors">
<span class="text-lg shrink-0"></span>
<span class="flex-1 min-w-0"><span class="block text-sm font-medium">Cloud</span>
<span class="block text-xs text-slate-500 dark:text-slate-400">OpenRouter · benötigt Internet</span></span>
<span class="check text-blue-600 dark:text-blue-400 font-bold opacity-0"></span>
</button>
</div>
</div>
<!-- Aktionen -->
<div class="border-t border-slate-100 dark:border-slate-700 pt-2 space-y-1">
<button id="new-chat" type="button"
class="w-full flex items-center gap-3 rounded-lg px-3 py-2 text-sm hover:bg-slate-50 dark:hover:bg-slate-700/50 text-left transition-colors">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" class="w-5 h-5 shrink-0"><path stroke-linecap="round" stroke-linejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10"/></svg>
Neues Gespräch
</button>
<button id="theme-toggle" type="button"
class="w-full flex items-center gap-3 rounded-lg px-3 py-2 text-sm hover:bg-slate-50 dark:hover:bg-slate-700/50 text-left transition-colors">
<span id="theme-icon" class="w-5 text-center shrink-0">🌙</span>
<span>Tag-/Nachtmodus</span>
</button>
<button id="admin-toggle" type="button"
class="hidden w-full flex items-center gap-3 rounded-lg px-3 py-2 text-sm hover:bg-slate-50 dark:hover:bg-slate-700/50 text-left transition-colors">
<span class="w-5 text-center shrink-0"></span>
<span>Admin-Bereich</span>
</button>
</div>
</div>
<main id="messages" class="flex-1 overflow-y-auto px-4 py-4 space-y-3 w-full max-w-3xl mx-auto"></main>
<footer class="shrink-0 border-t border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 px-3 pt-3 pb-[calc(env(safe-area-inset-bottom)+0.6rem)]">
<form id="prompt-form" class="flex items-center gap-2 w-full max-w-3xl mx-auto">
<button type="button" id="mic" title="Mikrofon" aria-label="Mikrofon"
class="shrink-0 h-11 w-11 grid place-items-center rounded-full bg-emerald-600 text-white text-lg">🎤</button>
<input id="prompt" type="text" autocomplete="off" placeholder="Nachricht …"
class="flex-1 min-w-0 rounded-xl border border-slate-300 dark:border-slate-600 bg-transparent px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500" />
<button type="submit" id="send" title="Senden" aria-label="Senden"
class="shrink-0 h-11 w-11 grid place-items-center rounded-full bg-blue-600 hover:bg-blue-700 text-white transition-colors">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 12 3.27 4.36a.6.6 0 0 1 .82-.74l16.2 7.83a.6.6 0 0 1 0 1.08l-16.2 7.83a.6.6 0 0 1-.82-.74L6 12Zm0 0h7" />
</svg>
</button>
</form>
<div id="status" class="w-full max-w-3xl mx-auto mt-1 min-h-[1rem] text-xs text-slate-500 dark:text-slate-400"></div>
</footer>
<script src="/app.js?v=42"></script>
</body>
</html>

76
chat_client.py Normal file
View file

@ -0,0 +1,76 @@
import io
import wave
import requests
import soundfile as sf
import numpy as np
import subprocess
import sys
GATEWAY_URL = "http://localhost:8003"
CHAT_ENDPOINT = f"{GATEWAY_URL}/api/chat"
# feste Annahmen für Gemini 3.1 Flash TTS über OpenRouter
SAMPLE_RATE = 24000
CHANNELS = 1
SAMPLE_WIDTH = 2 # 16-bit PCM
def pcm_to_wav(pcm_bytes: bytes, wav_path: str) -> None:
"""Rohes s16le-PCM in eine WAV-Datei schreiben."""
with wave.open(wav_path, "wb") as wf:
wf.setnchannels(CHANNELS)
wf.setsampwidth(SAMPLE_WIDTH)
wf.setframerate(SAMPLE_RATE)
wf.writeframes(pcm_bytes)
def play_wav(wav_path: str) -> None:
"""WAV-Datei abspielen (ffplay oder aplay/mpv, je nach System)."""
for cmd in (
["ffplay", "-nodisp", "-autoexit", wav_path],
["aplay", wav_path],
["mpv", wav_path],
):
try:
subprocess.run(cmd, check=True)
return
except (FileNotFoundError, subprocess.CalledProcessError):
continue
print(f"Konnte keine geeignete Player-CLI finden für {wav_path}", file=sys.stderr)
def chat_and_play(text: str, language: str = "de") -> None:
payload = {"text": text, "language": language}
resp = requests.post(
CHAT_ENDPOINT,
json=payload,
stream=True,
)
if not resp.ok:
print("HTTP", resp.status_code)
print(resp.text)
return
pcm_bytes = b"".join(resp.iter_content(chunk_size=8192))
# Hinweis: Den Text-Trace (Transkript/Antwort) liefert /api/chat nur im JSON,
# wenn man ?debug=true anhängt - nicht als Header im Audio-Stream.
print("Audio-Format:", resp.headers.get("X-Audio-Format"))
print("Sample-Rate:", resp.headers.get("X-Audio-Sample-Rate"))
wav_path = "chat_reply.wav"
pcm_to_wav(pcm_bytes, wav_path)
print(f"WAV gespeichert unter {wav_path}")
play_wav(wav_path)
if __name__ == "__main__":
if len(sys.argv) > 1:
user_text = " ".join(sys.argv[1:])
else:
user_text = "Wie wird das Wetter morgen in Bünde?"
chat_and_play(user_text, language="de")

View file

@ -0,0 +1,17 @@
abbreviations: {}
units: {}
terms:
bläst: blähst
büßt: bühßt
Chat: Tschätt
Dornröschen: Dornröhß-chen
Glycerin: Glüzeriehn
grüßt: grühßt
Iljitsch: Illjitsch
löst: löhst
Mond: Mohnd
Piotr: Pjottr
strömt: ströhmt
strömte: ströhmte
Tchaikovsky: Tschai'kowski
tönt: töhnt

View file

@ -0,0 +1,5 @@
abbreviations: {}
units: {}
terms:
Dieter: Deeter
Schlüter: Shleeter

View file

@ -0,0 +1,12 @@
# Léxico de pronunciación (Español) para la normalización TTS antes de Piper.
# Wirkt nur bei lokalem TTS (piper, Stufe "full").
abbreviations:
units:
terms:
# Nombre del responsable del sistema — aproximación fonética para espeak-ng es.
# ES no tiene /ʃ/; "Schlueter" es la mejor aproximación disponible.
# espeak-es leerá "sch" como /sk/ y "ue" como /we/ → /ˈsklweter/.
"Schlüter": "Schlueter"

View file

@ -0,0 +1,5 @@
abbreviations: {}
units: {}
terms:
Dieter: Diter
Schlüter: Chluteur

View file

@ -0,0 +1,12 @@
# Lessico di pronuncia (Italiano) per la normalizzazione TTS prima di Piper.
# Wirkt nur bei lokalem TTS (piper, Stufe "full").
abbreviations:
units:
terms:
# Nome del responsabile del sistema — approssimazione fonetica per espeak-ng it.
# IT: "sch" prima di consonante = /sk/; "ue" = /wɛ/ → /ˈsklwɛter/.
# Alternativa: "Scilueter" (sc+i = /ʃ/ in IT), ma suona strano.
"Schlüter": "Schlueter"

View file

@ -0,0 +1,12 @@
# Uitspraak-lexicon (Nederlands) voor TTS-normalisatie vóór Piper.
# Wirkt nur bei lokalem TTS (piper, Stufe "full").
abbreviations:
units:
terms:
# Naam van de systeembeheerder — fonetische benadering voor espeak-ng nl.
# NL: "sch" = /sx/, "uu" = /yː/ (= Duits ü) → /sxlyːtər/ ≈ Duits /ʃlyːtɐ/.
# "sch" klinkt anders dan Duits (sx vs. ʃ), maar "uu" treft de klinker exact.
"Schlüter": "Schluuter"

View file

@ -0,0 +1,14 @@
# Словарь произношения (Русский) для нормализации TTS перед Piper.
# Wirkt nur bei lokalem TTS (piper, Stufe "full").
# Kyrillisch verwenden — espeak-ng ru phonemisiert lateinische Buchstaben schlecht.
abbreviations:
units:
terms:
# Имя ответственного за систему — кириллическая транскрипция.
# "Шлютер": Ш=/ʃ/, лю=/lʲu/ (nächste Annäherung an /lyː/), тер=/tʲɛr/.
# "Дитер": Д=/d/, и=/i/, тер=/tʲɛr/ → /dʲitʲɛr/ ≈ deutsch /ˈdiːtɐ/.
"Dieter": "Дитер"
"Schlüter": "Шлютер"

View file

@ -0,0 +1,14 @@
# 发音词典中文TTS 前文本规范化。
# Wirkt nur bei lokalem TTS (piper, Stufe "full").
# Hanzi verwenden — espeak-ng zh phonemisiert lateinische Buchstaben schlecht.
abbreviations:
units:
terms:
# 系统负责人姓名——汉字音译。
# 施=/ʃɨ/ (sh-Sound), 吕=lǚ=/ly/ (exakt das deutsche ü!), 特=tè=/tɛ/ → 施吕特≈/ʃɨlytɛ/.
# 迪=Dí=/di/, 特=tè=/tɛ/ → 迪特≈/dite/ ≈ deutsch /ˈdiːtɐ/.
"Dieter": "迪特"
"Schlüter": "施吕特"

View file

@ -0,0 +1,45 @@
# Zentrale Konfiguration des Voice-Assistant-Gateways.
#
# WICHTIG: Secrets (API-Keys) gehoeren NICHT in diese Datei -> ausschliesslich
# ueber Umgebungsvariablen (z. B. OPENROUTER_API_KEY).
#
# Praezedenz (hoeher gewinnt):
# eingebaute Defaults < diese TOML-Datei < ENV/.env < Session-Route < Request
#
# Aktives Profil waehlen via ENV: VA_PROFILE=local-dev | hybrid | cloud
# Eigenen Pfad setzen via ENV: VA_CONFIG_FILE=/pfad/zu/voice-assistant.toml
#
# Diese Datei nach config/voice-assistant.toml kopieren und anpassen.
# Basiswerte, die fuer alle Profile gelten (von Profilen ueberschreibbar).
[defaults]
default_language = "de"
default_input_endpoint = "local-default"
default_output_endpoint = "local-default"
openrouter_stt_model = "openai/whisper-large-v3"
openrouter_tts_model = "openai/gpt-4o-mini-tts"
openrouter_tts_voice = "alloy"
openrouter_llm_model = "openai/gpt-4.1-mini"
# Lokaler llama.cpp-Server (zentrale, unzensierte KI) - Start: scripts/llm-server/start-llm-server.sh
local_llm_base_url = "http://127.0.0.1:8001/v1"
local_llm_model = "va_llm" # = --alias des llama.cpp-Servers
# Reines lokales Setup (eigene Hardware/KI) - z. B. fuer Entwicklung/Offline-Test.
[profiles.local-dev]
default_stt_provider = "faster-whisper"
default_llm_provider = "local-openai-compatible"
default_tts_provider = "piper"
# Hybrid: STT/TTS remote, Haupt-LLM lokal.
[profiles.hybrid]
default_stt_provider = "openrouter"
default_llm_provider = "local-openai-compatible"
default_tts_provider = "openrouter"
# Voll-Cloud: alle KI-Module remote (Standard fuer den produktiven vHost-Betrieb).
[profiles.cloud]
default_stt_provider = "openrouter"
default_llm_provider = "openrouter"
default_tts_provider = "openrouter"

37
config/voices/README.md Normal file
View file

@ -0,0 +1,37 @@
# Native Referenz-Stimmen (Chatterbox cross-lingual)
Je Sprache eine kurze Referenz-WAV (~68 s). Der Chatterbox-TTS-Provider wählt
sie automatisch nach der Antwortsprache aus (Konvention `<lang>.wav`, siehe
`CHATTERBOX_VOICES_DIR`). Das Timbre der Referenz wird cross-lingual auf die
jeweilige Sprache übertragen. Fehlt eine Datei, greift `CHATTERBOX_VOICE`
(persönlicher Klon) bzw. die Standardstimme des Dienstes.
Deutsch (`de`) hat bewusst **keine** Datei hier — dafür gilt der persönliche
Klon aus `CHATTERBOX_VOICE`.
## Quelle & Lizenz
Die Clips stammen aus dem **FLEURS**-Datensatz (Google), Split `validation`:
| Datei | FLEURS-Config | Sprache |
|-------|---------------|---------|
| en.wav | en_us | Englisch (US) |
| fr.wav | fr_fr | Französisch |
| es.wav | es_419 | Spanisch (Lateinamerika) |
| it.wav | it_it | Italienisch |
| nl.wav | nl_nl | Niederländisch |
| ru.wav | ru_ru | Russisch |
| zh.wav | cmn_hans_cn | Mandarin-Chinesisch |
Die Clips wurden auf einheitliche Lautheit normalisiert (EBU R128, `loudnorm`
I=-16 LUFS, TP=-1.5 dB) — die FLEURS-Originalpegel waren stark uneinheitlich
(z. B. ru/en/nl deutlich zu leise). Nur Pegelanpassung, kein inhaltlicher Eingriff.
**Lizenz: CC-BY 4.0** — Namensnennung erforderlich.
> FLEURS: Conneau et al., "FLEURS: Few-shot Learning Evaluation of Universal
> Representations of Speech" (Google Research). Datensatz: `google/fleurs`
> auf Hugging Face. Lizenz: Creative Commons Attribution 4.0 (CC-BY 4.0).
Zum Austauschen einfach eine andere `<lang>.wav` ablegen (Pfad muss für den
Chatterbox-Dienst lesbar sein; der Provider löst ihn absolut auf).

BIN
config/voices/en.wav Normal file

Binary file not shown.

BIN
config/voices/es.wav Normal file

Binary file not shown.

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