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

0
app/api/__init__.py Normal file
View file

92
app/api/chat.py Normal file
View file

@ -0,0 +1,92 @@
from io import BytesIO
from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import JSONResponse, StreamingResponse
from app.config import settings
from app.errors import RoutingError
from app.dependencies import (
resolve_route,
build_orchestrator,
resolve_output_endpoint,
)
from app.schemas import ChatRequest
router = APIRouter()
def _route_headers(route) -> dict:
return {
"X-Input-Endpoint": route.input_endpoint,
"X-Output-Endpoint": route.output_endpoint,
"X-STT-Provider": route.stt_provider,
"X-LLM-Provider": route.llm_provider,
"X-TTS-Provider": route.tts_provider,
}
@router.post("/chat")
async def chat(
payload: ChatRequest,
debug: bool = Query(
default=False,
description="Return JSON trace instead of audio response",
),
session_id: str | None = Query(
default=None,
description="Optional session id to apply a stored route",
),
):
overrides = {
"input_endpoint": payload.input_endpoint,
"output_endpoint": payload.output_endpoint,
"language": payload.language,
"stt_provider": payload.stt_provider,
"llm_provider": payload.llm_provider,
"tts_provider": payload.tts_provider,
}
route = resolve_route(session_id, overrides)
voice = payload.voice or settings.openrouter_tts_voice
try:
orchestrator = build_orchestrator(route)
output = await resolve_output_endpoint(route)
except RoutingError as exc:
raise HTTPException(status_code=422, detail=str(exc))
try:
trace, audio = await orchestrator.chat_text(
payload.text,
language=route.language,
voice=voice,
output=output,
)
if debug:
return JSONResponse(
content={
"ok": True,
"voice": voice,
"route": route.as_dict(),
"trace": {
"raw_transcript": trace.raw_transcript,
"cleaned_transcript": trace.cleaned_transcript,
"semantic_response": trace.semantic_response,
"spoken_response": trace.spoken_response,
"tts_ready_text": trace.tts_ready_text,
},
}
)
headers = {
"Content-Language": route.language,
"X-Audio-Format": "pcm",
"X-Audio-Sample-Rate": "24000",
"X-Audio-Channels": "1",
"X-Audio-Sample-Width": "16",
**_route_headers(route),
}
return StreamingResponse(BytesIO(audio), media_type="audio/pcm", headers=headers)
except Exception as exc:
raise HTTPException(status_code=502, detail=str(exc))

37
app/api/config.py Normal file
View file

@ -0,0 +1,37 @@
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()
@router.get("/config")
async def get_config():
"""Zeigt aktives Profil, aufgeloeste Default-Route und verfuegbare Bausteine.
Bewusst OHNE Secrets - API-Keys werden nur als 'gesetzt/nicht gesetzt' gemeldet.
"""
route = resolve_route()
audio_router = get_audio_router()
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()],
},
"secrets": {
"openrouter_api_key_set": bool(settings.openrouter_api_key.strip()),
},
}

12
app/api/devices.py Normal file
View file

@ -0,0 +1,12 @@
from fastapi import APIRouter
from app.dependencies import get_audio_router
router = APIRouter()
@router.get("/devices")
async def list_devices():
audio_router = get_audio_router()
return {
"inputs": [item.model_dump() for item in await audio_router.list_inputs()],
"outputs": [item.model_dump() for item in await audio_router.list_outputs()],
}

7
app/api/health.py Normal file
View file

@ -0,0 +1,7 @@
from fastapi import APIRouter
router = APIRouter()
@router.get("/health")
async def health():
return {"status": "ok"}

10
app/api/sessions.py Normal file
View file

@ -0,0 +1,10 @@
from fastapi import APIRouter
from app.schemas import SessionRouteRequest
from app.dependencies import session_manager
router = APIRouter()
@router.post("/sessions/{session_id}/route")
async def set_session_route(session_id: str, payload: SessionRouteRequest):
session = session_manager.update(session_id, payload.model_dump())
return {"session_id": session_id, "route": session}

60
app/api/speak.py Normal file
View file

@ -0,0 +1,60 @@
from io import BytesIO
from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import StreamingResponse
from app.config import settings
from app.errors import RoutingError
from app.dependencies import (
resolve_route,
build_orchestrator,
resolve_output_endpoint,
)
from app.schemas import SpeakRequest
router = APIRouter()
@router.post("/speak")
async def speak(
payload: SpeakRequest,
session_id: str | None = Query(
default=None,
description="Optional session id to apply a stored route",
),
):
overrides = {
"output_endpoint": payload.output_endpoint,
"language": payload.language,
"tts_provider": payload.tts_provider,
}
route = resolve_route(session_id, overrides)
voice = payload.voice or settings.openrouter_tts_voice
try:
orchestrator = build_orchestrator(route)
output = await resolve_output_endpoint(route)
except RoutingError as exc:
raise HTTPException(status_code=422, detail=str(exc))
try:
audio = await orchestrator.speak_only(
payload.text,
voice=voice,
language=route.language,
output=output,
)
headers = {
"Content-Language": route.language,
"X-Audio-Format": "pcm",
"X-Audio-Sample-Rate": "24000",
"X-Audio-Channels": "1",
"X-Audio-Sample-Width": "16",
"X-Output-Endpoint": route.output_endpoint,
"X-TTS-Provider": route.tts_provider,
}
return StreamingResponse(BytesIO(audio), media_type="audio/pcm", headers=headers)
except Exception as exc:
raise HTTPException(status_code=502, detail=str(exc))

50
app/api/transcribe.py Normal file
View file

@ -0,0 +1,50 @@
from fastapi import APIRouter, File, Form, HTTPException, Query, UploadFile
from app.errors import RoutingError
from app.dependencies import (
resolve_route,
build_orchestrator,
resolve_input_endpoint,
)
router = APIRouter()
@router.post("/transcribe")
async def transcribe(
file: UploadFile = File(...),
language: str | None = Form(default=None),
input_endpoint: str | None = Form(default=None),
stt_provider: str | None = Form(default=None),
session_id: str | None = Query(
default=None,
description="Optional session id to apply a stored route",
),
):
overrides = {
"input_endpoint": input_endpoint,
"language": language,
"stt_provider": stt_provider,
}
route = resolve_route(session_id, overrides)
try:
orchestrator = build_orchestrator(route)
source = await resolve_input_endpoint(route)
except RoutingError as exc:
raise HTTPException(status_code=422, detail=str(exc))
content = await file.read()
suffix = (file.filename or "audio.wav").rsplit(".", 1)[-1].lower()
try:
trace = await orchestrator.transcribe_only(
content,
fmt=suffix,
language=route.language,
input=source,
)
except Exception as exc:
raise HTTPException(status_code=502, detail=str(exc))
return {"route": route.as_dict(), "trace": trace.model_dump()}