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:
Dieter Schlüter 2026-06-19 01:39:36 +02:00
commit 5d022aaf63
7 changed files with 285 additions and 9 deletions

View file

@ -9,6 +9,7 @@ from pydantic import BaseModel
from app.auth import is_admin_user, require_admin, require_admin_or_user
from app.config import settings
from app.dependencies import get_store
from app.runtime_config import RUNTIME_SETTABLE, invalidate_cache, runtime_settings
from app.schemas import MemoryCreate, MemoryOut, UserCreate, UserCreated, UserUpdate
router = APIRouter()
@ -249,6 +250,55 @@ async def delete_pronunciation(lang: str, section: str, key: str):
return {"deleted": key}
# ── Laufzeit-Konfiguration ─────────────────────────────────────────────────
class ConfigValue(BaseModel):
value: str
@router.get("/admin/config", dependencies=[Depends(require_admin)])
async def get_runtime_config():
"""Gibt alle überschreibbaren Einstellungen mit aktuellem Wert zurück."""
overrides = get_store().get_config_overrides()
result = []
for key, (label, type_str, hint) in RUNTIME_SETTABLE.items():
base_val = getattr(settings, key, None)
effective_val = getattr(runtime_settings, key, base_val)
result.append({
"key": key,
"label": label,
"type": type_str,
"hint": hint,
"base_value": str(base_val) if base_val is not None else "",
"override_value": overrides.get(key),
"effective_value": str(effective_val) if effective_val is not None else "",
"is_overridden": key in overrides,
})
return result
@router.put("/admin/config/{key}", dependencies=[Depends(require_admin)])
async def set_runtime_config(key: str, body: ConfigValue):
"""Setzt eine Laufzeit-Einstellung (wirkt sofort, kein Neustart nötig)."""
if key not in RUNTIME_SETTABLE:
raise HTTPException(status_code=400, detail=f"Nicht überschreibbar: {key!r}")
get_store().set_config_override(key, body.value.strip())
invalidate_cache()
return {"key": key, "value": body.value.strip()}
@router.delete("/admin/config/{key}", dependencies=[Depends(require_admin)])
async def delete_runtime_config(key: str):
"""Entfernt eine Laufzeit-Einstellung (fällt auf .env-Wert zurück)."""
if key not in RUNTIME_SETTABLE:
raise HTTPException(status_code=400, detail=f"Nicht überschreibbar: {key!r}")
deleted = get_store().delete_config_override(key)
invalidate_cache()
if not deleted:
raise HTTPException(status_code=404, detail=f"Kein Override für {key!r} gesetzt.")
return {"deleted": key}
# ── Live-Log (journalctl → WebSocket) ──────────────────────────────────────
@router.websocket("/admin/log")

View file

@ -1,6 +1,7 @@
from dataclasses import dataclass
from app.config import Settings, settings
from app.runtime_config import runtime_settings
from app.errors import UnknownComponentError
from app.audio.router import AudioRouter
from app.audio.endpoints.input.local_default import LocalDefaultInput
@ -93,15 +94,18 @@ def _from_registry(registry: dict, name: str, kind: str, cfg: Settings):
return factory(cfg)
def get_stt_provider(name: str | None = None, cfg: Settings = settings):
def get_stt_provider(name: str | None = None, cfg=None):
cfg = cfg or runtime_settings
return _from_registry(STT_REGISTRY, name or cfg.default_stt_provider, "STT", cfg)
def get_llm_provider(name: str | None = None, cfg: Settings = settings):
def get_llm_provider(name: str | None = None, cfg=None):
cfg = cfg or runtime_settings
return _from_registry(LLM_REGISTRY, name or cfg.default_llm_provider, "LLM", cfg)
def get_tts_provider(name: str | None = None, cfg: Settings = settings):
def get_tts_provider(name: str | None = None, cfg=None):
cfg = cfg or runtime_settings
return _from_registry(TTS_REGISTRY, name or cfg.default_tts_provider, "TTS", cfg)
@ -190,8 +194,9 @@ def resolve_route(
user: User | None = None,
session_id: str | None = None,
overrides: dict | None = None,
cfg: Settings = settings,
cfg=None,
) -> ResolvedRoute:
cfg = cfg or runtime_settings
"""Loest die effektive Route auf.
Praezedenz (hoeher gewinnt): Defaults < Nutzer-Prefs < Session-Route < Request.
@ -247,7 +252,8 @@ def _resolve_normalize_level(tts_provider: str, cfg: Settings) -> str:
return level
def build_orchestrator(route: ResolvedRoute, cfg: Settings = settings) -> Orchestrator:
def build_orchestrator(route: ResolvedRoute, cfg=None) -> Orchestrator:
cfg = cfg or runtime_settings
return Orchestrator(
stt=_provider_chain(STT_REGISTRY, route.stt_provider, cfg.stt_fallback, "stt", cfg),
llm=_provider_chain(LLM_REGISTRY, route.llm_provider, cfg.llm_fallback, "llm", cfg),

View file

@ -4,7 +4,6 @@ Limit aus Settings (`daily_request_limit`), pro Nutzer ueber `prefs.daily_reques
ueberschreibbar. 0 bedeutet unbegrenzt.
"""
from app.config import settings, Settings
from app.metrics import metrics
@ -15,7 +14,10 @@ class QuotaExceededError(Exception):
super().__init__(f"Daily request limit reached ({count}/{limit})")
def effective_limit(user, cfg: Settings = settings) -> int:
def effective_limit(user, cfg=None) -> int:
if cfg is None:
from app.runtime_config import runtime_settings
cfg = runtime_settings
pref = user.prefs.get("daily_request_limit") if user and user.prefs else None
if pref is not None:
try:
@ -25,7 +27,7 @@ def effective_limit(user, cfg: Settings = settings) -> int:
return cfg.daily_request_limit
def enforce_quota(user, store, cfg: Settings = settings) -> None:
def enforce_quota(user, store, cfg=None) -> None:
"""Wirft QuotaExceededError, wenn das Tageslimit erreicht ist."""
limit = effective_limit(user, cfg)
if limit and limit > 0:

97
app/runtime_config.py Normal file
View 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.02.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)

View file

@ -152,6 +152,15 @@ class Store(ABC):
@abstractmethod
def get_all_usage(self) -> list[dict]: ...
@abstractmethod
def get_config_overrides(self) -> dict[str, str]: ...
@abstractmethod
def set_config_override(self, key: str, value: str) -> None: ...
@abstractmethod
def delete_config_override(self, key: str) -> bool: ...
class SQLiteStore(Store):
def __init__(self, db_path: str):
@ -216,6 +225,11 @@ class SQLiteStore(Store):
snippet TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS config_overrides (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL
);
"""
)
# Migration fuer bestehende DBs: external_id ergaenzen (falls noch nicht da).
@ -551,3 +565,21 @@ class SQLiteStore(Store):
" GROUP BY u.user_id ORDER BY total_requests DESC",
).fetchall()
return [dict(r) for r in rows]
def get_config_overrides(self) -> dict[str, str]:
with self._connect() as conn:
rows = conn.execute("SELECT key, value FROM config_overrides").fetchall()
return {r["key"]: r["value"] for r in rows}
def set_config_override(self, key: str, value: str) -> None:
with self._connect() as conn:
conn.execute(
"INSERT INTO config_overrides(key, value, updated_at) VALUES(?,?,?)"
" ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at",
(key, value, _now()),
)
def delete_config_override(self, key: str) -> bool:
with self._connect() as conn:
cur = conn.execute("DELETE FROM config_overrides WHERE key=?", (key,))
return cur.rowcount > 0

View file

@ -385,6 +385,7 @@ function switchAdminTab(name) {
if (name === "metrics") loadMetrics();
if (name === "words") loadWoerterbuch();
if (name === "log") { /* User muss manuell verbinden */ }
if (name === "settings") loadSettings();
}
document.querySelectorAll(".admin-tab").forEach((btn) =>
@ -968,3 +969,75 @@ function disconnectLog() {
$("#log-connect").addEventListener("click", connectLog);
$("#log-disconnect").addEventListener("click", disconnectLog);
$("#log-clear").addEventListener("click", () => { $("#log-output").innerHTML = ""; });
// ════════════════════════════════════════
// TAB: EINSTELLUNGEN
// ════════════════════════════════════════
async function loadSettings() {
const container = $("#settings-content");
container.innerHTML = '<p class="text-sm text-slate-400">lade …</p>';
const data = await adminFetch("/api/admin/config");
if (!data) { container.innerHTML = '<p class="text-sm text-red-500">Fehler beim Laden.</p>'; return; }
container.innerHTML = "";
data.forEach((item) => container.appendChild(buildSettingRow(item)));
}
function buildSettingRow(item) {
const wrap = document.createElement("div");
const overridden = item.is_overridden;
wrap.className = "bg-white dark:bg-slate-800 rounded-xl border " +
(overridden
? "border-amber-300 dark:border-amber-600"
: "border-slate-200 dark:border-slate-700") +
" p-4";
const inputId = "cfg-" + item.key;
const isLong = item.type === "str" && item.base_value.length > 60;
wrap.innerHTML = `
<div class="flex items-start justify-between gap-3 mb-2 flex-wrap">
<div>
<label for="${inputId}" class="font-medium text-sm">${escHtml(item.label)}</label>
<span class="ml-2 text-xs font-mono text-slate-400">${escHtml(item.key)}</span>
${overridden ? '<span class="ml-2 text-xs rounded-full bg-amber-100 dark:bg-amber-900/40 text-amber-700 dark:text-amber-300 px-2 py-0.5">überschrieben</span>' : ""}
</div>
${overridden ? `<button class="reset-btn text-xs text-red-500 hover:text-red-700 dark:hover:text-red-400 transition-colors whitespace-nowrap">↩ Zurücksetzen</button>` : ""}
</div>
<div class="flex gap-2 items-end flex-wrap">
${isLong
? `<textarea id="${inputId}" rows="3"
class="flex-1 min-w-0 rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-3 py-2 text-xs font-mono focus:outline-none focus:ring-2 focus:ring-blue-500 resize-y"
>${escHtml(item.effective_value)}</textarea>`
: `<input id="${inputId}" type="text" value="${escHtml(item.effective_value)}"
class="flex-1 min-w-0 rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-3 py-2 text-xs font-mono focus:outline-none focus:ring-2 focus:ring-blue-500" />`
}
<button class="save-btn rounded-lg bg-blue-600 hover:bg-blue-700 text-white text-xs px-3 py-2 font-medium transition-colors whitespace-nowrap">Speichern</button>
</div>
<p class="text-xs text-slate-400 mt-1.5">
<span class="font-medium">.env:</span> <span class="font-mono">${escHtml(item.base_value) || ""}</span>
<span class="ml-3 text-slate-300 dark:text-slate-600">·</span>
<span class="ml-2">${escHtml(item.hint)}</span>
</p>
`;
const inp = wrap.querySelector(`#${inputId}`);
wrap.querySelector(".save-btn").addEventListener("click", async () => {
const val = inp.value ?? inp.textContent ?? "";
const res = await adminFetch(`/api/admin/config/${item.key}`, "PUT", { value: val });
if (res !== null) loadSettings();
});
const resetBtn = wrap.querySelector(".reset-btn");
if (resetBtn) {
resetBtn.addEventListener("click", async () => {
if (!confirm(`Override für „${item.label}" entfernen?\nDer .env-Wert gilt danach wieder.`)) return;
const res = await adminFetch(`/api/admin/config/${item.key}`, "DELETE");
if (res !== null) loadSettings();
});
}
return wrap;
}
$("#load-settings").addEventListener("click", loadSettings);

View file

@ -38,6 +38,7 @@
<button class="admin-tab px-4 py-3 text-sm font-medium whitespace-nowrap border-b-2 border-transparent text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200 transition-colors" data-tab="metrics">📊 Metriken</button>
<button class="admin-tab px-4 py-3 text-sm font-medium whitespace-nowrap border-b-2 border-transparent text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200 transition-colors" data-tab="words">🔤 Wörterbuch</button>
<button class="admin-tab px-4 py-3 text-sm font-medium whitespace-nowrap border-b-2 border-transparent text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200 transition-colors" data-tab="log">📋 Log</button>
<button class="admin-tab px-4 py-3 text-sm font-medium whitespace-nowrap border-b-2 border-transparent text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200 transition-colors" data-tab="settings">⚙ Einstellungen</button>
</nav>
<!-- Tab Content Container -->
@ -164,6 +165,21 @@
</div>
</div>
<!-- ── Tab: Einstellungen ── -->
<div id="admin-tab-settings" class="hidden h-full overflow-y-auto p-4">
<div class="max-w-3xl mx-auto">
<div class="flex items-center justify-between mb-2 flex-wrap gap-2">
<h3 class="font-semibold text-base">Laufzeit-Einstellungen</h3>
<button id="load-settings"
class="rounded-lg border border-slate-300 dark:border-slate-600 text-sm px-3 py-1.5 hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors">Aktualisieren</button>
</div>
<p class="text-xs text-slate-400 mb-4">Änderungen wirken sofort — kein Server-Neustart nötig. „Zurücksetzen" entfernt den Override und der .env-Wert gilt wieder.</p>
<div id="settings-content" class="space-y-2">
<p class="text-sm text-slate-400">lade …</p>
</div>
</div>
</div>
</div><!-- /Tab Content Container -->
</div><!-- /Admin Panel -->
@ -202,6 +218,6 @@
<div id="status" class="w-full max-w-3xl mx-auto mt-1 min-h-[1rem] text-xs text-slate-500 dark:text-slate-400"></div>
</footer>
<script src="/app.js?v=11"></script>
<script src="/app.js?v=12"></script>
</body>
</html>