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.
This commit is contained in:
2026-04-20 10:08:50 +02:00
parent c8ad1f0585
commit 2941bb370c
+67 -19
View File
@@ -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-`<audio>`-Player brauchen Range für Seek ohne Re-Download. Sidecar spart den Extra-HEAD pro Transkript-Zeile; Worker schreibt ihn best-effort, lazy-backfill vorgesehen. |
| Admin-Log vs. Arzt-UI | Nur Arzt-UI geplant | Zusätzlich frühes Admin-Log unter `GET /web/` (flache Liste aller Fälle) | Gebaut, bevor Session/States/Fall-Detail existierten, um die Pipeline während Entwicklung inspizieren zu können. Soll später hinter `role = "admin"` geschützt werden. |
| Test-Client für Watch-Flow | Erst ab Phase 5 mit Hardware | `scripts/dictate.sh` ab Phase 2/3 als Stand-in (ffmpeg + curl + c/n/r/q-Loop) | End-to-End-Tests ohne Pixel-Watch-Hardware. |
| Hotwords (Whisper) | Nicht vorgesehen | Per-User-Feld `[user.whisper].hotwords` **im Code**, aber nicht als Feature angeboten | Regress-Lauf über 10 Fixtures zeigt **keinen** Vorteil (ohne 12/257 Wortfehler, mit 14/257). Hotwords schluckten Funktionswörter. Leitung bleibt durchverdrahtet, bewerben wir aber nicht — re-evaluieren bei konkretem Bedarf. |