# Architektur: Modularer Voice-Assistent

> Stand: aktueller Implementierungsstand des Gateways. Dieses Dokument beschreibt
> das Konzept, den umgesetzten Stand und die geplanten nächsten Schritte.

## 1. Ziel & Leitidee

Ein modularer, **cloud-first, aber hybrid betreibbarer** Sprachassistent für
Senioren — ein „digitaler Vertrauter", erreichbar von zuhause und unterwegs.

Der Hauptbetrieb läuft in der Cloud / auf einem vHost (Senioren sollen keinen
teuren KI-Rechner zuhause brauchen). Gleichzeitig muss jede Achse frei wählbar
bleiben, je nach Installation und Entwicklungs-/Testbedarf:

- **Hardware** — Audio-Eingabe und -Ausgabe (lokal, Bluetooth, Handy, Netzwerk)
- **Betrieb** — lokal, Cloud oder hybrid
- **Software** — lokale oder entfernte KI (STT / LLM / TTS), ganz oder teilweise

Designziele: geringe Latenz, robuste Fallbacks, gute Debugbarkeit, klare Trennung
von **semantischer** und **gesprochener** Antwort, und vor allem **Austauschbarkeit**:
einzelne Module gegen andere tauschen, ohne das Programm umzuschreiben.

## 2. Architekturprinzip — fünf Ebenen

1. **Audio Endpoints** – konkrete Quellen/Ziele (lokal, Bluetooth, Handy, WebRTC)
2. **Device Router** – wählt passende Input-/Output-Endpunkte
3. **Speech Pipeline** – STT, Input-Cleaner, Dialog-LLM, Spoken-Response-Adapter, TTS-Normalizer, TTS
4. **Transport Router** – lokale vs. entfernte Ausführung einzelner Module
5. **Orchestrator** – Session, Turn-Taking, Fallbacks, Metrik

Jede Ebene kommuniziert über wohldefinierte Interfaces (ABCs + Pydantic-Schemas),
nicht über konkrete Bibliotheken.

## 3. Konfigurations- & Routing-Modell (umgesetzt)

Das Herzstück der Austauschbarkeit. Jede Achse ist auf mehreren Ebenen
einstellbar; höhere Ebene gewinnt:

```
eingebaute Defaults  <  config/voice-assistant.toml (inkl. aktivem Profil)
                     <  ENV / .env  <  Nutzer-Prefs  <  Session-Route  <  Request
```

### 3.1 Zentrale Config + Profile

Eine geschichtete zentrale Datei (`config/voice-assistant.toml`, Vorlage
`config/voice-assistant.example.toml`), gelesen über die stdlib (`tomllib`).
**Profile** bündeln Betriebsarten und werden per ENV `VA_PROFILE` aktiviert:

| Profil      | STT            | LLM                      | TTS        |
|-------------|----------------|--------------------------|------------|
| `local-dev` | faster-whisper | local-openai-compatible  | piper      |
| `hybrid`    | openrouter     | local-openai-compatible  | openrouter |
| `cloud`     | openrouter     | openrouter               | openrouter |

Umgesetzt in `app/config.py`: `load_profile_config()` merged `[defaults]` +
`[profiles.<aktiv>]`; `TomlProfileSource` hängt diese Werte als Settings-Quelle
**unter** ENV ein (`settings_customise_sources`). `VA_PROFILE`/`VA_CONFIG_FILE`
werden aus echter Umgebung **oder** `.env` gelesen.

> **Secrets gehören nie in die Config-Datei** — nur in die Umgebung
> (z. B. `OPENROUTER_API_KEY`). Die TOML-Datei darf versioniert/geteilt werden.

### 3.2 Einheitliche Route-Auflösung

`app/dependencies.py` löst pro Aufruf eine `ResolvedRoute` auf
(`resolve_route(user, session_id, overrides)`): Defaults < Nutzer-Prefs <
Session-Route < Request. Die Route umfasst `input_endpoint`, `output_endpoint`,
`stt_provider`, `llm_provider`, `tts_provider`, `language`. Gehört eine Session
einem anderen Nutzer, wird `SessionOwnershipError` (→ 403) ausgelöst.

### 3.3 Registry-Pattern (Provider austauschbar)

`STT_REGISTRY` / `LLM_REGISTRY` / `TTS_REGISTRY` bilden `name -> factory(settings)`.
Ein neuer Provider = ein Eintrag, ohne Kern-Code zu ändern. Unbekannter Name →
`UnknownComponentError` → HTTP 422.

### 3.4 Device Router

`app/audio/router.py` wählt Endpunkte per `id`- oder `kind`-Match. Ein angefragter,
aber unbekannter Endpunkt wirft `UnknownEndpointError` (→ 422) — **kein** stiller
Default-Fallback. Ohne Wunsch greift der als `default` markierte Endpunkt. Der
Router ist ein Singleton (stabiler Zustand, z. B. `LoopbackOutput`).

## 4. Datenmodelle & Interfaces

Schemas in `app/schemas.py`, Interfaces als ABCs in den jeweiligen `base.py`.

```python
class EndpointCapabilities(BaseModel):
    id: str; kind: str
    direction: Literal["input", "output"]
    sample_rate: int = 16000; channels: int = 1
    latency_class: Literal["low", "medium", "high"] = "medium"
    supports_aec: bool = False; supports_barge_in: bool = False
    networked: bool = False; bluetooth: bool = False
    mobile: bool = False; default: bool = False

class AudioChunk(BaseModel):
    data: bytes; sample_rate: int = 16000; channels: int = 1
    format: str = "wav"; timestamp_ms: int = 0

class PipelineTrace(BaseModel):
    raw_transcript: str | None = None
    cleaned_transcript: str | None = None
    semantic_response: str | None = None
    spoken_response: str | None = None
    tts_ready_text: str | None = None
```

Provider-Interfaces:

```python
class STTProvider(ABC):
    async def transcribe(self, audio_bytes: bytes, fmt: str, language: str | None = None) -> str: ...

class LLMProvider(ABC):
    async def complete(self, text: str, session_id: str | None = None) -> str: ...

class TTSProvider(ABC):
    async def synthesize(self, text: str, voice: str | None = None, audio_format: str = "pcm") -> bytes: ...
```

Audio-Endpunkt-Interfaces: `AudioInputEndpoint` (`capabilities/open/read_chunk/close`),
`AudioOutputEndpoint` (`capabilities/open/write_chunk/flush/close`) — alle async.

## 5. Speech Pipeline

Trennung von **semantischer** und **gesprochener** Antwort ist zentral: eine
inhaltlich gute Antwort ist nicht automatisch gut hörbar.

Stufen: `raw_transcript → cleaned_transcript → semantic_response → spoken_response → tts_ready_text`.

- **Input Cleaner** (`pipeline/input_cleaner.py`) – konservative Bereinigung des STT-Texts (Füllwörter, Whitespace). Verändert die Nutzerintention nicht.
- **Dialog-LLM** – semantische Antwort; Persona/Sicherheitsregeln im System-Prompt (`providers/llm/openrouter.py`).
- **Spoken Response Adapter** (`pipeline/spoken_response_adapter.py`) – macht die Antwort sprechbar/seniorengerecht (Markdown raus, Aufzählungspunkte weg, **nummerierte Listen → Ordinalwörter** „1." → „erstens", Uhrzeiten/Verhältnisse erhalten).
- **Sentence Chunker** (`pipeline/sentence_chunker.py`) – inkrementelle Satzsegmentierung für satzweises Streaming-TTS. Trennt bewusst **nicht** nach Ziffer+Punkt („1. Mai"), Einzelbuchstabe+Punkt („z. B.", Initialen) oder bekannten Abkürzungen.
- **TTS Normalizer** (`pipeline/tts_normalizer.py`) – füllt gezielt die Lücken des Phonemizers (espeak-ng in piper), **ohne** zu duplizieren, was der schon gut kann (Kardinal-/Dezimalzahlen bleiben unangetastet): **Ordinalia** (Datum „1. Mai" → „erster Mai", Folgen „1. 2. 3." → „erstens, zweitens …"), **Einheiten nach Zahl** (kg/km/km-h/…), **Abkürzungen** (Dr./z. B./usw.) und ein **YAML-Aussprache-Lexikon** (`config/pronunciation.<lang>.yaml`, erweitert die eingebauten Defaults; case-insensitive Wort-Umschreibungen wie „strömt" → „ströhmt"). Stufe **provider-abhängig** (`TTS_NORMALIZE_LEVEL=auto|full|light|off`): piper → `full`, Cloud-TTS → `light` (Cloud spricht Zahlen/Abkürzungen selbst gut). Ordinalzahlen 1.–31. in `pipeline/german_numbers.py`.

Empfehlung: nicht jede Zwischenstufe braucht ein großes LLM — Cleaner, Chunker und
Normalizer überwiegend regelbasiert (so heute umgesetzt), Adapter promptbasiert.
Lexikon pflegen: `python scripts/add_pronunciation.py "wort:aussprache" [--verify]`.

## 6. Orchestrator

`app/core/orchestrator.py` verbindet Provider, Pipeline und Output-Endpunkt.

- `chat_text(text, language, voice, output, history)` → `(trace, audio_bytes)`
- `speak_only(text, voice, language, output)` → `audio_bytes`
- `transcribe_only(audio_bytes, fmt, language, input)` → `trace`

Bei `/api/chat` mit `session_id` lädt die API-Schicht den letzten Gesprächsverlauf
aus dem Store (`messages`-Tabelle, letzte `HISTORY_MAX_MESSAGES`), gibt ihn als
`history` an das LLM und speichert nach der Antwort User- und Assistant-Turn.
Ohne `session_id` bleibt der Aufruf zustandslos.

Das synthetisierte Audio wird **zusätzlich** durch den gewählten Output-Endpunkt
geschrieben (`open → write_chunk → flush → close`) und **gleichzeitig** als
HTTP-Stream zurückgegeben (additiv). Bei lokalen Geräten ist `write_chunk` heute
ein No-op; `LoopbackOutput` sammelt die Chunks (testbar ohne Hardware).

## 7. FastAPI-Endpunkte (umgesetzt)

| Methode & Pfad                       | Zweck |
|--------------------------------------|-------|
| `GET  /health`                       | Liveness |
| `POST /api/chat`                     | Text rein → Audio raus (`?debug=true` → JSON-Trace) |
| `POST /api/speak`                    | Text rein → TTS-Audio raus |
| `POST /api/transcribe`               | Audio-Upload → Transkript |
| `GET  /api/devices`                  | verfügbare Audio-Endpunkte + Capabilities |
| `POST /api/sessions/{id}/route`      | bevorzugte Geräte/Provider/Sprache je Session |
| `GET  /api/config`                   | aktives Profil + aufgelöste Route (ohne Secrets) |
| `POST /api/admin/users`              | Nutzer anlegen (Admin-Key) → Token einmalig |
| `GET  /api/me` · `PUT /api/me/prefs` | aktueller Nutzer + dauerhafte Präferenzen |
| `GET/POST/DELETE /api/me/memories`   | Langzeit-Erinnerungen des Nutzers |
| `GET  /api/metrics`                  | Metriken (JSON / Prometheus) |
| `WS   /ws/chat`                       | Echtzeit-Chat (Text rein, Streaming-Events) |
| `WS   /ws/voice`                      | Echtzeit-Sprache (Audio rein → STT → Antwort) |

Endpunkt-/Provider-Auswahl ist über **Request-Body** (pro Aufruf), **Session**
(`?session_id=…`) und **Defaults/Profil** steuerbar. Verwendete Route erscheint als
`X-*`-Header bzw. im `?debug`-JSON.

## 8. Stand der Implementierung

**Umgesetzt:** FastAPI-Gateway, alle o. g. REST-Endpunkte; OpenRouter-Adapter für
STT (JSON/base64), LLM und TTS; lokaler OpenAI-kompatibler LLM-Adapter; regelbasierte
Pipeline; geschichtete Config + Profile; Registry + einheitliche Route-Auflösung;
Device Router (strikt, Singleton); Output-Lifecycle; **Authentifizierung
(Bearer-Token) + persistenter SQLite-Store für Nutzer/Sessions + Mandanten-Trennung
+ dauerhafte Nutzer-Präferenzen**; **Gesprächsgedächtnis pro Session (Verlauf im
Store, fließt ins LLM)**; **Langzeit-Erinnerungen pro Nutzer (als LLM-Kontext)**;
**WebSocket-Streaming-Chat (`/ws/chat`) inkl. Token-Level-LLM-Streaming (SSE,
`stream:true`) und satzweisem Audio-Streaming (chunked TTS, `audio_stream:true`)**; **Sprach-Eingang
über WebSocket (`/ws/voice`: Audio rein → STT → Antwort-Pipeline) mit VAD-Aeusserungs-
erkennung und Barge-in (`interrupt`)**; **Resilienz (Fallback-Ketten je Modul,
In-Memory-Metriken `/api/metrics`), Tageskontingent pro Nutzer und heuristische
Notfall-Eskalation**; automatisierte Tests.

**Echtes lokales STT & TTS:** `faster-whisper` (optionale Dependency `.[local]`,
CTranslate2) transkribiert real; `piper` (in-process via piper-Python-API, Stimmmodell
einmal geladen + gecacht, In-Process-Resampling auf 24000 Hz) synthetisiert real. Lokale
Modelle werden beim Serverstart vorgeladen (Warm-up). Damit ist sowohl ein Hybrid „STT+LLM lokal, TTS
remote" als auch eine **voll-lokale** Konstellation möglich (live verifiziert).

**Platzhalter (Gerüst):** Audio-Endpunkte (`local-default`, `bluetooth`,
`mobile-ws`, `mobile-webrtc`) liefern leere Chunks — nur Auswahl/Lifecycle sind
verdrahtet, kein echtes Hardware-I/O. `transport_router.py` (Ebene 4) existiert, ist aber noch nicht aktiv
(lokal/remote trägt vorerst der Provider-Name).

## 9. Roadmap / bewusste nächste Schritte

Reihenfolge der Weiterentwicklung:

1. **(erledigt)** Konfig- & Routing-Fundament: Profile, Device Router, Registry, Pro-Request-Override.
2. **(erledigt)** Cloud-Fundament: Bearer-Token-Auth, Mehrbenutzer, persistenter SQLite-Store, Mandanten-Trennung, dauerhafte Nutzer-Präferenzen. Offen: Skalierung auf gemeinsamen Store (Postgres/Redis) für mehrere Instanzen.
3. **(erledigt)** Konversationsgedächtnis: Kurzzeit-Gesprächsverlauf pro Session + Langzeit-Erinnerungen pro Nutzer (manuell **und automatisch** gepflegt, als LLM-Kontext). **Automatische Extraktion** (`app/core/memory_extractor.py`): nach je N Turns destilliert ein LLM dauerhafte Fakten/Vorlieben aus dem Verlauf und legt sie dedupliziert als Erinnerungen ab — best-effort, nicht-blockierend (Hintergrund-Task), konfigurierbar (`MEMORY_EXTRACTION_*`). Offen: periodische Verdichtung/Zusammenfassung wachsender Erinnerungslisten.
4. **(weitgehend erledigt)** Echtzeit: WebSocket-Streaming-Chat (`/ws/chat`), **Token-Level-LLM-Streaming (SSE, `stream:true`)**, **Audio-Streaming (chunked TTS satzweise, `audio_stream:true`)**, **Audio-Eingang (`/ws/voice`)**, **Barge-in/Turn-Manager (`interrupt` bricht laufende Antwort ab)** und **VAD-Aeusserungserkennung (energie-basiert, opt-in)** sind umgesetzt. Offen: **echte partielle Live-Transkripte (Streaming-STT-Dienst, wortweise)** und **WebRTC (aiortc)** — beide brauchen schwere Abhaengigkeiten/Dienste. Heute laeuft STT pro Aeusserung.
5. **(weitgehend erledigt)** Resilienz: Fallback-Ketten je Modul (`*_FALLBACK`, Provider faellt aus → naechster) und In-Memory-Metriken (`/api/metrics`: Request/Latenz, Pipeline-Stufen, Fallback/Fehler; JSON + Prometheus). Offen: verteiltes Tracing, Alerting.
6. **(weitgehend erledigt)** Betrieb: Tageskontingent pro Nutzer (`DAILY_REQUEST_LIMIT`, 429) und **zweistufige** Notfall-Eskalation: (1) schnelle Stichwort-Heuristik im Hot-Path (0 Latenz) + (2) **LLM-Klassifikation** (`app/safety/llm_classifier.py`) als Hintergrund-Task, der laeuft, wenn die Heuristik nichts fand — faengt verpasste Formulierungen (z. B. metaphorisch geaeusserte Suizidalitaet, Schlaganfall-Symptome ohne Stichwort) mit Konfidenz-Schwelle, ohne die Antwortlatenz zu erhoehen. Eskalation jeweils -> Log + Metrik (`source`: keyword/llm) + optionaler Webhook + Event. Offen: Telefon-/Angehoerigen-Integration, Abrechnung.
7. **(weitgehend erledigt)** Lokale Provider: STT via `faster-whisper` (`.[local]`) und **TTS via `piper`** (lokales Neural-TTS, **in-process** mit gecachtem Stimmmodell, In-Process-Resampling auf 24000 Hz; lokale Modelle werden beim Start vorgeladen) sind echt — eine **voll-lokale Konstellation** (STT+LLM+TTS lokal, keine API-Kosten, max. Datenschutz) ist damit möglich. **`chatterbox`-TTS** (Resemble AI, eigener GPU-HTTP-Dienst, hohe Qualität + Voice-Cloning) ist als **wählbarer** Provider angebunden (job-basiert: `/speak`→`/status`→`/audio`, `no_playback`-Modus liefert nur Bytes). Offen: Streaming-Synthese für niedrigere Latenz.
8. **TransportRouter** als eigene lokal/remote-Achse aktivieren; echte Geräte-Endpunkte (PipeWire/Bluetooth) — heute OS-Ebene.

**Datenschutz (querschnittlich, ab sofort mitdenken):** Senioren-Sprachdaten sind
hochsensibel (oft gesundheitsbezogen → DSGVO Art. 9). EU-Datenresidenz,
Verschlüsselung at-rest/in-transit, Löschkonzept, Einwilligung — „privacy by design".

## 10. Verzeichnisstruktur (Ist)

```text
voice-assistant-scaffold/
├── app/
│   ├── main.py            # FastAPI-App + Router-Registrierung
│   ├── config.py          # Settings, TOML-Profile, Präzedenz
│   ├── dependencies.py    # Registries, ResolvedRoute, resolve_route, Store-/Router-Singleton
│   ├── store.py           # Persistenz: Store-Interface + SQLiteStore (Nutzer/Sessions/Verlauf)
│   ├── auth.py            # Bearer-Token-Auth (require_user) + Admin-Schutz
│   ├── metrics.py         # In-Memory-Metriken (Counter/Timer, JSON + Prometheus)
│   ├── quota.py           # Tageskontingent pro Nutzer (Kostenkontrolle)
│   ├── safety/            # emergency.py: heuristische Notfall-Erkennung/-Eskalation
│   ├── errors.py          # RoutingError -> HTTP 422
│   ├── schemas.py         # Pydantic-Modelle
│   ├── api/               # health, chat, speak, transcribe, devices, sessions, config, admin, me, ws
│   ├── core/              # orchestrator
│   ├── audio/             # router, transport_router, vad, endpoints/input|output/*
│   ├── pipeline/          # input_cleaner, spoken_response_adapter, tts_normalizer, sentence_chunker, german_numbers
│   └── providers/         # stt/ llm/ tts/ (openrouter + lokale Stubs) + fallback.py
├── config/                # voice-assistant.example.toml (+ lokale .toml, gitignored)
├── data/                  # SQLite-DB (gitignored)
├── deploy/                # systemd unit + env-Beispiel
├── tests/                 # config-profile, routing, audio-router, e2e
├── Docs/                  # dieses Dokument
├── Dockerfile, docker-compose.yml, Makefile, pyproject.toml
└── README.md, BEDIENUNGSANLEITUNG.md
```
