Initial commit: Voice Assistant Gateway mit Konfig-/Routing-Fundament

- FastAPI-Gateway mit REST-Endpunkten (chat/speak/transcribe/devices/sessions/config)
- Geschichtete Konfiguration mit Profilen (local-dev/hybrid/cloud) via TOML + ENV
- Registry-Pattern + einheitliche Route-Aufloesung (Default->Profil->ENV->Session->Request)
- Device Router (strikt, Singleton) und Output-Lifecycle im Orchestrator
- OpenRouter-Adapter (STT multipart/LLM/TTS) + lokale Provider-Stubs
- Regelbasierte Pipeline (Cleaner/Spoken-Adapter/TTS-Normalizer)
- 22 automatisierte Tests; Doku: README, BEDIENUNGSANLEITUNG, Architektur
- Secrets ausschliesslich ueber Umgebung; .env und lokale config gitignored

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-06-17 01:48:56 +02:00
commit 293ed257db
72 changed files with 2612 additions and 0 deletions

22
tests/conftest.py Normal file
View file

@ -0,0 +1,22 @@
import pytest
import app.dependencies as deps
@pytest.fixture(autouse=True)
def reset_state():
"""Isoliert den Singleton-Audio-Router (Loopback-Buffer) und Sessions je Test."""
deps._audio_router = None
deps.session_manager._sessions.clear()
yield
deps._audio_router = None
deps.session_manager._sessions.clear()
def loopback_output():
"""Liefert den LoopbackOutput aus dem aktuellen Singleton-Router."""
router = deps.get_audio_router()
for endpoint in router.outputs:
if type(endpoint).__name__ == "LoopbackOutput":
return endpoint
raise AssertionError("LoopbackOutput nicht gefunden")

View file

@ -0,0 +1,43 @@
import asyncio
import pytest
from app.audio.router import AudioRouter
from app.audio.endpoints.input.local_default import LocalDefaultInput
from app.audio.endpoints.input.bluetooth import BluetoothInput
from app.audio.endpoints.output.local_default import LocalDefaultOutput
from app.audio.endpoints.output.loopback import LoopbackOutput
from app.errors import UnknownEndpointError
def make_router():
return AudioRouter(
inputs=[LocalDefaultInput(), BluetoothInput()],
outputs=[LocalDefaultOutput(), LoopbackOutput()],
)
def test_select_default_output():
router = make_router()
endpoint = asyncio.run(router.select_output())
caps = asyncio.run(endpoint.capabilities())
assert caps.default is True
assert caps.kind == "local-default"
def test_select_output_by_kind():
router = make_router()
endpoint = asyncio.run(router.select_output("loopback"))
assert asyncio.run(endpoint.capabilities()).kind == "loopback"
def test_select_input_by_id():
router = make_router()
endpoint = asyncio.run(router.select_input("local-default-mic"))
assert asyncio.run(endpoint.capabilities()).id == "local-default-mic"
def test_unknown_endpoint_raises():
router = make_router()
with pytest.raises(UnknownEndpointError):
asyncio.run(router.select_output("does-not-exist"))

View file

@ -0,0 +1,7 @@
from pathlib import Path
def test_project_files_exist():
root = Path(__file__).resolve().parents[1]
assert (root / "app" / "main.py").exists()
assert (root / "pyproject.toml").exists()
assert (root / "docker-compose.yml").exists()

View file

@ -0,0 +1,60 @@
import pytest
from pydantic_settings import SettingsConfigDict
from app import config as cfg
EXAMPLE_TOML = str(cfg.BASE_DIR / "config" / "voice-assistant.example.toml")
class IsolatedSettings(cfg.Settings):
# .env ausblenden, damit nur TOML/Defaults/ENV-Monkeypatch zaehlen.
model_config = SettingsConfigDict(env_file=None, case_sensitive=False, extra="ignore")
def _clear_provider_env(monkeypatch):
for name in ("DEFAULT_STT_PROVIDER", "DEFAULT_LLM_PROVIDER", "DEFAULT_TTS_PROVIDER"):
monkeypatch.delenv(name, raising=False)
monkeypatch.setenv("VA_CONFIG_FILE", EXAMPLE_TOML)
def test_profile_local_dev(monkeypatch):
_clear_provider_env(monkeypatch)
monkeypatch.setenv("VA_PROFILE", "local-dev")
s = IsolatedSettings()
assert s.default_stt_provider == "faster-whisper"
assert s.default_llm_provider == "local-openai-compatible"
assert s.default_tts_provider == "piper"
def test_profile_cloud(monkeypatch):
_clear_provider_env(monkeypatch)
monkeypatch.setenv("VA_PROFILE", "cloud")
s = IsolatedSettings()
assert s.default_stt_provider == "openrouter"
assert s.default_llm_provider == "openrouter"
assert s.default_tts_provider == "openrouter"
def test_env_overrides_toml(monkeypatch):
_clear_provider_env(monkeypatch)
monkeypatch.setenv("VA_PROFILE", "local-dev")
monkeypatch.setenv("DEFAULT_LLM_PROVIDER", "openrouter") # ENV gewinnt ueber TOML
s = IsolatedSettings()
assert s.default_llm_provider == "openrouter"
assert s.default_tts_provider == "piper" # vom Profil, nicht ueberschrieben
def test_unknown_profile_raises(monkeypatch):
_clear_provider_env(monkeypatch)
monkeypatch.setenv("VA_PROFILE", "gibtsnicht")
with pytest.raises(ValueError):
IsolatedSettings()
def test_missing_config_file_falls_back_to_defaults(monkeypatch):
for name in ("DEFAULT_STT_PROVIDER", "DEFAULT_LLM_PROVIDER", "DEFAULT_TTS_PROVIDER"):
monkeypatch.delenv(name, raising=False)
monkeypatch.setenv("VA_CONFIG_FILE", "/nonexistent/voice-assistant.toml")
monkeypatch.delenv("VA_PROFILE", raising=False)
s = IsolatedSettings()
assert s.default_stt_provider == "openrouter" # eingebauter Field-Default

View file

@ -0,0 +1,96 @@
import json
from fastapi.testclient import TestClient
import app.dependencies as deps
from app.main import app
from tests.conftest import loopback_output
client = TestClient(app)
def test_speak_loopback_collects_chunks():
# piper-Stub liefert b"" -> kein Netzcall; Loopback sammelt den Chunk.
resp = client.post(
"/api/speak",
json={"text": "Hallo Welt", "tts_provider": "piper", "output_endpoint": "loopback"},
)
assert resp.status_code == 200
assert resp.headers["X-Output-Endpoint"] == "loopback"
assert resp.headers["X-TTS-Provider"] == "piper"
assert len(loopback_output().chunks) == 1
def test_unknown_endpoint_returns_422():
resp = client.post(
"/api/speak",
json={"text": "x", "tts_provider": "piper", "output_endpoint": "gibtsnicht"},
)
assert resp.status_code == 422
assert "gibtsnicht" in resp.json()["detail"]
def test_unknown_provider_returns_422():
resp = client.post("/api/speak", json={"text": "x", "tts_provider": "gibtsnicht"})
assert resp.status_code == 422
def test_session_route_applies():
client.post(
"/api/sessions/s1/route",
json={"tts_provider": "piper", "output_endpoint": "loopback"},
)
resp = client.post("/api/speak?session_id=s1", json={"text": "hallo"})
assert resp.status_code == 200
assert resp.headers["X-Output-Endpoint"] == "loopback"
assert resp.headers["X-TTS-Provider"] == "piper"
def test_chat_per_request_override_and_loopback(monkeypatch):
class StubLLM:
async def complete(self, text, session_id=None):
return "Mir geht es gut, danke."
class StubTTS:
async def synthesize(self, text, voice=None, audio_format="pcm"):
return b"AUDIO"
monkeypatch.setitem(deps.LLM_REGISTRY, "stub", lambda s: StubLLM())
monkeypatch.setitem(deps.TTS_REGISTRY, "stub", lambda s: StubTTS())
resp = client.post(
"/api/chat?debug=true",
json={
"text": "Wie geht es dir?",
"llm_provider": "stub",
"tts_provider": "stub",
"output_endpoint": "loopback",
},
)
assert resp.status_code == 200
body = resp.json()
assert body["route"]["llm_provider"] == "stub"
assert body["route"]["output_endpoint"] == "loopback"
assert body["trace"]["semantic_response"] == "Mir geht es gut, danke."
assert loopback_output().chunks[0].data == b"AUDIO"
def test_transcribe_local_provider():
files = {"file": ("a.wav", b"RIFFdata", "audio/wav")}
data = {"stt_provider": "faster-whisper"}
resp = client.post("/api/transcribe", data=data, files=files)
assert resp.status_code == 200
body = resp.json()
assert body["route"]["stt_provider"] == "faster-whisper"
assert body["trace"]["raw_transcript"] == "[local transcription placeholder]"
def test_config_endpoint_exposes_no_secrets():
resp = client.get("/api/config")
assert resp.status_code == 200
body = resp.json()
assert "piper" in body["available"]["tts_providers"]
assert "loopback" in {e["kind"] for e in body["available"]["output_endpoints"]}
# Keine echten Secrets im Body.
assert "sk-or-" not in json.dumps(body)
assert set(body["secrets"].keys()) == {"openrouter_api_key_set"}

46
tests/test_routing.py Normal file
View file

@ -0,0 +1,46 @@
import pytest
from app.config import Settings
from app.errors import UnknownComponentError
from app.dependencies import (
resolve_route,
get_llm_provider,
get_tts_provider,
session_manager,
)
def test_default_route_from_settings():
cfg = Settings()
route = resolve_route(cfg=cfg)
assert route.stt_provider == cfg.default_stt_provider
assert route.llm_provider == cfg.default_llm_provider
assert route.input_endpoint == cfg.default_input_endpoint
assert route.language == cfg.default_language
def test_request_overrides_win():
route = resolve_route(overrides={"llm_provider": "openrouter", "output_endpoint": "loopback"})
assert route.llm_provider == "openrouter"
assert route.output_endpoint == "loopback"
def test_session_then_request_precedence():
session_manager.update("s_test", {"tts_provider": "piper", "language": "en"})
route = resolve_route("s_test")
assert route.tts_provider == "piper"
assert route.language == "en"
# Request schlaegt Session.
route2 = resolve_route("s_test", {"tts_provider": "chatterbox"})
assert route2.tts_provider == "chatterbox"
assert route2.language == "en"
def test_registry_unknown_provider_raises():
with pytest.raises(UnknownComponentError):
get_llm_provider("does-not-exist")
def test_registry_known_provider():
assert type(get_tts_provider("piper")).__name__ == "PiperTTSProvider"