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/app/api/admin.py b/app/api/admin.py index 51642d4..8f4f944 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. @@ -225,6 +237,8 @@ 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)), + "web_search_daily_limit": (u.prefs or {}).get("web_search_daily_limit", ""), "is_admin": is_admin_user(u), } for u in get_store().list_users() diff --git a/app/api/ws.py b/app/api/ws.py index db320df..20a8720 100644 --- a/app/api/ws.py +++ b/app/api/ws.py @@ -32,7 +32,33 @@ from app.dependencies import ( from app.store import ANONYMOUS_USER_ID, SessionOwnershipError from app.audio.vad import EnergyVAD from app.core.memory_extractor import maybe_schedule_extraction -from app.quota import enforce_quota, record_usage, QuotaExceededError +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() @@ -63,9 +89,12 @@ def _authenticate(websocket: WebSocket, token: str | None): return authenticate(websocket.headers, client_host, _capability_token(websocket, token)) -async def _resolve(user, session_id, options): +async def _resolve(user, session_id, options, store=None): """Loest Route + Orchestrator + Output-Endpunkt auf (kann RoutingError/Ownership werfen).""" overrides = {key: options.get(key) for key in _OVERRIDE_KEYS} + # Web-Such-Tagesbudget aufgebraucht -> Web-Suche fuer diesen Turn aus. + if store is not None and not web_search_within_budget(user, store): + overrides["web_search_enabled"] = False route = resolve_route(user, session_id, overrides) orchestrator = build_orchestrator(route) output = await resolve_output_endpoint(route) @@ -169,6 +198,7 @@ async def _run_turn( return record_usage(user, store, len(text) + len(trace.semantic_response or "")) + record_web_search(user, store, trace.web_searches) if session_id: store.append_message(session_id, user.id, "user", text) @@ -179,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) @@ -199,7 +234,7 @@ async def _cancel_active(task, websocket) -> None: async def _chat_turn(websocket, store, user, session_id, text, options): try: - route, orchestrator, output = await _resolve(user, session_id, options) + route, orchestrator, output = await _resolve(user, session_id, options, store) except SessionOwnershipError as exc: await websocket.send_json({"type": "error", "status": 403, "detail": str(exc)}) return @@ -211,7 +246,7 @@ async def _chat_turn(websocket, store, user, session_id, text, options): async def _voice_turn(websocket, store, user, session_id, audio, fmt, options): try: - route, orchestrator, output = await _resolve(user, session_id, options) + route, orchestrator, output = await _resolve(user, session_id, options, store) except SessionOwnershipError as exc: await websocket.send_json({"type": "error", "status": 403, "detail": str(exc)}) return diff --git a/app/config.py b/app/config.py index c30493e..c38cedc 100644 --- a/app/config.py +++ b/app/config.py @@ -189,6 +189,7 @@ class Settings(BaseSettings): memory_extraction_provider: str = "" audio_stream_default: bool = True # satzweises TTS als Default (Admin kann abschalten) web_search_enabled: bool = True # Web-Suche via Tool-Calling (Weg 2); global an, pro Nutzer abschaltbar + web_search_daily_limit: int = 0 # max. Web-Suchen pro Nutzer/Tag (0 = unbegrenzt); pro Nutzer überschreibbar # TTS-Text-Normalisierung: auto|full|light|off. "auto" = piper -> full, Cloud -> light. tts_normalize_level: str = "auto" stt_fallback: str = "" # kommaseparierte Provider-Namen (Fallback-Kette) diff --git a/app/core/orchestrator.py b/app/core/orchestrator.py index 40762a2..8feba50 100644 --- a/app/core/orchestrator.py +++ b/app/core/orchestrator.py @@ -293,8 +293,10 @@ class Orchestrator: pass collected_citations: list[str] = [] + search_count = [0] async def _on_citations(urls) -> None: + search_count[0] += 1 # eine (erfolgreiche) Web-Suche je on_citations-Aufruf for u in (urls or []): if u not in collected_citations: collected_citations.append(u) @@ -332,6 +334,7 @@ class Orchestrator: if not trace.semantic_response: raise RuntimeError("LLM returned an empty response") trace.citations = collected_citations + trace.web_searches = search_count[0] trace.spoken_response = await self.spoken_adapter.run( trace.semantic_response, diff --git a/app/quota.py b/app/quota.py index 720ff0d..5733e84 100644 --- a/app/quota.py +++ b/app/quota.py @@ -40,3 +40,31 @@ def enforce_quota(user, store, cfg=None) -> None: def record_usage(user, store, units: int = 0) -> None: store.add_usage(user.id, units) metrics.inc("turns_total") + + +def web_search_limit(user, cfg=None) -> int: + """Max. Web-Suchen pro Tag fuer diesen Nutzer (Pref > global; 0 = unbegrenzt).""" + if cfg is None: + from app.runtime_config import runtime_settings + cfg = runtime_settings + pref = user.prefs.get("web_search_daily_limit") if user and user.prefs else None + if pref is not None: + try: + return int(pref) + except (TypeError, ValueError): + pass + return cfg.web_search_daily_limit + + +def web_search_within_budget(user, store, cfg=None) -> bool: + """Darf der Nutzer heute noch eine Web-Suche ausloesen? (Limit 0 = unbegrenzt).""" + limit = web_search_limit(user, cfg) + if not limit or limit <= 0: + return True + return store.web_search_count(user.id) < limit + + +def record_web_search(user, store, n: int = 1) -> None: + """Zaehlt n tatsaechlich ausgefuehrte Web-Suchen fuer den Nutzer.""" + if n and n > 0 and user is not None: + store.add_web_search_usage(user.id, n) diff --git a/app/schemas.py b/app/schemas.py index 6a782e6..6a9b0f8 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -35,6 +35,7 @@ class PipelineTrace(BaseModel): spoken_response: str | None = None tts_ready_text: str | None = None citations: list[str] = [] # Quellen-URLs der Web-Suche (für die UI-Bubble) + web_searches: int = 0 # Anzahl tatsächlich ausgeführter Web-Suchen in diesem Turn class SpeakRequest(BaseModel): @@ -164,6 +165,8 @@ 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 + web_search_daily_limit: int | None = None # max. Web-Suchen/Tag (0 = unbegrenzt) # 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/store.py b/app/store.py index c3252e7..4a6b35d 100644 --- a/app/store.py +++ b/app/store.py @@ -120,12 +120,25 @@ class Store(ABC): def add_usage(self, user_id: str, units: int = 0, day: str | None = None) -> int: """Zaehlt eine Anfrage (+units) und liefert die neue Tages-Anfragezahl.""" + @abstractmethod + def web_search_count(self, user_id: str, day: str | None = None) -> int: + """Anzahl Web-Suchen des Nutzers am Tag (Default: heute, UTC).""" + + @abstractmethod + def add_web_search_usage(self, user_id: str, n: int = 1, day: str | None = None) -> int: + """Zaehlt n Web-Suchen dazu und liefert die neue Tages-Zahl.""" + @abstractmethod def delete_user(self, user_id: str) -> bool: """Loescht einen Nutzer und alle seine Daten (Sessions, Nachrichten, Erinnerungen, 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. @@ -224,6 +237,12 @@ class SQLiteStore(Store): units INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (user_id, day) ); + CREATE TABLE IF NOT EXISTS web_search_usage ( + user_id TEXT NOT NULL, + day TEXT NOT NULL, + count INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, day) + ); CREATE TABLE IF NOT EXISTS emergency_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, @@ -475,6 +494,29 @@ class SQLiteStore(Store): ).fetchone() return int(row["requests"]) + def web_search_count(self, user_id: str, day: str | None = None) -> int: + day = day or self._today() + with self._connect() as conn: + row = conn.execute( + "SELECT count FROM web_search_usage WHERE user_id = ? AND day = ?", + (user_id, day), + ).fetchone() + return int(row["count"]) if row else 0 + + def add_web_search_usage(self, user_id: str, n: int = 1, day: str | None = None) -> int: + day = day or self._today() + with self._connect() as conn: + conn.execute( + "INSERT INTO web_search_usage (user_id, day, count) VALUES (?, ?, ?)" + " ON CONFLICT(user_id, day) DO UPDATE SET count = count + excluded.count", + (user_id, day, int(n)), + ) + row = conn.execute( + "SELECT count FROM web_search_usage WHERE user_id = ? AND day = ?", + (user_id, day), + ).fetchone() + return int(row["count"]) + def delete_user(self, user_id: str) -> bool: if user_id == ANONYMOUS_USER_ID: raise ValueError("Der anonyme Nutzer kann nicht geloescht werden.") @@ -489,9 +531,21 @@ class SQLiteStore(Store): conn.execute("DELETE FROM sessions WHERE user_id = ?", (user_id,)) conn.execute("DELETE FROM memories WHERE user_id = ?", (user_id,)) conn.execute("DELETE FROM usage WHERE user_id = ?", (user_id,)) + conn.execute("DELETE FROM web_search_usage WHERE user_id = ?", (user_id,)) 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..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(); @@ -1193,6 +1197,7 @@ function buildUserCard(u) { + @@ -1219,6 +1224,19 @@ function buildUserCard(u) {
+ +
+ +
+ max. Suchen pro Tag: + + +
+
+