feat: Web-Such-Budget pro Nutzer/Tag

Neue Tabelle web_search_usage + Store-Zaehler (web_search_count /
add_web_search_usage, kaskadiert in delete_user). Pro-Nutzer-Limit
web_search_daily_limit (Pref > global; 0 = unbegrenzt) in quota.py
(web_search_within_budget). ws.py: Gate vor _resolve (Budget aufgebraucht ->
Web-Suche fuer den Turn aus) + Zaehlung nach dem Turn (trace.web_searches,
gezaehlt im Orchestrator je on_citations). Admin: Feld + UI-Eingabe.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-06-30 09:22:06 +02:00
commit 88c8c9df7f
8 changed files with 106 additions and 5 deletions

View file

@ -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()

View file

@ -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

View file

@ -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)

View file

@ -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,

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -1226,6 +1226,11 @@ function buildUserCard(u) {
<input type="checkbox" class="ws-enabled" /> Web-Suche für aktuelle Fragen erlauben
<span class="ws-status text-slate-400"></span>
</label>
<div class="mt-1.5 flex items-center gap-2">
<span class="text-xs text-slate-500 dark:text-slate-400">max. Suchen pro Tag:</span>
<input type="number" min="0" class="ws-limit w-20 rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-2 py-1 text-xs focus:outline-none focus:ring-2 focus:ring-blue-500" placeholder="∞" />
<span class="ws-limit-status text-xs text-slate-400"></span>
</div>
</div>
<!-- Notfall-Profil -->
@ -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");