diff --git a/docs/projektplan.md b/docs/projektplan.md index 7efddea..8af9c5d 100644 --- a/docs/projektplan.md +++ b/docs/projektplan.md @@ -307,9 +307,25 @@ Wird nach Abschluss der Watch-App gebaut, andockt an dieselben `:core-*`-Module. **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. Weitere Plattformen (Linux / Windows / iOS) — optional, Ausblick +#### 1c. Desktop-Clients (Linux / Windows) — erster nativer Client -Wegen der fixierten Invariante ist jeder weitere Client lediglich „ein weiterer HTTP-Client". Tech-Stack ist pro Plattform offen — denkbare Optionen: **Compose Multiplatform** (Code-Sharing über Android hinaus), **Tauri + Rust** (Desktop, kleine Binaries, teilt ggf. Logik mit dem Server), **nativ** (SwiftUI für iOS). Entscheidung erst nach Handy-App-Erfahrung. +**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: `NotConfigured` → `Idle` → `Recording` → `FinalizingRecording` → `Uploading` → `Idle` | `Error` + +**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`). + +**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. + +**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. --- @@ -811,7 +827,8 @@ 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. | -| Weitere Clients (Linux/Windows/iOS, optional) | Stack offen — Kandidaten: Compose Multiplatform, Tauri + Rust, nativ (SwiftUI). Entscheidung nach Handy-App-Erfahrung. | +| 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. | +| 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) | | Preprocessing | Ollama, Gemma 3 4B (keep_alive: 0 für VRAM-Freigabe nach Request) | @@ -874,6 +891,53 @@ 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 drei Rust-Crates: + +``` +doctate/ +├── Cargo.toml (Workspace-Root, edition 2024, resolver 3) +├── server/ (doctate-server bin; Axum) +├── doctate-common/ (doctate-common lib; API-Typen + Konstanten) +├── 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: +- `ack::AckResponse`, `ack::AckStatus` — ACK-Typen für `POST /api/upload` +- `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. + +### Rust Crates (Client-Desktop) + +```toml +[dependencies] +doctate-common = { path = "../doctate-common" } +eframe = "0.28" # egui + winit +reqwest = { workspace = true } +tokio = { workspace = true } +serde, serde_json, toml, uuid, tracing, tracing-subscriber # workspace-geerbt +directories = "5" # OS-konforme Config-/Data-Pfade +thiserror = "1" +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 @@ -883,7 +947,9 @@ wiremock = "0.6" | 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-/Windows-Desktop, iOS-Gerät (optional, später) | Weitere Client-Plattformen — sprechen dieselbe Server-API wie alle anderen Clients | +| 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) | @@ -1021,10 +1087,16 @@ wiremock = "0.6" - [ ] 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 — Weitere Plattformen (Ausblick, nicht MVP) -- [ ] Tech-Stack-Entscheidung für Linux-/Windows-Desktop (Compose Multiplatform vs. Tauri vs. nativ) -- [ ] iOS-App (SwiftUI oder Compose Multiplatform) -- [ ] Invariante gilt weiterhin: jeder neue Client = weiterer HTTP-Client, kein Server-Code-Ausbau nötig +#### 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] Linux-Desktop: Config, Recorder (SIGINT-Stop), Uploader mit Retry + persistenter Queue, State-Machine, UI mit Fehlerpfad +- [x] Integration-Test für Recorder (`lavfi`-Silence, `#[ignore]`, verifiziert SIGINT-Graceful-Stop + `ftyp`-Magic) +- [ ] 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) +- [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) @@ -1142,3 +1214,13 @@ Alle Einträge beziehen sich auf den Ist-Stand im Repository. Die ursprüngliche | 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. | + +### 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. | +| 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. |