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.]. - 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" faster_whisper_model: str = "base" # tiny|base|small|medium|large-v3 faster_whisper_device: str = "auto" # auto|cpu|cuda faster_whisper_compute_type: str = "default" # default|int8|float16|int8_float16 db_path: str = str(BASE_DIR / "data" / "voice-assistant.db") admin_api_key: str = "" auth_enabled: bool = True history_max_messages: int = 10 audio_stream_default: bool = True # satzweises TTS als Default (Admin kann abschalten) stt_fallback: str = "" # kommaseparierte Provider-Namen (Fallback-Kette) llm_fallback: str = "" tts_fallback: str = "" daily_request_limit: int = 0 # 0 = unbegrenzt; Anfragen pro Nutzer pro Tag emergency_webhook_url: str = "" # optionaler Eskalations-Webhook 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()