From 69894b1e7be1b52ca0de7f12e5b6b7799cbd844c Mon Sep 17 00:00:00 2001 From: dschlueter Date: Tue, 30 Jun 2026 09:08:43 +0200 Subject: [PATCH 1/5] =?UTF-8?q?feat(admin):=20"Verlauf=20l=C3=B6schen"=20p?= =?UTF-8?q?ro=20Nutzer=20(Konto=20bleibt)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Store.delete_user_history (loescht Sessions + Nachrichten eines Nutzers, ohne Konto/Erinnerungen/Usage) + DELETE /api/admin/users/{id}/history (audit-geloggt) + Button im Admin-Nutzer-Bereich. Co-Authored-By: Claude Opus 4.8 --- app/api/admin.py | 12 ++++++++++++ app/store.py | 16 ++++++++++++++++ app/web/app.js | 8 ++++++++ 3 files changed, 36 insertions(+) diff --git a/app/api/admin.py b/app/api/admin.py index 51642d4..cab3535 100644 --- a/app/api/admin.py +++ b/app/api/admin.py @@ -131,6 +131,18 @@ async def delete_user(user_id: str): return {"deleted": user_id} +@router.delete("/admin/users/{user_id}/history", dependencies=[Depends(require_admin)]) +async def delete_user_history(user_id: str, request: Request): + """Loescht NUR den Gespraechsverlauf (Sessions + Nachrichten) eines Nutzers; + Konto, Erinnerungen und Nutzungsdaten bleiben erhalten.""" + store = get_store() + if store.get_user(user_id) is None: + raise HTTPException(status_code=404, detail=f"Nutzer {user_id!r} nicht gefunden.") + deleted = store.delete_user_history(user_id) + log_admin_action(request, "user_history_deleted", user_id=user_id) + return {"user_id": user_id, "deleted_messages": deleted} + + @router.post("/admin/users/{user_id}/token", response_model=UserCreated, dependencies=[Depends(require_admin)]) async def reset_token(user_id: str): """Stellt einen neuen Bearer-Token aus; der alte wird sofort ungueltig. diff --git a/app/store.py b/app/store.py index c3252e7..5194ef3 100644 --- a/app/store.py +++ b/app/store.py @@ -126,6 +126,11 @@ class Store(ABC): Nutzungsdaten). Anonymer Nutzer kann nicht geloescht werden. Liefert True, wenn der Nutzer existierte und geloescht wurde.""" + @abstractmethod + def delete_user_history(self, user_id: str) -> int: + """Loescht NUR den Gespraechsverlauf (Sessions + Nachrichten) eines Nutzers; + Konto, Erinnerungen und Nutzungsdaten bleiben. Liefert die Zahl geloeschter Nachrichten.""" + @abstractmethod def reset_token(self, user_id: str) -> tuple[User, str] | None: """Generiert einen neuen Token fuer den Nutzer; der alte wird sofort ungueltig. @@ -492,6 +497,17 @@ class SQLiteStore(Store): conn.execute("DELETE FROM users WHERE id = ?", (user_id,)) return True + def delete_user_history(self, user_id: str) -> int: + with self._connect() as conn: + cur = conn.execute( + "DELETE FROM messages WHERE session_id IN" + " (SELECT id FROM sessions WHERE user_id = ?)", + (user_id,), + ) + n = cur.rowcount + conn.execute("DELETE FROM sessions WHERE user_id = ?", (user_id,)) + return n + def reset_token(self, user_id: str) -> tuple[User, str] | None: with self._connect() as conn: row = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone() diff --git a/app/web/app.js b/app/web/app.js index f449547..016288d 100644 --- a/app/web/app.js +++ b/app/web/app.js @@ -1193,6 +1193,7 @@ function buildUserCard(u) { + @@ -1409,6 +1410,13 @@ function buildUserCard(u) { } }); + // Verlauf löschen (Konto + Erinnerungen bleiben) + card.querySelector(".btn-history").addEventListener("click", async () => { + if (!confirm(`Gesprächsverlauf von „${u.display_name}" löschen?\nKonto und Erinnerungen bleiben erhalten.`)) return; + const res = await adminFetch(`/api/admin/users/${uid}/history`, "DELETE"); + if (res) alert(`Verlauf gelöscht (${res.deleted_messages ?? 0} Nachrichten).`); + }); + // Löschen card.querySelector(".btn-delete").addEventListener("click", async () => { if (!confirm(`Nutzer „${u.display_name}" und alle Daten unwiderruflich löschen?`)) return; From 5609741039bc1feec0b739d9332546b7e40dd3e8 Mon Sep 17 00:00:00 2001 From: dschlueter Date: Tue, 30 Jun 2026 09:12:55 +0200 Subject: [PATCH 2/5] feat(admin): Web-Suche pro Nutzer ein-/ausschaltbar web_search_enabled in AdminUserPrefsUpdate (war nicht im Schema -> wurde wie frueher voice_gender still verworfen) + in der /admin/users-Antwort (Checkbox- Initialzustand, Default = globaler Wert) + Checkbox im Admin-Nutzer-Bereich (speichert sofort). resolve_route respektierte das Pref schon -> wirkt jetzt durchgaengig (AUS = plain Provider, AN = ToolCallingLLM). Co-Authored-By: Claude Opus 4.8 --- app/api/admin.py | 1 + app/schemas.py | 1 + app/web/app.js | 23 +++++++++++++++++++++++ 3 files changed, 25 insertions(+) diff --git a/app/api/admin.py b/app/api/admin.py index cab3535..37d15c8 100644 --- a/app/api/admin.py +++ b/app/api/admin.py @@ -237,6 +237,7 @@ async def list_users(): "emergency_contacts", "emergency_phones", "emergency_cooldown_minutes", )}, "location_consent": bool((u.prefs or {}).get("location_consent", False)), + "web_search_enabled": bool((u.prefs or {}).get("web_search_enabled", runtime_settings.web_search_enabled)), "is_admin": is_admin_user(u), } for u in get_store().list_users() diff --git a/app/schemas.py b/app/schemas.py index 6a782e6..b88745a 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -164,6 +164,7 @@ class AdminUserPrefsUpdate(BaseModel): Format geprüft (serverseitige Härtung zusätzlich zur Frontend-Validierung). """ allowed_languages: str | None = None + web_search_enabled: bool | None = None # Web-Suche (Weg 2) pro Nutzer an/aus # Notfall-Profil (in user.prefs gespeichert, fließt in die Notruf-Meldungen ein) full_name: str | None = None street: str | None = None diff --git a/app/web/app.js b/app/web/app.js index 016288d..40d1b6a 100644 --- a/app/web/app.js +++ b/app/web/app.js @@ -1220,6 +1220,14 @@ function buildUserCard(u) {
+ +
+ +
+
@@ -1331,6 +1336,24 @@ function buildUserCard(u) { } }); } + + // Web-Such-Tagesbudget (leer/0 = unbegrenzt) — sofort speichern + const wsLimit = card.querySelector(".ws-limit"); + const wsLimitStatus = card.querySelector(".ws-limit-status"); + if (wsLimit) { + const lim = Number(u.web_search_daily_limit); + wsLimit.value = Number.isFinite(lim) && lim > 0 ? lim : ""; + wsLimit.addEventListener("change", async () => { + const n = parseInt(wsLimit.value, 10); + const val = Number.isFinite(n) && n > 0 ? n : 0; // leer/0 = unbegrenzt + if (wsLimitStatus) wsLimitStatus.textContent = "speichere …"; + const res = await adminFetch(`/api/admin/users/${uid}/prefs`, "PUT", { web_search_daily_limit: val }); + if (wsLimitStatus) { + wsLimitStatus.textContent = res ? "✓" : "Fehler"; + setTimeout(() => { wsLimitStatus.textContent = ""; }, 2000); + } + }); + } const profBtn = card.querySelector(".btn-prof"); const profSection = card.querySelector(".prof-section"); const profArrow = card.querySelector(".prof-arrow"); From 4f1e15176bbb430cccafc857028bdb4f93c6724c Mon Sep 17 00:00:00 2001 From: dschlueter Date: Tue, 30 Jun 2026 09:25:10 +0200 Subject: [PATCH 4/5] feat: freundliche Hinweise bei abgeschalteter/aufgebrauchter Web-Suche Ist die Web-Suche bei einer offensichtlich tagesaktuellen Frage (should_force_search) nicht verfuegbar, sendet ws.py einen {type:"notice"}- Event mit einer kleinen, lokalisierten Senioren-Systemnachricht ("Web-Suche aus" bzw. "Tageskontingent aufgebraucht"); Frontend zeigt sie als System-Bubble. Co-Authored-By: Claude Opus 4.8 --- app/api/ws.py | 30 ++++++++++++++++++++++++++++++ app/web/app.js | 4 ++++ 2 files changed, 34 insertions(+) diff --git a/app/api/ws.py b/app/api/ws.py index 917d98f..20a8720 100644 --- a/app/api/ws.py +++ b/app/api/ws.py @@ -34,6 +34,31 @@ from app.audio.vad import EnergyVAD from app.core.memory_extractor import maybe_schedule_extraction from app.quota import (enforce_quota, record_usage, QuotaExceededError, web_search_within_budget, record_web_search) +from app.pipeline.search_backstop import should_force_search + +# Freundliche Hinweise (Senioren), wenn die Web-Suche bei einer offensichtlich +# tagesaktuellen Frage nicht verfuegbar ist. Fallback: Deutsch. +_WS_OFF_NOTICE = { + "de": "Hinweis: Die Web-Suche ist gerade nicht eingeschaltet — diese Antwort stammt aus meinem Wissen und ist vielleicht nicht ganz aktuell.", + "en": "Note: Web search is currently turned off — this answer comes from my own knowledge and may not be fully up to date.", + "nl": "Let op: webzoeken staat nu uit — dit antwoord komt uit mijn eigen kennis en is misschien niet helemaal actueel.", + "fr": "Remarque : la recherche web est désactivée — cette réponse vient de mes connaissances et n'est peut-être pas à jour.", + "es": "Nota: la búsqueda web está desactivada — esta respuesta procede de mis conocimientos y puede no estar actualizada.", + "it": "Nota: la ricerca web è disattivata — questa risposta viene dalle mie conoscenze e potrebbe non essere aggiornata.", +} +_WS_BUDGET_NOTICE = { + "de": "Hinweis: Das Web-Such-Kontingent für heute ist aufgebraucht — diese Antwort stammt aus meinem Wissen und ist vielleicht nicht ganz aktuell.", + "en": "Note: Today's web search allowance is used up — this answer comes from my own knowledge and may not be fully up to date.", + "nl": "Let op: het webzoek-tegoed voor vandaag is op — dit antwoord komt uit mijn eigen kennis en is misschien niet helemaal actueel.", + "fr": "Remarque : votre quota de recherche web pour aujourd'hui est épuisé — cette réponse vient de mes connaissances et n'est peut-être pas à jour.", + "es": "Nota: su cupo de búsqueda web de hoy se ha agotado — esta respuesta procede de mis conocimientos y puede no estar actualizada.", + "it": "Nota: il credito di ricerca web per oggi è esaurito — questa risposta viene dalle mie conoscenze e potrebbe non essere aggiornata.", +} + + +def _ws_notice(language: str | None, kind: str) -> str: + table = _WS_BUDGET_NOTICE if kind == "budget" else _WS_OFF_NOTICE + return table.get((language or "de").lower(), table["de"]) from app.auth import authenticate, _cookie_value, CAPABILITY_COOKIE router = APIRouter() @@ -184,6 +209,11 @@ async def _run_turn( {"type": "semantic", "text": trace.semantic_response, "spoken": trace.spoken_response, "citations": trace.citations} ) + # Freundlicher Hinweis, wenn die Web-Suche bei einer offensichtlich aktuellen + # Frage nicht verfuegbar war (abgeschaltet oder Tagesbudget aufgebraucht). + if not route.web_search_enabled and should_force_search(text): + kind = "budget" if not web_search_within_budget(user, store) else "off" + await websocket.send_json({"type": "notice", "text": _ws_notice(route.language, kind)}) # Im text_only-Modus kommt kein Audio (Gerät spricht selbst). if not audio_stream and not text_only: await websocket.send_bytes(audio) diff --git a/app/web/app.js b/app/web/app.js index da863da..eff7acd 100644 --- a/app/web/app.js +++ b/app/web/app.js @@ -757,6 +757,10 @@ function runTurn(path, onopen) { scrollToBottom(); ws.close(); break; + case "notice": + // Kleine System-Notiz (z. B. Web-Suche aus / Kontingent aufgebraucht). + if (msg.text) addMessage("system", msg.text, { lang: turnLang() }); + break; case "error": addMessage("system", "Fehler: " + (msg.detail || msg.status || "unbekannt")); ws.close(); From d738921d25a033bf47c862dd98e35a66c9c38f0b Mon Sep 17 00:00:00 2001 From: dschlueter Date: Tue, 30 Jun 2026 09:32:56 +0200 Subject: [PATCH 5/5] docs: CHANGELOG 0.3.1 (Web-Such-Verwaltung pro Nutzer) + Version 0.3.1 Verlauf-loeschen, Pro-Nutzer-Web-Suche an/aus, Web-Such-Tagesbudget und freundliche Hinweise dokumentiert. Version 0.3.0 -> 0.3.1. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 19 +++++++++++++++++++ pyproject.toml | 2 +- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b2e688f..b0aa844 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,25 @@ Alle nennenswerten Änderungen an **Alexis**. Format lose nach [Keep a Changelog](https://keepachangelog.com/de/); Versionierung nach [SemVer](https://semver.org/lang/de/) (prä-1.0: `0.x`). +## [0.3.1] — 2026-06-30 + +### Hinzugefügt +- **Admin: „Verlauf löschen" pro Nutzer** — löscht Sessions + Nachrichten eines + Nutzers, ohne Konto/Erinnerungen/Nutzungsdaten (Button + `DELETE + /api/admin/users/{id}/history`, audit-geloggt). +- **Web-Suche pro Nutzer ein-/ausschaltbar** (Admin-Checkbox). `web_search_enabled` + fehlte im `AdminUserPrefsUpdate`-Schema und wurde still verworfen — jetzt wirksam. +- **Web-Such-Budget pro Nutzer/Tag** (`web_search_daily_limit`, 0 = unbegrenzt; + Pref > global): Tageszähler (`web_search_usage`); ist das Kontingent aufgebraucht, + wird die Web-Suche für den Turn ausgesetzt. Admin-Eingabefeld. +- **Freundliche Hinweise:** Ist die Web-Suche bei einer klar tagesaktuellen Frage + nicht verfügbar (abgeschaltet oder Kontingent aufgebraucht), erscheint eine kleine, + lokalisierte System-Notiz in der Unterhaltung. + +### Behoben +- Veraltete Dev-Datenbank (`data/voice-assistant.db`) entfernt; Produktiv-DB liegt + unter `/var/lib/voice-assistant/` (Doku-Klarstellung der Prod/Dev-Trennung). + ## [0.3.0] — 2026-06-30 ### Hinzugefügt diff --git a/pyproject.toml b/pyproject.toml index 51bb559..68b674a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "voice-assistant-gateway" -version = "0.3.0" +version = "0.3.1" description = "Modular voice assistant gateway with pluggable audio endpoints and provider adapters" readme = "README.md" requires-python = ">=3.11"