diff --git a/app/api/admin.py b/app/api/admin.py index 37d15c8..8f4f944 100644 --- a/app/api/admin.py +++ b/app/api/admin.py @@ -238,6 +238,7 @@ async def list_users(): )}, "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..917d98f 100644 --- a/app/api/ws.py +++ b/app/api/ws.py @@ -32,7 +32,8 @@ 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.auth import authenticate, _cookie_value, CAPABILITY_COOKIE router = APIRouter() @@ -63,9 +64,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 +173,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) @@ -199,7 +204,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 +216,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 b88745a..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,7 +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_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 5194ef3..4a6b35d 100644 --- a/app/store.py +++ b/app/store.py @@ -120,6 +120,14 @@ 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, @@ -229,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, @@ -480,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.") @@ -494,6 +531,7 @@ 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 diff --git a/app/web/app.js b/app/web/app.js index 40d1b6a..da863da 100644 --- a/app/web/app.js +++ b/app/web/app.js @@ -1226,6 +1226,11 @@ function buildUserCard(u) { Web-Suche für aktuelle Fragen erlauben +
+ max. Suchen pro Tag: + + +
@@ -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");