From 1b1e1bb1d53161f94614f777c7c5d203c16eae5c Mon Sep 17 00:00:00 2001 From: dschlueter Date: Fri, 26 Jun 2026 11:33:04 +0200 Subject: [PATCH 1/9] =?UTF-8?q?perf(stream):=20TTS-Synthese=20vom=20Token-?= =?UTF-8?q?Streaming=20entkoppeln=20(Bubble=20l=C3=A4uft=20voraus)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Im Streaming-Pfad lief die satzweise TTS-Synthese bisher inline in der LLM-Leseschleife: pro Satzgrenze wartete `chat_stream` per `await` auf die Synthese, wodurch das Token-Streaming (Text in der Antwort-Bubble) satzweise pausierte. Jetzt Producer/Consumer: Die LLM-Leseschleife legt fertige Sätze in eine asyncio.Queue und liest sofort weiter; ein einzelner Consumer-Task synthetisiert sequenziell und ruft `on_audio` in Reihenfolge auf. Ein einzelner Consumer garantiert die Audio-Reihenfolge (gapless-Invariante im Client). Sentinel + await am Ende, Exceptions aus dem Consumer werden propagiert, verwaiste Tasks werden im finally abgeräumt. Deterministisch gemessen (Fake-LLM + Fake-TTS 3x800ms Synthese): Token-Spanne 1845ms -> 242ms, größtes Token-Loch 821ms -> 20ms. Audio-Chunks bleiben in Reihenfolge (seq 0..n). text_only- und Nicht-Streaming-Pfad unverändert. Co-Authored-By: Claude Opus 4.8 --- app/core/orchestrator.py | 119 +++++++++++++++++++++++++++------------ 1 file changed, 82 insertions(+), 37 deletions(-) diff --git a/app/core/orchestrator.py b/app/core/orchestrator.py index cff898a..af2e6ac 100644 --- a/app/core/orchestrator.py +++ b/app/core/orchestrator.py @@ -1,3 +1,5 @@ +import asyncio + from app.schemas import AudioChunk, PipelineTrace from app.pipeline.sentence_chunker import SentenceChunker from app.metrics import timer, metrics @@ -197,49 +199,92 @@ class Orchestrator: parts: list[str] = [] stream_fn = getattr(self.llm, "stream", None) - if stream_fn is not None: - async for delta in stream_fn(trace.cleaned_transcript or "", history=history, language=effective_language): - parts.append(delta) - if on_token: - await on_token(delta) - if chunker: - for sentence in chunker.feed(delta): - await _emit_sentence(sentence) - else: - result = await self.llm.complete(trace.cleaned_transcript or "", history=history, language=effective_language) - parts.append(result) - if on_token: - await on_token(result) - if chunker: - for sentence in chunker.feed(result): + + # Producer/Consumer: Die satzweise TTS-Synthese läuft in einem eigenen + # Consumer-Task, damit das Token-Streaming (Text in der Bubble) nicht + # mehr satzweise auf die Synthese wartet. Ein *einzelner* Consumer + # garantiert die Reihenfolge der Audio-Chunks — harte Invariante fürs + # gapless Playback im Client; out-of-order würde das Audio zerstören. + queue: asyncio.Queue | None = asyncio.Queue() if chunker else None + consumer_task: asyncio.Task | None = None + + async def _consume() -> None: + while True: + sentence = await queue.get() + try: + if sentence is None: # Sentinel: keine Sätze mehr + return await _emit_sentence(sentence) + finally: + queue.task_done() - trace.semantic_response = "".join(parts) - if not trace.semantic_response: - raise RuntimeError("LLM returned an empty response") + async def _dispatch(sentence: str) -> None: + # Satz in die Queue legen und sofort weiterlesen (kein await auf die + # Synthese). Ist der Consumer zuvor an einer Synthese-Exception + # gestorben, diese sofort hochreichen, statt weiter zu puffern. + if consumer_task is not None and consumer_task.done(): + await consumer_task + await queue.put(sentence) - trace.spoken_response = await self.spoken_adapter.run( - trace.semantic_response, - language=effective_language, - ) - trace.tts_ready_text = await self.tts_normalizer.run( - trace.spoken_response, - language=effective_language, - level=self.normalize_level, - ) + if queue is not None: + consumer_task = asyncio.create_task(_consume()) - if text_only: - return trace, b"" + try: + if stream_fn is not None: + async for delta in stream_fn(trace.cleaned_transcript or "", history=history, language=effective_language): + parts.append(delta) + if on_token: + await on_token(delta) + if chunker: + for sentence in chunker.feed(delta): + await _dispatch(sentence) + else: + result = await self.llm.complete(trace.cleaned_transcript or "", history=history, language=effective_language) + parts.append(result) + if on_token: + await on_token(result) + if chunker: + for sentence in chunker.feed(result): + await _dispatch(sentence) - if chunker: - tail = chunker.flush() - if tail: - await _emit_sentence(tail) - audio = b"".join(audio_parts) - else: - audio = await self.tts.synthesize( - trace.tts_ready_text, voice=voice, language=effective_language + trace.semantic_response = "".join(parts) + if not trace.semantic_response: + raise RuntimeError("LLM returned an empty response") + + trace.spoken_response = await self.spoken_adapter.run( + trace.semantic_response, + language=effective_language, ) + trace.tts_ready_text = await self.tts_normalizer.run( + trace.spoken_response, + language=effective_language, + level=self.normalize_level, + ) + + if text_only: + return trace, b"" + + if chunker: + tail = chunker.flush() + if tail: + await _dispatch(tail) + await queue.put(None) # Sentinel: Consumer beenden + await consumer_task # auf restliche Synthese warten + Exceptions propagieren + consumer_task = None + audio = b"".join(audio_parts) + else: + audio = await self.tts.synthesize( + trace.tts_ready_text, voice=voice, language=effective_language + ) + finally: + # Bei Fehler/Abbruch den noch laufenden Consumer-Task abräumen, + # damit kein verwaister Task zurückbleibt. + if consumer_task is not None and not consumer_task.done(): + consumer_task.cancel() + try: + await consumer_task + except (asyncio.CancelledError, Exception): + pass await self._emit_to_output(audio, output) return trace, audio From b4111c887e548e482ea20a8b8a96dc59d15e8575 Mon Sep 17 00:00:00 2001 From: dschlueter Date: Fri, 26 Jun 2026 12:37:14 +0200 Subject: [PATCH 2/9] feat(auth): Admin-Selbstanmeldung in die App (Login statt verlorenem Zugangslink) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die App-Oberflaeche (/) ist bewusst nur per persoenlichem Zugangslink erreichbar (kein SSO, damit Senioren keine Login-Maske sehen). Ein Admin, der seinen Link verliert, kam bisher nicht mehr in die App-UI. Neuer Endpunkt GET /api/admin/login: liegt hinter Authelia (nginx-Location /api/admin/ mit auth_request + error_page-Redirect, Remote-User durchgereicht), erkennt den SSO-Admin, mintet ein frisches Capability-Token, setzt es als va_token-Cookie und leitet in die App (/). Damit meldet sich der Admin allein mit seinem Authelia-Passwort an. Jeder Aufruf rotiert den Token (alte Links dieses Admins werden ungueltig) - fuer eine Recovery-Funktion korrekt. Die huebsche URL https://voice.jamulix.de/admin-login wird per nginx-Alias auf diesen Endpunkt gelegt (nginx-Config liegt ausserhalb des Repos). Doku: BEDIENUNGSANLEITUNG.md § 7.6 - Selbstanmeldung, Recovery per ADMIN_API_KEY (SSO-unabhaengig) und Haertung (zweiter Admin in ADMIN_USERS, Key im Passwortmanager). Tests: Cookie wird gesetzt + 303 -> /, gesetzter Token authentifiziert, Token-Rotation, Nicht-Admin/ohne Identitaet -> 403. Co-Authored-By: Claude Opus 4.8 --- BEDIENUNGSANLEITUNG.md | 67 +++++++++++++++++++++++++++++++- app/api/admin.py | 32 ++++++++++++++- tests/test_admin_app_login.py | 73 +++++++++++++++++++++++++++++++++++ 3 files changed, 169 insertions(+), 3 deletions(-) create mode 100644 tests/test_admin_app_login.py diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index a703ed3..a74b4da 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -44,7 +44,7 @@ funktioniert trotzdem, die Ausgabe ist dann unformatiert. 6. [Einstellungen und Konfiguration](#6-einstellungen-und-konfiguration) **Administration** -7. [Nutzerverwaltung und Authentifizierung](#7-nutzerverwaltung-und-authentifizierung) · [7.5 Admin-Web-Panel](#75-admin-web-panel) +7. [Nutzerverwaltung und Authentifizierung](#7-nutzerverwaltung-und-authentifizierung) · [7.5 Admin-Web-Panel](#75-admin-web-panel) · [7.6 Admin-Login & Recovery](#76-admin-zugang-ohne-persönlichen-link-selbstanmeldung--recovery) 8. [Gedächtnis und Erinnerungen](#8-gedächtnis-und-erinnerungen) 9. [Resilienz, Fallbacks und Metriken](#9-resilienz-fallbacks-und-metriken) 10. [Notfall-Erkennung und Eskalation](#10-notfall-erkennung-und-eskalation) @@ -1865,6 +1865,71 @@ Protokolliert werden u. a. `config_set` / `config_reset` (Laufzeit-Einstellungen > Sichtbar im Log-Tab nur im **Dienst-Betrieb** (Journal). Im Vordergrund-Betrieb > (`make run`) erscheinen die Audit-Zeilen im Terminal. +### 7.6 Admin-Zugang ohne persönlichen Link (Selbstanmeldung & Recovery) + +> 🔧 Admin + +Die App-Oberfläche (`/`) ist **nur per persönlichem Zugangslink** (`?k=` → Cookie +`va_token`) erreichbar — bewusst ohne SSO, damit Senioren keine Login-Maske sehen. Wer +seinen Link verliert, kommt zunächst nicht in die App. Als **Admin** bist Du dadurch +**nicht ausgesperrt**: Du hast einen Selbst-Login und zwei unabhängige Recovery-Wege. + +#### 7.6.1 Selbstanmeldung mit dem Admin-Passwort (empfohlen) + +Rufe im Browser auf: + +``` +https://voice.jamulix.de/admin-login +``` + +Ablauf: Der Reverse-Proxy schickt Dich zu **Authelia** (`auth.jamulix.de`) → Du meldest +Dich mit Deinem **Admin-Passwort/2FA** an → das Gateway erkennt Dich als SSO-Admin +(`ADMIN_USERS`), setzt Dir ein frisches `va_token`-Cookie und leitet in die App (`/`). +**Kein Link nötig.** Bookmarke diese URL als „Admin-Login". + +- Technisch dahinter steht der Endpunkt `GET /api/admin/login` (dieselbe Wirkung; die + hübsche URL `/admin-login` ist nur ein nginx-Alias darauf). +- **Jeder Aufruf rotiert Deinen Token** — alte persönliche Links *dieses Admin-Nutzers* + werden ungültig. Für eine Wiederherstellung korrekt und ein Sicherheitsplus. + +#### 7.6.2 Recovery per `ADMIN_API_KEY` (SSO-unabhängig) + +Funktioniert auch, wenn Authelia gerade nicht erreichbar ist — vom Server/localhost aus. +Den Key liest Du als root aus `/etc/voice-assistant/voice-assistant.env`. + +```bash +# 1) Deine user_id finden: +curl -s $URL/api/admin/users -H "X-Admin-Key: $ADMIN_API_KEY" \ + | jq -r '.[] | select(.external_id=="dschlueter") | .user_id' + +USER_ID= + +# 2) Neuen Token ausstellen (wird EINMALIG zurückgegeben): +TOKEN=$(curl -s -X POST $URL/api/admin/users/$USER_ID/token \ + -H "X-Admin-Key: $ADMIN_API_KEY" | jq -r '.token') + +# 3) Daraus den Zugangslink bauen: +echo "https://voice.jamulix.de/?k=$TOKEN" +``` + +> Der `ADMIN_API_KEY` ist sehr mächtig (voller Admin-Zugriff). Nicht in Proxy-Logs +> geraten lassen (kein `?key=` in URLs verwenden, außer beim einmaligen Bootstrap über +> `GET /api/admin/request-headers?key=…`), und nach solchem Gebrauch rotieren. + +#### 7.6.3 Härtung gegen Total-Aussperrung + +Eine echte Vollsperre bräuchte: Link weg **und** Authelia-Konto weg **und** +`ADMIN_API_KEY` weg **und** kein SSH. Um auch das abzusichern: + +- **Zweiter Admin:** einen weiteren Authelia-Namen in `ADMIN_USERS` aufnehmen (CSV), + damit ein einzelnes verlorenes Konto nicht alles blockiert: + ```bash + # in /etc/voice-assistant/voice-assistant.env, dann Gateway neu starten: + ADMIN_USERS=dschlueter, + ``` +- **`ADMIN_API_KEY` sichern:** im Passwortmanager hinterlegen (der Wert steht in + `/etc/voice-assistant/voice-assistant.env`). + --- ## 8. Gedächtnis und Erinnerungen diff --git a/app/api/admin.py b/app/api/admin.py index 22db3eb..72fec12 100644 --- a/app/api/admin.py +++ b/app/api/admin.py @@ -6,7 +6,7 @@ import httpx import yaml from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSocketDisconnect -from fastapi.responses import FileResponse +from fastapi.responses import FileResponse, RedirectResponse from pydantic import BaseModel from app.admin_llm import ( @@ -16,7 +16,7 @@ from app.admin_llm import ( switch_backend, ) from app.audit import log_admin_action -from app.auth import is_admin_user, require_admin, require_admin_or_user +from app.auth import CAPABILITY_COOKIE, is_admin_user, require_admin, require_admin_or_user from app.config import settings from app.dependencies import get_store from app.runtime_config import RUNTIME_SETTABLE, invalidate_cache, runtime_settings @@ -66,6 +66,34 @@ def _current_user_or_none(request: Request): return authenticate(request.headers, client_host, token) +@router.get("/admin/login") +async def admin_app_login(request: Request): + """Admin-Selbstanmeldung in die App — Ersatz fuer einen verlorenen Zugangslink. + + Liegt hinter Authelia (nginx: Location /api/admin/ bzw. /admin-login mit + auth_request + error_page-Redirect). Erkennt den SSO-Admin via Remote-User, + mintet ein frisches Capability-Token, setzt es als va_token-Cookie und leitet + in die App (/). Damit meldet sich der Admin allein mit seinem Authelia-Passwort + an, ohne persoenlichen Link. + + Jeder Aufruf rotiert das Token (alte Links DIESES Admin-Nutzers werden + ungueltig) — fuer eine Recovery-Funktion korrekt und ein Sicherheitsplus: + jede SSO-Anmeldung erzeugt eine frische Capability. + """ + user = _current_user_or_none(request) + if not is_admin_user(user): + raise HTTPException(status_code=403, detail="Admin privileges required") + result = get_store().reset_token(user.id) + if result is None: + raise HTTPException(status_code=404, detail="Admin-Nutzer nicht gefunden.") + _, raw_token = result + log_admin_action(request, "admin_app_login", user_id=user.id) + resp = RedirectResponse("/", status_code=303) + resp.set_cookie(CAPABILITY_COOKIE, raw_token, httponly=True, secure=True, + samesite="lax", max_age=31536000) + return resp + + @router.post("/admin/users", response_model=UserCreated, dependencies=[Depends(require_admin)]) async def create_user(payload: UserCreate): """Legt einen Nutzer an und gibt das Bearer-Token EINMALIG zurueck.""" diff --git a/tests/test_admin_app_login.py b/tests/test_admin_app_login.py new file mode 100644 index 0000000..15d7d85 --- /dev/null +++ b/tests/test_admin_app_login.py @@ -0,0 +1,73 @@ +"""Tests fuer die Admin-Selbstanmeldung in die App (GET /api/admin/login). + +Der Endpunkt liegt in Produktion hinter Authelia (nginx setzt Remote-User). +Hier wird die Forward-Auth ueber den vertrauten Proxy simuliert. +""" +import pytest +from fastapi.testclient import TestClient + +import app.dependencies as deps +from app.auth import CAPABILITY_COOKIE +from app.config import settings +from app.main import app + +client = TestClient(app) + +HEADER = "X-Remote-User" +# Starlette-TestClient meldet sich als Host "testclient" -> als vertrauten Proxy setzen. +TRUSTED = "testclient" + + +@pytest.fixture +def forward_auth(monkeypatch): + monkeypatch.setattr(settings, "trusted_auth_header", HEADER) + monkeypatch.setattr(settings, "trusted_proxy_ips", TRUSTED) + monkeypatch.setattr(settings, "admin_users", "dschlueter") + monkeypatch.setattr(settings, "auth_enabled", True) + + +def test_admin_login_sets_cookie_and_redirects(forward_auth): + r = client.get("/api/admin/login", headers={HEADER: "dschlueter"}, + follow_redirects=False) + assert r.status_code == 303 + assert r.headers["location"] == "/" + set_cookie = r.headers.get("set-cookie", "") + assert f"{CAPABILITY_COOKIE}=" in set_cookie + assert "HttpOnly" in set_cookie + + +def test_admin_login_cookie_token_authenticates(forward_auth): + r = client.get("/api/admin/login", headers={HEADER: "dschlueter"}, + follow_redirects=False) + # Den frisch gesetzten Token aus dem Set-Cookie ziehen ... + raw = r.headers["set-cookie"].split(f"{CAPABILITY_COOKIE}=", 1)[1].split(";", 1)[0] + # ... und pruefen, dass er den Admin-Nutzer authentifiziert. + user = deps.get_store().get_user_by_token(raw) + assert user is not None + assert user.external_id == "dschlueter" + + +def test_admin_login_rotates_token(forward_auth): + r1 = client.get("/api/admin/login", headers={HEADER: "dschlueter"}, + follow_redirects=False) + raw1 = r1.headers["set-cookie"].split(f"{CAPABILITY_COOKIE}=", 1)[1].split(";", 1)[0] + r2 = client.get("/api/admin/login", headers={HEADER: "dschlueter"}, + follow_redirects=False) + raw2 = r2.headers["set-cookie"].split(f"{CAPABILITY_COOKIE}=", 1)[1].split(";", 1)[0] + assert raw1 != raw2 + # Der alte Token ist nach der Rotation ungueltig. + assert deps.get_store().get_user_by_token(raw1) is None + assert deps.get_store().get_user_by_token(raw2) is not None + + +def test_admin_login_rejects_non_admin(forward_auth): + r = client.get("/api/admin/login", headers={HEADER: "lieschen"}, + follow_redirects=False) + assert r.status_code == 403 + + +def test_admin_login_rejects_without_identity(forward_auth): + # Vertrauter Proxy, aber kein Identitaets-Header -> keine Admin-Identitaet -> 403. + # (In Produktion faengt nginx das vorher mit dem Authelia-Redirect ab.) + r = client.get("/api/admin/login", follow_redirects=False) + assert r.status_code == 403 From 0ef4fe9cb8f9f6098f19156c22852b20fb3c6b2a Mon Sep 17 00:00:00 2001 From: dschlueter Date: Fri, 26 Jun 2026 13:48:35 +0200 Subject: [PATCH 3/9] =?UTF-8?q?feat(ui):=20SOS-Button=20prominent=20(rot?= =?UTF-8?q?=20gef=C3=BCllt,=20gro=C3=9Fes=20wei=C3=9Fes=20=E2=80=9ESOS")?= =?UTF-8?q?=20+=20Best=C3=A4tigung=20&=20Status?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der Notruf-Button war ein kleines 36px-Outline-Icon (🆘) — leicht zu übersehen. Jetzt ein rot gefüllter Button (bg-red-600) mit großem, fettem weißem „SOS" in der (immer sichtbaren) Kopfzeile — der Senior erkennt ihn sofort. Sprachwahl und Menü bleiben unverändert an ihrem Platz. - Bestätigung: statt native confirm() ein großer eigener Dialog (rote SOS-Kopf- zeile, Warnzeile, große Knöpfe „JA, HILFE RUFEN" / „Abbrechen"). Ein Tipp auf SOS, ein Tipp auf JA — schnell, aber gegen Fehlalarm geschützt. - Feedback am Button selbst: setSosState idle|calling|done|error — „Rufe …" (pulsierend) → „Gerufen ✓" (grün) → zurück zu „SOS". So sieht der Senior unmittelbar am Knopf, dass der Alarm ausgelöst wurde. Nur Frontend (app/web), live ohne Neustart (No-Cache-Static, Bump auf v=49). Verifiziert via Headless-Chrome/CDP: Idle/Dialog/Calling/Done-Zustände. Co-Authored-By: Claude Opus 4.8 --- app/web/app.js | 93 ++++++++++++++++++++++++++++++++++------------ app/web/index.html | 25 ++++++++++++- 2 files changed, 92 insertions(+), 26 deletions(-) diff --git a/app/web/app.js b/app/web/app.js index 7371de8..9845881 100644 --- a/app/web/app.js +++ b/app/web/app.js @@ -2245,29 +2245,74 @@ function deviceInfo() { return { mobile: /Mobi|Android|iPhone|iPad|iPod/i.test(ua), platform: ua.slice(0, 120) }; } -if (emergencyBtn) { - emergencyBtn.addEventListener("click", async () => { - if (!confirm("Wirklich Hilfe rufen?\nIch alarmiere sofort Helfer!")) return; - const lang = (langSel && langSel.value) || "de"; +// Sichtbarer Zustand am SOS-Button selbst: idle | calling | done | error. +// So erkennt der Senior unmittelbar am Knopf, dass der Alarm ausgelöst wurde. +function setSosState(state) { + if (!emergencyBtn) return; + emergencyBtn.classList.remove( + "bg-red-600", "hover:bg-red-700", "bg-green-600", "animate-pulse" + ); + if (state === "calling") { + emergencyBtn.textContent = "Rufe …"; emergencyBtn.disabled = true; - try { - const g = await captureGeo(); - const body = { language: lang, device: deviceInfo() }; - if (g.ok) body.geo = { lat: g.lat, lon: g.lon, accuracy: g.accuracy, ts: g.ts }; - else body.geo_error = g.error; - const res = await fetch("/api/emergency", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }); - const data = await res.json().catch(() => ({})); - const notice = (data && data.notice) || - "ACHTUNG: Es ist noch keine Benachrichtigung eingebaut."; - addMessage("emergency", "🆘 " + notice); - } catch (e) { - addMessage("emergency", "🆘 Fehler beim Auslösen des Notrufs."); - } finally { - emergencyBtn.disabled = false; - } - }); + emergencyBtn.classList.add("bg-red-600", "animate-pulse"); + } else if (state === "done") { + emergencyBtn.textContent = "Gerufen ✓"; + emergencyBtn.disabled = true; + emergencyBtn.classList.add("bg-green-600"); + } else if (state === "error") { + emergencyBtn.textContent = "Fehler"; + emergencyBtn.disabled = false; + emergencyBtn.classList.add("bg-red-600", "hover:bg-red-700"); + } else { // idle + emergencyBtn.textContent = "SOS"; + emergencyBtn.disabled = false; + emergencyBtn.classList.add("bg-red-600", "hover:bg-red-700"); + } } + +async function runEmergency() { + const lang = (langSel && langSel.value) || "de"; + setSosState("calling"); + try { + const g = await captureGeo(); + const body = { language: lang, device: deviceInfo() }; + if (g.ok) body.geo = { lat: g.lat, lon: g.lon, accuracy: g.accuracy, ts: g.ts }; + else body.geo_error = g.error; + const res = await fetch("/api/emergency", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const data = await res.json().catch(() => ({})); + 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" + } catch (e) { + addMessage("emergency", "🆘 Fehler beim Auslösen des Notrufs."); + setSosState("error"); + setTimeout(() => setSosState("idle"), 4000); + } +} + +// Großer eigener Bestätigungsdialog statt native confirm() — seniorentauglich. +const sosConfirm = $("#sos-confirm"); +function openSosConfirm() { if (sosConfirm) sosConfirm.classList.remove("hidden"); } +function closeSosConfirm() { if (sosConfirm) sosConfirm.classList.add("hidden"); } + +if (emergencyBtn) { + emergencyBtn.addEventListener("click", openSosConfirm); +} +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(); }); +} +document.addEventListener("keydown", (e) => { + if (e.key === "Escape" && sosConfirm && !sosConfirm.classList.contains("hidden")) closeSosConfirm(); +}); diff --git a/app/web/index.html b/app/web/index.html index f1c782c..edceaee 100644 --- a/app/web/index.html +++ b/app/web/index.html @@ -229,7 +229,7 @@

Voice Assistant

+ class="shrink-0 h-11 px-4 grid place-items-center rounded-lg bg-red-600 hover:bg-red-700 text-white font-extrabold tracking-wide text-lg shadow-sm whitespace-nowrap transition-colors disabled:opacity-100">SOS @@ -386,6 +386,6 @@ - + From 8c152ee77cebffb2fe7dcc44314633d56ad48cf0 Mon Sep 17 00:00:00 2001 From: dschlueter Date: Fri, 26 Jun 2026 15:58:17 +0200 Subject: [PATCH 5/9] =?UTF-8?q?feat(notruf):=20Wiederausl=C3=B6se-Sperre?= =?UTF-8?q?=20+=20gr=C3=BCner=20Hinweis;=20"Neues=20Gespr=C3=A4ch"=20in=20?= =?UTF-8?q?die=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 From f3dbd434b057d8eae720795116fd984dbe7d84df Mon Sep 17 00:00:00 2001 From: dschlueter Date: Fri, 26 Jun 2026 16:35:23 +0200 Subject: [PATCH 6/9] =?UTF-8?q?feat(ui):=20"Neues=20Gespr=C3=A4ch"=20in=20?= =?UTF-8?q?der=20Kopfzeile=20bei=20knappem=20Platz=20ausblenden?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Das Kopfzeilen-Symbol „Neues Gespräch" wird unterhalb von 384px Viewport-Breite ausgeblendet (hidden min-[384px]:grid), damit auf sehr schmalen Phones (z.B. iPhone SE 375px, kleine Androids ≤360px) der Titel Platz behält. Auf gängigen Phones (iPhone 12/13/14 390px, Pixel 393px) bleibt es sichtbar. Kein Funktionsverlust: das Menü enthält „Neues Gespräch" dauerhaft als Rückfall — es muss also nichts dynamisch verschoben werden, nur das Kopfzeilen-Symbol ausgeblendet. SOS bleibt auf allen Breiten sichtbar. Reine CSS-Lösung (kein JS, kein Flackern). Grenze empirisch eingemessen (voller Titel passt mit Symbol ab ~420px; 384px hält ihn auf den meisten Geräten sichtbar bei mild gekürztem Titel). Verifiziert via Headless-Chrome über mehrere Breiten. Co-Authored-By: Claude Opus 4.8 --- app/web/index.html | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/web/index.html b/app/web/index.html index 2371bb3..0f4e266 100644 --- a/app/web/index.html +++ b/app/web/index.html @@ -243,8 +243,11 @@ +