Files
doctate/docs/projektplan.md
T
Brummel 3218fc62bd feat: server-authoritative case window via users.toml
The /api/oneliners time window is now read per-user from users.toml
(window_hours, default 72h). Clients no longer carry a window:
client.toml oneliner_window_hours, SyncConfig.window_hours, the
?hours=N query param, and the render_case_list cutoff filter are
gone. ETag suffix keeps the effective hours so an admin edit to
users.toml invalidates client caches on the next request.
OnelinersResponse.window_hours stays in the wire format, but now
exists solely to anchor client reconciliation.
2026-04-21 16:48:01 +02:00

103 KiB
Raw Blame History

Smart Watch Recorder — Projektplan

Überblick

Medizinisches Diktiersystem für Ärzte. Aufnahmen werden per Pixel Watch (primäres Erfassungsgerät, MVP-Ziel) oder weiteren Clients (Linux-Desktop bereits in Betrieb, Android-Handy geplant, Windows geplant, iOS optional) erstellt, automatisch transkribiert, durch ein LLM aufbereitet und über ein schlichtes Webinterface abgerufen. Alle Clients — einschließlich Browser, Watch und jedes native App — sprechen dieselbe Server-HTTP-Schnittstelle; ihre Rolle ergibt sich allein daraus, welchen Teil der API sie nutzen. Das System unterstützt mehrere Ärzte mit vollständig getrennten Daten.

Datenphilosophie: Das System ist kein Langzeitspeicher. Daten sollen so schnell wie möglich durch die Pipeline fließen und nach Übernahme ins Praxissystem vom Arzt zum Entfernen markiert werden. Je weniger Daten in der Pipeline verbleiben, desto besser. Die Watch löscht Aufnahmen erst nach Server-Bestätigung — so gehen keine Daten verloren, solange die Watch funktioniert. Serverseitig besteht bewusst kein Backup-Konzept — bei Datenverlust auf dem Server vor Übernahme ins Praxissystem gehen Daten verloren. Dieses Risiko wird den Nutzern kommuniziert.

Timestamps: Alle Zeitstempel im gesamten System (Dateinamen, API-Kommunikation, Logs) sind UTC. Die Anzeige in der lokalen Zeitzone erfolgt ausschließlich in der UI-Schicht (Watch-App, Webinterface). "Heute" wird systemweit als aktueller UTC-Tag definiert.


Architektur

Clients (alle sprechen dieselbe HTTPS-API)
  ┌────────────────────────────────────────────────────────────┐
  │ Pixel Watch (Wear OS) — primär: Aufnahme + Oneliner-Polling│
  │ Linux-Desktop (gebaut) — Aufnahme + Oneliner-Poll + Review │
  │ Windows-Desktop (geplant) — gleiche Rolle wie Linux        │
  │ Android-Handy (geplant) — Aufnahme + optional Review       │
  │ Browser — Review, Audio-Streaming                          │
  │ iOS (optional, später) — dieselbe Rolle                    │
  └────────────────────────────────────────────────────────────┘
                │
                │  HTTPS (einheitliche Server-API, keine
                │  geräte-spezifischen Endpoints)
                │  Watch ohne eigene Connectivity:
                │  transparent via Wear OS Proxy über Phone
                ↓
Unraid Server
    └── nginx (reverse proxy, TLS)
            ↓ lokales Netz
Ubuntu Server (RTX 3060, 12 GB VRAM)
    ├── 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
    └── externer LLM-Provider (Ionos als Default, OpenAI-API-kompatibel, austauschbar)

Design-Prinzipien

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.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.
  • Recovery-Scan nach Restart ist trivial: finde .m4a ohne .transcript.txt → transkribieren. Natürliche Idempotenz, kein Crash-Recovery-Protokoll.
  • Crash mitten in Whisper = .transcript.txt fehlt = nächster Lauf macht's nochmal. Keine Intermediate-States, die rückwärts abgewickelt werden müssten.
  • Manuelle Reparatur möglich: Datei löschen, neu triggern. Kein „state irgendwie auf Received setzen".

Bewusst verzichtet auf: retry-Budget, Failed-Kategorie mit Error-Details. Wenn ein Audio dauerhaft an Whisper scheitert, loggt der Server im Crash-Loop — das fällt sofort auf und wird manuell entfernt. Diese Kategorie wird erst eingeführt, wenn ein realer Bedarf entsteht (z.B. hochvolumiger Betrieb).

Client-Prinzip

Clients sind flüchtige Zugriffs- und Erfassungsstellen. Der Server ist die einzige autoritative Datenquelle; jeder Client nutzt dieselbe HTTP-API, aber nur so viel davon, wie für seine Form und sein Use-Case sinnvoll ist.

  • Pixel Watch (primär): minimaler API-Ausschnitt — Upload + Oneliner-Polling. Kein Playback, kein Review, kein lokaler Archivspeicher.
  • Linux-Desktop (gebaut, produktiv): Upload + Oneliner-Polling + Review-Absprung per Magic-Link. Tech-Stack entschieden: eframe/egui + tokio + reqwest + ffmpeg-Subprozess; siehe Komponenten-Kapitel.
  • Windows-Desktop (geplant): gleiche Rolle wie Linux, derselbe client-desktop-Crate mit #[cfg(target_os)]-Gates.
  • Android-Handy (geplant): identische Erfassung wie die Watch; darf zusätzlich Review-Endpoints (/web/...) nutzen, weil Display und Eingabe das sinnvoll machen.
  • Browser: klassisches Review-Frontend via /web/... + Audio-Streaming.
  • iOS (optional, später): gleiche Invariante, SwiftUI-UI; würde doctate-client-core über UniFFI-Bindings konsumieren.

Invariante (fixiert)

Alle Clients sprechen ausschließlich die einheitliche Server-HTTP-Schnittstelle. Es gibt keine gerätespezifische API und keinen geräteeigenen Backchannel. Die Rolle eines Clients ergibt sich allein daraus, welchen Teil der API er nutzt.

Brisante medizinische Daten bleiben auf dem Server. Brisant sind Audioaufnahmen (.m4a), Transkripte (.transcript.txt) und die daraus generierten Dokumente (document.md) — also alles, was Anamnese, Diagnose, Medikation oder Patientenstimme direkt enthält. Clients dürfen sie nur so lange lokal halten, wie der laufende Upload- oder Render-Vorgang es erzwingt: die Pending-Queue beim Recorder bis zum ACK, temporäre Render-Puffer beim Audio-Playback. Danach werden sie auf dem Client gelöscht — der Server ist die einzige dauerhafte Wahrheit.

Informelle Navigationsdaten dürfen lokal gecacht werden. Informell sind Fall-IDs, Zeitstempel, Fall-Listen und der Oneliner — kurze Orientierungsdaten, die dem Arzt zeigen, welche Fälle existieren, ohne deren medizinischen Inhalt preiszugeben. Der Server bleibt auch hier die autoritative Quelle; der Client-Cache ist verwerfbar und wird beim nächsten erfolgreichen Poll überschrieben. Beispiel: doctate-client-core::snapshot_cache persistiert die Oneliner-/Fall-Liste, damit der Desktop-Client beim Launch keinen Flash-Fehlzustand zeigt.

Lackmustest für neue Features: „Erzwingt das Feature, dass brisante Daten (Audio, Transkript, Dokument) länger lokal gehalten werden als der aktuelle Upload- oder Render-Vorgang?" Wenn ja, gehört das Feature ins Web-UI / auf den Server, nicht in den Client. Informelle Navigationsdaten dürfen persistiert werden, solange sie durch den nächsten Server-Poll überschreibbar bleiben.

Konsequenzen:

  • Server-Code bleibt client-agnostisch: ein neuer Client-Typ erfordert keinen neuen Endpoint.
  • Clients sind austauschbar und können unterschiedlich mächtig sein (Watch = 2 Endpoints, Browser ≈ 15, Handy dazwischen).
  • Review-Funktionen sind automatisch überall konsistent — ein nativer Client kann die Web-UI als WebView einbetten und ist damit sofort synchron mit dem Browser.

Komponenten

1. Clients (Erfassung + Review)

Alle Clients folgen dem Client-Prinzip: einheitliche Server-API, flüchtige lokale Datenhaltung, Rolle ergibt sich aus genutztem API-Teil.

Entwicklungs-Reihenfolge:

  1. Pixel Watch (primäres Entwicklungsziel, MVP-relevant) — siehe unten.
  2. Android-Handy — geplant direkt nach der Watch, teilt Core-Module.
  3. Linux / Windows / iOS — optional, frühestens nach Handy-Abschluss; Tech-Stack pro Plattform zu entscheiden.

Gemeinsame Code-Basis (Android-Clients):

Die Watch-App und die spätere Handy-App werden als Multi-Modul-Gradle-Projekt aufgesetzt, sodass UI-unabhängige Business-Logik von Anfang an in geteilten Modulen liegt. Diese Struktur ist nicht optional — sie wird bereits beim Bau der Watch-App eingehalten, damit die Handy-App später ohne Refactoring andocken kann.

doctate-android/
├── :core-domain    ← case_id, Modelle, State-Machine (reines Kotlin/JVM)
├── :core-audio     ← MediaRecorder-Wrapper, Bluetooth-Headset-Routing
├── :core-sync      ← Queue, WorkManager-Jobs, Retry-Logik
├── :core-http      ← API-Client, API-Key-Auth, Endpoints
├── :core-storage   ← Marker-Dateien, Room/DataStore, Lazy Cleanup
│
├── :app-wear       ← Watch-UI (Compose for Wear OS)
└── :app-mobile     ← Handy-UI (Compose Material 3) — später

UI-unabhängige Tests laufen auf der JVM (keine Emulator-Runtime). Importe von android.*/androidx.compose.* sind in :core-* strukturell verboten — Verstöße schlagen beim Kompilieren fehl.

1a. Pixel Watch App (Wear OS / Kotlin) — primäres Entwicklungsziel

Funktion:

  • Audioaufnahme direkt auf der Watch (MediaRecorder, AAC/m4a)
  • Optionale Aufnahme über gekoppeltes Bluetooth-Headset (automatisch bevorzugt)
  • case_id (UUIDv4) Generierung lokal auf der Watch: "Neu" erzeugt eine neue case_id + Marker-Datei (/recordings/cases/{case_id}.json), "Fortsetzen" verwendet die bestehende case_id des gewählten Falls
  • Ein Fall = 1n Aufnahmen, identifiziert durch case_id + Aufnahme-Zeitstempel
  • Aufnahmen persistent ins lokale Dateisystem (überlebt Neustarts, Akku leer)
  • Nach Aufnahme: Stop → direkt in Sync-Queue, Korrekturen per Folge-Diktat
  • Vollautomatischer Sync im Hintergrund (Foreground Service)
  • Kein Review, kein Playback, kein Archiv — konsequent nach Client-Prinzip
  • Kein eigener Code auf dem Phone nötig (Wear OS Network Proxy tunnelt transparent, wenn Watch keine eigene Connectivity hat)

Sync-Service (Foreground Service):

  • Läuft permanent im Hintergrund, überlebt Doze-Modus
  • Bei Netzwerk-Verfügbarkeit: alle ungesyncten Aufnahmen hochladen
  • Upload → Server-Bestätigung abwarten → bei "received" oder "gone": lokal löschen
  • Bei Fehler / kein ACK: exponentieller Backoff, automatischer Retry
  • Bei Watch-Neustart: Service startet automatisch, prüft Dateisystem auf ungesyncte Aufnahmen
  • Kein manuelles Eingreifen des Arztes nötig — es muss einfach funktionieren

Netzwerk-Verhalten:

Watch mit LTE/WiFi  → direkt an Server
Watch ohne LTE/WiFi → Wear OS Proxy → Phone → Server (transparent)
Kein Netzwerk       → Aufnahmen bleiben lokal, Sync bei Wiederherstellung

UI — Ein Screen pro Eintrag, zwei Zustände:

Jeder Listeneintrag füllt das gesamte Watch-Display. Navigation via Swipe hoch/runter. "Neu" immer ganz oben, darunter heutige Fälle chronologisch absteigend (neuester zuerst). Nur ein Button pro Eintrag.

"Neu" (ganz oben):              Recording (gleicher Screen):
┌─────────────────┐            ┌─────────────────┐
│  ⏱ 10:35        │            │  ⏱ 10:35        │
│                  │            │  ● REC  00:42    │
│  [● Neu]         │            │  [■ Stop]        │
│                  │            │                  │
│  ☁↑ 2            │            │  ☁↑ 2            │
└─────────────────┘            └─────────────────┘

    ↓ Swipe runter                 nach Stop:

┌─────────────────┐            ┌─────────────────┐
│  10:32           │            │  10:32           │
│  Kniegelenk...   │            │  ⏳               │ ← Oneliner noch nicht da
│  [▶ Fortsetzen]  │            │  [▶ Fortsetzen]  │
└─────────────────┘            └─────────────────┘

    ↓ Swipe runter

┌─────────────────┐
│  09:15           │
│  Hypertonie...   │
│  [▶ Fortsetzen]  │
└─────────────────┘

Screen-Verhalten nach Stop: Nach dem Stoppen einer Aufnahme bleibt der aktuelle Fall auf dem Display — der Screen wechselt vom Recording-Zustand direkt zu "Fortsetzen". Der Arzt kann sofort weiter diktieren, ohne zu scrollen. Für einen neuen Fall swiped er bewusst nach oben zu "Neu".

Oneliner-Polling: Solange der aktuell angezeigte Fall noch keinen Oneliner hat (), fragt die Watch alle paar Sekunden den Server ab (GET /api/oneliner/{case_id}). Sobald ein Oneliner empfangen wird, wird er in die Marker-Datei geschrieben und im Display angezeigt — das Polling für diesen Fall stoppt. Beim Weiterswischen zu einem anderen Fall ohne Oneliner beginnt das Polling für den neuen sichtbaren Fall.

  • 🎧 Headset-Indikator wenn Bluetooth-Headset aktiv
  • ☁↑ zeigt Anzahl ungesyncter Aufnahmen (dezent, verschwindet bei 0)
  • Default beim Öffnen: immer ganz oben ("Neu")
  • Stop → Aufnahme geht direkt in Sync-Queue, kurzes ✓-Feedback, Screen bleibt auf aktuellem Fall
  • Kein Verwerfen-Button — Korrekturen per Diktat ("Korrektur: ...")

Oneliner-Zustände bei Fällen:

  • Verfügbar → Uhrzeit + Oneliner (stabil, Polling beendet)
  • Lädt → Uhrzeit + (Polling aktiv)
  • Kein Server → nur Uhrzeit (Polling mit Backoff)
  • Keine Fälle heute → nur "Neu" sichtbar

Lokale Datenhaltung (persistent im Dateisystem):

Das Dateisystem ist die einzige Quelle der Wahrheit. Die Fallliste ergibt sich aus zwei Ordnern: cases/ (welche Fälle existieren + Oneliner) und unsynced/ (welche Aufnahmen noch auf Upload warten).

/recordings/
├── cases/
│   ├── {case_id}.json    ← Marker-Datei (JSON, bei Erstellung minimal, wächst bei Bedarf)
│   └── ...
└── unsynced/
    ├── {case_id}_{UTC-timestamp}.m4a    ← wartet auf Upload
    └── ...
  • Marker-Datei (cases/{case_id}.json): wird bei "Neu" mit minimalem JSON angelegt. Oneliner wird ergänzt, sobald er vom Server empfangen wird. Format ist erweiterbar für zukünftige Felder.
// Bei Erstellung ("Neu"):
{
  "created_at": "2026-04-06T09:32:00Z"
}

// Nach Oneliner-Empfang:
{
  "created_at": "2026-04-06T09:32:00Z",
  "oneliner": "Kniegelenk re., V.a. Meniskus"
}
  • Fallliste = alle .json-Dateien in cases/ deren created_at von heute (UTC) ist. Sortierung absteigend. oneliner fehlt oder null → nur Uhrzeit anzeigen. Vorhanden → Uhrzeit + Oneliner.
  • Sync-Indikator (☁↑) = Anzahl Dateien in unsynced/
  • Dateinamen-Format Aufnahmen: {case_id}_{yyyy-MM-ddTHH:mm:ssZ}.m4a (UTC)
  • UI zeigt Uhrzeiten in lokaler Zeitzone an

Lazy Cleanup (Watch): Beim App-Start / Scan: Marker-Dateien in cases/ deren created_at nicht von heute (UTC) ist, werden gelöscht — sie sind rein informativ für die UI und enthalten keine Audiodaten.

Audiodateien in unsynced/ werden nie durch die Lazy Cleanup gelöscht. Der Sync-Service ist der einzige Verantwortliche für das Löschen von Audiodateien — ausschließlich nach Server-Bestätigung (ACK mit received oder gone). So gehen keine Aufnahmen verloren, auch wenn die Watch tagelang offline war (z.B. Wochenende im Spind). Beim nächsten Netzwerkzugang synct der Foreground Service alle ausstehenden Aufnahmen nach.

Speicherplatz-Warnung (optional): Pixel Watch 2/3 hat 32 GB, eine Minute AAC/m4a ≈ 1 MB. Selbst 100 ungesyncte Aufnahmen sind unkritisch. Dennoch: bei >500 MB ungesyncten Daten dezenten Warnhinweis in der UI anzeigen (nicht löschen, nur informieren).


Upload-Paket (vom Sync-Service gesendet):

{
  "case_id": "uuid",
  "recorded_at": "2026-04-06T09:32:00Z",
  "audio": "..."
}

Server-Antwort (ACK — drei Zustände):

{
  "case_id": "uuid",
  "recorded_at": "2026-04-06T09:32:00Z",
  "status": "received | gone"
}
Status Bedeutung Watch-Verhalten
received Upload angenommen Lokale Datei löschen
gone Fall existiert nicht mehr (vom Arzt entfernt) Lokale Datei löschen
(kein ACK / Fehler) Server nicht erreichbar oder interner Fehler Retry mit exponentiellem Backoff

→ Sowohl received als auch gone führen zum Löschen der lokalen Datei. Die Watch muss nicht verstehen, warum der Fall weg ist — nur dass sie die Datei gefahrlos löschen kann. Das hält den Watch-Code einfach.


Oneliner-Abfrage (Polling, informativ):

GET /api/oneliner/{case_id}
→ 200: { "oneliner": "Hypertonie Grad 2..." }
→ 404: noch nicht verarbeitet (erstes Transkript noch ausstehend)
→ 503: Ollama nicht verfügbar
  • Oneliner wird beim ersten Transkript einmalig generiert und danach nur noch gelesen
  • Der Arzt kann den Oneliner beeinflussen, indem er im Diktat eine Bezeichnung nennt (z.B. „Bezeichnung: Kniegelenk")
  • Timeout: ~2 Sekunden
  • Polling: Solange der aktuell angezeigte Fall keinen Oneliner in seiner Marker-Datei hat, fragt die Watch alle paar Sekunden ab. Polling läuft nur für den sichtbaren Fall.
  • Bei 200: Oneliner in Marker-Datei /recordings/cases/{case_id}.json schreiben → Polling für diesen Fall beenden
  • Bei 404/503/Timeout → weiter pollen (Watch funktioniert normal, Marker-Datei bleibt ohne Oneliner, UI zeigt )
  • Keine Blockierung des Aufnahme-Workflows — Polling läuft unabhängig vom Recording

Sicherheit Clients (API-Key-Auth für /api/...):

  • API-Key pro Arzt im HTTPS-Header
  • Server mappt API-Key → Arzt-Identität (bestimmt Speicherpfad /data/{arzt}/)
  • Einmalig pro Client-Gerät konfiguriert (Watch und Handy verwenden denselben Key pro Arzt; verschiedene Geräte desselben Arztes teilen sich den Key)

1b. Android-Handy-App — sekundäres Erfassungsgerät (geplant)

Wird nach Abschluss der Watch-App gebaut, andockt an dieselben :core-*-Module. Funktional-Set in zwei Blöcken:

Erfassungs-Features (identisch zur Watch):

  • Audioaufnahme, case_id-Verwaltung, Marker-Dateien, Sync-Queue, ACK-Protokoll, Oneliner-Polling, exponentieller Backoff, Lazy Cleanup — nichts davon wird neu geschrieben, alles kommt aus :core-*.
  • UI ist eigenständig (Compose Material 3, Bottom-Nav, größeres Display), folgt aber demselben „flüchtiges Erfassungsgerät"-Ansatz.

Review-Features (nur Handy, nutzen bestehende Web-Endpoints):

  • Zugriff auf /web/cases, /web/cases/{case_id}, /web/cases/{case_id}/document, /web/audio/{user}/{case_id}/{filename} etc.
  • Keine neuen Server-APIs nötig — das Handy ist aus Server-Sicht ein weiterer Web-Client.
  • Erste Ausbaustufe kann als WebView auf /web/... umgesetzt werden (kein doppelter UI-Code, automatisch synchron zum Browser-UI); spätere Ausbaustufen dürfen selektiv Teile nativ ersetzen.

Invariante bleibt gewahrt: Das Handy zeigt mehr als die Watch, hält aber keinen lokalen Archiv-Cache — Review-Daten werden on-demand vom Server gerendert.

Data Layer Sync (optional, später): Wenn Watch und Handy gekoppelt sind (klassisches Wear-OS-Pairing), könnten beide Geräte die heutige Fallliste via DataClient (Wear OS Data Layer API) replizieren — nur Marker-Dateien, keine Audios. Das ist eine reine UX-Verbesserung (beide Geräte sehen denselben Fall), nicht MVP-relevant und wird erst nach Handy-App-Grundfunktion evaluiert.

1c. Desktop-Clients (Linux / Windows) — erster nativer Client

Ist-Stand: Der Linux-Desktop-Client ist gebaut und funktioniert (client-desktop/). Er ist der erste echte native Client überhaupt — wurde vor der Watch-App gebaut, weil der Nutzer noch keine Pixel-Watch-Hardware hat und ein realer Client für API-Ergonomie-Tests (Retry, Content-Types, Auth-Fehlerpfade) nötig war.

Features (MVP):

  • Config-Panel beim Erststart (Server-URL + API-Key, TOML unter ~/.config/doctate/client.toml)
  • Ein-Klick-Aufnahme („● Neu") + Stop-Button mit Live-Timer
  • Persistente Pending-Queue (~/.local/share/doctate/pending/, m4a + {stem}.meta.json-Sidecar)
  • Uploader mit exponentieller Retry (2/4/8/16/32 s, Cap 60 s, unbegrenzt für transiente Fehler, sofortiger Abbruch bei 400/401/403/413)
  • Startup-Recovery: Pending-Dir-Scan beim Launch → Crash-Residuen automatisch retried
  • State-Machine: NotConfiguredIdleRecordingFinalizingRecordingUploadingIdle | Error
  • Single-Instance-Lock (PID-Lock, verhindert konkurrierende Instanzen, die dieselbe Pending-Queue racen würden)
  • Fall-Liste + Oneliner-Poller im Client: zeigt eigene Fälle der letzten Stunden, pollt /api/oneliners per ETag (304 bei unverändertem FS-Fingerprint)
  • Snapshot-Cache-Persistenz (Poller-State überlebt Neustart, kein Flash-Fehlzustand beim Launch)
  • Pending-Upload-Indicator + Last-Failure-Anzeige im Footer-Status
  • Startup-Cleanup für stale Markers / Orphans (Pending-Dateien ohne Sidecar, verwaiste meta.json)
  • Magic-Link-Login: „Im Browser öffnen"-Button tauscht den API-Key gegen einen Kurz-Token (POST /api/auth/magic-link) und öffnet den System-Browser auf /web/magic?token=…. Damit landet der Arzt ohne Passworteingabe in seiner Web-Review.

Tech-Stack (Linux): eframe/egui (Immediate-Mode-UI, natives Fenster, keine WebView) + tokio (Background-Runtime für Recorder + Uploader + Poller) + reqwest (Multipart-Upload + JSON-GET, rustls) + ffmpeg-Subprozess (m4a/AAC direkt; Graceful-Stop via SIGINT über libc::kill). Die gesamte Client-Business-Logik (Case-Store, Poller, Upload-Queue, Cleanup, Server-Sync) lebt in der Shared-Lib doctate-client-coreclient-desktop ist nur noch UI-Schale + OS-spezifischer Recorder.

Windows-Desktop: geplant „bald" — derselbe client-desktop-Crate mit #[cfg(target_os = "windows")]-Gates, vor allem für audio_input_args (dshow statt PulseAudio) und Graceful-Stop (q-Stdin als Best-Effort, weil tokio::process::Child keine einfache SIGINT-Entsprechung auf Windows bietet).

iOS: SwiftUI, Ausblick — nicht in Arbeit. Geplante Arbeitsaufteilung: Swift nur für UI + OS-Recording, alles andere konsumiert doctate-client-core über eine C-ABI-/UniFFI-Bindings-Schicht.

Zurückgestellt (nicht MVP): System-Tray-Icon (Design war fertig, dann tray-icon-Crate verworfen wegen System-Dep-Schwergewicht mit libxdo/gtk3/libappindicator; ksni als pure-Rust-dbus-Alternative für später dokumentiert), Autostart, Keyboard-Shortcuts.


2. Server (Axum / Rust — Docker Container auf Ubuntu Server)

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 (Ist-Stand):

# Erfassungs-API (API-Key-Auth; Watch, Handy, künftige Erfassungs-Clients)
POST /api/upload                              → Aufnahme empfangen, ACK zurück
GET  /api/health                              → Liveness-Probe für Deployments
GET  /api/debug/whoami                        → API-Key → slug (Entwicklungshilfe)
GET  /api/oneliners                           → kollektive Fall-/Oneliner-Liste (ETag-basiert,
                                                 Fensterweite via users.toml->window_hours
                                                 pro User, Default 72 h; Client-Polling)

# API: Magic-Link (API-Key → Browser-Session ohne Passwort)
POST /api/auth/magic-link                     → Einmal-Token ausstellen (X-API-Key, TTL 60 s,
                                                 optional return_to unter /web/)
GET  /web/magic?token=...                     → Token konsumieren, Session-Cookie setzen,
                                                 303-Redirect auf return_to

# 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-Übersicht (case_page: Oneliner, Aktionen,
                                                 gerendertes Dokument inline)
GET  /web/cases/{case_id}/recordings          → Einzel-Transkripte + Audio-Player (case_recordings)
POST /web/cases/{case_id}/analyze             → Analyse starten / neu anstoßen
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;
                                                 HTTP-Range-Support, `Accept-Ranges: bytes`,
                                                 206 Partial Content für HTML5-Seeking)
GET  /web/events                              → SSE-Stream für Live-UI-Updates (Cookie-Auth;
                                                 Non-Admins: eigene Slug-gefilterte Events;
                                                 Admins: alle Slugs; 15 s Keep-Alive)

Template-Split (Ist-Stand seit 2026-04-19): Die bisherigen Templates case_detail.html, document.html, cases.html sind entfernt. Stattdessen zwei dezidierte Seiten: case_page.html (Übersicht + Aktionen + gerendertes Dokument) und case_recordings.html (einzelne Transkripte + Audio). Das Admin-Log unter GET /web/ existiert damit in der Routing-Tabelle nicht mehr als eigenständige Seite — die Admin-Sichtbarkeit wird über den is_admin-Flag in den ViewModels und Templates an den regulären /web/cases-Views aufgehängt.

Geplant, noch nicht implementiert: Preset- und Undo-Endpoints für Dokument-Versionen. Siehe Phase 4 weiter unten.

Kein {arzt} in URLs (IDOR-Prävention): Die Arzt-Identität wird ausschließlich aus dem Session-Cookie abgeleitet, nie aus der URL. Ein Axum-Extractor (AuthenticatedArzt) liest das Session-Token, schlägt den zugehörigen Arzt nach und gibt ihn als typisierte Struct zurück. Alle /web/-Handler erhalten den Arzt nur über diesen Extractor — der Dateisystempfad /data/{arzt}/ wird serverseitig aus der Session konstruiert. Dadurch kann ein eingeloggter Arzt prinzipbedingt nicht auf Daten eines anderen Arztes zugreifen, selbst wenn er URLs manuell ändert. case_id wird zusätzlich als UUIDv4 validiert (uuid::Uuid::parse_str), um Path-Traversal über manipulierte IDs auszuschließen.

Fall-States:

┌────────────┐     sofort      ┌───────────────┐   auto-trigger    ┌─────────────┐
│  Empfangen │ ──────────────→ │ Transkribiert │ ──────────────→  │ Ausgewertet │
│  (queued)  │  Transkription  │  (einsehbar)  │  oder manuell    │ (Dokument)  │
└────────────┘                 └───────────────┘  "Analysieren"   └─────────────┘
  • Empfangen: Mindestens eine Aufnahme ist noch in der Transkriptions-Queue oder wird gerade verarbeitet
  • Transkribiert: Alle Aufnahmen dieses Falls sind transkribiert. Arzt kann jedes Transkript einzeln einsehen
  • Ausgewertet: LLM hat aus allen Transkripten ein Dokument generiert

Ein Fall wechselt erst zu "Transkribiert", wenn alle zugehörigen Aufnahmen transkribiert sind. Solange eine Aufnahme noch in der Queue ist, bleibt der Fall im State "Empfangen".

"Analysieren" wird automatisch ausgelöst (analyze::auto_trigger), sobald ein Fall im State "Transkribiert" ist und noch kein document.md existiert (oder das Dokument älter ist als die jüngste Aufnahme). Der Trigger läuft auf jedem /web/cases- und /web/cases/{id}-Handler-Aufruf; SSE-getriggerte Reloads stellen den Puls, ein Background-Timer ist nicht nötig. Der manuelle „Analysieren"-Button bleibt für erzwungenes Re-Analyze (z.B. nach Prompt-Änderung) erhalten und ist nur im State "Transkribiert" sowie bei konfiguriertem LLM-Provider sichtbar.


Pipeline nach Aufnahme-Eingang (Ist-Stand):

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 Fall mit bereits vorhandenem document.md eintrifft, wird sie angenommen, gespeichert und transkribiert. Sobald das neue Transkript fertig ist, erkennt der Auto-Trigger, dass das Dokument älter ist als die jüngste Aufnahme, löscht document.md und reiht den Fall neu ein — der Arzt sieht nach dem SSE-Reload das aktualisierte Dokument. Der geplante UI-Hinweis („⚠ 1 neue Aufnahme seit Abschluss") ist damit in der Sache bereits umgesetzt, als sichtbare Banner-Variante aber noch offen. Der manuelle „Neu analysieren"-Button bleibt für erzwungene Re-Runs (z.B. nach Prompt-Änderung).

Verspätete Uploads für gelöschte Fälle (Ist-Stand): Trifft ein Upload für einen Fall mit .deleted-Marker ein, wird der Marker entfernt und die Aufnahme normal als Nachtrag behandelt. Der ursprünglich geplante "gone"-ACK-Status ist nicht implementiert — der Upload-Handler antwortet immer mit "received". Grund: Sonst würde der allererste Upload einer neuen case_id unnötig abgelehnt. Details in der Abweichungen-Sektion.

Gazetteer (Post-AI-Terminologie-Normalisierung):

Whisper-Output  ──┐
Oneliner (Ollama) ├──→ gazetteer::replace() ──→ Persistenz (.transcript.txt, oneliner.json, document.md)
Analyse-LLM ──────┘

Der Gazetteer ist ein deterministischer Filter, der jede KI-Ausgabe passiert, bevor sie aufs Dateisystem geschrieben wird. Er korrigiert zwei typische Fehlerklassen:

  1. Whisper-Typos an Fachtermini (z.B. ZerebrumCerebrum, PantoprasolPantoprazol).
  2. LLM-Drift zurück zu englischem/anglisiertem Wortlaut (AmiodaroneAmiodaron), 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 (BeinBehn).
  • 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<AtomicBool> + 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<HashMap<CaseId, CaseState>> — 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 ~24 s. Typischer Ablauf bei einem Burst:

00:00  5 Aufnahmen in Queue
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

Mehrere Ärzte teilen sich die Queue fair (FIFO).

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:

  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.

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.
  • EmptyOllamaError::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.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.

Pipeline bei „Fall abschließen" (Ist-Stand):

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 analysieren" (Ist-Stand):

1. Handler löscht bestehendes `document.md`
2. Pipeline wie oben neu ausführen

Keine Versionierung. Eine neue Analyse überschreibt die alte in-place.


Auto-Trigger für LLM-Analyse (analyze::auto_trigger, Ist-Stand):

Die LLM-Analyse wird opportunistisch gestartet, sobald ein Fall transkriptionsvollständig ist — ohne Nutzeraktion. Ausgelöst wird der Trigger in den /web/cases- und /web/cases/{id}-Handlern; jeder SSE-getriggerte Reload wird damit zum natürlichen Polling-Puls, ein Background-Timer ist nicht nötig.

evaluate_case(case_dir) → AutoDecision::{Enqueue, Skip(reason)}
  Skip-Gründe (reine FS-Stats, keine Mutation, keine Channels):
    • keine Aufnahmen
    • nicht alle Aufnahmen transkribiert
    • analysis_input.json vorhanden → Job schon in Queue / in-flight
    • .analysis_failed.json vorhanden mit identischer last_recording_mtime
    • document.md aktueller als jüngste Aufnahme → nichts zu tun

try_enqueue(case_dir, …):
  1. config.llm_configured() ? sonst false
  2. evaluate_case → Enqueue ? sonst false
  3. build_analysis_input() aus allen Transkripten
  4. (re-analyse) document.md löschen, damit Worker-Guard nicht short-circuitet
  5. analysis_input.json überschreiben
  6. Job an AnalyzeSender; CaseEventKind::AnalysisQueued emittieren

Retry-Gate .analysis_failed.json: Bei LLM-Fehler schreibt der Worker einen Marker mit {last_recording_mtime, reason, failed_at}. evaluate_case überspringt Fälle mit passender last_recording_mtime — kein Retry-Loop, bis das Input-Signal (neue Aufnahme oder Reset) sich ändert. Der Worker löscht den Marker bei erfolgreichem Analyse-Abschluss.

Re-Analyse-Pfad: Trifft eine neue Aufnahme nach Fallabschluss ein, wird das Transkript geschrieben → Oneliner regeneriert → beim nächsten UI-Render erkennt evaluate_case das veraltete document.md, try_enqueue löscht es und reiht den Fall neu ein. Aus Arztsicht „passiert das von selbst".

try_enqueue_all_for_user(user_root, …) iteriert über alle UUID-Verzeichnisse und darf auf jedem Fall-Listen-Render laufen — Skips sind billig (nur stat).


Geplant für Phase 4 (noch nicht implementiert):

  • Preset-basiertes „Neu generieren": Preset-Prompt + optionaler Freitext kombinieren, LLM erneut aufrufen, vorige Version erhalten.
  • 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::spawn in main.rs):

  • 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.
  • 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.

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 (Ist-Stand):

Situation Verhalten
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, Vanilla-Forms):

Serverseitig gerendertes HTML + Formular-Submits. Live-Updates via SSE sind implementiert: der Client hält eine EventSource-Verbindung zu GET /web/events, filtert eingehende CaseEvents 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):

Meine Fälle — dr_mueller
────────────────────────────────────────────
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]

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-Übersicht (case_page, Ist-Stand seit 2026-04-19):

Fall 09:32 — „Kniegelenk, re."               ← Oneliner als Titel
────────────────────────────────────────────
Dokument (Markdown → HTML, single version)   ← inline gerendert, wenn vorhanden
... Inhalt ...

[Analysieren / Neu analysieren]   [Löschen]  [Reset]*   [Aufnahmen ansehen →]
                                              * admin-only

Einzel-Transkripte (case_recordings):

Fall 09:32 — 3 Aufnahmen
────────────────────────────────────────────
09:32  „Patient klagt über Schmerzen ..."    ← Transkript 1 (Audio-Link)
09:45  „Röntgenbild zeigt ..."               ← Transkript 2
10:02  „Diagnose: ..."                       ← Transkript 3

[← Zurück zur Übersicht]

Admin-spezifische Aktionen: Reset und die Bulk-Aktionen sind serverseitig auf role = "admin" eingegrenzt (Handler prüfen AuthenticatedUser::is_admin()). Die Templates erhalten is_admin: bool aus dem ViewModel und blenden Nicht-Admin-User die entsprechenden Buttons aus (Defense-in-Depth).

„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):

  • Nachtrag-Hinweis-Banner („⚠ 1 neue Aufnahme seit Abschluss — neu analysieren?"): funktional durch den Auto-Trigger abgedeckt (Doku oben), als sichtbarer Banner aber noch offen.
  • Preset-System + Undo für Dokument-Versionen (Arztbrief / Kürzer / Formeller / Diagnosen / Medikamente).
  • CSRF-Tokens in allen POST-Formularen.
  • Service-Ausfall-Warnung im UI (> 30 Min. Ausfall).

3. Filesystem-Struktur

/data/
└── {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}.duration.txt        ← ffprobe-Dauer in Sekunden (UI-Rendering; lazy-backfill)
        ├── {UTC-timestamp}.transcript.txt      ← nach erfolgreicher Transkription (Gazetteer-normalisiert)
        ├── 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)
        └── .deleted                            ← Soft-Delete-Marker (JSON mit Batch-UUID + deleted_at)

/var/log/recorder/
└── recorder.{datum}.log

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):

{ "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"
{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"
.deleted vorhanden Fall ist soft-deleted (per Undo-Batch wiederherstellbar)

4. Konfiguration

System-Konfiguration (.env):

# Server (required)
SERVER_PORT=3000
DATA_PATH=/data
USERS_FILE=users.toml

# Logging
LOG_LEVEL=info
LOG_PATH=/var/log/recorder
LOG_MAX_DAYS=90

# Retention (noch nicht durchgesetzt, siehe Phase 2-Checkliste)
RETENTION_AUDIO_DAYS=30
RETENTION_TRANSCRIPT_DAYS=30
RETENTION_DOCUMENT_DAYS=0        # 0 = permanent

# faster-whisper (eigener FastAPI-Wrapper, siehe Phase 2b.5)
WHISPER_URL=http://localhost:9001
WHISPER_TIMEOUT_SECONDS=120

# Ollama (Oneliner)
OLLAMA_URL=http://localhost:11434
OLLAMA_MODEL=gemma3:4b
OLLAMA_KEEP_ALIVE=0

# 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, 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).

[[user]]
slug = "dr_mueller"
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 = "..."
web_password = "$2b$12$..."
role = "doctor"

Der Transkriptions-Worker reicht [user.whisper] pro Upload an den Whisper-Service durch. Siehe Abweichungen-Eintrag „Hotwords".


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):

[[preset]]
label = "Arztbrief"
prompt = "Formatiere das Dokument als formellen Arztbrief mit Anrede und Grußformel."

[[preset]]
label = "Kürzer fassen"
prompt = "Fasse das Dokument kürzer zusammen ohne inhaltliche Verluste."

# ... weitere Presets

Ziel: neue Presets ohne Code-Änderung hinzufügbar.


6. Sicherheit

Verbindung Methode
Watch → nginx HTTPS TLS 1.3 + API-Key im Header
Browser → nginx HTTPS TLS 1.3 + Session-Cookie (Secure, HttpOnly, SameSite=Strict)
nginx → Axum HTTP lokal (Unraid → Ubuntu, internes Netz)
Axum → faster-whisper HTTP localhost (gleicher Server)
Axum → Ollama HTTP localhost (gleicher Server)
Axum → Ionos HTTPS + API-Key
TLS-Zertifikat Let's Encrypt (certbot, auto-renewal)
Fail2Ban Zu viele Fehlversuche → IP geblockt
Rate Limiting Max 10 Requests/Minute pro IP
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 (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

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  Transcription worker started
INFO  Analyze worker started                 vocab_entries=187
INFO  Transcribing                           audio=/data/dr_mueller/<uuid>/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.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=...
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

Schicht Technologie
Android-Clients (Watch primär, Handy geplant) Kotlin, Multi-Modul-Gradle. UI: Compose for Wear OS (:app-wear) bzw. Jetpack Compose Material 3 (:app-mobile). Geteilte Core-Module (:core-*) sind UI-unabhängig und JVM-testbar.
Desktop-Client (Linux gebaut, Windows geplant) Rust, Cargo-Workspace-Member client-desktop. eframe/egui (Immediate-Mode-UI), tokio (async I/O), reqwest (Multipart-Upload + Polling), ffmpeg-Subprozess (m4a-Recording, SIGINT-Stop). Teilt doctate-common (API-Typen) und doctate-client-core (Case-Store, Sync, Poller, Cleanup) mit Server und künftigen nativen Clients.
iOS-Gerät (optional, später) SwiftUI oder Compose Multiplatform. Entscheidung nach Watch/Handy-Erfahrung.
Server Rust (edition 2024), Axum 0.8, askama, reqwest, tracing, strsim + spellbook (Gazetteer); SSE-Live-Updates via tokio::sync::broadcast + axum::response::sse produktiv
STT faster-whisper (CTranslate2, large-v3, eigener Docker Container mit HTTP-API)
Preprocessing Ollama, Gemma 3 4B (keep_alive: 0 für VRAM-Freigabe nach Request)
Dokument-LLM Ionos (OpenAI-API-kompatibel, Temperatur 0)
Deployment Docker auf Ubuntu Server (NVIDIA Container Toolkit für faster-whisper + Ollama), nginx reverse proxy auf Unraid
TLS Let's Encrypt / certbot

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. Edition ist 2024, was axum 0.8, tower-http 0.6 und axum-extra 0.12 voraussetzt (native async fn in Traits).

[package]
edition = "2024"

[dependencies]
# 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"] }
tokio-stream = { version = "0.1", features = ["sync"] }   # BroadcastStream → SSE-Endpoint
futures-util = "0.3"                                      # Stream-Kombinatoren für SSE
reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "rustls-tls"] }

# Templates + Rendering
askama = "0.12"
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"
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.

Cargo-Workspace-Layout

Das Repository ist ein Cargo-Workspace mit vier Rust-Crates:

doctate/
├── Cargo.toml              (Workspace-Root, edition 2024, resolver 3)
├── server/                 (doctate-server bin; Axum)
├── doctate-common/         (doctate-common lib; API-Typen + Konstanten, runtime-agnostisch)
├── doctate-client-core/    (doctate-client-core lib; Client-Business-Logik: Case-Store,
│                            Server-Sync, Upload-Queue, Oneliner-Poller, Snapshot-Cache,
│                            Startup-/Pending-Cleanup, Footer-Status, Config)
├── client-desktop/         (doctate-desktop bin; eframe + ffmpeg)
└── watch/wearos/           (Kotlin, kein Rust-Member)

doctate-common ist die Quelle der API-Wahrheit zwischen Server und Clients:

  • ack::AckResponse, ack::AckStatus — ACK-Typen für POST /api/upload
  • oneliners::OnelinersResponse, OnelinerEntry — Wire-Format für GET /api/oneliners
  • timestamp::now_rfc3339(), filename_stem_to_recorded_at(), recorded_at_to_filename_stem() — bijektive Umwandlung UTC-RFC3339 ↔ Filesystem-safe-Name (Colon ↔ Hyphen im Zeit-Teil)
  • constants::API_KEY_HEADER (= "X-API-Key"), UPLOAD_PATH, FIELD_CASE_ID, FIELD_RECORDED_AT, FIELD_AUDIO, CONTENT_TYPE_AUDIO_MP4

Deps von doctate-common sind minimal: nur serde, uuid, timekein tokio, reqwest, axum. Dadurch bleibt sie runtime-agnostisch und kompiliert auch in WASM-Kontexten, falls je ein Browser-Client dazukommt.

doctate-client-core ist die neue Lib (seit 2026-04-18) für UI-freie Client-Logik:

  • case_store — lokaler Fall-Index mit Unterstützung für Soft-Deletions (Delete-Watermark)
  • server_sync — ETag-basiertes Polling gegen /api/oneliners, last_failure-Tracking
  • upload — persistente Pending-Queue mit Retry-Ladder und Sidecar-Metadaten
  • snapshot_cache — serialisierter Poller-Stand auf Disk, damit der Launch keinen Flash-Fehlzustand zeigt
  • startup + pending_cleanup — Konsistenz-Scans beim App-Start (stale Markers / Orphans)
  • footer_status — konsolidierter UI-Status (Server-Erreichbarkeit, pending-count, last_error)
  • config — geteilte TOML-Schemas für Client-Konfiguration

Abhängig von doctate-common + tokio + reqwest + serde. Kein eframe / winit — dadurch kann die Lib in einem iOS-SwiftUI-Projekt (über UniFFI-Bindings) oder einer möglichen Handy-App genauso konsumiert werden wie vom Linux-Desktop.

Rust Crates (Client-Desktop)

[dependencies]
doctate-common = { path = "../doctate-common" }     # Wire-Typen + Konstanten
doctate-client-core = { path = "../doctate-client-core" }  # Case-Store, Server-Sync, Upload-Queue, Poller, Cleanup
eframe = "0.28"                                     # egui + winit
reqwest = { workspace = true }
tokio = { workspace = true }
time = { workspace = true }
serde, serde_json, toml, uuid, tracing              # workspace-geerbt
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
directories = "5"                                   # OS-konforme Config-/Data-Pfade
thiserror = "1"
webbrowser = "1"                                    # Magic-Link: System-Browser öffnen
which = "6"                                         # ffmpeg-PATH-Check

[target.'cfg(unix)'.dependencies]
libc = "0.2"                                        # SIGINT via libc::kill

[dev-dependencies]
tempfile = "3"
tokio = { workspace = true, features = ["test-util"] }   # für start_paused in Retry-Tests
wiremock = "0.6"

Bewusst NICHT verwendet:

  • ffmpeg-next/libav-sys: Build-Komplexität (system-ffmpeg-Headers) + GPL/LGPL-Lizenzfragen. Subprozess-Aufruf hat keine Linkage-Probleme.
  • cpal + fdk-aac-sys + mp4: wäre ~500 LOC Reimplementierung von ffmpeg für marginalen Benefit.
  • tray-icon: zieht libxdo/gtk3/libappindicator als System-Deps. Für MVP zu viel Friktion; ksni als Pure-Rust-dbus-Alternative dokumentiert für später.

Hardware

Gerät Rolle
Pixel Watch 2 oder 3 (LTE empfohlen) Primäres Erfassungsgerät (Aufnahme + Upload + Oneliner-Polling)
Bluetooth-Headset (optional) Bessere Aufnahmequalität
Android Phone Sekundäres Erfassungsgerät (eigene App, geplant — Erfassung + Review) und Wear OS Companion. Für LTE-lose Watches bleibt der Wear-OS-Network-Proxy als Framework-Mechanismus erhalten — kein eigener Code auf der Companion-Ebene nötig.
Linux-Desktop (Arzt-PC, CachyOS/Arch) Erster nativer Client — Aufnahme + Upload. Tray-los, Fenster bleibt sichtbar.
Windows-Desktop Geplant „bald"; gleicher Crate via #[cfg(target_os = "windows")]-Gates
iOS-Gerät (optional, später) Weitere Client-Plattform — spricht dieselbe Server-API wie alle anderen
Unraid Server nginx reverse proxy, TLS-Terminierung
Ubuntu Server (RTX 3060, 12 GB VRAM) Docker: Axum (kein GPU), faster-whisper (GPU), Ollama (GPU)

Entwicklungsreihenfolge

Phase 1 — Server-Grundgerüst

  • Axum Setup, Routing, Authentifizierung (API-Key → User-Mapping via users.toml)
  • Filesystem-Struktur anlegen
  • .env Konfiguration (dotenvy)
  • Logging (tracing + tracing-appender)
  • Upload-Endpunkt mit ACK-Response
  • Docker Container für Axum auf Ubuntu Server (kein GPU nötig)
  • faster-whisper Container mit HTTP-API (NVIDIA Container Toolkit) — jetzt eigener whisper/-Service, siehe Phase 2b.5
  • Ollama einrichten, Modell gepullt (gemma4:latest statt gemma3:4b, siehe Abweichungen)
  • nginx auf Unraid + Let's Encrypt + Security Headers (CSP, X-Frame-Options, X-Content-Type-Options: nosniff, Referrer-Policy, Strict-Transport-Security/HSTS)
  • HTTP-Compression (gzip/brotli): Policy-Entscheidung nginx vs. tower-http::CompressionLayer (Axum) — bei direkter Axum-Exposition im Dev-Setup muss die Compression dort laufen, hinter nginx ist gzip on meist einfacher. Vorsicht bei SSE: text/event-stream darf nicht komprimiert werden (buffering bricht Live-Updates)
  • Axum-seitige Security-Header als Defense-in-Depth (tower-http SetResponseHeaderLayer): X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Content-Security-Policy, Referrer-Policy: no-referrer, Strict-Transport-Security (nur wenn HTTPS garantiert). Soll greifen, auch wenn nginx wegfällt oder im Dev-Mode direkt auf Axum zugegriffen wird. Magic-Link-Route setzt Referrer-Policy: no-referrer bereits — Layer zentralisiert das

Phase 2 — Transkriptions-Pipeline

  • Transkriptions-Queue (tokio mpsc channel)
  • [~] 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
  • faster-whisper HTTP-Client (reqwest, POST /asr, multipart Audio)
  • Ollama HTTP-Client (reqwest, POST /api/chat) — keep_alive=300 statt 0, siehe Abweichungen
  • Oneliner-Generierung (einmalig, erst geschrieben wenn ein sinnvoller Oneliner vorliegt; sonst Retry beim nächsten Transkript)
  • Queue-Recovery bei Serverstart (Filesystem-Scan)
  • faster-whisper Health-Check + Retry
  • Ollama Health-Check + Retry
  • Lazy Cleanup bei Falllisten-Zugriff (zum Entfernen markierte Fälle vom Vortag löschen)
  • Retention-Prüfung bei Zugriff (Audio/Transkripte nach RETENTION_*_DAYS)
  • Per-User Whisper-Settings in users.toml ([user.whisper] mit language, hotwords, initial_prompt) — Worker reicht sie pro Upload an den whisper/-Service durch. Hotwords sind technisch verfügbar, werden aber nicht als Feature angeboten (siehe Abweichungen).

Phase 2b.5 — Eigener faster-whisper-Service (whisper/)

  • FastAPI-Wrapper um faster-whisper mit fest verdrahteten Anti-Halluzinations-Params (condition_on_previous_text=False, temperature=0.0, vad_filter=True)
  • Endpoints: POST /asr (txt/json), GET /health, GET /info
  • Modell large-v3-turbo via WHISPER_MODEL umschaltbar, Modelle persistent in Docker-Volume
  • Hotwords-Support (faster-whisper 1.2.1) als Form-Feld durchgereicht
  • Dockerfile (CUDA 12.1 + cuDNN), docker-compose.yml, deploy.sh (rsync + /health-Polling)
  • README mit Begründung (Halluzinations-Befund), Build- und Deploy-Anleitung
  • Deployed auf minerva:9001, WHISPER_URL in server/.env umgestellt

Phase 3 — Fallverwaltung

  • Drei States: Empfangen → Transkribiert → Ausgewertet — rein aus FS abgeleitet (compute_flags in user_web.rs, kein State-Sidecar)
  • State-Übergang: erst "Transkribiert" wenn alle .m4a ein passendes .transcript.txt haben
  • "Analysieren" nur möglich, wenn LLM konfiguriert und Fall transkribiert — handle_analyze_case in routes/case_actions.rs
  • Aufnahmen nach case_id zusammenführen — analysis_input.json (single, nicht versioniert) enthält alle Transkripte
  • Chronologische Sortierung nach UTC-Zeitstempel — lexikographische Filename-Sortierung
  • Externes LLM → Dokument generieren — analyze/-Modul, OpenAI-kompatibel (llm.rs)
  • Fall soft-löschen — .deleted-Marker (JSON mit Batch-UUID), Undo letzter Batch via POST /web/cases/undo-delete
  • 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)
  • Bulk-Aktionen (analyze/delete auf mehrere Fälle gleichzeitig) — POST /web/cases/bulk
  • Reset-Endpoint — löscht Transkripte/Analyse/Document, re-enqueued alle .m4a (inkl. .m4a.failed → zurück auf .m4a)
  • Upload verspätet für gelöschten Fall → .deleted-Marker entfernen, Nachtrag normal behandeln
  • Auto-Trigger LLM-Analyse (analyze::auto_trigger) — Handler-getriggert auf /web/cases[...], Retry-Gate via .analysis_failed.json mit last_recording_mtime-Signatur, Re-Analyse bei veraltetem document.md
  • Concurrency-Schutz: optional Pending-Zähler / Lock — aktuell genügt sequenzieller Worker + WorkerBusy-Flag
  • Upload-Deduplizierung (gleiche case_id + Timestamp → ignorieren)
  • Nachtrag-Erkennung im UI (Transkript neuer als document.md → Hinweis im Fall-Detail)
  • 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

  • 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<HashMap<Token, Session>>, Ablauf nach 8 h
  • AuthenticatedWebUser-Extractor: Session-Token → User auflösen, bei ungültiger/abgelaufener Session → Redirect /web/login
  • Login-Seite (GET /web/login, POST /web/login), Logout (POST /web/logout)
  • 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.
  • 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).
  • Übersicht für Arzt: zwei Sektionen (Offen / Abgeschlossen), plus "Zuletzt gelöscht" mit Undo-Batch
  • 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.
  • Einzel-Transkripte: GET /web/cases/{case_id}/recordings rendert case_recordings.html (ein Eintrag pro Aufnahme, Audio-Link pro Transkript).
  • Audio-Streaming: GET /web/audio/{user}/{case_id}/{filename} (Cookie-Auth, Arzt eigene Dateien oder Admin) — HTTP-Range-Requests, Accept-Ranges: bytes, 206 Partial Content, Duration-Sidecar {ts}.duration.txt für HTML5-Player mit Seeking
  • Fall analysieren — Button in der Fall-Übersicht
  • Bulk-Aktionen (alle markierten analysieren / löschen) über POST /web/cases/bulkadmin-only (AuthenticatedUser::is_admin() auf role == "admin").
  • 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) — events-Modul + routes::events, 15 s Keep-Alive, Non-Admins slug-gefiltert, Admins ungefiltert, Lagged-Recovery durch Reload
  • Vanilla-JS EventSource-Client — debounced location.reload() in my_cases.html, case_page.html, case_recordings.html
  • [~] Nachtrag-Hinweis bei Aufnahmen nach Abschluss — funktional durch Auto-Trigger abgedeckt (re-analysiert automatisch), sichtbarer „⚠ N neue Aufnahmen"-Banner noch offen
  • Preset-basiertes „Neu generieren" (Preset + Freitext, Freitext max. 500 Zeichen) — hängt an Dokument-Versionierung
  • Undo-Button für Dokument-Versionen — hängt an Versionierung
  • Service-Ausfall-Warnung (faster-whisper/Ollama/LLM > 30 Min.)
  • CSRF-Token pro Session (Hidden Field in allen POST-Formularen, serverseitige Validierung)

Admin-Features (integriert in die Arzt-UI)

  • hash-password CLI (cargo run --bin hash-password) — erzeugt bcrypt-Hashes für users.toml, mit toml_edit-Schreibzugriff ohne Kommentarverlust.
  • Admin-Gate auf destruktiven Aktionen: POST /web/cases/{id}/reset und POST /web/cases/bulk prüfen AuthenticatedUser::is_admin() (role == "admin"). Templates erhalten is_admin: bool aus dem ViewModel und blenden die entsprechenden Buttons für Nicht-Admins aus (Defense-in-Depth).
  • [~] Das frühere separate Admin-Log (GET /web/ als flache Cross-User-Liste) ist mit dem Case-Pages-Refactor (2026-04-19) entfernt worden. Admin-übergreifende Sichten sind aktuell nicht implementiert; bei Bedarf als Phase-4-TODO wieder einziehen.

Phase 5 — Clients (Erfassung + Review)

Reihenfolge: 5a (gemeinsame Code-Basis) → 5b (Watch, primäres Entwicklungsziel) → 5c (Handy) → 5d (optional: Data Layer Sync) → 5e (Ausblick: weitere Plattformen).

Hinweis: Ein früher Proof-of-Concept (minimale Aufnahme + Upload auf echter Pixel Watch) sollte parallel zu Phase 23 stattfinden, um Wear-OS-spezifische Einschränkungen (Doze-Mode, Foreground-Service-Limits, Battery-Optimization) frühzeitig aufzudecken.

Stand-in, solange keine Hardware: scripts/dictate.sh simuliert den Client-Flow via ffmpeg-PulseAudio-Aufnahme + Upload gegen den laufenden Server (Modi: neuer Fall / aktuellen Fall fortsetzen / refresh). State in /tmp/doctate-current-case. So lassen sich Transkription und Oneliner ohne native Clients testen.

5a — Gemeinsame Code-Basis (vor jeder UI-Arbeit)

  • Gradle-Multi-Modul-Projekt aufsetzen (:core-domain, :core-audio, :core-sync, :core-http, :core-storage, :app-wear, :app-mobile als Platzhalter)
  • :core-*-Module strikt UI-frei halten (kein Import von android.*/androidx.compose.* erlaubt — Build schlägt sonst fehl)
  • JVM-Unit-Tests für :core-domain und :core-sync (case_id-Generierung, ACK-Zustandsautomat, Backoff-Kurve)
  • API-Client (:core-http): Endpoints typisiert, API-Key im Header, Reqwest-Äquivalent (Ktor oder OkHttp)

5b — Pixel Watch App (primäres Entwicklungsziel)

  • Wear OS Projekt in Android Studio (:app-wear)
  • Jetpack Compose UI (ein Screen pro Eintrag, fullscreen, Swipe-Navigation)
  • Vertikale Fallliste (Neu ganz oben, heutige Fälle darunter)
  • Nach Stop: Screen bleibt auf aktuellem Fall (→ "Fortsetzen" direkt sichtbar)
  • MediaRecorder-Integration via :core-audio
  • Bluetooth-Headset Erkennung + Indikator
  • case_id (UUIDv4) Generierung: Neu → neue case_id, Fortsetzen → bestehende
  • Persistente Speicherung im lokalen Dateisystem (UTC-Timestamps in Dateinamen)
  • Marker-Datei pro Fall (/recordings/cases/{case_id}.json): minimales JSON bei Erstellung, Oneliner ergänzen nach Empfang
  • Fallliste aus Marker-Dateien ableiten (created_at von heute + optionaler Oneliner)
  • Lazy Cleanup: nur Marker-Dateien (cases/) von gestrigen/älteren UTC-Tagen beim Scan löschen — Audiodateien in unsynced/ werden nie durch Cleanup gelöscht
  • Foreground Sync Service (automatischer Upload) — einziger Verantwortlicher für Audio-Löschung (nur nach ACK)
  • ACK-Protokoll: "received" → lokal löschen, "gone" → lokal löschen, kein ACK → Retry
  • Exponentieller Backoff bei Fehlern
  • Doze-Modus-Kompatibilität (Netzwerk-Wiederherstellung)
  • Sync-Indikator (☁↑ Anzahl ungesyncter Aufnahmen)
  • (optional) Speicherplatz-Warnung bei >500 MB ungesyncten Daten
  • Stop → direkt in Sync-Queue, ✓-Feedback, Screen bleibt auf aktuellem Fall
  • Oneliner-Polling (alle paar Sekunden für den sichtbaren Fall, bis Oneliner empfangen)
  • Emulator-Tests + Pixel Watch Hardware-Test

5c — Android-Handy-App (nach Watch-Abschluss)

  • :app-mobile-Modul auf bestehende :core-*-Basis aufsetzen
  • Erfassungs-UI (Compose Material 3): Fallliste, Neu/Fortsetzen, Recording-Screen — funktional-identisch zur Watch, aber an Handy-Ergonomie angepasst
  • Alle Erfassungs-Features via :core-* (keine Duplizierung von Sync/Queue/ACK/Marker-Logik)
  • Review-UI: Erste Ausbaustufe als WebView auf /web/...-Endpoints (schneller, kein doppelter Code, automatisch synchron zum Browser-UI)
  • Auth-Integration für /web/... (siehe „Offene Entscheidungen": Handy-App-Auth)
  • Invariante prüfen: kein lokaler Archiv-Cache für Transkripte/Audios außerhalb des aktiven Upload-Vorgangs
  • End-to-End-Test: Erfassung Handy → Server → Review in Handy-App

5d — Data Layer Sync zwischen Watch und Handy (optional, später)

  • DataClient (Wear OS Data Layer API) in :core-storage einbetten
  • Nur Marker-Dateien syncen (keine Audios — Audio-Upload bleibt beim Aufnahmegerät)
  • Tombstone-Strategie für „gestern gelöscht" (TTL ~25 h, um Auferstehung zu vermeiden)
  • Test: Watch + Handy offline → beide nehmen auf → bei erneuter Bluetooth-Verbindung gleicht sich die Liste ab

5e — Desktop-Clients (in Arbeit, nativer Rust-Stack)

  • Tech-Stack entschieden: eframe/egui + tokio + reqwest + ffmpeg-Subprozess (nicht Tauri/Compose)
  • Cargo-Workspace + doctate-common-Shared-Lib
  • Zweite Shared-Lib doctate-client-core für UI-freie Client-Logik (Case-Store, Server-Sync, Upload, Poller, Cleanup)
  • Linux-Desktop: Config, Recorder (SIGINT-Stop), Uploader mit Retry + persistenter Queue, State-Machine, UI mit Fehlerpfad
  • Single-Instance-Lock (PID-File, verhindert konkurrierende Pending-Queue-Zugriffe)
  • Snapshot-Cache-Persistenz (Poller-State überlebt Neustart, kein Flash-Fehlzustand)
  • Oneliner-Poller + Fall-Liste im Client (ETag-basiert gegen /api/oneliners; Delete-Watermark respektiert)
  • Startup-Cleanup für stale Markers / Orphans (pending_cleanup)
  • Pending-Upload-Indicator + last_failure-Anzeige im Footer-Status
  • Magic-Link-Login („Im Browser öffnen"-Button tauscht API-Key gegen Einmal-Token)
  • Integration-Test für Recorder (lavfi-Silence, #[ignore], verifiziert SIGINT-Graceful-Stop + ftyp-Magic)
  • Integration-Tests für Magic-Link-Flow und Single-Instance-Lock
  • Windows-Desktop: dshow-Device-Enumeration; ffmpeg-Installationspfad (winget vs. Bundle)
  • Tray-Icon (zurückgestellt; später via ksni, weil tray-icon zu viele System-Deps mitzieht)
  • iOS-App (SwiftUI, Ausblick; würde doctate-client-core via UniFFI konsumieren)
  • Invariante gewahrt: jeder Client = weiterer HTTP-Client, kein Server-Code-Ausbau nötig

Phase 6 — Integration & Testing

  • End-to-End Test (Watch → Server → Webinterface)
  • [~] End-to-End Test (Linux-Desktop → Server → Webinterface inkl. Oneliner-Poll-Refresh und Magic-Link-Handoff in den Browser) — im Alltagsbetrieb validiert, aber kein dedizierter automatisierter E2E-Testlauf
  • [~] SSE Live-Updates testen — Unit-Tests im events-Modul (Capacity, Lagged, Subscriber-Fanout), server/tests/sse_integration.rs + server/tests/sse_cleanup_test.rs (Connection-Pool-Leak)
  • Pixel Watch Hardware-Test
  • LTE-Modus testen
  • Bluetooth-Headset testen
  • Sync-Service testen (Netzwerkausfall, Neustart, Doze)
  • Stop-und-Sync-Flow testen
  • 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) — 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
  • Mehrere Ärzte testen
  • IDOR-Test: eingeloggter Arzt A versucht case_id von Arzt B aufzurufen → 404, kein Datenleck
  • Session-Ablauf testen: nach 8 h → Redirect zu Login, SSE-Stream geschlossen
  • CSRF-Test: POST ohne gültiges CSRF-Token → 403
  • Path-Traversal-Test: case_id mit ../ → 400
  • Ungültige case_id (kein UUID) → 400
  • Lazy Cleanup testen (Watch: nur Marker-Dateien von gestern gelöscht, Audiodateien in unsynced/ bleiben)
  • Lazy Cleanup testen (Server: markierte Fälle am Folgetag entfernt?)
  • Retention-Prüfung testen (Audio/Transkripte nach RETENTION_*_DAYS)
  • Undo/Regenerate testen
  • 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 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

Offene Entscheidungen

Thema Status
Whisper Modell Entschieden: large-v3-turbo via eigener whisper/-Service, float16, optional Hotwords per Request. Qualitativer Vergleich gegen large-v3 zeigte: gleichwertig bei Fachvokabular, nur Hotwords bringen echte Fehlerreduktion. Deutsches Fine-Tuning (primeline) getestet, lieferte schlechtere Ergebnisse. Umschaltbar via WHISPER_MODEL.
Ollama Modell Entschieden (vorläufig): gemma4:latest (läuft). Llama 3.1 8B als Option, wenn Oneliner-Qualität nicht reicht.
Ionos Modell Noch zu evaluieren
Watch Hardware Pixel Watch 2 oder 3 (LTE empfohlen)
Authentifizierung Browser Entschieden: Serverseitiges Session-Token (256-Bit, Cookie mit Secure/HttpOnly/SameSite=Strict), Ablauf 8 h. User-Identität ausschließlich aus Session, nie aus URL (IDOR-Prävention). Login gegen User-Passwort in users.toml, oder passwortlos per Magic-Link (API-Key → Einmal-Token via POST /api/auth/magic-linkGET /web/magic?token=…, TTL 60 s, one-time-use, return_to per Whitelist auf /web/-Pfade beschränkt).
Prompt-Qualität Erfordert Testing mit echten Diktaten
Transkript editierbar? Read-only oder editierbar vor Abschluss — offen
Sicheres Löschen Reicht rm oder Overwrite nötig? — offen
faster-whisper HTTP-API Entschieden: eigener FastAPI-Wrapper (whisper/). Grund siehe Phase 2b.5 / Abweichungen.
Recovery-UI (Webinterface) Dedizierte Ansicht für verspätet eingetroffene Aufnahmen (Anhören / Ins Dokument / Verwerfen) — optional, Bedarf im Echtbetrieb evaluieren
Handy-App-Authentifizierung Aktuell drei Mechanismen verfügbar: API-Key für /api/... (Erfassung), Cookie-Session für /web/... (Review), Magic-Link (API-Key → Browser-Session ohne Passwort). Varianten für die Handy-App: (a) alle parallel, (b) API-Key für alles vereinheitlichen (Cookie-Pfad erweitern oder Token-basiert), (c) WebView mit Cookie-Login für Review + API-Key für Upload, (d) Magic-Link aus der App heraus → eingebetteter WebView übernimmt Session. Entscheidung zu Beginn von Phase 5c.
Weitere Client-Plattformen (Linux/Windows/iOS) Tech-Stack-Kandidaten: Compose Multiplatform (Code-Sharing über Android hinaus), Tauri + Rust (Desktop, kleine Binaries), nativ (SwiftUI). Entscheidung nach Handy-App-Erfahrung; Server-seitig keine Änderungen nötig (Client-Invariante).

Nicht im Scope (vorerst)

  • Apple Watch (iOS-App für iPhone/iPad ist in Phase 5e als optionaler Ausblick enthalten)
  • Praxissoftware-Integration (Medical Office / GDT)
  • Multi-Tenant / Cloud-Hosting
  • Echtzeit-Transkription
  • Automatische Patientenzuordnung

Hinweis zu Client-Plattformen: Android-Handy, Linux-/Windows-Desktop und iOS sind nach dem Client-Prinzip explizit erlaubt — ihre Umsetzung ist in Phase 5c bzw. 5e geplant, aber zeitlich nach der Watch-App priorisiert.


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
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-webserviceeigener 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): 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.
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

Ä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.
Analyse-Trigger Arzt klickt „Analysieren" analyze::auto_trigger läuft auf jedem /web/cases[...]-Handler-Aufruf; SSE-Reloads sind der natürliche Puls. Manueller Button bleibt als „erzwingen". Jeder Reload liefert aktuellen FS-Stand → warum den Arzt einen Knopf drücken lassen, wenn der Server anhand reiner FS-Stats entscheiden kann? Retry-Gate .analysis_failed.json (mit last_recording_mtime-Signatur) verhindert Retry-Loops bei fehlgeschlagenen LLM-Calls, bis neue Eingaben eintreffen.
Event-Bus + SSE Nicht vorgesehen events::channel (tokio broadcast, Cap 256), Worker und Route-Handler emittieren CaseEvents; SSE-Route streamt an Browser-Tabs; Client macht debounced location.reload() FS bleibt SoT — Events sind Trigger, nicht State. Browser-Diffing entfällt komplett: Re-Render vom aktuellen FS-Stand ist billiger und trivial korrekt. Lagged(n) für langsame Subscriber wird durch den Reload automatisch resynchronisiert.

UI / Entwickler-Tools

Ä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.
Audio-Seeking Nicht vorgesehen HTTP-Range-Requests in handle_audio (parse_range + serve_range), Accept-Ranges: bytes, 206 Partial Content; Duration-Sidecar {ts}.duration.txt (ffprobe auf der remuxten Kopie, ~ms) für Player-Rendering ohne HEAD-Roundtrips HTML5-<audio>-Player brauchen Range für Seek ohne Re-Download. Sidecar spart den Extra-HEAD pro Transkript-Zeile; Worker schreibt ihn best-effort, lazy-backfill vorgesehen.
Admin-Log vs. Arzt-UI Nur Arzt-UI geplant Zusätzlich frühes Admin-Log unter GET /web/ (flache Liste aller Fälle) Gebaut, bevor Session/States/Fall-Detail existierten, um die Pipeline während Entwicklung inspizieren zu können. Soll später hinter role = "admin" geschützt werden.
Test-Client für Watch-Flow Erst ab Phase 5 mit Hardware scripts/dictate.sh ab Phase 2/3 als Stand-in (ffmpeg + curl + c/n/r/q-Loop) End-to-End-Tests ohne Pixel-Watch-Hardware.
Hotwords (Whisper) Nicht vorgesehen Per-User-Feld [user.whisper].hotwords im Code, aber nicht als Feature angeboten Regress-Lauf über 10 Fixtures zeigt keinen Vorteil (ohne 12/257 Wortfehler, mit 14/257). Hotwords schluckten Funktionswörter. Leitung bleibt durchverdrahtet, bewerben wir aber nicht — re-evaluieren bei konkretem Bedarf.

Authentifizierung

Änderung Original Aktuell Grund
Browser-Login Nur Passwort-Formular gegen users.toml Zusätzlich Magic-Link: POST /api/auth/magic-link (API-Key) → GET /web/magic?token=… (60 s TTL, one-time-use, return_to auf /web/-Pfade whitelisted, Referrer-Policy: no-referrer) Der Desktop-Client hat den API-Key ohnehin, das Passwort separat einzutippen ist Friktion ohne Sicherheitsgewinn. Flow nutzt den vorhandenen AuthenticatedUser-Extractor, sodass Policy an einer Stelle bleibt.
Admin-Gating für destruktive Aktionen Reset/Bulk für jeden eingeloggten Arzt AuthenticatedUser::is_admin() (role == "admin") — Handler für POST /web/cases/{id}/reset und POST /web/cases/bulk rejecten Nicht-Admins, Templates blenden Buttons via is_admin: bool-Feld in den ViewModels aus (Defense-in-Depth) Reset wirft alle Transkripte weg, Bulk kann viele Fälle löschen. Admin-Gate schützt vor Fat-Finger während der Entwicklung und bleibt auf Dauer sinnvoll.

Client-Architektur

Änderung Original Aktuell Grund
Repository-Layout server/ standalone + watch/wearos/ separat Cargo-Workspace mit server/, doctate-common/ (API-Typen), doctate-client-core/ (UI-freie Client-Logik), client-desktop/ (erster nativer Client) Mit zweitem Rust-Consumer rechtfertigt sich eine geteilte Lib. Zusätzlich macht ein UI-freies Core-Crate Client-Business-Logik (Case-Store, Server-Sync, Poller, Cleanup) zwischen Desktop und künftigen mobilen/iOS-Clients wiederverwendbar.
Client-Shared-Lib-Grenze doctate-common enthält alles, was zwischen Server und Client geteilt wird Zweistufig: doctate-common nur runtime-agnostische Wire-Typen + Konstanten, doctate-client-core runtime-behaftete Client-Logik (tokio + reqwest) doctate-common soll WASM-fähig bleiben (kein tokio, kein reqwest). Client-Runtime-Abhängigkeiten wandern in eine zweite Lib, damit Server + potenzieller Browser-Client weiter von der Common-Lib konsumieren können, ohne tokio-Transitive zu ziehen.
Reihenfolge der Clients Pixel Watch zuerst, dann Handy, Desktop später (Phase 5e „Ausblick") Linux-Desktop zuerst (vor Watch), Windows nächster, Watch sobald Hardware verfügbar Nutzer hat noch keine Pixel-Watch-Hardware. Desktop-Client dient zusätzlich als API-Ergonomie-Test-Fahrzeug (Retry, Content-Types, Auth-Fehlerpfade, die Wiremock-Tests nicht zeigen) und als Arzt-Workflow-Tool am PC (nebenher diktieren beim Arztbrief-Tippen).
Desktop-Tech-Stack Offen — Kandidaten Tauri / Compose Multiplatform / nativ eframe/egui (Immediate-Mode-UI) + tokio + reqwest + ffmpeg-Subprozess Kleinere Binaries als Tauri (keine WebView, kein WebKitGTK auf Linux), keine Android-Verpflichtung wie Compose, pure Rust-Toolchain. ffmpeg-Subprozess statt libav-FFI: Lizenz (GPL vs. LGPL), Build-Komplexität, plattformübergreifende Konsistenz.
Desktop-Audio-Stop nicht spezifiziert Unix: SIGINT via libc::kill. Windows: q-Stdin-Write als Best-Effort. ffmpeg's term_init() prüft isatty(stdin); bei Pipe-stdin wird Keyboard-Polling deaktiviert, q landet in der Pipe, wird aber nie gelesen. SIGINT triggert ffmpeg's Signal-Handler unabhängig vom TTY-Status. Exit-Code 255 bei SIGINT-Exit ist normal — wir prüfen Datei-Existenz + Nicht-Leer statt Exit-Code.
Desktop-Tray-Icon Geplant als Schritt 4f des Client-Aufbaus Zurückgestellt, kein Tray im MVP. Close-Button beendet App. tray-icon-Crate zieht drei System-Libs (libxdo, gtk3, libappindicator) für triviale Funktion. ksni (pure-Rust via dbus/zbus) als saubere Linux-Alternative für später dokumentiert. Client läuft ohne Tray sinnvoll.