# 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.

---

### 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, 13 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) | ♂ | medium |
| `pl_PL-gosia-medium` | Polnisch | ♀ | medium |
| `pl_PL-darkman-medium` | Polnisch | ♂ | medium |
| `ar_JO-kareem-medium` | Arabisch (JO) | ♂ | medium |
| `ru_RU-irina-medium` | Russisch | ♀ | medium |
| `ru_RU-ruslan-medium` | Russisch | ♂ | medium |
| `zh_CN-huayan-medium` | Chinesisch | ♀ | medium |

```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
```

```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-24b-instruct-2501"
llm_fallback         = "openrouter"

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.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
    location /ws/ {
        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;
    }

    # Alle anderen Routen — Authelia 2FA-Schutz
    location / {
        auth_request /authelia-check;
        auth_request_set $user $upstream_http_remote_user;
        error_page 401 =302 https://auth.jamulix.de/?rd=$scheme://$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;
    }
}
```

```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
```

---

## 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

```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-24b-instruct-2501"
```

Free-Tier-Modelle (`:free`-Suffix) sind für Produktion ungeeignet — hohe
Wartezeiten durch geteilte Kapazität (z.B. Venice-Backend: 8 req/min).
