From 2941bb370c0d872138edb2b618c6c868d99a9a05 Mon Sep 17 00:00:00 2001 From: Brummel Date: Mon, 20 Apr 2026 10:08:50 +0200 Subject: [PATCH] feat: Add SSE for live UI updates Implement Server-Sent Events to push real-time updates to the client, eliminating the need for manual refreshes. The SSE endpoint filters events for non-admins based on slugs and provides a 15-second keep-alive to prevent proxy timeouts. --- docs/projektplan.md | 86 +++++++++++++++++++++++++++++++++++---------- 1 file changed, 67 insertions(+), 19 deletions(-) diff --git a/docs/projektplan.md b/docs/projektplan.md index 5eb9bb8..56b895e 100644 --- a/docs/projektplan.md +++ b/docs/projektplan.md @@ -377,12 +377,17 @@ POST /web/cases/{case_id}/reset → Analyse/Transkripte verwerfen, POST /web/cases/{case_id}/delete → Soft-Delete (Batch-Marker) POST /web/cases/undo-delete → letzte Lösch-Batch wiederherstellen POST /web/cases/bulk → Bulk-Aktionen (analyze / delete, admin-only) -GET /web/audio/{user}/{case_id}/{filename} → Audio-Streaming (Cookie-Auth; Arzt oder Admin) +GET /web/audio/{user}/{case_id}/{filename} → Audio-Streaming (Cookie-Auth; Arzt oder Admin; + HTTP-Range-Support, `Accept-Ranges: bytes`, + 206 Partial Content für HTML5-Seeking) +GET /web/events → SSE-Stream für Live-UI-Updates (Cookie-Auth; + Non-Admins: eigene Slug-gefilterte Events; + Admins: alle Slugs; 15 s Keep-Alive) ``` **Template-Split (Ist-Stand seit 2026-04-19):** Die bisherigen Templates `case_detail.html`, `document.html`, `cases.html` sind entfernt. Stattdessen zwei dezidierte Seiten: `case_page.html` (Übersicht + Aktionen + gerendertes Dokument) und `case_recordings.html` (einzelne Transkripte + Audio). Das Admin-Log unter `GET /web/` existiert damit in der Routing-Tabelle nicht mehr als eigenständige Seite — die Admin-Sichtbarkeit wird über den `is_admin`-Flag in den ViewModels und Templates an den regulären `/web/cases`-Views aufgehängt. -**Geplant, noch nicht implementiert:** `GET /web/events` (SSE), Preset- und Undo-Endpoints für Dokument-Versionen. Siehe Phase 4 weiter unten. +**Geplant, noch nicht implementiert:** Preset- und Undo-Endpoints für Dokument-Versionen. Siehe Phase 4 weiter unten. **Kein `{arzt}` in URLs (IDOR-Prävention):** Die Arzt-Identität wird ausschließlich aus dem Session-Cookie abgeleitet, nie aus der URL. Ein Axum-Extractor (`AuthenticatedArzt`) liest das Session-Token, schlägt den zugehörigen Arzt nach und gibt ihn als typisierte Struct zurück. Alle `/web/`-Handler erhalten den Arzt nur über diesen Extractor — der Dateisystempfad `/data/{arzt}/` wird serverseitig aus der Session konstruiert. Dadurch kann ein eingeloggter Arzt prinzipbedingt nicht auf Daten eines anderen Arztes zugreifen, selbst wenn er URLs manuell ändert. `case_id` wird zusätzlich als UUIDv4 validiert (`uuid::Uuid::parse_str`), um Path-Traversal über manipulierte IDs auszuschließen. @@ -390,10 +395,10 @@ Die Arzt-Identität wird ausschließlich aus dem Session-Cookie abgeleitet, nie **Fall-States:** ``` -┌────────────┐ sofort ┌───────────────┐ Arzt klickt ┌─────────────┐ +┌────────────┐ sofort ┌───────────────┐ auto-trigger ┌─────────────┐ │ Empfangen │ ──────────────→ │ Transkribiert │ ──────────────→ │ Ausgewertet │ -│ (queued) │ Transkription │ (einsehbar) │ "Analysieren" │ (Dokument) │ -└────────────┘ └───────────────┘ └─────────────┘ +│ (queued) │ Transkription │ (einsehbar) │ oder manuell │ (Dokument) │ +└────────────┘ └───────────────┘ "Analysieren" └─────────────┘ ``` - **Empfangen:** Mindestens eine Aufnahme ist noch in der Transkriptions-Queue oder wird gerade verarbeitet @@ -402,7 +407,7 @@ Die Arzt-Identität wird ausschließlich aus dem Session-Cookie abgeleitet, nie Ein Fall wechselt erst zu "Transkribiert", wenn *alle* zugehörigen Aufnahmen transkribiert sind. Solange eine Aufnahme noch in der Queue ist, bleibt der Fall im State "Empfangen". -"Analysieren" ist nur im State "Transkribiert" möglich. Der Button ist ausgegraut / nicht sichtbar, solange Aufnahmen noch ausstehen **oder** der LLM-Provider nicht konfiguriert ist. +"Analysieren" wird **automatisch ausgelöst** (`analyze::auto_trigger`), sobald ein Fall im State "Transkribiert" ist und noch kein `document.md` existiert (oder das Dokument älter ist als die jüngste Aufnahme). Der Trigger läuft auf jedem `/web/cases`- und `/web/cases/{id}`-Handler-Aufruf; SSE-getriggerte Reloads stellen den Puls, ein Background-Timer ist nicht nötig. Der manuelle „Analysieren"-Button bleibt für erzwungenes Re-Analyze (z.B. nach Prompt-Änderung) erhalten und ist nur im State "Transkribiert" sowie bei konfiguriertem LLM-Provider sichtbar. --- @@ -423,7 +428,7 @@ Ein Fall wechselt erst zu "Transkribiert", wenn *alle* zugehörigen Aufnahmen tr Das ursprünglich geplante `open/`/`done/`-Split wurde verworfen — siehe Abweichungen-Sektion. Es gibt nur noch eine flache Struktur pro User. **Nachträgliche Aufnahmen nach Fallabschluss:** -Wenn eine Aufnahme für einen Fall mit bereits vorhandenem `document.md` eintrifft, wird sie angenommen, gespeichert und transkribiert. Das Dokument bleibt unverändert bestehen. Der Nachtrag-Hinweis im UI („⚠ 1 neue Aufnahme seit Abschluss — neu analysieren?") ist in Phase 4 geplant, aber noch nicht implementiert. Der Arzt kann manuell „Neu analysieren" anstoßen, dann wird `document.md` aus allen Transkripten neu erzeugt. +Wenn eine Aufnahme für einen Fall mit bereits vorhandenem `document.md` eintrifft, wird sie angenommen, gespeichert und transkribiert. Sobald das neue Transkript fertig ist, erkennt der Auto-Trigger, dass das Dokument älter ist als die jüngste Aufnahme, löscht `document.md` und reiht den Fall neu ein — der Arzt sieht nach dem SSE-Reload das aktualisierte Dokument. Der geplante UI-Hinweis („⚠ 1 neue Aufnahme seit Abschluss") ist damit in der Sache bereits umgesetzt, als sichtbare Banner-Variante aber noch offen. Der manuelle „Neu analysieren"-Button bleibt für erzwungene Re-Runs (z.B. nach Prompt-Änderung). **Verspätete Uploads für gelöschte Fälle (Ist-Stand):** Trifft ein Upload für einen Fall mit `.deleted`-Marker ein, wird der Marker entfernt und die Aufnahme normal als Nachtrag behandelt. Der ursprünglich geplante `"gone"`-ACK-Status ist nicht implementiert — der Upload-Handler antwortet immer mit `"received"`. Grund: Sonst würde der allererste Upload einer neuen case_id unnötig abgelehnt. Details in der Abweichungen-Sektion. @@ -542,6 +547,36 @@ Keine Versionierung. Eine neue Analyse überschreibt die alte in-place. --- +**Auto-Trigger für LLM-Analyse (`analyze::auto_trigger`, Ist-Stand):** + +Die LLM-Analyse wird opportunistisch gestartet, sobald ein Fall transkriptionsvollständig ist — ohne Nutzeraktion. Ausgelöst wird der Trigger in den `/web/cases`- und `/web/cases/{id}`-Handlern; jeder SSE-getriggerte Reload wird damit zum natürlichen Polling-Puls, ein Background-Timer ist nicht nötig. + +``` +evaluate_case(case_dir) → AutoDecision::{Enqueue, Skip(reason)} + Skip-Gründe (reine FS-Stats, keine Mutation, keine Channels): + • keine Aufnahmen + • nicht alle Aufnahmen transkribiert + • analysis_input.json vorhanden → Job schon in Queue / in-flight + • .analysis_failed.json vorhanden mit identischer last_recording_mtime + • document.md aktueller als jüngste Aufnahme → nichts zu tun + +try_enqueue(case_dir, …): + 1. config.llm_configured() ? sonst false + 2. evaluate_case → Enqueue ? sonst false + 3. build_analysis_input() aus allen Transkripten + 4. (re-analyse) document.md löschen, damit Worker-Guard nicht short-circuitet + 5. analysis_input.json überschreiben + 6. Job an AnalyzeSender; CaseEventKind::AnalysisQueued emittieren +``` + +**Retry-Gate `.analysis_failed.json`:** Bei LLM-Fehler schreibt der Worker einen Marker mit `{last_recording_mtime, reason, failed_at}`. `evaluate_case` überspringt Fälle mit passender `last_recording_mtime` — kein Retry-Loop, bis das Input-Signal (neue Aufnahme oder Reset) sich ändert. Der Worker löscht den Marker bei erfolgreichem Analyse-Abschluss. + +**Re-Analyse-Pfad:** Trifft eine neue Aufnahme nach Fallabschluss ein, wird das Transkript geschrieben → Oneliner regeneriert → beim nächsten UI-Render erkennt `evaluate_case` das veraltete `document.md`, `try_enqueue` löscht es und reiht den Fall neu ein. Aus Arztsicht „passiert das von selbst". + +`try_enqueue_all_for_user(user_root, …)` iteriert über alle UUID-Verzeichnisse und darf auf jedem Fall-Listen-Render laufen — Skips sind billig (nur `stat`). + +--- + **Geplant für Phase 4 (noch nicht implementiert):** - **Preset-basiertes „Neu generieren"**: Preset-Prompt + optionaler Freitext kombinieren, LLM erneut aufrufen, vorige Version erhalten. @@ -554,8 +589,10 @@ Diese drei Features hängen zusammen — erst mit Versionierung ist Undo sinnvol **Background Tasks (tokio::spawn in main.rs):** -- **Transcribe-Worker**: sequentielle mpsc-Abarbeitung (ffmpeg remux → Whisper → Gazetteer → Transkript-Write → Oneliner am Batch-Ende). -- **Analyze-Worker**: sequentielle mpsc-Abarbeitung (LLM → Gazetteer → atomic rename auf `document.md`). +- **Transcribe-Worker**: sequentielle mpsc-Abarbeitung (ffmpeg remux → Whisper → Gazetteer → Transkript-Write → Oneliner am Batch-Ende). Emittiert `RecordingUploaded`, `TranscriptReady`/`TranscriptFailed`, `OnelinerUpdated` auf den Event-Bus. +- **Analyze-Worker**: sequentielle mpsc-Abarbeitung (LLM → Gazetteer → atomic rename auf `document.md`). Emittiert `AnalysisQueued`, `DocumentReady`; schreibt bei Fehler `.analysis_failed.json` (siehe Auto-Trigger). +- **Event-Bus** (`events::channel`, `tokio::sync::broadcast`, Kapazität 256): multi-producer/multi-consumer. Langsame Subscriber erhalten `Lagged(n)` und resynchronisieren sich beim nächsten Reload; FS bleibt SoT, Events sind reine Trigger. +- **SSE-Route** `GET /web/events`: eine Verbindung pro Browser-Tab, Non-Admins slug-gefiltert, Admins ungefiltert, 15 s Keep-Alive-Kommentar gegen Reverse-Proxy-Timeouts. Client-Side debounced `location.reload()` in den Templates. - **Recovery-Scans bei Boot** (siehe Worker-Zyklus): fehlende Transkripte, fehlende Oneliner, fehlende Dokumente. - **Rolling-File-Logger** (`tracing_appender::rolling::daily`) für `recorder.{datum}.log`. @@ -581,7 +618,7 @@ Diese drei Features hängen zusammen — erst mit Versionierung ist Undo sinnvol **Webinterface (askama Templates, Vanilla-Forms):** -Aktuell reines HTML + Formular-Submits, ohne Live-Updates. SSE ist als Phase-4-Feature geplant, aber noch nicht implementiert. +Serverseitig gerendertes HTML + Formular-Submits. Live-Updates via SSE sind implementiert: der Client hält eine `EventSource`-Verbindung zu `GET /web/events`, filtert eingehende `CaseEvent`s nach der aktuellen Sicht (Fall-Übersicht, Fall-Detail, Recordings) und löst einen debounced `location.reload()` aus — der Server rendert mit aktuellem FS-Stand neu, ohne dass das HTML Teilzustände diffen müsste. **Ist-Stand UI (vereinfacht):** @@ -628,8 +665,7 @@ Fall 09:32 — 3 Aufnahmen „Löschen" legt einen `.deleted`-Batch-Marker an. Der Fall verschwindet aus den Listen, kann aber über „Zuletzt gelöscht → Undo" en bloc wiederhergestellt werden. Physisches Entfernen (Cleanup) ist geplant, aber noch nicht implementiert — der Ordner bleibt bis auf Weiteres bestehen. **Phase-4-Entwurf (noch nicht gebaut):** -- SSE-Live-Updates (`GET /web/events`) für Status-Wechsel und Oneliner-Fertig ohne Reload. -- Nachtrag-Hinweis: „⚠ 1 neue Aufnahme seit Abschluss — neu analysieren?" +- Nachtrag-Hinweis-Banner („⚠ 1 neue Aufnahme seit Abschluss — neu analysieren?"): funktional durch den Auto-Trigger abgedeckt (Doku oben), als sichtbarer Banner aber noch offen. - Preset-System + Undo für Dokument-Versionen (Arztbrief / Kürzer / Formeller / Diagnosen / Medikamente). - CSRF-Tokens in allen POST-Formularen. - Service-Ausfall-Warnung im UI (> 30 Min. Ausfall). @@ -644,9 +680,11 @@ Fall 09:32 — 3 Aufnahmen └── {case_id}/ ├── {UTC-timestamp}.m4a ← Aufnahme (unveränderlich) ├── {UTC-timestamp}.m4a.failed ← optional: dauerhaft gescheiterte Aufnahme + ├── {UTC-timestamp}.duration.txt ← ffprobe-Dauer in Sekunden (UI-Rendering; lazy-backfill) ├── {UTC-timestamp}.transcript.txt ← nach erfolgreicher Transkription (Gazetteer-normalisiert) ├── oneliner.txt ← aus allen Transkripten regeneriert ├── analysis_input.json ← nur während Analyse-Lauf (wird nach Erfolg gelöscht) + ├── .analysis_failed.json ← Auto-Trigger-Retry-Gate (JSON: last_recording_mtime, reason, failed_at) ├── document.md ← nach LLM-Analyse (Gazetteer-normalisiert) └── .deleted ← Soft-Delete-Marker (JSON mit Batch-UUID + deleted_at) @@ -671,7 +709,9 @@ Fall 09:32 — 3 Aufnahmen |---|---| | `{ts}.m4a` ohne `{ts}.transcript.txt` | Transkriptions-Auftrag offen | | `{ts}.m4a.failed` | Dauerhaft gescheitert, Recovery-Scan ignoriert, UI zeigt „failed" | +| `{ts}.duration.txt` | ffprobe-Dauer in ganzen Sekunden (Sidecar für HTML5-Audio-Player) | | `analysis_input.json` vorhanden | Analyse-Job in Queue / in-flight | +| `.analysis_failed.json` vorhanden | Auto-Trigger-Retry-Gate; Auto-Analyse übersprungen, solange `last_recording_mtime` unverändert | | `document.md` vorhanden | Fall gilt als „ausgewertet" | | `.deleted` vorhanden | Fall ist soft-deleted (per Undo-Batch wiederherstellbar) | @@ -854,7 +894,7 @@ Wichtig: LLM-Antworten werden **nicht** geloggt (potenziell patientenbezogene Da | Android-Clients (Watch primär, Handy geplant) | Kotlin, Multi-Modul-Gradle. UI: Compose for Wear OS (`:app-wear`) bzw. Jetpack Compose Material 3 (`:app-mobile`). Geteilte Core-Module (`:core-*`) sind UI-unabhängig und JVM-testbar. | | Desktop-Client (Linux gebaut, Windows geplant) | Rust, Cargo-Workspace-Member `client-desktop`. `eframe`/`egui` (Immediate-Mode-UI), `tokio` (async I/O), `reqwest` (Multipart-Upload + Polling), `ffmpeg`-Subprozess (m4a-Recording, SIGINT-Stop). Teilt `doctate-common` (API-Typen) und `doctate-client-core` (Case-Store, Sync, Poller, Cleanup) mit Server und künftigen nativen Clients. | | iOS-Gerät (optional, später) | SwiftUI oder Compose Multiplatform. Entscheidung nach Watch/Handy-Erfahrung. | -| Server | Rust (edition 2024), Axum 0.8, askama, reqwest, tracing, `strsim` + `spellbook` (Gazetteer); SSE für Live-Updates ist Phase-4-TODO | +| Server | Rust (edition 2024), Axum 0.8, askama, reqwest, tracing, `strsim` + `spellbook` (Gazetteer); SSE-Live-Updates via `tokio::sync::broadcast` + `axum::response::sse` produktiv | | STT | faster-whisper (CTranslate2, large-v3, eigener Docker Container mit HTTP-API) | | Preprocessing | Ollama, Gemma 3 4B (keep_alive: 0 für VRAM-Freigabe nach Request) | | Dokument-LLM | Ionos (OpenAI-API-kompatibel, Temperatur 0) | @@ -877,6 +917,8 @@ tower-http = { version = "0.6", features = ["limit", "trace"] } # Async runtime + HTTP client tokio = { version = "1", features = ["full"] } +tokio-stream = { version = "0.1", features = ["sync"] } # BroadcastStream → SSE-Endpoint +futures-util = "0.3" # Stream-Kombinatoren für SSE reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "rustls-tls"] } # Templates + Rendering @@ -1010,7 +1052,9 @@ wiremock = "0.6" - [x] Docker Container für Axum auf Ubuntu Server (kein GPU nötig) - [x] faster-whisper Container mit HTTP-API (NVIDIA Container Toolkit) — jetzt eigener `whisper/`-Service, siehe Phase 2b.5 - [x] Ollama einrichten, Modell gepullt (`gemma4:latest` statt `gemma3:4b`, siehe Abweichungen) -- [ ] nginx auf Unraid + Let's Encrypt + Security Headers (CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy) +- [ ] nginx auf Unraid + Let's Encrypt + Security Headers (CSP, X-Frame-Options, X-Content-Type-Options: nosniff, Referrer-Policy, Strict-Transport-Security/HSTS) +- [ ] HTTP-Compression (gzip/brotli): Policy-Entscheidung nginx vs. `tower-http::CompressionLayer` (Axum) — bei direkter Axum-Exposition im Dev-Setup muss die Compression dort laufen, hinter nginx ist `gzip on` meist einfacher. Vorsicht bei SSE: `text/event-stream` darf **nicht** komprimiert werden (buffering bricht Live-Updates) +- [ ] Axum-seitige Security-Header als Defense-in-Depth (tower-http `SetResponseHeaderLayer`): `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Content-Security-Policy`, `Referrer-Policy: no-referrer`, `Strict-Transport-Security` (nur wenn HTTPS garantiert). Soll greifen, auch wenn nginx wegfällt oder im Dev-Mode direkt auf Axum zugegriffen wird. Magic-Link-Route setzt `Referrer-Policy: no-referrer` bereits — Layer zentralisiert das ### Phase 2 — Transkriptions-Pipeline - [x] Transkriptions-Queue (tokio mpsc channel) @@ -1045,6 +1089,7 @@ wiremock = "0.6" - [x] Bulk-Aktionen (analyze/delete auf mehrere Fälle gleichzeitig) — `POST /web/cases/bulk` - [x] Reset-Endpoint — löscht Transkripte/Analyse/Document, re-enqueued alle `.m4a` (inkl. `.m4a.failed` → zurück auf `.m4a`) - [x] Upload verspätet für gelöschten Fall → `.deleted`-Marker entfernen, Nachtrag normal behandeln +- [x] Auto-Trigger LLM-Analyse (`analyze::auto_trigger`) — Handler-getriggert auf `/web/cases[...]`, Retry-Gate via `.analysis_failed.json` mit `last_recording_mtime`-Signatur, Re-Analyse bei veraltetem `document.md` - [ ] Concurrency-Schutz: optional Pending-Zähler / Lock — aktuell genügt sequenzieller Worker + WorkerBusy-Flag - [ ] Upload-Deduplizierung (gleiche case_id + Timestamp → ignorieren) - [ ] Nachtrag-Erkennung im UI (Transkript neuer als `document.md` → Hinweis im Fall-Detail) @@ -1062,14 +1107,14 @@ wiremock = "0.6" - [x] Übersicht für Arzt: zwei Sektionen (Offen / Abgeschlossen), plus "Zuletzt gelöscht" mit Undo-Batch - [x] Fall-Übersicht: `GET /web/cases/{case_id}` rendert `case_page.html` mit Oneliner + Aktionen + inline-gerendertem Dokument (via `pulldown-cmark`); IDOR-geschützt via Session-Slug. - [x] Einzel-Transkripte: `GET /web/cases/{case_id}/recordings` rendert `case_recordings.html` (ein Eintrag pro Aufnahme, Audio-Link pro Transkript). -- [x] Audio-Streaming: `GET /web/audio/{user}/{case_id}/{filename}` (Cookie-Auth, Arzt eigene Dateien oder Admin) +- [x] Audio-Streaming: `GET /web/audio/{user}/{case_id}/{filename}` (Cookie-Auth, Arzt eigene Dateien oder Admin) — HTTP-Range-Requests, `Accept-Ranges: bytes`, 206 Partial Content, Duration-Sidecar `{ts}.duration.txt` für HTML5-Player mit Seeking - [x] Fall analysieren — Button in der Fall-Übersicht - [x] Bulk-Aktionen (alle markierten analysieren / löschen) über `POST /web/cases/bulk` — **admin-only** (`AuthenticatedUser::is_admin()` auf `role == "admin"`). - [x] Soft-Delete mit Undo letzter Batch (ohne Bestätigungsdialog) - [ ] `ValidCaseId`-Extractor: UUID-Validierung als Axum-Extractor (aktuell inline in Handlern) -- [ ] SSE-Endpunkt (`GET /web/events`), Session-Check bei Aufbau + periodisch bei Heartbeat -- [ ] Vanilla-JS EventSource-Client -- [ ] Nachtrag-Hinweis bei Aufnahmen nach Abschluss (⚠ "N neue Aufnahmen seit Abschluss") +- [x] SSE-Endpunkt (`GET /web/events`) — `events`-Modul + `routes::events`, 15 s Keep-Alive, Non-Admins slug-gefiltert, Admins ungefiltert, `Lagged`-Recovery durch Reload +- [x] Vanilla-JS EventSource-Client — debounced `location.reload()` in `my_cases.html`, `case_page.html`, `case_recordings.html` +- [~] Nachtrag-Hinweis bei Aufnahmen nach Abschluss — funktional durch Auto-Trigger abgedeckt (re-analysiert automatisch), sichtbarer „⚠ N neue Aufnahmen"-Banner noch offen - [ ] Preset-basiertes „Neu generieren" (Preset + Freitext, Freitext max. 500 Zeichen) — hängt an Dokument-Versionierung - [ ] Undo-Button für Dokument-Versionen — hängt an Versionierung - [ ] Service-Ausfall-Warnung (faster-whisper/Ollama/LLM > 30 Min.) @@ -1152,7 +1197,7 @@ wiremock = "0.6" ### Phase 6 — Integration & Testing - [ ] End-to-End Test (Watch → Server → Webinterface) - [~] End-to-End Test (Linux-Desktop → Server → Webinterface inkl. Oneliner-Poll-Refresh und Magic-Link-Handoff in den Browser) — im Alltagsbetrieb validiert, aber kein dedizierter automatisierter E2E-Testlauf -- [ ] SSE Live-Updates testen +- [~] SSE Live-Updates testen — Unit-Tests im `events`-Modul (Capacity, Lagged, Subscriber-Fanout), `server/tests/sse_integration.rs` + `server/tests/sse_cleanup_test.rs` (Connection-Pool-Leak) - [ ] Pixel Watch Hardware-Test - [ ] LTE-Modus testen - [ ] Bluetooth-Headset testen @@ -1253,6 +1298,8 @@ Alle Einträge beziehen sich auf den Ist-Stand im Repository. Die ursprüngliche | LLM-Annotation `==text==` | Nicht vorgesehen | Analyse-Prompt fordert, unsichere/korrigierte Stellen mit `==text==` zu umschließen | Dem Arzt expliziten „bitte prüfen"-Hinweis geben. Der Gazetteer respektiert diese Markierungen und normalisiert Inhalt darin nicht. | | Oneliner-Semantik | „Einmalig beim ersten Transkript, danach unveränderlich" | Regeneriert am **Batch-Ende** aus **allen** Transkripten (`has_pending_recordings == false`). Spätere Aufnahmen dürfen korrigieren (symmetrisch zur Analyse-LLM-Regel). | Der Arzt hält den Oneliner für mutierbar: „Korrektur: das Mittel heißt Vomex" soll auch im Oneliner landen. Batch-Ende verhindert, dass wir mitten in einem Upload-Burst LLM-Calls verbrennen, die der nächste Job wieder überschreibt. | | Recovery-Scans | Nur „fehlende Transkripte bei Start" | Drei parallele Scans bei Boot: Transkripte, **Oneliner**, Dokumente | Ein früherer Lauf kann zwischen Transkript-Write und Oneliner-Write crashen → Startup-Scan holt das auf, bevor der Arzt die Watch öffnet. | +| Analyse-Trigger | Arzt klickt „Analysieren" | `analyze::auto_trigger` läuft auf jedem `/web/cases[...]`-Handler-Aufruf; SSE-Reloads sind der natürliche Puls. Manueller Button bleibt als „erzwingen". | Jeder Reload liefert aktuellen FS-Stand → warum den Arzt einen Knopf drücken lassen, wenn der Server anhand reiner FS-Stats entscheiden kann? Retry-Gate `.analysis_failed.json` (mit `last_recording_mtime`-Signatur) verhindert Retry-Loops bei fehlgeschlagenen LLM-Calls, bis neue Eingaben eintreffen. | +| Event-Bus + SSE | Nicht vorgesehen | `events::channel` (tokio `broadcast`, Cap 256), Worker und Route-Handler emittieren `CaseEvent`s; SSE-Route streamt an Browser-Tabs; Client macht debounced `location.reload()` | FS bleibt SoT — Events sind **Trigger**, nicht State. Browser-Diffing entfällt komplett: Re-Render vom aktuellen FS-Stand ist billiger und trivial korrekt. `Lagged(n)` für langsame Subscriber wird durch den Reload automatisch resynchronisiert. | ### UI / Entwickler-Tools @@ -1263,6 +1310,7 @@ Alle Einträge beziehen sich auf den Ist-Stand im Repository. Die ursprüngliche | Bulk-Aktionen | Nur „Alle abschließen" im UI angedacht | `POST /web/cases/bulk` mit mehreren markierten Fällen, Aktion `analyze` oder `delete` | Realer Workflow: Arzt räumt am Tagesende mehrere Fälle gleichzeitig ab. | | Reset-Endpoint | Nicht vorgesehen | `POST /web/cases/{id}/reset` löscht Transkripte/Oneliner/Analyse/Document und re-enqueued alle `.m4a` (inkl. `.m4a.failed` → zurück auf `.m4a`) | Debug-Tool während der Entwicklung; hilft bei Prompt-Iteration und Gazetteer-Tuning, ohne den Case neu aufzunehmen. | | Audio-Streaming-Route | Nicht vorgesehen | `GET /web/audio/{user}/{case_id}/{filename}` mit Cookie-Auth (Arzt eigene Audios, Admin alle) | Ermöglicht das direkte Anhören im Browser — unverzichtbar für Plausibilitätsprüfung bei Gazetteer/LLM-Fehlern. | +| Audio-Seeking | Nicht vorgesehen | HTTP-Range-Requests in `handle_audio` (`parse_range` + `serve_range`), `Accept-Ranges: bytes`, 206 Partial Content; Duration-Sidecar `{ts}.duration.txt` (ffprobe auf der remuxten Kopie, ~ms) für Player-Rendering ohne HEAD-Roundtrips | HTML5-`