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
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
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)
|
||||
32
app/store.py
32
app/store.py
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue