diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index b15614f..fe200cd 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -44,7 +44,7 @@ funktioniert trotzdem, die Ausgabe ist dann unformatiert. 6. [Einstellungen und Konfiguration](#6-einstellungen-und-konfiguration) **Administration** -7. [Nutzerverwaltung und Authentifizierung](#7-nutzerverwaltung-und-authentifizierung) +7. [Nutzerverwaltung und Authentifizierung](#7-nutzerverwaltung-und-authentifizierung) · [7.5 Admin-Web-Panel](#75-admin-web-panel) 8. [Gedächtnis und Erinnerungen](#8-gedächtnis-und-erinnerungen) 9. [Resilienz, Fallbacks und Metriken](#9-resilienz-fallbacks-und-metriken) 10. [Notfall-Erkennung und Eskalation](#10-notfall-erkennung-und-eskalation) @@ -487,6 +487,7 @@ https://va.beispiel.de/ ← remote über Reverse-Proxy (alles, inkl. Mikr | **🎤 Mikrofon-Button** | **Idle (grün 🎤):** Tippen → Aufnahme startet · **Aufnahme (rot pulsierend 🎤):** Tippen → Aufnahme stoppt und wird gesendet · **KI antwortet (amber ⏹):** Tippen → Antwort sofort unterbrechen (Barge-in) | | **Stimme ▾** | TTS-Provider wählen: leer = Server-Default, `chatterbox` = neuronale Stimme, `openrouter` = Cloud-TTS | | **☀️ / 🌙** | Tag-/Nacht-Modus; folgt sonst automatisch dem Betriebssystem | +| **⚙️** (Admin) | Öffnet das Admin-Panel — nur für Admin-Nutzer sichtbar (→ § 7.5) | | **Angemeldet als …** | SSO-Identität; „Gast" wenn AUTH deaktiviert oder kein SSO-Cookie | #### 5.1.3 Typischer Ablauf — Textchat @@ -833,13 +834,17 @@ Vor dem TTS läuft ein Normalizer, der Ausspracheprobleme des Phonemizers behebt Stärke: `TTS_NORMALIZE_LEVEL=auto|full|light|off` — `auto` = piper bekommt `full`, Cloud-TTS bekommt `light` (Cloud kann Zahlen selbst). -Eigene Aussprache hinzufügen: +Eigene Aussprache hinzufügen — **zwei Wege:** + +**Web-UI (empfohlen):** Admin-Panel → Tab „🔤 Wörterbuch" (→ § 7.5). Kein Neustart nötig. + +**Kommandozeile:** ```bash python scripts/add_pronunciation.py "strömt:ströhmt" # Wort:Aussprache python scripts/add_pronunciation.py Mond Mohnd --verify # mit Phonem-Check python scripts/add_pronunciation.py kWh "Kilowattstunden" --section units ``` -Danach Server neu starten. +Danach Server neu starten (damit der Cache geleert wird). --- @@ -1185,6 +1190,57 @@ TRUSTED_AUTH_HEADER=X-Remote-User Vollständige Anleitung: [deploy/README.md](deploy/README.md). +### 7.5 Admin-Web-Panel + +> 🔧 Admin — erreichbar über den **⚙️-Button** im Web-Interface (nur für Admin-Nutzer sichtbar) + +Das Admin-Panel öffnet sich als Vollbild-Overlay über dem Chat. Es enthält sieben Tabs: + +#### Nutzer + +Nutzer anlegen (Name eingeben → „Anlegen" → Token erscheint **einmalig** — sofort kopieren!), +umbenennen, Token zurücksetzen und löschen. Erinnerungen je Nutzer auf- und zuklappen, +neue Erinnerungen hinzufügen oder vorhandene löschen. + +#### Gespräche + +Nutzerliste links → Session auswählen → Gesprächs-Transkript als Chat-Bubbles ansehen. + +#### Notfälle + +Tabellarische Übersicht aller protokollierten Notfall-Ereignisse (Zeitpunkt, Nutzer, +Kategorie, Textausschnitt). + +#### Status + +Zeigt aktives Profil, Provider-Konfiguration, Laufzeit-Metriken und verfügbare Provider. +Am Ende: **⬇ voice-assistant.db herunterladen** — lädt die SQLite-Datenbank als Backup. + +#### Metriken + +Nutzungsstatistik je Nutzer (Anfragen, Einheiten, letzte Aktivität) als Tabelle +und CSS-Balkendiagramm. + +#### Wörterbuch + +Aussprache-Lexikon direkt im Browser bearbeiten — kein Kommandozeilen-Skript nötig: + +1. Sprache wählen (Deutsch / Englisch). +2. Sektion wählen: **Abkürzungen**, **Einheiten**, **Begriffe / Aussprache**. +3. Vorhandene Einträge: Maus drüber → **✕** erscheint → löschen. +4. Neuer Eintrag: Schlüssel + Ersetzung eingeben → **+ Hinzufügen**. + Die Änderung greift sofort (Server-Cache wird automatisch geleert). + +#### Log + +Zeigt den systemd-Journal-Log des `voice-assistant.service` live im Browser: + +1. **▶ Verbinden** → letzte 100 Zeilen + laufende Ausgabe erscheinen im Terminal-Fenster. +2. **■ Trennen** → Stream stoppen. +3. **Leeren** → Anzeige leeren (Log auf dem Server bleibt erhalten). + +Der Log hilft, Fehler zu diagnostizieren ohne SSH-Zugang. + --- ## 8. Gedächtnis und Erinnerungen @@ -1659,12 +1715,26 @@ Body-Felder: `input_endpoint`, `output_endpoint`, `stt_provider`, `llm_provider` | Methode | Pfad | Auth | Beschreibung | |---------|------|------|--------------| -| `POST` | `/api/admin/users` | `X-Admin-Key` | Nutzer anlegen → Token einmalig | -| `GET` | `/api/admin/users` | `X-Admin-Key` | Alle Nutzer auflisten | -| `PUT` | `/api/admin/users/{user_id}` | `X-Admin-Key` | Anzeigenamen aktualisieren (`{"display_name":"…"}`) | -| `DELETE` | `/api/admin/users/{user_id}` | `X-Admin-Key` | Nutzer + alle Daten löschen | -| `POST` | `/api/admin/users/{user_id}/token` | `X-Admin-Key` | Neues Token ausstellen (alter Token sofort ungültig) | -| `POST` | `/api/admin/users/{user_id}/memories` | `X-Admin-Key` | Erinnerung für Nutzer vorbelegen (`{"content":"…"}`) | +| `POST` | `/api/admin/users` | Admin | Nutzer anlegen → Token einmalig | +| `GET` | `/api/admin/users` | Admin | Alle Nutzer auflisten | +| `PUT` | `/api/admin/users/{user_id}` | Admin | Anzeigenamen aktualisieren (`{"display_name":"…"}`) | +| `DELETE` | `/api/admin/users/{user_id}` | Admin | Nutzer + alle Daten löschen | +| `POST` | `/api/admin/users/{user_id}/token` | Admin | Neues Token ausstellen (alter Token sofort ungültig) | +| `POST` | `/api/admin/users/{user_id}/memories` | Admin | Erinnerung für Nutzer vorbelegen (`{"content":"…"}`) | +| `GET` | `/api/admin/users/{user_id}/memories` | Admin | Alle Erinnerungen eines Nutzers | +| `DELETE` | `/api/admin/users/{user_id}/memories/{id}` | Admin | Eine Erinnerung löschen | +| `GET` | `/api/admin/users/{user_id}/sessions` | Admin | Sessions eines Nutzers (neueste zuerst) | +| `GET` | `/api/admin/sessions/{session_id}/messages` | Admin | Nachrichten einer Session (`?limit=200`) | +| `GET` | `/api/admin/emergency-events` | Admin | Notfall-Ereignisse (`?limit=50`) | +| `GET` | `/api/admin/users/{user_id}/usage` | Admin | Nutzungsstatistik eines Nutzers | +| `GET` | `/api/admin/usage` | Admin | Aggregierte Nutzungsstatistik aller Nutzer | +| `GET` | `/api/admin/db-export` | Admin | SQLite-Datenbank als Datei-Download (Backup) | +| `GET` | `/api/admin/pronunciation/{lang}` | Admin | Aussprache-Lexikon lesen (`lang`: `de`\|`en`) | +| `POST` | `/api/admin/pronunciation/{lang}` | Admin | Eintrag hinzufügen/überschreiben (`{"section":"…","key":"…","value":"…"}`) | +| `DELETE` | `/api/admin/pronunciation/{lang}/{section}/{key}` | Admin | Eintrag löschen | +| `WS` | `/api/admin/log` | Admin | Live-Log via WebSocket (journalctl stream) | + +**Auth:** `X-Admin-Key`-Header oder SSO-Admin-Cookie (→ § 7.4). ## B.6 WebSocket @@ -1714,6 +1784,7 @@ in `app/dependencies.py` + Implementierung in `app/providers/`. → [Architektur | Begriff | Abschnitt | |---------|-----------| +| Admin-Web-Panel | § 7.5 | | API-Key (OpenRouter) | § 2.3, Anhang A.2 | | Authentifizierung / Bearer-Token | § 7.1, § 7.3, Anhang B.4 | | Audio-Geräte / Mikrofon / Lautsprecher | § 6.7 | diff --git a/app/api/admin.py b/app/api/admin.py index e070b8e..b8fc3ed 100644 --- a/app/api/admin.py +++ b/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 diff --git a/app/web/app.js b/app/web/app.js index 3938b7c..448a2d3 100644 --- a/app/web/app.js +++ b/app/web/app.js @@ -383,6 +383,8 @@ function switchAdminTab(name) { if (name === "emergency") loadEmergencyEvents(); if (name === "status") loadStatus(); if (name === "metrics") loadMetrics(); + if (name === "words") loadWoerterbuch(); + if (name === "log") { /* User muss manuell verbinden */ } } document.querySelectorAll(".admin-tab").forEach((btn) => @@ -746,6 +748,16 @@ async function loadStatus() { ${providerRow("TTS", (cfg.available?.tts_providers || []).join(", "))} + + +
+

Datenbank-Backup

+

SQLite-Datenbank als Datei herunterladen (Backup / Migration).

+ + ⬇ voice-assistant.db herunterladen + +
`; } catch (e) { container.innerHTML = '

Fehler beim Laden.

'; @@ -763,6 +775,19 @@ async function loadMetrics() { if (!data.length) { container.innerHTML = '

Noch keine Nutzungsdaten vorhanden.

'; return; } const totalReq = data.reduce((s, u) => s + (u.total_requests || 0), 0); + const maxReq = Math.max(...data.map((u) => u.total_requests || 0), 1); + + const bars = data.map((u) => { + const pct = Math.round(((u.total_requests || 0) / maxReq) * 100); + return ` +
+ ${escHtml(u.display_name)} +
+
+
+ ${u.total_requests ?? 0} +
`; + }).join(""); container.innerHTML = `
@@ -794,5 +819,151 @@ async function loadMetrics() {
+ +
+

Anfragen je Nutzer

+
${bars}
+
`; } + +// ════════════════════════════════════════ +// TAB: WÖRTERBUCH +// ════════════════════════════════════════ +async function loadWoerterbuch() { + const lang = ($("#words-lang") || {}).value || "de"; + const container = $("#words-content"); + container.innerHTML = '

lade …

'; + const data = await adminFetch(`/api/admin/pronunciation/${lang}`); + if (!data) { container.innerHTML = '

Fehler beim Laden.

'; return; } + container.innerHTML = ""; + const sections = [ + ["abbreviations", "Abkürzungen"], + ["units", "Einheiten"], + ["terms", "Begriffe / Aussprache"], + ]; + for (const [section, label] of sections) { + container.appendChild(buildWortSection(label, section, data[section] || {}, lang)); + } +} + +function buildWortSection(title, section, entries, lang) { + const wrap = document.createElement("div"); + wrap.className = "bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4"; + + const rows = Object.entries(entries).map(([k, v]) => ` + + ${escHtml(k)} + → + ${escHtml(v)} + + + + `).join(""); + + wrap.innerHTML = ` +

+ ${escHtml(title)} + ${Object.keys(entries).length} Einträge +

+ ${rows ? ` +
+ + ${rows} +
+
` : '

Keine Einträge.

'} +
+ + + +
+ `; + + wrap.querySelectorAll(".del-btn").forEach((btn) => + btn.addEventListener("click", async () => { + const key = btn.dataset.key; + if (!confirm(`Eintrag „${key}" löschen?`)) return; + const res = await adminFetch( + `/api/admin/pronunciation/${lang}/${section}/${encodeURIComponent(key)}`, "DELETE" + ); + if (res !== null) btn.closest("tr").remove(); + }) + ); + + wrap.querySelector(".add-form").addEventListener("submit", async (e) => { + e.preventDefault(); + const [keyInp, valInp] = e.target.querySelectorAll("input"); + const key = keyInp.value.trim(); + const value = valInp.value.trim(); + if (!key || !value) return; + const res = await adminFetch(`/api/admin/pronunciation/${lang}`, "POST", { section, key, value }); + if (res !== null) { keyInp.value = ""; valInp.value = ""; loadWoerterbuch(); } + }); + + return wrap; +} + +$("#load-words").addEventListener("click", loadWoerterbuch); +$("#words-lang").addEventListener("change", loadWoerterbuch); + +// ════════════════════════════════════════ +// TAB: LOG +// ════════════════════════════════════════ +let _logWs = null; + +function connectLog() { + if (_logWs) return; + const proto = location.protocol === "https:" ? "wss" : "ws"; + _logWs = new WebSocket(`${proto}://${location.host}/api/admin/log`); + const output = $("#log-output"); + const logStat = $("#log-status"); + const btnConn = $("#log-connect"); + const btnDisc = $("#log-disconnect"); + + btnConn.disabled = true; + btnDisc.disabled = false; + btnDisc.classList.remove("opacity-40"); + logStat.textContent = "verbinde …"; + logStat.className = "text-xs text-amber-400"; + + _logWs.onopen = () => { + logStat.textContent = "verbunden"; + logStat.className = "text-xs text-emerald-400"; + output.innerHTML = ""; + }; + + _logWs.onmessage = (e) => { + const line = document.createElement("div"); + line.textContent = e.data; + output.appendChild(line); + output.scrollTop = output.scrollHeight; + }; + + _logWs.onerror = () => { + logStat.textContent = "Verbindungsfehler"; + logStat.className = "text-xs text-red-400"; + }; + + _logWs.onclose = () => { + _logWs = null; + btnConn.disabled = false; + btnDisc.disabled = true; + btnDisc.classList.add("opacity-40"); + if (!logStat.className.includes("red")) { + logStat.textContent = "getrennt"; + logStat.className = "text-xs text-slate-400"; + } + }; +} + +function disconnectLog() { + if (_logWs) { _logWs.close(); } +} + +$("#log-connect").addEventListener("click", connectLog); +$("#log-disconnect").addEventListener("click", disconnectLog); +$("#log-clear").addEventListener("click", () => { $("#log-output").innerHTML = ""; }); diff --git a/app/web/index.html b/app/web/index.html index e87c003..828977c 100644 --- a/app/web/index.html +++ b/app/web/index.html @@ -36,6 +36,8 @@ + + @@ -124,6 +126,44 @@ + + + + + + @@ -162,6 +202,6 @@
- +