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

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