Update file marker for oneliner to json

The `oneliner.txt` file marker has been updated to `oneliner.json`. This
change reflects the new state management for the oneliner, which now
uses an internally-tagged enum (`OnelinerState`) to represent different
states (Ready, Empty, Error) along with a `generated_at` timestamp. This
provides a more robust way to handle different outcomes of the LLM call,
particularly differentiating between an intentionally empty response and
a transient error.
This commit is contained in:
2026-04-20 17:39:17 +02:00
parent 5effa7c96e
commit a8389a89db
+25 -7
View File
@@ -48,7 +48,7 @@ Ubuntu Server (RTX 3060, 12 GB VRAM)
### 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. 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.
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.json`, `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.
@@ -375,6 +375,10 @@ POST /web/cases/{case_id}/analyze → Analyse starten / neu anstoße
POST /web/cases/{case_id}/reset → Analyse/Transkripte verwerfen, alles neu
transkribieren (admin-only)
POST /web/cases/{case_id}/delete → Soft-Delete (Batch-Marker)
POST /web/cases/{case_id}/recordings/delete → Einzel-Aufnahme hart löschen (m4a + Sidecars),
derived artefacts (oneliner.json, document.md,
analysis_input.json) invalidieren — Auto-Trigger
regeneriert beim nächsten View-Load
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;
@@ -437,7 +441,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.txt, document.md)
Oneliner (Ollama) ├──→ gazetteer::replace() ──→ Persistenz (.transcript.txt, oneliner.json, document.md)
Analyse-LLM ──────┘
```
@@ -502,13 +506,18 @@ Der Oneliner wird am Ende jedes Batches aus **allen** nicht-leeren Transkripten
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.
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.
**Persistenz:** `oneliner.json` hält das Resultat als internally-tagged Enum `OnelinerState::{Ready{text,generated_at}, Empty{generated_at}, Error{generated_at}}` (siehe `doctate-common::oneliners`). Drei disjunkte Ausgänge des LLM-Calls:
- `Ready` → brauchbarer Einzeiler.
- `Empty``OllamaError::EmptyResponse` (Modell folgte der Silence-Rule: kein medizinischer Schlüsselbegriff). **Valides Endergebnis, kein Retry.**
- `Error` → transient gescheitert (Timeout, HTTP, Parse). Startup-Recovery retryt **nur** diese Variante.
Silent-Case-Handling: bei `Error` bleibt ein evtl. bereits existierender `Ready`-Zustand unangetastet, der nächste Transkript-Write versucht es erneut. Startup-Recovery (`recovery::regenerate_missing_oneliners`) fängt Fälle ein, bei denen die Datei fehlt oder `Error` enthält.
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::regenerate_missing_oneliners`: für Fälle mit Transkripten aber ohne `oneliner.txt` (oder mit leerem) wird der Oneliner einmalig erzeugt.
- `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.
@@ -589,7 +598,7 @@ 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). Emittiert `RecordingUploaded`, `TranscriptReady`/`TranscriptFailed`, `OnelinerUpdated` auf den Event-Bus.
- **Transcribe-Worker**: sequentielle mpsc-Abarbeitung (ffmpeg remux → Whisper → Gazetteer → Transkript-Write → Oneliner am Batch-Ende). Emittiert `RecordingUploaded`, `RecordingDeleted`, `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.
@@ -620,6 +629,11 @@ Diese drei Features hängen zusammen — erst mit Versionierung ist Undo sinnvol
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.
**UI-Helfer:**
- `partials/oneliner.html` definiert ein Askama-Macro `render`, das alle fünf `OnelinerDisplay`-Zustände (`Ready`, `Empty`, `Error`, `Pending` = Transkription läuft, `Generating` = Transkription fertig, LLM-Call noch offen) einheitlich rendert. Sowohl `case_page.html` als auch `my_cases.html` konsumieren es — kein divergentes Markup pro View.
- `partials/time_format.js` ist ein gemeinsames Client-Script, das UTC-ISO-Timestamps in lokaler Zeitzone formatiert (bewusst JS, nicht Rust: Browser kennt TZ des Nutzers, Server nicht — siehe Memory „JS-OK, Logic-Rust"). Wird u.a. für `recorded_at_iso` (letzte Aufnahme) und Gruppen-Überschriften genutzt.
- **Post-Action-Redirect:** `POST`-Handler für Aktionen auf einem Fall (analyze, reset, delete-recording) nutzen `resolve_return_path(Referer)` — wenn der Referer **gleich-origin** auf `/web/...` zeigt, landet der Arzt wieder genau dort, sonst Fallback `/web/cases`. Schema und Host werden bewusst weggeworfen, damit ein feindlicher Referer keinen Open-Redirect triggern kann.
**Ist-Stand UI (vereinfacht):**
```
@@ -682,7 +696,7 @@ Fall 09:32 — 3 Aufnahmen
├── {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
├── 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)
├── document.md ← nach LLM-Analyse (Gazetteer-normalisiert)
@@ -710,6 +724,7 @@ 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) |
| `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 |
| `document.md` vorhanden | Fall gilt als „ausgewertet" |
@@ -874,7 +889,7 @@ INFO Transcribing audio=/data/dr_mueller/<uuid>/2026-
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 Oneliner updated path=/data/.../oneliner.json kind=ready chars=23
INFO sending to llm case=/data/.../<uuid> recording_count=3 total_chars=1840
INFO analysis done case=... bytes=2105
WARN Gazetteer not available; running without proper-name correction dir=... error=...
@@ -1086,6 +1101,7 @@ wiremock = "0.6"
- [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] 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
@@ -1104,6 +1120,7 @@ wiremock = "0.6"
- [x] `AuthenticatedWebUser`-Extractor: Session-Token → User auflösen, bei ungültiger/abgelaufener Session → Redirect `/web/login`
- [x] Login-Seite (`GET /web/login`, `POST /web/login`), Logout (`POST /web/logout`)
- [x] askama Templates (seit Split 2026-04-19): `login.html`, `my_cases.html`, `case_page.html` (Fall-Übersicht + inline gerendertes Dokument), `case_recordings.html` (Einzel-Transkripte + Audio). `case_detail.html`, `cases.html`, `document.html` sind entfernt.
- [x] Shared Partials: `partials/oneliner.html` (Askama-Macro für alle `OnelinerDisplay`-Zustände, konsumiert von `case_page.html` und `my_cases.html`) und `partials/time_format.js` (Client-TZ-Rendering für UTC-Timestamps).
- [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).
@@ -1288,6 +1305,7 @@ Alle Einträge beziehen sich auf den Ist-Stand im Repository. Die ursprüngliche
| 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. |
| Oneliner-Persistenz | Plain-Text `oneliner.txt` (Sentinel: Datei fehlt/leer → nicht generiert) | `oneliner.json` mit internally-tagged Enum `OnelinerState::{Ready, Empty, Error}` + `generated_at`-Timestamp | Silence-Rule des Ollama-Prompts macht „leer" zu einem **gültigen** Ergebnis (kein medizinisches Schlüsselwort im Transkript). Vorher war nicht unterscheidbar, ob „leer" bedeutet „noch nie versucht", „gerade gescheitert" oder „bewusst leer" — Recovery hätte endlos retryed. `OllamaError::EmptyResponse` persistiert als `Empty` und bricht den Retry-Loop sauber ab; UI rendert dafür einen eigenen Zustand („kein medizinischer Inhalt"), während `Error` eine Fehleranzeige zeigt. |
### Pipeline und LLM-Nutzung