diff --git a/docs/projektplan.md b/docs/projektplan.md index 73ed25d..d016498 100644 --- a/docs/projektplan.md +++ b/docs/projektplan.md @@ -21,14 +21,14 @@ Unraid Server └── nginx (reverse proxy, TLS) ↓ lokales Netz Ubuntu Server (RTX 3060, 12 GB VRAM) - ├── Docker: Axum (Webserver, API, SSE, Worker) - │ Empfang → Transkriptions-Queue - │ Worker steuert GPU-Phasen (Whisper → Ollama → Whisper → ...) + ├── Docker: Axum (Webserver, API, Worker) + │ Empfang → Transkriptions-Queue (mpsc, sequentiell) + │ + separater Analyse-Worker │ ↓ HTTP (localhost) ├── Docker: faster-whisper (STT, CTranslate2, large-v3) ├── Ollama (Gemma 3 4B, Oneliner) │ ↓ HTTPS - └── Ionos LLM (Dokument-Generierung bei Fallabschluss) + └── externer LLM-Provider (Ionos als Default, OpenAI-API-kompatibel, austauschbar) Webinterface (Browser) → nginx → Axum (SSE für Live-Updates) ``` @@ -39,7 +39,7 @@ Webinterface (Browser) → nginx → Axum (SSE für Live-Updates) ### Filesystem ist Source of Truth (SoT) -Der Zustand eines Falls wird **ausschließlich aus dem Dateisystem** abgeleitet, nicht aus einer parallelen Sidecar- oder Metadaten-Datei. Ordnerlage (`open/` vs. `done/`) plus Dateiexistenz (`.m4a`, `.transcript.txt`, `oneliner.txt`, `document_vN.md`, `.remove`) bestimmen jederzeit eindeutig, was als Nächstes zu tun ist. +Der Zustand eines Falls wird **ausschließlich aus dem Dateisystem** abgeleitet, nicht aus einer parallelen Sidecar- oder Metadaten-Datei. Das Vorhandensein bestimmter Dateimarker (`.m4a`, `.m4a.failed`, `.transcript.txt`, `oneliner.txt`, `analysis_input.json`, `document.md`, `.deleted`) plus die `WorkerBusy`-Flags bestimmen jederzeit eindeutig, was als Nächstes zu tun ist. **Konsequenzen**: - Keine State-Duplizierung (kein `state.json`, keine DB). Der zu synchronisierende Zweitstand fehlt ersatzlos — also kann er auch nicht drift. @@ -234,28 +234,38 @@ GET /api/oneliner/{case_id} ### 2. Server (Axum / Rust — Docker Container auf Ubuntu Server) -Axum ist der zentrale Koordinator. Er empfängt Uploads, steuert den GPU-Phasen-Worker und bedient das Webinterface. STT und Preprocessing laufen als externe Services (faster-whisper, Ollama) — Axum selbst braucht keinen GPU-Zugriff. +Axum ist der zentrale Koordinator. Er empfängt Uploads, startet die sequentiellen Worker (Transcribe + Analyze) und bedient das Webinterface. STT und Preprocessing laufen als externe Services (faster-whisper, Ollama) — Axum selbst braucht keinen GPU-Zugriff. -**Endpunkte:** +**Endpunkte (Ist-Stand):** ``` # Watch API -POST /api/upload → Aufnahme empfangen, ACK zurück -GET /api/oneliner/{case_id} → Oneliner abrufen (informativ) +POST /api/upload → Aufnahme empfangen, ACK zurück +GET /api/health → Liveness-Probe für Deployments +GET /api/debug/whoami → API-Key → slug (Entwicklungshilfe) -# Webinterface (Arzt-Identität kommt aus der Session, nie aus der URL) -GET /web/login → Login -POST /web/login → Login-Formular absenden -GET /web/ → Übersicht (drei States) -GET /web/case/{case_id} → Fall-Detail (Transkripte + Dokument) -GET /web/events → SSE Live-Updates -POST /web/case/{case_id}/close → Fall abschließen → LLM-Generierung -POST /web/close-all → Alle Fälle abschließen -POST /web/case/{case_id}/regenerate → Dokument neu generieren -POST /web/case/{case_id}/undo → Vorherige Version wiederherstellen -POST /web/case/{case_id}/mark-remove → Fall zum Entfernen markieren (nach Übernahme) +# Web: Login / Session +GET /web/login → Login-Seite +POST /web/login → Login-Formular absenden +POST /web/logout → Session zerstören + +# Web: Arzt-UI (Session-gebunden, kein {slug} in URL, IDOR-geschützt) +GET /web/cases → eigene Fallübersicht +GET /web/cases/{case_id} → Fall-Detail (Transkripte + Status) +GET /web/cases/{case_id}/document → Gerendertes Dokument (Markdown → HTML) +POST /web/cases/{case_id}/analyze → Analyse starten / neu anstoßen +POST /web/cases/{case_id}/reset → Analyse/Transkripte verwerfen, alles neu transkribieren +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 auf mehrere Fälle) +GET /web/audio/{user}/{case_id}/{filename} → Audio-Streaming (Cookie-Auth; Arzt oder Admin) + +# Web: Admin-Log (flache Liste aller User/Fälle für Entwicklung) +GET /web/ → Admin-Übersicht (später auf role="admin" eingrenzen) ``` +**Geplant, noch nicht implementiert:** `GET /api/oneliner/{case_id}` (Watch-Polling), `GET /web/events` (SSE), Preset- und Undo-Endpoints für Dokument-Versionen. Siehe Phase 4 und Watch-API-Pläne 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. @@ -264,7 +274,7 @@ Die Arzt-Identität wird ausschließlich aus dem Session-Cookie abgeleitet, nie ``` ┌────────────┐ sofort ┌───────────────┐ Arzt klickt ┌─────────────┐ │ Empfangen │ ──────────────→ │ Transkribiert │ ──────────────→ │ Ausgewertet │ -│ (queued) │ Transkription │ (einsehbar) │ "Abschließen" │ (Dokument) │ +│ (queued) │ Transkription │ (einsehbar) │ "Analysieren" │ (Dokument) │ └────────────┘ └───────────────┘ └─────────────┘ ``` @@ -274,200 +284,233 @@ 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". -"Abschließen" ist nur im State "Transkribiert" möglich. Der Button ist ausgegraut / nicht sichtbar, solange Aufnahmen noch ausstehen. +"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. --- -**Pipeline nach Aufnahme-Eingang:** +**Pipeline nach Aufnahme-Eingang (Ist-Stand):** ``` -1. API-Key → Arzt auflösen -2. case_id prüfen: - a) Fall in open/ → normal weiter (Schritt 3) - b) Fall in done/ → Aufnahme trotzdem speichern (Nachtrag, siehe unten) - c) Fall in done/ mit .remove-Marker → .remove entfernen, Aufnahme speichern (Nachtrag) - d) Fall unbekannt → ACK mit status "gone", fertig -3. Audio speichern → /data/{arzt}/open/{case_id}/ (bzw. done/) -4. ACK mit status "received" an Watch senden -5. Aufnahme in Transkriptions-Queue einreihen (tokio mpsc channel) -6. Worker verarbeitet in GPU-Phasen (siehe "Worker-Zyklus") +1. API-Key → User-Slug auflösen (HashMap aus users.toml, kein {slug} in URL) +2. case_id validieren (UUIDv4) +3. Case-Verzeichnis bestimmen: /data/{slug}/{case_id}/ + ├─ Verzeichnis fehlt → anlegen (erster Upload einer neuen case_id) + ├─ .deleted-Marker vorhanden → Marker entfernen (verspäteter Upload für gelöschten Fall) + └─ sonst → Nachtrag zu bestehendem Fall +4. Audio speichern: {case_dir}/{UTC-ISO-Zeitstempel}.m4a +5. Transcribe-Job in mpsc-Queue einreihen +6. ACK mit status "received" an Watch senden ``` +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 bereits abgeschlossenen Fall eintrifft (z.B. Watch war offline), wird sie trotzdem angenommen, gespeichert und transkribiert. Der Fall bleibt im State "Ausgewertet", aber das Webinterface erkennt anhand der Timestamps, dass Transkripte existieren, die neuer sind als das generierte Dokument. Es zeigt einen Hinweis: *"1 neue Aufnahme seit Abschluss — Dokument neu generieren?"* Der Arzt entscheidet selbst. Das bestehende Dokument bleibt intakt. +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. -**Verspätete Uploads für zum Entfernen markierte Fälle:** -Wenn ein Fall zum Entfernen markiert ist (`.remove`-Marker) und eine verspätete Aufnahme eintrifft, wird der `.remove`-Marker entfernt und die Aufnahme normal als Nachtrag behandelt. Der Arzt wird im Webinterface informiert und muss den Fall bei Bedarf erneut zum Entfernen markieren. So gehen keine Daten verloren, die noch unterwegs waren. +**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. -**Worker-Zyklus (GPU-Phasen, Greedy):** - -Der Worker verarbeitet Aufgaben nicht pro Job, sondern in GPU-Phasen. Die GPU gehört immer genau einem Dienst — kein gleichzeitiges Laden von Whisper und Ollama im VRAM. +**Gazetteer (Post-AI-Terminologie-Normalisierung):** ``` -Loop: - Phase 1 — Whisper (faster-whisper hat GPU exklusiv): - Queue drainen: ALLE untranskribierten Aufnahmen nacheinander - transkribieren (reqwest → faster-whisper HTTP-API). - Kommt während Phase 1 eine neue Aufnahme in die Queue, - wird sie noch mitgenommen (greedy). - Erst wenn die Queue leer ist → weiter zu Phase 2. - - Phase 2 — Ollama (Gemma 3 4B hat GPU exklusiv): - Alle Fälle prüfen, die ein erstes Transkript haben - aber noch keinen Oneliner → Oneliner generieren - (reqwest → Ollama HTTP-API, keep_alive: 0). - keep_alive: 0 entlädt das Modell sofort nach dem - letzten Request → VRAM ist frei für nächste Whisper-Phase. - - Phase 3 — Aufräumen: - Pending-Zähler prüfen, State-Übergänge durchführen, - SSE-Events an Webinterface senden. - Queue leer? → Sleep / auf mpsc-Signal warten → zurück zu Phase 1. +Whisper-Output ──┐ +Oneliner (Ollama) ├──→ gazetteer::replace() ──→ Persistenz (.transcript.txt, oneliner.txt, document.md) +Analyse-LLM ──────┘ ``` -**VRAM-Management:** -faster-whisper (CTranslate2, large-v3 int8) belegt ~2 GB VRAM, Gemma 3 4B ~3 GB. Beide passen theoretisch gleichzeitig in 12 GB, werden aber bewusst abwechselnd genutzt — so wie die bestehende Infrastruktur (faster-whisper + Ollama) bereits erfolgreich betrieben wird. Ollama entlädt das Modell automatisch nach jedem Request (`keep_alive: 0`), faster-whisper hält sein Modell dauerhaft geladen (es ist das "heiße" Modell mit ~3 Min. Taktung). +Der Gazetteer ist ein deterministischer Filter, der jede KI-Ausgabe passiert, bevor sie aufs Dateisystem geschrieben wird. Er korrigiert zwei typische Fehlerklassen: -**Latenz-Profil (3 Ärzte, ~10 Min. pro Patient):** -Alle ~3 Minuten trifft eine Aufnahme ein. faster-whisper verarbeitet 60 Sek. Audio in ~6–9 Sek. (CTranslate2, ~0,1× Echtzeit). Ein typischer Zyklus: +1. **Whisper-Typos** an Fachtermini (z.B. `Zerebrum` → `Cerebrum`, `Pantoprasol` → `Pantoprazol`). +2. **LLM-Drift** zurück zu englischem/anglisiertem Wortlaut (`Amiodarone` → `Amiodaron`), den ein Pre-LLM-Hint-Kanal nicht einfangen könnte, weil das Drift im Output entsteht. + +**Matching-Logik:** +- Kuratiertes Vokabular in `server/vocab/*.txt`, eine Phrase pro Zeile, Leerzeilen und `#`-Kommentare erlaubt, non-recursive, case-insensitive. +- Min. Tokenlänge 5 Zeichen (`MIN_TOKEN_LEN`) — schützt gegen falsche Kollisionen bei kurzen Alltagswörtern (`Bein` ↔ `Behn`). +- Damerau-Levenshtein-Distanz ≤ 2 (`MAX_EDIT_DISTANCE`) gegen das Vocab. Exakte Treffer werden per `HashSet`-Shortcut gehandhabt. +- `==text==`-Markierungen aus dem LLM-Prompt werden **nicht angetastet**. + +**Dict-Veto gegen False Positives:** +`Kaktus` (DL=2 zu `Lantus`) darf nicht zu einem Medikamentennamen umgeschrieben werden. Deswegen prüft der Gazetteer vor jedem Commit, ob das Input-Token selbst ein gültiges deutsches Wort ist — über einen pluggable `DictChecker`-Trait. Produktiv liefert `SpellbookDict` (pure-Rust Hunspell-Parser) diese Prüfung, inklusive Flexions-Expansion: `Kakteen` wird aus `Kaktus` via `.aff`-Affix-Regeln anerkannt, ohne dass wir Flexionen explizit im Dict haben müssten. Der Dict-Check läuft lazy — nur wenn es überhaupt einen DL-Kandidaten gibt (99% der Tokens fallen nicht in dieses Fenster). + +**Konfiguration:** `VOCAB_DIR` (Vokabular-Verzeichnis) und `HUNSPELL_DICT` (Stamm-Pfad zu `.aff`/`.dic`). Beide sind optional — ohne Vokabular läuft die Pipeline ungefiltert, ohne Dict-Veto erhöht sich die False-Positive-Rate (aktuelle Fälle zeigen das deutlich). + +**Wichtige Eigenschaft:** Das Vokabular ist manuell kuratiert (ca. 200 Einträge, Medikamente + Substanzen + Anatomie). Keine Web-Crawls. Das hält die Qualität hoch, das Repository klein und das Urheberrecht sauber. + +--- + +**Worker-Zyklus (sequentielle Tokio-Worker):** + +**Ist-Stand:** Zwei unabhängige `tokio::spawn`-Worker, je mit eigener `mpsc`-Queue: + +| Worker | Eingang | Externer Call | Ausgang | +|---|---|---|---| +| `transcribe::worker` | `TranscribeSender` (unbounded) | ffmpeg remux → `WHISPER_URL/asr` | `{ts}.transcript.txt`, 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. + +**Kein striktes GPU-Phasen-Modell mehr.** Ein früher Entwurf wechselte explizit zwischen Whisper- und Ollama-Phase (mit `keep_alive=0`), um VRAM zu sparen. Der aktuelle Whisper-Wrapper (`large-v3-turbo`, ~1.6 GB) und Ollama (`gemma4:latest`, ~9 GB) passen gleichzeitig in die 12 GB der RTX 3060, das Phasen-Modell ist daher nicht mehr nötig. Ollama nutzt `OLLAMA_KEEP_ALIVE=300` statt 0 — spart das Modell-Reload pro Oneliner. Siehe Abweichungs-Sektion. + +**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`). +- 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):** +Eine Aufnahme trifft alle ~3 Min. ein. faster-whisper-turbo verarbeitet 60 s Audio in ~2–4 s. Typischer Ablauf bei einem Burst: ``` 00:00 5 Aufnahmen in Queue -00:00 Phase 1: 5× ~8 Sek. = ~40 Sek. Transkription -00:40 Phase 2: 3 Oneliner × ~0,4 Sek. = ~1 Sek. (+ ~2 Sek. Modell laden) -00:43 Phase 3: States aktualisieren, SSE-Events -00:43 Worker idle, wartet auf nächsten Job +00:00 Transcribe-Worker: sequentielle Abarbeitung +00:00 ├─ ffmpeg faststart remux (~0.5 s/Datei) +00:00 ├─ Whisper call (~3 s/Datei) +00:00 └─ Gazetteer replace (<1 ms) +00:18 Alle 5 Transkripte geschrieben. Weil keine .m4a mehr pending: +00:18 Oneliner wird aus allen 5 Transkripten EINMAL regeneriert (~1 s) +00:19 Worker idle, wartet auf nächsten Job ``` -Die Transkriptions-Queue ist FIFO, der Worker verarbeitet sequenziell (GPU kann nur eine Aufgabe gleichzeitig sinnvoll bedienen). Mehrere Ärzte teilen sich die Queue fair. +Mehrere Ärzte teilen sich die Queue fair (FIFO). -**Concurrency-Schutz (Fall-State):** -Die State-Prüfung ("sind alle Aufnahmen transkribiert?") und das Registrieren neuer Aufnahmen müssen synchronisiert werden. Ohne Locking entsteht eine Race-Condition: Der Worker beendet eine Transkription, scannt den Ordner und sieht alle `.txt`-Dateien → setzt State auf "Transkribiert". Gleichzeitig schreibt der Upload-Handler eine neue `.m4a` in denselben Ordner, die beim Scan noch nicht sichtbar war. Der Fall gilt als fertig, obwohl noch ein Job in der Queue hängt. +**Oneliner-Generierung (aktuelle Semantik):** +Der Oneliner wird am Ende jedes Batches aus **allen** nicht-leeren Transkripten des Falls neu generiert (`update_oneliner`). „Batch-Ende" = `has_pending_recordings(case_dir) == false`. Das hat zwei Gründe: -Lösung: Ein `tokio::sync::RwLock>` (oder granularer ein Lock pro Case) koordiniert Upload-Handler und Worker. Der Upload-Handler nimmt den Lock und registriert die neue Aufnahme (inkrementiert einen Pending-Zähler). Der Worker nimmt nach der Transkription den Lock, dekrementiert den Zähler, und setzt den State nur auf "Transkribiert", wenn der Zähler auf 0 steht. Damit ist die State-Berechnung nicht mehr vom Filesystem-Scan abhängig und die Race-Condition eliminiert. +1. **Spätere Aufnahmen dürfen korrigieren.** Sagt der Arzt „Korrektur: das Mittel heißt Vomex", soll der Oneliner diese Korrektur reflektieren. Wäre der Oneliner nach dem ersten Transkript eingefroren, bliebe der ursprüngliche Begriff (z.B. `Womax`) sichtbar. +2. **Keine überflüssigen LLM-Calls.** Während ein Batch noch läuft (mehrere Uploads hintereinander), wird der Oneliner nicht nach jedem einzelnen Transkript, sondern genau einmal am Ende regeneriert. -**Oneliner-Generierung:** -Der Oneliner wird *einmalig* beim ersten Transkript eines Falls generiert und danach nie überschrieben. Er dient als stabiler Identifier, den der Arzt auf Watch und Webinterface wiedererkennt — auch Stunden später. Folgediktate ändern den Oneliner nicht. +Silent-Case-Handling: LLM-Fehler oder leerer Oneliner-Text lassen einen evtl. bereits existierenden Oneliner unangetastet. Der nächste Transkript-Write versucht es erneut. Startup-Recovery (`recovery::regenerate_missing_oneliners`) fängt Fälle ein, bei denen ein früherer Lauf zwischen Transkript- und Oneliner-Write abgestürzt ist. -Der Arzt kann den Oneliner beeinflussen, indem er im Diktat eine eigene Bezeichnung nennt (z.B. „Bezeichnung: Kniegelenk" oder „Fall-ID: Schulter rechts"). Ollama erkennt solche Formulierungen im Transkript und übernimmt sie. Es gibt keine deterministische Keyword-Suche — die Erkennung läuft vollständig über den LLM-Prompt. Ärzte, die das Feature nicht kennen, bekommen automatisch einen Oneliner aus dem Inhalt. +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:** -- Dateisystem scannen: alle Aufnahmen ohne Transkript zurück in die Queue -- Kein Datenverlust bei Server-Neustart +- `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::regenerate_missing_oneliners`: für Fälle mit Transkripten aber ohne `oneliner.txt` (oder mit leerem) wird der Oneliner einmalig erzeugt. +- `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. --- -**Pipeline bei "Fall abschließen":** +**Pipeline bei „Fall abschließen" (Ist-Stand):** ``` -1. Alle Transkripte zusammenführen (chronologisch) -2. Ionos LLM → Dokument generieren (Temperatur: 0) - System-Prompt: bereinigen und strukturieren, - nichts hinzufügen, nichts interpretieren. - Spätere Aufnahmen haben Vorrang — Korrekturen, - Nachträge und Widersprüche zugunsten der - chronologisch letzten Aussage auflösen. -3. document_v1.md speichern -4. current-Symlink setzen → document_v1.md +1. Handler serialisiert alle Transkripte chronologisch als AnalysisInput-JSON + → schreibt `analysis_input.json` (persistent, überlebt Crash) +2. Job in AnalyzeSender-Queue einreihen (mpsc::unbounded) +3. Analyze-Worker liest `analysis_input.json`, rendert Prompt, ruft LLM + (Temperatur 0, System-Prompt aus Config): + - "bereinigen und strukturieren, nichts hinzufügen" + - "spätere Aufnahmen haben Vorrang" + - ASR-Fehler aktiv korrigieren, unsichere Stellen mit ==text== markieren +4. Gazetteer::replace() auf der LLM-Antwort (post-AI-Normalisierung) +5. Atomic write (`.tmp` → rename) → `document.md` +6. `analysis_input.json` löschen (Queue-Marker weg, recovery kein Duplikat) ``` +Crash zwischen LLM-Antwort und Dokument-Write: akzeptiert. Recovery-Scan enqueued den Job erneut, ein LLM-Call wird wiederholt — seltenes Ereignis, kleiner Preis. + +Silent-Case (keine verwertbaren Transkripte): Worker schreibt eine Stub-Datei (`_Keine verwertbaren Aufnahmen (alle Aufnahmen still)._`) statt LLM-Call. + --- -**Pipeline bei "Neu generieren":** +**Pipeline bei „Neu analysieren" (Ist-Stand):** ``` -1. Preset-Prompt + optionaler Freitext kombinieren -2. Ionos LLM → neues Dokument -3. document_vN.md speichern -4. current-Symlink aktualisieren +1. Handler löscht bestehendes `document.md` +2. Pipeline wie oben neu ausführen ``` +Keine Versionierung. Eine neue Analyse überschreibt die alte in-place. + --- -**Undo-Logik:** +**Geplant für Phase 4 (noch nicht implementiert):** -``` -current zeigt auf vN -Undo → current zeigt auf v(N-1) -v1 → kein Undo mehr möglich, Button deaktiviert -``` +- **Preset-basiertes „Neu generieren"**: Preset-Prompt + optionaler Freitext kombinieren, LLM erneut aufrufen, vorige Version erhalten. +- **Dokument-Versionierung** (`document_v{N}.md` oder Alternative) als Voraussetzung für Undo. Noch offen, ob via Filename-Suffix + Max-Scan oder Content-Hash. +- **Undo-Button**: Rückkehr zur vorherigen Version. + +Diese drei Features hängen zusammen — erst mit Versionierung ist Undo sinnvoll. --- -**Background Tasks (tokio):** +**Background Tasks (tokio::spawn in main.rs):** -- Worker: GPU-Phasen-Zyklus (Whisper-Phase → Ollama-Phase → Aufräumen → warten) -- Queue-Recovery bei Start: Dateisystem nach fehlenden Transkripten scannen -- faster-whisper Health-Check: bei Ausfall Whisper-Phase überspringen, Retry im nächsten Zyklus -- Ollama Health-Check: bei Ausfall Ollama-Phase überspringen, Retry im nächsten Zyklus -- Retention-Prüfung: lazy bei Zugriff — Audio/Transkripte älter als `RETENTION_*_DAYS` werden entfernt, wenn der Ordner ohnehin gelesen wird +- **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`). +- **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`. -**Lazy Cleanup (Server):** -Es gibt keinen Cronjob um Mitternacht. Stattdessen prüft der Server bei jedem Request, der Fälle auflistet (Webinterface-Übersicht, SSE-Reconnect): -- Fälle, die zum Entfernen markiert sind **und** nicht von heute (UTC) stammen → löschen statt anzeigen -- Fälle, die niemand abfragt, bleiben liegen — aber niemand sieht sie. Beim nächsten Login räumt der Server auf. - -**Wichtig:** Wenn ein verspäteter Upload für einen zum Entfernen markierten Fall eintrifft, wird der `.remove`-Marker entfernt (siehe "Verspätete Uploads für zum Entfernen markierte Fälle"). Die Lazy Cleanup greift dann nicht mehr — der Fall bleibt erhalten, bis der Arzt ihn erneut zum Entfernen markiert. +**Geplant, noch nicht implementiert:** +- Health-Checks / Backoff für faster-whisper und Ollama bei Ausfall. +- Retention-Prüfung: lazy bei Zugriff — Audio/Transkripte älter als `RETENTION_*_DAYS` löschen, wenn der Ordner ohnehin gelesen wird. +- Lazy Cleanup für soft-deleted Fälle (`.deleted`-Marker älter als ein Tag → physisch entfernen). Aktuell bleibt der Ordner bestehen. --- -**Service-Ausfall:** +**Service-Ausfall (Ist-Stand):** | Situation | Verhalten | |---|---| -| faster-whisper nicht erreichbar | Whisper-Phase überspringen, Jobs bleiben in Queue, Retry im nächsten Zyklus | -| Ollama nicht erreichbar | Ollama-Phase überspringen, Oneliner-Jobs bleiben pending, Retry im nächsten Zyklus | -| Service wieder da | Automatische Weiterverarbeitung im nächsten Zyklus | -| > 30 Minuten Ausfall | Warnung im Webinterface für betroffene Ärzte | -| Server-Neustart | Queue-Recovery aus Dateisystem | +| faster-whisper nicht erreichbar | Transcribe-Worker loggt Fehler, markiert Aufnahme als `.m4a.failed`. Recovery-Scan ignoriert sie. | +| Ollama nicht erreichbar | Oneliner-Generierung loggt Fehler, bestehender Oneliner bleibt erhalten. Nächster Transkript-Write versucht es erneut. | +| LLM-Provider nicht erreichbar | Analyze-Worker loggt Fehler, `analysis_input.json` bleibt → Recovery-Scan enqueued beim nächsten Start. | +| Server-Neustart | Queue-Recovery aus Dateisystem (alle drei Recovery-Scans). | + +**Geplant:** Automatische Re-Try-Policy statt `.m4a.failed`-Rename nach einmaligem Whisper-Fehler; Admin-Warnung im UI bei > 30 Min. Ausfall. --- -**Webinterface (askama Templates, SSE + minimales Vanilla-JS):** +**Webinterface (askama Templates, Vanilla-Forms):** -Live-Updates via Server-Sent Events (EventSource) — Status-Wechsel erscheinen automatisch ohne Reload. +Aktuell reines HTML + Formular-Submits, ohne Live-Updates. SSE ist als Phase-4-Feature geplant, aber noch nicht implementiert. + +**Ist-Stand UI (vereinfacht):** -Übersicht: ``` -Offene Fälle +Meine Fälle — dr_mueller ──────────────────────────────────────────── -08:14 │ ⏳ Empfangen (wird transkribiert...) -09:32 │ ✓ Transkribiert [Ansehen] [Abschließen] -11:05 │ ✓ Transkribiert [Ansehen] [Abschließen] - [Alle abschließen] +Offen + 08:14 │ ⏳ Empfangen (transkribiere ...) + 09:32 │ ✓ Transkribiert [Ansehen] [Analysieren] [Reset] + 11:05 │ ✓ Transkribiert [Ansehen] [Analysieren] [Reset] + [Bulk: alle markierten analysieren] [Bulk: alle markierten löschen] -Ausgewertete Dokumente -──────────────────────────────────────────── -2026-04-06 08:14 [Öffnen] [Entfernen] -2026-04-05 14:20 [Öffnen] [Entfernen] +Abgeschlossen + 2026-04-06 08:14 „Kniegelenk, re." [Öffnen] [Reset] [Löschen] + 2026-04-05 14:20 „Hypertonie Grad 2" [Öffnen] [Reset] [Löschen] + +Zuletzt gelöscht [Undo letzten Batch] ``` -Fall-Detail (transkribiert): +Fall-Detail (transkribiert, Ist-Stand): ``` Fall 09:32 — 3 Aufnahmen ──────────────────────────────────────────── -09:32 „Patient klagt über Schmerzen..." ← Transkript Aufnahme 1 -09:45 „Röntgenbild zeigt..." ← Transkript Aufnahme 2 -10:02 „Diagnose: ..." ← Transkript Aufnahme 3 +09:32 „Patient klagt über Schmerzen ..." ← Transkript 1 (Audio-Link) +09:45 „Röntgenbild zeigt ..." ← Transkript 2 +10:02 „Diagnose: ..." ← Transkript 3 -[Abschließen] +[Analysieren] [Reset] [Löschen] ``` -Dokumentansicht (ausgewertet): +Dokumentansicht (ausgewertet, Ist-Stand): ``` -Dokument v3 +Dokument (Markdown → HTML, single version) +──────────────────────────────────────────── +... Inhalt ... -...Inhalt... - -⚠ 1 neue Aufnahme seit Abschluss ← nur sichtbar wenn Nachträge existieren -Preset: [Arztbrief] [Kürzer] [Formeller] [Diagnosen] [Medikamente] -Freitext: [________________________] -[← Undo v2] [Neu generieren] - -[Entfernen] ← Fall zum Entfernen markieren (wird am Folgetag gelöscht) +[Neu analysieren] [Reset] [Löschen] ``` -"Entfernen" markiert den Fall zum Löschen. Er bleibt den restlichen Tag sichtbar und wird beim nächsten Server-Scan am Folgetag (UTC) endgültig gelöscht. So können verspätete Uploads von der Watch noch angenommen werden. +„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?" +- 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). --- @@ -475,34 +518,40 @@ Freitext: [________________________] ``` /data/ -└── {arzt}/ - ├── open/ - │ └── {case_id}/ - │ ├── {UTC-timestamp}.m4a - │ ├── {UTC-timestamp}.transcript.txt ← Transkript (existiert erst nach Transkription) - │ ├── oneliner.txt - │ ├── analysis_input_v1.json ← nach Klick auf „Abschließen" - │ └── document_v1.md ← nach erfolgreicher LLM-Analyse - └── done/ - └── {case_id}/ - ├── {UTC-timestamp}.m4a ← RETENTION_AUDIO_DAYS - ├── {UTC-timestamp}.transcript.txt ← RETENTION_TRANSCRIPT_DAYS - ├── analysis_input_v1.json - ├── analysis_input_v2.json - ├── document_v1.md - ├── document_v2.md - ├── document_v3.md ← höchste Version gewinnt (kein Symlink) - └── .remove ← Marker: zum Entfernen markiert (Lazy Cleanup am Folgetag) +└── {slug}/ ← User-Slug aus users.toml (kein open/done-Split) + └── {case_id}/ + ├── {UTC-timestamp}.m4a ← Aufnahme (unveränderlich) + ├── {UTC-timestamp}.m4a.failed ← optional: dauerhaft gescheiterte Aufnahme + ├── {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) + ├── document.md ← nach LLM-Analyse (Gazetteer-normalisiert) + └── .deleted ← Soft-Delete-Marker (JSON mit Batch-UUID + deleted_at) /var/log/recorder/ └── recorder.{datum}.log - -/etc/recorder/ -└── prompts.toml ``` -- `.remove` ist eine leere Datei, die beim Klick auf "Entfernen" angelegt wird -- Der Lazy Cleanup prüft: `.remove` vorhanden **und** Falldatum ≠ heute (UTC) → Ordner löschen +**Keine Versionierung im Code (Ist-Stand).** `document.md` und `analysis_input.json` existieren genau einmal pro Fall. Re-Analyze überschreibt `document.md` in-place (Handler löscht vorher). Dokument-Versionierung (`document_v{N}.md`) und „Undo" sind als Phase-4-Feature geplant — siehe Abweichungen-Sektion. + +**Soft-Delete (`.deleted`-Marker):** + +```json +{ "batch": "7f3a...uuid", "deleted_at": "2026-04-17T10:20:00Z" } +``` + +- Marker wird beim Klick auf „Entfernen" geschrieben. Alle in einem UI-Klick gelöschten Fälle teilen sich die `batch`-UUID, damit `POST /web/cases/undo-delete` gezielt den letzten Batch wiederherstellen kann. +- Ordner bleibt physisch bestehen, ist nur in den Listen ausgeblendet. Lazy-Cleanup älter als ein Tag ist geplant, aber aktuell **nicht implementiert**. + +**Marker-Semantik im Überblick:** + +| Datei | Bedeutung | +|---|---| +| `{ts}.m4a` ohne `{ts}.transcript.txt` | Transkriptions-Auftrag offen | +| `{ts}.m4a.failed` | Dauerhaft gescheitert, Recovery-Scan ignoriert, UI zeigt „failed" | +| `analysis_input.json` vorhanden | Analyse-Job in Queue / in-flight | +| `document.md` vorhanden | Fall gilt als „ausgewertet" | +| `.deleted` vorhanden | Fall ist soft-deleted (per Undo-Batch wiederherstellbar) | --- @@ -511,44 +560,64 @@ Freitext: [________________________] **System-Konfiguration (.env):** ``` -# Server +# Server (required) SERVER_PORT=3000 DATA_PATH=/data USERS_FILE=users.toml # Logging LOG_LEVEL=info -LOG_PATH=/var/log/recorder/ +LOG_PATH=/var/log/recorder LOG_MAX_DAYS=90 -# Aufbewahrung +# Retention (noch nicht durchgesetzt, siehe Phase 2-Checkliste) RETENTION_AUDIO_DAYS=30 RETENTION_TRANSCRIPT_DAYS=30 RETENTION_DOCUMENT_DAYS=0 # 0 = permanent -# faster-whisper -WHISPER_URL=http://localhost:10300 +# faster-whisper (eigener FastAPI-Wrapper, siehe Phase 2b.5) +WHISPER_URL=http://localhost:9001 WHISPER_TIMEOUT_SECONDS=120 -# Ollama +# Ollama (Oneliner) OLLAMA_URL=http://localhost:11434 OLLAMA_MODEL=gemma3:4b OLLAMA_KEEP_ALIVE=0 -# LLM Provider (OpenAI-kompatibel, z.B. Ionos) +# LLM Provider (OpenAI-kompatibel, z.B. Ionos) — optional: +# Fehlt einer dieser Werte, versteckt das UI den "Analysieren"-Button. LLM_URL=https://openai.inference.de-txl.ionos.com LLM_API_KEY=... LLM_MODEL=... LLM_TEMPERATURE=0 LLM_TIMEOUT_SECONDS=180 +# Consolidation system prompt — optional override. Leer → Default aus +# analyze/prompt.rs. Runtime-konfigurierbar, damit Admins iterieren und +# per "Neu analysieren" bestehende Fälle re-runnen können. +LLM_SYSTEM_PROMPT="..." + +# Gazetteer (Post-AI-Normalisierung) +VOCAB_DIR=./vocab # Verzeichnis mit *.txt (eine Phrase pro Zeile) +HUNSPELL_DICT=/usr/share/hunspell/de_DE # Stamm-Pfad (ohne .aff/.dic), Dict-Veto + # Session SESSION_TIMEOUT_HOURS=8 +COOKIE_SECURE=true # false nur für Plain-HTTP-Dev ``` +**Verhalten bei fehlenden optionalen Blöcken:** + +| Fehlt | Konsequenz | +|---|---| +| `LLM_URL` / `LLM_API_KEY` / `LLM_MODEL` | UI versteckt „Abschließen", Handler antwortet 503. Pipeline startet ohne Fehler (`config.llm_configured()` entscheidet). | +| `VOCAB_DIR` oder kein passendes `*.txt` darin | `warn!` beim Start, Pipeline läuft **ohne** Gazetteer-Normalisierung. | +| `HUNSPELL_DICT` bzw. `.aff`/`.dic` nicht lesbar | `warn!` beim Start, Gazetteer läuft **ohne** Dict-Veto (potentiell mehr False-Positive-Rewrites). | +| `LLM_SYSTEM_PROMPT` leer | Fallback auf hardcoded Default aus `analyze/prompt.rs` (inkl. `==text==`-Markierungen). | + **User-Verwaltung (users.toml):** -User-Daten (API-Keys, Passwörter, Rollen) werden separat in `users.toml` verwaltet statt in .env. So können neue User hinzugefügt werden, ohne die System-Konfiguration anzufassen. Das `role`-Feld ermöglicht neben `doctor` auch andere Rollen (z.B. `mta`, `admin`). +User-Daten (API-Keys, Passwörter, Rollen, Per-User-Whisper-Settings) werden separat in `users.toml` verwaltet statt in .env. So können neue User hinzugefügt werden, ohne die System-Konfiguration anzufassen. Das `role`-Feld ermöglicht neben `doctor` auch andere Rollen (z.B. `mta`, `admin`). ```toml [[user]] @@ -557,6 +626,11 @@ api_key = "..." web_password = "$2b$12$..." role = "doctor" + [user.whisper] # optional; missing block = Service-Defaults + language = "de" + hotwords = "HOCM Valsalva" # technisch durchverdrahtet, wird nicht beworben + initial_prompt = "Kardiologie" + [[user]] slug = "dr_schmidt" api_key = "..." @@ -564,39 +638,30 @@ web_password = "$2b$12$..." role = "doctor" ``` +Der Transkriptions-Worker reicht `[user.whisper]` pro Upload an den Whisper-Service durch. Siehe Abweichungen-Eintrag „Hotwords". + --- -### 5. Vorgefertigte Prompts (prompts.toml) +### 5. Prompts (Ist-Stand: hardcoded) + +**Aktuell keine `prompts.toml`.** Die zwei aktiven Prompts leben direkt im Rust-Code: + +| Prompt | Ort | Override | +|---|---|---| +| Consolidation-Prompt (Fallabschluss-LLM) | `server/src/analyze/prompt.rs::SYSTEM_PROMPT` | env `LLM_SYSTEM_PROMPT` | +| Oneliner-Prompt (Ollama) | `server/src/transcribe/ollama.rs` | bisher kein Override | + +**Semantik des Consolidation-Prompts:** +- „Bereinigen und strukturieren, nichts hinzufügen, keine Diagnose, keine Arztbrief-Struktur." +- „Spätere Aufnahmen haben Vorrang — Korrekturen, Nachträge und Widersprüche zugunsten der chronologisch letzten Aussage auflösen." +- ASR-typische Fehler (phonetische Verwechslungen, zerschnittene Komposita) aktiv korrigieren. Bei Unsicherheit Original behalten. +- Unsichere Stellen / korrigierte Tokens / unklare Zahlen mit `==text==` umschließen. Diese Markierungen werden später vom Gazetteer nicht mehr angefasst. Keine anderen Annotation-Formen erlaubt. + +**Geplant für Phase 4 (noch nicht implementiert):** Preset-System für „Neu generieren" (Arztbrief / Kürzer / Formeller / Diagnosen / Medikamente). Konfigurierbar vermutlich via `prompts.toml` — Format ist noch nicht festgelegt. Bis dahin gibt es nur den einen Default-Prompt. + +**Entwurf (für spätere Umsetzung):** ```toml -[system] -base_prompt = """ -Du erhältst chronologisch sortierte Transkripte ärztlicher Diktate zu einem Fall. -Bereinige und strukturiere den Inhalt zu einem medizinischen Dokument. -Füge nichts hinzu und interpretiere nichts. -Spätere Aufnahmen haben Vorrang: Korrekturen, Nachträge und Widersprüche -werden zugunsten der chronologisch letzten Aussage aufgelöst. -Hinweise wie 'Korrektur:', 'Nachtrag:', 'Streichung:' sind Anweisungen -des Arztes und dürfen nicht im Dokument erscheinen. -""" - -[oneliner] -prompt = """ -Du erhältst das Transkript einer ärztlichen Erstaufnahme. -Erzeuge eine kurze Bezeichnung für diesen Fall (maximal 4–5 Wörter). - -Wenn der Arzt im Diktat eine eigene Bezeichnung nennt -(z.B. „Bezeichnung: Kniegelenk", „Fall-ID: Schulter rechts", -„Das ist der Patient mit dem Tennisarm"), -verwende diese als Grundlage für den Oneliner. - -Wenn keine explizite Bezeichnung erkennbar ist, -leite den Oneliner aus dem medizinischen Inhalt ab -(z.B. Beschwerde, Diagnose, Körperregion). - -Antwort: nur der Oneliner, keine Erklärung, keine Anführungszeichen. -""" - [[preset]] label = "Arztbrief" prompt = "Formatiere das Dokument als formellen Arztbrief mit Anrede und Grußformel." @@ -605,20 +670,10 @@ prompt = "Formatiere das Dokument als formellen Arztbrief mit Anrede und Grußfo label = "Kürzer fassen" prompt = "Fasse das Dokument kürzer zusammen ohne inhaltliche Verluste." -[[preset]] -label = "Formeller" -prompt = "Formuliere das Dokument formeller und sachlicher." - -[[preset]] -label = "Diagnosen hervorheben" -prompt = "Hebe alle Diagnosen als strukturierte Liste hervor." - -[[preset]] -label = "Medikamente hervorheben" -prompt = "Hebe alle Medikamente und Dosierungen als strukturierte Liste hervor." +# ... weitere Presets ``` -Neue Presets ohne Code-Änderung hinzufügbar. +Ziel: neue Presets ohne Code-Änderung hinzufügbar. --- @@ -638,7 +693,7 @@ Neue Presets ohne Code-Änderung hinzufügbar. | Ports | Nur 443 offen (Unraid), SSH nur lokal | | Datenhaltung | /data/ nur root lesbar | | IDOR-Prävention | Arzt-Identität kommt ausschließlich aus der Session, nie aus der URL. `AuthenticatedArzt`-Extractor leitet den Dateisystempfad serverseitig ab. | -| Input-Validierung | `case_id` wird als UUIDv4 validiert (`ValidCaseId`-Extractor), ungültige Werte → 400. Verhindert Path-Traversal. | +| Input-Validierung | `case_id` wird als UUIDv4 validiert (`uuid::Uuid::parse_str`, aktuell inline in Handlern; `ValidCaseId`-Extractor als Phase-4-TODO). Ungültige Werte → 400. Verhindert Path-Traversal. | | CSRF | Token pro Session, Hidden Field in allen POST-Formularen, serverseitige Validierung. `SameSite=Strict` als zusätzliche Ebene. | | Security Headers (nginx) | `Content-Security-Policy: default-src 'self'`, `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy: no-referrer` | @@ -646,29 +701,28 @@ Neue Presets ohne Code-Änderung hinzufügbar. ### 7. Logging +**Setup:** `tracing` + `tracing-subscriber` mit zwei Sinks — stdout (Container-Logs) und `tracing_appender::rolling::daily` für `{LOG_PATH}/recorder.{datum}.log`. Log-Level via `LOG_LEVEL` env (Default `info`). Keine ANSI-Farben im File-Sink. + +Typische Log-Linien im Regelbetrieb (strukturiert, key=value): + ``` -INFO 2026-04-06 08:14:22 Aufnahme empfangen arzt=dr_mueller case_id=abc123 -INFO 2026-04-06 08:14:23 Whisper-Phase gestartet queue=3 -INFO 2026-04-06 08:14:31 Transkript fertig case_id=abc123 dauer=8s -INFO 2026-04-06 08:14:39 Transkript fertig case_id=def456 dauer=7s -INFO 2026-04-06 08:14:46 Transkript fertig case_id=ghi789 dauer=6s -INFO 2026-04-06 08:14:46 Whisper-Phase fertig transkribiert=3 -INFO 2026-04-06 08:14:47 Ollama-Phase gestartet pending_oneliner=2 -INFO 2026-04-06 08:14:48 Oneliner generiert case_id=abc123 -INFO 2026-04-06 08:14:48 Oneliner generiert case_id=def456 -INFO 2026-04-06 08:14:48 Ollama-Phase fertig oneliner=2 -WARN 2026-04-06 09:00:00 faster-whisper unerreichbar retry=nächster Zyklus -WARN 2026-04-06 09:00:00 Ollama unerreichbar retry=nächster Zyklus -INFO 2026-04-06 09:05:00 Services wieder da -INFO 2026-04-06 10:30:00 Fall abgeschlossen case_id=abc123 -INFO 2026-04-06 10:30:45 Dokument v1 generiert case_id=abc123 -INFO 2026-04-06 10:35:00 Dokument v2 generiert case_id=abc123 preset=Arztbrief -INFO 2026-04-06 10:36:00 Undo → v1 case_id=abc123 -INFO 2026-04-06 10:40:00 Fall zum Entfernen markiert case_id=xyz789 -INFO 2026-04-07 08:00:12 Lazy Cleanup gelöscht=2 (markierte Fälle von gestern) -INFO 2026-04-07 08:00:12 Retention Cleanup audio=3 transkripte=1 +INFO Transcription worker started +INFO Analyze worker started vocab_entries=187 +INFO Transcribing audio=/data/dr_mueller//2026-04-17T10-15-00Z.m4a user=dr_mueller +INFO Transcript written audio=... bytes=1423 +INFO gazetteer replaced token="Pantoprasol" canonical="Pantoprazol" distance=1 +INFO gazetteer blocked by dict veto token="Kaktus" candidate="Lantus" distance=2 +INFO Oneliner updated path=/data/.../oneliner.txt chars=23 +INFO sending to llm case=/data/.../ recording_count=3 total_chars=1840 +INFO analysis done case=... bytes=2105 +WARN Gazetteer not available; running without proper-name correction dir=... error=... +WARN Hunspell dict unavailable; gazetteer running without dict veto stem=... error=... +ERROR whisper call failed audio=... error=... +ERROR llm call failed case=... error=... ``` +Wichtig: LLM-Antworten werden **nicht** geloggt (potenziell patientenbezogene Daten). Der `LlmError::Display` ist bereits redigiert. + --- ## Tech Stack @@ -676,7 +730,7 @@ INFO 2026-04-07 08:00:12 Retention Cleanup audio=3 transkripte=1 | Schicht | Technologie | |---|---| | Watch App | Kotlin, Jetpack Compose for Wear OS | -| Server | Rust, Axum, askama, reqwest, tracing, SSE | +| Server | Rust (edition 2024), Axum 0.8, askama, reqwest, tracing, `strsim` + `spellbook` (Gazetteer); SSE für Live-Updates ist Phase-4-TODO | | 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) | @@ -685,28 +739,59 @@ INFO 2026-04-07 08:00:12 Retention Cleanup audio=3 transkripte=1 ### Rust Crates (Server) -Axum selbst benötigt keinen GPU-Zugriff — STT und Preprocessing laufen als externe Services (faster-whisper, Ollama). Der Axum-Container ist daher ein schlankes Image ohne NVIDIA-Abhängigkeiten. +Axum selbst benötigt keinen GPU-Zugriff — STT und Preprocessing laufen als externe Services (faster-whisper, Ollama). Der Axum-Container ist daher ein schlankes Image ohne NVIDIA-Abhängigkeiten. Edition ist `2024`, was axum `0.8`, tower-http `0.6` und axum-extra `0.12` voraussetzt (native async fn in Traits). ```toml +[package] +edition = "2024" + [dependencies] -axum = "0.8" # 0.8 wegen edition 2024 (native async fn in Traits) +# Web +axum = { version = "0.8", features = ["multipart"] } +axum-extra = { version = "0.12", features = ["cookie", "form"] } +tower-http = { version = "0.6", features = ["limit", "trace"] } + +# Async runtime + HTTP client tokio = { version = "1", features = ["full"] } -reqwest = { version = "0.12", features = ["json", "multipart"] } +reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "rustls-tls"] } + +# Templates + Rendering askama = "0.12" -tracing = "0.1" -tracing-subscriber = { version = "0.3", features = ["env-filter"] } -tracing-appender = "0.2" -dotenvy = "0.15" -uuid = { version = "1", features = ["v4"] } +pulldown-cmark = { version = "0.13", default-features = false, features = ["html"] } # document.md → HTML + +# Auth / Session +bcrypt = "0.15" +rand = "0.8" # Session-Token (OsRng) +rpassword = "7.4" # hash-password CLI (keine Passwort-Echos) + +# Config / Daten serde = { version = "1", features = ["derive"] } serde_json = "1" toml = "0.8" -tower-http = { version = "0.6", features = ["limit", "trace"] } # passend zu axum 0.8 -rand = "0.8" # Session-Token-Generierung (OsRng) -bcrypt = "0.15" # Passwort-Hashing (Web-Login) -axum-extra = { version = "0.12", features = ["cookie"] } # passend zu axum 0.8 +toml_edit = "0.25" # User-CRUD ohne Kommentarverlust +dotenvy = "0.15" +uuid = { version = "1", features = ["v4", "serde"] } +time = { version = "0.3", features = ["local-offset", "parsing", "formatting", "macros"] } + +# Observability +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +tracing-appender = "0.2" + +# Pipeline-Utilities +tempfile = "3" # ffmpeg remux temp files + +# Gazetteer (Post-AI-Normalisierung) +strsim = "0.11" # Damerau-Levenshtein +spellbook = "0.4" # pure-Rust Hunspell-compat, Dict-Veto + +[dev-dependencies] +tower = { version = "0.5", features = ["util"] } +wiremock = "0.6" ``` +**Gazetteer-Stack separat begründet:** `strsim` liefert die Kernmetrik (Damerau-Levenshtein ≤ 2 über N-Gramme gegen kuratiertes Vocab). `spellbook` ist ein pure-Rust Hunspell-kompatibler Parser, der `.aff`-Affix-Regeln live anwendet — Tokens, die als gültige deutsche Alltagswörter erkannt werden (inkl. Flexionen wie *Kakteen* aus *Kaktus*), werden vom Rewrite ausgenommen (Dict-Veto). Siehe Abschnitt „Gazetteer" weiter oben. + --- ## Hardware @@ -736,7 +821,7 @@ axum-extra = { version = "0.12", features = ["cookie"] } # passend zu axum 0.8 ### Phase 2 — Transkriptions-Pipeline - [x] Transkriptions-Queue (tokio mpsc channel) -- [ ] GPU-Phasen-Worker (Greedy-Zyklus: Whisper-Phase → Ollama-Phase → Aufräumen) — aktuell kein striktes Phasenmodell, siehe Abweichungen +- [~] GPU-Phasen-Worker (Greedy-Zyklus Whisper↔Ollama) — **nicht nötig** im aktuellen Setup (Turbo + Ollama passen gleichzeitig in VRAM, OLLAMA_KEEP_ALIVE=300). Wird erst wieder relevant, wenn größere Modelle zurückkehren — siehe Abweichungen - [x] faster-whisper HTTP-Client (reqwest, POST `/asr`, multipart Audio) - [x] Ollama HTTP-Client (reqwest, POST /api/chat) — `keep_alive=300` statt `0`, siehe Abweichungen - [x] Oneliner-Generierung (einmalig, erst geschrieben wenn ein sinnvoller Oneliner vorliegt; sonst Retry beim nächsten Transkript) @@ -757,43 +842,50 @@ axum-extra = { version = "0.12", features = ["cookie"] } # passend zu axum 0.8 - [x] Deployed auf minerva:9001, `WHISPER_URL` in `server/.env` umgestellt ### Phase 3 — Fallverwaltung -- [x] Drei States: Empfangen → Transkribiert → Ausgewertet — rein aus FS abgeleitet (keine State-Datei, siehe Abweichungen) -- [x] State-Übergang: erst "Transkribiert" wenn alle Aufnahmen eines Falls fertig — `compute_flags` in `user_web.rs` -- [ ] Concurrency-Schutz: RwLock + Pending-Zähler pro Fall (Upload-Handler und Worker synchronisieren) -- [x] "Abschließen" nur im State "Transkribiert" erlaubt — `handle_close_case` in `routes/case_actions.rs` -- [x] Aufnahmen nach case_id zusammenführen — `analysis_input_v{N}.json` mit allen Transkripten +- [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] "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 (bei Fallabschluss) — `analyze/`-Modul, OpenAI-kompatibel (siehe Abweichungen: generischer Name `llm`) -- [x] Versionierung `document_vN.md` — v1 implementiert; **kein `current`-Symlink** (siehe Abweichungen: höchste v{N} gewinnt) -- [ ] Undo-Logik -- [ ] Fall zum Entfernen markieren (.remove Marker, Lazy Cleanup am Folgetag) +- [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] 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 +- [ ] Concurrency-Schutz: optional Pending-Zähler / Lock — aktuell genügt sequenzieller Worker + WorkerBusy-Flag - [ ] Upload-Deduplizierung (gleiche case_id + Timestamp → ignorieren) -- [ ] Nachträgliche Uploads für abgeschlossene Fälle (Nachtrag-Erkennung via Timestamp-Vergleich) -- [ ] Verspäteter Upload für zum Entfernen markierte Fälle: `.remove`-Marker entfernen, Nachtrag normal verarbeiten -- [ ] Upload für gelöschte Fälle (ACK mit status "gone") +- [ ] Nachtrag-Erkennung im UI (Transkript neuer als `document.md` → Hinweis im Fall-Detail) +- [ ] Dokument-Versionierung (`document_v{N}.md` o.ä.) — Voraussetzung für Undo +- [ ] Undo-Logik für Dokument-Versionen +- [ ] Lazy Cleanup: `.deleted`-Marker älter als N Tage → Ordner physisch entfernen +- [ ] Retention-Prüfung: `RETENTION_*_DAYS` durchsetzen (derzeit nur in Config) +- [ ] Upload für gelöschte Fälle: explizit ACK `"gone"` statt Marker-entfernen (Rollback vs. Auferstehung — Policy-Entscheidung offen) ### Phase 4 — Webinterface - [x] Session-Management: kryptographisches Token (256-Bit über 43 Alphanumeric-Zeichen, `OsRng`), Cookie (`HttpOnly`, `SameSite=Strict`, `Secure` via `COOKIE_SECURE`-ENV schaltbar für Dev), serverseitiger `RwLock>`, Ablauf nach 8 h - [x] `AuthenticatedWebUser`-Extractor: Session-Token → User auflösen, bei ungültiger/abgelaufener Session → Redirect `/web/login` -- [ ] `ValidCaseId`-Extractor: `case_id` als UUIDv4 validieren (`uuid::Uuid::parse_str`), bei Fehler → 400 - [x] Login-Seite (`GET /web/login`, `POST /web/login`), Logout (`POST /web/logout`) -- [x] askama Templates: `login.html`, `my_cases.html`, `case_detail.html` +- [x] askama Templates: `login.html`, `my_cases.html`, `case_detail.html`, `cases.html` (Admin), `document.html` +- [x] Übersicht für Arzt: zwei Sektionen (Offen / Abgeschlossen), plus "Zuletzt gelöscht" mit Undo-Batch +- [x] Fall-Detail: Transkripte pro Aufnahme einsehen (`GET /web/cases/{case_id}`, read-only, IDOR-geschützt via Session-Slug) +- [x] Audio-Streaming: `GET /web/audio/{user}/{case_id}/{filename}` (Cookie-Auth, Arzt eigene Dateien oder Admin) +- [x] Fall analysieren — Button im Detail-View +- [x] Bulk-Aktionen (alle markierten analysieren / löschen) über `POST /web/cases/bulk` +- [x] Dokumentansicht — `GET /web/cases/{id}/document`, rendert `document.md` via `pulldown-cmark` nach HTML +- [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 -- [ ] Übersicht mit drei States (Empfangen/Transkribiert/Ausgewertet) — aktuell: zwei Sektionen (Offen/Abgeschlossen) via FS-Layout, States kommen mit Phase 3 -- [x] Fall-Detail: Transkripte pro Aufnahme einsehen (`GET /web/cases/{case_id}`, read-only, IDOR-geschützt via Session-Slug) -- [x] Fall abschließen — Button im Detail-View; „Alle abschließen"-Bulk-Action noch offen -- [x] Dokumentansicht — `GET /web/cases/{id}/document`, rendert höchste `document_v{N}.md` in `
`
 - [ ] Nachtrag-Hinweis bei Aufnahmen nach Abschluss (⚠ "N neue Aufnahmen seit Abschluss")
-- [ ] Neu generieren (Preset + Freitext, Freitext max. 500 Zeichen)
-- [ ] Undo-Button
-- [ ] Fall zum Entfernen markieren (mit Bestätigung)
-- [ ] Service-Ausfall-Warnung (faster-whisper/Ollama > 30 Min.)
+- [ ] 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.)
 - [ ] CSRF-Token pro Session (Hidden Field in allen POST-Formularen, serverseitige Validierung)
 
 **Admin-Log (separat vom Arzt-UI)**
-- [x] `GET /web/` — flache Liste aller Fälle quer über User, inkl. Transkripten und Oneliner (aktueller Stand `cases.html`). Dient als "interaktives Log" für Entwicklung und Admin-Zwecke, ersetzt nicht das Arzt-UI.
-- [ ] Zugriffsschutz: später auf `role = "admin"` einschränken, sobald die Auth-Schicht steht.
+- [x] `GET /web/` — flache Liste aller Fälle quer über User, inkl. Transkripten und Oneliner. Dient als "interaktives Log" für Entwicklung und Admin-Zwecke.
+- [x] `hash-password` CLI (`cargo run --bin hash-password`) — erzeugt bcrypt-Hashes für `users.toml`, mit `toml_edit`-Schreibzugriff ohne Kommentarverlust.
+- [ ] Zugriffsschutz: `GET /web/` und Admin-Audio auf `role = "admin"` einschränken (aktuell noch offen für alle).
 
 ### Phase 5 — Watch App
 
@@ -833,7 +925,7 @@ axum-extra = { version = "0.12", features = ["cookie"] }   # passend zu axum 0.8
 - [ ] Korrektur-Diktate testen (LLM löst Widersprüche korrekt auf)
 - [ ] faster-whisper-Ausfall simulieren + Warnung prüfen
 - [ ] Ollama-Ausfall simulieren + Warnung prüfen
-- [ ] GPU-Phasen-Wechsel testen (Whisper → Ollama → Whisper, VRAM-Freigabe)
+- [~] GPU-Phasen-Wechsel testen (Whisper → Ollama → Whisper, VRAM-Freigabe) — obsolet, Phasen-Modell existiert nicht mehr; nur relevant beim Zurückkehren zu großen Modellen
 - [ ] Queue-Recovery nach Server-Neustart testen
 - [ ] Upload-Deduplizierung testen
 - [ ] Concurrent Uploads: mehrere Aufnahmen gleichzeitig für denselben Fall → State-Übergang erst nach letzter Transkription
@@ -850,7 +942,7 @@ axum-extra = { version = "0.12", features = ["cookie"] }   # passend zu axum 0.8
 - [ ] Fall zum Entfernen markieren → am Folgetag gelöscht
 - [ ] Nachträglicher Upload nach Fallabschluss → Nachtrag-Hinweis im Webinterface
 - [ ] Upload für gelöschten Fall → ACK "gone", Watch löscht lokal
-- [ ] Verspäteter Upload für zum Entfernen markierten Fall → .remove-Marker entfernt, Nachtrag verarbeitet
+- [ ] Verspäteter Upload für soft-deleted Fall → `.deleted`-Marker entfernt, Nachtrag verarbeitet (Logik implementiert, End-to-End-Test offen)
 - [ ] Edge-Case: Watch über Nacht/Wochenende offline → Aufnahmen bleiben in unsynced/, Sync bei Wiederherstellung
 
 ---
@@ -885,26 +977,53 @@ axum-extra = { version = "0.12", features = ["cookie"] }   # passend zu axum 0.8
 
 ## Abweichungen von der Originalplanung
 
+Alle Einträge beziehen sich auf den Ist-Stand im Repository. Die ursprüngliche Idee bleibt erhalten, damit spätere Entscheidungen nachvollziehbar bleiben.
+
+### Infrastruktur / Toolchain
+
 | Änderung | Original | Aktuell | Grund |
 |---|---|---|---|
-| Crate-Versionen | axum 0.7, tower-http 0.5, axum-extra 0.9 | axum 0.8, tower-http 0.6, axum-extra 0.12 | Rust edition 2024 erfordert native async fn in Traits; axum 0.7 nutzt `#[async_trait]`, was inkompatibel ist |
-| User-Verwaltung | `API_KEY_*` / `WEB_PASSWORD_*` in .env | Separate `users.toml` mit role-Feld | Flexibler: neue User ohne .env-Änderung, Rollen-System (doctor, mta, admin) |
-| faster-whisper Port | 8100 | 10300 | Tatsächlicher Port des laufenden Containers auf minerva |
-| STT-Container | `lscr.io/linuxserver/faster-whisper` (Wyoming-Protokoll) | `onerahmet/openai-whisper-asr-webservice` (HTTP-API) | Wyoming ist binäres TCP-Protokoll, ungeeignet für einfache HTTP-Clients. whisper-asr-webservice bietet OpenAPI-kompatiblen `/asr`-Endpoint mit multipart. |
-| m4a-Preprocessing | nicht erwähnt | Server remuxt m4a mit `-movflags faststart` vor Whisper-Aufruf | Bekannter Bug in whisper-asr-webservice ([Issue #97](https://github.com/ahmetoner/whisper-asr-webservice/issues/97)): m4a-Dateien >20s liefern leere Transkripte, weil Android `MediaRecorder` das `moov atom` ans Dateiende schreibt. `faststart`-Remuxing (verlustfrei, <1s) verschiebt die Metadaten an den Anfang und behebt das Problem. ffmpeg wird dafür im Axum-Container mitgeliefert. |
-| Terminologie | "Arzt/Ärzte" | "User" | Rollen-System: nicht nur Ärzte, auch MTAs und ggf. Admins |
-| Erster Upload neue case_id | "Fall unbekannt → ACK gone" | Neuer Fall anlegen, ACK "received" | Sonst würde der allererste Upload einer neuen case_id immer abgelehnt. "gone" nur für gelöschte Fälle. |
-| STT-Container | `onerahmet/openai-whisper-asr-webservice` | Eigener `whisper/`-FastAPI-Wrapper auf Port 9001 | Kein verfügbarer Wrapper (ahmetoner, speaches, linuxserver, hwdsl2) reicht `condition_on_previous_text=False` und feste `temperature=0.0` durch. Tests zeigten 2/13 Runs mit katastrophalen Halluzinationen (russisch/chinesisch) bei `large-v3` mit Wrapper-Defaults. Eigener Service hardcodet die Anti-Halluzinations-Params. |
-| Whisper-Modell | `large-v3` int8 | `large-v3-turbo` float16 + optional Hotwords | Turbo: gleicher 32-Layer-Encoder wie large-v3, Decoder destilliert auf 4 Layer — gleiche Qualität auf europäischen Sprachen, ~1.6 GB VRAM statt ~3 GB. Ermöglicht Koexistenz mit Ollama im 12-GB-VRAM. Hotwords (faster-whisper 1.2.1) bringen den größten Qualitätssprung bei Fachvokabular (in Experiment 8/8 korrekt vs. 3/8 ohne). |
-| Ollama-Modell | `gemma3:4b` | `gemma4:latest` | Neuere Version beim Aufsetzen des Ollama-Containers verfügbar. Llama 3.1 8B als Fallback, falls Oneliner-Qualität nicht reicht. |
-| GPU-Phasen-Worker | Strikter Whisper↔Ollama-Wechsel mit Ollama `keep_alive=0` | Turbo (~1.6 GB) + Ollama gemma4 (~9 GB) bleiben parallel geladen, `OLLAMA_KEEP_ALIVE=300` | Dank Turbo-VRAM-Footprint passen beide gleichzeitig in die GPU. Spart das Modell-Reload (~2 s pro Oneliner). Strikter Phasenwechsel bleibt als Fallback, falls später größere Modelle (`large-v3`, Llama 3.1 8B) zurückkehren. |
-| faster-whisper Port | 10300 (ahmetoner) | 9001 (eigener Service) | Neuer Service auf freiem Port; alter Container optional parallel belassen. |
-| Test-Client für Watch-Flow | Erst ab Phase 5 mit echter Hardware | `scripts/dictate.sh` ab Phase 2/3 als Stand-in (ffmpeg + curl + interaktiver c/n/r/q-Loop) | Erlaubt End-to-End-Tests der Server-Pipeline ohne Watch-Hardware, solange die Pixel Watch nicht verfügbar ist. |
-| Admin-Log vs. Arzt-UI | Nur Arzt-UI geplant | Zusätzlich frühes Admin-Log unter `/web/` (flache Liste aller Fälle, Transkripte, Oneliner) | Gebaut, bevor das Arzt-UI (Session, States, Fall-Detail) existiert, um die Pipeline während Entwicklung inspizieren zu können. Soll bleiben, aber später hinter `role = "admin"` geschützt; das Arzt-UI wird separat entwickelt. |
-| Hotwords | Nicht vorgesehen | Per-User-Feld `[user.whisper].hotwords` bleibt **im Code**, wird aber **nicht als Feature angeboten** | Erstes Ad-hoc-Experiment sah 8/8 vs. 3/8 aus. Regress-Lauf über 10 kuratierte Fixtures (`scripts/regress_whisper.sh`) widerlegte das: ohne Hotwords 12/257 Wortfehler (~4.7% WER), mit Hotwords 14/257 — leicht schlechter. Hotwords schluckten sogar Funktionswörter wie „Beginn mit" und „mittels". Kein belegter Nutzen, dafür zusätzliche Bedienkomplexität pro Arzt. KISS: wir lassen die Leitung durchverdrahtet (kein Code-Rückbau), aber bewerben/konfigurieren es nicht. Re-evaluiert, sobald ein konkreter Fachvokabular-Bedarf auftaucht. |
-| Case-State-Machine | Nicht vorgesehen | **Verworfen**: explizite State-Machine + `state.json`-Sidecar (Received/Transcribing/Transcribed/Evaluating/Evaluated/Closed/Failed) wurde gebaut und nach Live-Test wieder entfernt. FS bleibt einzige Wahrheitsquelle. | Der Sidecar führte sofort zu Datendrift: bei jeder Schema-/Code-Änderung waren Bestandsfälle inkonsistent (State sagt „fertig", aber `.m4a` ohne `.transcript.txt` vorhanden). Recovery hätte Self-Healing-Checks gegen die FS-Invariante gebraucht — womit `state.json` zur bloßen Cache-Kopie wird und ihre eigentliche Daseinsberechtigung verliert. Erkenntnis in `docs/projektplan.md` → „Design-Prinzipien: Filesystem ist SoT" festgehalten. |
-| Dokument-Versionierung | `document_vN.md` + `current`-Symlink | Nur `document_vN.md`; höchste `N` gewinnt (`find_latest_document` scannt Ordner) | Symlink-Swap wäre ein zusätzlicher atomarer Schritt mit eigenem Crash-Pfad; Undo wäre `rm current + symlink new` statt einfachem `rm document_v{max}.md`. Max-Scan ist O(n), bei n ≤ 10 Versionen vernachlässigbar. Weniger bewegte Teile. |
-| Analyse-Input-Format | Zusammengeführter Markdown-Text | JSON-Datei `analysis_input_v{N}.json` mit strukturierten Metadaten | Persistenz und LLM-Prompt entkoppelt: Datei speichert `version`, `last_recording_mtime` (für zukünftige Nachtrag-Detection), `recordings[{recorded_at, text}]`. Prompt wird zur Call-Zeit aus dem JSON gerendert. Schema-Evolution bleibt billig, ohne Prompt-Format zu brechen. |
-| LLM-Provider-Naming | `ionos.rs`, `IonosError`, `IonosSettings` | `llm.rs`, `LlmError`, `LlmSettings` — generischer Name im Code | Ionos ist OpenAI-API-kompatibel und jederzeit durch Azure OpenAI, OpenAI direkt oder Together.ai ersetzbar. Code-Identifier sollen anbieter-agnostisch bleiben; konkreter Anbieter lebt nur in `.env` (`LLM_URL`, `LLM_API_KEY`). |
-| Analyse-Queue | `mpsc::channel(depth=32)` (bounded) | `mpsc::unbounded_channel` | `AnalyzeJob` ist ~110 Bytes (Path + u32). Selbst 100.000 Jobs wären 11 MB — realistisch unerreichbar bei 3 Ärzten × wenigen Abschlüssen/Tag. Persistenz lebt in `analysis_input_v{N}.json`, nicht in der Queue. Unbounded spart den 503-Pfad und den ganzen Queue-Full-Recovery-Mechanismus. |
-| LLM-Gate im UI | Nicht vorgesehen | Abschließen-Button nur sichtbar, wenn `LLM_URL`/`LLM_API_KEY`/`LLM_MODEL` alle gesetzt sind; sonst Hinweis „LLM-Analyse nicht konfiguriert". Handler hat denselben Guard (503 Defense-in-Depth). | Ohne konfigurierten Provider würde der Close zur Laufzeit an einem `reqwest`-Fehler scheitern — verwirrend. `Config::llm_configured()` macht das Gate explizit, Tests prüfen beide Pfade. |
+| Rust-Edition + Crate-Versionen | edition 2021, axum 0.7, tower-http 0.5, axum-extra 0.9 | edition 2024, axum 0.8, tower-http 0.6, axum-extra 0.12 | Edition 2024 erlaubt native `async fn` in Traits; axum 0.7 nutzt `#[async_trait]`, inkompatibel |
+| STT-Service | `lscr.io/linuxserver/faster-whisper` (Wyoming-Protokoll) → `onerahmet/openai-whisper-asr-webservice` → **eigener `whisper/`-FastAPI-Wrapper** | Wyoming ist binäres TCP, ungeeignet. Kein verfügbarer HTTP-Wrapper reicht `condition_on_previous_text=False` + feste `temperature=0.0` durch; Tests zeigten 2/13 Runs mit katastrophalen Halluzinationen (russisch/chinesisch). Eigener Service hardcodet die Anti-Halluzinations-Params. | Hardware-Tests auf echter Pipeline |
+| Whisper-Port | 8100 (Plan) → 10300 (ahmetoner-Container) → **9001** (eigener Service) | freier Port auf minerva; alter Container optional parallel belassen |
+| Whisper-Modell | `large-v3` int8 | `large-v3-turbo` float16 | Gleiche Qualität auf europäischen Sprachen, ~1.6 GB statt ~3 GB VRAM — ermöglicht Koexistenz mit Ollama im 12-GB-VRAM |
+| Ollama-Modell | `gemma3:4b` | `gemma4:latest` | Neuere Version beim Aufsetzen verfügbar; Llama 3.1 8B als Fallback, falls Oneliner-Qualität nicht reicht |
+| m4a-Preprocessing | nicht erwähnt | Server remuxt m4a mit `-movflags faststart` vor Whisper-Aufruf | Bekannter Bug ([Issue #97](https://github.com/ahmetoner/whisper-asr-webservice/issues/97)): m4a > 20 s liefern leere Transkripte, weil Android `MediaRecorder` das `moov atom` ans Dateiende schreibt. `faststart`-Remuxing (verlustfrei, < 1 s) behebt es. ffmpeg wird im Axum-Container mitgeliefert. |
+| GPU-Phasen-Worker | Strikter Whisper↔Ollama-Wechsel, Ollama `keep_alive=0` | Zwei unabhängige sequentielle Worker (transcribe + analyze), beide Modelle parallel im VRAM, `OLLAMA_KEEP_ALIVE=300` | Dank Turbo-VRAM-Footprint passen beide gleichzeitig in die GPU. Spart Modell-Reload (~2 s pro Oneliner). Phasen-Wechsel bleibt als Fallback für größere Modelle (`large-v3`, Llama 3.1 8B) dokumentiert. |
+| Analyse-Queue | `mpsc::channel(depth=32)` (bounded) | `mpsc::unbounded_channel` | `AnalyzeJob` ist ~110 Bytes; selbst 100 000 Jobs ≈ 11 MB. Persistenz lebt in `analysis_input.json` auf Platte, nicht in der Queue. Unbounded spart den 503-Pfad. |
+| LLM-Provider-Naming | `ionos.rs`, `IonosError`, `IonosSettings` | `llm.rs`, `LlmError`, `LlmSettings` | OpenAI-API-kompatibel — Ionos ist durch Azure/OpenAI/Together.ai ersetzbar. Provider lebt nur in `.env`. |
+
+### Daten-Modell und State
+
+| Änderung | Original | Aktuell | Grund |
+|---|---|---|---|
+| Filesystem-Layout | `/data/{arzt}/open/{case_id}/` bzw. `done/{case_id}/` | Flach: `/data/{slug}/{case_id}/` | State aus Dateimarkern ableiten statt aus Ordnerlage. Migration zwischen `open`/`done` war ein zusätzlicher atomarer Schritt mit eigenem Crash-Pfad. |
+| Terminologie | "Arzt/Ärzte" | "User/slug" | Rollen-System: auch MTAs und Admins (`role`-Feld in `users.toml`) |
+| Case-State-Machine | Nicht vorgesehen | **Verworfen** (nach Live-Test): explizite State-Machine + `state.json`-Sidecar war kurzzeitig gebaut, erzeugte aber sofort Datendrift bei Schema-/Code-Änderungen. FS bleibt einzige Wahrheitsquelle. | Recovery hätte Self-Healing-Checks gegen die FS-Invariante gebraucht — `state.json` wäre dann eine überflüssige Cache-Kopie. Siehe „Design-Prinzipien: Filesystem ist SoT". |
+| Soft-Delete | `.remove`-Marker (leere Datei) + Lazy Cleanup am Folgetag | `.deleted`-JSON-Marker `{batch, deleted_at}` + explizites „Undo letzter Batch" via `POST /web/cases/undo-delete` | Mehrere in einem UI-Klick gelöschte Fälle bilden eine Batch, dezidiertes Undo ist besser testbar als Zeit-Fenster. Physisches Cleanup älter als N Tage bleibt als TODO. |
+| Erster Upload neue case_id | ACK „gone" | Neuer Fall anlegen, ACK „received" | Sonst würde der allererste Upload einer neuen case_id immer abgelehnt. „gone" aktuell gar nicht implementiert — auch Uploads für soft-deleted Fälle reaktivieren den Fall (Marker entfernen + Nachtrag). |
+| Dokument-Versionierung | `document_v{N}.md` + `current`-Symlink | **Single-Version** `document.md`; Re-Analyze überschreibt in-place | Versionierung + Undo sind aktuell nicht benötigt — Re-Analyze ist selten, der Arzt hat den Markdown-Export in der Hand. Als Phase-4-TODO behalten (Voraussetzung für Preset-System). |
+| Analyse-Input-Format | Zusammengeführter Markdown-Text als Prompt | Single `analysis_input.json` mit `{last_recording_mtime, recordings[{recorded_at, text}]}` | Persistenz und LLM-Prompt entkoppelt. Prompt wird zur Call-Zeit aus dem JSON gerendert. Schema-Evolution bleibt billig, ohne Prompt-Format zu brechen. Keine Versionierung der Input-Datei — sie ist ephemeral und wird nach erfolgreichem Dokument-Write entfernt. |
+
+### Pipeline und LLM-Nutzung
+
+| Änderung | Original | Aktuell | Grund |
+|---|---|---|---|
+| Gazetteer (Post-AI) | Nicht vorgesehen | Deterministische Terminologie-Normalisierung auf **jeder** KI-Ausgabe (Whisper, Oneliner, Analyse). Damerau-Levenshtein ≤ 2 über kuratiertes Vokabular | LLMs haben starke Training-Priors, die Kontext-Hints überstimmen (z.B. `Amiodarone` statt `Amiodaron`). Ein Pre-LLM-Hint-Kanal kann das nicht fangen, weil der Drift im Output passiert. Post-AI fängt Whisper-Typos und LLM-Drift in einem Mechanismus. |
+| Gazetteer-Dict-Veto | Nicht vorgesehen | Hunspell-kompatibler `spellbook`-Crate veto'ed Rewrite, wenn Token ein gültiges deutsches Wort ist | `Kaktus` (DL=2 zu `Lantus`) darf nicht zu Medikamentennamen umgeschrieben werden. Flexions-Expansion via `.aff` fängt `Kakteen` ohne expliziten Eintrag. Lazy Lookup (nur bei DL-Kandidaten) → vernachlässigbarer Overhead. |
+| 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. |
+
+### UI / Entwickler-Tools
+
+| Änderung | Original | Aktuell | Grund |
+|---|---|---|---|
+| User-Verwaltung | `API_KEY_*` / `WEB_PASSWORD_*` in `.env` | Separate `users.toml` + `hash-password`-CLI mit `toml_edit` | Neue User ohne `.env`-Änderung; CLI aktualisiert `users.toml` kommentar-erhaltend; Rollen-System (doctor, mta, admin) |
+| LLM-Gate im UI | Nicht vorgesehen | „Analysieren"-Button nur sichtbar, wenn `LLM_URL`/`LLM_API_KEY`/`LLM_MODEL` alle gesetzt; Handler hat denselben Guard (503 Defense-in-Depth) | Ohne Provider würde der Close zur Laufzeit an einem `reqwest`-Fehler scheitern. `Config::llm_configured()` macht das Gate explizit. |
+| 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. |
+| 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. |