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:
+25
-7
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user