diff --git a/CHANGELOG.md b/CHANGELOG.md index b0aa844..b2e688f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,25 +3,6 @@ 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 8f4f944..51642d4 100644 --- a/app/api/admin.py +++ b/app/api/admin.py @@ -131,18 +131,6 @@ 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. @@ -237,8 +225,6 @@ 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 20a8720..db320df 100644 --- a/app/api/ws.py +++ b/app/api/ws.py @@ -32,33 +32,7 @@ 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, - 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.quota import enforce_quota, record_usage, QuotaExceededError from app.auth import authenticate, _cookie_value, CAPABILITY_COOKIE router = APIRouter() @@ -89,12 +63,9 @@ def _authenticate(websocket: WebSocket, token: str | None): return authenticate(websocket.headers, client_host, _capability_token(websocket, token)) -async def _resolve(user, session_id, options, store=None): +async def _resolve(user, session_id, options): """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) @@ -198,7 +169,6 @@ 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) @@ -209,11 +179,6 @@ 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) @@ -234,7 +199,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, store) + route, orchestrator, output = await _resolve(user, session_id, options) except SessionOwnershipError as exc: await websocket.send_json({"type": "error", "status": 403, "detail": str(exc)}) return @@ -246,7 +211,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, store) + route, orchestrator, output = await _resolve(user, session_id, options) 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 c38cedc..c30493e 100644 --- a/app/config.py +++ b/app/config.py @@ -189,7 +189,6 @@ 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 8feba50..40762a2 100644 --- a/app/core/orchestrator.py +++ b/app/core/orchestrator.py @@ -293,10 +293,8 @@ 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) @@ -334,7 +332,6 @@ 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 5733e84..720ff0d 100644 --- a/app/quota.py +++ b/app/quota.py @@ -40,31 +40,3 @@ 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 6a9b0f8..6a782e6 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -35,7 +35,6 @@ 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): @@ -165,8 +164,6 @@ 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 4a6b35d..c3252e7 100644 --- a/app/store.py +++ b/app/store.py @@ -120,25 +120,12 @@ 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. @@ -237,12 +224,6 @@ 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, @@ -494,29 +475,6 @@ 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.") @@ -531,21 +489,9 @@ 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 eff7acd..f449547 100644 --- a/app/web/app.js +++ b/app/web/app.js @@ -757,10 +757,6 @@ 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(); @@ -1197,7 +1193,6 @@ function buildUserCard(u) { - @@ -1224,19 +1219,6 @@ function buildUserCard(u) {
- -
- -
- max. Suchen pro Tag: - - -
-
-