feat(notruf): Wiederauslöse-Sperre + grüner Hinweis; "Neues Gespräch" in die Kopfzeile

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 <n> 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 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-06-26 15:58:17 +02:00
commit 8c152ee77c
10 changed files with 290 additions and 22 deletions

View file

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

View file

@ -206,6 +206,10 @@ class Settings(BaseSettings):
emergency_webhook_token: str = "" # optional -> Authorization: Bearer <token>
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"
)

View file

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

View file

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

View file

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

View file

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

View file

@ -243,6 +243,10 @@
<option value="ru">🇷🇺 RU</option>
<option value="zh">🇨🇳 ZH</option>
</select>
<button id="new-chat-top" type="button" title="Neues Gespräch" aria-label="Neues Gespräch"
class="shrink-0 h-9 w-9 grid place-items-center rounded-lg border border-slate-300 dark:border-slate-600 hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" class="w-5 h-5"><path stroke-linecap="round" stroke-linejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10"/></svg>
</button>
<button id="menu-toggle" title="Menü" aria-label="Menü"
class="h-9 w-9 grid place-items-center rounded-lg border border-slate-300 dark:border-slate-600 hover:bg-slate-100 dark:hover:bg-slate-700 text-xl leading-none transition-colors">⋮</button>
</header>
@ -386,6 +390,24 @@
</div>
</div>
<script src="/app.js?v=50"></script>
<!-- ═══════════════ Notruf bereits ausgelöst (Sperre) ═══════════════ -->
<div id="sos-cooldown" role="dialog" aria-modal="true" aria-labelledby="sos-cooldown-title"
class="hidden fixed inset-0 z-[60] flex items-center justify-center bg-black/50 p-4">
<div class="w-full max-w-sm rounded-2xl bg-white dark:bg-slate-800 shadow-xl overflow-hidden">
<div class="bg-green-600 text-white px-5 py-4 flex items-center gap-3">
<span class="text-3xl font-extrabold"></span>
<span id="sos-cooldown-title" class="text-lg font-semibold">Hilfe ist unterwegs</span>
</div>
<div class="px-5 py-4">
<p id="sos-cooldown-text" class="text-base text-slate-700 dark:text-slate-200"></p>
</div>
<div class="px-5 pb-5">
<button type="button" id="sos-cooldown-ok"
class="w-full h-14 rounded-xl bg-green-600 hover:bg-green-700 text-white text-xl font-extrabold transition-colors">Verstanden</button>
</div>
</div>
</div>
<script src="/app.js?v=51"></script>
</body>
</html>