Compare commits

..

No commits in common. "d738921d25a033bf47c862dd98e35a66c9c38f0b" and "15d87141050431260a2027c4ff4423b5b4968b51" have entirely different histories.

10 changed files with 5 additions and 220 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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) {
<button class="btn-rename text-xs rounded-lg border border-slate-300 dark:border-slate-600 px-2.5 py-1.5 hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors">Umbenennen</button>
<button class="btn-admin text-xs rounded-lg border px-2.5 py-1.5 transition-colors"></button>
<button class="btn-token text-xs rounded-lg border border-amber-300 dark:border-amber-700 text-amber-700 dark:text-amber-300 px-2.5 py-1.5 hover:bg-amber-50 dark:hover:bg-amber-900/20 transition-colors">Link erneuern</button>
<button class="btn-history text-xs rounded-lg border border-orange-300 dark:border-orange-700 text-orange-700 dark:text-orange-300 px-2.5 py-1.5 hover:bg-orange-50 dark:hover:bg-orange-900/20 transition-colors">Verlauf löschen</button>
<button class="btn-delete text-xs rounded-lg border border-red-300 dark:border-red-800 text-red-600 dark:text-red-400 px-2.5 py-1.5 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors">Löschen</button>
</div>
</div>
@ -1224,19 +1219,6 @@ function buildUserCard(u) {
<div class="lang-allow flex flex-wrap gap-1"></div>
</div>
<!-- Web-Suche (Weg 2) -->
<div class="mt-3 pt-3 border-t border-slate-100 dark:border-slate-700">
<label class="text-xs text-slate-600 dark:text-slate-300 flex items-center gap-2 cursor-pointer">
<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 -->
<div class="mt-3 pt-3 border-t border-slate-100 dark:border-slate-700">
<button class="btn-prof text-xs text-slate-500 hover:text-slate-700 dark:hover:text-slate-300 flex items-center gap-1 transition-colors">
@ -1325,39 +1307,6 @@ function buildUserCard(u) {
});
const consentEl = card.querySelector(".prof-consent");
if (consentEl) consentEl.checked = !!u.location_consent;
// Web-Suche pro Nutzer: sofort speichern bei Änderung
const wsEl = card.querySelector(".ws-enabled");
const wsStatus = card.querySelector(".ws-status");
if (wsEl) {
wsEl.checked = !!u.web_search_enabled;
wsEl.addEventListener("change", async () => {
if (wsStatus) wsStatus.textContent = "speichere …";
const res = await adminFetch(`/api/admin/users/${uid}/prefs`, "PUT", { web_search_enabled: wsEl.checked });
if (wsStatus) {
wsStatus.textContent = res ? "✓" : "Fehler";
setTimeout(() => { wsStatus.textContent = ""; }, 2000);
}
});
}
// 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");
@ -1460,13 +1409,6 @@ function buildUserCard(u) {
}
});
// Verlauf löschen (Konto + Erinnerungen bleiben)
card.querySelector(".btn-history").addEventListener("click", async () => {
if (!confirm(`Gesprächsverlauf von „${u.display_name}" löschen?\nKonto und Erinnerungen bleiben erhalten.`)) return;
const res = await adminFetch(`/api/admin/users/${uid}/history`, "DELETE");
if (res) alert(`Verlauf gelöscht (${res.deleted_messages ?? 0} Nachrichten).`);
});
// Löschen
card.querySelector(".btn-delete").addEventListener("click", async () => {
if (!confirm(`Nutzer „${u.display_name}" und alle Daten unwiderruflich löschen?`)) return;

View file

@ -1,6 +1,6 @@
[project]
name = "voice-assistant-gateway"
version = "0.3.1"
version = "0.3.0"
description = "Modular voice assistant gateway with pluggable audio endpoints and provider adapters"
readme = "README.md"
requires-python = ">=3.11"