From 8c152ee77cebffb2fe7dcc44314633d56ad48cf0 Mon Sep 17 00:00:00 2001 From: dschlueter Date: Fri, 26 Jun 2026 15:58:17 +0200 Subject: [PATCH] =?UTF-8?q?feat(notruf):=20Wiederausl=C3=B6se-Sperre=20+?= =?UTF-8?q?=20gr=C3=BCner=20Hinweis;=20"Neues=20Gespr=C3=A4ch"=20in=20die?= =?UTF-8?q?=20Kopfzeile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1) Notruf-Sperre (Cooldown) gegen Mehrfach-Alarme verwirrter/verängstigter Senioren: - Neue Einstellung emergency_cooldown_minutes (Default 5), zur Laufzeit im Admin-Bereich änderbar (RUNTIME_SETTABLE), 0 = aus. - record_manual_emergency unterdrückt innerhalb der Sperre jeden weiteren Alarm (kein zweiter Versand, kein Log-Eintrag) und liefert status=cooldown mit minutes_ago/remaining_seconds. Neuer Endpunkt GET /api/emergency/status für die Anzeige nach einem Seiten-Neuladen. Store: last_manual_emergency_at. - UI: Nach dem Alarm wird der Button grün, zeigt „✓" und pulsiert für die Dauer der Sperre. Erneuter Druck öffnet einen grünen Hinweis-Dialog: „Ich habe Deine Helfer vor Minuten alarmiert. Wahrscheinlich ist Deine Hilfe schon unterwegs." Der grüne Zustand übersteht ein Neuladen (Status-Abruf beim Start). 2) „Neues Gespräch" als Symbol (Stift) zurück in die Kopfzeile — zwischen Sprachauswahl und Menü; löst dieselbe Aktion aus wie der Menüeintrag. Tests: neue test_emergency_cooldown.py (Status/Suppression/Endpoint); zwei bestehende Notruf-Tests an die neue Interface-Methode bzw. die Sperre angepasst. 250 Offline-Tests grün. Frontend via Headless-Chrome verifiziert (Alarm->grün/pulsierend, Hinweis-Dialog, Reload-Persistenz, Header-Button). Bump app.js v=50 -> v=51. Co-Authored-By: Claude Opus 4.8 --- app/api/chat.py | 11 ++- app/config.py | 4 ++ app/runtime_config.py | 1 + app/safety/emergency.py | 54 ++++++++++++++- app/store.py | 14 ++++ app/web/app.js | 108 ++++++++++++++++++++++++----- app/web/index.html | 24 ++++++- tests/test_emergency_cooldown.py | 89 ++++++++++++++++++++++++ tests/test_emergency_enrichment.py | 3 + tests/test_quota_safety.py | 4 +- 10 files changed, 290 insertions(+), 22 deletions(-) create mode 100644 tests/test_emergency_cooldown.py diff --git a/app/api/chat.py b/app/api/chat.py index 26ac77b..8098f60 100644 --- a/app/api/chat.py +++ b/app/api/chat.py @@ -17,7 +17,8 @@ from app.dependencies import ( ) from app.core.memory_extractor import maybe_schedule_extraction from app.quota import enforce_quota, record_usage, QuotaExceededError -from app.safety.emergency import record_manual_emergency +from app.runtime_config import runtime_settings +from app.safety.emergency import record_manual_emergency, emergency_cooldown_status from app.schemas import ChatRequest, EmergencyRequest router = APIRouter() @@ -153,4 +154,12 @@ async def trigger_emergency(payload: EmergencyRequest, user: User = Depends(requ return await record_manual_emergency( user, store, payload.language, geo=payload.geo, geo_error=payload.geo_error, device=payload.device, + cfg=runtime_settings, ) + + +@router.get("/emergency/status") +async def emergency_status(user: User = Depends(require_user)): + """Aktueller Notruf-Sperr-Status des Nutzers (für die grüne Button-Anzeige, + auch nach einem Seiten-Neuladen).""" + return emergency_cooldown_status(user, get_store(), runtime_settings) diff --git a/app/config.py b/app/config.py index 4c2ffc1..4f8c6cb 100644 --- a/app/config.py +++ b/app/config.py @@ -206,6 +206,10 @@ class Settings(BaseSettings): emergency_webhook_token: str = "" # optional -> Authorization: Bearer emergency_contact_phone: str = "" # Default-Telefonnummer(n), CSV, E.164 (+49…) emergency_webhook_channels: str = "sms,call" # angefragte Kanäle (CSV) + # Sperre nach ausgelöstem Notruf: erneuter Alarm ist N Minuten blockiert. + # Verhindert, dass verängstigte/verwirrte Senioren mehrfach hintereinander + # alarmieren. 0 = keine Sperre. Im Admin-Bereich zur Laufzeit änderbar. + emergency_cooldown_minutes: int = 5 model_config = SettingsConfigDict( env_file=ENV_FILE, case_sensitive=False, extra="ignore" ) diff --git a/app/runtime_config.py b/app/runtime_config.py index 4f67f48..aaef3ea 100644 --- a/app/runtime_config.py +++ b/app/runtime_config.py @@ -33,6 +33,7 @@ RUNTIME_SETTABLE: dict[str, tuple[str, str, str]] = { "memory_extraction_enabled": ("Erinnerungs-Extraktion", "bool", "true | false"), "memory_extraction_every_n_turns": ("Extraktion alle N Turns", "int", "z.B. 3"), "daily_request_limit": ("Tageskontingent (global)", "int", "0 = unbegrenzt"), + "emergency_cooldown_minutes": ("Notruf-Sperre (Minuten)", "int", "Erneuter Alarm für N Minuten blockiert (Standard 5, 0 = aus)"), } _TTL = 30.0 diff --git a/app/safety/emergency.py b/app/safety/emergency.py index 70f5062..ef57459 100644 --- a/app/safety/emergency.py +++ b/app/safety/emergency.py @@ -270,10 +270,60 @@ async def _post_webhook(url: str, payload: dict, cfg) -> bool: return False +def _cooldown_seconds(cfg) -> int: + try: + return max(0, int(getattr(cfg, "emergency_cooldown_minutes", 5)) * 60) + except (TypeError, ValueError): + return 300 + + +def emergency_cooldown_status(user, store, cfg=settings) -> dict: + """Aktueller Sperr-Status: ist nach dem letzten manuellen Notruf noch + eine Wiederauslöse-Sperre aktiv, wie lange ist er her, wie viel bleibt. + + Schützt verwirrte/verängstigte Senioren vor Mehrfach-Alarmen. + """ + total = _cooldown_seconds(cfg) + last = store.last_manual_emergency_at(user.id) if total > 0 else None + base = {"active": False, "cooldown_seconds": total, "remaining_seconds": 0, "minutes_ago": 0} + if not last: + return base + try: + last_dt = datetime.fromisoformat(last) + except (ValueError, TypeError): + return base + elapsed = (datetime.now(timezone.utc) - last_dt).total_seconds() + remaining = int(total - elapsed) + return { + "active": remaining > 0, + "cooldown_seconds": total, + "remaining_seconds": max(0, remaining), + "minutes_ago": max(0, int(elapsed // 60)), + } + + async def record_manual_emergency(user, store, language: str | None = None, geo=None, geo_error: str | None = None, device=None, cfg=settings) -> dict: - """Protokolliert einen bestätigten Notruf, benachrichtigt Kontakte und liefert den Hinweis.""" + """Protokolliert einen bestätigten Notruf, benachrichtigt Kontakte und liefert den Hinweis. + + Innerhalb der Wiederauslöse-Sperre (emergency_cooldown_minutes) wird KEIN + neuer Alarm gesendet und kein Ereignis protokolliert — stattdessen liefert + die Antwort status='cooldown' mit minutes_ago/remaining_seconds, damit das + Frontend den beruhigenden Hinweis zeigen kann. + """ + cd = emergency_cooldown_status(user, store, cfg) + if cd["active"]: + metrics.inc("emergency_total", {"category": "manual", "source": "user_suppressed"}) + return { + "category": "manual", + "status": "cooldown", + "cooldown_seconds": cd["cooldown_seconds"], + "remaining_seconds": cd["remaining_seconds"], + "minutes_ago": cd["minutes_ago"], + "notice": notify_notice(language, True), + } + # Bewusst minimaler Log-Eintrag — KEINE Adresse/Koordinaten (Admin-Log bleibt unkritisch). store.log_emergency(user.id, "manual", "Vom Nutzer ausgelöster Notruf") metrics.inc("emergency_total", {"category": "manual", "source": "user"}) @@ -302,6 +352,8 @@ async def record_manual_emergency(user, store, language: str | None = None, notified = email_sent or webhook_sent return { "category": "manual", + "status": "alerted", + "cooldown_seconds": cd["cooldown_seconds"], "notice": notify_notice(language, notified), "email_sent": email_sent, "webhook_sent": webhook_sent, diff --git a/app/store.py b/app/store.py index 8e3d9be..c3252e7 100644 --- a/app/store.py +++ b/app/store.py @@ -148,6 +148,9 @@ class Store(ABC): @abstractmethod def list_emergency_events(self, limit: int = 50) -> list[dict]: ... + @abstractmethod + def last_manual_emergency_at(self, user_id: str) -> str | None: ... + @abstractmethod def get_usage_for_user(self, user_id: str) -> list[dict]: ... @@ -561,6 +564,17 @@ class SQLiteStore(Store): ).fetchall() return [dict(r) for r in rows] + def last_manual_emergency_at(self, user_id: str) -> str | None: + """ISO-Zeitstempel des letzten vom Nutzer ausgelösten Notrufs (oder None).""" + with self._connect() as conn: + row = conn.execute( + "SELECT created_at FROM emergency_events" + " WHERE user_id = ? AND category = 'manual'" + " ORDER BY id DESC LIMIT 1", + (user_id,), + ).fetchone() + return row["created_at"] if row else None + def get_usage_for_user(self, user_id: str) -> list[dict]: with self._connect() as conn: rows = conn.execute( diff --git a/app/web/app.js b/app/web/app.js index 01548cb..9f0e6dc 100644 --- a/app/web/app.js +++ b/app/web/app.js @@ -422,6 +422,7 @@ function loadMe() { applyAllowedLanguages(prefs); applyPlaybackChoice(prefs.tts_provider); applyGenderChoice(prefs.voice_gender || 'f'); + initSosCooldown(); // grünen Sperr-Zustand nach Reload wiederherstellen return me; } catch (e) { $("#identity").textContent = "Verbindung fehlgeschlagen"; @@ -840,6 +841,9 @@ function newConversation() { }, 2500); } $("#new-chat").addEventListener("click", () => { closeMenu(); newConversation(); }); +// Gleiche Aktion als Symbol oben in der Kopfzeile (zwischen Sprachwahl und Menü). +const newChatTop = $("#new-chat-top"); +if (newChatTop) newChatTop.addEventListener("click", newConversation); // ---------- Menü (⋮) verdrahten ---------- $("#menu-toggle").addEventListener("click", () => @@ -2245,24 +2249,23 @@ function deviceInfo() { return { mobile: /Mobi|Android|iPhone|iPad|iPod/i.test(ua), platform: ua.slice(0, 120) }; } -// Sichtbarer Zustand am SOS-Button selbst: idle | calling | done | error. -// So erkennt der Senior unmittelbar am Knopf, dass der Alarm ausgelöst wurde. -// Button bleibt ein kleines Quadrat (so groß wie die Sprachauswahl) -> Feedback -// über Farbe/Symbol statt langer Wörter, damit es nicht aus der Form läuft. +// Sichtbarer Zustand am SOS-Button (Quadrat in Höhe der Sprachauswahl): +// idle (rot „SOS") | calling (rot, pulsierend) | done (grün ✓, pulsierend — +// während der Sperre) | error. Feedback über Farbe/Symbol, nicht über Wörter. function setSosState(state) { if (!emergencyBtn) return; emergencyBtn.classList.remove( - "bg-red-600", "hover:bg-red-700", "bg-green-600", "animate-pulse", - "text-sm", "text-lg" + "bg-red-600", "hover:bg-red-700", "bg-green-600", "hover:bg-green-700", + "animate-pulse", "text-sm", "text-lg" ); if (state === "calling") { emergencyBtn.textContent = "SOS"; // pulsiert = „wird gerufen“ emergencyBtn.disabled = true; emergencyBtn.classList.add("bg-red-600", "animate-pulse", "text-sm"); } else if (state === "done") { - emergencyBtn.textContent = "✓"; // grün + Haken = Alarm raus - emergencyBtn.disabled = true; - emergencyBtn.classList.add("bg-green-600", "text-lg"); + emergencyBtn.textContent = "✓"; // grün + Haken + pulsiert = Alarm raus + emergencyBtn.disabled = false; // klickbar -> beruhigender Hinweis + emergencyBtn.classList.add("bg-green-600", "hover:bg-green-700", "animate-pulse", "text-lg"); } else if (state === "error") { emergencyBtn.textContent = "!"; emergencyBtn.disabled = false; @@ -2274,6 +2277,34 @@ function setSosState(state) { } } +// ----- Wiederauslöse-Sperre (Cooldown) ----- +// Nach einem Alarm bleibt der Button für die (im Admin einstellbare) Dauer grün +// und pulsierend; erneutes Drücken zeigt nur einen beruhigenden Hinweis, statt +// einen zweiten Alarm auszulösen — Schutz vor Mehrfach-Alarmen. +let sosCooldownActive = false; +let sosAlertedAtMs = 0; +let sosCooldownTimer = null; + +function enterCooldown(remainingSec, minutesAgo) { + sosCooldownActive = true; + // Auslösezeitpunkt rekonstruieren, damit „vor X Minuten" stimmt (auch nach Reload). + sosAlertedAtMs = Date.now() - Math.max(0, minutesAgo || 0) * 60000; + setSosState("done"); + if (sosCooldownTimer) clearTimeout(sosCooldownTimer); + if (remainingSec > 0) sosCooldownTimer = setTimeout(exitCooldown, remainingSec * 1000); + else exitCooldown(); +} + +function exitCooldown() { + sosCooldownActive = false; + sosCooldownTimer = null; + setSosState("idle"); +} + +function sosMinutesAgo() { + return Math.max(0, Math.floor((Date.now() - sosAlertedAtMs) / 60000)); +} + async function runEmergency() { const lang = (langSel && langSel.value) || "de"; setSosState("calling"); @@ -2288,15 +2319,26 @@ async function runEmergency() { body: JSON.stringify(body), }); const data = await res.json().catch(() => ({})); + if (data && data.status === "cooldown") { + // Server hat den (Doppel-)Alarm unterdrückt -> nur beruhigen, kein neuer Alarm. + enterCooldown(data.remaining_seconds || 0, data.minutes_ago || 0); + openSosCooldown(); + return; + } const notice = (data && data.notice) || "ACHTUNG: Es ist noch keine Benachrichtigung eingebaut."; addMessage("emergency", "🆘 " + notice); - setSosState("done"); - setTimeout(() => setSosState("idle"), 6000); // danach zurück zu „SOS" + const cd = (data && data.cooldown_seconds) || 0; + if (cd > 0) { + enterCooldown(cd, 0); // grün/pulsierend für die Sperre + } else { + setSosState("done"); // Sperre aus -> nur kurzes Erfolgs-Feedback + setTimeout(() => { if (!sosCooldownActive) setSosState("idle"); }, 6000); + } } catch (e) { addMessage("emergency", "🆘 Fehler beim Auslösen des Notrufs."); setSosState("error"); - setTimeout(() => setSosState("idle"), 4000); + setTimeout(() => { if (!sosCooldownActive) setSosState("idle"); }, 4000); } } @@ -2305,17 +2347,47 @@ const sosConfirm = $("#sos-confirm"); function openSosConfirm() { if (sosConfirm) sosConfirm.classList.remove("hidden"); } function closeSosConfirm() { if (sosConfirm) sosConfirm.classList.add("hidden"); } +// Beruhigender grüner Hinweis, wenn während der Sperre erneut gedrückt wird. +const sosCooldownDlg = $("#sos-cooldown"); +function openSosCooldown() { + const m = sosMinutesAgo(); + const when = m < 1 ? "gerade eben" : ("vor " + m + (m === 1 ? " Minute" : " Minuten")); + const txtEl = $("#sos-cooldown-text"); + if (txtEl) txtEl.textContent = + "Ich habe Deine Helfer " + when + " alarmiert. " + + "Wahrscheinlich ist Deine Hilfe schon unterwegs."; + if (sosCooldownDlg) sosCooldownDlg.classList.remove("hidden"); +} +function closeSosCooldown() { if (sosCooldownDlg) sosCooldownDlg.classList.add("hidden"); } + +// Beim Laden den Sperr-Status vom Server holen, damit der grüne Zustand auch +// ein Seiten-Neuladen übersteht (verwirrte Senioren laden gern neu). +async function initSosCooldown() { + try { + const res = await fetch("/api/emergency/status", { headers: { Accept: "application/json" } }); + if (!res.ok) return; + const s = await res.json(); + if (s && s.active) enterCooldown(s.remaining_seconds || 0, s.minutes_ago || 0); + } catch (e) { /* ignore */ } +} + if (emergencyBtn) { - emergencyBtn.addEventListener("click", openSosConfirm); + emergencyBtn.addEventListener("click", () => { + if (sosCooldownActive) openSosCooldown(); // schon alarmiert -> nur beruhigen + else openSosConfirm(); // sonst Notruf bestätigen + }); } const sosYesBtn = $("#sos-confirm-yes"); const sosNoBtn = $("#sos-confirm-no"); if (sosYesBtn) sosYesBtn.addEventListener("click", () => { closeSosConfirm(); runEmergency(); }); if (sosNoBtn) sosNoBtn.addEventListener("click", closeSosConfirm); -// Klick auf den Hintergrund oder Escape bricht ab (schließt nur den Dialog). -if (sosConfirm) { - sosConfirm.addEventListener("click", (e) => { if (e.target === sosConfirm) closeSosConfirm(); }); -} +const sosCooldownOkBtn = $("#sos-cooldown-ok"); +if (sosCooldownOkBtn) sosCooldownOkBtn.addEventListener("click", closeSosCooldown); +// Klick auf den Hintergrund oder Escape schließt die Dialoge (kein Alarm). +if (sosConfirm) sosConfirm.addEventListener("click", (e) => { if (e.target === sosConfirm) closeSosConfirm(); }); +if (sosCooldownDlg) sosCooldownDlg.addEventListener("click", (e) => { if (e.target === sosCooldownDlg) closeSosCooldown(); }); document.addEventListener("keydown", (e) => { - if (e.key === "Escape" && sosConfirm && !sosConfirm.classList.contains("hidden")) closeSosConfirm(); + if (e.key !== "Escape") return; + if (sosConfirm && !sosConfirm.classList.contains("hidden")) closeSosConfirm(); + if (sosCooldownDlg && !sosCooldownDlg.classList.contains("hidden")) closeSosCooldown(); }); diff --git a/app/web/index.html b/app/web/index.html index 99b3264..2371bb3 100644 --- a/app/web/index.html +++ b/app/web/index.html @@ -243,6 +243,10 @@ + @@ -386,6 +390,24 @@ - + + + + diff --git a/tests/test_emergency_cooldown.py b/tests/test_emergency_cooldown.py new file mode 100644 index 0000000..9d295b6 --- /dev/null +++ b/tests/test_emergency_cooldown.py @@ -0,0 +1,89 @@ +"""Wiederauslöse-Sperre (Cooldown) für den manuellen Notruf. + +Nach einem Alarm ist ein erneuter Alarm für emergency_cooldown_minutes blockiert +(Schutz vor Mehrfach-Alarmen durch verwirrte Senioren). +""" +import asyncio +from datetime import datetime, timezone, timedelta + +from fastapi.testclient import TestClient + +import app.safety.emergency as em +from app.config import settings +from app.main import app + +client = TestClient(app) + + +class FakeUser: + id = "u-cooldown" + display_name = "Test" + prefs: dict = {} + + +class FakeStore: + def __init__(self, last=None): + self._last = last + self.logged = 0 + + def log_emergency(self, *a, **k): + self.logged += 1 + self._last = datetime.now(timezone.utc).isoformat() + + def last_manual_emergency_at(self, user_id): + return self._last + + +def _iso_ago(seconds: int) -> str: + return (datetime.now(timezone.utc) - timedelta(seconds=seconds)).isoformat() + + +def test_status_active_within_window(monkeypatch): + monkeypatch.setattr(em.settings, "emergency_cooldown_minutes", 5) + s = em.emergency_cooldown_status(FakeUser(), FakeStore(last=_iso_ago(60))) + assert s["active"] is True + assert s["minutes_ago"] == 1 + assert 0 < s["remaining_seconds"] <= 300 + + +def test_status_expired(monkeypatch): + monkeypatch.setattr(em.settings, "emergency_cooldown_minutes", 5) + s = em.emergency_cooldown_status(FakeUser(), FakeStore(last=_iso_ago(600))) + assert s["active"] is False + assert s["remaining_seconds"] == 0 + + +def test_status_disabled_when_zero(monkeypatch): + monkeypatch.setattr(em.settings, "emergency_cooldown_minutes", 0) + s = em.emergency_cooldown_status(FakeUser(), FakeStore(last=_iso_ago(1))) + assert s["active"] is False + + +def test_second_alarm_suppressed(monkeypatch): + monkeypatch.setattr(em.settings, "emergency_cooldown_minutes", 5) + monkeypatch.setattr(em, "_send_email_sync", lambda *a, **k: True) # falls doch versucht + store = FakeStore(last=_iso_ago(30)) + res = asyncio.run(em.record_manual_emergency(FakeUser(), store, language="de")) + assert res["status"] == "cooldown" + assert res["minutes_ago"] == 0 + assert store.logged == 0 # KEIN neuer Eintrag, KEIN zweiter Alarm + + +def test_first_alarm_goes_through(monkeypatch): + monkeypatch.setattr(em.settings, "emergency_cooldown_minutes", 5) + monkeypatch.setattr(em, "_send_email_sync", lambda *a, **k: False) + monkeypatch.setattr(em.settings, "emergency_webhook_url", "") + store = FakeStore(last=None) + res = asyncio.run(em.record_manual_emergency(FakeUser(), store, language="de")) + assert res["status"] == "alerted" + assert res["cooldown_seconds"] == 300 + assert store.logged == 1 + + +def test_status_endpoint(monkeypatch): + # Endpunkt-Verdrahtung: nach einem Alarm meldet /status active=True. + monkeypatch.setattr(settings, "emergency_cooldown_minutes", 5) + client.post("/api/emergency", json={"language": "de"}) + st = client.get("/api/emergency/status").json() + assert st["active"] is True + assert st["cooldown_seconds"] == 300 diff --git a/tests/test_emergency_enrichment.py b/tests/test_emergency_enrichment.py index 32d60e2..db8d90f 100644 --- a/tests/test_emergency_enrichment.py +++ b/tests/test_emergency_enrichment.py @@ -99,6 +99,9 @@ def test_record_manual_emergency_uses_profile(monkeypatch): def log_emergency(self, *a, **k): pass + def last_manual_emergency_at(self, user_id): + return None # kein vorheriger Notruf -> keine Sperre aktiv + res = asyncio.run( em.record_manual_emergency(FakeUser(FULL_PREFS), DummyStore(), language="de", geo=GEO) ) diff --git a/tests/test_quota_safety.py b/tests/test_quota_safety.py index 88bd0c9..5093495 100644 --- a/tests/test_quota_safety.py +++ b/tests/test_quota_safety.py @@ -51,7 +51,9 @@ def test_manual_emergency_logs_and_returns_localized_notice(): assert any(e["category"] == "manual" for e in events) -def test_manual_emergency_notice_language_fallback(): +def test_manual_emergency_notice_language_fallback(monkeypatch): + # Sperre aus, damit beide Auslösungen unabhängig durchgehen (nicht unterdrückt). + monkeypatch.setattr(settings, "emergency_cooldown_minutes", 0) # Unbekannte Sprache -> deutscher Fallback-Hinweis en = client.post("/api/emergency", json={"language": "en"}).json()["notice"] assert "ATTENTION" in en