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" # Lokaler llama.cpp-Server (OpenAI-kompatibel), siehe scripts/llm-server/. local_llm_base_url: str = "http://127.0.0.1:8001/v1" local_llm_api_key: str = "dummy" local_llm_model: str = "va_llm" # = --alias des llama.cpp-Servers # Sprach-Assistent: knappe, vorlesbare Antworten + Reasoning aus = deutlich schneller. local_llm_system_prompt: str = ( "Du bist ein gesprochener Sprachassistent. Antworte kurz und natuerlich " "(in der Regel 1-3 Saetze), in reinem Fliesstext ohne Markdown, ohne " "Aufzaehlungen, ohne Emojis. Formuliere so, wie man es laut vorliest." ) local_llm_disable_reasoning: bool = True # Qwen3 /no_think: spart die Denkphase local_llm_max_tokens: int = 0 # 0 = serverseitiges Limit (-n) local_llm_temperature: float = 0.3 local_llm_top_p: float = 0.9 # Nucleus-Sampling (0.0–1.0) faster_whisper_model: str = "small" # tiny|base|small|medium|large-v3 faster_whisper_device: str = "auto" # auto|cpu|cuda faster_whisper_compute_type: str = "int8" # default|int8|float16|int8_float16 stt_force_language: bool = True # STT mit fester Zielsprache (robust); False = Auto-Detect + Übersetzung # GPU-Sichtbarkeit: "auto" = erkennen (respektiert vorhandenes CUDA_VISIBLE_DEVICES), # "none"/leer = keine GPU erzwingen, "0,1,2" = nur diese Indizes. Wird beim Start an # CUDA_VISIBLE_DEVICES gekoppelt (siehe _apply_gpu_visibility), damit das, was das Menü # anzeigt, auch real von torch/faster-whisper genutzt wird. gpus: str = "auto" # --- Lokales TTS (piper) ------------------------------------------------- piper_bin: str = "piper" # Pfad/Name des piper-Binaries piper_voices_dir: str = str(Path.home() / ".local" / "share" / "piper" / "voices") piper_voice: str = "de_DE-thorsten-high" # Stimmmodell-Name (ohne .onnx) oder voller Pfad tts_sample_rate: int = 24000 # Ziel-Sample-Rate (das Gateway erwartet 24000 Hz) # --- Chatterbox-TTS (hohe Qualitaet + Voice-Cloning, eigener HTTP-Dienst) - # --- Cartesia TTS (Cloud, sehr niedrige Latenz) ------------------------- cartesia_api_key: str = "" cartesia_voice_id: str = "" # UUID aus cartesia.ai/voices (Fallback) cartesia_tts_model: str = "sonic-turbo" cartesia_voices: str = "" # "lang:gender:uuid:name" je Zeile, # = Kommentar # sonic-2 | sonic-multilingual chatterbox_base_url: str = "http://127.0.0.1:9999" chatterbox_voice: str = "" # Pfad zu Referenz-WAV (Voice-Cloning) oder leer chatterbox_lang: str = "de" chatterbox_speed: float = 1.0 chatterbox_timeout: int = 180 # Verzeichnis mit nativen Referenz-WAVs je Sprache (Konvention .wav, z. B. fr.wav). # Greift cross-lingual: pro Sprache eine muttersprachliche Stimme. Leer -> nur chatterbox_voice. chatterbox_voices_dir: str = str(BASE_DIR / "config" / "voices") db_path: str = str(BASE_DIR / "data" / "voice-assistant.db") admin_api_key: str = "" auth_enabled: bool = True # --- Forward-/Trusted-Header-Auth (Reverse-Proxy / YunoHost-SSO) --------- # Ist trusted_auth_header gesetzt UND die Quell-IP in trusted_proxy_ips, wird die # Identitaet aus diesem Header gelesen (SSO-User) und ein interner Nutzer # automatisch angelegt. Sonst gilt die normale Token-/Anonymous-Auth. trusted_auth_header: str = "" # Alternativ zur Header-Variante: Identitaet aus einem (signierten) JWT-Cookie lesen. # YunoHost reicht den Usernamen nicht als Header durch, sondern im Cookie # "yunohost.portal" (JWT, Claim "user"). Nur von der Proxy-IP akzeptiert. trusted_auth_cookie: str = "" # Cookie-Name (z. B. yunohost.portal) trusted_auth_cookie_claim: str = "user" # JWT-Claim mit dem Usernamen trusted_auth_jwt_secret: str = "" # optional: HS256-Secret -> Signatur pruefen trusted_proxy_ips: str = "" # kommasepariert; IP(s) des Reverse-Proxys admin_users: str = "" # kommaseparierte SSO-Usernamen mit Admin-Rechten sso_logout_url: str = "" # Logout-Link fuers Frontend (SSO-Portal) # Login-/Portal-URL: unauthentifizierte Seitenaufrufe werden hierhin umgeleitet # (Defense-in-Depth zusaetzlich zu SSOwat). Leer -> stattdessen HTTP 401. sso_login_url: str = "" history_max_messages: int = 10 # Automatische Erinnerungs-Extraktion: das LLM destilliert dauerhafte Fakten # aus dem Gespraech und legt sie als Nutzer-Erinnerungen ab (best-effort, # nicht-blockierend). Leerer Provider = Default-LLM-Provider. memory_extraction_enabled: bool = True memory_extraction_every_n_turns: int = 3 memory_extraction_max: int = 50 memory_extraction_provider: str = "" audio_stream_default: bool = True # satzweises TTS als Default (Admin kann abschalten) web_search_enabled: bool = True # Web-Suche via Tool-Calling (Weg 2); global an, pro Nutzer abschaltbar # TTS-Text-Normalisierung: auto|full|light|off. "auto" = piper -> full, Cloud -> light. tts_normalize_level: str = "auto" 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 # Notruf-Benachrichtigung per E-Mail. emergency_contact_email: str = "dieter.schlueter@linix.de" # Default-Kontaktperson smtp_host: str = "" # ohne Host wird KEINE E-Mail versendet smtp_port: int = 587 smtp_user: str = "" smtp_password: str = "" smtp_from: str = "" # Absenderadresse; leer -> smtp_user smtp_starttls: bool = True # Notruf-Eskalation per SMS/Anruf: provider-agnostischer Webhook. Bei einem Notruf # geht ein JSON-POST an emergency_webhook_url (Kanäle, Telefonnummern, fertige # SMS-/Anruf-Texte). Den konkreten Gateway (Twilio, seven.io, …) verdrahtet der # Empfänger dahinter. Ohne URL wird nichts gesendet. emergency_webhook_url: str = "" emergency_webhook_token: str = "" # optional -> Authorization: Bearer emergency_contact_phone: str = "" # Default-Telefonnummer(n), CSV, E.164 (+49…) emergency_webhook_channels: str = "sms,call" # angefragte Kanäle (CSV) # Sperre nach ausgelöstem Notruf: erneuter Alarm ist N Minuten blockiert. # Verhindert, dass verängstigte/verwirrte Senioren mehrfach hintereinander # alarmieren. 0 = keine Sperre. Im Admin-Bereich zur Laufzeit änderbar. emergency_cooldown_minutes: int = 5 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() def _parse_gpu_indices(value: str) -> list[int]: """'0,2' -> [0, 2]; leer/ungültig -> [].""" return sorted({int(t) for t in (value or "").split(",") if t.strip().isdigit()}) def _apply_gpu_visibility() -> None: """Koppelt settings.gpus an CUDA_VISIBLE_DEVICES, BEVOR torch/CUDA initialisiert. - "auto": nichts erzwingen (ein bereits gesetztes CUDA_VISIBLE_DEVICES bleibt gültig). - "none"/leer/"-1": CUDA_VISIBLE_DEVICES="" -> faster-whisper "auto" fällt auf CPU. - "0,1,...": genau diese Indizes sichtbar machen (explizit gewinnt). """ mode = (settings.gpus or "auto").strip().lower() if mode == "auto": return if mode in ("none", "off", "false", "-1", ""): os.environ["CUDA_VISIBLE_DEVICES"] = "" return indices = _parse_gpu_indices(settings.gpus) if indices: os.environ["CUDA_VISIBLE_DEVICES"] = ",".join(str(i) for i in indices) _apply_gpu_visibility()