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:
parent
8a6f428c1b
commit
8c152ee77c
10 changed files with 290 additions and 22 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
14
app/store.py
14
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(
|
||||
|
|
|
|||
108
app/web/app.js
108
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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue