# 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-24b-instruct-2501"
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-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).
