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
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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue