feat(admin): Admin-Panel Phase 2 — Wörterbuch-CRUD, Live-Log, DB-Export, Metriken-Balken
- Aussprache-Lexikon (de/en) komplett im Browser editierbar: Einträge hinzufügen/löschen in Sektionen Abkürzungen/Einheiten/Begriffe; LRU-Cache wird nach jedem Schreibvorgang automatisch geleert. - Live-Log-Tab: WebSocket auf /api/admin/log streamt journalctl des voice-assistant.service live im Terminal-Style ins Admin-Panel. - DB-Export: SQLite-Datenbank über /api/admin/db-export herunterladen. - Metriken-Tab: CSS-Balkendiagramm (Anfragen je Nutzer) ergänzt. - Dokumentation: §7.5 Admin-Web-Panel, B.5 API-Endpunkte, Sachregister. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
b38992cf28
commit
366d71ba26
4 changed files with 424 additions and 11 deletions
133
app/api/admin.py
133
app/api/admin.py
|
|
@ -1,4 +1,10 @@
|
|||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSocketDisconnect
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.auth import is_admin_user, require_admin, require_admin_or_user
|
||||
from app.config import settings
|
||||
|
|
@ -7,6 +13,16 @@ from app.schemas import MemoryCreate, MemoryOut, UserCreate, UserCreated, UserUp
|
|||
|
||||
router = APIRouter()
|
||||
|
||||
_CONFIG_DIR = Path(__file__).resolve().parents[2] / "config"
|
||||
_ALLOWED_SECTIONS = {"abbreviations", "units", "terms"}
|
||||
_ALLOWED_LANGS = {"de", "en"}
|
||||
|
||||
|
||||
class PronunciationEntry(BaseModel):
|
||||
section: str
|
||||
key: str
|
||||
value: str
|
||||
|
||||
|
||||
@router.get("/admin/request-headers")
|
||||
async def request_headers(request: Request, key: str | None = None):
|
||||
|
|
@ -152,3 +168,118 @@ async def get_user_usage(user_id: str):
|
|||
async def get_all_usage():
|
||||
"""Aggregierte Nutzungsstatistik aller Nutzer."""
|
||||
return get_store().get_all_usage()
|
||||
|
||||
|
||||
# ── Datenbank-Export ────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/admin/db-export", dependencies=[Depends(require_admin)])
|
||||
async def export_db():
|
||||
"""Laed die SQLite-Datenbank als Datei herunter (Backup)."""
|
||||
path = Path(settings.db_path)
|
||||
if not path.exists():
|
||||
raise HTTPException(status_code=404, detail="Datenbank nicht gefunden.")
|
||||
return FileResponse(
|
||||
path,
|
||||
media_type="application/octet-stream",
|
||||
filename="voice-assistant.db",
|
||||
headers={"Content-Disposition": 'attachment; filename="voice-assistant.db"'},
|
||||
)
|
||||
|
||||
|
||||
# ── Aussprache-Lexikon CRUD ─────────────────────────────────────────────────
|
||||
|
||||
def _read_pronunciation(lang: str) -> dict:
|
||||
path = _CONFIG_DIR / f"pronunciation.{lang}.yaml"
|
||||
if not path.exists():
|
||||
return {"abbreviations": {}, "units": {}, "terms": {}}
|
||||
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||
return {
|
||||
"abbreviations": dict(data.get("abbreviations") or {}),
|
||||
"units": dict(data.get("units") or {}),
|
||||
"terms": dict(data.get("terms") or {}),
|
||||
}
|
||||
|
||||
|
||||
def _write_pronunciation(lang: str, data: dict) -> None:
|
||||
path = _CONFIG_DIR / f"pronunciation.{lang}.yaml"
|
||||
path.write_text(
|
||||
yaml.dump(data, allow_unicode=True, default_flow_style=False, sort_keys=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
# LRU-Cache des Normalizers invalidieren, damit die Aenderung sofort greift.
|
||||
from app.pipeline.tts_normalizer import _load_lexicon
|
||||
_load_lexicon.cache_clear()
|
||||
|
||||
|
||||
@router.get("/admin/pronunciation/{lang}", dependencies=[Depends(require_admin)])
|
||||
async def get_pronunciation(lang: str):
|
||||
"""Gibt alle Eintraege des Aussprache-Lexikons zurueck."""
|
||||
if lang not in _ALLOWED_LANGS:
|
||||
raise HTTPException(status_code=400, detail=f"Sprache muss eine von {_ALLOWED_LANGS} sein.")
|
||||
return _read_pronunciation(lang)
|
||||
|
||||
|
||||
@router.post("/admin/pronunciation/{lang}", dependencies=[Depends(require_admin)])
|
||||
async def add_pronunciation(lang: str, entry: PronunciationEntry):
|
||||
"""Fuegt einen Eintrag zum Aussprache-Lexikon hinzu oder ueberschreibt ihn."""
|
||||
if lang not in _ALLOWED_LANGS:
|
||||
raise HTTPException(status_code=400, detail=f"Sprache muss eine von {_ALLOWED_LANGS} sein.")
|
||||
if entry.section not in _ALLOWED_SECTIONS:
|
||||
raise HTTPException(status_code=400, detail=f"Section muss eine von {_ALLOWED_SECTIONS} sein.")
|
||||
if not entry.key.strip() or not entry.value.strip():
|
||||
raise HTTPException(status_code=422, detail="key und value duerfen nicht leer sein.")
|
||||
data = _read_pronunciation(lang)
|
||||
data[entry.section][entry.key.strip()] = entry.value.strip()
|
||||
_write_pronunciation(lang, data)
|
||||
return {"section": entry.section, "key": entry.key.strip(), "value": entry.value.strip()}
|
||||
|
||||
|
||||
@router.delete("/admin/pronunciation/{lang}/{section}/{key:path}", dependencies=[Depends(require_admin)])
|
||||
async def delete_pronunciation(lang: str, section: str, key: str):
|
||||
"""Loescht einen Eintrag aus dem Aussprache-Lexikon."""
|
||||
if lang not in _ALLOWED_LANGS:
|
||||
raise HTTPException(status_code=400, detail="Unbekannte Sprache.")
|
||||
if section not in _ALLOWED_SECTIONS:
|
||||
raise HTTPException(status_code=400, detail="Unbekannte Section.")
|
||||
data = _read_pronunciation(lang)
|
||||
if key not in data[section]:
|
||||
raise HTTPException(status_code=404, detail=f"Eintrag '{key}' nicht gefunden.")
|
||||
del data[section][key]
|
||||
_write_pronunciation(lang, data)
|
||||
return {"deleted": key}
|
||||
|
||||
|
||||
# ── Live-Log (journalctl → WebSocket) ──────────────────────────────────────
|
||||
|
||||
@router.websocket("/admin/log")
|
||||
async def admin_log_ws(websocket: WebSocket, key: str | None = None):
|
||||
"""Streamt den systemd-Journal-Log des Voice-Assistant-Service live."""
|
||||
# Auth: Admin-Key als Query-Param ODER SSO-Identitaet via Cookie/Header.
|
||||
from app.auth import authenticate, _bearer_token
|
||||
client_host = websocket.client.host if websocket.client else ""
|
||||
token = _bearer_token(websocket.headers.get("authorization")) or key
|
||||
user = authenticate(websocket.headers, client_host, token)
|
||||
if not is_admin_user(user):
|
||||
await websocket.close(code=1008)
|
||||
return
|
||||
|
||||
await websocket.accept()
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"journalctl", "--user", "-f", "-u", "voice-assistant.service",
|
||||
"-n", "100", "--no-pager", "-o", "short",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.STDOUT,
|
||||
)
|
||||
try:
|
||||
while True:
|
||||
line = await proc.stdout.readline()
|
||||
if not line:
|
||||
break
|
||||
await websocket.send_text(line.decode("utf-8", "replace").rstrip())
|
||||
except (WebSocketDisconnect, Exception):
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
proc.terminate()
|
||||
except Exception:
|
||||
pass
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue