Web-Such-/Tool-Calling-Funktion in der Doku nachgezogen: CHANGELOG (0.3.0), README, CLAUDE.md (Querschnitt), Architektur-Doc (§6.1), Bedienungsanleitung (§5.5 Nutzer/Admin) und DEPLOYMENT (Tool-Faehigkeit des Modells). Version 0.2.0 -> 0.3.0 (pyproject.toml). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1651 lines
59 KiB
Markdown
1651 lines
59 KiB
Markdown
# Deployment-Prompt: Voice Assistant auf jamulix.de
|
|
|
|
Dieses Dokument beschreibt alle Änderungen, die am Repository
|
|
`my_voice_assistant_v3` für den Betrieb auf **jamulix.de** vorgenommen wurden.
|
|
Ziel: Ein zweites Claude-Instanz (oder ein Mensch) soll die Änderungen exakt
|
|
reproduzieren können.
|
|
|
|
---
|
|
|
|
## Kontext
|
|
|
|
- **Server:** jamulix.de, Ubuntu 24.04 LTS, 4 CPU-Kerne (QEMU), 3,8 GB RAM
|
|
- **Kein GPU** — Chatterbox ist nicht nutzbar
|
|
- **Installationspfad:** `/opt/voice-assistant`
|
|
- **Systembenutzer:** `voice` (GID 22, bereits vorhanden als Gruppe)
|
|
- **Konfigurationsverzeichnis:** `/etc/voice-assistant/`
|
|
- **Datenbank:** `/var/lib/voice-assistant/`
|
|
- **Profil:** `jamulix` (STT lokal, LLM Cloud, TTS lokal)
|
|
- **URL:** `https://voice.jamulix.de` (Subdomain, eigenes SSL-Zertifikat)
|
|
- **Authentifizierung:** Authelia 2FA (bereits auf Server installiert, läuft auf Port 9091)
|
|
|
|
---
|
|
|
|
## Teil 1: Repository-Änderungen (Code)
|
|
|
|
### 1.1 `app/api/config.py` — vollständig ersetzen
|
|
|
|
Die originale Datei meldet keine Verfügbarkeit von Chatterbox oder Cloud-TTS.
|
|
Ersetze den gesamten Inhalt durch:
|
|
|
|
```python
|
|
import httpx
|
|
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()
|
|
|
|
|
|
async def _chatterbox_reachable() -> bool:
|
|
try:
|
|
async with httpx.AsyncClient(timeout=1.5) as client:
|
|
r = await client.get(f"{settings.chatterbox_base_url.rstrip('/')}/status")
|
|
return r.status_code < 500
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
async def _openrouter_tts_available() -> bool:
|
|
"""Prüft ob das konfigurierte TTS-Modell auf OpenRouter via /audio/speech erreichbar ist."""
|
|
key = settings.openrouter_api_key.strip()
|
|
model = settings.openrouter_tts_model.strip()
|
|
if not key or not model:
|
|
return False
|
|
try:
|
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
|
r = await client.post(
|
|
"https://openrouter.ai/api/v1/audio/speech",
|
|
headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"},
|
|
json={"model": model, "input": "Test", "voice": settings.openrouter_tts_voice or "alloy",
|
|
"response_format": "mp3"},
|
|
)
|
|
if r.status_code == 200:
|
|
return True
|
|
body = r.text
|
|
return "does not exist" not in body and r.status_code < 500
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
@router.get("/config")
|
|
async def get_config():
|
|
"""Zeigt aktives Profil, aufgeloeste Default-Route und verfuegbare Bausteine."""
|
|
route = resolve_route()
|
|
audio_router = get_audio_router()
|
|
chatterbox_ok, openrouter_tts_ok = await _chatterbox_reachable(), await _openrouter_tts_available()
|
|
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()],
|
|
"chatterbox_reachable": chatterbox_ok,
|
|
"openrouter_tts_available": openrouter_tts_ok,
|
|
},
|
|
"secrets": {
|
|
"openrouter_api_key_set": bool(settings.openrouter_api_key.strip()),
|
|
},
|
|
}
|
|
```
|
|
|
|
**Zweck:** Das Frontend kann damit nicht verfügbare TTS-Optionen automatisch
|
|
ausgrauen, ohne dass der Nutzer erst auf einen Fehler stößt.
|
|
|
|
Das Feld `available.cartesia_unsupported_languages` (z. B. `["ar","it","ru","pl",…]`,
|
|
abgeleitet aus `_UNSUPPORTED_LANGS` in `app/providers/tts/cartesia.py`) speist im UI
|
|
einen Hinweis: Wählt der Nutzer Cartesia **und** eine feste Sprache, die Cartesia
|
|
nicht spricht, erscheint „Cartesia spricht <Sprache> nicht — es wird automatisch die
|
|
lokale Stimme (Piper) verwendet." Das macht den `tts_fallback = "piper"`-Mechanismus
|
|
sichtbar, statt lautlos eine andere Stimme zu liefern.
|
|
|
|
---
|
|
|
|
### 1.2 `app/web/app.js` — Chatterbox/OpenRouter Verfügbarkeits-Refresh
|
|
|
|
#### A) Zwei Funktionen nach `refreshDevicePreset()` einfügen
|
|
|
|
Suche in `app.js` die Funktion `refreshDevicePreset()`. Sie endet mit:
|
|
```javascript
|
|
syncPresetUI();
|
|
}
|
|
```
|
|
|
|
Füge **direkt danach** (vor `// Markiert das aktive Ton-Preset`) diese beiden
|
|
Funktionen ein:
|
|
|
|
```javascript
|
|
// Graut "Hohe Qualität" (Chatterbox) aus, wenn kein GPU-Backend erreichbar ist.
|
|
async function refreshChatterboxPreset() {
|
|
const btn = document.querySelector('.tts-preset[data-tts="chatterbox"]');
|
|
if (!btn || !ttsSel) return;
|
|
try {
|
|
const cfg = await fetch("/api/config", { cache: "no-store" }).then(r => r.json());
|
|
const available = cfg.available?.chatterbox_reachable === true;
|
|
btn.disabled = !available;
|
|
btn.classList.toggle("opacity-50", !available);
|
|
btn.classList.toggle("cursor-not-allowed", !available);
|
|
const sub = btn.querySelector(".tts-sub");
|
|
if (sub) sub.textContent = available
|
|
? "Chatterbox · natürliche Stimme"
|
|
: "Chatterbox · kein GPU-Backend erreichbar";
|
|
if (!available && ttsSel.value === "chatterbox") {
|
|
ttsSel.value = "piper";
|
|
syncPresetUI();
|
|
}
|
|
} catch (e) { /* ignorieren */ }
|
|
}
|
|
|
|
// Graut "Cloud" (OpenRouter TTS) aus, wenn das TTS-Modell nicht verfügbar ist.
|
|
async function refreshOpenrouterTtsPreset() {
|
|
const btn = document.querySelector('.tts-preset[data-tts="openrouter"]');
|
|
if (!btn || !ttsSel) return;
|
|
try {
|
|
const cfg = await fetch("/api/config", { cache: "no-store" }).then(r => r.json());
|
|
const available = cfg.available?.openrouter_tts_available === true;
|
|
btn.disabled = !available;
|
|
btn.classList.toggle("opacity-50", !available);
|
|
btn.classList.toggle("cursor-not-allowed", !available);
|
|
const sub = btn.querySelector(".tts-sub");
|
|
if (sub) sub.textContent = available
|
|
? "OpenRouter · benötigt Internet"
|
|
: "OpenRouter · kein TTS-Modell verfügbar";
|
|
if (!available && ttsSel.value === "openrouter") {
|
|
ttsSel.value = "piper";
|
|
syncPresetUI();
|
|
}
|
|
} catch (e) { /* ignorieren */ }
|
|
}
|
|
```
|
|
|
|
#### B) Zwei Aufrufe nach `loadMe()` einfügen
|
|
|
|
Suche die Zeile `loadMe();`. Füge **direkt danach** ein:
|
|
|
|
```javascript
|
|
refreshChatterboxPreset();
|
|
refreshOpenrouterTtsPreset();
|
|
```
|
|
|
|
---
|
|
|
|
### 1.3 `app/web/app.js` — Sprachen und Geschlechtspräferenz
|
|
|
|
#### A) `LANG_BCP47`-Map anpassen
|
|
|
|
`nl` entfernen, `pt` (Brasilianisch), `pl` (Polnisch) und `ar` (Arabisch) ergänzen:
|
|
|
|
```javascript
|
|
const LANG_BCP47 = {
|
|
de: "de-DE", en: "en-US", fr: "fr-FR", es: "es-ES",
|
|
it: "it-IT", pt: "pt-BR", pl: "pl-PL", ar: "ar-SA", ru: "ru-RU", zh: "zh-CN",
|
|
};
|
|
```
|
|
|
|
#### B) Admin-Panel `default_language` anpassen
|
|
|
|
```javascript
|
|
default_language: { ui: "combo",
|
|
opts: ["de","en","fr","es","it","pt","pl","ar"],
|
|
labels: { de:"Deutsch", en:"Englisch", fr:"Französisch", es:"Spanisch",
|
|
it:"Italienisch", pt:"Portugiesisch", pl:"Polnisch", ar:"Arabisch" } },
|
|
```
|
|
|
|
#### C) Admin-Panel `piper_voice` — vollständige Liste
|
|
|
|
```javascript
|
|
piper_voice: { ui: "combo", opts: [
|
|
"de_DE-thorsten-high","de_DE-kerstin-low",
|
|
"en_US-lessac-high","en_US-amy-medium","en_US-ryan-high","en_GB-cori-high",
|
|
"fr_FR-siwis-medium","fr_FR-tom-medium",
|
|
"es_ES-sharvard-medium",
|
|
"it_IT-paola-medium","it_IT-riccardo-x_low",
|
|
"pt_BR-faber-medium",
|
|
"pl_PL-gosia-medium","pl_PL-darkman-medium",
|
|
"ar_JO-kareem-medium",
|
|
"ru_RU-irina-medium","ru_RU-ruslan-medium",
|
|
"zh_CN-huayan-medium"
|
|
], test: "tts", testProvider: "piper" },
|
|
```
|
|
|
|
#### D) Geschlechtspräferenz — globale Variable und Hilfsfunktion
|
|
|
|
Direkt vor `function overrides()` einfügen:
|
|
|
|
```javascript
|
|
// Geschlechtspräferenz: 'm' oder 'f' — Radiobutton-Logik, immer genau einer aktiv
|
|
let _voiceGender = 'f';
|
|
|
|
function applyGenderChoice(gender) {
|
|
// 'any' aus alten Prefs → weiblich als Standardwert
|
|
_voiceGender = (gender === 'm') ? 'm' : 'f';
|
|
const btnM = document.getElementById('gender-m');
|
|
const btnF = document.getElementById('gender-f');
|
|
if (!btnM || !btnF) return;
|
|
const mActive = (_voiceGender === 'm');
|
|
const fActive = (_voiceGender === 'f');
|
|
// Aktiv: blauer Hintergrund + weiße Schrift
|
|
// Inaktiv: text-slate-700 explizit, damit kein Vererbungsproblem in Tag-Modus
|
|
btnM.classList.toggle('bg-blue-600', mActive);
|
|
btnM.classList.toggle('text-white', mActive);
|
|
btnM.classList.toggle('border-blue-600', mActive);
|
|
btnM.classList.toggle('text-slate-700', !mActive);
|
|
btnF.classList.toggle('bg-blue-600', fActive);
|
|
btnF.classList.toggle('text-white', fActive);
|
|
btnF.classList.toggle('border-blue-600', fActive);
|
|
btnF.classList.toggle('text-slate-700', !fActive);
|
|
}
|
|
```
|
|
|
|
#### E) `overrides()` — `voice_gender` mitsenden
|
|
|
|
```javascript
|
|
function overrides() {
|
|
const out = { ...langOverrides() };
|
|
if (isDeviceMode()) {
|
|
out.text_only = true;
|
|
} else if (ttsSel && ttsSel.value) {
|
|
out.tts_provider = ttsSel.value;
|
|
}
|
|
out.voice_gender = _voiceGender;
|
|
return out;
|
|
}
|
|
```
|
|
|
|
#### F) `loadMe()` — Präferenz laden
|
|
|
|
In der `loadMe()`-Funktion direkt nach `applyPlaybackChoice(prefs.tts_provider)`:
|
|
|
|
```javascript
|
|
applyGenderChoice(prefs.voice_gender || 'f');
|
|
```
|
|
|
|
#### G) Gender-Button Event-Listener
|
|
|
|
Nach dem `ttsSel`-Listener und vor `loadMe()` einfügen.
|
|
Radiobutton-Logik: Klick setzt immer direkt auf m oder f, kein Toggle zurück auf "any":
|
|
|
|
```javascript
|
|
document.addEventListener("DOMContentLoaded", () => {
|
|
const btnM = document.getElementById("gender-m");
|
|
const btnF = document.getElementById("gender-f");
|
|
if (btnM) btnM.addEventListener("click", () => {
|
|
applyGenderChoice("m");
|
|
savePrefs({ voice_gender: "m" });
|
|
});
|
|
if (btnF) btnF.addEventListener("click", () => {
|
|
applyGenderChoice("f");
|
|
savePrefs({ voice_gender: "f" });
|
|
});
|
|
});
|
|
```
|
|
|
|
#### H) Nachrichten-Bubbles: RTL-Unterstützung
|
|
|
|
In `addMessage()` nach `span.className = "bubble-text";`:
|
|
|
|
```javascript
|
|
span.dir = "auto";
|
|
```
|
|
|
|
---
|
|
|
|
### 1.4 `app/web/index.html` — Sprachen und Geschlechts-UI
|
|
|
|
#### A) `words-lang`-Select (Wörter-Trainer): `nl` → `ar`, PT und PL ergänzen
|
|
|
|
```html
|
|
<option value="it">Italienisch (it)</option>
|
|
<option value="pt">Portugiesisch (pt)</option>
|
|
<option value="pl">Polnisch (pl)</option>
|
|
<option value="ar">Arabisch (ar)</option>
|
|
```
|
|
|
|
#### B) `lang-sel`-Select (Sprach-Auswahl oben): `nl` → `ar`, PT und PL ergänzen
|
|
|
|
```html
|
|
<option value="it">🇮🇹 IT</option>
|
|
<option value="pt">🇧🇷 PT</option>
|
|
<option value="pl">🇵🇱 PL</option>
|
|
<option value="ar">🇸🇦 AR</option>
|
|
```
|
|
|
|
#### C) Texteingabe-Feld: automatische Schreibrichtung
|
|
|
|
```html
|
|
<input id="prompt" type="text" autocomplete="off" dir="auto" placeholder="Nachricht …" …>
|
|
```
|
|
|
|
#### D) Gender-Buttons in der Sidebar
|
|
|
|
Direkt vor `<!-- Aktionen -->` einfügen.
|
|
`text-slate-700` muss in der Basis-Klasse stehen, damit Tailwind JIT die Farbe generiert
|
|
(inaktive Buttons bekommen sie per JS, aktive `text-white` überschreibt sie):
|
|
|
|
```html
|
|
<!-- Geschlechtspräferenz (wirkt bei Cartesia und Piper mit m+f Varianten) -->
|
|
<div id="gender-pref-row">
|
|
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400 mb-1.5 px-1">Stimme</p>
|
|
<div class="flex gap-2">
|
|
<button type="button" id="gender-m"
|
|
class="gender-btn flex-1 rounded-lg border border-slate-200 dark:border-slate-700 py-1.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50 dark:hover:bg-slate-700/50">
|
|
♂ Männlich
|
|
</button>
|
|
<button type="button" id="gender-f"
|
|
class="gender-btn flex-1 rounded-lg border border-slate-200 dark:border-slate-700 py-1.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50 dark:hover:bg-slate-700/50">
|
|
♀ Weiblich
|
|
</button>
|
|
</div>
|
|
</div>
|
|
```
|
|
|
|
---
|
|
|
|
### 1.5 `app/providers/tts/piper.py` — Speaker-ID-Unterstützung ergänzen
|
|
|
|
Piper-Modelle können mehrere Sprecher enthalten (z. B. `es_ES-sharvard-medium` mit
|
|
Speaker `M=0` und `F=1`). Die Stimmauswahl nutzt das Format `stimmname#speaker_id`,
|
|
z. B. `es_ES-sharvard-medium#0`. Drei Stellen in `piper.py` müssen angepasst werden:
|
|
|
|
#### A) Neue statische Methode `_parse_speaker()` in `PiperTTSProvider`
|
|
|
|
```python
|
|
@staticmethod
|
|
def _parse_speaker(voice: str | None) -> tuple[str | None, int | None]:
|
|
"""Trennt 'stimme#speaker_id' auf. Liefert (stimme, int_id) oder (stimme, None)."""
|
|
if voice and "#" in voice:
|
|
name, sid = voice.rsplit("#", 1)
|
|
try:
|
|
return name, int(sid)
|
|
except ValueError:
|
|
pass
|
|
return voice, None
|
|
```
|
|
|
|
#### B) `synthesize()` — `_parse_speaker` aufrufen
|
|
|
|
```python
|
|
async def synthesize(self, text, voice=None, audio_format="pcm", language=None):
|
|
if not text or not text.strip():
|
|
raise ValueError("TTS input text is empty")
|
|
|
|
voice_name, speaker_id = self._parse_speaker(voice)
|
|
model, config = self._model_paths(voice_name)
|
|
pcm, native_rate = await asyncio.to_thread(
|
|
self._synthesize_sync, str(model), str(config), text.strip(), speaker_id
|
|
)
|
|
...
|
|
```
|
|
|
|
#### C) `_synthesize_sync()` — `speaker_id` via `SynthesisConfig` übergeben
|
|
|
|
**Wichtig:** Die installierte `piper`-Library (Stand 2026-06) kennt kein `speaker_id`-Argument
|
|
direkt — der Parameter wird über `piper.config.SynthesisConfig` übergeben:
|
|
|
|
```python
|
|
def _synthesize_sync(self, model, config, text, speaker_id=None):
|
|
if _PIPER_LIB:
|
|
from piper.config import SynthesisConfig
|
|
piper_voice = _load_voice(model)
|
|
syn_cfg = SynthesisConfig(speaker_id=speaker_id) if speaker_id is not None else None
|
|
pcm = bytearray()
|
|
rate = self.target_rate
|
|
for chunk in piper_voice.synthesize(text, syn_config=syn_cfg):
|
|
pcm += chunk.audio_int16_bytes
|
|
rate = chunk.sample_rate
|
|
if not pcm:
|
|
raise RuntimeError("Piper lieferte kein Audio")
|
|
return bytes(pcm), rate
|
|
return self._run_piper_binary(model, config, text, speaker_id)
|
|
```
|
|
|
|
#### D) `_run_piper_binary()` — `--speaker`-Flag ergänzen
|
|
|
|
```python
|
|
def _run_piper_binary(self, model, config, text, speaker_id=None):
|
|
import subprocess
|
|
...
|
|
cmd = [self.bin_path, "-m", model, "-c", config, "--output-raw"]
|
|
if speaker_id is not None:
|
|
cmd += ["--speaker", str(speaker_id)]
|
|
proc = subprocess.run(cmd, input=text.encode("utf-8"), capture_output=True)
|
|
...
|
|
```
|
|
|
|
---
|
|
|
|
### 1.6 `app/providers/tts/cartesia.py` — vollständig ersetzen
|
|
|
|
```python
|
|
import httpx
|
|
|
|
from app.providers.tts.base import TTSProvider
|
|
|
|
_API_URL = "https://api.cartesia.ai/tts/bytes"
|
|
_API_VERSION = "2024-06-10"
|
|
|
|
# Sprachen, die sonic-turbo unterstützt (Stand 2026-06).
|
|
_LANG_MAP = {
|
|
"de": "de", "en": "en", "fr": "fr", "es": "es",
|
|
"zh": "zh", "cmn": "zh", "pt": "pt", "ja": "ja", "ko": "ko", "hi": "hi",
|
|
}
|
|
|
|
# sonic-turbo unterstützt diese Sprachen nicht — Fallback auf Piper empfohlen.
|
|
_UNSUPPORTED_LANGS = {"ar", "it", "ru", "nl", "pl", "sv", "tr"}
|
|
|
|
_LANG_NAMES = {
|
|
"ar": "Arabisch", "it": "Italienisch", "ru": "Russisch",
|
|
"nl": "Niederländisch", "pl": "Polnisch", "sv": "Schwedisch", "tr": "Türkisch",
|
|
}
|
|
|
|
_GENDER_CODES = {"m", "f", "any"}
|
|
|
|
|
|
def _parse_voice_map(voices_str: str) -> dict[tuple[str, str], dict]:
|
|
"""Parst 'lang:gender:uuid:name'-Zeilen in ein (lang,gender)→{uuid,name}-Dict."""
|
|
result = {}
|
|
for line in (voices_str or "").splitlines():
|
|
line = line.strip()
|
|
if not line or line.startswith("#"):
|
|
continue
|
|
parts = line.split(":", 3)
|
|
if len(parts) != 4:
|
|
continue
|
|
lang, gender, uuid, name = [p.strip() for p in parts]
|
|
if lang and gender in ("m", "f") and uuid:
|
|
result[(lang, gender)] = {"uuid": uuid, "name": name}
|
|
return result
|
|
|
|
|
|
class CartesiaTTSProvider(TTSProvider):
|
|
def __init__(
|
|
self,
|
|
api_key: str,
|
|
voice_id: str,
|
|
model: str = "sonic-turbo",
|
|
sample_rate: int = 24000,
|
|
voices_str: str = "",
|
|
):
|
|
self.api_key = (api_key or "").strip()
|
|
self.default_voice_id = (voice_id or "").strip()
|
|
self.model = (model or "sonic-turbo").strip()
|
|
self.sample_rate = int(sample_rate)
|
|
self.voice_map = _parse_voice_map(voices_str)
|
|
|
|
def _resolve_voice_id(self, language: str | None, gender_hint: str | None) -> str:
|
|
"""Wählt UUID aus Voice-Map. Fallback-Kette: f→m→any→default_voice_id."""
|
|
lang = (language or "de").lower()
|
|
gender = (gender_hint or "any").strip().lower()
|
|
|
|
# "any" und "f" → weiblich bevorzugt
|
|
if gender in ("any", "f"):
|
|
candidates = ["f", "m"]
|
|
else:
|
|
candidates = ["m", "f"]
|
|
|
|
for g in candidates:
|
|
entry = self.voice_map.get((lang, g))
|
|
if entry:
|
|
return entry["uuid"]
|
|
|
|
return self.default_voice_id
|
|
|
|
async def synthesize(
|
|
self,
|
|
text: str,
|
|
voice: str | None = None,
|
|
audio_format: str = "pcm",
|
|
language: str | None = None,
|
|
) -> bytes:
|
|
if not self.api_key:
|
|
raise ValueError("CARTESIA_API_KEY is empty")
|
|
if not text or not text.strip():
|
|
raise ValueError("TTS input text is empty")
|
|
|
|
# voice ist entweder ein Geschlechts-Code ("m"/"f"/"any") oder eine direkte UUID
|
|
if voice and voice not in _GENDER_CODES:
|
|
voice_id = voice
|
|
else:
|
|
voice_id = self._resolve_voice_id(language, voice)
|
|
|
|
if not voice_id:
|
|
raise ValueError(
|
|
"CARTESIA_VOICE_ID ist nicht konfiguriert — bitte UUID in voice-assistant.toml eintragen"
|
|
)
|
|
|
|
lang_key = (language or "de").lower()
|
|
if lang_key in _UNSUPPORTED_LANGS:
|
|
lang_label = _LANG_NAMES.get(lang_key, lang_key)
|
|
raise RuntimeError(
|
|
f"Cartesia unterstützt {lang_label} ({lang_key.upper()}) noch nicht als TTS-Sprache"
|
|
" — bitte Piper oder einen anderen Provider wählen"
|
|
)
|
|
lang_code = _LANG_MAP.get(lang_key, "de")
|
|
|
|
payload = {
|
|
"model_id": self.model,
|
|
"transcript": text.strip(),
|
|
"voice": {"mode": "id", "id": voice_id},
|
|
"output_format": {
|
|
"container": "raw",
|
|
"encoding": "pcm_s16le",
|
|
"sample_rate": self.sample_rate,
|
|
},
|
|
"language": lang_code,
|
|
}
|
|
|
|
timeout = httpx.Timeout(connect=10.0, read=60.0, write=10.0, pool=10.0)
|
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
|
try:
|
|
response = await client.post(
|
|
_API_URL,
|
|
headers={
|
|
"X-API-Key": self.api_key,
|
|
"Cartesia-Version": _API_VERSION,
|
|
"Content-Type": "application/json",
|
|
},
|
|
json=payload,
|
|
)
|
|
response.raise_for_status()
|
|
except httpx.HTTPStatusError as exc:
|
|
raise RuntimeError(
|
|
f"Cartesia TTS error {exc.response.status_code}: {exc.response.text}"
|
|
) from exc
|
|
except httpx.TimeoutException as exc:
|
|
raise RuntimeError("Cartesia TTS timeout") from exc
|
|
except httpx.HTTPError as exc:
|
|
raise RuntimeError(f"Cartesia TTS transport error: {exc}") from exc
|
|
|
|
if not response.content:
|
|
raise RuntimeError("Cartesia TTS returned empty audio")
|
|
return response.content
|
|
```
|
|
|
|
**Wichtig:** Cartesia hat `sonic-2` und alle anderen älteren Modelle abgekündigt.
|
|
`sonic-turbo` ist das einzige aktuell funktionierende Modell (Stand 2026-06).
|
|
`sonic-turbo` unterstützt: DE, EN, FR, ES, ZH, PT, JA, KO, HI — aber **nicht** AR, IT, RU, PL, NL, SV, TR.
|
|
|
|
---
|
|
|
|
### 1.7 `app/dependencies.py` — Routing-Erweiterungen
|
|
|
|
#### A) `voice_gender` zu `ROUTE_KEYS` hinzufügen
|
|
|
|
```python
|
|
ROUTE_KEYS = (
|
|
"input_endpoint",
|
|
"output_endpoint",
|
|
"stt_provider",
|
|
"llm_provider",
|
|
"tts_provider",
|
|
"language",
|
|
"language_mode",
|
|
"voice_gender", # neu
|
|
)
|
|
```
|
|
|
|
#### B) `LANG_TO_PIPER_VOICE` — `nl` entfernen, neue Sprachen ergänzen
|
|
|
|
```python
|
|
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",
|
|
"pt": "pt_BR-faber-medium",
|
|
"pl": "pl_PL-darkman-medium",
|
|
"ar": "ar_JO-kareem-medium",
|
|
"ru": "ru_RU-irina-medium",
|
|
"zh": "zh_CN-huayan-medium",
|
|
"cmn": "zh_CN-huayan-medium",
|
|
}
|
|
```
|
|
|
|
#### C) `PIPER_VOICE_GENDERED` neu anlegen (nach `LANG_TO_PIPER_VOICE`)
|
|
|
|
ES sharvard ist ein Multi-Speaker-Modell (`speaker_id_map: {'M': 0, 'F': 1}`), deshalb
|
|
`#0`/`#1`-Suffix statt separater Dateien. ZH, PT, AR haben keinen gleichwertigen
|
|
Geschlechtspartner in der piper-voices-Bibliothek — dort greift `LANG_TO_PIPER_VOICE`.
|
|
|
|
```python
|
|
# Geschlechtsspezifische Piper-Stimmen für Sprachen mit m+f Varianten.
|
|
# Präzedenz: PIPER_VOICE_GENDERED > LANG_TO_PIPER_VOICE.
|
|
# ES sharvard ist ein Multi-Speaker-Modell: #0=M, #1=F (speaker_id_map: {'M':0,'F':1})
|
|
PIPER_VOICE_GENDERED: dict[tuple[str, str], str] = {
|
|
("de", "f"): "de_DE-kerstin-low",
|
|
("de", "m"): "de_DE-thorsten-high",
|
|
("en", "f"): "en_US-amy-medium",
|
|
("en", "m"): "en_US-lessac-high",
|
|
("fr", "f"): "fr_FR-siwis-medium",
|
|
("fr", "m"): "fr_FR-tom-medium",
|
|
("es", "f"): "es_ES-sharvard-medium#1",
|
|
("es", "m"): "es_ES-sharvard-medium#0",
|
|
("it", "f"): "it_IT-paola-medium",
|
|
("it", "m"): "it_IT-riccardo-x_low",
|
|
("ru", "f"): "ru_RU-irina-medium",
|
|
("ru", "m"): "ru_RU-ruslan-medium",
|
|
("pl", "f"): "pl_PL-gosia-medium",
|
|
("pl", "m"): "pl_PL-darkman-medium",
|
|
}
|
|
```
|
|
|
|
#### D) `piper_voice_for_language()` ersetzen durch `voice_for_route()`
|
|
|
|
Die bisherige Funktion `piper_voice_for_language()` durch zwei Funktionen ersetzen:
|
|
|
|
```python
|
|
def piper_voice_for_language(tts_provider: str, language: str | None) -> str | None:
|
|
"""Kompatibilitäts-Alias für voice_for_route ohne gender."""
|
|
return voice_for_route(tts_provider, language, "any")
|
|
|
|
|
|
def voice_for_route(
|
|
tts_provider: str, language: str | None, voice_gender: str = "any"
|
|
) -> str | None:
|
|
"""Liefert den voice-Parameter für synthesize() passend zum Provider.
|
|
|
|
- piper: Piper-Stimmmodell-Name; geschlechtsspezifisch wenn Varianten vorhanden
|
|
- cartesia: Geschlechts-Code ("m"/"f"/"any") → CartesiaTTSProvider löst UUID auf
|
|
- andere: None (Provider nutzt seinen eigenen Default)
|
|
"""
|
|
if tts_provider == "piper" and language:
|
|
lang = language.lower()
|
|
gender = (voice_gender or "any").lower()
|
|
if gender in ("m", "f"):
|
|
candidates = [gender, "f" if gender == "m" else "m"]
|
|
else: # "any" → weiblich bevorzugt (konsistent mit Cartesia)
|
|
candidates = ["f", "m"]
|
|
for g in candidates:
|
|
voice = PIPER_VOICE_GENDERED.get((lang, g))
|
|
if voice:
|
|
return voice
|
|
return LANG_TO_PIPER_VOICE.get(lang)
|
|
if tts_provider == "cartesia":
|
|
return voice_gender or "any"
|
|
return None
|
|
```
|
|
|
|
#### E) `voice_for_route` in `api/ws.py`, `api/chat.py`, `api/speak.py` einbinden
|
|
|
|
In allen drei Dateien:
|
|
```python
|
|
from app.dependencies import (
|
|
piper_voice_for_language,
|
|
voice_for_route, # neu importieren
|
|
...
|
|
)
|
|
```
|
|
|
|
Alle Aufrufe von `piper_voice_for_language(route.tts_provider, route.language)` ersetzen durch:
|
|
```python
|
|
voice_for_route(route.tts_provider, route.language, route.voice_gender)
|
|
```
|
|
|
|
#### F) `ResolvedRoute` — `voice_gender` ergänzen
|
|
|
|
```python
|
|
@dataclass
|
|
class ResolvedRoute:
|
|
input_endpoint: str
|
|
output_endpoint: str
|
|
stt_provider: str
|
|
llm_provider: str
|
|
tts_provider: str
|
|
language: str
|
|
language_mode: str = "fix"
|
|
voice_gender: str = "any" # neu
|
|
```
|
|
|
|
Und in `as_dict()`:
|
|
```python
|
|
"voice_gender": self.voice_gender,
|
|
```
|
|
|
|
#### G) CartesiaTTSProvider-Konstruktor — `voices_str` übergeben
|
|
|
|
In `TTS_REGISTRY`:
|
|
```python
|
|
"cartesia": lambda s: CartesiaTTSProvider(
|
|
s.cartesia_api_key, s.cartesia_voice_id, s.cartesia_tts_model, s.tts_sample_rate,
|
|
voices_str=s.cartesia_voices,
|
|
),
|
|
```
|
|
|
|
---
|
|
|
|
### 1.8 `app/config.py` — Cartesia-Felder ergänzen
|
|
|
|
In der `Settings`-Klasse:
|
|
|
|
```python
|
|
cartesia_voice_id: str = "" # UUID aus cartesia.ai/voices (Fallback)
|
|
cartesia_tts_model: str = "sonic-turbo"
|
|
cartesia_voices: str = "" # "lang:gender:uuid:name" je Zeile, # = Kommentar
|
|
```
|
|
|
|
---
|
|
|
|
## Teil 2: Deployment auf dem Server
|
|
|
|
### 2.1 System-Benutzer und Verzeichnisse
|
|
|
|
```bash
|
|
sudo useradd --system --no-create-home --shell /usr/sbin/nologin --gid voice voice
|
|
sudo mkdir -p /var/lib/voice-assistant/hf-cache
|
|
sudo mkdir -p /etc/voice-assistant
|
|
```
|
|
|
|
### 2.2 Repository klonen
|
|
|
|
```bash
|
|
sudo git clone https://kitux.de/forgejo/dschlueter/my_voice_assistant_v3 /opt/voice-assistant
|
|
sudo chown -R voice:voice /opt/voice-assistant /var/lib/voice-assistant
|
|
```
|
|
|
|
### 2.3 Python-Umgebung
|
|
|
|
```bash
|
|
cd /opt/voice-assistant
|
|
sudo -u voice python3 -m venv .venv
|
|
sudo -H -u voice .venv/bin/pip install -e ".[local]"
|
|
sudo -H -u voice .venv/bin/pip install faster-whisper piper-tts
|
|
```
|
|
|
|
### 2.4 Piper-Stimmen herunterladen (10 Sprachen, 16 Dateien)
|
|
|
|
| Dateiname | Sprache | Geschlecht | Qualität |
|
|
|---|---|---|---|
|
|
| `de_DE-thorsten-high` | Deutsch | ♂ | high |
|
|
| `de_DE-kerstin-low` | Deutsch | ♀ | low |
|
|
| `en_US-lessac-high` | Englisch | ♂ | high |
|
|
| `en_US-amy-medium` | Englisch | ♀ | medium |
|
|
| `fr_FR-siwis-medium` | Französisch | ♀ | medium |
|
|
| `fr_FR-tom-medium` | Französisch | ♂ | medium |
|
|
| `es_ES-sharvard-medium` | Spanisch | ♂+♀ (Multi-Speaker: #0=M, #1=F) | medium |
|
|
| `it_IT-paola-medium` | Italienisch | ♀ | medium |
|
|
| `it_IT-riccardo-x_low` | Italienisch | ♂ | x_low |
|
|
| `pt_BR-faber-medium` | Portugiesisch (BR) | ♂ (kein ♀ verfügbar) | medium |
|
|
| `pl_PL-gosia-medium` | Polnisch | ♀ | medium |
|
|
| `pl_PL-darkman-medium` | Polnisch | ♂ | medium |
|
|
| `ar_JO-kareem-medium` | Arabisch (JO) | ♂ (kein ♀ verfügbar) | medium |
|
|
| `ru_RU-irina-medium` | Russisch | ♀ | medium |
|
|
| `ru_RU-ruslan-medium` | Russisch | ♂ | medium |
|
|
| `zh_CN-huayan-medium` | Chinesisch | ♀ (kein ♂ verfügbar) | medium |
|
|
|
|
Das Skript `scripts/download_voices.py` übernimmt den Download automatisch:
|
|
|
|
```bash
|
|
cd /opt/voice-assistant
|
|
sudo -u voice python scripts/download_voices.py
|
|
```
|
|
|
|
Es prüft, welche Dateien bereits vorhanden sind, und lädt nur fehlende nach.
|
|
Mit `--force` werden alle Stimmen neu heruntergeladen.
|
|
|
|
Manuell (Fallback ohne Python):
|
|
|
|
```bash
|
|
BASE="https://huggingface.co/rhasspy/piper-voices/resolve/v1.0.0"
|
|
DIR="/opt/voice-assistant/config/voices"
|
|
|
|
declare -A VOICES=(
|
|
["de_DE-thorsten-high"]="de/de_DE/thorsten/high"
|
|
["de_DE-kerstin-low"]="de/de_DE/kerstin/low"
|
|
["en_US-lessac-high"]="en/en_US/lessac/high"
|
|
["en_US-amy-medium"]="en/en_US/amy/medium"
|
|
["fr_FR-siwis-medium"]="fr/fr_FR/siwis/medium"
|
|
["fr_FR-tom-medium"]="fr/fr_FR/tom/medium"
|
|
["es_ES-sharvard-medium"]="es/es_ES/sharvard/medium"
|
|
["it_IT-paola-medium"]="it/it_IT/paola/medium"
|
|
["it_IT-riccardo-x_low"]="it/it_IT/riccardo/x_low"
|
|
["pt_BR-faber-medium"]="pt/pt_BR/faber/medium"
|
|
["pl_PL-gosia-medium"]="pl/pl_PL/gosia/medium"
|
|
["pl_PL-darkman-medium"]="pl/pl_PL/darkman/medium"
|
|
["ar_JO-kareem-medium"]="ar/ar_JO/kareem/medium"
|
|
["ru_RU-irina-medium"]="ru/ru_RU/irina/medium"
|
|
["ru_RU-ruslan-medium"]="ru/ru_RU/ruslan/medium"
|
|
["zh_CN-huayan-medium"]="zh/zh_CN/huayan/medium"
|
|
)
|
|
|
|
for NAME in "${!VOICES[@]}"; do
|
|
PATH_PART="${VOICES[$NAME]}"
|
|
sudo -u voice wget -q "$BASE/$PATH_PART/$NAME.onnx" -O "$DIR/$NAME.onnx" &
|
|
sudo -u voice wget -q "$BASE/$PATH_PART/$NAME.onnx.json" -O "$DIR/$NAME.onnx.json" &
|
|
done
|
|
wait
|
|
```
|
|
|
|
### 2.5 Umgebungsvariablen `/etc/voice-assistant/voice-assistant.env`
|
|
|
|
```ini
|
|
# Voice Assistant Gateway — jamulix.de
|
|
HOST=127.0.0.1
|
|
PORT=8003
|
|
OPENROUTER_API_KEY=<dein-openrouter-key>
|
|
CARTESIA_API_KEY=<dein-cartesia-key>
|
|
VA_PROFILE=jamulix
|
|
VA_CONFIG_FILE=/opt/voice-assistant/config/voice-assistant.toml
|
|
AUTH_ENABLED=true
|
|
ADMIN_API_KEY=<zufälliger-hex-key: openssl rand -hex 24>
|
|
TRUSTED_AUTH_HEADER=Remote-User
|
|
TRUSTED_PROXY_IPS=127.0.0.1
|
|
ADMIN_USERS=dschlueter
|
|
DB_PATH=/var/lib/voice-assistant/voice-assistant.db
|
|
HF_HOME=/var/lib/voice-assistant/hf-cache
|
|
SSO_LOGOUT_URL=https://auth.jamulix.de/?action=logout
|
|
|
|
# Notruf-Benachrichtigung per E-Mail (Testphase). OHNE SMTP_HOST kein Versand.
|
|
# ACHTUNG: keine Inline-Kommentare hinter den Werten — der Loader nimmt die ganze
|
|
# Zeile als Wert. SMTP_FROM muss dem SMTP_USER gehören (sonst 553 Sender rejected).
|
|
EMERGENCY_CONTACT_EMAIL=dieter.schlueter@linix.de
|
|
SMTP_HOST=
|
|
SMTP_PORT=587
|
|
SMTP_USER=
|
|
SMTP_PASSWORD=
|
|
SMTP_FROM=
|
|
SMTP_STARTTLS=true
|
|
```
|
|
|
|
```bash
|
|
sudo chmod 640 /etc/voice-assistant/voice-assistant.env
|
|
sudo chown root:voice /etc/voice-assistant/voice-assistant.env
|
|
```
|
|
|
|
### 2.6 TOML-Konfiguration `/opt/voice-assistant/config/voice-assistant.toml`
|
|
|
|
```toml
|
|
# Konfiguration für jamulix.de
|
|
# Profil: jamulix — STT lokal (faster-whisper), LLM OpenRouter, TTS Piper/Cartesia
|
|
|
|
[defaults]
|
|
default_language = "de"
|
|
default_input_endpoint = "local-default"
|
|
default_output_endpoint = "local-default"
|
|
|
|
openrouter_llm_model = "mistralai/mistral-small-3.2-24b-instruct"
|
|
llm_fallback = "openrouter"
|
|
|
|
# WICHTIG: Piper als TTS-Fallback. Greift, wenn Cartesia eine Sprache nicht kann
|
|
# (IT/RU/AR/PL). Ohne diese Zeile schlägt TTS für diese Sprachen still fehl,
|
|
# statt auf die lokale Piper-Stimme auszuweichen.
|
|
tts_fallback = "piper"
|
|
|
|
openrouter_tts_model = "openai/gpt-audio-mini"
|
|
openrouter_tts_voice = "alloy"
|
|
openrouter_stt_model = "openai/whisper-large-v3"
|
|
|
|
piper_voices_dir = "/opt/voice-assistant/config/voices"
|
|
piper_voice = "de_DE-thorsten-high"
|
|
|
|
# Cartesia Stimmen: Format lang:gender:uuid:name (# = Kommentar / Alternative)
|
|
# Modell: sonic-turbo (einziges aktuell verfügbares Modell, Stand 2026-06)
|
|
# Unterstützte Sprachen: DE, EN, FR, ES, ZH, PT — NICHT IT, RU, AR, PL
|
|
cartesia_voices = """
|
|
de:m:b7187e84-fe22-4344-ba4a-bc013fcb533e:Sebastian
|
|
de:f:38aabb6a-f52b-4fb0-a3d1-988518f4dc06:Alina
|
|
en:m:4f7f1324-1853-48a6-b294-4e78e8036a83:Caspar
|
|
en:f:62ae83ad-4f6a-430b-af41-a9bede9286ca:Gemma
|
|
fr:m:0418348a-0ca2-4e90-9986-800fb8b3bbc0:Antoine
|
|
fr:f:7c58f4a4-a72c-42fa-a503-41b9408820f3:Amélie
|
|
es:m:2695b6b5-5543-4be1-96d9-3967fb5e7fec:Augustin (mx)
|
|
# es:m:13ff5deb-2591-42ad-a356-63a04e524411:Marcos (cast)
|
|
es:f:5c5ad5e7-1020-476b-8b91-fdcbe9cc313c:Daniela (mx)
|
|
# es:f:9d8c6b2e-0a23-4a15-ae1b-121d5b5af417:Nuria (cast)
|
|
pt:m:b603811e-54c2-4a0a-8854-09eab9ffa63f:Bruno
|
|
pt:f:c9611be8-aae9-4a93-bb1c-98dd6b7d52a4:Isabella
|
|
# it:m:408daed0-c597-4c27-aae8-fa0497d644bf:Matteo — sonic-turbo unterstützt IT nicht
|
|
# it:f:d718e944-b313-4998-b011-d1cc078d4ef3:Liv — sonic-turbo unterstützt IT nicht
|
|
# ar:m:40f9b5d1-bc79-43a6-b5cc-1c692b3b40d2:Zain — Cartesia unterstützt AR nicht
|
|
# ar:f:731ace69-ee17-41bc-8c6f-665c9f1db95c:Fatima — Cartesia unterstützt AR nicht
|
|
# ru:m:1e4176b1-3db9-44d6-a601-4fe68b041942:Sergei — sonic-turbo unterstützt RU nicht
|
|
# ru:f:064b17af-d36b-4bfb-b003-be07dba1b649:Tatiana — sonic-turbo unterstützt RU nicht
|
|
# pl:m — sonic-turbo unterstützt PL nicht (Piper: pl_PL-darkman-medium)
|
|
# pl:f — sonic-turbo unterstützt PL nicht (Piper: pl_PL-gosia-medium)
|
|
zh:m:eda5bbff-1ff1-4886-8ef1-4e69a77640a0:Kai
|
|
zh:f:7a5d4663-88ae-47b7-808e-8f9b9ee4127b:Hua
|
|
"""
|
|
|
|
[profiles.jamulix]
|
|
default_stt_provider = "faster-whisper"
|
|
default_llm_provider = "openrouter"
|
|
default_tts_provider = "piper"
|
|
|
|
[profiles.cloud]
|
|
default_stt_provider = "openrouter"
|
|
default_llm_provider = "openrouter"
|
|
default_tts_provider = "openrouter"
|
|
```
|
|
|
|
```bash
|
|
sudo chown voice:voice /opt/voice-assistant/config/voice-assistant.toml
|
|
```
|
|
|
|
### 2.7 systemd-Service installieren
|
|
|
|
Die Datei `deploy/voice-assistant.service` aus dem Repository verwenden:
|
|
|
|
```bash
|
|
sudo cp /opt/voice-assistant/deploy/voice-assistant.service \
|
|
/etc/systemd/system/voice-assistant.service
|
|
sudo systemctl daemon-reload
|
|
sudo systemctl enable --now voice-assistant
|
|
```
|
|
|
|
#### 2.7.1 Admin-Rechte: Log lesen & Gateway neu starten
|
|
|
|
Der Dienst läuft als **System**-Dienst unter dem User `voice`. Damit das Admin-Panel
|
|
(a) den Live-Log anzeigen und (b) das Gateway neu starten kann, braucht `voice` zwei
|
|
eng begrenzte Rechte. **Ohne diese beiden Schritte bleiben der Log-Tab leer und der
|
|
Neustart-Button wirkungslos.**
|
|
|
|
```bash
|
|
# (a) Log lesen: voice darf das System-Journal lesen
|
|
sudo usermod -aG systemd-journal voice
|
|
|
|
# (b) Neustart: voice darf AUSSCHLIESSLICH den eigenen Dienst neu starten
|
|
sudo tee /etc/sudoers.d/voice-assistant > /dev/null <<'SUDO'
|
|
# Erlaubt dem voice-User ausschließlich den Neustart des eigenen Dienstes
|
|
# (für den "Gateway neu starten"-Button im Admin-Panel). Sonst nichts.
|
|
voice ALL=(root) NOPASSWD: /usr/bin/systemctl restart voice-assistant.service
|
|
SUDO
|
|
sudo chmod 0440 /etc/sudoers.d/voice-assistant
|
|
sudo visudo -cf /etc/sudoers.d/voice-assistant # validieren
|
|
|
|
# Dienst neu starten, damit der Prozess die neue Gruppe übernimmt
|
|
sudo systemctl restart voice-assistant
|
|
```
|
|
|
|
Rückgängig: `sudo gpasswd -d voice systemd-journal` bzw. `sudo rm /etc/sudoers.d/voice-assistant`.
|
|
|
|
### 2.8 nginx-Konfiguration `/etc/nginx/sites-available/voice.jamulix.de`
|
|
|
|
**Wichtig:** `X-Forwarded-For` muss leer gesetzt werden (`""`), da uvicorn
|
|
sonst die echte Browser-IP als Client-Host interpretiert und der
|
|
`TRUSTED_PROXY_IPS=127.0.0.1`-Check fehlschlägt.
|
|
|
|
```nginx
|
|
server {
|
|
listen 80;
|
|
listen [::]:80;
|
|
server_name voice.jamulix.de;
|
|
return 301 https://$host$request_uri;
|
|
}
|
|
|
|
server {
|
|
listen 443 ssl;
|
|
listen [::]:443 ssl;
|
|
server_name voice.jamulix.de;
|
|
server_tokens off;
|
|
|
|
ssl_certificate /etc/letsencrypt/live/voice.jamulix.de/fullchain.pem;
|
|
ssl_certificate_key /etc/letsencrypt/live/voice.jamulix.de/privkey.pem;
|
|
include /etc/letsencrypt/options-ssl-nginx.conf;
|
|
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
|
|
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
|
add_header X-Content-Type-Options "nosniff" always;
|
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
|
add_header X-XSS-Protection "1; mode=block" always;
|
|
|
|
# Authelia Auth-Check (intern)
|
|
location = /authelia-check {
|
|
internal;
|
|
proxy_pass http://127.0.0.1:9091/api/verify;
|
|
proxy_pass_request_body off;
|
|
proxy_set_header Content-Length "";
|
|
proxy_set_header X-Original-URL $scheme://$http_host$request_uri;
|
|
proxy_set_header X-Forwarded-Proto $scheme;
|
|
proxy_set_header X-Forwarded-Host $http_host;
|
|
proxy_set_header X-Forwarded-Uri $request_uri;
|
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
proxy_set_header X-Forwarded-Method $request_method;
|
|
}
|
|
|
|
# Health-Check (öffentlich)
|
|
location = /health {
|
|
proxy_pass http://127.0.0.1:8003/health;
|
|
}
|
|
|
|
# WebSocket-Routen /ws/chat und /ws/voice
|
|
# Admin-Live-Log (WebSocket) — hinter Authelia, mit WS-Upgrade.
|
|
location = /api/admin/log {
|
|
auth_request /authelia-check;
|
|
auth_request_set $user $upstream_http_remote_user;
|
|
error_page 401 =302 https://auth.jamulix.de/?rd=$scheme://$http_host$request_uri;
|
|
|
|
proxy_pass http://127.0.0.1:8003;
|
|
proxy_http_version 1.1;
|
|
proxy_set_header Upgrade $http_upgrade;
|
|
proxy_set_header Connection "upgrade";
|
|
proxy_set_header Host $host;
|
|
proxy_set_header X-Forwarded-For "";
|
|
proxy_set_header Remote-User $user;
|
|
proxy_read_timeout 300s;
|
|
proxy_send_timeout 300s;
|
|
}
|
|
|
|
# Admin-API — ZUSÄTZLICH hinter Authelia (Doppelschutz). Identität via Remote-User.
|
|
location /api/admin/ {
|
|
auth_request /authelia-check;
|
|
auth_request_set $user $upstream_http_remote_user;
|
|
error_page 401 =302 https://auth.jamulix.de/?rd=$scheme://$http_host$request_uri;
|
|
|
|
proxy_pass http://127.0.0.1:8003;
|
|
proxy_http_version 1.1;
|
|
proxy_set_header Host $host;
|
|
proxy_set_header X-Real-IP $remote_addr;
|
|
proxy_set_header X-Forwarded-For "";
|
|
proxy_set_header X-Forwarded-Proto $scheme;
|
|
proxy_set_header Remote-User $user;
|
|
}
|
|
|
|
# Sprach-/Chat-WebSockets — KEIN Authelia. Auth via Token-Cookie (Senioren-Link).
|
|
# Remote-User HART LEEREN: sonst könnte ein Client per Header eine Identität
|
|
# vortäuschen (die App vertraut Remote-User von der Proxy-IP 127.0.0.1).
|
|
location /ws/ {
|
|
proxy_pass http://127.0.0.1:8003;
|
|
proxy_http_version 1.1;
|
|
proxy_set_header Upgrade $http_upgrade;
|
|
proxy_set_header Connection "upgrade";
|
|
proxy_set_header Host $host;
|
|
proxy_set_header X-Forwarded-For "";
|
|
proxy_set_header Remote-User "";
|
|
proxy_read_timeout 300s;
|
|
proxy_send_timeout 300s;
|
|
}
|
|
|
|
# App (Senioren) — KEIN Authelia. Auth via Token-Cookie / Ein-Klick-Link
|
|
# (https://voice.jamulix.de/?k=<token>). Remote-User HART LEEREN (Anti-Spoofing).
|
|
location / {
|
|
proxy_pass http://127.0.0.1:8003;
|
|
proxy_http_version 1.1;
|
|
proxy_set_header Host $host;
|
|
proxy_set_header X-Real-IP $remote_addr;
|
|
proxy_set_header X-Forwarded-For "";
|
|
proxy_set_header X-Forwarded-Proto $scheme;
|
|
proxy_set_header Remote-User "";
|
|
}
|
|
}
|
|
```
|
|
|
|
```bash
|
|
sudo ln -sf /etc/nginx/sites-available/voice.jamulix.de \
|
|
/etc/nginx/sites-enabled/voice.jamulix.de
|
|
sudo certbot --nginx -d voice.jamulix.de --non-interactive --agree-tos \
|
|
-m dieter.schlueter@linix.de
|
|
sudo systemctl reload nginx
|
|
```
|
|
|
|
---
|
|
|
|
### 2.9 Notruf-Bridge (optional, SMS/Anruf via seven.io)
|
|
|
|
Damit der Notruf zusätzlich zur E-Mail **SMS und Sprachanruf** auslöst, läuft ein
|
|
kleiner Webhook-Empfänger (`emergency_bridge/`), der über
|
|
[seven.io](https://www.seven.io) sendet. Vollständige Anleitung:
|
|
[`emergency_bridge/README.md`](emergency_bridge/README.md). Kurz:
|
|
|
|
```bash
|
|
# 0. Library seven-send in die venv (kapselt die seven.io-Aufrufe)
|
|
sudo -u voice /opt/voice-assistant/.venv/bin/pip install \
|
|
git+https://kitux.de/forgejo/dschlueter/seven_send.git
|
|
|
|
# 1. Konfig (API-Key + BRIDGE_TOKEN setzen)
|
|
sudo cp /opt/voice-assistant/emergency_bridge/emergency-bridge.env.example \
|
|
/etc/voice-assistant/emergency-bridge.env
|
|
sudo chown voice:voice /etc/voice-assistant/emergency-bridge.env
|
|
sudo chmod 600 /etc/voice-assistant/emergency-bridge.env
|
|
sudo editor /etc/voice-assistant/emergency-bridge.env
|
|
|
|
# 2. Service
|
|
sudo cp /opt/voice-assistant/emergency_bridge/emergency-bridge.service \
|
|
/etc/systemd/system/
|
|
sudo systemctl daemon-reload
|
|
sudo systemctl enable --now emergency-bridge
|
|
curl -s localhost:8090/health # -> {"status":"ok","configured":true}
|
|
```
|
|
|
|
Anschließend in `/etc/voice-assistant/voice-assistant.env` ergänzen und
|
|
`sudo systemctl restart voice-assistant`:
|
|
|
|
```bash
|
|
EMERGENCY_WEBHOOK_URL=http://127.0.0.1:8090/notruf
|
|
EMERGENCY_WEBHOOK_TOKEN=<derselbe Wert wie BRIDGE_TOKEN>
|
|
EMERGENCY_CONTACT_PHONE=+49XXXXXXXXXX
|
|
```
|
|
|
|
Ohne diese Schritte bleibt der Webhook-Kanal inaktiv; der Notruf nutzt dann nur E-Mail.
|
|
|
|
---
|
|
|
|
## Teil 3: Bekannte Fallstricke
|
|
|
|
| Problem | Ursache | Fix |
|
|
|---|---|---|
|
|
| `PermissionError: /home/voice` | Kein Home-Verzeichnis für `voice`-User | `HF_HOME=/var/lib/voice-assistant/hf-cache` in env setzen |
|
|
| `{"detail":"Authentication required"}` | uvicorn liest `X-Forwarded-For` als Client-IP | `proxy_set_header X-Forwarded-For ""` in nginx |
|
|
| WebSocket 404 | nginx hatte keine `/ws/`-Location mit Upgrade-Headern | Separate `location /ws/` mit `Upgrade`/`Connection`-Headern |
|
|
| Piper findet Stimme nicht | `piper_voices_dir` zeigt auf falsches Verzeichnis | In TOML auf `/opt/voice-assistant/config/voices` setzen |
|
|
| OpenRouter Rate-Limit 429 | Free-Tier-Modelle sind gemeinsam genutzt | Bezahlmodell verwenden (`mistral-small-24b`) |
|
|
| Authelia `/api/authz/forward-auth` gibt 400 | Falscher Endpoint-Typ für nginx auth_request | `/api/verify` mit `X-Original-URL`-Header verwenden |
|
|
| Cartesia `Model sunsetted` | `sonic-2` und alle Datums-Varianten sind abgekündigt | Modell auf `sonic-turbo` setzen (in `config.py` und TOML) |
|
|
| Cartesia 400 bei IT/RU/AR/PL | `sonic-turbo` unterstützt diese Sprachen nicht | Für diese Sprachen Piper verwenden; Cartesia gibt klare Fehlermeldung |
|
|
| Gender-Button: weiße Schrift auf weißem Grund (Tag-Modus) | Tailwind JIT generiert `text-slate-700` nur, wenn die Klasse im HTML-Quelltext vorkommt — per JS dynamisch hinzugefügte Klassen werden nicht gescannt | `text-slate-700` in die Button-Basis-Klasse im HTML eintragen; `applyGenderChoice()` setzt sie explizit für den inaktiven Zustand |
|
|
| `PiperVoice.synthesize()` kennt kein `speaker_id`-Argument | Die piper-Python-Library erwartet `speaker_id` in `SynthesisConfig`, nicht als direkten Parameter | `from piper.config import SynthesisConfig; voice.synthesize(text, syn_config=SynthesisConfig(speaker_id=N))` |
|
|
|
|
---
|
|
|
|
## Teil 4: Verifikation
|
|
|
|
> Zuletzt vollständig getestet: 2026-06-25 — pytest 213 passed ✓, Piper alle 10 Sprachen ✓, Cartesia DE/EN/FR/ES/PT/ZH ✓
|
|
|
|
### 4.0 Automatische Test-Suite (PFLICHT vor jedem Deploy)
|
|
|
|
Die pytest-Suite läuft offline und deterministisch (keine echten Netz-/API-Aufrufe,
|
|
keine Stimm-Modelle nötig). Vor jedem Deploy ausführen — muss **grün** sein:
|
|
|
|
```bash
|
|
cd /opt/voice-assistant
|
|
sudo -u voice .venv/bin/pip install -q -e '.[test]' # einmalig: pytest installieren
|
|
sudo -u voice .venv/bin/python -m pytest -q
|
|
# Erwartet: "213 passed"
|
|
```
|
|
|
|
**Automatischer Schutz vor jedem Push (empfohlen):** Ein versionierter
|
|
`pre-push`-Hook lässt die Suite vor jedem `git push` laufen und bricht bei roten
|
|
Tests ab. Nach einem frischen Clone einmalig installieren:
|
|
|
|
```bash
|
|
cd /opt/voice-assistant
|
|
sudo -u voice bash scripts/install-hooks.sh
|
|
```
|
|
|
|
Der Hook liegt unter `scripts/git-hooks/pre-push` (versioniert); `.git/hooks/`
|
|
selbst wird von Git nicht versioniert, daher der Installer. Im Notfall umgehen:
|
|
`git push --no-verify`.
|
|
|
|
Abdeckung u. a.: Stimm-/Geschlechts-Routing (`voice_for_route`), Piper Multi-Speaker
|
|
(`#speaker_id`) und Gender-Code-Auflösung, Cartesia Voice-Map + nicht unterstützte
|
|
Sprachen, Cartesia→Piper-Fallback. Die Fixture in `tests/conftest.py` neutralisiert
|
|
die Deployment-Fallback-Ketten (`*_fallback`), damit Tests hermetisch bleiben.
|
|
|
|
### 4.1 Manuelle Smoke-Checks
|
|
|
|
```bash
|
|
# Service läuft?
|
|
sudo systemctl status voice-assistant
|
|
|
|
# Health-Check
|
|
curl -s https://voice.jamulix.de/health
|
|
|
|
# Capability-Flags korrekt?
|
|
curl -s http://127.0.0.1:8003/api/config | python3 -c "
|
|
import json,sys; d=json.load(sys.stdin)
|
|
print('chatterbox_reachable: ', d['available']['chatterbox_reachable'])
|
|
print('openrouter_tts_available:', d['available']['openrouter_tts_available'])
|
|
"
|
|
|
|
# WebSocket erreichbar?
|
|
curl -s --no-buffer \
|
|
-H "Upgrade: websocket" -H "Connection: Upgrade" \
|
|
-H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
|
|
-H "Sec-WebSocket-Version: 13" -H "Remote-User: dschlueter" \
|
|
"http://127.0.0.1:8003/ws/voice?session_id=test" | head -1
|
|
# Erwartet: HTTP/1.1 101 Switching Protocols
|
|
|
|
# Piper — alle Stimmen testen (DE/EN/FR/ES/IT/RU/PL je ♂+♀; AR/ZH/PT nur eine Stimme)
|
|
cd /opt/voice-assistant && sudo -u voice .venv/bin/python - << 'EOF'
|
|
import asyncio
|
|
from app.providers.tts.piper import PiperTTSProvider
|
|
from app.dependencies import voice_for_route
|
|
from app.config import settings as cfg
|
|
p = PiperTTSProvider(cfg.piper_bin, cfg.piper_voices_dir, cfg.piper_voice, cfg.tts_sample_rate)
|
|
async def run():
|
|
for lang, gender, text in [
|
|
("de","f","Guten Tag!"), ("de","m","Guten Tag!"),
|
|
("en","f","Hello!"), ("en","m","Hello!"),
|
|
("fr","f","Bonjour!"), ("fr","m","Bonjour!"),
|
|
("es","f","¡Hola!"), ("es","m","¡Hola!"),
|
|
("it","f","Ciao!"), ("it","m","Ciao!"),
|
|
("ru","f","Привет!"), ("ru","m","Привет!"),
|
|
("pl","f","Cześć!"), ("pl","m","Cześć!"),
|
|
("ar","f","مرحباً!"), ("ar","m","مرحباً!"), # → immer kareem (kein ♀)
|
|
("pt","f","Bom dia!"), ("pt","m","Bom dia!"), # → immer faber (kein ♀)
|
|
("zh","f","你好!"), ("zh","m","你好!"), # → immer huayan (kein ♂)
|
|
]:
|
|
voice = voice_for_route("piper", lang, gender)
|
|
audio = await p.synthesize(text, voice=voice, audio_format="pcm")
|
|
print(f"{'✓' if len(audio)>5000 else '✗'} {lang} {gender} {voice}")
|
|
asyncio.run(run())
|
|
EOF
|
|
|
|
# Cartesia — alle unterstützten Sprachen je ♂+♀ testen
|
|
# Hinweis: env-Datei muss explizit geladen werden (wird sonst nur vom systemd-Service eingelesen)
|
|
cd /opt/voice-assistant && sudo -u voice env $(sudo cat /etc/voice-assistant/voice-assistant.env | grep -v '^#' | xargs) .venv/bin/python - << 'EOF'
|
|
import asyncio
|
|
from app.providers.tts.cartesia import CartesiaTTSProvider
|
|
from app.config import settings as cfg
|
|
p = CartesiaTTSProvider(cfg.cartesia_api_key, cfg.cartesia_voice_id, cfg.cartesia_tts_model,
|
|
cfg.tts_sample_rate, voices_str=cfg.cartesia_voices)
|
|
async def run():
|
|
for lang, gender, text in [
|
|
("de","f","Guten Tag!"), ("de","m","Guten Tag!"),
|
|
("en","f","Hello!"), ("en","m","Hello!"),
|
|
("fr","f","Bonjour!"), ("fr","m","Bonjour!"),
|
|
("es","f","¡Hola!"), ("es","m","¡Hola!"),
|
|
("pt","f","Bom dia!"), ("pt","m","Bom dia!"),
|
|
("zh","f","你好!"), ("zh","m","你好!"),
|
|
]:
|
|
vid = p._resolve_voice_id(lang, gender)
|
|
name = next((v["name"] for (l,g),v in p.voice_map.items() if v["uuid"]==vid), "?")
|
|
audio = await p.synthesize(text, voice=gender, language=lang)
|
|
print(f"{'✓' if len(audio)>5000 else '✗'} {lang} {gender} {name}")
|
|
asyncio.run(run())
|
|
EOF
|
|
```
|
|
|
|
---
|
|
|
|
## Teil 5: Authelia — E-Mail-Versand einrichten
|
|
|
|
Authelia schreibt Benachrichtigungen (TOTP-Registrierung, Passwort-Reset) standardmäßig
|
|
in eine Log-Datei. Für echten E-Mail-Versand über `linix.de` als SMTP-Relay:
|
|
|
|
### 5.1 SMTP-Passwort in `/etc/authelia/secrets.env` eintragen
|
|
|
|
```bash
|
|
echo 'AUTHELIA_NOTIFIER_SMTP_PASSWORD=<passwort-für-valinix@linix.de>' \
|
|
| sudo tee -a /etc/authelia/secrets.env > /dev/null
|
|
```
|
|
|
|
Die Datei wird bereits vom systemd-Service als `EnvironmentFile` geladen —
|
|
Authelia erkennt `AUTHELIA_NOTIFIER_SMTP_PASSWORD` automatisch.
|
|
|
|
### 5.2 Notifier in `/etc/authelia/configuration.yml` umstellen
|
|
|
|
Den Block `notifier:` ersetzen (war `filesystem`, jetzt `smtp`):
|
|
|
|
```yaml
|
|
notifier:
|
|
smtp:
|
|
address: 'submission://linix.de:587'
|
|
username: valinix@linix.de
|
|
sender: "Voice Assistant <valinix@linix.de>"
|
|
tls:
|
|
skip_verify: false
|
|
```
|
|
|
|
```bash
|
|
sudo systemctl restart authelia
|
|
```
|
|
|
|
**Hinweis:** Port 587 mit STARTTLS (TLS 1.3). Port 465 ist auf linix.de nicht offen.
|
|
|
|
---
|
|
|
|
## Teil 6: Authelia — Benutzerverwaltung
|
|
|
|
### 6.1 Neuen User anlegen
|
|
|
|
**Schritt 1 — Argon2id-Hash erzeugen:**
|
|
```bash
|
|
sudo authelia crypto hash generate argon2 --password 'GewuenschtePasswort'
|
|
# Ausgabe: Digest: $argon2id$v=19$...
|
|
```
|
|
|
|
**Schritt 2 — Eintrag in `/etc/authelia/users_database.yml` hinzufügen:**
|
|
```yaml
|
|
username:
|
|
password: $argon2id$v=19$...<hash>...
|
|
displayname: Vorname Nachname
|
|
email: name@example.com
|
|
groups: []
|
|
disabled: false
|
|
```
|
|
Normale User brauchen keine Gruppe (`groups: []`). Nur `admins` hat Sonderbedeutung.
|
|
|
|
**Schritt 3:**
|
|
```bash
|
|
sudo systemctl restart authelia
|
|
```
|
|
|
|
Der neue User erhält beim ersten Login den TOTP-Einrichtungslink per Mail.
|
|
|
|
### 6.2 Username nachträglich umbenennen
|
|
|
|
Alle drei Stellen müssen konsistent geändert werden:
|
|
|
|
```bash
|
|
# 1. YAML
|
|
sudo sed -i 's/^ altername:$/ neuername:/' /etc/authelia/users_database.yml
|
|
|
|
# 2. SQLite — alle relevanten Tabellen
|
|
for TABLE in totp_configurations authentication_logs user_opaque_identifier \
|
|
user_preferences identity_verification banned_user; do
|
|
sudo sqlite3 /var/lib/authelia/db.sqlite3 \
|
|
"UPDATE $TABLE SET username='neuername' WHERE username='altername';" 2>/dev/null
|
|
done
|
|
|
|
# 3. Voice-Assistant-Env (falls ADMIN_USERS betroffen)
|
|
sudo sed -i 's/altername/neuername/g' /etc/voice-assistant/voice-assistant.env
|
|
|
|
sudo systemctl restart authelia voice-assistant
|
|
```
|
|
|
|
Das TOTP bleibt erhalten — kein Re-Enroll nötig.
|
|
|
|
### 6.3 TOTP eines Users zurücksetzen (Handy verloren o.ä.)
|
|
|
|
```bash
|
|
sudo sqlite3 /var/lib/authelia/db.sqlite3 \
|
|
"DELETE FROM totp_configurations WHERE username='username';"
|
|
sudo systemctl restart authelia
|
|
```
|
|
User erhält beim nächsten Login einen neuen Registrierungslink per Mail.
|
|
|
|
---
|
|
|
|
## Teil 7: Initiale Authelia-User auf jamulix.de
|
|
|
|
| Username | Displayname | E-Mail | Gruppe |
|
|
|---|---|---|---|
|
|
| `dschlueter` | Dieter Schlüter | dieter.schlueter@linix.de | admins |
|
|
| `valinix` | VA Linix | valinix@linix.de | — |
|
|
|
|
---
|
|
|
|
## Teil 8: Frontend-Fixes
|
|
|
|
### 8.1 Standard-Sprache DE statt Flex
|
|
|
|
Neue User haben leere Einstellungen `{}` in der DB — das Frontend zeigt dann
|
|
„Flex" als Fallback. Zwei Fixes:
|
|
|
|
**A) JS-Fallback in `app/web/app.js` ändern** (Zeile ~286):
|
|
```javascript
|
|
// Vorher:
|
|
: (prefs.language || "flex");
|
|
// Nachher:
|
|
: (prefs.language || "de");
|
|
```
|
|
|
|
**B) Bestehende User ohne Einstellungen in der DB korrigieren:**
|
|
```bash
|
|
sudo sqlite3 /var/lib/voice-assistant/voice-assistant.db \
|
|
"UPDATE users SET prefs_json='{\"language\": \"de\", \"language_mode\": \"fix\", \"tts_provider\": \"piper\"}' WHERE prefs_json='{}';"
|
|
```
|
|
|
|
---
|
|
|
|
## Teil 9: OpenRouter LLM — Retry-Logik
|
|
|
|
Free-Tier-Modelle auf OpenRouter sind oft rate-limited (429) oder temporär nicht
|
|
verfügbar (404). Die Datei `app/providers/llm/openrouter.py` wurde mit einer
|
|
Retry-Logik ausgestattet.
|
|
|
|
### 9.1 Vollständiger Ersatz von `app/providers/llm/openrouter.py`
|
|
|
|
```python
|
|
import asyncio
|
|
import time
|
|
from collections.abc import AsyncIterator
|
|
|
|
import httpx
|
|
|
|
from app.providers.llm.base import (
|
|
LLMProvider,
|
|
lang_instruction,
|
|
with_lang_reminder,
|
|
sse_delta,
|
|
)
|
|
|
|
_RETRY_STATUS = {404, 429, 500, 502, 503}
|
|
|
|
|
|
def _retry_wait(status: int, headers: httpx.Headers, attempt: int, body: str = "") -> float:
|
|
"""Wartezeit: JSON retry_after_seconds > Retry-After > X-RateLimit-Reset > exponentiell."""
|
|
if status == 429:
|
|
if body:
|
|
try:
|
|
import json as _json
|
|
d = _json.loads(body)
|
|
secs = d.get("error", {}).get("metadata", {}).get("retry_after_seconds")
|
|
if secs:
|
|
return max(1.0, min(float(secs) + 0.5, 120.0))
|
|
except Exception:
|
|
pass
|
|
retry_after = headers.get("Retry-After")
|
|
if retry_after:
|
|
try:
|
|
return max(1.0, min(float(retry_after) + 0.5, 120.0))
|
|
except ValueError:
|
|
pass
|
|
reset_ms = headers.get("X-RateLimit-Reset")
|
|
if reset_ms:
|
|
try:
|
|
wait = (int(reset_ms) / 1000.0) - time.time() + 0.5
|
|
return max(1.0, min(wait, 120.0))
|
|
except ValueError:
|
|
pass
|
|
return 30.0
|
|
return 2.0 * attempt
|
|
|
|
|
|
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, max_retries: int = 5):
|
|
self.api_key = (api_key or "").strip()
|
|
self.model = (model or "").strip()
|
|
self.max_retries = max(1, max_retries)
|
|
|
|
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)
|
|
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)
|
|
last_error: Exception | None = None
|
|
|
|
for attempt in range(1, self.max_retries + 1):
|
|
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,
|
|
)
|
|
if response.status_code in _RETRY_STATUS and attempt < self.max_retries:
|
|
last_error = RuntimeError(
|
|
f"OpenRouter LLM error {response.status_code}: {response.text}"
|
|
)
|
|
await asyncio.sleep(_retry_wait(response.status_code, response.headers, attempt, response.text))
|
|
continue
|
|
response.raise_for_status()
|
|
except httpx.HTTPStatusError as exc:
|
|
last_error = RuntimeError(
|
|
f"OpenRouter LLM error {exc.response.status_code}: {exc.response.text}"
|
|
)
|
|
if exc.response.status_code in _RETRY_STATUS and attempt < self.max_retries:
|
|
await asyncio.sleep(_retry_wait(exc.response.status_code, exc.response.headers, attempt, exc.response.text))
|
|
continue
|
|
raise last_error 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()
|
|
|
|
raise last_error or RuntimeError("OpenRouter LLM: all attempts failed")
|
|
|
|
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)
|
|
last_error: Exception | None = None
|
|
|
|
for attempt in range(1, self.max_retries + 1):
|
|
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 in _RETRY_STATUS and attempt < self.max_retries:
|
|
body = await response.aread()
|
|
last_error = RuntimeError(
|
|
f"OpenRouter LLM error {response.status_code}: "
|
|
f"{body.decode(errors='replace')}"
|
|
)
|
|
await asyncio.sleep(_retry_wait(response.status_code, response.headers, attempt, body.decode(errors='replace')))
|
|
continue
|
|
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
|
|
return
|
|
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
|
|
|
|
raise last_error or RuntimeError("OpenRouter LLM: all attempts failed")
|
|
```
|
|
|
|
Priorität der Wartezeit bei 429:
|
|
1. `error.metadata.retry_after_seconds` im JSON-Body (zuverlässigster Wert)
|
|
2. HTTP-Header `Retry-After` (Sekunden)
|
|
3. HTTP-Header `X-RateLimit-Reset` (Millisekunden-Timestamp)
|
|
4. Fallback: 30 Sekunden
|
|
|
|
`max_retries` ist im Konstruktor konfigurierbar (Standard: 5).
|
|
|
|
### 9.2 Empfohlenes LLM-Modell
|
|
|
|
Für zuverlässigen Betrieb das **Bezahlmodell** verwenden:
|
|
|
|
```toml
|
|
openrouter_llm_model = "mistralai/mistral-small-3.2-24b-instruct"
|
|
```
|
|
|
|
Free-Tier-Modelle (`:free`-Suffix) sind für Produktion ungeeignet — hohe
|
|
Wartezeiten durch geteilte Kapazität (z.B. Venice-Backend: 8 req/min).
|
|
|
|
> **Tool-Fähigkeit (für die Web-Suche, `web_search_enabled`):** Das zentrale Modell
|
|
> muss Tool-Calling über OpenRouter unterstützen. `mistral-small-3.2-24b-instruct`
|
|
> tut das; die alte Baseline `mistral-small-24b-instruct-2501` **nicht** (404 „No
|
|
> endpoints found that support tool use"). Ein tool-unfähiges Modell wird zur Laufzeit
|
|
> erkannt (antwortet dann ohne Suche, geloggt), aber nicht jede Volatil-Frage wird
|
|
> dann frisch beantwortet — also kein tool-unfähiges Modell wählen, solange die
|
|
> Web-Suche aktiv ist.
|