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:
commit
293ed257db
72 changed files with 2612 additions and 0 deletions
0
app/__init__.py
Normal file
0
app/__init__.py
Normal file
0
app/api/__init__.py
Normal file
0
app/api/__init__.py
Normal file
92
app/api/chat.py
Normal file
92
app/api/chat.py
Normal 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
37
app/api/config.py
Normal 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
12
app/api/devices.py
Normal 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
7
app/api/health.py
Normal 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
10
app/api/sessions.py
Normal 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
60
app/api/speak.py
Normal 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
50
app/api/transcribe.py
Normal 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()}
|
||||
0
app/audio/__init__.py
Normal file
0
app/audio/__init__.py
Normal file
0
app/audio/endpoints/__init__.py
Normal file
0
app/audio/endpoints/__init__.py
Normal file
0
app/audio/endpoints/input/__init__.py
Normal file
0
app/audio/endpoints/input/__init__.py
Normal file
17
app/audio/endpoints/input/base.py
Normal file
17
app/audio/endpoints/input/base.py
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
from abc import ABC, abstractmethod
|
||||
from app.schemas import AudioChunk, EndpointCapabilities
|
||||
|
||||
class AudioInputEndpoint(ABC):
|
||||
endpoint_id: str
|
||||
|
||||
@abstractmethod
|
||||
async def capabilities(self) -> EndpointCapabilities: ...
|
||||
|
||||
@abstractmethod
|
||||
async def open(self) -> None: ...
|
||||
|
||||
@abstractmethod
|
||||
async def read_chunk(self) -> AudioChunk: ...
|
||||
|
||||
@abstractmethod
|
||||
async def close(self) -> None: ...
|
||||
23
app/audio/endpoints/input/bluetooth.py
Normal file
23
app/audio/endpoints/input/bluetooth.py
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
from app.audio.endpoints.input.base import AudioInputEndpoint
|
||||
from app.schemas import AudioChunk, EndpointCapabilities
|
||||
|
||||
class BluetoothInput(AudioInputEndpoint):
|
||||
endpoint_id = "bt-headset-01"
|
||||
|
||||
async def capabilities(self) -> EndpointCapabilities:
|
||||
return EndpointCapabilities(
|
||||
id=self.endpoint_id,
|
||||
kind="bluetooth",
|
||||
direction="input",
|
||||
latency_class="medium",
|
||||
bluetooth=True,
|
||||
)
|
||||
|
||||
async def open(self) -> None:
|
||||
return None
|
||||
|
||||
async def read_chunk(self) -> AudioChunk:
|
||||
return AudioChunk(data=b"", format="wav", timestamp_ms=0)
|
||||
|
||||
async def close(self) -> None:
|
||||
return None
|
||||
24
app/audio/endpoints/input/local_default.py
Normal file
24
app/audio/endpoints/input/local_default.py
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
from app.audio.endpoints.input.base import AudioInputEndpoint
|
||||
from app.schemas import AudioChunk, EndpointCapabilities
|
||||
|
||||
class LocalDefaultInput(AudioInputEndpoint):
|
||||
endpoint_id = "local-default-mic"
|
||||
|
||||
async def capabilities(self) -> EndpointCapabilities:
|
||||
return EndpointCapabilities(
|
||||
id=self.endpoint_id,
|
||||
kind="local-default",
|
||||
direction="input",
|
||||
latency_class="low",
|
||||
supports_barge_in=True,
|
||||
default=True,
|
||||
)
|
||||
|
||||
async def open(self) -> None:
|
||||
return None
|
||||
|
||||
async def read_chunk(self) -> AudioChunk:
|
||||
return AudioChunk(data=b"", format="wav", timestamp_ms=0)
|
||||
|
||||
async def close(self) -> None:
|
||||
return None
|
||||
25
app/audio/endpoints/input/mobile_webrtc.py
Normal file
25
app/audio/endpoints/input/mobile_webrtc.py
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
from app.audio.endpoints.input.base import AudioInputEndpoint
|
||||
from app.schemas import AudioChunk, EndpointCapabilities
|
||||
|
||||
class MobileWebRTCInput(AudioInputEndpoint):
|
||||
endpoint_id = "mobile-webrtc-client"
|
||||
|
||||
async def capabilities(self) -> EndpointCapabilities:
|
||||
return EndpointCapabilities(
|
||||
id=self.endpoint_id,
|
||||
kind="mobile-webrtc",
|
||||
direction="input",
|
||||
latency_class="low",
|
||||
networked=True,
|
||||
mobile=True,
|
||||
supports_barge_in=True,
|
||||
)
|
||||
|
||||
async def open(self) -> None:
|
||||
return None
|
||||
|
||||
async def read_chunk(self) -> AudioChunk:
|
||||
return AudioChunk(data=b"", format="wav", timestamp_ms=0)
|
||||
|
||||
async def close(self) -> None:
|
||||
return None
|
||||
24
app/audio/endpoints/input/mobile_ws.py
Normal file
24
app/audio/endpoints/input/mobile_ws.py
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
from app.audio.endpoints.input.base import AudioInputEndpoint
|
||||
from app.schemas import AudioChunk, EndpointCapabilities
|
||||
|
||||
class MobileWebSocketInput(AudioInputEndpoint):
|
||||
endpoint_id = "mobile-ws-client"
|
||||
|
||||
async def capabilities(self) -> EndpointCapabilities:
|
||||
return EndpointCapabilities(
|
||||
id=self.endpoint_id,
|
||||
kind="mobile-ws",
|
||||
direction="input",
|
||||
latency_class="medium",
|
||||
networked=True,
|
||||
mobile=True,
|
||||
)
|
||||
|
||||
async def open(self) -> None:
|
||||
return None
|
||||
|
||||
async def read_chunk(self) -> AudioChunk:
|
||||
return AudioChunk(data=b"", format="wav", timestamp_ms=0)
|
||||
|
||||
async def close(self) -> None:
|
||||
return None
|
||||
0
app/audio/endpoints/output/__init__.py
Normal file
0
app/audio/endpoints/output/__init__.py
Normal file
20
app/audio/endpoints/output/base.py
Normal file
20
app/audio/endpoints/output/base.py
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
from abc import ABC, abstractmethod
|
||||
from app.schemas import AudioChunk, EndpointCapabilities
|
||||
|
||||
class AudioOutputEndpoint(ABC):
|
||||
endpoint_id: str
|
||||
|
||||
@abstractmethod
|
||||
async def capabilities(self) -> EndpointCapabilities: ...
|
||||
|
||||
@abstractmethod
|
||||
async def open(self) -> None: ...
|
||||
|
||||
@abstractmethod
|
||||
async def write_chunk(self, chunk: AudioChunk) -> None: ...
|
||||
|
||||
@abstractmethod
|
||||
async def flush(self) -> None: ...
|
||||
|
||||
@abstractmethod
|
||||
async def close(self) -> None: ...
|
||||
26
app/audio/endpoints/output/bluetooth.py
Normal file
26
app/audio/endpoints/output/bluetooth.py
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
from app.audio.endpoints.output.base import AudioOutputEndpoint
|
||||
from app.schemas import AudioChunk, EndpointCapabilities
|
||||
|
||||
class BluetoothOutput(AudioOutputEndpoint):
|
||||
endpoint_id = "bt-speaker-01"
|
||||
|
||||
async def capabilities(self) -> EndpointCapabilities:
|
||||
return EndpointCapabilities(
|
||||
id=self.endpoint_id,
|
||||
kind="bluetooth",
|
||||
direction="output",
|
||||
latency_class="medium",
|
||||
bluetooth=True,
|
||||
)
|
||||
|
||||
async def open(self) -> None:
|
||||
return None
|
||||
|
||||
async def write_chunk(self, chunk: AudioChunk) -> None:
|
||||
return None
|
||||
|
||||
async def flush(self) -> None:
|
||||
return None
|
||||
|
||||
async def close(self) -> None:
|
||||
return None
|
||||
26
app/audio/endpoints/output/local_default.py
Normal file
26
app/audio/endpoints/output/local_default.py
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
from app.audio.endpoints.output.base import AudioOutputEndpoint
|
||||
from app.schemas import AudioChunk, EndpointCapabilities
|
||||
|
||||
class LocalDefaultOutput(AudioOutputEndpoint):
|
||||
endpoint_id = "local-default-speaker"
|
||||
|
||||
async def capabilities(self) -> EndpointCapabilities:
|
||||
return EndpointCapabilities(
|
||||
id=self.endpoint_id,
|
||||
kind="local-default",
|
||||
direction="output",
|
||||
latency_class="low",
|
||||
default=True,
|
||||
)
|
||||
|
||||
async def open(self) -> None:
|
||||
return None
|
||||
|
||||
async def write_chunk(self, chunk: AudioChunk) -> None:
|
||||
return None
|
||||
|
||||
async def flush(self) -> None:
|
||||
return None
|
||||
|
||||
async def close(self) -> None:
|
||||
return None
|
||||
28
app/audio/endpoints/output/loopback.py
Normal file
28
app/audio/endpoints/output/loopback.py
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
from app.audio.endpoints.output.base import AudioOutputEndpoint
|
||||
from app.schemas import AudioChunk, EndpointCapabilities
|
||||
|
||||
class LoopbackOutput(AudioOutputEndpoint):
|
||||
endpoint_id = "loopback-output"
|
||||
|
||||
def __init__(self):
|
||||
self.chunks = []
|
||||
|
||||
async def capabilities(self) -> EndpointCapabilities:
|
||||
return EndpointCapabilities(
|
||||
id=self.endpoint_id,
|
||||
kind="loopback",
|
||||
direction="output",
|
||||
latency_class="low",
|
||||
)
|
||||
|
||||
async def open(self) -> None:
|
||||
return None
|
||||
|
||||
async def write_chunk(self, chunk: AudioChunk) -> None:
|
||||
self.chunks.append(chunk)
|
||||
|
||||
async def flush(self) -> None:
|
||||
return None
|
||||
|
||||
async def close(self) -> None:
|
||||
return None
|
||||
27
app/audio/endpoints/output/mobile_webrtc.py
Normal file
27
app/audio/endpoints/output/mobile_webrtc.py
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
from app.audio.endpoints.output.base import AudioOutputEndpoint
|
||||
from app.schemas import AudioChunk, EndpointCapabilities
|
||||
|
||||
class MobileWebRTCOutput(AudioOutputEndpoint):
|
||||
endpoint_id = "mobile-webrtc-client"
|
||||
|
||||
async def capabilities(self) -> EndpointCapabilities:
|
||||
return EndpointCapabilities(
|
||||
id=self.endpoint_id,
|
||||
kind="mobile-webrtc",
|
||||
direction="output",
|
||||
latency_class="low",
|
||||
networked=True,
|
||||
mobile=True,
|
||||
)
|
||||
|
||||
async def open(self) -> None:
|
||||
return None
|
||||
|
||||
async def write_chunk(self, chunk: AudioChunk) -> None:
|
||||
return None
|
||||
|
||||
async def flush(self) -> None:
|
||||
return None
|
||||
|
||||
async def close(self) -> None:
|
||||
return None
|
||||
27
app/audio/endpoints/output/mobile_ws.py
Normal file
27
app/audio/endpoints/output/mobile_ws.py
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
from app.audio.endpoints.output.base import AudioOutputEndpoint
|
||||
from app.schemas import AudioChunk, EndpointCapabilities
|
||||
|
||||
class MobileWebSocketOutput(AudioOutputEndpoint):
|
||||
endpoint_id = "mobile-ws-client"
|
||||
|
||||
async def capabilities(self) -> EndpointCapabilities:
|
||||
return EndpointCapabilities(
|
||||
id=self.endpoint_id,
|
||||
kind="mobile-ws",
|
||||
direction="output",
|
||||
latency_class="medium",
|
||||
networked=True,
|
||||
mobile=True,
|
||||
)
|
||||
|
||||
async def open(self) -> None:
|
||||
return None
|
||||
|
||||
async def write_chunk(self, chunk: AudioChunk) -> None:
|
||||
return None
|
||||
|
||||
async def flush(self) -> None:
|
||||
return None
|
||||
|
||||
async def close(self) -> None:
|
||||
return None
|
||||
38
app/audio/router.py
Normal file
38
app/audio/router.py
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
from app.errors import UnknownEndpointError
|
||||
|
||||
|
||||
class AudioRouter:
|
||||
def __init__(self, inputs, outputs):
|
||||
self.inputs = inputs
|
||||
self.outputs = outputs
|
||||
|
||||
async def list_inputs(self):
|
||||
return [await endpoint.capabilities() for endpoint in self.inputs]
|
||||
|
||||
async def list_outputs(self):
|
||||
return [await endpoint.capabilities() for endpoint in self.outputs]
|
||||
|
||||
async def select_input(self, preferred: str | None = None):
|
||||
return await self._select(self.inputs, preferred, direction="input")
|
||||
|
||||
async def select_output(self, preferred: str | None = None):
|
||||
return await self._select(self.outputs, preferred, direction="output")
|
||||
|
||||
async def _select(self, endpoints, preferred: str | None, direction: str):
|
||||
# Capabilities einmal sammeln (Basis fuer spaetere capability-basierte Auswahl).
|
||||
pairs = [(endpoint, await endpoint.capabilities()) for endpoint in endpoints]
|
||||
|
||||
if preferred:
|
||||
for endpoint, caps in pairs:
|
||||
if caps.id == preferred or caps.kind == preferred:
|
||||
return endpoint
|
||||
# Angefragter Endpunkt existiert nicht -> KEIN stiller Default-Fallback.
|
||||
available = sorted({caps.id for _, caps in pairs} | {caps.kind for _, caps in pairs})
|
||||
raise UnknownEndpointError(
|
||||
f"Unbekannter {direction}-Endpunkt {preferred!r}. Verfuegbar: {available}"
|
||||
)
|
||||
|
||||
for endpoint, caps in pairs:
|
||||
if caps.default:
|
||||
return endpoint
|
||||
raise RuntimeError(f"Kein {direction}-Standardendpunkt verfuegbar")
|
||||
11
app/audio/transport_router.py
Normal file
11
app/audio/transport_router.py
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
class TransportRouter:
|
||||
def __init__(self, local_registry: dict, remote_registry: dict):
|
||||
self.local_registry = local_registry
|
||||
self.remote_registry = remote_registry
|
||||
|
||||
def resolve(self, module_type: str, provider_name: str):
|
||||
if provider_name in self.local_registry.get(module_type, {}):
|
||||
return self.local_registry[module_type][provider_name]
|
||||
if provider_name in self.remote_registry.get(module_type, {}):
|
||||
return self.remote_registry[module_type][provider_name]
|
||||
raise KeyError(f"Unknown provider: {module_type}/{provider_name}")
|
||||
146
app/config.py
Normal file
146
app/config.py
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
import os
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
import tomllib # Python >= 3.11 (stdlib)
|
||||
except ModuleNotFoundError: # pragma: no cover - Fallback fuer aeltere Interpreter
|
||||
import tomli as tomllib # type: ignore
|
||||
|
||||
from pydantic.fields import FieldInfo
|
||||
from pydantic_settings import (
|
||||
BaseSettings,
|
||||
PydanticBaseSettingsSource,
|
||||
SettingsConfigDict,
|
||||
)
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
ENV_FILE = BASE_DIR / ".env"
|
||||
DEFAULT_CONFIG_FILE = BASE_DIR / "config" / "voice-assistant.toml"
|
||||
|
||||
|
||||
def _setting_lookup(key: str) -> str | None:
|
||||
"""Liest einen Steuer-Schluessel: echte Umgebung zuerst, dann die .env-Datei.
|
||||
|
||||
Noetig fuer VA_PROFILE/VA_CONFIG_FILE, weil diese gebraucht werden, BEVOR
|
||||
pydantic-settings die .env laedt - und .env-Werte sonst nicht in os.environ stehen.
|
||||
"""
|
||||
value = os.getenv(key)
|
||||
if value is not None:
|
||||
return value
|
||||
try:
|
||||
from dotenv import dotenv_values
|
||||
except ModuleNotFoundError: # pragma: no cover
|
||||
return None
|
||||
if ENV_FILE.is_file():
|
||||
return dotenv_values(ENV_FILE).get(key)
|
||||
return None
|
||||
|
||||
|
||||
def _config_file_path() -> Path:
|
||||
return Path(_setting_lookup("VA_CONFIG_FILE") or str(DEFAULT_CONFIG_FILE))
|
||||
|
||||
|
||||
def active_profile() -> str | None:
|
||||
"""Name des aktiven Profils (VA_PROFILE) aus Umgebung oder .env, falls gesetzt."""
|
||||
profile = _setting_lookup("VA_PROFILE")
|
||||
return profile.strip() or None if profile else None
|
||||
|
||||
|
||||
def load_profile_config() -> dict:
|
||||
"""Liest die zentrale TOML-Config und merged [defaults] + [profiles.<VA_PROFILE>].
|
||||
|
||||
- Fehlt die Datei, gilt ein leeres dict (nur ENV/Defaults greifen) - kein Fehler,
|
||||
damit reine Cloud-Deployments ohne Datei (nur ENV) funktionieren.
|
||||
- Ein gesetztes, aber unbekanntes VA_PROFILE ist ein Konfigurationsfehler.
|
||||
"""
|
||||
path = _config_file_path()
|
||||
if not path.is_file():
|
||||
return {}
|
||||
|
||||
with path.open("rb") as handle:
|
||||
data = tomllib.load(handle)
|
||||
|
||||
merged: dict = dict(data.get("defaults", {}))
|
||||
|
||||
profile = active_profile()
|
||||
if profile:
|
||||
profiles = data.get("profiles", {})
|
||||
if profile not in profiles:
|
||||
raise ValueError(
|
||||
f"Unbekanntes VA_PROFILE {profile!r}. "
|
||||
f"Verfuegbar: {sorted(profiles)}"
|
||||
)
|
||||
merged.update(profiles[profile])
|
||||
|
||||
return merged
|
||||
|
||||
|
||||
class TomlProfileSource(PydanticBaseSettingsSource):
|
||||
"""Settings-Quelle aus der zentralen TOML-Config (inkl. aktivem Profil).
|
||||
|
||||
Liegt in der Praezedenz unter ENV/.env, aber ueber den eingebauten Defaults.
|
||||
Es werden nur Schluessel durchgereicht, die auch als Settings-Feld existieren.
|
||||
"""
|
||||
|
||||
def __init__(self, settings_cls):
|
||||
super().__init__(settings_cls)
|
||||
raw = load_profile_config()
|
||||
known = set(settings_cls.model_fields)
|
||||
self._values = {
|
||||
key.lower(): value
|
||||
for key, value in raw.items()
|
||||
if key.lower() in known
|
||||
}
|
||||
|
||||
def get_field_value(self, field: FieldInfo, field_name: str):
|
||||
if field_name in self._values:
|
||||
return self._values[field_name], field_name, False
|
||||
return None, field_name, False
|
||||
|
||||
def __call__(self) -> dict:
|
||||
return dict(self._values)
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
app_env: str = "dev"
|
||||
host: str = "0.0.0.0"
|
||||
port: int = 8080
|
||||
log_level: str = "info"
|
||||
openrouter_api_key: str = ""
|
||||
openrouter_stt_model: str = "openai/whisper-large-v3"
|
||||
openrouter_tts_model: str = "openai/gpt-4o-mini-tts"
|
||||
openrouter_tts_voice: str = "alloy"
|
||||
openrouter_llm_model: str = "openai/gpt-4.1-mini"
|
||||
default_language: str = "de"
|
||||
default_input_endpoint: str = "local-default"
|
||||
default_output_endpoint: str = "local-default"
|
||||
default_stt_provider: str = "openrouter"
|
||||
default_llm_provider: str = "local-openai-compatible"
|
||||
default_tts_provider: str = "openrouter"
|
||||
local_llm_base_url: str = "http://127.0.0.1:11434/v1"
|
||||
local_llm_api_key: str = "dummy"
|
||||
local_llm_model: str = "llama3.1"
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=ENV_FILE, case_sensitive=False, extra="ignore"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def settings_customise_sources(
|
||||
cls,
|
||||
settings_cls,
|
||||
init_settings,
|
||||
env_settings,
|
||||
dotenv_settings,
|
||||
file_secret_settings,
|
||||
):
|
||||
# Praezedenz (frueher = hoeher): init > ENV > .env > TOML/Profil > Defaults
|
||||
return (
|
||||
init_settings,
|
||||
env_settings,
|
||||
dotenv_settings,
|
||||
TomlProfileSource(settings_cls),
|
||||
file_secret_settings,
|
||||
)
|
||||
|
||||
|
||||
settings = Settings()
|
||||
0
app/core/__init__.py
Normal file
0
app/core/__init__.py
Normal file
105
app/core/orchestrator.py
Normal file
105
app/core/orchestrator.py
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
from app.schemas import AudioChunk, PipelineTrace
|
||||
|
||||
# Festes Ausgabeformat der TTS-Stufe (s16le PCM, 24 kHz, mono).
|
||||
TTS_AUDIO_FORMAT = "pcm"
|
||||
TTS_SAMPLE_RATE = 24000
|
||||
TTS_CHANNELS = 1
|
||||
|
||||
|
||||
class Orchestrator:
|
||||
def __init__(self, stt, llm, tts, input_cleaner, spoken_adapter, tts_normalizer):
|
||||
self.stt = stt
|
||||
self.llm = llm
|
||||
self.tts = tts
|
||||
self.input_cleaner = input_cleaner
|
||||
self.spoken_adapter = spoken_adapter
|
||||
self.tts_normalizer = tts_normalizer
|
||||
|
||||
async def _emit_to_output(self, audio: bytes, output) -> None:
|
||||
"""Schreibt das synthetisierte Audio durch den gewaehlten Output-Endpunkt.
|
||||
|
||||
Der HTTP-Stream bleibt davon unberuehrt (additiv). Bei lokalen Geraeten
|
||||
ist write_chunk heute ein no-op; LoopbackOutput sammelt die Chunks.
|
||||
"""
|
||||
if output is None:
|
||||
return
|
||||
chunk = AudioChunk(
|
||||
data=audio,
|
||||
sample_rate=TTS_SAMPLE_RATE,
|
||||
channels=TTS_CHANNELS,
|
||||
format=TTS_AUDIO_FORMAT,
|
||||
)
|
||||
await output.open()
|
||||
try:
|
||||
await output.write_chunk(chunk)
|
||||
await output.flush()
|
||||
finally:
|
||||
await output.close()
|
||||
|
||||
async def transcribe_only(
|
||||
self,
|
||||
audio_bytes: bytes,
|
||||
fmt: str,
|
||||
language: str | None = None,
|
||||
input=None,
|
||||
):
|
||||
trace = PipelineTrace()
|
||||
# input dient hier nur der Validierung/Metadaten; das Audio kommt per Upload.
|
||||
if input is not None:
|
||||
await input.capabilities()
|
||||
trace.raw_transcript = await self.stt.transcribe(
|
||||
audio_bytes,
|
||||
fmt=fmt,
|
||||
language=language,
|
||||
)
|
||||
trace.cleaned_transcript = await self.input_cleaner.run(
|
||||
trace.raw_transcript or ""
|
||||
)
|
||||
return trace
|
||||
|
||||
async def speak_only(
|
||||
self,
|
||||
text: str,
|
||||
voice: str | None = None,
|
||||
language: str | None = None,
|
||||
output=None,
|
||||
):
|
||||
spoken = await self.spoken_adapter.run(text, language=language)
|
||||
normalized = await self.tts_normalizer.run(spoken, language=language)
|
||||
audio = await self.tts.synthesize(normalized, voice=voice)
|
||||
await self._emit_to_output(audio, output)
|
||||
return audio
|
||||
|
||||
async def chat_text(
|
||||
self,
|
||||
text: str,
|
||||
language: str | None = None,
|
||||
voice: str | None = None,
|
||||
output=None,
|
||||
):
|
||||
trace = PipelineTrace()
|
||||
|
||||
trace.raw_transcript = text
|
||||
trace.cleaned_transcript = await self.input_cleaner.run(text or "")
|
||||
|
||||
trace.semantic_response = await self.llm.complete(
|
||||
trace.cleaned_transcript or ""
|
||||
)
|
||||
if not trace.semantic_response:
|
||||
raise RuntimeError("LLM returned an empty response")
|
||||
|
||||
trace.spoken_response = await self.spoken_adapter.run(
|
||||
trace.semantic_response,
|
||||
language=language,
|
||||
)
|
||||
trace.tts_ready_text = await self.tts_normalizer.run(
|
||||
trace.spoken_response,
|
||||
language=language,
|
||||
)
|
||||
|
||||
audio = await self.tts.synthesize(
|
||||
trace.tts_ready_text,
|
||||
voice=voice,
|
||||
)
|
||||
await self._emit_to_output(audio, output)
|
||||
return trace, audio
|
||||
11
app/core/session_manager.py
Normal file
11
app/core/session_manager.py
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
class SessionManager:
|
||||
def __init__(self):
|
||||
self._sessions = {}
|
||||
|
||||
def get(self, session_id: str) -> dict:
|
||||
return self._sessions.setdefault(session_id, {})
|
||||
|
||||
def update(self, session_id: str, values: dict) -> dict:
|
||||
session = self.get(session_id)
|
||||
session.update({k: v for k, v in values.items() if v is not None})
|
||||
return session
|
||||
187
app/dependencies.py
Normal file
187
app/dependencies.py
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
from dataclasses import dataclass
|
||||
|
||||
from app.config import Settings, settings
|
||||
from app.errors import UnknownComponentError
|
||||
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.input.mobile_ws import MobileWebSocketInput
|
||||
from app.audio.endpoints.input.mobile_webrtc import MobileWebRTCInput
|
||||
from app.audio.endpoints.output.local_default import LocalDefaultOutput
|
||||
from app.audio.endpoints.output.bluetooth import BluetoothOutput
|
||||
from app.audio.endpoints.output.mobile_ws import MobileWebSocketOutput
|
||||
from app.audio.endpoints.output.mobile_webrtc import MobileWebRTCOutput
|
||||
from app.audio.endpoints.output.loopback import LoopbackOutput
|
||||
from app.providers.stt.openrouter import OpenRouterSTTProvider
|
||||
from app.providers.stt.faster_whisper import FasterWhisperProvider
|
||||
from app.providers.llm.local_openai_compatible import LocalOpenAICompatibleLLM
|
||||
from app.providers.llm.openrouter import OpenRouterLLMProvider
|
||||
from app.providers.tts.openrouter import OpenRouterTTSProvider
|
||||
from app.providers.tts.chatterbox import ChatterboxTTSProvider
|
||||
from app.providers.tts.piper import PiperTTSProvider
|
||||
from app.pipeline.input_cleaner import InputCleaner
|
||||
from app.pipeline.spoken_response_adapter import SpokenResponseAdapter
|
||||
from app.pipeline.tts_normalizer import TTSNormalizer
|
||||
from app.core.orchestrator import Orchestrator
|
||||
from app.core.session_manager import SessionManager
|
||||
|
||||
session_manager = SessionManager()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provider-Registries: Modul austauschbar via Name, ohne Kern-Code zu aendern.
|
||||
# Ein neuer Provider = ein Eintrag. Unbekannter Name -> UnknownComponentError.
|
||||
# ---------------------------------------------------------------------------
|
||||
STT_REGISTRY = {
|
||||
"openrouter": lambda s: OpenRouterSTTProvider(s.openrouter_api_key, s.openrouter_stt_model),
|
||||
"faster-whisper": lambda s: FasterWhisperProvider(),
|
||||
}
|
||||
|
||||
LLM_REGISTRY = {
|
||||
"openrouter": lambda s: OpenRouterLLMProvider(s.openrouter_api_key, s.openrouter_llm_model),
|
||||
"local-openai-compatible": lambda s: LocalOpenAICompatibleLLM(
|
||||
s.local_llm_base_url, s.local_llm_api_key, s.local_llm_model
|
||||
),
|
||||
}
|
||||
|
||||
TTS_REGISTRY = {
|
||||
"openrouter": lambda s: OpenRouterTTSProvider(
|
||||
s.openrouter_api_key, s.openrouter_tts_model, s.openrouter_tts_voice
|
||||
),
|
||||
"chatterbox": lambda s: ChatterboxTTSProvider(),
|
||||
"piper": lambda s: PiperTTSProvider(),
|
||||
}
|
||||
|
||||
|
||||
def _from_registry(registry: dict, name: str, kind: str, cfg: Settings):
|
||||
try:
|
||||
factory = registry[name]
|
||||
except KeyError as exc:
|
||||
raise UnknownComponentError(
|
||||
f"Unbekannter {kind}-Provider {name!r}. Verfuegbar: {sorted(registry)}"
|
||||
) from exc
|
||||
return factory(cfg)
|
||||
|
||||
|
||||
def get_stt_provider(name: str | None = None, cfg: Settings = settings):
|
||||
return _from_registry(STT_REGISTRY, name or cfg.default_stt_provider, "STT", cfg)
|
||||
|
||||
|
||||
def get_llm_provider(name: str | None = None, cfg: Settings = settings):
|
||||
return _from_registry(LLM_REGISTRY, name or cfg.default_llm_provider, "LLM", cfg)
|
||||
|
||||
|
||||
def get_tts_provider(name: str | None = None, cfg: Settings = settings):
|
||||
return _from_registry(TTS_REGISTRY, name or cfg.default_tts_provider, "TTS", cfg)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Audio-Router: Modul-Singleton, damit zustandsbehaftete Endpunkte
|
||||
# (z. B. LoopbackOutput.chunks) ueber Requests hinweg stabil bleiben.
|
||||
# ---------------------------------------------------------------------------
|
||||
_audio_router: AudioRouter | None = None
|
||||
|
||||
|
||||
def get_audio_router() -> AudioRouter:
|
||||
global _audio_router
|
||||
if _audio_router is None:
|
||||
_audio_router = AudioRouter(
|
||||
inputs=[
|
||||
LocalDefaultInput(),
|
||||
BluetoothInput(),
|
||||
MobileWebSocketInput(),
|
||||
MobileWebRTCInput(),
|
||||
],
|
||||
outputs=[
|
||||
LocalDefaultOutput(),
|
||||
BluetoothOutput(),
|
||||
MobileWebSocketOutput(),
|
||||
MobileWebRTCOutput(),
|
||||
LoopbackOutput(),
|
||||
],
|
||||
)
|
||||
return _audio_router
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Session-Routing und einheitliche Route-Aufloesung ueber alle Achsen.
|
||||
# Praezedenz: Settings-Defaults < Session-Route < Request-Overrides.
|
||||
# ---------------------------------------------------------------------------
|
||||
ROUTE_KEYS = (
|
||||
"input_endpoint",
|
||||
"output_endpoint",
|
||||
"stt_provider",
|
||||
"llm_provider",
|
||||
"tts_provider",
|
||||
"language",
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ResolvedRoute:
|
||||
input_endpoint: str
|
||||
output_endpoint: str
|
||||
stt_provider: str
|
||||
llm_provider: str
|
||||
tts_provider: str
|
||||
language: str
|
||||
|
||||
def as_dict(self) -> dict:
|
||||
return {
|
||||
"input_endpoint": self.input_endpoint,
|
||||
"output_endpoint": self.output_endpoint,
|
||||
"stt_provider": self.stt_provider,
|
||||
"llm_provider": self.llm_provider,
|
||||
"tts_provider": self.tts_provider,
|
||||
"language": self.language,
|
||||
}
|
||||
|
||||
|
||||
def get_session_route(session_id: str | None) -> dict:
|
||||
"""Liefert die gespeicherte Route einer Session (leeres dict ohne session_id)."""
|
||||
return session_manager.get(session_id) if session_id else {}
|
||||
|
||||
|
||||
def resolve_route(
|
||||
session_id: str | None = None,
|
||||
overrides: dict | None = None,
|
||||
cfg: Settings = settings,
|
||||
) -> ResolvedRoute:
|
||||
"""Loest die effektive Route aus Defaults, Session und Request-Overrides auf."""
|
||||
resolved = {
|
||||
"input_endpoint": cfg.default_input_endpoint,
|
||||
"output_endpoint": cfg.default_output_endpoint,
|
||||
"stt_provider": cfg.default_stt_provider,
|
||||
"llm_provider": cfg.default_llm_provider,
|
||||
"tts_provider": cfg.default_tts_provider,
|
||||
"language": cfg.default_language,
|
||||
}
|
||||
|
||||
session_route = get_session_route(session_id)
|
||||
request_overrides = overrides or {}
|
||||
|
||||
for layer in (session_route, request_overrides):
|
||||
for key in ROUTE_KEYS:
|
||||
value = layer.get(key)
|
||||
if value is not None:
|
||||
resolved[key] = value
|
||||
|
||||
return ResolvedRoute(**resolved)
|
||||
|
||||
|
||||
def build_orchestrator(route: ResolvedRoute, cfg: Settings = settings) -> Orchestrator:
|
||||
return Orchestrator(
|
||||
stt=get_stt_provider(route.stt_provider, cfg),
|
||||
llm=get_llm_provider(route.llm_provider, cfg),
|
||||
tts=get_tts_provider(route.tts_provider, cfg),
|
||||
input_cleaner=InputCleaner(),
|
||||
spoken_adapter=SpokenResponseAdapter(),
|
||||
tts_normalizer=TTSNormalizer(),
|
||||
)
|
||||
|
||||
|
||||
async def resolve_output_endpoint(route: ResolvedRoute):
|
||||
return await get_audio_router().select_output(route.output_endpoint)
|
||||
|
||||
|
||||
async def resolve_input_endpoint(route: ResolvedRoute):
|
||||
return await get_audio_router().select_input(route.input_endpoint)
|
||||
13
app/errors.py
Normal file
13
app/errors.py
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
class RoutingError(Exception):
|
||||
"""Basis fuer Fehler bei der Routing-/Komponentenauswahl.
|
||||
|
||||
Wird in der API-Schicht zu HTTP 422 uebersetzt (Client-Konfigurationsfehler).
|
||||
"""
|
||||
|
||||
|
||||
class UnknownComponentError(RoutingError):
|
||||
"""Unbekannter Provider-Name fuer STT, LLM oder TTS."""
|
||||
|
||||
|
||||
class UnknownEndpointError(RoutingError):
|
||||
"""Ein angefragter Audio-Endpunkt (input/output) existiert nicht."""
|
||||
17
app/main.py
Normal file
17
app/main.py
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
from fastapi import FastAPI
|
||||
from app.api.health import router as health_router
|
||||
from app.api.chat import router as chat_router
|
||||
from app.api.transcribe import router as transcribe_router
|
||||
from app.api.speak import router as speak_router
|
||||
from app.api.devices import router as devices_router
|
||||
from app.api.sessions import router as sessions_router
|
||||
from app.api.config import router as config_router
|
||||
|
||||
app = FastAPI(title="Voice Assistant Gateway")
|
||||
app.include_router(health_router)
|
||||
app.include_router(chat_router, prefix="/api")
|
||||
app.include_router(transcribe_router, prefix="/api")
|
||||
app.include_router(speak_router, prefix="/api")
|
||||
app.include_router(devices_router, prefix="/api")
|
||||
app.include_router(sessions_router, prefix="/api")
|
||||
app.include_router(config_router, prefix="/api")
|
||||
0
app/pipeline/__init__.py
Normal file
0
app/pipeline/__init__.py
Normal file
4
app/pipeline/input_cleaner.py
Normal file
4
app/pipeline/input_cleaner.py
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
class InputCleaner:
|
||||
async def run(self, text: str) -> str:
|
||||
cleaned = " ".join(text.strip().split())
|
||||
return cleaned.replace(" äh ", " ").replace(" hm ", " ")
|
||||
37
app/pipeline/spoken_response_adapter.py
Normal file
37
app/pipeline/spoken_response_adapter.py
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import re
|
||||
|
||||
|
||||
class SpokenResponseAdapter:
|
||||
async def run(self, text: str, language: str = "de") -> str:
|
||||
if not text:
|
||||
return ""
|
||||
|
||||
text = text.strip()
|
||||
|
||||
# Markdown / Formatierung entfernen
|
||||
text = re.sub(r"```[\s\S]*?```", " ", text) # code blocks
|
||||
text = re.sub(r"`([^`]*)`", r"\1", text) # inline code
|
||||
text = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", text) # markdown links
|
||||
text = re.sub(r"[*_~#>]+", " ", text) # markdown symbols
|
||||
|
||||
# Listen entschärfen
|
||||
text = re.sub(r"(?m)^\s*[-•]\s+", "", text)
|
||||
text = re.sub(r"(?m)^\s*\d+\.\s+", "", text)
|
||||
|
||||
# Mehrfache Leerzeichen / Zeilenumbrüche glätten
|
||||
text = re.sub(r"\s+", " ", text).strip()
|
||||
|
||||
# Für Voice natürlicher machen: Doppelpunkte/Semikolons etwas beruhigen,
|
||||
# aber Uhrzeiten/Verhältnisse (10:30) nicht zerstören -> nur am Wortende ersetzen.
|
||||
text = re.sub(r"[:;](?=\s|$)", ",", text)
|
||||
|
||||
# Klammern meist nicht gut für TTS
|
||||
text = text.replace("(", ", ")
|
||||
text = text.replace(")", " ")
|
||||
|
||||
# Abschlusspunktion sicherstellen
|
||||
if text and not text.endswith((".", "!", "?")):
|
||||
text += "."
|
||||
|
||||
return text
|
||||
|
||||
59
app/pipeline/tts_normalizer.py
Normal file
59
app/pipeline/tts_normalizer.py
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import re
|
||||
|
||||
|
||||
class TTSNormalizer:
|
||||
async def run(self, text: str, language: str = "de") -> str:
|
||||
if not text:
|
||||
return ""
|
||||
|
||||
normalized = text
|
||||
|
||||
if language == "de":
|
||||
replacements = {
|
||||
"24/7": "vierundzwanzig sieben",
|
||||
"&": " und ",
|
||||
"%": " Prozent",
|
||||
"€": " Euro",
|
||||
"$": " Dollar",
|
||||
"km/h": " Kilometer pro Stunde",
|
||||
"z.B.": "zum Beispiel",
|
||||
"bzw.": "beziehungsweise",
|
||||
"u.a.": "unter anderem",
|
||||
"ca.": "circa",
|
||||
}
|
||||
else:
|
||||
replacements = {
|
||||
"24/7": "twenty four seven",
|
||||
"&": " and ",
|
||||
"%": " percent",
|
||||
"€": " euros",
|
||||
"$": " dollars",
|
||||
"km/h": " kilometers per hour",
|
||||
"e.g.": "for example",
|
||||
"i.e.": "that is",
|
||||
}
|
||||
|
||||
for old, new in replacements.items():
|
||||
normalized = normalized.replace(old, new)
|
||||
|
||||
# Slashes zwischen Wörtern/Zahlen sprachfreundlicher machen
|
||||
normalized = re.sub(r"(\w)/(\w)", r"\1 oder \2", normalized)
|
||||
|
||||
# Datums-/Versions-/Bereichsstriche etwas entschärfen
|
||||
normalized = normalized.replace("–", " bis ")
|
||||
normalized = normalized.replace("—", ", ")
|
||||
normalized = normalized.replace(" - ", ", ")
|
||||
|
||||
# URLs und E-Mails nicht roh vorlesen
|
||||
normalized = re.sub(r"https?://\S+", "Link", normalized)
|
||||
normalized = re.sub(r"\b[\w\.-]+@[\w\.-]+\.\w+\b", "E-Mail-Adresse", normalized)
|
||||
|
||||
# Mehrfache Leerzeichen glätten
|
||||
normalized = re.sub(r"\s+", " ", normalized).strip()
|
||||
|
||||
if normalized and not normalized.endswith((".", "!", "?")):
|
||||
normalized += "."
|
||||
|
||||
return normalized
|
||||
|
||||
|
||||
0
app/providers/__init__.py
Normal file
0
app/providers/__init__.py
Normal file
0
app/providers/llm/__init__.py
Normal file
0
app/providers/llm/__init__.py
Normal file
5
app/providers/llm/base.py
Normal file
5
app/providers/llm/base.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
from abc import ABC, abstractmethod
|
||||
|
||||
class LLMProvider(ABC):
|
||||
@abstractmethod
|
||||
async def complete(self, text: str, session_id: str | None = None) -> str: ...
|
||||
24
app/providers/llm/local_openai_compatible.py
Normal file
24
app/providers/llm/local_openai_compatible.py
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import httpx
|
||||
from app.providers.llm.base import LLMProvider
|
||||
|
||||
class LocalOpenAICompatibleLLM(LLMProvider):
|
||||
def __init__(self, base_url: str, api_key: str, model: str):
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.api_key = api_key
|
||||
self.model = model
|
||||
|
||||
async def complete(self, text: str, session_id: str | None = None) -> str:
|
||||
payload = {
|
||||
"model": self.model,
|
||||
"messages": [{"role": "user", "content": text}],
|
||||
"temperature": 0.3,
|
||||
}
|
||||
async with httpx.AsyncClient(timeout=120) as client:
|
||||
response = await client.post(
|
||||
f"{self.base_url}/chat/completions",
|
||||
headers={"Authorization": f"Bearer {self.api_key}"},
|
||||
json=payload,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
return data["choices"][0]["message"]["content"]
|
||||
101
app/providers/llm/openrouter.py
Normal file
101
app/providers/llm/openrouter.py
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import httpx
|
||||
|
||||
from app.providers.llm.base import LLMProvider
|
||||
|
||||
|
||||
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):
|
||||
self.api_key = (api_key or "").strip()
|
||||
self.model = (model or "").strip()
|
||||
|
||||
async def complete(self, text: str, session_id: str | None = None) -> str:
|
||||
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")
|
||||
|
||||
payload = {
|
||||
"model": self.model,
|
||||
"messages": [
|
||||
{"role": "system", "content": SYSTEM_PROMPT},
|
||||
{"role": "user", "content": text.strip()},
|
||||
],
|
||||
}
|
||||
|
||||
timeout = httpx.Timeout(connect=10.0, read=120.0, write=30.0, pool=10.0)
|
||||
|
||||
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,
|
||||
)
|
||||
response.raise_for_status()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
raise RuntimeError(
|
||||
f"OpenRouter LLM error {exc.response.status_code}: {exc.response.text}"
|
||||
) 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()
|
||||
|
||||
0
app/providers/stt/__init__.py
Normal file
0
app/providers/stt/__init__.py
Normal file
5
app/providers/stt/base.py
Normal file
5
app/providers/stt/base.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
from abc import ABC, abstractmethod
|
||||
|
||||
class STTProvider(ABC):
|
||||
@abstractmethod
|
||||
async def transcribe(self, audio_bytes: bytes, fmt: str, language: str | None = None) -> str: ...
|
||||
5
app/providers/stt/faster_whisper.py
Normal file
5
app/providers/stt/faster_whisper.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
from app.providers.stt.base import STTProvider
|
||||
|
||||
class FasterWhisperProvider(STTProvider):
|
||||
async def transcribe(self, audio_bytes: bytes, fmt: str, language: str | None = None) -> str:
|
||||
return "[local transcription placeholder]"
|
||||
46
app/providers/stt/openrouter.py
Normal file
46
app/providers/stt/openrouter.py
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import httpx
|
||||
|
||||
from app.providers.stt.base import STTProvider
|
||||
|
||||
|
||||
class OpenRouterSTTProvider(STTProvider):
|
||||
def __init__(self, api_key: str, model: str):
|
||||
self.api_key = (api_key or "").strip()
|
||||
self.model = (model or "").strip()
|
||||
|
||||
async def transcribe(self, audio_bytes: bytes, fmt: str, language: str | None = None) -> str:
|
||||
if not self.api_key:
|
||||
raise ValueError("OPENROUTER_API_KEY is empty")
|
||||
if not self.model:
|
||||
raise ValueError("OPENROUTER_STT_MODEL is empty")
|
||||
if not audio_bytes:
|
||||
raise ValueError("STT input audio is empty")
|
||||
|
||||
# OpenAI-kompatibler /audio/transcriptions-Endpunkt erwartet multipart/form-data
|
||||
# mit binärem file-Feld, nicht JSON mit base64.
|
||||
files = {"file": (f"audio.{fmt}", audio_bytes, f"audio/{fmt}")}
|
||||
data: dict[str, str] = {"model": self.model}
|
||||
if language:
|
||||
data["language"] = language
|
||||
|
||||
timeout = httpx.Timeout(connect=10.0, read=120.0, write=30.0, pool=10.0)
|
||||
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
try:
|
||||
response = await client.post(
|
||||
"https://openrouter.ai/api/v1/audio/transcriptions",
|
||||
headers={"Authorization": f"Bearer {self.api_key}"},
|
||||
files=files,
|
||||
data=data,
|
||||
)
|
||||
response.raise_for_status()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
raise RuntimeError(
|
||||
f"OpenRouter STT error {exc.response.status_code}: {exc.response.text}"
|
||||
) from exc
|
||||
except httpx.TimeoutException as exc:
|
||||
raise RuntimeError("OpenRouter STT timeout") from exc
|
||||
except httpx.HTTPError as exc:
|
||||
raise RuntimeError(f"OpenRouter STT transport error: {exc}") from exc
|
||||
|
||||
return response.json().get("text", "")
|
||||
0
app/providers/tts/__init__.py
Normal file
0
app/providers/tts/__init__.py
Normal file
5
app/providers/tts/base.py
Normal file
5
app/providers/tts/base.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
from abc import ABC, abstractmethod
|
||||
|
||||
class TTSProvider(ABC):
|
||||
@abstractmethod
|
||||
async def synthesize(self, text: str, voice: str | None = None, audio_format: str = "pcm") -> bytes: ...
|
||||
5
app/providers/tts/chatterbox.py
Normal file
5
app/providers/tts/chatterbox.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
from app.providers.tts.base import TTSProvider
|
||||
|
||||
class ChatterboxTTSProvider(TTSProvider):
|
||||
async def synthesize(self, text: str, voice: str | None = None, audio_format: str = "pcm") -> bytes:
|
||||
return b""
|
||||
62
app/providers/tts/openrouter.py
Normal file
62
app/providers/tts/openrouter.py
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import httpx
|
||||
|
||||
from app.providers.tts.base import TTSProvider
|
||||
|
||||
|
||||
class OpenRouterTTSProvider(TTSProvider):
|
||||
def __init__(self, api_key: str, model: str, voice: str):
|
||||
self.api_key = (api_key or "").strip()
|
||||
self.model = (model or "").strip()
|
||||
self.voice = (voice or "").strip()
|
||||
|
||||
async def synthesize(
|
||||
self,
|
||||
text: str,
|
||||
voice: str | None = None,
|
||||
audio_format: str = "pcm",
|
||||
) -> bytes:
|
||||
if not self.api_key:
|
||||
raise ValueError("OPENROUTER_API_KEY is empty")
|
||||
if not self.model:
|
||||
raise ValueError("OPENROUTER_TTS_MODEL is empty")
|
||||
if not text or not text.strip():
|
||||
raise ValueError("TTS input text is empty")
|
||||
|
||||
effective_voice = (voice or self.voice).strip()
|
||||
if not effective_voice:
|
||||
raise ValueError("TTS voice is required for OpenRouter TTS")
|
||||
|
||||
payload = {
|
||||
"model": self.model,
|
||||
"input": text.strip(),
|
||||
"voice": effective_voice,
|
||||
"response_format": audio_format,
|
||||
}
|
||||
|
||||
timeout = httpx.Timeout(connect=10.0, read=120.0, write=30.0, pool=10.0)
|
||||
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
try:
|
||||
response = await client.post(
|
||||
"https://openrouter.ai/api/v1/audio/speech",
|
||||
headers={
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json=payload,
|
||||
)
|
||||
response.raise_for_status()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
raise RuntimeError(
|
||||
f"OpenRouter TTS error {exc.response.status_code}: {exc.response.text}"
|
||||
) from exc
|
||||
except httpx.TimeoutException as exc:
|
||||
raise RuntimeError("OpenRouter TTS timeout") from exc
|
||||
except httpx.HTTPError as exc:
|
||||
raise RuntimeError(f"OpenRouter TTS transport error: {exc}") from exc
|
||||
|
||||
if not response.content:
|
||||
raise RuntimeError("OpenRouter TTS returned empty audio content")
|
||||
|
||||
return response.content
|
||||
|
||||
5
app/providers/tts/piper.py
Normal file
5
app/providers/tts/piper.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
from app.providers.tts.base import TTSProvider
|
||||
|
||||
class PiperTTSProvider(TTSProvider):
|
||||
async def synthesize(self, text: str, voice: str | None = None, audio_format: str = "pcm") -> bytes:
|
||||
return b""
|
||||
71
app/schemas.py
Normal file
71
app/schemas.py
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
from typing import Literal
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
class SpeakRequest(BaseModel):
|
||||
text: str = Field(min_length=1)
|
||||
voice: str | None = None
|
||||
language: str | None = None
|
||||
output_endpoint: str | None = None
|
||||
tts_provider: str | None = None
|
||||
|
||||
|
||||
class ChatRequest(BaseModel):
|
||||
text: str = Field(min_length=1)
|
||||
input_endpoint: str | None = None
|
||||
output_endpoint: str | None = None
|
||||
language: str | None = None
|
||||
voice: str | None = None
|
||||
stt_provider: str | None = None
|
||||
llm_provider: str | None = None
|
||||
tts_provider: str | None = None
|
||||
|
||||
|
||||
class SessionRouteRequest(BaseModel):
|
||||
input_endpoint: str | None = None
|
||||
output_endpoint: str | None = None
|
||||
stt_provider: str | None = None
|
||||
llm_provider: str | None = None
|
||||
tts_provider: str | None = None
|
||||
language: str | None = None
|
||||
|
||||
|
||||
class RouteInfo(BaseModel):
|
||||
input_endpoint: str
|
||||
output_endpoint: str
|
||||
stt_provider: str
|
||||
llm_provider: str
|
||||
tts_provider: str
|
||||
language: str
|
||||
|
||||
0
app/utils/__init__.py
Normal file
0
app/utils/__init__.py
Normal file
Loading…
Add table
Add a link
Reference in a new issue