Files
doctate/docs/projektplan.md
T

40 KiB
Raw Blame History

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 = 1n Aufnahmen, identifiziert durch case_id + Aufnahme-Zeitstempel
  • Aufnahmen persistent ins lokale Dateisystem (überlebt Neustarts, Akku leer)
  • Nach Aufnahme: Stop → direkt in Sync-Queue, Korrekturen per Folge-Diktat
  • Vollautomatischer Sync im Hintergrund (Foreground Service)
  • Kein 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.
// Bei Erstellung ("Neu"):
{
  "created_at": "2026-04-06T09:32:00Z"
}

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

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

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

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


Upload-Paket (vom Sync-Service gesendet):

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

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

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

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


Oneliner-Abfrage (Polling, informativ):

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

Sicherheit 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 ~69 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)

[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 45 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.

[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 23 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