diff --git a/docs/projektplan.md b/docs/projektplan.md index bcce5a5..d22271d 100644 --- a/docs/projektplan.md +++ b/docs/projektplan.md @@ -73,7 +73,7 @@ Clients sind flüchtige Zugriffs- und Erfassungsstellen. Der Server ist die einz > **Alle Clients sprechen ausschließlich die einheitliche Server-HTTP-Schnittstelle.** Es gibt keine gerätespezifische API und keinen geräteeigenen Backchannel. Die Rolle eines Clients ergibt sich allein daraus, *welchen Teil* der API er nutzt. > -> **Brisante medizinische Daten bleiben auf dem Server.** Brisant sind Audioaufnahmen (`.m4a`), Transkripte (`.transcript.txt`) und die daraus generierten Dokumente (`document.md`) — also alles, was Anamnese, Diagnose, Medikation oder Patientenstimme direkt enthält. Clients dürfen sie nur so lange lokal halten, wie der laufende Upload- oder Render-Vorgang es erzwingt: die Pending-Queue beim Recorder bis zum ACK, temporäre Render-Puffer beim Audio-Playback. Danach werden sie auf dem Client gelöscht — der Server ist die einzige dauerhafte Wahrheit. +> **Brisante medizinische Daten bleiben auf dem Server.** Brisant sind Audioaufnahmen (`.m4a`), Transkripte (`.json`) und die daraus generierten Dokumente (`document.md`) — also alles, was Anamnese, Diagnose, Medikation oder Patientenstimme direkt enthält. Clients dürfen sie nur so lange lokal halten, wie der laufende Upload- oder Render-Vorgang es erzwingt: die Pending-Queue beim Recorder bis zum ACK, temporäre Render-Puffer beim Audio-Playback. Danach werden sie auf dem Client gelöscht — der Server ist die einzige dauerhafte Wahrheit. > > **Informelle Navigationsdaten dürfen lokal gecacht werden.** Informell sind Fall-IDs, Zeitstempel, Fall-Listen und der Oneliner — kurze Orientierungsdaten, die dem Arzt zeigen, *welche* Fälle existieren, ohne deren medizinischen Inhalt preiszugeben. Der Server bleibt auch hier die autoritative Quelle; der Client-Cache ist verwerfbar und wird beim nächsten erfolgreichen Poll überschrieben. Beispiel: `doctate-client-core::snapshot_cache` persistiert die Oneliner-/Fall-Liste, damit der Desktop-Client beim Launch keinen Flash-Fehlzustand zeigt. @@ -478,7 +478,7 @@ Trifft ein Upload für einen Fall mit `.deleted`-Marker ein, wird der Marker ent ``` Whisper-Output ──┐ -Oneliner (Ollama) ├──→ gazetteer::replace() ──→ Persistenz (.transcript.txt, oneliner.json, document.md) +Oneliner (Ollama) ├──→ gazetteer::replace() ──→ Persistenz (`.json`, oneliner.json, document.md) Analyse-LLM ──────┘ ``` @@ -508,7 +508,7 @@ Der Gazetteer ist ein deterministischer Filter, der jede KI-Ausgabe passiert, be | Worker | Eingang | Externer Call | Ausgang | |---|---|---|---| -| `transcribe::worker` | `TranscribeSender` (unbounded) | ffmpeg remux → `WHISPER_URL/asr` | `{ts}.transcript.txt`, anschließend Oneliner via Ollama | +| `transcribe::worker` | `TranscribeSender` (unbounded) | ffmpeg remux → `WHISPER_URL/asr` | `{ts}.json` (single atomic write: transcript + duration), anschließend Oneliner via Ollama | | `analyze::worker` | `AnalyzeSender` (unbounded) | `LLM_URL/v1/chat/completions` | `document.md` | Jeder Worker arbeitet sequentiell (ein Job nach dem anderen). Parallelität innerhalb eines Workers ist bewusst ausgeschlossen — die GPU auf der Whisper-Seite kann nur eine Aufgabe gleichzeitig sinnvoll bedienen, und der Analyse-LLM profitiert nicht von Burst-Lasten. Beide Worker laufen aber **zueinander parallel**: während Whisper noch transkribiert, kann der Analyse-Worker bereits einen anderen Fall abschließen. @@ -518,7 +518,7 @@ Jeder Worker arbeitet sequentiell (ein Job nach dem anderen). Parallelität inne **Live-Flag pro Worker:** `WorkerBusy = Arc` + `BusyGuard` (RAII). Der Worker setzt `true` bei Job-Start, `false` bei Job-Ende (Drop-safe). Das UI nutzt das Flag, um zwischen echter In-flight-Aufgabe und orphaned On-disk-Markern (Crash-Residuen) zu unterscheiden. Es ersetzt den ursprünglich geplanten `RwLock>` — aufgrund der sequentiellen Worker-Semantik genügt ein einfaches Flag. **Concurrency-Schutz aktuell:** -- Upload-Handler und Worker teilen sich keinen In-Memory-State; Synchronisation läuft ausschließlich über das Dateisystem (z.B. `has_pending_recordings(case_dir)` scannt nach `.m4a` ohne passendes `.transcript.txt`). +- Upload-Handler und Worker teilen sich keinen In-Memory-State; Synchronisation läuft ausschließlich über das Dateisystem (z.B. `has_pending_recordings(case_dir)` scannt nach `.m4a` ohne passendes `.json`). - Race zwischen Upload-Write und Worker-Scan: in der Praxis unkritisch, weil nachfolgende Uploads erneut in die Queue wandern und ein weiterer Recovery-Scan offene Stellen findet. Im Plan als Phase-3-TODO markiert, bei Bedarf auf explizites Locking nachrüstbar. **Latenz-Profil (3 Ärzte, ~10 Min. pro Patient, Turbo-Modell):** @@ -553,7 +553,7 @@ Silent-Case-Handling: bei `Error` bleibt ein evtl. bereits existierender `Ready` Der Arzt kann den Oneliner weiterhin durch eine explizite Bezeichnung im Diktat beeinflussen („Bezeichnung: Kniegelenk"). Die Erkennung läuft vollständig über den Ollama-Prompt, keine deterministische Keyword-Suche. **Queue-Recovery bei Serverstart:** -- `transcribe::recovery::scan_and_enqueue`: findet alle `.m4a` ohne passendes `.transcript.txt` (und ohne `.m4a.failed`) und schiebt sie in die Transcribe-Queue. +- `transcribe::recovery::scan_and_enqueue`: findet alle `.m4a` ohne passendes `.json` (und ohne `.m4a.failed`) und schiebt sie in die Transcribe-Queue. - `transcribe::recovery::regenerate_missing_oneliners`: für Fälle mit Transkripten aber ohne `oneliner.json` (oder mit `OnelinerState::Error`) wird der Oneliner einmalig erzeugt. `Empty` gilt als Endzustand und wird **nicht** retryed. - `analyze::recovery::scan_and_enqueue`: findet `analysis_input.json` ohne `document.md` und reiht sie in die Analyze-Queue ein. - Kein Datenverlust bei Server-Neustart; alle drei Scans laufen bei Boot parallel. @@ -744,8 +744,7 @@ 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) + ├── {UTC-timestamp}.json ← `RecordingMeta` (Transcript + duration_seconds), single atomic write am Ende des Worker-Pipelines ├── oneliner.json ← `OnelinerState` (Ready/Empty/Error), 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) @@ -771,9 +770,9 @@ Fall 09:32 — 3 Aufnahmen | Datei | Bedeutung | |---|---| -| `{ts}.m4a` ohne `{ts}.transcript.txt` | Transkriptions-Auftrag offen | +| `{ts}.m4a` ohne `{ts}.json` | 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) | +| `{ts}.json` | `RecordingMeta` mit `transcript` (`Silent` oder `Content`) und `duration_seconds`. Single atomic write — Existenz = transcribiert | | `oneliner.json` | `OnelinerState` (Ready/Empty/Error). Fehlt oder `Error` → Recovery-Retry; `Empty`/`Ready` sind Endzustände | | `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 | @@ -1157,13 +1156,13 @@ wiremock = "0.6" ### Phase 3 — Fallverwaltung - [x] Drei States: Empfangen → Transkribiert → Ausgewertet — rein aus FS abgeleitet (`compute_flags` in `user_web.rs`, kein State-Sidecar) -- [x] State-Übergang: erst "Transkribiert" wenn alle `.m4a` ein passendes `.transcript.txt` haben +- [x] State-Übergang: erst "Transkribiert" wenn alle `.m4a` ein passendes `.json` haben - [x] "Analysieren" nur möglich, wenn LLM konfiguriert und Fall transkribiert — `handle_analyze_case` in `routes/case_actions.rs` - [x] Aufnahmen nach case_id zusammenführen — `analysis_input.json` (single, nicht versioniert) enthält alle Transkripte - [x] Chronologische Sortierung nach UTC-Zeitstempel — lexikographische Filename-Sortierung - [x] Externes LLM → Dokument generieren — `analyze/`-Modul, OpenAI-kompatibel (`llm.rs`) - [x] Fall soft-löschen — `.deleted`-Marker (JSON mit Batch-UUID), Undo letzter Batch via `POST /web/cases/undo-delete` -- [x] Einzel-Aufnahme hart löschen — `POST /web/cases/{case_id}/recordings/delete` entfernt `.m4a` + `.transcript.txt` + `.duration.txt`, invalidiert `oneliner.json`/`document.md`/`analysis_input.json`; Auto-Trigger regeneriert beim nächsten View-Load (siehe Phase 4) +- [x] Einzel-Aufnahme hart löschen — `POST /web/cases/{case_id}/recordings/delete` entfernt `.m4a` + `.json`, invalidiert `oneliner.json`/`document.md`/`analysis_input.json`; Auto-Trigger regeneriert beim nächsten View-Load (siehe Phase 4) - [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 @@ -1186,7 +1185,7 @@ 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) — HTTP-Range-Requests, `Accept-Ranges: bytes`, 206 Partial Content, Duration-Sidecar `{ts}.duration.txt` für HTML5-Player mit Seeking +- [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 aus `{ts}.json` (`duration_seconds`-Feld) für HTML5-Player mit Seeking - [ ] Replay-Gain-Normalisierung für Wiedergabe (nicht destruktiv) — verschiedene Erfassungsgeräte liefern stark unterschiedliche Pegel (Watch `MIC` ~-38 dB mean, Desktop ~-27 dB mean). PoC am 2026-04-23 erfolgreich, aber nicht committed; Re-Implementierung steht aus. Erprobte Architektur: pro `.m4a` ein `.loudness.json`-Sidecar mit statischem `gain_db` (aus `ffmpeg -af volumedetect`, Ziel −16 dB mean, Peak-Cap −1 dB). Lazy-Backfill im bestehenden `scan_recordings`-JoinSet analog zum Duration-Sidecar. Browser appliziert den Gain über Web Audio API `GainNode` (kein Disk-Rewrite, Original + Whisper unberührt). Kombiniert mit `AudioSource.VOICE_RECOGNITION` auf der Watch (besseres SNR, siehe 5b). Verworfene Alternativen: `ffmpeg loudnorm` (pumpt + Artefakte), fixes `volume=+XdB` (client-abhängig). Kern-Einsicht: SNR > Loudness an der Source, solange Post-Gain verfügbar ist. - [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"`, Check am Entry-Handler). @@ -1397,7 +1396,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-`