Refactor desktop client crate naming
The `client-desktop` crate is now located within the `clients/desktop/` directory and renamed to `doctate-desktop`. This change improves organization and clarity within the project structure.
This commit is contained in:
+110
-50
@@ -64,10 +64,10 @@ Clients sind flüchtige Zugriffs- und Erfassungsstellen. Der Server ist die einz
|
|||||||
|
|
||||||
- **Pixel Watch (primär):** minimaler API-Ausschnitt — Upload + Oneliner-Polling. Kein Playback, kein Review, kein lokaler Archivspeicher.
|
- **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.
|
- **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.
|
- **Windows-Desktop (geplant):** gleiche Rolle wie Linux, derselbe `clients/desktop/`-Crate (`doctate-desktop`) 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.
|
- **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.
|
- **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.
|
- **iOS (optional, später):** gleiche Invariante, SwiftUI-UI; würde die Client-Logik aus `clients/desktop/src/` (Case-Store, Server-Sync, Upload, Snapshot-Cache, Cleanup) über UniFFI-Bindings konsumieren — sobald ein zweiter Rust-Konsument das rechtfertigt, wandern diese Module in eine eigene Lib zurück.
|
||||||
|
|
||||||
#### Invariante (fixiert)
|
#### Invariante (fixiert)
|
||||||
|
|
||||||
@@ -75,7 +75,7 @@ Clients sind flüchtige Zugriffs- und Erfassungsstellen. Der Server ist die einz
|
|||||||
>
|
>
|
||||||
> **Brisante medizinische Daten bleiben auf dem Server.** Brisant sind Audioaufnahmen (`.m4a`), Transkripte (`<stem>.json`) 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.
|
> **Brisante medizinische Daten bleiben auf dem Server.** Brisant sind Audioaufnahmen (`.m4a`), Transkripte (`<stem>.json`) 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.
|
> **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-desktop::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.
|
**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.
|
||||||
|
|
||||||
@@ -178,7 +178,7 @@ Die Watch-App exponiert ihre Funktion über die drei kanonischen Wear-OS-Surface
|
|||||||
└─────────────────────┘
|
└─────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
- Fallliste als `ScalingLazyColumn`, gespeist aus dem Singleton-`CaseStore`-Snapshot (Kotlin-Pendant zum Rust-`CaseStore` in `doctate-client-core`; strukturell 1:1, kein UniFFI-Binding bis zum Handy-App-Start — siehe Abweichungen → Client-Architektur)
|
- Fallliste als `ScalingLazyColumn`, gespeist aus dem Singleton-`CaseStore`-Snapshot (Kotlin-Pendant zum Rust-`CaseStore` in `doctate-desktop::case_store`; strukturell 1:1, kein UniFFI-Binding bis zum Handy-App-Start — siehe Abweichungen → Client-Architektur)
|
||||||
- EdgeButton „● Neu" → neue `case_id` + Marker + direkter Recording-Screen
|
- EdgeButton „● Neu" → neue `case_id` + Marker + direkter Recording-Screen
|
||||||
- Tap auf Listeneintrag → „Fortsetzen" (neue Aufnahme mit bestehender `case_id`)
|
- Tap auf Listeneintrag → „Fortsetzen" (neue Aufnahme mit bestehender `case_id`)
|
||||||
- Nach Stop: Screen bleibt auf dem aktiven Fall, Foreground Service startet aggressives Oneliner-Polling (Post-Stop-Burst, siehe unten)
|
- Nach Stop: Screen bleibt auf dem aktiven Fall, Foreground Service startet aggressives Oneliner-Polling (Post-Stop-Burst, siehe unten)
|
||||||
@@ -348,7 +348,7 @@ Wenn Watch und Handy gekoppelt sind (klassisches Wear-OS-Pairing), *könnten* be
|
|||||||
|
|
||||||
#### 1c. Desktop-Clients (Linux / Windows) — erster nativer Client
|
#### 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 zu dem Zeitpunkt noch keine Pixel-Watch-Hardware verfügbar war und ein realer Client für API-Ergonomie-Tests (Retry, Content-Types, Auth-Fehlerpfade) nötig war. (Pixel Watch 2 Hardware eingetroffen am 2026-04-23.)
|
**Ist-Stand:** Der Linux-Desktop-Client ist gebaut und funktioniert (`clients/desktop/`, Crate `doctate-desktop`, Bin-Name `doctate`). Er ist der **erste echte native Client überhaupt** — wurde vor der Watch-App gebaut, weil zu dem Zeitpunkt noch keine Pixel-Watch-Hardware verfügbar war und ein realer Client für API-Ergonomie-Tests (Retry, Content-Types, Auth-Fehlerpfade) nötig war. (Pixel Watch 2 Hardware eingetroffen am 2026-04-23.)
|
||||||
|
|
||||||
**Features (MVP):**
|
**Features (MVP):**
|
||||||
- Config-Panel beim Erststart (Server-URL + API-Key, TOML unter `~/.config/doctate/client.toml`)
|
- Config-Panel beim Erststart (Server-URL + API-Key, TOML unter `~/.config/doctate/client.toml`)
|
||||||
@@ -364,11 +364,11 @@ Wenn Watch und Handy gekoppelt sind (klassisches Wear-OS-Pairing), *könnten* be
|
|||||||
- Startup-Cleanup für stale Markers / Orphans (Pending-Dateien ohne Sidecar, verwaiste meta.json)
|
- 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.
|
- 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-core` — `client-desktop` ist nur noch UI-Schale + OS-spezifischer Recorder.
|
**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 als reguläre Module direkt in `clients/desktop/src/` — UI-Schale, OS-spezifischer Recorder und Headless-Logik teilen sich heute genau einen Crate. (Bis zum 2026-05-02 lebte die Headless-Logik in einer separaten Lib `doctate-client-core`; die Lib wurde aufgelöst, weil es nur einen Rust-Konsumenten gab — siehe Abweichungen → Client-Architektur.)
|
||||||
|
|
||||||
**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).
|
**Windows-Desktop:** geplant „bald" — derselbe `clients/desktop/`-Crate (`doctate-desktop`) 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.
|
**iOS:** SwiftUI, Ausblick — nicht in Arbeit. Geplante Arbeitsaufteilung: Swift nur für UI + OS-Recording, alles andere konsumiert die Headless-Module aus `clients/desktop/src/` über eine C-ABI-/UniFFI-Bindings-Schicht. Mit dem Start von iOS oder Handy-Rust-Konsum wandern die UI-freien Module wieder in eine eigene Lib (z.B. `clients/core/`) — die Auflösung 2026-05-02 war bewusst umkehrbar, alle Module sind weiterhin als `pub mod` aus `clients/desktop/src/lib.rs` re-exportiert.
|
||||||
|
|
||||||
**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.
|
**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.
|
||||||
|
|
||||||
@@ -491,10 +491,12 @@ Der Gazetteer ist ein deterministischer Filter, der jede KI-Ausgabe passiert, be
|
|||||||
- Kuratiertes Vokabular in `server/vocab/*.txt`, eine Phrase pro Zeile, Leerzeilen und `#`-Kommentare erlaubt, non-recursive, case-insensitive.
|
- 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 (`Bein` ↔ `Behn`).
|
- Min. Tokenlänge 5 Zeichen (`MIN_TOKEN_LEN`) — schützt gegen falsche Kollisionen bei kurzen Alltagswörtern (`Bein` ↔ `Behn`).
|
||||||
- Damerau-Levenshtein-Distanz ≤ 2 (`MAX_EDIT_DISTANCE`) gegen das Vocab. Exakte Treffer werden per `HashSet`-Shortcut gehandhabt.
|
- Damerau-Levenshtein-Distanz ≤ 2 (`MAX_EDIT_DISTANCE`) gegen das Vocab. Exakte Treffer werden per `HashSet`-Shortcut gehandhabt.
|
||||||
|
- **Per-Entry edit distance** (Commit 1fe7c27, 2026-04-30): Einträge dürfen ihre eigene Edit-Distanz übersteuern (z.B. extrem lange Termini erlauben DL=3, sehr kurze auf DL=1 herunter), für phonetisch verzwickte Fälle ohne globalen Default zu lockern.
|
||||||
|
- **Bypass-Aliases** (Commit 9c452e8, 2026-04-30): Pro Vocab-Eintrag dürfen mit `-`-Prefix Aliases gelistet werden (Beispiel: `Enoxaparin -Inoxaparin`), die das Hunspell-Dict-Veto explizit überstimmen — nötig, wenn Hunspell ein Compound fälschlich als gültig akzeptiert (z.B. `Inoxaparin` als deutsches Wort) und damit den Rewrite blockiert. Malformed Lines (Sekundär-Tokens ohne `-`-Prefix) sind ein **Hard-Error** und brechen den Server-Start mit `io::ErrorKind::InvalidData` — Drift zwischen Vocab und Code wird sofort sichtbar statt im Stillen verlorengehen.
|
||||||
- `==text==`-Markierungen aus dem LLM-Prompt werden **nicht angetastet**.
|
- `==text==`-Markierungen aus dem LLM-Prompt werden **nicht angetastet**.
|
||||||
|
|
||||||
**Dict-Veto gegen False Positives:**
|
**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).
|
`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). Das Veto wird nur dann übersteuert, wenn der Kandidat auf der Bypass-Alias-Liste seines Canonical-Eintrags steht (siehe oben).
|
||||||
|
|
||||||
**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).
|
**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).
|
||||||
|
|
||||||
@@ -744,7 +746,7 @@ Fall 09:32 — 3 Aufnahmen
|
|||||||
└── {case_id}/
|
└── {case_id}/
|
||||||
├── {UTC-timestamp}.m4a ← Aufnahme (unveränderlich)
|
├── {UTC-timestamp}.m4a ← Aufnahme (unveränderlich)
|
||||||
├── {UTC-timestamp}.m4a.failed ← optional: dauerhaft gescheiterte Aufnahme
|
├── {UTC-timestamp}.m4a.failed ← optional: dauerhaft gescheiterte Aufnahme
|
||||||
├── {UTC-timestamp}.json ← `RecordingMeta` (Transcript + duration_seconds), single atomic write am Ende des Worker-Pipelines
|
├── {UTC-timestamp}.json ← `RecordingMeta` (Transcript + duration_seconds + optional `loudness`), single atomic write am Ende des Worker-Pipelines
|
||||||
├── oneliner.json ← `OnelinerState` (Ready/Empty/Error), aus allen Transkripten regeneriert
|
├── oneliner.json ← `OnelinerState` (Ready/Empty/Error), aus allen Transkripten regeneriert
|
||||||
├── analysis_input.json ← nur während Analyse-Lauf (wird nach Erfolg gelöscht)
|
├── analysis_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)
|
├── .analysis_failed.json ← Auto-Trigger-Retry-Gate (JSON: last_recording_mtime, reason, failed_at)
|
||||||
@@ -772,7 +774,7 @@ Fall 09:32 — 3 Aufnahmen
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `{ts}.m4a` ohne `{ts}.json` | Transkriptions-Auftrag offen |
|
| `{ts}.m4a` ohne `{ts}.json` | Transkriptions-Auftrag offen |
|
||||||
| `{ts}.m4a.failed` | Dauerhaft gescheitert, Recovery-Scan ignoriert, UI zeigt „failed" |
|
| `{ts}.m4a.failed` | Dauerhaft gescheitert, Recovery-Scan ignoriert, UI zeigt „failed" |
|
||||||
| `{ts}.json` | `RecordingMeta` mit `transcript` (`Silent` oder `Content`) und `duration_seconds`. Single atomic write — Existenz = transcribiert |
|
| `{ts}.json` | `RecordingMeta` mit `transcript` (`Silent` oder `Content`), `duration_seconds` und optional `loudness` (`{mean_db, max_db, gain_db}` für Replay-Gain im Browser; `#[serde(default)]`-rückwärtskompatibel). Single atomic write — Existenz = transcribiert |
|
||||||
| `oneliner.json` | `OnelinerState` (Ready/Empty/Error). Fehlt oder `Error` → Recovery-Retry; `Empty`/`Ready` sind Endzustände |
|
| `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_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 |
|
| `.analysis_failed.json` vorhanden | Auto-Trigger-Retry-Gate; Auto-Analyse übersprungen, solange `last_recording_mtime` unverändert |
|
||||||
@@ -817,6 +819,10 @@ LLM_API_KEY=...
|
|||||||
LLM_MODEL=...
|
LLM_MODEL=...
|
||||||
LLM_TEMPERATURE=0
|
LLM_TEMPERATURE=0
|
||||||
LLM_TIMEOUT_SECONDS=180
|
LLM_TIMEOUT_SECONDS=180
|
||||||
|
LLM_REASONING_EFFORT=medium # optional, default "medium". Wirkt nur auf
|
||||||
|
# GPT-OSS-Reasoning-Modelle (Ionos); andere
|
||||||
|
# OpenAI-kompatible Modelle ignorieren das Feld.
|
||||||
|
# Trade-off Halluzinations-Risiko ↔ Latenz/Token-Kosten.
|
||||||
|
|
||||||
# Consolidation system prompt — optional override. Leer → Default aus
|
# Consolidation system prompt — optional override. Leer → Default aus
|
||||||
# analyze/prompt.rs. Runtime-konfigurierbar, damit Admins iterieren und
|
# analyze/prompt.rs. Runtime-konfigurierbar, damit Admins iterieren und
|
||||||
@@ -966,7 +972,7 @@ Wichtig: LLM-Antworten werden **nicht** geloggt (potenziell patientenbezogene Da
|
|||||||
| Schicht | Technologie |
|
| 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. |
|
| 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. |
|
| Desktop-Client (Linux gebaut, Windows geplant) | Rust, Standalone-Cargo-Workspace `clients/desktop/` (Crate `doctate-desktop`, Bin-Name `doctate`). `eframe`/`egui` (Immediate-Mode-UI), `tokio` (async I/O), `reqwest` (Multipart-Upload + Polling), `ffmpeg`-Subprozess (m4a-Recording, SIGINT-Stop). Teilt `doctate-common` (API-Typen) als `path = "../../common"`-Dependency mit dem Server. Headless-Logik (Case-Store, Sync, Poller, Cleanup) lebt als reguläre Module im selben Crate. |
|
||||||
| iOS-Gerät (optional, später) | SwiftUI oder Compose Multiplatform. Entscheidung nach Watch/Handy-Erfahrung. |
|
| 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 |
|
| 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) |
|
| STT | faster-whisper (CTranslate2, large-v3, eigener Docker Container mit HTTP-API) |
|
||||||
@@ -1034,60 +1040,94 @@ wiremock = "0.6"
|
|||||||
|
|
||||||
### Cargo-Workspace-Layout
|
### Cargo-Workspace-Layout
|
||||||
|
|
||||||
Das Repository ist ein **Cargo-Workspace** mit vier Rust-Crates:
|
Das Repository ist seit dem Stage-Split (2026-05-02) **kein** monolithischer Cargo-Workspace mehr, sondern besteht aus **drei isolierten Build-Welten** mit eigenen `Cargo.lock`-Files plus einer reinen Source-Lib `common/` und dem Wear-OS-Gradle-Projekt:
|
||||||
|
|
||||||
```
|
```
|
||||||
doctate/
|
doctate/
|
||||||
├── Cargo.toml (Workspace-Root, edition 2024, resolver 3)
|
├── server/ Standalone-Workspace (eigener Cargo.lock + target/)
|
||||||
├── server/ (doctate-server bin; Axum)
|
│ Crate doctate-server, Bin doctate-server, Axum
|
||||||
├── doctate-common/ (doctate-common lib; API-Typen + Konstanten, runtime-agnostisch)
|
├── common/ Source-Lib (kein Workspace, kein Cargo.lock)
|
||||||
├── doctate-client-core/ (doctate-client-core lib; Client-Business-Logik: Case-Store,
|
│ Crate doctate-common, runtime-agnostische API-Typen
|
||||||
│ Server-Sync, Upload-Queue, Oneliner-Poller, Snapshot-Cache,
|
├── clients/
|
||||||
│ Startup-/Pending-Cleanup, Footer-Status, Config)
|
│ ├── desktop/ Standalone-Workspace (eigener Cargo.lock + target/)
|
||||||
├── client-desktop/ (doctate-desktop bin; eframe + ffmpeg)
|
│ │ Crate doctate-desktop, Bin doctate, eframe + ffmpeg
|
||||||
└── watch/wearos/ (Kotlin, kein Rust-Member)
|
│ └── wearos/ Gradle (Kotlin), kein Cargo
|
||||||
|
├── experiments/ Standalone-Workspace (eigener Cargo.lock + target/)
|
||||||
|
│ Sandbox: Prompt-Iteration, Sweep-Tools
|
||||||
|
└── (kein Cargo.toml im Root, kein Cargo.lock im Root)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Drei Build-Welten, drei Tests-Befehle.** `cargo test --workspace` aus dem Root ist ohne Wirkung (kein Workspace-Root). Volle Test-Coverage erfordert vier separate Befehle:
|
||||||
|
|
||||||
|
| Welt | Build | Test |
|
||||||
|
|------|-------|------|
|
||||||
|
| server | `cd server && cargo build [--release --locked]` | `cd server && cargo test -j 4` |
|
||||||
|
| clients/desktop | `cd clients/desktop && cargo build` | `cd clients/desktop && cargo test -j 4` |
|
||||||
|
| common | (von Konsumenten gebaut) | `cd common && cargo test -j 4` |
|
||||||
|
| experiments | `cd experiments && cargo build` | `cd experiments && cargo test -j 4` |
|
||||||
|
| clients/wearos | `cd clients/wearos && JAVA_HOME=/opt/android-studio/jbr ./gradlew :app:assembleDebug` | `cd clients/wearos && JAVA_HOME=/opt/android-studio/jbr ./gradlew :app:testDebugUnitTest` |
|
||||||
|
|
||||||
|
Test-Coverage (Stand 2026-05-02): server 385 + clients/desktop 89 + common 34 = **508 Cargo-Tests**, plus Wear-OS-JVM-Tests.
|
||||||
|
|
||||||
|
**Warum drei Welten statt einem Root-Workspace?** Vor dem Split kontaminierte ein gemeinsamer `Cargo.lock` Server-Container-Builds (`cargo build --release --locked`) mit GUI-Lock-Drift: jeder eframe/egui/winit-Bump invalidierte den Server-Build-Cache. Heute hat `server/Cargo.lock` exakt **0** GUI-Deps. Der Container-Build (`cd server && cargo build --release --locked`) bleibt unangetastet, wenn der Desktop-Client iteriert.
|
||||||
|
|
||||||
**`doctate-common`** ist die 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`
|
- `ack::AckResponse`, `ack::AckStatus` — ACK-Typen für `POST /api/upload`
|
||||||
- `oneliners::OnelinersResponse`, `OnelinerEntry`, `ONELINER_FILENAME` — Wire-Format + kanonischer Dateiname für `GET /api/oneliners`
|
- `oneliners::OnelinersResponse`, `OnelinerEntry`, `ONELINER_FILENAME` — Wire-Format + kanonischer Dateiname 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)
|
- `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)
|
||||||
- `bulk::BulkAction` — getyptes Enum für `close`/`analyze`/`reset` im Bulk-Handler, `FromStr` mit stabiler Fehlermeldung. Ersetzt die String-Literale, die vorher zwischen Handler und drei Testdateien dupliziert waren; Handler-Match ist exhaustive.
|
- `bulk::BulkAction` — getyptes Enum für `close`/`analyze`/`reset` im Bulk-Handler, `FromStr` mit stabiler Fehlermeldung. Ersetzt die String-Literale, die vorher zwischen Handler und drei Testdateien dupliziert waren; Handler-Match ist exhaustive.
|
||||||
- `url::join_url` — Helper zum sauberen Zusammensetzen von `base_url` + `path` (trimmt Trailing-Slash + vorangestellte Slashes einheitlich); ersetzt sieben identische `trim_end_matches`+`format!`-Stellen in `client-core`, `client-desktop`, `server/transcribe::{ollama,whisper}` und `server/analyze::llm`.
|
- `url::join_url` — Helper zum sauberen Zusammensetzen von `base_url` + `path` (trimmt Trailing-Slash + vorangestellte Slashes einheitlich); konsumiert von `clients/desktop/src/{server_sync,upload,case_update}`, `server/transcribe::{ollama,whisper}` und `server/analyze::llm`.
|
||||||
- `constants::API_KEY_HEADER` (= `"X-API-Key"`), `UPLOAD_PATH`, `FIELD_CASE_ID`, `FIELD_RECORDED_AT`, `FIELD_AUDIO`, `CONTENT_TYPE_AUDIO_MP4`
|
- `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`, `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`. `common/Cargo.toml` pinnt **konkrete Versionen** statt `workspace = true`, weil common keinen Workspace-Root mehr hat und gleichzeitig von drei Konsumenten (server, clients/desktop, experiments) per `path = "..."`-Dependency gepullt wird. Bei einem Versions-Bump (z.B. `tokio` 1.42 → 1.43) müssen vier Stellen gepflegt werden: `server/[workspace.dependencies]`, `clients/desktop/[workspace.dependencies]`, `common/[dependencies]` (konkrete Version), und `experiments/[dependencies]`.
|
||||||
|
|
||||||
**`doctate-client-core`** ist die neue Lib (seit 2026-04-18) für UI-freie Client-Logik:
|
**Headless Client-Logik** lebt seit dem 2026-05-02 als reguläre Module direkt in `clients/desktop/src/`:
|
||||||
- `case_store` — lokaler Fall-Index mit Unterstützung für Soft-Deletions (Delete-Watermark)
|
- `case_store` — lokaler Fall-Index mit Unterstützung für Soft-Deletions (Delete-Watermark)
|
||||||
|
- `case_update` — `PUT /api/oneliner/{case_id}` (Manual-Override-Pfad)
|
||||||
- `server_sync` — ETag-basiertes Polling gegen `/api/oneliners`, `last_failure`-Tracking
|
- `server_sync` — ETag-basiertes Polling gegen `/api/oneliners`, `last_failure`-Tracking
|
||||||
- `upload` — persistente Pending-Queue mit Retry-Ladder und Sidecar-Metadaten
|
- `upload` — persistente Pending-Queue mit Retry-Ladder und Sidecar-Metadaten
|
||||||
- `snapshot_cache` — serialisierter Poller-Stand auf Disk, damit der Launch keinen Flash-Fehlzustand zeigt
|
- `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)
|
- `startup` + `pending_cleanup` — Konsistenz-Scans beim App-Start (stale Markers / Orphans)
|
||||||
- `footer_status` — konsolidierter UI-Status (Server-Erreichbarkeit, pending-count, last_error)
|
- `footer_status` — konsolidierter UI-Status (Server-Erreichbarkeit, pending-count, last_error)
|
||||||
- `config` — geteilte TOML-Schemas für Client-Konfiguration
|
- `config` — TOML-Schema für Client-Konfiguration (`config_path` ist der OS-Pfad-Wrapper)
|
||||||
|
|
||||||
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.
|
Diese Module sind in `clients/desktop/src/lib.rs` als `pub mod` re-exportiert, damit Wear-OS-Doku-Anker und künftige UniFFI-Bindings auf canonical Crate-Pfade (`doctate-desktop::case_store`, `doctate-desktop::server_sync`, …) zeigen können statt auf interne `crate::`-Pfade. Vor dem 2026-05-02 lebten diese Module in einer separaten Lib `doctate-client-core`; sie wurde aufgelöst, weil es nur einen Rust-Konsumenten (Desktop) gab. Mit Start eines zweiten Rust-Konsumenten (z.B. iOS via UniFFI oder ein Rust-Handy-Client) wandern sie zurück in eine eigene Lib — die Auflösung war bewusst umkehrbar, kein Modul wurde am Inhalt verändert.
|
||||||
|
|
||||||
### Rust Crates (Client-Desktop)
|
### Rust Crates (Client-Desktop)
|
||||||
|
|
||||||
|
`clients/desktop/Cargo.toml` ist ein 1-Member-Workspace mit eigenem `[workspace.dependencies]`-Block:
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
|
[workspace]
|
||||||
|
resolver = "3"
|
||||||
|
|
||||||
|
[workspace.dependencies]
|
||||||
|
serde, serde_json, uuid, time, tokio, reqwest, tracing, toml # heute identisch zu server/
|
||||||
|
|
||||||
|
[package]
|
||||||
|
name = "doctate-desktop"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "doctate" # nicht doctate-desktop
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
# implizit src/lib.rs — re-exportiert alle headless Module als pub mod
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
doctate-common = { path = "../doctate-common" } # Wire-Typen + Konstanten
|
doctate-common = { path = "../../common" } # path-dep, KEIN workspace-dep (common ist nicht Member)
|
||||||
doctate-client-core = { path = "../doctate-client-core" } # Case-Store, Server-Sync, Upload-Queue, Poller, Cleanup
|
eframe = "0.28" # egui + winit
|
||||||
eframe = "0.28" # egui + winit
|
reqwest, tokio, time, serde, serde_json, toml, uuid, tracing # workspace-geerbt
|
||||||
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"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
directories = "5" # OS-konforme Config-/Data-Pfade
|
directories = "5" # OS-konforme Config-/Data-Pfade
|
||||||
thiserror = "1"
|
thiserror = "1"
|
||||||
webbrowser = "1" # Magic-Link: System-Browser öffnen
|
webbrowser = "1" # Magic-Link: System-Browser öffnen
|
||||||
which = "6" # ffmpeg-PATH-Check
|
which = "6" # ffmpeg-PATH-Check
|
||||||
|
filetime = "0.2" # für mtime-basierte Cleanup-Heuristiken
|
||||||
|
|
||||||
[target.'cfg(unix)'.dependencies]
|
[target.'cfg(unix)'.dependencies]
|
||||||
libc = "0.2" # SIGINT via libc::kill
|
libc = "0.2" # SIGINT via libc::kill
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
@@ -1095,6 +1135,8 @@ tokio = { workspace = true, features = ["test-util"] } # für start_paused in
|
|||||||
wiremock = "0.6"
|
wiremock = "0.6"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Bin-Output:** `clients/desktop/target/{debug,release}/doctate` — nicht `doctate-desktop`. `cargo run -p doctate-desktop` aus dem Repo-Root funktioniert nicht mehr (kein Root-Workspace); stattdessen `cd clients/desktop && cargo run`.
|
||||||
|
|
||||||
**Bewusst NICHT verwendet:**
|
**Bewusst NICHT verwendet:**
|
||||||
- `ffmpeg-next`/`libav-sys`: Build-Komplexität (system-ffmpeg-Headers) + GPL/LGPL-Lizenzfragen. Subprozess-Aufruf hat keine Linkage-Probleme.
|
- `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.
|
- `cpal` + `fdk-aac-sys` + `mp4`: wäre ~500 LOC Reimplementierung von ffmpeg für marginalen Benefit.
|
||||||
@@ -1154,6 +1196,22 @@ wiremock = "0.6"
|
|||||||
- [x] README mit Begründung (Halluzinations-Befund), Build- und Deploy-Anleitung
|
- [x] README mit Begründung (Halluzinations-Befund), Build- und Deploy-Anleitung
|
||||||
- [x] Deployed auf minerva:9001, `WHISPER_URL` in `server/.env` umgestellt
|
- [x] Deployed auf minerva:9001, `WHISPER_URL` in `server/.env` umgestellt
|
||||||
|
|
||||||
|
### Phase 2b.6 — Alternative ASR-Modelle: Canary-Sweep (`canary/` + `experiments/`)
|
||||||
|
- [x] Standalone-Service `canary/` (FastAPI auf NVIDIA Canary 1B v2, port 9002 auf minerva). Eigener Docker-Build, deploy-Skript pre-pullt das Base-Image gegen TLS-Cache-Fehler.
|
||||||
|
- [x] Long-Form-Strategie: buffered inference mit chunk/overlap (siehe `canary/CHUNKING.md`)
|
||||||
|
- [x] Sweep-Skripte (`canary/sweep.py`, `canary/sweep_chunklen.py`, `canary/sweep_threshold.py`) für single-shot vs. buffered, greedy vs. beam, chunk_len/overlap/batch/precision-Grid
|
||||||
|
- [x] Probes für Konfidenz und Time-Stamps (`probe_attn_conf.py`, `probe_canary_ts.py`, `probe_ctc_standalone.py`, `probe_tdt.py`); Befund festgehalten in Memory `feedback_asr_confidence_advisory_only`: weder per-token-Confidence noch `dur=0` sind als Halluzinations-Marker brauchbar (Precision ~2.5% auf Real-Korpus)
|
||||||
|
- [x] Dokumentations-Stub: Suppress-Tokens-Sektion (Memory `project_whisper_suppress_tokens_dead_end`: `\d+` produziert Halluzinations-Loops, `\d` löscht Sequenzen — nicht erneut versuchen ohne anderes ASR-Backend)
|
||||||
|
- **Status:** Sweep-Track, **kein** Pipeline-Switch. Live-Server fährt weiterhin Whisper (`WHISPER_URL=…:9001`). Canary bleibt Sandbox bis Qualitäts-/Latenz-Vergleich gegenüber Whisper-Turbo eindeutig ist.
|
||||||
|
- **VRAM-Konflikt** mit Ollama auf minerva (Memory `project_canary_ollama_vram_conflict`): Vor Canary-Start Ollama explizit unloaden — beide passen nicht gleichzeitig in die 12 GB; Whisper + Ollama dagegen koexistieren.
|
||||||
|
|
||||||
|
### Phase 2b.7 — Experiments-Sandbox (`experiments/`)
|
||||||
|
- [x] Standalone-Cargo-Workspace, `path = "../server"`-Dependency (reproduziert Production-Pipeline 1:1 durch direkte Wiederverwendung der Server-Module — kein eigenes HTTP-Re-Implementieren, kein Drift-Risiko zur Live-Pipeline).
|
||||||
|
- [x] Drei Bin-Targets: `run_full_case` (volle Pipeline ffmpeg → whisper → pre-gazetteer → llm → post-gazetteer, n Runs einer Variante), `run_llm_only` (schneller LLM-Iterations-Pfad auf gazetteer-bereinigten Transkripten — spart Whisper-Calls), `diff_runs` (qualitativer Vergleich zwischen zwei Run-Verzeichnissen).
|
||||||
|
- [x] Pre-LLM-Gazetteer-Pass auch in `run_llm_only` (Commit ff53d8b) — damit die Sandbox die Production-Pipeline nicht stillschweigend unterschätzt (Memory `feedback_replicate_full_pipeline_in_sandbox`).
|
||||||
|
- [x] Versionierte Prompt-Iteration unter `experiments/prompts/llm/` (z.B. `v3_treue_konsolidiert.txt` als gewinnende Variante, `v4_treue_kompakt.txt` als verworfen erhalten — `baseline.txt` spiegelt den aktuellen Server-Prompt für Drift-Detection).
|
||||||
|
- [x] Repo-Hygiene: `case_*/` (Patientendaten, Symlinks zu `tmpdata/`) und `_data/` (Run-Outputs) komplett gitignored. Nur Code und fall-übergreifende Prompt-Hypothesen werden versioniert.
|
||||||
|
|
||||||
### Phase 3 — Fallverwaltung
|
### Phase 3 — Fallverwaltung
|
||||||
- [x] Drei States: Empfangen → Transkribiert → Ausgewertet — rein aus FS abgeleitet (`compute_flags` in `user_web.rs`, kein State-Sidecar)
|
- [x] Drei States: Empfangen → Transkribiert → Ausgewertet — rein aus FS abgeleitet (`compute_flags` in `user_web.rs`, kein State-Sidecar)
|
||||||
- [x] State-Übergang: erst "Transkribiert" wenn alle `.m4a` ein passendes `<stem>.json` haben
|
- [x] State-Übergang: erst "Transkribiert" wenn alle `.m4a` ein passendes `<stem>.json` haben
|
||||||
@@ -1186,7 +1244,7 @@ wiremock = "0.6"
|
|||||||
- [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] 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] 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) — HTTP-Range-Requests, `Accept-Ranges: bytes`, 206 Partial Content, Duration aus `{ts}.json` (`duration_seconds`-Feld) für HTML5-Player mit Seeking
|
- [x] 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 aus `{ts}.json` (`duration_seconds`-Feld) für HTML5-Player mit Seeking
|
||||||
- [ ] Replay-Gain-Normalisierung für Wiedergabe (nicht destruktiv) — verschiedene Erfassungsgeräte liefern stark unterschiedliche Pegel (Watch `MIC` ~-38 dB mean, Desktop ~-27 dB mean). PoC am 2026-04-23 erfolgreich, aber nicht committed; Re-Implementierung steht aus. Erprobte Architektur: pro `.m4a` ein `<stem>.loudness.json`-Sidecar mit statischem `gain_db` (aus `ffmpeg -af volumedetect`, Ziel −16 dB mean, Peak-Cap −1 dB). Lazy-Backfill im bestehenden `scan_recordings`-JoinSet analog zum Duration-Sidecar. Browser appliziert den Gain über Web Audio API `GainNode` (kein Disk-Rewrite, Original + Whisper unberührt). Kombiniert mit `AudioSource.VOICE_RECOGNITION` auf der Watch (besseres SNR, siehe 5b). Verworfene Alternativen: `ffmpeg loudnorm` (pumpt + Artefakte), fixes `volume=+XdB` (client-abhängig). Kern-Einsicht: SNR > Loudness an der Source, solange Post-Gain verfügbar ist.
|
- [x] Replay-Gain-Normalisierung für Wiedergabe (nicht destruktiv) — verschiedene Erfassungsgeräte liefern stark unterschiedliche Pegel (Watch `MIC` ~-38 dB mean, Desktop ~-27 dB mean). **Umgesetzt 2026-04-27** (Commits 427a28a, 4a7f84d, 710b60b, 99f77b6): `Loudness { mean_db, max_db, gain_db }` lebt in `RecordingMeta.loudness` (`#[serde(default)]` für Backward-Compat) — single atomic write zusammen mit `transcript` + `duration_seconds` ins `<stem>.json`, **kein** separates Sidecar. Audio-Analyse (`server/src/loudness.rs` + `server/src/transcribe/ffmpeg.rs`) läuft via `ffmpeg -af volumedetect` und ist **concurrent** zum Whisper-Call (`tokio::join!`), damit die Pipeline-Latenz nicht steigt. `analyze::analysis_to_meta_fields` ist als pure Funktion extrahiert (handhabt finite/silent/error). Browser appliziert den Gain über Web Audio API `GainNode` (kein Disk-Rewrite, Original + Whisper unberührt); `RecordingView.gain_db` wird im `case_recordings.html`-Template via `data-gain-db`-Attribut nach unten gereicht, gemeinsame `AudioContext`-Instanz spart Ressourcen. Ziel −16 dB mean, Peak-Cap −1 dB. Kombiniert mit `AudioSource.VOICE_RECOGNITION` auf der Watch (besseres SNR). Verworfene Alternativen: `ffmpeg loudnorm` (pumpt + Artefakte), fixes `volume=+XdB` (client-abhängig). Kern-Einsicht: SNR > Loudness an der Source, solange Post-Gain verfügbar ist.
|
||||||
- [x] Fall analysieren — Button in der Fall-Übersicht
|
- [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"`, Check am Entry-Handler).
|
- [x] Bulk-Aktionen (alle markierten analysieren / löschen) über `POST /web/cases/bulk` — **admin-only** (`AuthenticatedUser::is_admin()` auf `role == "admin"`, Check am Entry-Handler).
|
||||||
- [x] Purge-Closed (`POST /web/cases/purge-closed`, `confirm=yes` Pflicht) — **admin-only**, entfernt geschlossene Fälle hart, emittiert `CaseEventKind::CasePurged` pro entferntem Case.
|
- [x] Purge-Closed (`POST /web/cases/purge-closed`, `confirm=yes` Pflicht) — **admin-only**, entfernt geschlossene Fälle hart, emittiert `CaseEventKind::CasePurged` pro entferntem Case.
|
||||||
@@ -1218,19 +1276,19 @@ wiremock = "0.6"
|
|||||||
- [ ] Gradle-Multi-Modul-Projekt aufsetzen (`:core-domain`, `:core-audio`, `:core-sync`, `:core-http`, `:core-storage`, `:app-wear`, `:app-mobile` als Platzhalter) — PoC (2026-04-23) läuft vorerst paket-basiert in einem `:app`-Modul; Split wird spätestens beim Start der Handy-App fällig (siehe Abweichungen → Client-Architektur).
|
- [ ] Gradle-Multi-Modul-Projekt aufsetzen (`:core-domain`, `:core-audio`, `:core-sync`, `:core-http`, `:core-storage`, `:app-wear`, `:app-mobile` als Platzhalter) — PoC (2026-04-23) läuft vorerst paket-basiert in einem `:app`-Modul; Split wird spätestens beim Start der Handy-App fällig (siehe Abweichungen → Client-Architektur).
|
||||||
- [ ] `:core-*`-Module strikt UI-frei halten (kein Import von `android.*`/`androidx.compose.*` erlaubt — Build schlägt sonst fehl)
|
- [ ] `: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) — Teil-abgedeckt im `:app`-Modul: `CaseIdTest` (UUID-v4-Vertrag) und `UiStateReducerTest` (10 Übergänge inkl. Recorder-Failure). Backoff-Kurve fehlt noch, weil keine Retry-Queue existiert.
|
- [~] JVM-Unit-Tests für `:core-domain` und `:core-sync` (case_id-Generierung, ACK-Zustandsautomat, Backoff-Kurve) — Teil-abgedeckt im `:app`-Modul: `CaseIdTest` (UUID-v4-Vertrag) und `UiStateReducerTest` (10 Übergänge inkl. Recorder-Failure). Backoff-Kurve fehlt noch, weil keine Retry-Queue existiert.
|
||||||
- [x] API-Client (`:core-http`): Endpoints typisiert, API-Key im Header, Reqwest-Äquivalent (Ktor oder OkHttp) — implementiert als `net/UploadClient` mit **OkHttp 4.12.0**, Wire-Protocol 1:1 zu `doctate-client-core/src/server_sync.rs:344-399`. Siehe Abweichungen.
|
- [x] API-Client (`:core-http`): Endpoints typisiert, API-Key im Header, Reqwest-Äquivalent (Ktor oder OkHttp) — implementiert als `net/UploadClient` mit **OkHttp 4.12.0**, Wire-Protocol 1:1 zu `doctate-desktop::server_sync` (heute `clients/desktop/src/server_sync.rs`). Siehe Abweichungen.
|
||||||
|
|
||||||
#### 5b — Pixel Watch App (primäres Entwicklungsziel)
|
#### 5b — Pixel Watch App (primäres Entwicklungsziel)
|
||||||
- [x] Wear OS Projekt in Android Studio (`:app-wear`) — `watch/wearos/` Gradle-Projekt (AGP 9.2, Kotlin 2.2.10, Compose BOM 2024.09, minSdk 30, targetSdk 36). **Single `:app` statt `:app-wear` + `:core-*`.**
|
- [x] Wear OS Projekt in Android Studio (`:app-wear`) — `clients/wearos/` Gradle-Projekt (AGP 9.2, Kotlin 2.2.10, Compose BOM 2024.09, minSdk 30, targetSdk 36). **Single `:app` statt `:app-wear` + `:core-*`.**
|
||||||
- [x] Jetpack Compose UI — PoC-UI umfasst jetzt `CaseListScreen` + `CaseDetailScreen` + `RecordingScreen` plus `AppNav`/`NavCommand`-Navigation. Nur die Datenquelle ist noch Stub (siehe unten).
|
- [x] Jetpack Compose UI — PoC-UI umfasst jetzt `CaseListScreen` + `CaseDetailScreen` + `RecordingScreen` plus `AppNav`/`NavCommand`-Navigation. Nur die Datenquelle ist noch Stub (siehe unten).
|
||||||
- [~] Activity-Hauptscreen: Fallliste als `ScalingLazyColumn` + EdgeButton „● Neu" (funktional-analog zum Desktop-Client). Tap auf Eintrag → Fortsetzen; Tap auf EdgeButton → neuer Fall. — UI-Scaffold gebaut (reverse-chronologisch, Auto-Scroll zum neuesten, Auto-Centering deaktiviert, `CaseRow` mit minHeight/Padding/Ellipsis); Tap auf Eintrag öffnet jetzt erst `CaseDetailScreen` (siehe Client-Architektur → Watch-Recording-Flow), „● Neu" überspringt den Detail-Schritt. Datenquelle noch `CaseStoreStub`.
|
- [~] Activity-Hauptscreen: Fallliste als `ScalingLazyColumn` + EdgeButton „● Neu" (funktional-analog zum Desktop-Client). Tap auf Eintrag → Fortsetzen; Tap auf EdgeButton → neuer Fall. — UI-Scaffold gebaut (reverse-chronologisch, Auto-Scroll zum neuesten, Auto-Centering deaktiviert, `CaseRow` mit minHeight/Padding/Ellipsis); Tap auf Eintrag öffnet jetzt erst `CaseDetailScreen` (siehe Client-Architektur → Watch-Recording-Flow), „● Neu" überspringt den Detail-Schritt. Datenquelle noch `CaseStoreStub`.
|
||||||
- [~] Singleton-`CaseStore` in Kotlin (Pendant zum Rust-`CaseStore` in `doctate-client-core`): Snapshot-Flow, `create_local` / `mark_activity` / `merge_server_snapshot` / `reconcile_with_server_snapshot`. Gemeinsame Datenquelle für Activity + TileService. — **PoC: `CaseStoreStub`** (in-memory Singleton, Demo-Seed) speist Activity + Tile + Complication aus derselben Quelle. Snapshot-Flow + Merge-Asymmetrie zum Rust-Pendant stehen noch aus, ebenso die Bindung an Marker-Dateien.
|
- [~] Singleton-`CaseStore` in Kotlin (Pendant zum Rust-`CaseStore` in `doctate-desktop::case_store`): Snapshot-Flow, `create_local` / `mark_activity` / `merge_server_snapshot` / `reconcile_with_server_snapshot`. Gemeinsame Datenquelle für Activity + TileService. — **PoC: `CaseStoreStub`** (in-memory Singleton, Demo-Seed) speist Activity + Tile + Complication aus derselben Quelle. Snapshot-Flow + Merge-Asymmetrie zum Rust-Pendant stehen noch aus, ebenso die Bindung an Marker-Dateien.
|
||||||
- [~] Tile-Service (`DoctateTileService` via androidx.wear.tiles): ProtoLayout mit drei Tap-Regionen — Mitte (OneLiner → Fortsetzen), oben-rechts (☰ Fälle → Activity/Liste), EdgeButton (● Neu → Activity/Recording). Refresh via `getUpdater().requestUpdate(...)` nach jedem `CaseStore`-Merge. — PoC gebaut (Tap-Regionen: continue-case / list / new-case). Refresh-Trigger nach Merge fehlt, solange der echte `CaseStore` fehlt.
|
- [~] Tile-Service (`DoctateTileService` via androidx.wear.tiles): ProtoLayout mit drei Tap-Regionen — Mitte (OneLiner → Fortsetzen), oben-rechts (☰ Fälle → Activity/Liste), EdgeButton (● Neu → Activity/Recording). Refresh via `getUpdater().requestUpdate(...)` nach jedem `CaseStore`-Merge. — PoC gebaut (Tap-Regionen: continue-case / list / new-case). Refresh-Trigger nach Merge fehlt, solange der echte `CaseStore` fehlt.
|
||||||
- [~] Complication-Service (`ComplicationDataSourceService`): `SHORT_TEXT` oder `SMALL_IMAGE`, Tap → MainActivity mit `open=list`. Kein OneLiner-Text im Slot. — PoC: `DoctateComplicationService` (SHORT_TEXT „Doctate", Tap → Fallliste).
|
- [~] Complication-Service (`ComplicationDataSourceService`): `SHORT_TEXT` oder `SMALL_IMAGE`, Tap → MainActivity mit `open=list`. Kein OneLiner-Text im Slot. — PoC: `DoctateComplicationService` (SHORT_TEXT „Doctate", Tap → Fallliste).
|
||||||
- [ ] Nach Stop: Screen bleibt auf aktuellem Fall (→ „Fortsetzen" direkt sichtbar) — aktuell: Stop poppt `RecordingScreen` zurück auf `CaseDetailScreen` (oder auf die Liste, wenn „● Neu" den Detail-Schritt übersprungen hat). Der „Fortsetzen"-EdgeButton sitzt dort, kostet aber einen Zusatz-Tap gegenüber der ursprünglichen Plan-Skizze.
|
- [ ] Nach Stop: Screen bleibt auf aktuellem Fall (→ „Fortsetzen" direkt sichtbar) — aktuell: Stop poppt `RecordingScreen` zurück auf `CaseDetailScreen` (oder auf die Liste, wenn „● Neu" den Detail-Schritt übersprungen hat). Der „Fortsetzen"-EdgeButton sitzt dort, kostet aber einen Zusatz-Tap gegenüber der ursprünglichen Plan-Skizze.
|
||||||
- [ ] Post-Stop-Burst-Polling: 2 s Intervall, 60 s Budget, dann Abstieg ins reguläre Intervall; triggert Tile-Refresh bei Oneliner-Treffer
|
- [ ] Post-Stop-Burst-Polling: 2 s Intervall, 60 s Budget, dann Abstieg ins reguläre Intervall; triggert Tile-Refresh bei Oneliner-Treffer
|
||||||
- [x] MediaRecorder-Integration via `:core-audio` — in `audio/AudioRecorder.kt` (MPEG-4/AAC-LC, 16 kHz mono, 64 kbps, `context.cacheDir`). Landet bei künftigem Multi-Modul-Split in `:core-audio`.
|
- [x] MediaRecorder-Integration via `:core-audio` — in `audio/AudioRecorder.kt` (MPEG-4/AAC-LC, 16 kHz mono, 64 kbps, `context.cacheDir`). Landet bei künftigem Multi-Modul-Split in `:core-audio`.
|
||||||
- [ ] `AudioSource.MIC` → `VOICE_RECOGNITION` umstellen — Wear-OS-DSP (Noise-Suppression, AGC) liefert saubereres Signal bei niedrigerem Rohpegel. Geht nur zusammen mit serverseitigem Replay-Gain (siehe Phase 4), weil VR allein ~9 dB leiser ist als MIC. PoC am 2026-04-23 bestätigt: VR + Replay-Gain klingt deutlich besser als MIC + Replay-Gain (letzteres verstärkt auch Rauschen mit).
|
- [x] `AudioSource.MIC` → `VOICE_RECOGNITION` umgestellt (2026-04-27, Commit 99f77b6) — Wear-OS-DSP (Noise-Suppression, AGC) liefert saubereres Signal bei niedrigerem Rohpegel. Funktioniert nur zusammen mit serverseitigem Replay-Gain (siehe Phase 4), weil VR allein ~9 dB leiser ist als MIC. Validierung: VR + Replay-Gain klingt deutlich besser als MIC + Replay-Gain (letzteres verstärkt auch Rauschen mit).
|
||||||
- [ ] Bluetooth-Headset Erkennung + Indikator
|
- [ ] Bluetooth-Headset Erkennung + Indikator
|
||||||
- [~] case_id (UUIDv4) Generierung: Neu → neue case_id, Fortsetzen → bestehende — PoC erzeugt `CaseId.new()` pro Aufnahme (`domain/CaseId.kt`). Neu/Fortsetzen-Dualität fehlt, weil keine Fallliste-UI existiert.
|
- [~] case_id (UUIDv4) Generierung: Neu → neue case_id, Fortsetzen → bestehende — PoC erzeugt `CaseId.new()` pro Aufnahme (`domain/CaseId.kt`). Neu/Fortsetzen-Dualität fehlt, weil keine Fallliste-UI existiert.
|
||||||
- [~] Persistente Speicherung im lokalen Dateisystem (UTC-Timestamps in Dateinamen) — PoC speichert in `cacheDir` und **löscht nach Upload**; Datei lebt also nur während der laufenden Pipeline. `unsynced/`-Pattern (überlebt Neustarts) fehlt.
|
- [~] Persistente Speicherung im lokalen Dateisystem (UTC-Timestamps in Dateinamen) — PoC speichert in `cacheDir` und **löscht nach Upload**; Datei lebt also nur während der laufenden Pipeline. `unsynced/`-Pattern (überlebt Neustarts) fehlt.
|
||||||
@@ -1265,8 +1323,8 @@ wiremock = "0.6"
|
|||||||
|
|
||||||
#### 5e — Desktop-Clients (in Arbeit, nativer Rust-Stack)
|
#### 5e — Desktop-Clients (in Arbeit, nativer Rust-Stack)
|
||||||
- [x] Tech-Stack entschieden: `eframe`/`egui` + `tokio` + `reqwest` + `ffmpeg`-Subprozess (nicht Tauri/Compose)
|
- [x] Tech-Stack entschieden: `eframe`/`egui` + `tokio` + `reqwest` + `ffmpeg`-Subprozess (nicht Tauri/Compose)
|
||||||
- [x] Cargo-Workspace + `doctate-common`-Shared-Lib
|
- [x] Cargo-Topologie: drei isolierte Build-Welten (`server/`, `clients/desktop/`, `experiments/`) + lib-only `common/`. Bis 2026-05-02 ein gemeinsamer Root-Workspace; aufgelöst zur Lock-Isolation Server vs. Client-GUI (Container-Build sieht keine eframe/egui/winit-Drift mehr). Siehe Abweichungen → Client-Architektur.
|
||||||
- [x] Zweite Shared-Lib `doctate-client-core` für UI-freie Client-Logik (Case-Store, Server-Sync, Upload, Poller, Cleanup)
|
- [x] Headless Client-Logik (Case-Store, Server-Sync, Upload, Poller, Cleanup) — bis 2026-05-02 in der Lib `doctate-client-core`, seitdem als reguläre Module direkt in `clients/desktop/src/` (in `lib.rs` als `pub mod` re-exportiert)
|
||||||
- [x] Linux-Desktop: Config, Recorder (SIGINT-Stop), Uploader mit Retry + persistenter Queue, State-Machine, UI mit Fehlerpfad
|
- [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] Single-Instance-Lock (PID-File, verhindert konkurrierende Pending-Queue-Zugriffe)
|
||||||
- [x] Snapshot-Cache-Persistenz (Poller-State überlebt Neustart, kein Flash-Fehlzustand)
|
- [x] Snapshot-Cache-Persistenz (Poller-State überlebt Neustart, kein Flash-Fehlzustand)
|
||||||
@@ -1278,7 +1336,7 @@ wiremock = "0.6"
|
|||||||
- [x] Integration-Tests für Magic-Link-Flow und Single-Instance-Lock
|
- [x] Integration-Tests für Magic-Link-Flow und Single-Instance-Lock
|
||||||
- [ ] Windows-Desktop: `dshow`-Device-Enumeration; ffmpeg-Installationspfad (winget vs. Bundle)
|
- [ ] 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)
|
- [ ] 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)
|
- [ ] iOS-App (SwiftUI, Ausblick; würde die Headless-Module aus `clients/desktop/src/` via UniFFI konsumieren — bei zweitem Rust-Konsumenten wandern die Module in eine separate Lib zurück)
|
||||||
- [x] Invariante gewahrt: jeder Client = weiterer HTTP-Client, kein Server-Code-Ausbau nötig
|
- [x] Invariante gewahrt: jeder Client = weiterer HTTP-Client, kein Server-Code-Ausbau nötig
|
||||||
|
|
||||||
### Phase 6 — Integration & Testing
|
### Phase 6 — Integration & Testing
|
||||||
@@ -1320,6 +1378,7 @@ wiremock = "0.6"
|
|||||||
| Thema | Status |
|
| 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`. |
|
| 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`. |
|
||||||
|
| Alternative ASR | **Sandbox-Track:** `canary/`-Service auf NVIDIA Canary 1B v2 (port 9002) inkl. Sweep-Skripten für single-shot/buffered + chunk/overlap/precision-Grid. Probes für token-Confidence und `dur=0`-Halluzinations-Marker liefen, Befund: weder Confidence noch `dur=0` ist als Halluzinations-Filter brauchbar. Live-Pipeline bleibt auf Whisper, bis Canary-Vergleich qualitativ entscheidet. VRAM-Konflikt mit Ollama: Canary-Start setzt Ollama-Unload voraus. |
|
||||||
| Ollama Modell | **Entschieden (vorläufig):** `gemma4:latest` (läuft). Llama 3.1 8B als Option, wenn Oneliner-Qualität nicht reicht. |
|
| 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 |
|
| Ionos Modell | Noch zu evaluieren |
|
||||||
| Watch Hardware | Pixel Watch 2 oder 3 (LTE empfohlen) |
|
| Watch Hardware | Pixel Watch 2 oder 3 (LTE empfohlen) |
|
||||||
@@ -1376,6 +1435,7 @@ Alle Einträge beziehen sich auf den Ist-Stand im Repository. Die ursprüngliche
|
|||||||
| 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). |
|
| 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. |
|
| 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. |
|
| 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. |
|
||||||
|
| Audio-Loudness-Persistenz | Geplant als separates Sidecar `<stem>.loudness.json` (eigener atomic write, Lazy-Backfill in `scan_recordings`-JoinSet) | **In `<stem>.json` integriert:** Field `loudness: { mean_db, max_db, gain_db }` als optionaler Teil von `RecordingMeta` mit `#[serde(default)]`. Single atomic write zusammen mit Transcript + Duration. Audio-Analyse (`server/src/loudness.rs` + `transcribe/ffmpeg.rs::probe_loudness`) läuft via `tokio::join!` parallel zu Whisper. Mapping pure: `analyze::analysis_to_meta_fields`. | Zwei Writes für ein Recording sind zwei Failure-Modi und zwei Recovery-Pfade — `RecordingMeta` ist sowieso Single-Write am Pipeline-Ende, also kommt Loudness ins gleiche JSON. `tokio::join!` verbindet zwei externe Calls, deren dominanter (Whisper) die Latenz definiert: Loudness ist bei jedem Audio kostenlos zu haben. `#[serde(default)]` hält ältere `<stem>.json` ohne Loudness lesbar — Pre-Production-Migration ist trotzdem überflüssig (Memory `project_pre_production_no_migration`). Browser appliziert `gain_db` über Web-Audio-API `GainNode`; Originaldatei bleibt unverändert (Whisper-Input und Replay sind getrennt). |
|
||||||
|
|
||||||
### Pipeline und LLM-Nutzung
|
### Pipeline und LLM-Nutzung
|
||||||
|
|
||||||
@@ -1403,7 +1463,7 @@ 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. |
|
| 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. |
|
| 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. |
|
||||||
| Test-Infrastruktur | Jede Integrationstest-Datei brachte eigenen Harness mit (User-Factories, TestConfig-Builder, Login-Flow inline) | **Shared-Modul `server/tests/common/`** (2026-04-22/23): `config`, `http`, `paths`, `seed`, `session`, `users`, `artefacts`. 95 Tests migriert, ~780 Zeilen Duplikat entfernt, neue Tests starten mit drei Zeilen statt achtzig. | Beim Einführen von CSRF mussten alle POST-Tests durch `common::session` den Login→CSRF→POST-Flow abwickeln — ad-hoc-Harnesses hätten jede Testdatei gleichzeitig umgebaut. Zusätzlicher Gewinn: `health_test` nutzte vorher einen hartcodierten `/tmp/doctate-test`-Pfad, der bei parallelen `cargo test`-Läufen kollidierte; das einheitliche Unique-Tmpdir-Pattern hat diese Flake entfernt. |
|
| Test-Infrastruktur | Jede Integrationstest-Datei brachte eigenen Harness mit (User-Factories, TestConfig-Builder, Login-Flow inline) | **Shared-Modul `server/tests/common/`** (2026-04-22/23): `config`, `http`, `paths`, `seed`, `session`, `users`, `artefacts`. 95 Tests migriert, ~780 Zeilen Duplikat entfernt, neue Tests starten mit drei Zeilen statt achtzig. | Beim Einführen von CSRF mussten alle POST-Tests durch `common::session` den Login→CSRF→POST-Flow abwickeln — ad-hoc-Harnesses hätten jede Testdatei gleichzeitig umgebaut. Zusätzlicher Gewinn: `health_test` nutzte vorher einen hartcodierten `/tmp/doctate-test`-Pfad, der bei parallelen `cargo test`-Läufen kollidierte; das einheitliche Unique-Tmpdir-Pattern hat diese Flake entfernt. |
|
||||||
| Konsolidierung wiederholter Muster | Bulk-Action-Literals, URL-Assembly, Case-Artefakt-Dateinamen als inline-Strings an 10+ Stellen | Drei Helper-Gruppen in `doctate-common` + `server/tests/common/artefacts.rs` (siehe Cargo-Workspace-Layout): `BulkAction`-Enum, `join_url`, und zentral re-exportierte Artefakt-Filenames (`ONELINER_FILENAME`, `DOCUMENT_FILE`, `ANALYSIS_INPUT_FILE`, `CLOSE_MARKER`). Zusätzlich in `client-desktop`: `paths::project_path`-Helper, `main::or_die`-Helper, `RecordingContext`-Struct statt `(Uuid, String)`-Tupel. | Rename oder Wire-Format-Tweak bearbeitet jetzt eine Datei statt 10+. `BulkAction`-Match ist exhaustive — neue Actions fallen beim Kompilieren auf. |
|
| Konsolidierung wiederholter Muster | Bulk-Action-Literals, URL-Assembly, Case-Artefakt-Dateinamen als inline-Strings an 10+ Stellen | Drei Helper-Gruppen in `doctate-common` + `server/tests/common/artefacts.rs` (siehe Cargo-Workspace-Layout): `BulkAction`-Enum, `join_url`, und zentral re-exportierte Artefakt-Filenames (`ONELINER_FILENAME`, `DOCUMENT_FILE`, `ANALYSIS_INPUT_FILE`, `CLOSE_MARKER`). Zusätzlich in `clients/desktop/`: `paths::project_path`-Helper, `main::or_die`-Helper, `RecordingContext`-Struct statt `(Uuid, String)`-Tupel. | Rename oder Wire-Format-Tweak bearbeitet jetzt eine Datei statt 10+. `BulkAction`-Match ist exhaustive — neue Actions fallen beim Kompilieren auf. |
|
||||||
|
|
||||||
### Authentifizierung
|
### Authentifizierung
|
||||||
|
|
||||||
@@ -1424,21 +1484,21 @@ Alle Einträge beziehen sich auf den Ist-Stand im Repository. Die ursprüngliche
|
|||||||
|
|
||||||
| Änderung | Original | Aktuell | Grund |
|
| Ä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. |
|
| Repository-Layout | `server/` standalone + `watch/wearos/` separat | **Drei isolierte Cargo-Build-Welten** (2026-05-02): `server/` (Standalone-Workspace, Container-Build), `clients/desktop/` (Standalone-Workspace, GUI), `experiments/` (Standalone-Workspace, Sandbox); plus reine Source-Lib `common/` (kein Workspace, von allen drei Welten als `path = "..."`-Dependency gepullt) und das Wear-OS-Gradle-Projekt unter `clients/wearos/`. Vorgeschichte: kurzzeitig (2026-04-18 – 2026-05-02) Root-Cargo-Workspace mit vier Mitgliedern (server, doctate-common, doctate-client-core, client-desktop). | **Lock-Isolation Server vs. Client-GUI.** Im Root-Workspace zog `cargo build --release --locked` für den Server-Container den vollen `eframe`/`egui`/`winit`-Stack mit ins `Cargo.lock` — jeder GUI-Bump invalidierte den Server-Build-Cache, jeder Server-Tokio-Bump zwang einen GUI-Rebuild. Mit drei `Cargo.lock`-Files iteriert jede Welt unabhängig; `server/Cargo.lock` enthält 0 GUI-Deps. Test-Coverage bleibt erhalten (508 Cargo-Tests, jetzt verteilt auf 385/89/34 zwischen den Welten). Trade-off: gemeinsame Versions-Pins (`tokio`, `serde`, `time` etc.) müssen jetzt manuell synchron in vier `[workspace.dependencies]`/`[dependencies]`-Blöcken gepflegt werden. |
|
||||||
| 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. |
|
| Client-Shared-Lib-Grenze | `doctate-common` enthält alles, was zwischen Server und Client geteilt wird | Zwei Schritte: (1) seit 2026-04-18 zweistufig — `doctate-common` runtime-agnostisch, `doctate-client-core` runtime-behaftete Client-Logik (tokio + reqwest). (2) Seit 2026-05-02 nur noch eine geteilte Lib (`doctate-common`); die ehemaligen `doctate-client-core`-Module (case_store, case_update, config, footer_status, pending_cleanup, server_sync, snapshot_cache, startup, upload) leben jetzt direkt in `clients/desktop/src/` und sind über `lib.rs`-Re-Exports unter `doctate-desktop::*` adressierbar. | `doctate-common` soll WASM-fähig bleiben (kein tokio, kein reqwest) — diese Grenze bleibt scharf. Eine zweite Lib hat sich aber nicht gerechnet: nur ein Rust-Konsument (Desktop) heißt, jeder Cross-Crate-Aufruf war Boilerplate ohne Code-Sharing-Gewinn. Re-Exports über `clients/desktop/src/lib.rs` halten die canonical Pfade (`doctate-desktop::case_store`) für künftige UniFFI-Bindings stabil — wenn ein zweiter Rust-Konsument auftaucht (iOS, Handy-Rust), wandern die Module ohne API-Bruch in eine eigene Lib zurück. |
|
||||||
| 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). |
|
| 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-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-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. |
|
| 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. |
|
||||||
| Watch-Modul-Struktur | Multi-Modul-Gradle-Projekt (`:core-domain`, `:core-audio`, `:core-sync`, `:core-http`, `:core-storage`, `:app-wear`, `:app-mobile`) — "nicht optional" | Single `:app`-Modul mit paket-basierter Trennung: `presentation/`, `audio/`, `net/`, `settings/`, `domain/`. Split auf Multi-Modul spätestens beim Handy-App-Start (Phase 5c). | Für den vertikalen PoC (2026-04-23) war Multi-Modul reine Build-Ergonomie-Last ohne Mehrwert — alle Schichten sind testbar getrennt, Imports zwischen `presentation` und `audio`/`net` sind manuell diszipliniert. Beim Handy-App-Ausbau zwingt das fehlende Modul zur ersten echten Code-Duplikation — das ist der natürliche Trigger-Punkt für den Split. |
|
| Watch-Modul-Struktur | Multi-Modul-Gradle-Projekt (`:core-domain`, `:core-audio`, `:core-sync`, `:core-http`, `:core-storage`, `:app-wear`, `:app-mobile`) — "nicht optional" | Single `:app`-Modul mit paket-basierter Trennung: `presentation/`, `audio/`, `net/`, `settings/`, `domain/`. Split auf Multi-Modul spätestens beim Handy-App-Start (Phase 5c). | Für den vertikalen PoC (2026-04-23) war Multi-Modul reine Build-Ergonomie-Last ohne Mehrwert — alle Schichten sind testbar getrennt, Imports zwischen `presentation` und `audio`/`net` sind manuell diszipliniert. Beim Handy-App-Ausbau zwingt das fehlende Modul zur ersten echten Code-Duplikation — das ist der natürliche Trigger-Punkt für den Split. |
|
||||||
| Watch-HTTP-Client | Im Plan offen als "Reqwest-Äquivalent (Ktor oder OkHttp)" | **OkHttp 4.12.0** mit `kotlinx-coroutines-android`; Multipart-API ist API-Klon zu `reqwest::multipart::Form` | API-Nähe zum bestehenden `doctate-client-core/src/server_sync.rs` (1:1-Portierung der `post_upload`-Klassifikation), kleinere APK als Ktor (keine zusätzliche Engine + Serialization-Transitiv), MockWebServer ist im selben Maven-Artefakt für Wire-Tests. Ktor bleibt Option, falls wir später einen WASM-Browser-Client wollen. |
|
| Watch-HTTP-Client | Im Plan offen als "Reqwest-Äquivalent (Ktor oder OkHttp)" | **OkHttp 4.12.0** mit `kotlinx-coroutines-android`; Multipart-API ist API-Klon zu `reqwest::multipart::Form` | API-Nähe zum bestehenden `doctate-desktop::server_sync` (heute `clients/desktop/src/server_sync.rs`, 1:1-Portierung der `post_upload`-Klassifikation), kleinere APK als Ktor (keine zusätzliche Engine + Serialization-Transitiv), MockWebServer ist im selben Maven-Artefakt für Wire-Tests. Ktor bleibt Option, falls wir später einen WASM-Browser-Client wollen. |
|
||||||
| Watch-Dependency-Injection | Nicht explizit im Plan (impliziert Hilt, das Android-Studio-Scaffold-Default) | **Service-Locator** in `DoctateApp : Application` mit `by lazy`-Properties | Für vier Singletons (Settings, OkHttpClient, UploadClient, AudioRecorder) rechtfertigen keinen Annotation-Processor + Compile-Time-Graph. `ViewModelProvider.Factory` via `viewModelFactory { initializer { ... } }` gibt Constructor-Injection ohne DI-Framework. Bei deutlich mehr Singletons (~10+) ist Hilt der natürliche Aufrüstpfad, weil `by lazy`-Boilerplate dann skaliert. |
|
| Watch-Dependency-Injection | Nicht explizit im Plan (impliziert Hilt, das Android-Studio-Scaffold-Default) | **Service-Locator** in `DoctateApp : Application` mit `by lazy`-Properties | Für vier Singletons (Settings, OkHttpClient, UploadClient, AudioRecorder) rechtfertigen keinen Annotation-Processor + Compile-Time-Graph. `ViewModelProvider.Factory` via `viewModelFactory { initializer { ... } }` gibt Constructor-Injection ohne DI-Framework. Bei deutlich mehr Singletons (~10+) ist Hilt der natürliche Aufrüstpfad, weil `by lazy`-Boilerplate dann skaliert. |
|
||||||
| Watch-Settings-Kanal | Nicht explizit im Plan | `local.properties` (gitignored) speist `BuildConfigField("SERVER_URL", "API_KEY")`; `Settings`-Interface + `BuildConfigSettings`-Impl erlauben späteres `DataStoreSettings` ohne Konsumenten-Refactor | Watch hat kein ergonomisches Text-Input-UI; Build-Time-Konstante via `local.properties` ist die einzige pragmatische Lösung, bis ein Settings-Screen existiert. Default `"MISSING_API_KEY"` bei fehlender Property sorgt für sichtbaren 401-Fehler statt unsichtbarer Silent-Fail. |
|
| Watch-Settings-Kanal | Nicht explizit im Plan | `local.properties` (gitignored) speist `BuildConfigField("SERVER_URL", "API_KEY")`; `Settings`-Interface + `BuildConfigSettings`-Impl erlauben späteres `DataStoreSettings` ohne Konsumenten-Refactor | Watch hat kein ergonomisches Text-Input-UI; Build-Time-Konstante via `local.properties` ist die einzige pragmatische Lösung, bis ein Settings-Screen existiert. Default `"MISSING_API_KEY"` bei fehlender Property sorgt für sichtbaren 401-Fehler statt unsichtbarer Silent-Fail. |
|
||||||
| Watch-Case-ID-Strategie im PoC | Plan: "Neu" → neue case_id + Marker, "Fortsetzen" → bestehende | **PoC**: jede Aufnahme = neue `UUID.randomUUID()`; keine Marker-Datei, keine Fortsetzen-Logik | Solange keine Fallliste-UI existiert, gibt es keinen Mechanismus zum Auswählen eines bestehenden Cases. Neu-pro-Aufnahme matcht das Server-Verhalten (unbekannte UUID → neuer Case-Ordner) und hält den PoC-Scope klein. Umstellung auf Neu/Fortsetzen berührt nur das ViewModel (Case-Store hält die "aktuelle" ID), nicht `UploadClient` oder `AudioRecorder`. |
|
| Watch-Case-ID-Strategie im PoC | Plan: "Neu" → neue case_id + Marker, "Fortsetzen" → bestehende | **PoC**: jede Aufnahme = neue `UUID.randomUUID()`; keine Marker-Datei, keine Fortsetzen-Logik | Solange keine Fallliste-UI existiert, gibt es keinen Mechanismus zum Auswählen eines bestehenden Cases. Neu-pro-Aufnahme matcht das Server-Verhalten (unbekannte UUID → neuer Case-Ordner) und hält den PoC-Scope klein. Umstellung auf Neu/Fortsetzen berührt nur das ViewModel (Case-Store hält die "aktuelle" ID), nicht `UploadClient` oder `AudioRecorder`. |
|
||||||
| Watch-Datei-Lifecycle im PoC | Plan: Persistentes `unsynced/`-Verzeichnis, Sync-Service löscht nach ACK | **PoC**: Aufnahme in `cacheDir`, im `finally`-Block des Upload-Flows gelöscht — egal ob Success, Transient oder Terminal | Für den vertikalen Beweis unnötig, Crash-Recovery zu implementieren. Volle Pending-Queue mit Marker + Retry + Backoff kommt in der Arbeitsphase nach dem PoC (entspricht den noch offenen Phase-5b-Tasks). |
|
| Watch-Datei-Lifecycle im PoC | Plan: Persistentes `unsynced/`-Verzeichnis, Sync-Service löscht nach ACK | **PoC**: Aufnahme in `cacheDir`, im `finally`-Block des Upload-Flows gelöscht — egal ob Success, Transient oder Terminal | Für den vertikalen Beweis unnötig, Crash-Recovery zu implementieren. Volle Pending-Queue mit Marker + Retry + Backoff kommt in der Arbeitsphase nach dem PoC (entspricht den noch offenen Phase-5b-Tasks). |
|
||||||
| Watch-Instrumented-Test-Hang | Nicht im Plan | `UploadClientTest` (MockWebServer-basiert) **hängt in `@Before setUp()`** auf Wear-OS-34-AVD. JVM-Unit-Tests laufen. Real-Server-E2E validiert denselben Vertrag strenger. | Vermutlich Wear-SELinux-Policy gegen Loopback-Socket-Bind aus der Instrumentation-APK-Prozess. Priorität niedrig, weil der echte Server-Upload funktioniert — Mock-Test ist nur Regressions-Absicherung fürs Refactoring. `SKIP_INSTRUMENTED=1 ./run.sh test` überspringt den on-device-Tier. |
|
| Watch-Instrumented-Test-Hang | Nicht im Plan | `UploadClientTest` (MockWebServer-basiert) **hängt in `@Before setUp()`** auf Wear-OS-34-AVD. JVM-Unit-Tests laufen. Real-Server-E2E validiert denselben Vertrag strenger. | Vermutlich Wear-SELinux-Policy gegen Loopback-Socket-Bind aus der Instrumentation-APK-Prozess. Priorität niedrig, weil der echte Server-Upload funktioniert — Mock-Test ist nur Regressions-Absicherung fürs Refactoring. `SKIP_INSTRUMENTED=1 ./run.sh test` überspringt den on-device-Tier. |
|
||||||
| Watch-UI-Surface-Aufteilung | Plan (alt): „ein Screen pro Eintrag, Swipe hoch/runter zwischen Fällen", konzeptionell als Tile-Paginierung gedacht | **Drei native Wear-OS-Surfaces** in getrennten Rollen: Activity (`ScalingLazyColumn` + EdgeButton „● Neu") = Fallliste + Recording; Tile (ProtoLayout mit drei Tap-Regionen: OneLiner-Mitte / ☰ Fälle / EdgeButton „● Neu") = Glance auf den aktuellen Fall; Complication (`SHORT_TEXT`/`SMALL_IMAGE`, Tap → Liste) = optionaler Fast-Launch vom Watchface | Tile-Paginierung ist technisch unmöglich: ProtoLayout ist nicht scrollbar, Long-Press und vertikaler Swipe sind System-Gesten (Tile-Edit-Karussell / Quick-Panel) und von Apps nicht abfangbar. Die einzige App-seitige Interaktion auf einem Tile ist Tap auf `Clickable`-Regionen — mehrere pro Tile sind HIG-konform und von System-Tiles etabliert. Activity + Tile + Complication teilen sich einen Singleton-`CaseStore`-Snapshot (Kotlin-Pendant zum Rust-`CaseStore`), damit sie nie divergieren; Tile-Refresh via `getUpdater().requestUpdate()` nach jedem Merge. |
|
| Watch-UI-Surface-Aufteilung | Plan (alt): „ein Screen pro Eintrag, Swipe hoch/runter zwischen Fällen", konzeptionell als Tile-Paginierung gedacht | **Drei native Wear-OS-Surfaces** in getrennten Rollen: Activity (`ScalingLazyColumn` + EdgeButton „● Neu") = Fallliste + Recording; Tile (ProtoLayout mit drei Tap-Regionen: OneLiner-Mitte / ☰ Fälle / EdgeButton „● Neu") = Glance auf den aktuellen Fall; Complication (`SHORT_TEXT`/`SMALL_IMAGE`, Tap → Liste) = optionaler Fast-Launch vom Watchface | Tile-Paginierung ist technisch unmöglich: ProtoLayout ist nicht scrollbar, Long-Press und vertikaler Swipe sind System-Gesten (Tile-Edit-Karussell / Quick-Panel) und von Apps nicht abfangbar. Die einzige App-seitige Interaktion auf einem Tile ist Tap auf `Clickable`-Regionen — mehrere pro Tile sind HIG-konform und von System-Tiles etabliert. Activity + Tile + Complication teilen sich einen Singleton-`CaseStore`-Snapshot (Kotlin-Pendant zum Rust-`CaseStore`), damit sie nie divergieren; Tile-Refresh via `getUpdater().requestUpdate()` nach jedem Merge. |
|
||||||
| Watch-Core in Kotlin (nicht Rust) | „Es war geplant, die Business-Logik aller Clients in Rust zu entwickeln" (Diskussionsstand) — technisch machbar via UniFFI-Bindings auf `doctate-client-core` | **Watch-`CaseStore` in Kotlin nachgebaut**, strukturell 1:1 zum Rust-Pendant (Snapshot-Flow, Merge-/Reconcile-Asymmetrie, Sync-Flag-Semantik) | Für MVP (PoC → Watch-App-Abschluss) wiegt der zusätzliche Build-Stack (NDK, Cross-Compile für `aarch64-linux-android` + `armv7-linux-androideabi`, APK-Größe) schwerer als der Code-Sharing-Nutzen — es gibt genau *einen* Kotlin-Consumer. Natürlicher Einzug-Moment ist der Start der **Handy-App** (zweiter Kotlin-Consumer → erster echter Duplikations-Druck); bis dahin wird die Kotlin-Portierung diszipliniert strukturgleich zum Rust-Original geführt, damit ein späterer UniFFI-Swap kein Refactoring der Call-Sites erzwingt. |
|
| Watch-Core in Kotlin (nicht Rust) | „Es war geplant, die Business-Logik aller Clients in Rust zu entwickeln" (Diskussionsstand) — technisch machbar via UniFFI-Bindings auf das Rust-Pendant | **Watch-`CaseStore` in Kotlin nachgebaut**, strukturell 1:1 zum Rust-Pendant in `doctate-desktop::case_store` (Snapshot-Flow, Merge-/Reconcile-Asymmetrie, Sync-Flag-Semantik) | Für MVP (PoC → Watch-App-Abschluss) wiegt der zusätzliche Build-Stack (NDK, Cross-Compile für `aarch64-linux-android` + `armv7-linux-androideabi`, APK-Größe) schwerer als der Code-Sharing-Nutzen — es gibt genau *einen* Kotlin-Consumer. Natürlicher Einzug-Moment ist der Start der **Handy-App** (zweiter Kotlin-Consumer → erster echter Duplikations-Druck); bis dahin wird die Kotlin-Portierung diszipliniert strukturgleich zum Rust-Original geführt, damit ein späterer UniFFI-Swap kein Refactoring der Call-Sites erzwingt. |
|
||||||
| Watch-Recording-Flow | Plan: Einstieg über EdgeButton „● Neu" *oder* Tap auf Listen-Eintrag → direkt Aufnahme; Stop → zurück auf den aktiven Fall | **Drei separate Screens**: `CaseListScreen` → `CaseDetailScreen` (bei Tap auf einen Fall) → `RecordingScreen` (bei Tap auf den Detail-Record-Button). „● Neu" bleibt Direkt-Einstieg (überspringt Detail). `RecordingScreen` ist radikal reduziert (live `mm:ss`-Counter, Stop-EdgeButton, 300 s Safety-Cap via `elapsedSeconds`-Reducer, `FLAG_KEEP_SCREEN_ON` via `view.keepScreenOn`). Swipe-right oder Back = Discard ohne Bestätigung (`DisposableEffect.onDispose` ist der einzige Discard-Pfad). | Ein Tap auf einen Listeneintrag darf nicht stumm das Mikrofon öffnen — der Arzt soll erst den Fall (Datum + Oneliner) sehen und explizit „Record" drücken. Der State-Machine-Split kommt mit zwei neuen Invarianten: (1) `finalizeAndUpload` läuft in `applicationScope`, damit der Pop nach Stop den Upload nicht kill — `AtomicBoolean finalizationStarted` serialisiert Stop/Discard/Auto-Stop gegen Doppelspiel; (2) `formatTime` ist nach `TimeFormat.kt` extrahiert, damit Liste und Detail-Header dieselbe Relativ-/Absolut-Formatierung („Gerade eben" / „Heute HH:mm" / „dd.MM.yy HH:mm") zeigen. |
|
| Watch-Recording-Flow | Plan: Einstieg über EdgeButton „● Neu" *oder* Tap auf Listen-Eintrag → direkt Aufnahme; Stop → zurück auf den aktiven Fall | **Drei separate Screens**: `CaseListScreen` → `CaseDetailScreen` (bei Tap auf einen Fall) → `RecordingScreen` (bei Tap auf den Detail-Record-Button). „● Neu" bleibt Direkt-Einstieg (überspringt Detail). `RecordingScreen` ist radikal reduziert (live `mm:ss`-Counter, Stop-EdgeButton, 300 s Safety-Cap via `elapsedSeconds`-Reducer, `FLAG_KEEP_SCREEN_ON` via `view.keepScreenOn`). Swipe-right oder Back = Discard ohne Bestätigung (`DisposableEffect.onDispose` ist der einzige Discard-Pfad). | Ein Tap auf einen Listeneintrag darf nicht stumm das Mikrofon öffnen — der Arzt soll erst den Fall (Datum + Oneliner) sehen und explizit „Record" drücken. Der State-Machine-Split kommt mit zwei neuen Invarianten: (1) `finalizeAndUpload` läuft in `applicationScope`, damit der Pop nach Stop den Upload nicht kill — `AtomicBoolean finalizationStarted` serialisiert Stop/Discard/Auto-Stop gegen Doppelspiel; (2) `formatTime` ist nach `TimeFormat.kt` extrahiert, damit Liste und Detail-Header dieselbe Relativ-/Absolut-Formatierung („Gerade eben" / „Heute HH:mm" / „dd.MM.yy HH:mm") zeigen. |
|
||||||
| Watch-Oneliner-Manual-Override | Plan (Zeile 1384 ff.): Oneliner wird ausschließlich serverseitig am Batch-Ende aus allen Transkripten regeneriert — der Arzt beeinflusst ihn nur über das Diktat („Bezeichnung: …") | **PoC auf der Watch**: Tap auf den Oneliner im `CaseDetailScreen` öffnet den Wear-OS-System-Input-Picker (voice/keyboard/handwriting, fest auf `de-DE` gepinnt). Ein Doctor-Edit latched ein Manual-Flag am `CaseEntry`, das den simulierten LLM-Burst blockiert; ein erneutes Manual-Edit gewinnt wieder. | Medizinische Oneliner-Typos und Fall-Bezeichnungen sollen ohne Umweg übers Diktat korrigierbar sein. „Doctor wins" ist die neue Invariante gegenüber der serverseitigen Regen-Logik. **Offene Entwurfsentscheidung für Phase 5b:** wie wird das Manual-Flag zum Server synchronisiert, damit Browser-UI, Desktop-Client und künftiger Handy-Client es respektieren (kandidierende Varianten: eigenes Upload-Feld `{oneliner, manual: true}`, oder dedizierter `PUT /api/oneliner/{case_id}` mit API-Key-Auth — konsistent zum bestehenden API-Key-Erfassungs-Vertrag). Aktuell arbeitet der PoC nur gegen den `CaseStoreStub`, das Wire-Protokoll zum Server fehlt. |
|
| Watch-Oneliner-Manual-Override | Plan (Zeile 1384 ff.): Oneliner wird ausschließlich serverseitig am Batch-Ende aus allen Transkripten regeneriert — der Arzt beeinflusst ihn nur über das Diktat („Bezeichnung: …") | **PoC auf der Watch**: Tap auf den Oneliner im `CaseDetailScreen` öffnet den Wear-OS-System-Input-Picker (voice/keyboard/handwriting, fest auf `de-DE` gepinnt). Ein Doctor-Edit latched ein Manual-Flag am `CaseEntry`, das den simulierten LLM-Burst blockiert; ein erneutes Manual-Edit gewinnt wieder. | Medizinische Oneliner-Typos und Fall-Bezeichnungen sollen ohne Umweg übers Diktat korrigierbar sein. „Doctor wins" ist die neue Invariante gegenüber der serverseitigen Regen-Logik. **Offene Entwurfsentscheidung für Phase 5b:** wie wird das Manual-Flag zum Server synchronisiert, damit Browser-UI, Desktop-Client und künftiger Handy-Client es respektieren (kandidierende Varianten: eigenes Upload-Feld `{oneliner, manual: true}`, oder dedizierter `PUT /api/oneliner/{case_id}` mit API-Key-Auth — konsistent zum bestehenden API-Key-Erfassungs-Vertrag). Aktuell arbeitet der PoC nur gegen den `CaseStoreStub`, das Wire-Protokoll zum Server fehlt. |
|
||||||
| Watch-Timestamp-Erzeugung | Plan implizit: `Instant.now().toString()` als RFC3339-Quelle (siehe ältere PoC-Beispiele) | **`PendingStore.nowRfc3339()`** als zentrale Quelle: `Instant.now().truncatedTo(ChronoUnit.SECONDS).toString()` — spiegelt `doctate-common::timestamp::now_rfc3339()` 1:1. Konsumiert von `RecordingViewModel.startRecordingFlow`, `RecordingViewModel.finalizeAndUpload` und `CaseDetailScreen` (`OnelinerState.Manual.setAt`). Unit-Test `nowRfc3339_has_no_subsecond_component` regression-guard'et den Truncate. | `Instant.now().toString()` leakt plattformabhängig Mikro- oder Nanosekunden in den Filename. Der Server-seitige Filename-Parser (`utc_date_and_iso_of`) erwartet exakt `YYYY-MM-DDTHH-MM-SSZ` nach `:` → `-`-Substitution; bei Drift gibt das `<time datetime>` leer zurück und die Tag-Gruppierung fällt auf `today` zurück — sichtbar als doppelte „Heute"-Header in der Web-UI. Truncate auf Sekunden ist load-bearing, nicht kosmetisch. Zentraler Helper im `audio/PendingStore`-companion verhindert Drift bei künftigen neuen Call-Sites; flankiert vom strikten Server-Boundary-Validator (siehe Sicherheit / Hardening). |
|
| Watch-Timestamp-Erzeugung | Plan implizit: `Instant.now().toString()` als RFC3339-Quelle (siehe ältere PoC-Beispiele) | **`PendingStore.nowRfc3339()`** als zentrale Quelle: `Instant.now().truncatedTo(ChronoUnit.SECONDS).toString()` — spiegelt `doctate-common::timestamp::now_rfc3339()` 1:1. Konsumiert von `RecordingViewModel.startRecordingFlow`, `RecordingViewModel.finalizeAndUpload` und `CaseDetailScreen` (`OnelinerState.Manual.setAt`). Unit-Test `nowRfc3339_has_no_subsecond_component` regression-guard'et den Truncate. | `Instant.now().toString()` leakt plattformabhängig Mikro- oder Nanosekunden in den Filename. Der Server-seitige Filename-Parser (`utc_date_and_iso_of`) erwartet exakt `YYYY-MM-DDTHH-MM-SSZ` nach `:` → `-`-Substitution; bei Drift gibt das `<time datetime>` leer zurück und die Tag-Gruppierung fällt auf `today` zurück — sichtbar als doppelte „Heute"-Header in der Web-UI. Truncate auf Sekunden ist load-bearing, nicht kosmetisch. Zentraler Helper im `audio/PendingStore`-companion verhindert Drift bei künftigen neuen Call-Sites; flankiert vom strikten Server-Boundary-Validator (siehe Sicherheit / Hardening). |
|
||||||
|
|||||||
Reference in New Issue
Block a user