my_voice_assistant_v3/app/config.py
Dieter Schlüter b2576fd465 feat(llm): Live-Parameter top_p + Live/Restart-Kennzeichnung — Plan-Schritt 4
- local_llm_top_p (Default 0.9) durch Config -> runtime_config (RUNTIME_SETTABLE,
  live ohne Neustart) -> local-openai-compatible Payload (top_p).
- Admin-Einstellungen: top_p als Slider (0–1); Hints "wirkt sofort (kein Neustart)"
  für temperature/top_p.
- Doku: top_p + Klarstellung Live-Parameter (temp/top_p/max_tokens) vs.
  Startup-Wert Kontextfenster (OLLAMA_CONTEXT_LENGTH / llama.cpp -c).

Verifiziert: top_p im Payload, Live-Config-Round-Trip (PUT/DELETE). 165 grün.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 18:46:33 +02:00

217 lines
9.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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_language_mode: str = "fix"
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.01.0)
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
# --- 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) -
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 <lang>.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)
# 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
emergency_webhook_url: str = "" # optionaler Eskalations-Webhook
# LLM-Notfall-Klassifikation (zweite Stufe, faengt was die Stichwoerter verpassen).
# Laeuft als Hintergrund-Task NUR wenn der Keyword-Filter nichts fand -> keine
# zusaetzliche Antwortlatenz. Leerer Provider = Default-LLM-Provider.
emergency_llm_enabled: bool = True
emergency_llm_provider: str = ""
emergency_llm_min_confidence: float = 0.6
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()