Compare commits

..

2 commits

Author SHA1 Message Date
3d3973dc16 feat(i18n): Niederländisch (nl) als Sprache aufnehmen
NL war als Halbzustand angelegt (Piper-Modell nl_NL-mls-medium,
Chatterbox-nl.wav, LLM-Sprachname, Cartesia-Ausschluss, leeres
pronunciation.nl.yaml), fehlte aber im Sprachmenü und Backend-Mapping.

- app.js: NL in ALL_LANGS, LANG_BCP47, default_language-Auswahl und
  piper_voice-Liste (jeweils zwischen IT und PT)
- dependencies.py: LANG_TO_PIPER_VOICE nl -> nl_NL-mls-medium
  (kein GENDERED-Eintrag: nur eine Stimme, wie PT/AR)
- safety/emergency.py: NL-Texte in allen 4 Hinweis-Dicts

STT (faster-whisper large-v3) und LLM (_LANG_NAMES) unterstützen nl
bereits automatisch. Offen: deutsche Ordinalzahlen nur für language=de
(NL fällt durch, kosmetisch).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-28 15:13:29 +02:00
37b4c175d2 fix(notruf): ehrlicher Status, wenn niemand verständigt werden kann
Bisher meldete ein Notruf ohne nutzbaren Kanal trotzdem Erfolg
(status="alerted", grüner Haken), obwohl tatsächlich niemand
benachrichtigt wurde — gefährlich für Senioren.

- emergency.py: drei klare Endzustände alerted / no_recipients /
  delivery_failed; notify_readiness() prüft, ob ein Kanal wirklich
  senden würde (SMTP+Empfaenger bzw. Webhook). Mehrsprachige Hinweise
  fuer die Fehlfaelle (nennen Selbsthilfe 112). Antwort traegt jetzt
  notified + notify_ready; Warn-Log, wenn nichts zugestellt wurde.
- /api/emergency/status liefert notify_ready fuer die Vorab-Warnung.
- Frontend: gelbe Vorab-Warnung im SOS-Dialog, wenn kein Empfaenger
  eingerichtet ist; bei no_recipients/delivery_failed kein gruenes
  Erfolgssignal mehr, sondern roter Fehlerzustand (app.js v54).
- Tests an die neue Semantik angepasst.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 16:50:03 +02:00
7 changed files with 136 additions and 23 deletions

View file

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

View file

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

View file

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

View file

@ -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 */ }
}

View file

@ -386,6 +386,11 @@
<div class="px-5 py-4">
<p class="text-base text-slate-700 dark:text-slate-200">Möchten Sie wirklich Hilfe rufen?</p>
<p class="mt-1 text-sm font-semibold text-red-600 dark:text-red-400">Es werden sofort Helfer alarmiert.</p>
<p id="sos-confirm-warn"
class="hidden mt-3 rounded-lg bg-amber-100 dark:bg-amber-900/40 px-3 py-2 text-sm font-semibold text-amber-800 dark:text-amber-200">
⚠️ Achtung: Es sind noch keine Notfall-Kontakte eingerichtet. Es kann niemand
automatisch benachrichtigt werden im Notfall bitte selbst 112 anrufen.
</p>
</div>
<div class="px-5 pb-5 grid grid-cols-1 gap-3">
<button type="button" id="sos-confirm-yes"
@ -414,6 +419,6 @@
</div>
</div>
<script src="/app.js?v=53"></script>
<script src="/app.js?v=54"></script>
</body>
</html>

View file

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

View file

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