feat(admin): Phase 3 — Laufzeit-Konfiguration ohne Server-Neustart
Neue Tabelle config_overrides in SQLite; RuntimeSettings-Wrapper liest
überschreibbare Felder mit 30s TTL-Cache aus der DB und fällt auf
.env-Werte zurück. dependencies.py und quota.py nutzen runtime_settings
als Default statt des statischen Settings-Singletons.
16 Felder überschreibbar: STT/LLM/TTS-Provider, LLM-Modelle, Stimmen,
Systemprompt, Temperatur, Tageskontingent, Normalisierung u.a.
Backend: GET/PUT/DELETE /api/admin/config/{key}
Admin-UI: neuer Tab "⚙ Einstellungen" mit Inline-Edit und Reset.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
296138ac1e
commit
5d022aaf63
7 changed files with 285 additions and 9 deletions
97
app/runtime_config.py
Normal file
97
app/runtime_config.py
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
"""Laufzeit-Konfigurationsüberschreibungen aus der Datenbank.
|
||||
|
||||
Einzelne Settings-Felder können zur Laufzeit via Admin-UI geändert werden,
|
||||
ohne den Server neu zu starten. Die Werte liegen in der Tabelle
|
||||
`config_overrides` und werden mit 30s TTL gecacht.
|
||||
|
||||
Nur Felder aus RUNTIME_SETTABLE sind überschreibbar — alle anderen
|
||||
kommen weiterhin aus .env / TOML / pydantic-settings.
|
||||
"""
|
||||
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from app.config import Settings, settings as _base
|
||||
|
||||
# (label, type_str, hint)
|
||||
RUNTIME_SETTABLE: dict[str, tuple[str, str, str]] = {
|
||||
"default_stt_provider": ("STT-Provider (Standard)", "str", "openrouter | faster-whisper"),
|
||||
"default_llm_provider": ("LLM-Provider (Standard)", "str", "openrouter | local-openai-compatible"),
|
||||
"default_tts_provider": ("TTS-Provider (Standard)", "str", "openrouter | piper | chatterbox"),
|
||||
"default_language": ("Sprache (Standard)", "str", "de | en | …"),
|
||||
"openrouter_llm_model": ("LLM-Modell (OpenRouter)", "str", "z.B. google/gemini-3.1-flash-lite"),
|
||||
"openrouter_tts_model": ("TTS-Modell (OpenRouter)", "str", "z.B. google/gemini-3.1-flash-tts-preview"),
|
||||
"openrouter_tts_voice": ("TTS-Stimme (OpenRouter)", "str", "z.B. Zephyr, Puck, Kore"),
|
||||
"piper_voice": ("TTS-Stimme (piper)", "str", "z.B. de_DE-thorsten-high"),
|
||||
"local_llm_system_prompt": ("Systemprompt (lokal)", "str", "Freier Text"),
|
||||
"local_llm_temperature": ("Temperatur (lokal)", "float", "0.0–2.0"),
|
||||
"local_llm_max_tokens": ("Max. Tokens (lokal)", "int", "0 = kein Limit"),
|
||||
"tts_normalize_level": ("TTS-Normalisierung", "str", "auto | full | light | off"),
|
||||
"audio_stream_default": ("Audio-Streaming Standard", "bool", "true | false"),
|
||||
"memory_extraction_enabled": ("Erinnerungs-Extraktion", "bool", "true | false"),
|
||||
"memory_extraction_every_n_turns": ("Extraktion alle N Turns", "int", "z.B. 3"),
|
||||
"daily_request_limit": ("Tageskontingent (global)", "int", "0 = unbegrenzt"),
|
||||
}
|
||||
|
||||
_TTL = 30.0
|
||||
_cache: dict[str, str] = {}
|
||||
_cache_time: float = 0.0
|
||||
_lock = threading.Lock()
|
||||
|
||||
|
||||
def _coerce(key: str, raw: str) -> Any:
|
||||
_, type_str, _ = RUNTIME_SETTABLE[key]
|
||||
try:
|
||||
if type_str == "bool":
|
||||
return raw.strip().lower() in ("1", "true", "yes")
|
||||
if type_str == "int":
|
||||
return int(raw)
|
||||
if type_str == "float":
|
||||
return float(raw)
|
||||
except (ValueError, AttributeError):
|
||||
pass
|
||||
return raw
|
||||
|
||||
|
||||
def _get_cache() -> dict[str, str]:
|
||||
global _cache, _cache_time
|
||||
now = time.monotonic()
|
||||
with _lock:
|
||||
if now - _cache_time < _TTL:
|
||||
return _cache
|
||||
try:
|
||||
from app.dependencies import get_store
|
||||
_cache = get_store().get_config_overrides()
|
||||
_cache_time = now
|
||||
except Exception:
|
||||
pass
|
||||
return _cache
|
||||
|
||||
|
||||
def invalidate_cache() -> None:
|
||||
global _cache_time
|
||||
with _lock:
|
||||
_cache_time = 0.0
|
||||
|
||||
|
||||
class RuntimeSettings:
|
||||
"""Wraps Settings; liest überschreibbare Felder aus der DB (30s TTL)."""
|
||||
|
||||
def __init__(self, base: Settings):
|
||||
object.__setattr__(self, "_base", base)
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
if name in RUNTIME_SETTABLE:
|
||||
overrides = _get_cache()
|
||||
if name in overrides:
|
||||
return _coerce(name, overrides[name])
|
||||
return getattr(object.__getattribute__(self, "_base"), name)
|
||||
|
||||
# Delegiere Pydantic-Metadaten ans Basis-Objekt.
|
||||
@property
|
||||
def model_fields(self):
|
||||
return self._base.model_fields
|
||||
|
||||
|
||||
runtime_settings = RuntimeSettings(_base)
|
||||
Loading…
Add table
Add a link
Reference in a new issue