feat(admin): Provider & Sprache — Eigener-Wert-Eingabe + Tests (v14)
app.js: default_stt/llm/tts_provider und default_language von 'select' auf 'combo' umgestellt, damit neben den vordefinierten Optionen jederzeit ein beliebiger eigener Wert eingegeben werden kann (Zukunftssicherheit). combo-Renderer unterstützt jetzt auch meta.labels (Sprachkürzel → Klarname). tests/test_admin_settings.py: 28 Tests für GET/PUT/DELETE /api/admin/config — Auth, bekannte Werte, Custom-Values, Persistenz, Runtime-Wirkung. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
a892896d32
commit
4a4cbe643d
4 changed files with 199 additions and 9 deletions
|
|
@ -1,6 +1,4 @@
|
|||
<img src="https://r2cdn.perplexity.ai/pplx-full-logo-primary-dark%402x.png" style="height:64px;margin-right:32px"/>
|
||||
|
||||
# Voice Assistant, Ubuntu 24.04, Python, iPhone, Andoid-Handsy:
|
||||
# Voice Assistant, Ubuntu 24.04, Python, iPhone, Android-Handy:
|
||||
|
||||
Ich habe mit Python unter Linux einen Voice Assistenten Programmiert, mit dem sich der User unterhalten kann. Das System soll für Senioren stundenlangen Smalltalk mit KI ermöglichen (gegen Einsamkeit, multiuserfähig, mit Gedächtnis). Es funktioniert vereinfacht so: Das System wandelt Sprache in Text um (STT), gibt den Text an ein LLM-KI-Tool weiter. Das LLM formuliert eine adäquate Text-Antwort. Die wird angezeigt und in Sprache umgewandelt (TTS) und abgespielt.
|
||||
|
||||
|
|
|
|||
|
|
@ -976,10 +976,10 @@ $("#log-clear").addEventListener("click", () => { $("#log-output").innerHTML = "
|
|||
|
||||
// Metadaten für jedes Feld: UI-Typ, Optionen, Test-Typ
|
||||
const FIELD_META = {
|
||||
default_stt_provider: { ui: "select", opts: ["openrouter","faster-whisper"], test: "stt" },
|
||||
default_llm_provider: { ui: "select", opts: ["openrouter","local-openai-compatible"], test: "llm" },
|
||||
default_tts_provider: { ui: "select", opts: ["openrouter","piper","chatterbox"], test: "tts" },
|
||||
default_language: { ui: "select", opts: ["de","en","fr","es","it","nl"],
|
||||
default_stt_provider: { ui: "combo", opts: ["openrouter","faster-whisper"], test: "stt" },
|
||||
default_llm_provider: { ui: "combo", opts: ["openrouter","local-openai-compatible"], test: "llm" },
|
||||
default_tts_provider: { ui: "combo", opts: ["openrouter","piper","chatterbox"], test: "tts" },
|
||||
default_language: { ui: "combo", opts: ["de","en","fr","es","it","nl"],
|
||||
labels: { de:"Deutsch",en:"Englisch",fr:"Französisch",es:"Spanisch",it:"Italienisch",nl:"Niederländisch" } },
|
||||
openrouter_llm_model: { ui: "combo", opts: ["google/gemini-3.1-flash-lite","openai/gpt-4.1-mini","google/gemini-2.5-flash","anthropic/claude-haiku-4-5-20251001","meta-llama/llama-3.3-70b-instruct"], test: "llm" },
|
||||
openrouter_tts_model: { ui: "combo", opts: ["google/gemini-3.1-flash-tts-preview","openai/gpt-4o-mini-tts"], test: "tts" },
|
||||
|
|
@ -1050,8 +1050,9 @@ function _buildControl(item, meta) {
|
|||
|
||||
if (meta.ui === "combo") {
|
||||
const inList = meta.opts.includes(val);
|
||||
const labels = meta.labels || {};
|
||||
const options = meta.opts.map((o) =>
|
||||
`<option value="${escHtml(o)}" ${o === val ? "selected" : ""}>${escHtml(o)}</option>`
|
||||
`<option value="${escHtml(o)}" ${o === val ? "selected" : ""}>${escHtml(labels[o] || o)}</option>`
|
||||
).join("");
|
||||
const customSel = !inList ? 'selected' : '';
|
||||
return `
|
||||
|
|
|
|||
|
|
@ -218,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=13"></script>
|
||||
<script src="/app.js?v=14"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
191
tests/test_admin_settings.py
Normal file
191
tests/test_admin_settings.py
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
"""Tests für den Admin-Einstellungen-Tab (Provider & Sprache + Laufzeit-Config).
|
||||
|
||||
Getestet: GET /api/admin/config, PUT /api/admin/config/{key},
|
||||
DELETE /api/admin/config/{key} — Auth, Custom-Values, Persistenz.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import app.dependencies as deps
|
||||
from app.main import app
|
||||
from app.config import settings
|
||||
from app.runtime_config import RUNTIME_SETTABLE, invalidate_cache
|
||||
|
||||
client = TestClient(app)
|
||||
ADMIN = "test-admin-key"
|
||||
ADM_HDR = {"X-Admin-Key": ADMIN}
|
||||
|
||||
PROVIDER_KEYS = [
|
||||
"default_stt_provider",
|
||||
"default_llm_provider",
|
||||
"default_tts_provider",
|
||||
"default_language",
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup(monkeypatch):
|
||||
monkeypatch.setattr(settings, "admin_api_key", ADMIN)
|
||||
monkeypatch.setattr(settings, "auth_enabled", False)
|
||||
invalidate_cache()
|
||||
yield
|
||||
invalidate_cache()
|
||||
|
||||
|
||||
# ── GET /api/admin/config ────────────────────────────────────────────────────
|
||||
|
||||
def test_get_config_returns_all_keys():
|
||||
resp = client.get("/api/admin/config", headers=ADM_HDR)
|
||||
assert resp.status_code == 200
|
||||
keys = {e["key"] for e in resp.json()}
|
||||
assert keys == set(RUNTIME_SETTABLE.keys())
|
||||
|
||||
|
||||
def test_get_config_requires_admin_key():
|
||||
resp = client.get("/api/admin/config")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_get_config_provider_keys_present():
|
||||
resp = client.get("/api/admin/config", headers=ADM_HDR)
|
||||
keys = {e["key"] for e in resp.json()}
|
||||
for k in PROVIDER_KEYS:
|
||||
assert k in keys, f"Schlüssel {k!r} fehlt in /api/admin/config"
|
||||
|
||||
|
||||
def test_get_config_entry_structure():
|
||||
resp = client.get("/api/admin/config", headers=ADM_HDR)
|
||||
entry = next(e for e in resp.json() if e["key"] == "default_stt_provider")
|
||||
assert "label" in entry
|
||||
assert "hint" in entry
|
||||
assert "effective_value" in entry
|
||||
assert "base_value" in entry
|
||||
assert "is_overridden" in entry
|
||||
assert entry["is_overridden"] is False
|
||||
|
||||
|
||||
def test_get_config_not_overridden_initially():
|
||||
resp = client.get("/api/admin/config", headers=ADM_HDR)
|
||||
for entry in resp.json():
|
||||
if entry["key"] in PROVIDER_KEYS:
|
||||
assert not entry["is_overridden"], (
|
||||
f"{entry['key']} sollte initial nicht überschrieben sein"
|
||||
)
|
||||
|
||||
|
||||
# ── PUT /api/admin/config/{key} ──────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.parametrize("key,value", [
|
||||
("default_stt_provider", "openrouter"),
|
||||
("default_stt_provider", "faster-whisper"),
|
||||
("default_llm_provider", "openrouter"),
|
||||
("default_llm_provider", "local-openai-compatible"),
|
||||
("default_tts_provider", "openrouter"),
|
||||
("default_tts_provider", "piper"),
|
||||
("default_tts_provider", "chatterbox"),
|
||||
("default_language", "de"),
|
||||
("default_language", "en"),
|
||||
])
|
||||
def test_put_known_value_accepted(key, value):
|
||||
resp = client.put(f"/api/admin/config/{key}", headers=ADM_HDR, json={"value": value})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["value"] == value
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key,custom_value", [
|
||||
("default_stt_provider", "my-custom-stt"),
|
||||
("default_llm_provider", "local-openai-compatible-v2"),
|
||||
("default_tts_provider", "future-tts-engine"),
|
||||
("default_language", "pl"),
|
||||
])
|
||||
def test_put_custom_value_accepted(key, custom_value):
|
||||
"""Eigener Wert (nicht in der Dropdown-Liste) muss akzeptiert werden."""
|
||||
resp = client.put(f"/api/admin/config/{key}", headers=ADM_HDR, json={"value": custom_value})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["value"] == custom_value
|
||||
|
||||
|
||||
def test_put_custom_value_reflected_in_get():
|
||||
"""Gespeicherter Custom-Wert erscheint in effective_value."""
|
||||
client.put("/api/admin/config/default_stt_provider", headers=ADM_HDR,
|
||||
json={"value": "my-new-stt"})
|
||||
invalidate_cache()
|
||||
resp = client.get("/api/admin/config", headers=ADM_HDR)
|
||||
entry = next(e for e in resp.json() if e["key"] == "default_stt_provider")
|
||||
assert entry["effective_value"] == "my-new-stt"
|
||||
assert entry["is_overridden"] is True
|
||||
|
||||
|
||||
def test_put_unknown_key_returns_400():
|
||||
resp = client.put("/api/admin/config/nonexistent_key", headers=ADM_HDR,
|
||||
json={"value": "x"})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_put_trims_whitespace():
|
||||
resp = client.put("/api/admin/config/default_stt_provider", headers=ADM_HDR,
|
||||
json={"value": " openrouter "})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["value"] == "openrouter"
|
||||
|
||||
|
||||
def test_put_requires_admin_key():
|
||||
resp = client.put("/api/admin/config/default_stt_provider", json={"value": "x"})
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
# ── DELETE /api/admin/config/{key} ──────────────────────────────────────────
|
||||
|
||||
def test_delete_removes_override():
|
||||
client.put("/api/admin/config/default_tts_provider", headers=ADM_HDR,
|
||||
json={"value": "future-engine"})
|
||||
invalidate_cache()
|
||||
|
||||
del_resp = client.delete("/api/admin/config/default_tts_provider", headers=ADM_HDR)
|
||||
assert del_resp.status_code == 200
|
||||
assert del_resp.json()["deleted"] == "default_tts_provider"
|
||||
|
||||
invalidate_cache()
|
||||
cfg = {e["key"]: e for e in
|
||||
client.get("/api/admin/config", headers=ADM_HDR).json()}
|
||||
assert not cfg["default_tts_provider"]["is_overridden"]
|
||||
|
||||
|
||||
def test_delete_nonexistent_override_returns_404():
|
||||
resp = client.delete("/api/admin/config/default_tts_provider", headers=ADM_HDR)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_delete_unknown_key_returns_400():
|
||||
resp = client.delete("/api/admin/config/does_not_exist", headers=ADM_HDR)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_delete_requires_admin_key():
|
||||
resp = client.delete("/api/admin/config/default_tts_provider")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
# ── Persistenz: Override wirkt sich auf Runtime aus ──────────────────────────
|
||||
|
||||
def test_override_reflected_in_runtime_settings():
|
||||
from app.runtime_config import runtime_settings
|
||||
client.put("/api/admin/config/default_language", headers=ADM_HDR,
|
||||
json={"value": "fr"})
|
||||
invalidate_cache()
|
||||
assert runtime_settings.default_language == "fr"
|
||||
|
||||
|
||||
def test_delete_restores_base_value():
|
||||
from app.runtime_config import runtime_settings
|
||||
base = str(settings.default_language)
|
||||
|
||||
client.put("/api/admin/config/default_language", headers=ADM_HDR,
|
||||
json={"value": "it"})
|
||||
invalidate_cache()
|
||||
assert runtime_settings.default_language == "it"
|
||||
|
||||
client.delete("/api/admin/config/default_language", headers=ADM_HDR)
|
||||
invalidate_cache()
|
||||
assert str(runtime_settings.default_language) == base
|
||||
Loading…
Add table
Add a link
Reference in a new issue