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:
parent
5609741039
commit
88c8c9df7f
8 changed files with 106 additions and 5 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
28
app/quota.py
28
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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
38
app/store.py
38
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue