Initial commit: project structure, CLAUDE.md, and project plan
This commit is contained in:
@@ -0,0 +1,833 @@
|
||||
# Smart Watch Recorder — Projektplan
|
||||
|
||||
## Überblick
|
||||
|
||||
Medizinisches Diktiersystem für Ärzte. Aufnahmen werden per Pixel Watch erstellt, automatisch transkribiert, durch ein LLM aufbereitet und über ein schlichtes Webinterface abgerufen. Das System unterstützt mehrere Ärzte mit vollständig getrennten Daten.
|
||||
|
||||
**Datenphilosophie:** Das System ist kein Langzeitspeicher. Daten sollen so schnell wie möglich durch die Pipeline fließen und nach Übernahme ins Praxissystem vom Arzt zum Entfernen markiert werden. Je weniger Daten in der Pipeline verbleiben, desto besser. Die Watch löscht Aufnahmen erst nach Server-Bestätigung — so gehen keine Daten verloren, solange die Watch funktioniert. Serverseitig besteht bewusst kein Backup-Konzept — bei Datenverlust auf dem Server vor Übernahme ins Praxissystem gehen Daten verloren. Dieses Risiko wird den Nutzern kommuniziert.
|
||||
|
||||
**Timestamps:** Alle Zeitstempel im gesamten System (Dateinamen, API-Kommunikation, Logs) sind **UTC**. Die Anzeige in der lokalen Zeitzone erfolgt ausschließlich in der UI-Schicht (Watch-App, Webinterface). "Heute" wird systemweit als aktueller UTC-Tag definiert.
|
||||
|
||||
---
|
||||
|
||||
## Architektur
|
||||
|
||||
```
|
||||
Pixel Watch (Wear OS)
|
||||
│ Aufnahmen → lokales Dateisystem
|
||||
│ Foreground Sync Service (automatisch bei Netzwerk)
|
||||
↓ HTTPS direkt (LTE/WiFi) oder transparent über Phone (Wear OS Proxy)
|
||||
Unraid Server
|
||||
└── nginx (reverse proxy, TLS)
|
||||
↓ lokales Netz
|
||||
Ubuntu Server (RTX 3060, 12 GB VRAM)
|
||||
├── Docker: Axum (Webserver, API, SSE, Worker)
|
||||
│ Empfang → Transkriptions-Queue
|
||||
│ Worker steuert GPU-Phasen (Whisper → Ollama → Whisper → ...)
|
||||
│ ↓ HTTP (localhost)
|
||||
├── Docker: faster-whisper (STT, CTranslate2, large-v3)
|
||||
├── Ollama (Gemma 3 4B, Oneliner)
|
||||
│ ↓ HTTPS
|
||||
└── Ionos LLM (Dokument-Generierung bei Fallabschluss)
|
||||
|
||||
Webinterface (Browser) → nginx → Axum (SSE für Live-Updates)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Komponenten
|
||||
|
||||
### 1. Pixel Watch App (Wear OS / Kotlin)
|
||||
|
||||
**Funktion:**
|
||||
- Audioaufnahme direkt auf der Watch (MediaRecorder, AAC/m4a)
|
||||
- Optionale Aufnahme über gekoppeltes Bluetooth-Headset (automatisch bevorzugt)
|
||||
- case_id (UUIDv4) Generierung lokal auf der Watch: "Neu" erzeugt eine neue case_id + Marker-Datei (`/recordings/cases/{case_id}.json`), "Fortsetzen" verwendet die bestehende case_id des gewählten Falls
|
||||
- Ein Fall = 1–n Aufnahmen, identifiziert durch case_id + Aufnahme-Zeitstempel
|
||||
- Aufnahmen persistent ins lokale Dateisystem (überlebt Neustarts, Akku leer)
|
||||
- Nach Aufnahme: Stop → direkt in Sync-Queue, Korrekturen per Folge-Diktat
|
||||
- Vollautomatischer Sync im Hintergrund (Foreground Service)
|
||||
- Kein eigener Code auf dem Phone nötig
|
||||
|
||||
**Sync-Service (Foreground Service):**
|
||||
- Läuft permanent im Hintergrund, überlebt Doze-Modus
|
||||
- Bei Netzwerk-Verfügbarkeit: alle ungesyncten Aufnahmen hochladen
|
||||
- Upload → Server-Bestätigung abwarten → bei "received" oder "gone": lokal löschen
|
||||
- Bei Fehler / kein ACK: exponentieller Backoff, automatischer Retry
|
||||
- Bei Watch-Neustart: Service startet automatisch, prüft Dateisystem auf ungesyncte Aufnahmen
|
||||
- Kein manuelles Eingreifen des Arztes nötig — es muss einfach funktionieren
|
||||
|
||||
**Netzwerk-Verhalten:**
|
||||
```
|
||||
Watch mit LTE/WiFi → direkt an Server
|
||||
Watch ohne LTE/WiFi → Wear OS Proxy → Phone → Server (transparent)
|
||||
Kein Netzwerk → Aufnahmen bleiben lokal, Sync bei Wiederherstellung
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**UI — Ein Screen pro Eintrag, zwei Zustände:**
|
||||
|
||||
Jeder Listeneintrag füllt das gesamte Watch-Display. Navigation via Swipe hoch/runter. "Neu" immer ganz oben, darunter heutige Fälle chronologisch absteigend (neuester zuerst). Nur ein Button pro Eintrag.
|
||||
|
||||
```
|
||||
"Neu" (ganz oben): Recording (gleicher Screen):
|
||||
┌─────────────────┐ ┌─────────────────┐
|
||||
│ ⏱ 10:35 │ │ ⏱ 10:35 │
|
||||
│ │ │ ● REC 00:42 │
|
||||
│ [● Neu] │ │ [■ Stop] │
|
||||
│ │ │ │
|
||||
│ ☁↑ 2 │ │ ☁↑ 2 │
|
||||
└─────────────────┘ └─────────────────┘
|
||||
|
||||
↓ Swipe runter nach Stop:
|
||||
|
||||
┌─────────────────┐ ┌─────────────────┐
|
||||
│ 10:32 │ │ 10:32 │
|
||||
│ Kniegelenk... │ │ ⏳ │ ← Oneliner noch nicht da
|
||||
│ [▶ Fortsetzen] │ │ [▶ Fortsetzen] │
|
||||
└─────────────────┘ └─────────────────┘
|
||||
|
||||
↓ Swipe runter
|
||||
|
||||
┌─────────────────┐
|
||||
│ 09:15 │
|
||||
│ Hypertonie... │
|
||||
│ [▶ Fortsetzen] │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
**Screen-Verhalten nach Stop:**
|
||||
Nach dem Stoppen einer Aufnahme bleibt der aktuelle Fall auf dem Display — der Screen wechselt vom Recording-Zustand direkt zu "Fortsetzen". Der Arzt kann sofort weiter diktieren, ohne zu scrollen. Für einen neuen Fall swiped er bewusst nach oben zu "Neu".
|
||||
|
||||
**Oneliner-Polling:**
|
||||
Solange der aktuell angezeigte Fall noch keinen Oneliner hat (⏳), fragt die Watch alle paar Sekunden den Server ab (`GET /api/oneliner/{case_id}`). Sobald ein Oneliner empfangen wird, wird er in die Marker-Datei geschrieben und im Display angezeigt — das Polling für diesen Fall stoppt. Beim Weiterswischen zu einem anderen Fall ohne Oneliner beginnt das Polling für den neuen sichtbaren Fall.
|
||||
|
||||
- 🎧 Headset-Indikator wenn Bluetooth-Headset aktiv
|
||||
- ☁↑ zeigt Anzahl ungesyncter Aufnahmen (dezent, verschwindet bei 0)
|
||||
- Default beim Öffnen: immer ganz oben ("Neu")
|
||||
- Stop → Aufnahme geht direkt in Sync-Queue, kurzes ✓-Feedback, Screen bleibt auf aktuellem Fall
|
||||
- Kein Verwerfen-Button — Korrekturen per Diktat ("Korrektur: ...")
|
||||
|
||||
Oneliner-Zustände bei Fällen:
|
||||
- Verfügbar → Uhrzeit + Oneliner (stabil, Polling beendet)
|
||||
- Lädt → Uhrzeit + ⏳ (Polling aktiv)
|
||||
- Kein Server → nur Uhrzeit (Polling mit Backoff)
|
||||
- Keine Fälle heute → nur "Neu" sichtbar
|
||||
|
||||
---
|
||||
|
||||
**Lokale Datenhaltung (persistent im Dateisystem):**
|
||||
|
||||
Das Dateisystem ist die einzige Quelle der Wahrheit. Die Fallliste ergibt sich aus zwei Ordnern: `cases/` (welche Fälle existieren + Oneliner) und `unsynced/` (welche Aufnahmen noch auf Upload warten).
|
||||
|
||||
```
|
||||
/recordings/
|
||||
├── cases/
|
||||
│ ├── {case_id}.json ← Marker-Datei (JSON, bei Erstellung minimal, wächst bei Bedarf)
|
||||
│ └── ...
|
||||
└── unsynced/
|
||||
├── {case_id}_{UTC-timestamp}.m4a ← wartet auf Upload
|
||||
└── ...
|
||||
```
|
||||
|
||||
- **Marker-Datei** (`cases/{case_id}.json`): wird bei "Neu" mit minimalem JSON angelegt. Oneliner wird ergänzt, sobald er vom Server empfangen wird. Format ist erweiterbar für zukünftige Felder.
|
||||
|
||||
```json
|
||||
// Bei Erstellung ("Neu"):
|
||||
{
|
||||
"created_at": "2026-04-06T09:32:00Z"
|
||||
}
|
||||
|
||||
// Nach Oneliner-Empfang:
|
||||
{
|
||||
"created_at": "2026-04-06T09:32:00Z",
|
||||
"oneliner": "Kniegelenk re., V.a. Meniskus"
|
||||
}
|
||||
```
|
||||
|
||||
- **Fallliste** = alle `.json`-Dateien in `cases/` deren `created_at` von heute (UTC) ist. Sortierung absteigend. `oneliner` fehlt oder null → nur Uhrzeit anzeigen. Vorhanden → Uhrzeit + Oneliner.
|
||||
- **Sync-Indikator** (☁↑) = Anzahl Dateien in `unsynced/`
|
||||
- Dateinamen-Format Aufnahmen: `{case_id}_{yyyy-MM-ddTHH:mm:ssZ}.m4a` (UTC)
|
||||
- UI zeigt Uhrzeiten in lokaler Zeitzone an
|
||||
|
||||
**Lazy Cleanup (Watch):**
|
||||
Beim App-Start / Scan: Marker-Dateien in `cases/` deren `created_at` nicht von heute (UTC) ist, werden gelöscht — sie sind rein informativ für die UI und enthalten keine Audiodaten.
|
||||
|
||||
Audiodateien in `unsynced/` werden **nie** durch die Lazy Cleanup gelöscht. Der Sync-Service ist der einzige Verantwortliche für das Löschen von Audiodateien — ausschließlich nach Server-Bestätigung (ACK mit `received` oder `gone`). So gehen keine Aufnahmen verloren, auch wenn die Watch tagelang offline war (z.B. Wochenende im Spind). Beim nächsten Netzwerkzugang synct der Foreground Service alle ausstehenden Aufnahmen nach.
|
||||
|
||||
**Speicherplatz-Warnung (optional):** Pixel Watch 2/3 hat 32 GB, eine Minute AAC/m4a ≈ 1 MB. Selbst 100 ungesyncte Aufnahmen sind unkritisch. Dennoch: bei >500 MB ungesyncten Daten dezenten Warnhinweis in der UI anzeigen (nicht löschen, nur informieren).
|
||||
|
||||
---
|
||||
|
||||
**Upload-Paket (vom Sync-Service gesendet):**
|
||||
|
||||
```json
|
||||
{
|
||||
"case_id": "uuid",
|
||||
"recorded_at": "2026-04-06T09:32:00Z",
|
||||
"audio": "..."
|
||||
}
|
||||
```
|
||||
|
||||
**Server-Antwort (ACK — drei Zustände):**
|
||||
|
||||
```json
|
||||
{
|
||||
"case_id": "uuid",
|
||||
"recorded_at": "2026-04-06T09:32:00Z",
|
||||
"status": "received | gone"
|
||||
}
|
||||
```
|
||||
|
||||
| Status | Bedeutung | Watch-Verhalten |
|
||||
|---|---|---|
|
||||
| `received` | Upload angenommen | Lokale Datei löschen |
|
||||
| `gone` | Fall existiert nicht mehr (vom Arzt entfernt) | Lokale Datei löschen |
|
||||
| (kein ACK / Fehler) | Server nicht erreichbar oder interner Fehler | Retry mit exponentiellem Backoff |
|
||||
|
||||
→ Sowohl `received` als auch `gone` führen zum Löschen der lokalen Datei. Die Watch muss nicht verstehen, *warum* der Fall weg ist — nur dass sie die Datei gefahrlos löschen kann. Das hält den Watch-Code einfach.
|
||||
|
||||
---
|
||||
|
||||
**Oneliner-Abfrage (Polling, informativ):**
|
||||
|
||||
```
|
||||
GET /api/oneliner/{case_id}
|
||||
→ 200: { "oneliner": "Hypertonie Grad 2..." }
|
||||
→ 404: noch nicht verarbeitet (erstes Transkript noch ausstehend)
|
||||
→ 503: Ollama nicht verfügbar
|
||||
```
|
||||
|
||||
- Oneliner wird beim ersten Transkript einmalig generiert und danach nur noch gelesen
|
||||
- Der Arzt kann den Oneliner beeinflussen, indem er im Diktat eine Bezeichnung nennt (z.B. „Bezeichnung: Kniegelenk")
|
||||
- Timeout: ~2 Sekunden
|
||||
- **Polling:** Solange der aktuell angezeigte Fall keinen Oneliner in seiner Marker-Datei hat, fragt die Watch alle paar Sekunden ab. Polling läuft nur für den sichtbaren Fall.
|
||||
- Bei 200: Oneliner in Marker-Datei `/recordings/cases/{case_id}.json` schreiben → Polling für diesen Fall beenden
|
||||
- Bei 404/503/Timeout → weiter pollen (Watch funktioniert normal, Marker-Datei bleibt ohne Oneliner, UI zeigt ⏳)
|
||||
- Keine Blockierung des Aufnahme-Workflows — Polling läuft unabhängig vom Recording
|
||||
|
||||
---
|
||||
|
||||
**Sicherheit Watch:**
|
||||
- API-Key pro Arzt im HTTPS-Header
|
||||
- Server mappt API-Key → Arzt-Identität (bestimmt Speicherpfad `/data/{arzt}/`)
|
||||
- Einmalig auf der Watch konfiguriert
|
||||
|
||||
---
|
||||
|
||||
### 2. Server (Axum / Rust — Docker Container auf Ubuntu Server)
|
||||
|
||||
Axum ist der zentrale Koordinator. Er empfängt Uploads, steuert den GPU-Phasen-Worker und bedient das Webinterface. STT und Preprocessing laufen als externe Services (faster-whisper, Ollama) — Axum selbst braucht keinen GPU-Zugriff.
|
||||
|
||||
**Endpunkte:**
|
||||
|
||||
```
|
||||
# Watch API
|
||||
POST /api/upload → Aufnahme empfangen, ACK zurück
|
||||
GET /api/oneliner/{case_id} → Oneliner abrufen (informativ)
|
||||
|
||||
# Webinterface (Arzt-Identität kommt aus der Session, nie aus der URL)
|
||||
GET /web/login → Login
|
||||
POST /web/login → Login-Formular absenden
|
||||
GET /web/ → Übersicht (drei States)
|
||||
GET /web/case/{case_id} → Fall-Detail (Transkripte + Dokument)
|
||||
GET /web/events → SSE Live-Updates
|
||||
POST /web/case/{case_id}/close → Fall abschließen → LLM-Generierung
|
||||
POST /web/close-all → Alle Fälle abschließen
|
||||
POST /web/case/{case_id}/regenerate → Dokument neu generieren
|
||||
POST /web/case/{case_id}/undo → Vorherige Version wiederherstellen
|
||||
POST /web/case/{case_id}/mark-remove → Fall zum Entfernen markieren (nach Übernahme)
|
||||
```
|
||||
|
||||
**Kein `{arzt}` in URLs (IDOR-Prävention):**
|
||||
Die Arzt-Identität wird ausschließlich aus dem Session-Cookie abgeleitet, nie aus der URL. Ein Axum-Extractor (`AuthenticatedArzt`) liest das Session-Token, schlägt den zugehörigen Arzt nach und gibt ihn als typisierte Struct zurück. Alle `/web/`-Handler erhalten den Arzt nur über diesen Extractor — der Dateisystempfad `/data/{arzt}/` wird serverseitig aus der Session konstruiert. Dadurch kann ein eingeloggter Arzt prinzipbedingt nicht auf Daten eines anderen Arztes zugreifen, selbst wenn er URLs manuell ändert. `case_id` wird zusätzlich als UUIDv4 validiert (`uuid::Uuid::parse_str`), um Path-Traversal über manipulierte IDs auszuschließen.
|
||||
|
||||
**Fall-States:**
|
||||
|
||||
```
|
||||
┌────────────┐ sofort ┌───────────────┐ Arzt klickt ┌─────────────┐
|
||||
│ Empfangen │ ──────────────→ │ Transkribiert │ ──────────────→ │ Ausgewertet │
|
||||
│ (queued) │ Transkription │ (einsehbar) │ "Abschließen" │ (Dokument) │
|
||||
└────────────┘ └───────────────┘ └─────────────┘
|
||||
```
|
||||
|
||||
- **Empfangen:** Mindestens eine Aufnahme ist noch in der Transkriptions-Queue oder wird gerade verarbeitet
|
||||
- **Transkribiert:** Alle Aufnahmen dieses Falls sind transkribiert. Arzt kann jedes Transkript einzeln einsehen
|
||||
- **Ausgewertet:** LLM hat aus allen Transkripten ein Dokument generiert
|
||||
|
||||
Ein Fall wechselt erst zu "Transkribiert", wenn *alle* zugehörigen Aufnahmen transkribiert sind. Solange eine Aufnahme noch in der Queue ist, bleibt der Fall im State "Empfangen".
|
||||
|
||||
"Abschließen" ist nur im State "Transkribiert" möglich. Der Button ist ausgegraut / nicht sichtbar, solange Aufnahmen noch ausstehen.
|
||||
|
||||
---
|
||||
|
||||
**Pipeline nach Aufnahme-Eingang:**
|
||||
|
||||
```
|
||||
1. API-Key → Arzt auflösen
|
||||
2. case_id prüfen:
|
||||
a) Fall in open/ → normal weiter (Schritt 3)
|
||||
b) Fall in done/ → Aufnahme trotzdem speichern (Nachtrag, siehe unten)
|
||||
c) Fall in done/ mit .remove-Marker → .remove entfernen, Aufnahme speichern (Nachtrag)
|
||||
d) Fall unbekannt → ACK mit status "gone", fertig
|
||||
3. Audio speichern → /data/{arzt}/open/{case_id}/ (bzw. done/)
|
||||
4. ACK mit status "received" an Watch senden
|
||||
5. Aufnahme in Transkriptions-Queue einreihen (tokio mpsc channel)
|
||||
6. Worker verarbeitet in GPU-Phasen (siehe "Worker-Zyklus")
|
||||
```
|
||||
|
||||
**Nachträgliche Aufnahmen nach Fallabschluss:**
|
||||
Wenn eine Aufnahme für einen bereits abgeschlossenen Fall eintrifft (z.B. Watch war offline), wird sie trotzdem angenommen, gespeichert und transkribiert. Der Fall bleibt im State "Ausgewertet", aber das Webinterface erkennt anhand der Timestamps, dass Transkripte existieren, die neuer sind als das generierte Dokument. Es zeigt einen Hinweis: *"1 neue Aufnahme seit Abschluss — Dokument neu generieren?"* Der Arzt entscheidet selbst. Das bestehende Dokument bleibt intakt.
|
||||
|
||||
**Verspätete Uploads für zum Entfernen markierte Fälle:**
|
||||
Wenn ein Fall zum Entfernen markiert ist (`.remove`-Marker) und eine verspätete Aufnahme eintrifft, wird der `.remove`-Marker entfernt und die Aufnahme normal als Nachtrag behandelt. Der Arzt wird im Webinterface informiert und muss den Fall bei Bedarf erneut zum Entfernen markieren. So gehen keine Daten verloren, die noch unterwegs waren.
|
||||
|
||||
**Worker-Zyklus (GPU-Phasen, Greedy):**
|
||||
|
||||
Der Worker verarbeitet Aufgaben nicht pro Job, sondern in GPU-Phasen. Die GPU gehört immer genau einem Dienst — kein gleichzeitiges Laden von Whisper und Ollama im VRAM.
|
||||
|
||||
```
|
||||
Loop:
|
||||
Phase 1 — Whisper (faster-whisper hat GPU exklusiv):
|
||||
Queue drainen: ALLE untranskribierten Aufnahmen nacheinander
|
||||
transkribieren (reqwest → faster-whisper HTTP-API).
|
||||
Kommt während Phase 1 eine neue Aufnahme in die Queue,
|
||||
wird sie noch mitgenommen (greedy).
|
||||
Erst wenn die Queue leer ist → weiter zu Phase 2.
|
||||
|
||||
Phase 2 — Ollama (Gemma 3 4B hat GPU exklusiv):
|
||||
Alle Fälle prüfen, die ein erstes Transkript haben
|
||||
aber noch keinen Oneliner → Oneliner generieren
|
||||
(reqwest → Ollama HTTP-API, keep_alive: 0).
|
||||
keep_alive: 0 entlädt das Modell sofort nach dem
|
||||
letzten Request → VRAM ist frei für nächste Whisper-Phase.
|
||||
|
||||
Phase 3 — Aufräumen:
|
||||
Pending-Zähler prüfen, State-Übergänge durchführen,
|
||||
SSE-Events an Webinterface senden.
|
||||
Queue leer? → Sleep / auf mpsc-Signal warten → zurück zu Phase 1.
|
||||
```
|
||||
|
||||
**VRAM-Management:**
|
||||
faster-whisper (CTranslate2, large-v3 int8) belegt ~2 GB VRAM, Gemma 3 4B ~3 GB. Beide passen theoretisch gleichzeitig in 12 GB, werden aber bewusst abwechselnd genutzt — so wie die bestehende Infrastruktur (faster-whisper + Ollama) bereits erfolgreich betrieben wird. Ollama entlädt das Modell automatisch nach jedem Request (`keep_alive: 0`), faster-whisper hält sein Modell dauerhaft geladen (es ist das "heiße" Modell mit ~3 Min. Taktung).
|
||||
|
||||
**Latenz-Profil (3 Ärzte, ~10 Min. pro Patient):**
|
||||
Alle ~3 Minuten trifft eine Aufnahme ein. faster-whisper verarbeitet 60 Sek. Audio in ~6–9 Sek. (CTranslate2, ~0,1× Echtzeit). Ein typischer Zyklus:
|
||||
|
||||
```
|
||||
00:00 5 Aufnahmen in Queue
|
||||
00:00 Phase 1: 5× ~8 Sek. = ~40 Sek. Transkription
|
||||
00:40 Phase 2: 3 Oneliner × ~0,4 Sek. = ~1 Sek. (+ ~2 Sek. Modell laden)
|
||||
00:43 Phase 3: States aktualisieren, SSE-Events
|
||||
00:43 Worker idle, wartet auf nächsten Job
|
||||
```
|
||||
|
||||
Die Transkriptions-Queue ist FIFO, der Worker verarbeitet sequenziell (GPU kann nur eine Aufgabe gleichzeitig sinnvoll bedienen). Mehrere Ärzte teilen sich die Queue fair.
|
||||
|
||||
**Concurrency-Schutz (Fall-State):**
|
||||
Die State-Prüfung ("sind alle Aufnahmen transkribiert?") und das Registrieren neuer Aufnahmen müssen synchronisiert werden. Ohne Locking entsteht eine Race-Condition: Der Worker beendet eine Transkription, scannt den Ordner und sieht alle `.txt`-Dateien → setzt State auf "Transkribiert". Gleichzeitig schreibt der Upload-Handler eine neue `.m4a` in denselben Ordner, die beim Scan noch nicht sichtbar war. Der Fall gilt als fertig, obwohl noch ein Job in der Queue hängt.
|
||||
|
||||
Lösung: Ein `tokio::sync::RwLock<HashMap<CaseId, CaseState>>` (oder granularer ein Lock pro Case) koordiniert Upload-Handler und Worker. Der Upload-Handler nimmt den Lock und registriert die neue Aufnahme (inkrementiert einen Pending-Zähler). Der Worker nimmt nach der Transkription den Lock, dekrementiert den Zähler, und setzt den State nur auf "Transkribiert", wenn der Zähler auf 0 steht. Damit ist die State-Berechnung nicht mehr vom Filesystem-Scan abhängig und die Race-Condition eliminiert.
|
||||
|
||||
**Oneliner-Generierung:**
|
||||
Der Oneliner wird *einmalig* beim ersten Transkript eines Falls generiert und danach nie überschrieben. Er dient als stabiler Identifier, den der Arzt auf Watch und Webinterface wiedererkennt — auch Stunden später. Folgediktate ändern den Oneliner nicht.
|
||||
|
||||
Der Arzt kann den Oneliner beeinflussen, indem er im Diktat eine eigene Bezeichnung nennt (z.B. „Bezeichnung: Kniegelenk" oder „Fall-ID: Schulter rechts"). Ollama erkennt solche Formulierungen im Transkript und übernimmt sie. Es gibt keine deterministische Keyword-Suche — die Erkennung läuft vollständig über den LLM-Prompt. Ärzte, die das Feature nicht kennen, bekommen automatisch einen Oneliner aus dem Inhalt.
|
||||
|
||||
**Queue-Recovery bei Serverstart:**
|
||||
- Dateisystem scannen: alle Aufnahmen ohne Transkript zurück in die Queue
|
||||
- Kein Datenverlust bei Server-Neustart
|
||||
|
||||
---
|
||||
|
||||
**Pipeline bei "Fall abschließen":**
|
||||
|
||||
```
|
||||
1. Alle Transkripte zusammenführen (chronologisch)
|
||||
2. Ionos LLM → Dokument generieren (Temperatur: 0)
|
||||
System-Prompt: bereinigen und strukturieren,
|
||||
nichts hinzufügen, nichts interpretieren.
|
||||
Spätere Aufnahmen haben Vorrang — Korrekturen,
|
||||
Nachträge und Widersprüche zugunsten der
|
||||
chronologisch letzten Aussage auflösen.
|
||||
3. document_v1.md speichern
|
||||
4. current-Symlink setzen → document_v1.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Pipeline bei "Neu generieren":**
|
||||
|
||||
```
|
||||
1. Preset-Prompt + optionaler Freitext kombinieren
|
||||
2. Ionos LLM → neues Dokument
|
||||
3. document_vN.md speichern
|
||||
4. current-Symlink aktualisieren
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Undo-Logik:**
|
||||
|
||||
```
|
||||
current zeigt auf vN
|
||||
Undo → current zeigt auf v(N-1)
|
||||
v1 → kein Undo mehr möglich, Button deaktiviert
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Background Tasks (tokio):**
|
||||
|
||||
- Worker: GPU-Phasen-Zyklus (Whisper-Phase → Ollama-Phase → Aufräumen → warten)
|
||||
- Queue-Recovery bei Start: Dateisystem nach fehlenden Transkripten scannen
|
||||
- faster-whisper Health-Check: bei Ausfall Whisper-Phase überspringen, Retry im nächsten Zyklus
|
||||
- Ollama Health-Check: bei Ausfall Ollama-Phase überspringen, Retry im nächsten Zyklus
|
||||
- Retention-Prüfung: lazy bei Zugriff — Audio/Transkripte älter als `RETENTION_*_DAYS` werden entfernt, wenn der Ordner ohnehin gelesen wird
|
||||
|
||||
**Lazy Cleanup (Server):**
|
||||
Es gibt keinen Cronjob um Mitternacht. Stattdessen prüft der Server bei jedem Request, der Fälle auflistet (Webinterface-Übersicht, SSE-Reconnect):
|
||||
- Fälle, die zum Entfernen markiert sind **und** nicht von heute (UTC) stammen → löschen statt anzeigen
|
||||
- Fälle, die niemand abfragt, bleiben liegen — aber niemand sieht sie. Beim nächsten Login räumt der Server auf.
|
||||
|
||||
**Wichtig:** Wenn ein verspäteter Upload für einen zum Entfernen markierten Fall eintrifft, wird der `.remove`-Marker entfernt (siehe "Verspätete Uploads für zum Entfernen markierte Fälle"). Die Lazy Cleanup greift dann nicht mehr — der Fall bleibt erhalten, bis der Arzt ihn erneut zum Entfernen markiert.
|
||||
|
||||
---
|
||||
|
||||
**Service-Ausfall:**
|
||||
|
||||
| Situation | Verhalten |
|
||||
|---|---|
|
||||
| faster-whisper nicht erreichbar | Whisper-Phase überspringen, Jobs bleiben in Queue, Retry im nächsten Zyklus |
|
||||
| Ollama nicht erreichbar | Ollama-Phase überspringen, Oneliner-Jobs bleiben pending, Retry im nächsten Zyklus |
|
||||
| Service wieder da | Automatische Weiterverarbeitung im nächsten Zyklus |
|
||||
| > 30 Minuten Ausfall | Warnung im Webinterface für betroffene Ärzte |
|
||||
| Server-Neustart | Queue-Recovery aus Dateisystem |
|
||||
|
||||
---
|
||||
|
||||
**Webinterface (askama Templates, SSE + minimales Vanilla-JS):**
|
||||
|
||||
Live-Updates via Server-Sent Events (EventSource) — Status-Wechsel erscheinen automatisch ohne Reload.
|
||||
|
||||
Übersicht:
|
||||
```
|
||||
Offene Fälle
|
||||
────────────────────────────────────────────
|
||||
08:14 │ ⏳ Empfangen (wird transkribiert...)
|
||||
09:32 │ ✓ Transkribiert [Ansehen] [Abschließen]
|
||||
11:05 │ ✓ Transkribiert [Ansehen] [Abschließen]
|
||||
[Alle abschließen]
|
||||
|
||||
Ausgewertete Dokumente
|
||||
────────────────────────────────────────────
|
||||
2026-04-06 08:14 [Öffnen] [Entfernen]
|
||||
2026-04-05 14:20 [Öffnen] [Entfernen]
|
||||
```
|
||||
|
||||
Fall-Detail (transkribiert):
|
||||
```
|
||||
Fall 09:32 — 3 Aufnahmen
|
||||
────────────────────────────────────────────
|
||||
09:32 „Patient klagt über Schmerzen..." ← Transkript Aufnahme 1
|
||||
09:45 „Röntgenbild zeigt..." ← Transkript Aufnahme 2
|
||||
10:02 „Diagnose: ..." ← Transkript Aufnahme 3
|
||||
|
||||
[Abschließen]
|
||||
```
|
||||
|
||||
Dokumentansicht (ausgewertet):
|
||||
```
|
||||
Dokument v3
|
||||
|
||||
...Inhalt...
|
||||
|
||||
⚠ 1 neue Aufnahme seit Abschluss ← nur sichtbar wenn Nachträge existieren
|
||||
Preset: [Arztbrief] [Kürzer] [Formeller] [Diagnosen] [Medikamente]
|
||||
Freitext: [________________________]
|
||||
[← Undo v2] [Neu generieren]
|
||||
|
||||
[Entfernen] ← Fall zum Entfernen markieren (wird am Folgetag gelöscht)
|
||||
```
|
||||
|
||||
"Entfernen" markiert den Fall zum Löschen. Er bleibt den restlichen Tag sichtbar und wird beim nächsten Server-Scan am Folgetag (UTC) endgültig gelöscht. So können verspätete Uploads von der Watch noch angenommen werden.
|
||||
|
||||
---
|
||||
|
||||
### 3. Filesystem-Struktur
|
||||
|
||||
```
|
||||
/data/
|
||||
└── {arzt}/
|
||||
├── open/
|
||||
│ └── {case_id}/
|
||||
│ ├── {UTC-timestamp}.m4a
|
||||
│ ├── {UTC-timestamp}.txt ← Transkript (existiert erst nach Transkription)
|
||||
│ └── oneliner.txt
|
||||
└── done/
|
||||
└── {case_id}/
|
||||
├── {UTC-timestamp}.m4a ← RETENTION_AUDIO_DAYS
|
||||
├── {UTC-timestamp}.txt ← RETENTION_TRANSCRIPT_DAYS
|
||||
├── document_v1.md
|
||||
├── document_v2.md
|
||||
├── document_v3.md
|
||||
├── current → document_v3.md
|
||||
└── .remove ← Marker: zum Entfernen markiert (Lazy Cleanup am Folgetag)
|
||||
|
||||
/var/log/recorder/
|
||||
└── recorder.{datum}.log
|
||||
|
||||
/etc/recorder/
|
||||
└── prompts.toml
|
||||
```
|
||||
|
||||
- `.remove` ist eine leere Datei, die beim Klick auf "Entfernen" angelegt wird
|
||||
- Der Lazy Cleanup prüft: `.remove` vorhanden **und** Falldatum ≠ heute (UTC) → Ordner löschen
|
||||
|
||||
---
|
||||
|
||||
### 4. Konfiguration (.env)
|
||||
|
||||
```
|
||||
# Aufbewahrung
|
||||
RETENTION_AUDIO_DAYS=30
|
||||
RETENTION_TRANSCRIPT_DAYS=30
|
||||
RETENTION_DOCUMENT_DAYS=0 # 0 = permanent
|
||||
|
||||
# faster-whisper
|
||||
WHISPER_URL=http://localhost:8100
|
||||
WHISPER_TIMEOUT_SECONDS=120
|
||||
|
||||
# Ollama
|
||||
OLLAMA_URL=http://localhost:11434
|
||||
OLLAMA_MODEL=gemma3:4b
|
||||
OLLAMA_KEEP_ALIVE=0
|
||||
|
||||
# LLM Provider
|
||||
LLM_URL=https://openai.ionos.com/openai
|
||||
LLM_API_KEY=...
|
||||
LLM_MODEL=...
|
||||
LLM_TEMPERATURE=0
|
||||
|
||||
# Server
|
||||
SERVER_PORT=3000
|
||||
DATA_PATH=/data
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL=info
|
||||
LOG_PATH=/var/log/recorder/
|
||||
LOG_MAX_DAYS=90
|
||||
|
||||
# API Keys (ein Eintrag pro Arzt, für Watch-Authentifizierung)
|
||||
API_KEY_DR_MUELLER=...
|
||||
API_KEY_DR_SCHMIDT=...
|
||||
|
||||
# Web Login (ein Eintrag pro Arzt, bcrypt-Hash)
|
||||
WEB_PASSWORD_DR_MUELLER=$2b$12$...
|
||||
WEB_PASSWORD_DR_SCHMIDT=$2b$12$...
|
||||
|
||||
# Session
|
||||
SESSION_TIMEOUT_HOURS=8
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. Vorgefertigte Prompts (prompts.toml)
|
||||
|
||||
```toml
|
||||
[system]
|
||||
base_prompt = """
|
||||
Du erhältst chronologisch sortierte Transkripte ärztlicher Diktate zu einem Fall.
|
||||
Bereinige und strukturiere den Inhalt zu einem medizinischen Dokument.
|
||||
Füge nichts hinzu und interpretiere nichts.
|
||||
Spätere Aufnahmen haben Vorrang: Korrekturen, Nachträge und Widersprüche
|
||||
werden zugunsten der chronologisch letzten Aussage aufgelöst.
|
||||
Hinweise wie 'Korrektur:', 'Nachtrag:', 'Streichung:' sind Anweisungen
|
||||
des Arztes und dürfen nicht im Dokument erscheinen.
|
||||
"""
|
||||
|
||||
[oneliner]
|
||||
prompt = """
|
||||
Du erhältst das Transkript einer ärztlichen Erstaufnahme.
|
||||
Erzeuge eine kurze Bezeichnung für diesen Fall (maximal 4–5 Wörter).
|
||||
|
||||
Wenn der Arzt im Diktat eine eigene Bezeichnung nennt
|
||||
(z.B. „Bezeichnung: Kniegelenk", „Fall-ID: Schulter rechts",
|
||||
„Das ist der Patient mit dem Tennisarm"),
|
||||
verwende diese als Grundlage für den Oneliner.
|
||||
|
||||
Wenn keine explizite Bezeichnung erkennbar ist,
|
||||
leite den Oneliner aus dem medizinischen Inhalt ab
|
||||
(z.B. Beschwerde, Diagnose, Körperregion).
|
||||
|
||||
Antwort: nur der Oneliner, keine Erklärung, keine Anführungszeichen.
|
||||
"""
|
||||
|
||||
[[preset]]
|
||||
label = "Arztbrief"
|
||||
prompt = "Formatiere das Dokument als formellen Arztbrief mit Anrede und Grußformel."
|
||||
|
||||
[[preset]]
|
||||
label = "Kürzer fassen"
|
||||
prompt = "Fasse das Dokument kürzer zusammen ohne inhaltliche Verluste."
|
||||
|
||||
[[preset]]
|
||||
label = "Formeller"
|
||||
prompt = "Formuliere das Dokument formeller und sachlicher."
|
||||
|
||||
[[preset]]
|
||||
label = "Diagnosen hervorheben"
|
||||
prompt = "Hebe alle Diagnosen als strukturierte Liste hervor."
|
||||
|
||||
[[preset]]
|
||||
label = "Medikamente hervorheben"
|
||||
prompt = "Hebe alle Medikamente und Dosierungen als strukturierte Liste hervor."
|
||||
```
|
||||
|
||||
Neue Presets ohne Code-Änderung hinzufügbar.
|
||||
|
||||
---
|
||||
|
||||
### 6. Sicherheit
|
||||
|
||||
| Verbindung | Methode |
|
||||
|---|---|
|
||||
| Watch → nginx | HTTPS TLS 1.3 + API-Key im Header |
|
||||
| Browser → nginx | HTTPS TLS 1.3 + Session-Cookie (`Secure`, `HttpOnly`, `SameSite=Strict`) |
|
||||
| nginx → Axum | HTTP lokal (Unraid → Ubuntu, internes Netz) |
|
||||
| Axum → faster-whisper | HTTP localhost (gleicher Server) |
|
||||
| Axum → Ollama | HTTP localhost (gleicher Server) |
|
||||
| Axum → Ionos | HTTPS + API-Key |
|
||||
| TLS-Zertifikat | Let's Encrypt (certbot, auto-renewal) |
|
||||
| Fail2Ban | Zu viele Fehlversuche → IP geblockt |
|
||||
| Rate Limiting | Max 10 Requests/Minute pro IP |
|
||||
| Ports | Nur 443 offen (Unraid), SSH nur lokal |
|
||||
| Datenhaltung | /data/ nur root lesbar |
|
||||
| IDOR-Prävention | Arzt-Identität kommt ausschließlich aus der Session, nie aus der URL. `AuthenticatedArzt`-Extractor leitet den Dateisystempfad serverseitig ab. |
|
||||
| Input-Validierung | `case_id` wird als UUIDv4 validiert (`ValidCaseId`-Extractor), ungültige Werte → 400. Verhindert Path-Traversal. |
|
||||
| CSRF | Token pro Session, Hidden Field in allen POST-Formularen, serverseitige Validierung. `SameSite=Strict` als zusätzliche Ebene. |
|
||||
| Security Headers (nginx) | `Content-Security-Policy: default-src 'self'`, `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy: no-referrer` |
|
||||
|
||||
---
|
||||
|
||||
### 7. Logging
|
||||
|
||||
```
|
||||
INFO 2026-04-06 08:14:22 Aufnahme empfangen arzt=dr_mueller case_id=abc123
|
||||
INFO 2026-04-06 08:14:23 Whisper-Phase gestartet queue=3
|
||||
INFO 2026-04-06 08:14:31 Transkript fertig case_id=abc123 dauer=8s
|
||||
INFO 2026-04-06 08:14:39 Transkript fertig case_id=def456 dauer=7s
|
||||
INFO 2026-04-06 08:14:46 Transkript fertig case_id=ghi789 dauer=6s
|
||||
INFO 2026-04-06 08:14:46 Whisper-Phase fertig transkribiert=3
|
||||
INFO 2026-04-06 08:14:47 Ollama-Phase gestartet pending_oneliner=2
|
||||
INFO 2026-04-06 08:14:48 Oneliner generiert case_id=abc123
|
||||
INFO 2026-04-06 08:14:48 Oneliner generiert case_id=def456
|
||||
INFO 2026-04-06 08:14:48 Ollama-Phase fertig oneliner=2
|
||||
WARN 2026-04-06 09:00:00 faster-whisper unerreichbar retry=nächster Zyklus
|
||||
WARN 2026-04-06 09:00:00 Ollama unerreichbar retry=nächster Zyklus
|
||||
INFO 2026-04-06 09:05:00 Services wieder da
|
||||
INFO 2026-04-06 10:30:00 Fall abgeschlossen case_id=abc123
|
||||
INFO 2026-04-06 10:30:45 Dokument v1 generiert case_id=abc123
|
||||
INFO 2026-04-06 10:35:00 Dokument v2 generiert case_id=abc123 preset=Arztbrief
|
||||
INFO 2026-04-06 10:36:00 Undo → v1 case_id=abc123
|
||||
INFO 2026-04-06 10:40:00 Fall zum Entfernen markiert case_id=xyz789
|
||||
INFO 2026-04-07 08:00:12 Lazy Cleanup gelöscht=2 (markierte Fälle von gestern)
|
||||
INFO 2026-04-07 08:00:12 Retention Cleanup audio=3 transkripte=1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Schicht | Technologie |
|
||||
|---|---|
|
||||
| Watch App | Kotlin, Jetpack Compose for Wear OS |
|
||||
| Server | Rust, Axum, askama, reqwest, tracing, SSE |
|
||||
| STT | faster-whisper (CTranslate2, large-v3, eigener Docker Container mit HTTP-API) |
|
||||
| Preprocessing | Ollama, Gemma 3 4B (keep_alive: 0 für VRAM-Freigabe nach Request) |
|
||||
| Dokument-LLM | Ionos (OpenAI-API-kompatibel, Temperatur 0) |
|
||||
| Deployment | Docker auf Ubuntu Server (NVIDIA Container Toolkit für faster-whisper + Ollama), nginx reverse proxy auf Unraid |
|
||||
| TLS | Let's Encrypt / certbot |
|
||||
|
||||
### Rust Crates (Server)
|
||||
|
||||
Axum selbst benötigt keinen GPU-Zugriff — STT und Preprocessing laufen als externe Services (faster-whisper, Ollama). Der Axum-Container ist daher ein schlankes Image ohne NVIDIA-Abhängigkeiten.
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
axum = "0.7"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
reqwest = { version = "0.12", features = ["json", "multipart"] }
|
||||
askama = "0.12"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
tracing-appender = "0.2"
|
||||
dotenvy = "0.15"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
toml = "0.8"
|
||||
tower-http = { version = "0.5", features = ["limit", "trace"] }
|
||||
rand = "0.8" # Session-Token-Generierung (OsRng)
|
||||
bcrypt = "0.15" # Passwort-Hashing (Web-Login)
|
||||
axum-extra = { version = "0.9", features = ["cookie"] } # Cookie-Handling
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Hardware
|
||||
|
||||
| Gerät | Rolle |
|
||||
|---|---|
|
||||
| Pixel Watch 2 oder 3 (LTE empfohlen) | Aufnahme + Upload |
|
||||
| Bluetooth-Headset (optional) | Bessere Aufnahmequalität |
|
||||
| Android Phone | Wear OS Companion (kein eigener Code) |
|
||||
| Unraid Server | nginx reverse proxy, TLS-Terminierung |
|
||||
| Ubuntu Server (RTX 3060, 12 GB VRAM) | Docker: Axum (kein GPU), faster-whisper (GPU), Ollama (GPU) |
|
||||
|
||||
---
|
||||
|
||||
## Entwicklungsreihenfolge
|
||||
|
||||
### Phase 1 — Server-Grundgerüst
|
||||
- [ ] Axum Setup, Routing, Authentifizierung (API-Key → Arzt-Mapping)
|
||||
- [ ] Filesystem-Struktur anlegen
|
||||
- [ ] .env Konfiguration (dotenvy)
|
||||
- [ ] Logging (tracing + tracing-appender)
|
||||
- [ ] Upload-Endpunkt mit ACK-Response
|
||||
- [ ] Docker Container für Axum auf Ubuntu Server (kein GPU nötig)
|
||||
- [ ] faster-whisper Container mit HTTP-API (NVIDIA Container Toolkit)
|
||||
- [ ] Ollama einrichten, `gemma3:4b` pullen
|
||||
- [ ] nginx auf Unraid + Let's Encrypt + Security Headers (CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy)
|
||||
|
||||
### Phase 2 — Transkriptions-Pipeline
|
||||
- [ ] Transkriptions-Queue (tokio mpsc channel)
|
||||
- [ ] GPU-Phasen-Worker (Greedy-Zyklus: Whisper-Phase → Ollama-Phase → Aufräumen)
|
||||
- [ ] faster-whisper HTTP-Client (reqwest, POST /transcribe, multipart Audio)
|
||||
- [ ] Ollama HTTP-Client (reqwest, POST /api/chat, keep_alive: 0)
|
||||
- [ ] Oneliner-Generierung (einmalig beim ersten Transkript, danach unveränderlich)
|
||||
- [ ] Queue-Recovery bei Serverstart (Filesystem-Scan)
|
||||
- [ ] faster-whisper Health-Check + Retry
|
||||
- [ ] Ollama Health-Check + Retry
|
||||
- [ ] Lazy Cleanup bei Falllisten-Zugriff (zum Entfernen markierte Fälle vom Vortag löschen)
|
||||
- [ ] Retention-Prüfung bei Zugriff (Audio/Transkripte nach RETENTION_*_DAYS)
|
||||
|
||||
### Phase 3 — Fallverwaltung
|
||||
- [ ] Drei States: Empfangen → Transkribiert → Ausgewertet
|
||||
- [ ] State-Übergang: erst "Transkribiert" wenn alle Aufnahmen eines Falls fertig
|
||||
- [ ] Concurrency-Schutz: RwLock + Pending-Zähler pro Fall (Upload-Handler und Worker synchronisieren)
|
||||
- [ ] "Abschließen" nur im State "Transkribiert" erlaubt
|
||||
- [ ] Aufnahmen nach case_id zusammenführen
|
||||
- [ ] Chronologische Sortierung nach UTC-Zeitstempel
|
||||
- [ ] Ionos LLM → Dokument generieren (bei Fallabschluss)
|
||||
- [ ] Versionierung (document_vN.md + current Symlink)
|
||||
- [ ] Undo-Logik
|
||||
- [ ] Fall zum Entfernen markieren (.remove Marker, Lazy Cleanup am Folgetag)
|
||||
- [ ] Upload-Deduplizierung (gleiche case_id + Timestamp → ignorieren)
|
||||
- [ ] Nachträgliche Uploads für abgeschlossene Fälle (Nachtrag-Erkennung via Timestamp-Vergleich)
|
||||
- [ ] Verspäteter Upload für zum Entfernen markierte Fälle: `.remove`-Marker entfernen, Nachtrag normal verarbeiten
|
||||
- [ ] Upload für gelöschte Fälle (ACK mit status "gone")
|
||||
|
||||
### Phase 4 — Webinterface
|
||||
- [ ] Session-Management: kryptographisches Token (256-Bit, `OsRng`), Cookie (`Secure`, `HttpOnly`, `SameSite=Strict`), serverseitiger `RwLock<HashMap<Token, ArztId>>`, Ablauf nach 8 h
|
||||
- [ ] `AuthenticatedArzt`-Extractor: Session-Token → Arzt auflösen, bei ungültiger/abgelaufener Session → Redirect `/web/login`
|
||||
- [ ] `ValidCaseId`-Extractor: `case_id` als UUIDv4 validieren (`uuid::Uuid::parse_str`), bei Fehler → 400
|
||||
- [ ] Login-Seite (`GET /web/login`, `POST /web/login`)
|
||||
- [ ] askama Templates
|
||||
- [ ] SSE-Endpunkt (`GET /web/events`), Session-Check bei Aufbau + periodisch bei Heartbeat
|
||||
- [ ] Vanilla-JS EventSource-Client
|
||||
- [ ] Übersicht mit drei States (Empfangen/Transkribiert/Ausgewertet)
|
||||
- [ ] Fall-Detail: Transkripte pro Aufnahme einsehen
|
||||
- [ ] Fall abschließen / Alle abschließen
|
||||
- [ ] Dokumentansicht
|
||||
- [ ] Nachtrag-Hinweis bei Aufnahmen nach Abschluss (⚠ "N neue Aufnahmen seit Abschluss")
|
||||
- [ ] Neu generieren (Preset + Freitext, Freitext max. 500 Zeichen)
|
||||
- [ ] Undo-Button
|
||||
- [ ] Fall zum Entfernen markieren (mit Bestätigung)
|
||||
- [ ] Service-Ausfall-Warnung (faster-whisper/Ollama > 30 Min.)
|
||||
- [ ] CSRF-Token pro Session (Hidden Field in allen POST-Formularen, serverseitige Validierung)
|
||||
|
||||
### Phase 5 — Watch App
|
||||
|
||||
**Hinweis:** Ein früher Proof-of-Concept (minimale Aufnahme + Upload auf echter Pixel Watch) sollte parallel zu Phase 2–3 stattfinden, um Wear-OS-spezifische Einschränkungen (Doze-Mode, Foreground-Service-Limits, Battery-Optimization) frühzeitig aufzudecken.
|
||||
|
||||
- [ ] Wear OS Projekt in Android Studio
|
||||
- [ ] Jetpack Compose UI (ein Screen pro Eintrag, fullscreen, Swipe-Navigation)
|
||||
- [ ] Vertikale Fallliste (Neu ganz oben, heutige Fälle darunter)
|
||||
- [ ] Nach Stop: Screen bleibt auf aktuellem Fall (→ "Fortsetzen" direkt sichtbar)
|
||||
- [ ] MediaRecorder (AAC/m4a)
|
||||
- [ ] Bluetooth-Headset Erkennung + Indikator
|
||||
- [ ] case_id (UUIDv4) Generierung: Neu → neue case_id, Fortsetzen → bestehende
|
||||
- [ ] Persistente Speicherung im lokalen Dateisystem (UTC-Timestamps in Dateinamen)
|
||||
- [ ] Marker-Datei pro Fall (`/recordings/cases/{case_id}.json`): minimales JSON bei Erstellung, Oneliner ergänzen nach Empfang
|
||||
- [ ] Fallliste aus Marker-Dateien ableiten (`created_at` von heute + optionaler Oneliner)
|
||||
- [ ] Lazy Cleanup: nur Marker-Dateien (`cases/`) von gestrigen/älteren UTC-Tagen beim Scan löschen — Audiodateien in `unsynced/` werden nie durch Cleanup gelöscht
|
||||
- [ ] Foreground Sync Service (automatischer Upload) — einziger Verantwortlicher für Audio-Löschung (nur nach ACK)
|
||||
- [ ] ACK-Protokoll: "received" → lokal löschen, "gone" → lokal löschen, kein ACK → Retry
|
||||
- [ ] Exponentieller Backoff bei Fehlern
|
||||
- [ ] Doze-Modus-Kompatibilität (Netzwerk-Wiederherstellung)
|
||||
- [ ] Sync-Indikator (☁↑ Anzahl ungesyncter Aufnahmen)
|
||||
- [ ] (optional) Speicherplatz-Warnung bei >500 MB ungesyncten Daten
|
||||
- [ ] Stop → direkt in Sync-Queue, ✓-Feedback, Screen bleibt auf aktuellem Fall
|
||||
- [ ] Oneliner-Polling (alle paar Sekunden für den sichtbaren Fall, bis Oneliner empfangen)
|
||||
- [ ] Emulator-Tests + Pixel Watch Hardware-Test
|
||||
|
||||
### Phase 6 — Integration & Testing
|
||||
- [ ] End-to-End Test (Watch → Server → Webinterface)
|
||||
- [ ] SSE Live-Updates testen
|
||||
- [ ] Pixel Watch Hardware-Test
|
||||
- [ ] LTE-Modus testen
|
||||
- [ ] Bluetooth-Headset testen
|
||||
- [ ] Sync-Service testen (Netzwerkausfall, Neustart, Doze)
|
||||
- [ ] Stop-und-Sync-Flow testen
|
||||
- [ ] Korrektur-Diktate testen (LLM löst Widersprüche korrekt auf)
|
||||
- [ ] faster-whisper-Ausfall simulieren + Warnung prüfen
|
||||
- [ ] Ollama-Ausfall simulieren + Warnung prüfen
|
||||
- [ ] GPU-Phasen-Wechsel testen (Whisper → Ollama → Whisper, VRAM-Freigabe)
|
||||
- [ ] Queue-Recovery nach Server-Neustart testen
|
||||
- [ ] Upload-Deduplizierung testen
|
||||
- [ ] Concurrent Uploads: mehrere Aufnahmen gleichzeitig für denselben Fall → State-Übergang erst nach letzter Transkription
|
||||
- [ ] Mehrere Ärzte testen
|
||||
- [ ] IDOR-Test: eingeloggter Arzt A versucht `case_id` von Arzt B aufzurufen → 404, kein Datenleck
|
||||
- [ ] Session-Ablauf testen: nach 8 h → Redirect zu Login, SSE-Stream geschlossen
|
||||
- [ ] CSRF-Test: POST ohne gültiges CSRF-Token → 403
|
||||
- [ ] Path-Traversal-Test: `case_id` mit `../` → 400
|
||||
- [ ] Ungültige `case_id` (kein UUID) → 400
|
||||
- [ ] Lazy Cleanup testen (Watch: nur Marker-Dateien von gestern gelöscht, Audiodateien in unsynced/ bleiben)
|
||||
- [ ] Lazy Cleanup testen (Server: markierte Fälle am Folgetag entfernt?)
|
||||
- [ ] Retention-Prüfung testen (Audio/Transkripte nach RETENTION_*_DAYS)
|
||||
- [ ] Undo/Regenerate testen
|
||||
- [ ] Fall zum Entfernen markieren → am Folgetag gelöscht
|
||||
- [ ] Nachträglicher Upload nach Fallabschluss → Nachtrag-Hinweis im Webinterface
|
||||
- [ ] Upload für gelöschten Fall → ACK "gone", Watch löscht lokal
|
||||
- [ ] Verspäteter Upload für zum Entfernen markierten Fall → .remove-Marker entfernt, Nachtrag verarbeitet
|
||||
- [ ] Edge-Case: Watch über Nacht/Wochenende offline → Aufnahmen bleiben in unsynced/, Sync bei Wiederherstellung
|
||||
|
||||
---
|
||||
|
||||
## Offene Entscheidungen
|
||||
|
||||
| Thema | Status |
|
||||
|---|---|
|
||||
| Whisper Modell | large-v3 via faster-whisper (CTranslate2, int8) — ggf. large-v3-turbo oder deutsches Fine-Tuning |
|
||||
| Ollama Modell | Gemma 3 4B — ggf. Llama 3.1 8B falls Qualität nicht reicht |
|
||||
| Ionos Modell | Noch zu evaluieren |
|
||||
| Watch Hardware | Pixel Watch 2 oder 3 (LTE empfohlen) |
|
||||
| Authentifizierung Browser | Entschieden: Serverseitiges Session-Token (256-Bit, Cookie mit `Secure`/`HttpOnly`/`SameSite=Strict`), Ablauf 8 h. Arzt-Identität ausschließlich aus Session, nie aus URL (IDOR-Prävention). Login gegen Arzt-Passwort in `.env`. |
|
||||
| Prompt-Qualität | Erfordert Testing mit echten Diktaten |
|
||||
| Transkript editierbar? | Read-only oder editierbar vor Abschluss — offen |
|
||||
| Sicheres Löschen | Reicht rm oder Overwrite nötig? — offen |
|
||||
| faster-whisper HTTP-API | Bestehender Container nutzen oder eigener FastAPI-Wrapper — offen |
|
||||
| Recovery-UI (Webinterface) | Dedizierte Ansicht für verspätet eingetroffene Aufnahmen (Anhören / Ins Dokument / Verwerfen) — optional, Bedarf im Echtbetrieb evaluieren |
|
||||
|
||||
---
|
||||
|
||||
## Nicht im Scope (vorerst)
|
||||
|
||||
- iOS / Apple Watch
|
||||
- Mobile Client-App (Tauri/React)
|
||||
- Praxissoftware-Integration (Medical Office / GDT)
|
||||
- Multi-Tenant / Cloud-Hosting
|
||||
- Echtzeit-Transkription
|
||||
- Automatische Patientenzuordnung
|
||||
Reference in New Issue
Block a user