Refactor: Extract client core logic into separate crate
Introduce `doctate-client-core` to house UI-agnostic client business logic. This promotes code reuse and allows consumption by different client platforms, such as iOS via UniFFI bindings. This commit also updates `client-desktop` to depend on `doctate-client-core` and removes duplicated logic.
This commit is contained in:
+108
-48
@@ -2,7 +2,7 @@
|
||||
|
||||
## Überblick
|
||||
|
||||
Medizinisches Diktiersystem für Ärzte. Aufnahmen werden per Pixel Watch (primäres Erfassungsgerät) oder weiteren Clients (Android-Handy, künftig optional Linux-/Windows-Desktop, iOS) 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.
|
||||
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.
|
||||
|
||||
@@ -16,9 +16,11 @@ Medizinisches Diktiersystem für Ärzte. Aufnahmen werden per Pixel Watch (prim
|
||||
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, Admin, Audio-Streaming │
|
||||
│ Linux / Windows / iOS (optional, später) — dieselbe Rolle │
|
||||
│ Browser — Review, Audio-Streaming │
|
||||
│ iOS (optional, später) — dieselbe Rolle │
|
||||
└────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
│ HTTPS (einheitliche Server-API, keine
|
||||
@@ -61,17 +63,21 @@ Der Zustand eines Falls wird **ausschließlich aus dem Dateisystem** abgeleitet,
|
||||
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.
|
||||
- **Linux / Windows / iOS (optional, später):** gleiche Invariante, beliebiger Funktionsumfang — Tech-Stack pro Plattform offen (z.B. Compose Multiplatform, Tauri, nativ).
|
||||
- **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.
|
||||
>
|
||||
> **Lokale Datenhaltung ist strikt flüchtig:** Kein Client hält mehr Daten lokal, als für den aktuellen Upload- oder Render-Vorgang nötig. Alle dauerhaften Daten leben auf dem Server.
|
||||
> **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:** *„Zwingt das Feature einen Client, Daten länger lokal zu halten als für den aktuellen Upload- oder Render-Vorgang nötig?"* Wenn ja, gehört das Feature ins Web-UI / auf den Server, nicht in den Client.
|
||||
**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.
|
||||
@@ -318,14 +324,20 @@ Wenn Watch und Handy gekoppelt sind (klassisches Wear-OS-Pairing), *könnten* be
|
||||
- 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: `NotConfigured` → `Idle` → `Recording` → `FinalizingRecording` → `Uploading` → `Idle` | `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) + `reqwest` (Multipart-Upload, rustls) + `ffmpeg`-Subprozess (m4a/AAC direkt; Graceful-Stop via `SIGINT` über `libc::kill`).
|
||||
**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-core` — `client-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.
|
||||
**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, Fall-Liste, Oneliner-Polling.
|
||||
**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.
|
||||
|
||||
---
|
||||
|
||||
@@ -340,6 +352,14 @@ Axum ist der zentrale Koordinator. Er empfängt Uploads, startet die sequentiell
|
||||
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?hours=N → kollektive Fall-/Oneliner-Liste (ETag-basiert,
|
||||
default 16 h, max 168 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
|
||||
@@ -348,20 +368,21 @@ POST /web/logout → Session zerstören
|
||||
|
||||
# Web: Arzt-UI (Session-gebunden, kein {slug} in URL, IDOR-geschützt)
|
||||
GET /web/cases → eigene Fallübersicht
|
||||
GET /web/cases/{case_id} → Fall-Detail (Transkripte + Status)
|
||||
GET /web/cases/{case_id}/document → Gerendertes Dokument (Markdown → HTML)
|
||||
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
|
||||
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/undo-delete → letzte Lösch-Batch wiederherstellen
|
||||
POST /web/cases/bulk → Bulk-Aktionen (analyze / delete auf mehrere Fälle)
|
||||
POST /web/cases/bulk → Bulk-Aktionen (analyze / delete, admin-only)
|
||||
GET /web/audio/{user}/{case_id}/{filename} → Audio-Streaming (Cookie-Auth; Arzt oder Admin)
|
||||
|
||||
# Web: Admin-Log (flache Liste aller User/Fälle für Entwicklung)
|
||||
GET /web/ → Admin-Übersicht (später auf role="admin" eingrenzen)
|
||||
```
|
||||
|
||||
**Geplant, noch nicht implementiert:** `GET /api/oneliner/{case_id}` (Client-Polling, genutzt von Watch und Handy), `GET /web/events` (SSE), Preset- und Undo-Endpoints für Dokument-Versionen. Siehe Phase 4 und Client-Pläne weiter unten.
|
||||
**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:** `GET /web/events` (SSE), 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.
|
||||
@@ -580,7 +601,18 @@ Abgeschlossen
|
||||
Zuletzt gelöscht [Undo letzten Batch]
|
||||
```
|
||||
|
||||
Fall-Detail (transkribiert, Ist-Stand):
|
||||
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
|
||||
────────────────────────────────────────────
|
||||
@@ -588,17 +620,10 @@ Fall 09:32 — 3 Aufnahmen
|
||||
09:45 „Röntgenbild zeigt ..." ← Transkript 2
|
||||
10:02 „Diagnose: ..." ← Transkript 3
|
||||
|
||||
[Analysieren] [Reset] [Löschen]
|
||||
[← Zurück zur Übersicht]
|
||||
```
|
||||
|
||||
Dokumentansicht (ausgewertet, Ist-Stand):
|
||||
```
|
||||
Dokument (Markdown → HTML, single version)
|
||||
────────────────────────────────────────────
|
||||
... Inhalt ...
|
||||
|
||||
[Neu analysieren] [Reset] [Löschen]
|
||||
```
|
||||
**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.
|
||||
|
||||
@@ -827,7 +852,7 @@ Wichtig: LLM-Antworten werden **nicht** geloggt (potenziell patientenbezogene Da
|
||||
| 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), `ffmpeg`-Subprozess (m4a-Recording, SIGINT-Stop). Teilt `doctate-common`-Lib mit dem Server. |
|
||||
| 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 für Live-Updates ist Phase-4-TODO |
|
||||
| STT | faster-whisper (CTranslate2, large-v3, eigener Docker Container mit HTTP-API) |
|
||||
@@ -893,35 +918,54 @@ wiremock = "0.6"
|
||||
|
||||
### Cargo-Workspace-Layout
|
||||
|
||||
Das Repository ist ein **Cargo-Workspace** mit drei Rust-Crates:
|
||||
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)
|
||||
├── 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 einzige Quelle der API-Wahrheit zwischen Server und Clients:
|
||||
**`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 der Shared-Lib sind minimal: nur `serde`, `uuid`, `time` — **kein** `tokio`, `reqwest`, `axum`. Dadurch bleibt sie runtime-agnostisch und kompiliert auch in WASM-Kontexten, falls je ein Browser-Client dazukommt.
|
||||
Deps von `doctate-common` sind minimal: nur `serde`, `uuid`, `time` — **kein** `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)
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
doctate-common = { path = "../doctate-common" }
|
||||
eframe = "0.28" # egui + winit
|
||||
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 }
|
||||
serde, serde_json, toml, uuid, tracing, tracing-subscriber # workspace-geerbt
|
||||
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]
|
||||
@@ -1014,13 +1058,13 @@ wiremock = "0.6"
|
||||
- [x] Session-Management: kryptographisches Token (256-Bit über 43 Alphanumeric-Zeichen, `OsRng`), Cookie (`HttpOnly`, `SameSite=Strict`, `Secure` via `COOKIE_SECURE`-ENV schaltbar für Dev), serverseitiger `RwLock<HashMap<Token, Session>>`, Ablauf nach 8 h
|
||||
- [x] `AuthenticatedWebUser`-Extractor: Session-Token → User auflösen, bei ungültiger/abgelaufener Session → Redirect `/web/login`
|
||||
- [x] Login-Seite (`GET /web/login`, `POST /web/login`), Logout (`POST /web/logout`)
|
||||
- [x] askama Templates: `login.html`, `my_cases.html`, `case_detail.html`, `cases.html` (Admin), `document.html`
|
||||
- [x] askama Templates (seit Split 2026-04-19): `login.html`, `my_cases.html`, `case_page.html` (Fall-Übersicht + inline gerendertes Dokument), `case_recordings.html` (Einzel-Transkripte + Audio). `case_detail.html`, `cases.html`, `document.html` sind entfernt.
|
||||
- [x] Übersicht für Arzt: zwei Sektionen (Offen / Abgeschlossen), plus "Zuletzt gelöscht" mit Undo-Batch
|
||||
- [x] Fall-Detail: Transkripte pro Aufnahme einsehen (`GET /web/cases/{case_id}`, read-only, IDOR-geschützt via Session-Slug)
|
||||
- [x] Fall-Übersicht: `GET /web/cases/{case_id}` rendert `case_page.html` mit Oneliner + Aktionen + inline-gerendertem Dokument (via `pulldown-cmark`); IDOR-geschützt via Session-Slug.
|
||||
- [x] Einzel-Transkripte: `GET /web/cases/{case_id}/recordings` rendert `case_recordings.html` (ein Eintrag pro Aufnahme, Audio-Link pro Transkript).
|
||||
- [x] Audio-Streaming: `GET /web/audio/{user}/{case_id}/{filename}` (Cookie-Auth, Arzt eigene Dateien oder Admin)
|
||||
- [x] Fall analysieren — Button im Detail-View
|
||||
- [x] Bulk-Aktionen (alle markierten analysieren / löschen) über `POST /web/cases/bulk`
|
||||
- [x] Dokumentansicht — `GET /web/cases/{id}/document`, rendert `document.md` via `pulldown-cmark` nach HTML
|
||||
- [x] Fall analysieren — Button in der Fall-Übersicht
|
||||
- [x] Bulk-Aktionen (alle markierten analysieren / löschen) über `POST /web/cases/bulk` — **admin-only** (`AuthenticatedUser::is_admin()` auf `role == "admin"`).
|
||||
- [x] Soft-Delete mit Undo letzter Batch (ohne Bestätigungsdialog)
|
||||
- [ ] `ValidCaseId`-Extractor: UUID-Validierung als Axum-Extractor (aktuell inline in Handlern)
|
||||
- [ ] SSE-Endpunkt (`GET /web/events`), Session-Check bei Aufbau + periodisch bei Heartbeat
|
||||
@@ -1031,10 +1075,10 @@ wiremock = "0.6"
|
||||
- [ ] Service-Ausfall-Warnung (faster-whisper/Ollama/LLM > 30 Min.)
|
||||
- [ ] CSRF-Token pro Session (Hidden Field in allen POST-Formularen, serverseitige Validierung)
|
||||
|
||||
**Admin-Log (separat vom Arzt-UI)**
|
||||
- [x] `GET /web/` — flache Liste aller Fälle quer über User, inkl. Transkripten und Oneliner. Dient als "interaktives Log" für Entwicklung und Admin-Zwecke.
|
||||
**Admin-Features (integriert in die Arzt-UI)**
|
||||
- [x] `hash-password` CLI (`cargo run --bin hash-password`) — erzeugt bcrypt-Hashes für `users.toml`, mit `toml_edit`-Schreibzugriff ohne Kommentarverlust.
|
||||
- [ ] Zugriffsschutz: `GET /web/` und Admin-Audio auf `role = "admin"` einschränken (aktuell noch offen für alle).
|
||||
- [x] 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)
|
||||
|
||||
@@ -1090,16 +1134,24 @@ wiremock = "0.6"
|
||||
#### 5e — Desktop-Clients (in Arbeit, nativer Rust-Stack)
|
||||
- [x] Tech-Stack entschieden: `eframe`/`egui` + `tokio` + `reqwest` + `ffmpeg`-Subprozess (nicht Tauri/Compose)
|
||||
- [x] Cargo-Workspace + `doctate-common`-Shared-Lib
|
||||
- [x] Zweite Shared-Lib `doctate-client-core` für UI-freie Client-Logik (Case-Store, Server-Sync, Upload, Poller, Cleanup)
|
||||
- [x] Linux-Desktop: Config, Recorder (SIGINT-Stop), Uploader mit Retry + persistenter Queue, State-Machine, UI mit Fehlerpfad
|
||||
- [x] Single-Instance-Lock (PID-File, verhindert konkurrierende Pending-Queue-Zugriffe)
|
||||
- [x] Snapshot-Cache-Persistenz (Poller-State überlebt Neustart, kein Flash-Fehlzustand)
|
||||
- [x] Oneliner-Poller + Fall-Liste im Client (ETag-basiert gegen `/api/oneliners`; Delete-Watermark respektiert)
|
||||
- [x] Startup-Cleanup für stale Markers / Orphans (`pending_cleanup`)
|
||||
- [x] Pending-Upload-Indicator + `last_failure`-Anzeige im Footer-Status
|
||||
- [x] Magic-Link-Login („Im Browser öffnen"-Button tauscht API-Key gegen Einmal-Token)
|
||||
- [x] Integration-Test für Recorder (`lavfi`-Silence, `#[ignore]`, verifiziert SIGINT-Graceful-Stop + `ftyp`-Magic)
|
||||
- [x] 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)
|
||||
- [ ] Oneliner-Polling + Fall-Liste im Client (post-MVP)
|
||||
- [ ] iOS-App (SwiftUI, Ausblick)
|
||||
- [ ] iOS-App (SwiftUI, Ausblick; würde `doctate-client-core` via UniFFI konsumieren)
|
||||
- [x] 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
|
||||
- [ ] Pixel Watch Hardware-Test
|
||||
- [ ] LTE-Modus testen
|
||||
@@ -1139,13 +1191,13 @@ wiremock = "0.6"
|
||||
| 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`. |
|
||||
| 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-link` → `GET /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 zwei Mechanismen: API-Key für `/api/...` (Erfassung), Cookie-Session für `/web/...` (Review). Varianten für die Handy-App: (a) beide 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. Entscheidung zu Beginn von Phase 5c. |
|
||||
| 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). |
|
||||
|
||||
---
|
||||
@@ -1215,11 +1267,19 @@ Alle Einträge beziehen sich auf den Ist-Stand im Repository. Die ursprüngliche
|
||||
| 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/` (shared lib), `client-desktop/` (erster nativer Client) | Mit zweitem Rust-Consumer rechtfertigt sich eine geteilte Lib. API-Typen + Konstanten + Timestamp-Helper zentral → keine Wire-Format-Drift zwischen Server und Clients. |
|
||||
| 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. |
|
||||
|
||||
Reference in New Issue
Block a user