diff --git a/app/api/chat.py b/app/api/chat.py index 8098f60..a83fd61 100644 --- a/app/api/chat.py +++ b/app/api/chat.py @@ -18,7 +18,11 @@ from app.dependencies import ( from app.core.memory_extractor import maybe_schedule_extraction from app.quota import enforce_quota, record_usage, QuotaExceededError from app.runtime_config import runtime_settings -from app.safety.emergency import record_manual_emergency, emergency_cooldown_status +from app.safety.emergency import ( + record_manual_emergency, + emergency_cooldown_status, + notify_readiness, +) from app.schemas import ChatRequest, EmergencyRequest router = APIRouter() @@ -161,5 +165,8 @@ async def trigger_emergency(payload: EmergencyRequest, user: User = Depends(requ @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) + auch nach einem Seiten-Neuladen) plus notify_ready für die Vorab-Warnung, + falls (noch) kein Benachrichtigungskanal eingerichtet ist.""" + st = emergency_cooldown_status(user, get_store(), runtime_settings) + st["notify_ready"] = notify_readiness(user, runtime_settings) + return st diff --git a/app/dependencies.py b/app/dependencies.py index f9ac40c..35479f4 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -165,6 +165,7 @@ LANG_TO_PIPER_VOICE: dict[str, str] = { "fr": "fr_FR-siwis-medium", "es": "es_ES-sharvard-medium", "it": "it_IT-paola-medium", + "nl": "nl_NL-mls-medium", "pt": "pt_BR-faber-medium", "pl": "pl_PL-darkman-medium", "ar": "ar_JO-kareem-medium", diff --git a/app/safety/emergency.py b/app/safety/emergency.py index 1a0d7fb..0dfa255 100644 --- a/app/safety/emergency.py +++ b/app/safety/emergency.py @@ -35,6 +35,7 @@ _NO_NOTIFY_NOTICE: dict[str, str] = { "fr": "ATTENTION : aucune notification n'est encore configurée.", "es": "ATENCIÓN: todavía no se ha configurado ninguna notificación.", "it": "ATTENZIONE: non è ancora stata configurata alcuna notifica.", + "nl": "LET OP: er is nog geen melding ingesteld.", "pt": "ATENÇÃO: ainda não há nenhuma notificação configurada.", "pl": "UWAGA: Nie skonfigurowano jeszcze żadnego powiadomienia.", "ar": "تنبيه: لم يتم إعداد أي إشعار بعد.", @@ -49,6 +50,7 @@ _SENT_NOTICE: dict[str, str] = { "fr": "Les secours ont été alertés. Un proche a été prévenu.", "es": "Se ha avisado a ayuda. Se notificó a un contacto.", "it": "L'aiuto è stato allertato. Una persona di contatto è stata avvisata.", + "nl": "Er is hulp gewaarschuwd. Een contactpersoon is op de hoogte gesteld.", "pt": "A ajuda foi avisada. Uma pessoa de contato foi notificada.", "pl": "Pomoc została powiadomiona. Osoba kontaktowa została powiadomiona.", "ar": "تم تنبيه المساعدة. تم إخطار شخص الاتصال.", @@ -56,6 +58,51 @@ _SENT_NOTICE: dict[str, str] = { "zh": "已通知求助。已通知联系人。", } +# Hinweis je Sprache, wenn GAR KEIN Kanal nutzbar ist (keine Kontakte/kein Webhook +# konfiguriert) — niemand wurde erreicht. Handlungsleitend: selbst den Notruf rufen. +_NO_RECIPIENTS_NOTICE: dict[str, str] = { + "de": "Es konnte niemand benachrichtigt werden – es sind keine Notfall-Kontakte eingerichtet. Bitte rufen Sie selbst den Notruf 112 an.", + "en": "No one could be notified – no emergency contacts are set up. Please call your emergency number (112) yourself.", + "fr": "Personne n'a pu être prévenu – aucun contact d'urgence n'est configuré. Veuillez appeler vous-même le 112.", + "es": "No se pudo avisar a nadie: no hay contactos de emergencia configurados. Por favor, llame usted mismo al 112.", + "it": "Non è stato possibile avvisare nessuno: nessun contatto di emergenza è configurato. Per favore chiami lei stesso il 112.", + "nl": "Er kon niemand worden gewaarschuwd – er zijn geen noodcontacten ingesteld. Bel zelf het alarmnummer 112.", + "pt": "Não foi possível avisar ninguém – não há contactos de emergência configurados. Por favor, ligue você mesmo para o 112.", + "pl": "Nie udało się nikogo powiadomić – nie skonfigurowano kontaktów alarmowych. Proszę samodzielnie zadzwonić pod numer 112.", + "ar": "تعذّر إخطار أي شخص – لا توجد جهات اتصال للطوارئ. يرجى الاتصال بنفسك برقم الطوارئ 112.", + "ru": "Никого не удалось уведомить — экстренные контакты не настроены. Пожалуйста, позвоните в службу спасения 112 сами.", + "zh": "无法通知任何人——尚未设置紧急联系人。请您自行拨打急救电话 112。", +} + +# Hinweis je Sprache, wenn Kanäle KONFIGURIERT waren, der Versand aber scheiterte +# (SMTP-/Webhook-Fehler). Unterscheidet den echten Zustellfehler vom Fehlen von Kontakten. +_DELIVERY_FAILED_NOTICE: dict[str, str] = { + "de": "Die Benachrichtigung konnte nicht zugestellt werden. Bitte versuchen Sie es erneut oder rufen Sie selbst den Notruf 112 an.", + "en": "The notification could not be delivered. Please try again or call your emergency number (112) yourself.", + "fr": "La notification n'a pas pu être transmise. Veuillez réessayer ou appeler vous-même le 112.", + "es": "No se pudo entregar la notificación. Por favor, inténtelo de nuevo o llame usted mismo al 112.", + "it": "Non è stato possibile recapitare la notifica. Per favore riprovi o chiami lei stesso il 112.", + "nl": "De melding kon niet worden afgeleverd. Probeer het opnieuw of bel zelf het alarmnummer 112.", + "pt": "Não foi possível entregar a notificação. Por favor, tente novamente ou ligue você mesmo para o 112.", + "pl": "Nie udało się dostarczyć powiadomienia. Proszę spróbować ponownie lub samodzielnie zadzwonić pod numer 112.", + "ar": "تعذّر تسليم الإشعار. يرجى المحاولة مرة أخرى أو الاتصال بنفسك برقم الطوارئ 112.", + "ru": "Не удалось доставить уведомление. Пожалуйста, повторите попытку или позвоните в службу спасения 112 сами.", + "zh": "通知发送失败。请重试或您自行拨打急救电话 112。", +} + +# Status -> Hinweis-Tabelle. "cooldown" nutzt bewusst den "gesendet"-Text (beruhigen). +_NOTICE_TABLES: dict[str, dict[str, str]] = { + "alerted": _SENT_NOTICE, + "no_recipients": _NO_RECIPIENTS_NOTICE, + "delivery_failed": _DELIVERY_FAILED_NOTICE, +} + + +def emergency_notice(language: str | None, status: str) -> str: + """Hinweis in Nutzersprache (Fallback Deutsch) passend zum Notruf-Status.""" + table = _NOTICE_TABLES.get(status, _NO_NOTIFY_NOTICE) + return table.get((language or "de").lower(), table["de"]) + def notify_notice(language: str | None, sent: bool) -> str: """Hinweis in Nutzersprache (Fallback Deutsch), je nachdem ob etwas rausging.""" @@ -214,6 +261,19 @@ def _phones(user, cfg) -> list[str]: return _csv(raw) or _csv(cfg.emergency_contact_phone) +def notify_readiness(user, cfg=settings) -> dict: + """Ob für diesen Nutzer ein Benachrichtigungskanal *funktionsfähig* wäre. + + Wichtig: nicht „Kontakt hinterlegt", sondern „Kanal würde tatsächlich senden". + E-Mail braucht einen SMTP-Host UND mindestens einen Empfänger; SMS/Anruf + braucht eine Webhook-URL. Dient dem Frontend für eine Vorab-Warnung und der + sauberen Status-Unterscheidung no_recipients vs. delivery_failed. + """ + email = bool(cfg.smtp_host) and bool(_contacts(user, cfg)) + webhook = bool(cfg.emergency_webhook_url) + return {"email": email, "webhook": webhook, "any": email or webhook} + + def _send_email_sync(to: list[str], subject: str, body: str, cfg) -> bool: """Versendet eine E-Mail per SMTP. Liefert True bei Erfolg, sonst False (best effort).""" if not cfg.smtp_host or not to: @@ -364,11 +424,25 @@ async def record_manual_emergency(user, store, language: str | None = None, metrics.inc("emergency_notify_total", {"channel": "webhook"}) notified = email_sent or webhook_sent + ready = notify_readiness(user, cfg) + # Drei ehrliche Endzustände: erreicht / kein Kanal eingerichtet / Zustellung gescheitert. + if notified: + status = "alerted" + elif not ready["any"]: + status = "no_recipients" + else: + status = "delivery_failed" + if status != "alerted": + logger.warning("Notruf von %s: status=%s, keine Benachrichtigung zugestellt " + "(email_ready=%s webhook_ready=%s)", + profile["name"], status, ready["email"], ready["webhook"]) return { "category": "manual", - "status": "alerted", + "status": status, "cooldown_seconds": cd["cooldown_seconds"], - "notice": notify_notice(language, notified), + "notice": emergency_notice(language, status), + "notified": notified, + "notify_ready": ready, "email_sent": email_sent, "webhook_sent": webhook_sent, "channels": channels, diff --git a/app/web/app.js b/app/web/app.js index fbdcc96..ecb1b3a 100644 --- a/app/web/app.js +++ b/app/web/app.js @@ -68,7 +68,7 @@ async function savePrefs(obj) { const TTS_SUPPORTED = typeof window !== "undefined" && "speechSynthesis" in window; const LANG_BCP47 = { de: "de-DE", en: "en-US", fr: "fr-FR", es: "es-ES", - it: "it-IT", pt: "pt-BR", pl: "pl-PL", ar: "ar-SA", ru: "ru-RU", zh: "zh-CN", + it: "it-IT", nl: "nl-NL", pt: "pt-BR", pl: "pl-PL", ar: "ar-SA", ru: "ru-RU", zh: "zh-CN", }; function isMobile() { @@ -359,7 +359,7 @@ async function copyText(text, btn) { } const ALL_LANGS = [["de","DE"],["en","EN"],["fr","FR"],["es","ES"],["it","IT"], - ["pt","PT"],["pl","PL"],["ar","AR"],["ru","RU"],["zh","ZH"]]; + ["nl","NL"],["pt","PT"],["pl","PL"],["ar","AR"],["ru","RU"],["zh","ZH"]]; // --- Format-Validierung der Notfall-Profil-Felder ------------------------ const _isEmail = (v) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v); @@ -1955,12 +1955,12 @@ const FIELD_META = { default_stt_provider: { ui: "combo", opts: ["openrouter","faster-whisper"], test: "stt" }, default_llm_provider: { ui: "combo", opts: ["openrouter","local-openai-compatible"], test: "llm" }, default_tts_provider: { ui: "combo", opts: ["openrouter","piper","chatterbox"], test: "tts" }, - default_language: { ui: "combo", opts: ["de","en","fr","es","it","pt","pl","ar"], - labels: { de:"Deutsch",en:"Englisch",fr:"Französisch",es:"Spanisch",it:"Italienisch",pt:"Portugiesisch",pl:"Polnisch",ar:"Arabisch" } }, + default_language: { ui: "combo", opts: ["de","en","fr","es","it","nl","pt","pl","ar"], + labels: { de:"Deutsch",en:"Englisch",fr:"Französisch",es:"Spanisch",it:"Italienisch",nl:"Niederländisch",pt:"Portugiesisch",pl:"Polnisch",ar:"Arabisch" } }, openrouter_llm_model: { ui: "datalist", test: "llm" }, openrouter_tts_model: { ui: "datalist", test: "tts", testProvider: "openrouter" }, openrouter_tts_voice: { ui: "combo", opts: ["Zephyr","Puck","Charon","Kore","Fenrir","Leda","Orus","Aoede","Callirrhoe","Enceladus","Algieba","Despina","Achernar"], test: "tts", testProvider: "openrouter" }, - piper_voice: { ui: "combo", opts: ["de_DE-thorsten-high","de_DE-kerstin-low","en_US-lessac-high","en_US-amy-medium","en_US-ryan-high","en_GB-cori-high","fr_FR-siwis-medium","fr_FR-tom-medium","es_ES-sharvard-medium","it_IT-paola-medium","it_IT-riccardo-x_low","pt_BR-faber-medium","pl_PL-gosia-medium","pl_PL-darkman-medium","ar_JO-kareem-medium","ru_RU-irina-medium","ru_RU-ruslan-medium","zh_CN-huayan-medium"], test: "tts", testProvider: "piper" }, + piper_voice: { ui: "combo", opts: ["de_DE-thorsten-high","de_DE-kerstin-low","en_US-lessac-high","en_US-amy-medium","en_US-ryan-high","en_GB-cori-high","fr_FR-siwis-medium","fr_FR-tom-medium","es_ES-sharvard-medium","it_IT-paola-medium","it_IT-riccardo-x_low","nl_NL-mls-medium","pt_BR-faber-medium","pl_PL-gosia-medium","pl_PL-darkman-medium","ar_JO-kareem-medium","ru_RU-irina-medium","ru_RU-ruslan-medium","zh_CN-huayan-medium"], test: "tts", testProvider: "piper" }, local_llm_system_prompt: { ui: "textarea", test: "llm" }, local_llm_temperature: { ui: "range", min: 0, max: 2, step: 0.1, test: "llm" }, local_llm_top_p: { ui: "range", min: 0, max: 1, step: 0.05, test: "llm" }, @@ -2380,6 +2380,7 @@ async function runEmergency() { body: JSON.stringify(body), }); const data = await res.json().catch(() => ({})); + if (data && data.notify_ready) sosNotifyReady = !!data.notify_ready.any; 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); @@ -2389,12 +2390,19 @@ async function runEmergency() { const notice = (data && data.notice) || "ACHTUNG: Es ist noch keine Benachrichtigung eingebaut."; addMessage("emergency", "🆘 " + notice); - const cd = (data && data.cooldown_seconds) || 0; - if (cd > 0) { - enterCooldown(cd, 0); // grün/pulsierend für die Sperre + if (data && data.status === "alerted") { + const cd = 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); + } } else { - setSosState("done"); // Sperre aus -> nur kurzes Erfolgs-Feedback - setTimeout(() => { if (!sosCooldownActive) setSosState("idle"); }, 6000); + // no_recipients / delivery_failed: es ging NICHTS raus -> KEIN grünes Erfolgssignal, + // sondern roter Fehlerzustand; der Hinweis nennt die Selbsthilfe (112). + setSosState("error"); + setTimeout(() => { if (!sosCooldownActive) setSosState("idle"); }, 8000); } } catch (e) { addMessage("emergency", "🆘 Fehler beim Auslösen des Notrufs."); @@ -2403,9 +2411,17 @@ async function runEmergency() { } } +// Ist (serverseitig) überhaupt ein Benachrichtigungskanal nutzbar? null = noch +// unbekannt; erst bei sicherem `false` zeigen wir die Vorab-Warnung im Dialog. +let sosNotifyReady = null; + // Großer eigener Bestätigungsdialog statt native confirm() — seniorentauglich. const sosConfirm = $("#sos-confirm"); -function openSosConfirm() { if (sosConfirm) sosConfirm.classList.remove("hidden"); } +function openSosConfirm() { + const warn = $("#sos-confirm-warn"); + if (warn) warn.classList.toggle("hidden", sosNotifyReady !== false); + 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. @@ -2428,6 +2444,7 @@ async function initSosCooldown() { const res = await fetch("/api/emergency/status", { headers: { Accept: "application/json" } }); if (!res.ok) return; const s = await res.json(); + if (s && s.notify_ready) sosNotifyReady = !!s.notify_ready.any; if (s && s.active) enterCooldown(s.remaining_seconds || 0, s.minutes_ago || 0); } catch (e) { /* ignore */ } } diff --git a/app/web/index.html b/app/web/index.html index 0e9bbfb..a0fd6d0 100644 --- a/app/web/index.html +++ b/app/web/index.html @@ -386,6 +386,11 @@

Möchten Sie wirklich Hilfe rufen?

Es werden sofort Helfer alarmiert.

+
- + diff --git a/tests/test_emergency_cooldown.py b/tests/test_emergency_cooldown.py index 84b95ac..eec16c4 100644 --- a/tests/test_emergency_cooldown.py +++ b/tests/test_emergency_cooldown.py @@ -73,10 +73,15 @@ def test_second_alarm_suppressed(monkeypatch): 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, "smtp_host", "") 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" + # Erster Alarm wird protokolliert + Sperre gesetzt; ohne nutzbaren Kanal aber + # ehrlich als "no_recipients" gemeldet (niemand wurde erreicht), nicht "alerted". + assert res["status"] == "no_recipients" + assert res["notified"] is False + assert res["notify_ready"]["any"] is False assert res["cooldown_seconds"] == 300 assert store.logged == 1 diff --git a/tests/test_quota_safety.py b/tests/test_quota_safety.py index 5093495..8aa4cbf 100644 --- a/tests/test_quota_safety.py +++ b/tests/test_quota_safety.py @@ -41,12 +41,16 @@ def test_quota_unlimited_by_default(monkeypatch): # --- Manueller Notruf (vom Nutzer ausgelöst) ------------------------------- def test_manual_emergency_logs_and_returns_localized_notice(): + # Ohne konfigurierten Kanal (Default: kein SMTP/Webhook) -> ehrlicher Status + # no_recipients mit handlungsleitendem Hinweis (Selbsthilfe 112). resp = client.post("/api/emergency", json={"language": "de"}) assert resp.status_code == 200 data = resp.json() assert data["category"] == "manual" - assert "noch keine Benachrichtigung" in data["notice"] - # Notruf wurde protokolliert + assert data["status"] == "no_recipients" + assert data["notified"] is False + assert "112" in data["notice"] + # Notruf wurde trotzdem protokolliert events = deps.get_store().list_emergency_events(10) assert any(e["category"] == "manual" for e in events) @@ -54,11 +58,11 @@ def test_manual_emergency_logs_and_returns_localized_notice(): 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 + # Englisch -> englischer Hinweis; unbekannte Sprache -> deutscher Fallback. en = client.post("/api/emergency", json={"language": "en"}).json()["notice"] - assert "ATTENTION" in en + assert "notified" in en.lower() xx = client.post("/api/emergency", json={"language": "xx"}).json()["notice"] - assert "ACHTUNG" in xx + assert "niemand benachrichtigt" in xx def test_manual_emergency_does_not_count_against_quota(monkeypatch):