Commit Graph

232 Commits

Author SHA1 Message Date
Brummel 67c0cafb63 docs: update wearos doc anchors after workspace split
Replace stale references in 17 Kotlin files:
- doctate-client-core::*       -> doctate-desktop::*
- doctate-client-core/src/*    -> clients/desktop/src/*
- doctate-common/src/*         -> common/src/*

The doctate-common:: crate path stays valid (crate name unchanged).
PendingStoreTest.kt is in src/test/, so the test mirror is also
updated.

Verification:
- grep for "doctate-client-core" / "doctate-common/src/" in
  clients/wearos/ returns 0 hits.
- gradlew :app:testDebugUnitTest BUILD SUCCESSFUL (23s) -- also
  retroactively confirms stage-2's directory move did not break
  any Gradle relative-path assumptions.
- cargo clippy --all-targets -- -D warnings clean in both
  server/ and clients/desktop/ workspaces.

This concludes the four-stage server-vs-clients build-world split:
- stage 1 (39cc666): dissolve doctate-client-core
- stage 2 (0c66fc6): rename to common/, clients/desktop, clients/wearos
- stage 3 (7b4fe87): split into standalone workspaces
- stage 4 (this):    update wearos anchors

docs/projektplan.md to be updated separately.
2026-05-02 12:27:03 +02:00
Brummel 7b4fe87f95 refactor: split server and clients/desktop into standalone workspaces
Add [workspace] / [workspace.package] / [workspace.dependencies]
blocks to server/Cargo.toml and clients/desktop/Cargo.toml so each
becomes a self-contained workspace with its own Cargo.lock.

Remove the root Cargo.toml and Cargo.lock; the repo now hosts
three independent build worlds: server, clients/desktop, experiments.

Convert common/Cargo.toml to concrete versions (was workspace-
inherited from the root). common/ is consumed via path-dep from
all three worlds, but is not itself a workspace -- consumers
unify versions via their own lockfiles. Add common/Cargo.lock to
.gitignore per Cargo lib-only-crate convention.

Verification:
- server/Cargo.lock has 0 eframe/egui/winit entries
- cargo build --release --locked passes in server/
- test split: server 385 + desktop 89 + common 34 = 508 total
- bin output: clients/desktop/target/debug/doctate (named "doctate")
2026-05-02 12:17:50 +02:00
Brummel 0c66fc6010 refactor: rename to common/, clients/desktop, clients/wearos
Move three directories into their target topology:
- doctate-common/ -> common/
- client-desktop/ -> clients/desktop/
- watch/wearos/   -> clients/wearos/

Update doctate-common path-dependency in 3 Cargo.toml files
(server, clients/desktop, experiments) and the workspace
members list in the root Cargo.toml. Crate names unchanged
(doctate-server, doctate-common, doctate-desktop).

Workspace still in single-Cargo.lock form; isolation into
standalone workspaces follows in stage 3. All 508 tests pass,
experiments standalone-workspace also resolves the new path.
2026-05-02 11:55:38 +02:00
Brummel 39cc666ca6 refactor: dissolve doctate-client-core into doctate-desktop
Move the 9 client-core modules (case_store, case_update, config,
footer_status, pending_cleanup, server_sync, snapshot_cache,
startup, upload) into client-desktop/src/. Resolve the config.rs
name collision by keeping the TOML schema in config.rs (was core)
and renaming the platform-path wrapper to config_path.rs.

Introduce client-desktop/src/lib.rs so the modules are reachable
under the doctate-desktop:: crate path -- needed for Wear-OS doc
anchors that mirror Rust symbols. Bin target stays as `doctate`
via [[bin]] name in Cargo.toml.

This is the first of four stages preparing the server-vs-clients
build-world split. Workspace still has 3 members; directory layout
unchanged. All 508 workspace tests pass.
2026-05-02 11:50:37 +02:00
Brummel c5535c0848 Add reasoning_effort to chat request
Introduce a new field `reasoning_effort` to the `ChatRequest` struct.
This field, set to "medium" by default, allows tuning LLM reasoning
depth to balance hallucination risk against latency and token cost. This
setting is specifically for Ionos' GPT-OSS reasoning models and is
ignored by other OpenAI-compatible models.
2026-04-30 23:52:40 +02:00
Brummel 1fe7c27abf Refactor gazetteer to support per-entry edit distance 2026-04-30 22:54:38 +02:00
Brummel dc7ba40433 Refactor system prompt for medical assistant
The system prompt for the medical assistant has been refactored to
improve clarity and precision. Key changes include:

- Enhanced instructions on handling transcribed errors, emphasizing
  verbatim transcription and the use of `==...==` for uncertain parts.
- More explicit guidance on the formatting of dosage schemes, including
  the conversion of numerical representations to hyphenated formats
  (e.g., "1-0-2").
- Introduction of specific examples for common medical abbreviations and
  terms, such as "CDAI > 450", "per os", and "iv.".
- A new critical rule to enclose any output not part of the transcript
  in `[...]`.
- The addition of "Ileus" to `medical_terms.txt` and "Adalimumab",
  "Infliximab" to `vocabulary.txt`.
2026-04-30 22:41:19 +02:00
Brummel 9c452e8db3 Refactor gazetteer to support bypass aliases
The `Gazetteer` struct has been refactored to store `Entry` structs,
where each entry contains a canonical form and a set of bypass aliases.
This change enables the bypassing of Hunspell's dictionary veto for
specific, explicitly defined aliases.

The `parse_line` function has been introduced to handle the new line
format, which allows for `-`-prefixed aliases. Malformed lines with
secondary tokens not prefixed by `-` now result in a hard error, failing
server startup with `io::ErrorKind::InvalidData`.

The `replace` method has been updated to check bypass aliases before
consulting the dictionary, ensuring that explicitly allowed aliases are
used for rewriting. This addresses issues where Hunspell incorrectly
identifies compounds, blocking necessary corrections.

The `README.md` in the `vocab` directory has been updated to document
the new bypass-alias functionality.
2026-04-30 21:02:41 +02:00
Brummel bb584b6ea0 Refactor: Remove unused Whisper variants
This commit removes the `whisper_variant` and `whisper_hotwords_variant`
fields from the `run_id` generation and the `print_meta_summary`
function.

These variants are no longer used as the project is shifting focus to
LLM-based generation. The `run_full_case.rs` example has also been
updated to reflect this change.
2026-04-30 16:56:12 +02:00
Brummel 6b7a1cea49 feat: Add Canary ASR model service and sweep experiments
This commit introduces the `doctate-canary` service and associated sweep
experiments.

The service provides access to the `nvidia/canary-1b-v2` ASR model via a
native FastAPI API. It includes deployment scripts, Docker
configurations, and detailed documentation on installation, API usage,
and environment variables.

The sweep experiments aim to thoroughly evaluate the Canary model's
performance under various configurations. This includes testing
different inference pipelines (single-shot vs. buffered), decoding
strategies (greedy vs. beam search), and parameter tuning (chunk length,
overlap, batch size, precision). The goal is to reproduce previous
findings and identify optimal settings.

The commit also includes:
- Utility scripts for audio transcoding and manipulation.
- Comprehensive logging and result collection mechanisms for the sweep
  runs.
- Detailed analysis of `dur=0` occurrences and word confidence,
  concluding they are not reliable indicators of hallucination.
- Documentation on the interaction between decoding parameters,
  especially `return_hypotheses`, and the availability of confidence
  scores.
2026-04-30 14:30:12 +02:00
Brummel 1de5cf3891 Add Docker image pre-pull to deploy script
Pre-pulling the base Docker image in the deploy script prevents
potential TLS certificate errors during the `docker build` process. This
ensures a smoother deployment by leveraging the cached image layer.
2026-04-30 09:33:22 +02:00
Brummel 268954f722 feat: Add standalone canary service
This commit introduces a new standalone FastAPI service for the NVIDIA
Canary ASR model.

Key changes include:
- A new `canary/` directory containing the service code.
- `Dockerfile`: Defines the Docker image for the Canary service.
- `docker-compose.yml`: Configures the Docker Compose setup for running
  the service.
- `main.py`: Implements the FastAPI application, model loading, and
  inference logic.
- `requirements.txt`: Lists the Python dependencies for the service.
- `CHUNKING.md`: Documents the long-form audio handling strategy for
  Canary.
- `README.md`: Provides an overview of the service, API, and deployment
  instructions.
- `deploy.sh`: A script for deploying the service to a remote host.

This service allows for independent evaluation and deployment of the
Canary ASR model, separate from the existing `doctate-whisper` service.
It utilizes a buffered inference approach for handling long audio files,
as detailed in `CHUNKING.md`.
2026-04-30 09:24:33 +02:00
Brummel fa585c4316 Add section on suppressed tokens for digits 2026-04-28 00:56:05 +02:00
Brummel ff53d8bd1b experiments: pre-LLM gazetteer pass in run_llm_only + new prompt variants
The previous run_llm_only only applied the gazetteer post-LLM, which
silently underestimated the production pipeline: tokens like Inoxaparin
or Klappenvizien are caught pre-LLM in production, but the test tool
left them in the LLM input. Adding `gazetteer.replace()` to each
recording before render_prompt mirrors transcribe/worker.rs:138 exactly,
making sandbox results faithful to production behavior.

New prompt variants kept for the iteration record:
- v3_treue_konsolidiert.txt — the winning variant now in
  server/src/analyze/prompt.rs; redundancies eliminated where they were
  not load-bearing
- v4_treue_kompakt.txt — rejected variant (3/3 unmarked Hypotonie
  hallucinations); kept so future iterations don't repeat the
  experiment

baseline.txt now mirrors the current server prompt (was a stale
pre-v2 snapshot), so future sandbox runs against `baseline.txt`
compare against what is actually shipping.
2026-04-27 23:27:18 +02:00
Brummel 330e84e473 Consolidate analysis prompt and extend domain vocabulary
Sandbox-validated against case c414cf52 (3 runs each, fair pre+post-LLM
gazetteer pipeline): the new prompt eliminates two hallucination classes
the prior version produced — unmarked "Hypotonie" when the dictation said
"Hypertonie" (0/3 vs 2/3) and inventing units like "35 ng/l" for values
without unit (0/3 vs 2/3) — while keeping Latin terms (Punctum Maximum
etc.) intact in 3/3 runs vs 2/3.

Block layout reorganized so each rule appears exactly once: AUFGABE /
QUELLE / KORREKTUR-POLITIK / TREUE / CHRONOLOGIE / DOSIERUNGSSCHEMA /
MARKIERUNGEN. The removed "AKTIVE KORREKTUR (nicht durchreichen)" hammer
block is no longer load-bearing because the gazetteer's pre-LLM pass now
catches the typical drug-name typos before the LLM sees them.

Vocabulary additions (single-token, alphabetical, ≥5 chars):
- vocabulary.txt: Enoxaparin (was missing — Inoxaparin→Enoxaparin is a
  recurrent ASR error and the prior pipeline relied on the LLM alone)
- medical_terms.txt (new file, picked up by the dir-glob loader):
  Holosystolikum, Klappenvitien, Koronarbaum, Pumpfunktion
2026-04-27 23:27:05 +02:00
Brummel 86e7affbc6 Drop curve-fitted whisper prompt v1_dosing_minimal
Datei enthielt Bisoprolol/Ramipril mit den exakten Schemata aus dem
Test-Case c414cf52 — klassisches Curve-Fitting, das beim Test gegen
genau diesen Fall keinen Erkenntnisgewinn bringt und beim Roll-out
auf andere Fälle aktiv halluziniert (Whisper bias'd auf gelistete
Termini, auch bei Stille oder Gemurmel).

Whisper-Initial-Prompts werden vorerst nicht weiter iteriert: Das
Hauptproblem 1-0-0/1-0-1 ist durch den LLM-DOSIERUNGSSCHEMA-Block
bereits gelöst, und der Halluzinations-Risikoaufschlag bei Whisper-
Prompts ist höher als bei der LLM-Stufe. baseline.txt (leer) bleibt
als Slot-Referenz erhalten.
2026-04-27 22:50:30 +02:00
Brummel d092c1621c Tighten consolidation prompt: fidelity clause, conflict-resolution
Sandbox-iteration unter experiments/case_c414cf52 (3 Runs je Variante,
gpt-oss-120b @ Ionos, temperature=0). v2_treue gewinnt gegen Baseline
und v1_treue auf folgenden Achsen ohne Curve-Fitting im Prompt:

- Active drug-name correction (Inoxaparin→Enoxaparin, Thorazemit→
  Torasemid, AFREF→HFrEF) durchgesetzt: 3/3 statt 0/3
- Schema 1-0-0 / 1-0-1 zuverlässiger: 3/3 statt 2/3
- Troponin-T fehlende Einheit als ==35== markiert (vorher unkommuniziert)
- Hypo/Hyper-Verlaufsgeschichte aus widersprüchlichen Aufnahmen
  unterdrückt: 0/3 (war 1/3 in v1_treue)
- Anatomische Lokalisationen, "adipöser Ernährungszustand" und
  Diktat-Reihenfolge bleiben treu (vorher selten aber gelegentlich
  weggelassen oder umsortiert)
- Format als Fließtext explizit, Pseudo-Headings reduziert

Strukturell offen (kein Prompt-Fix möglich): "Holosystolikum" aus
Whispers Halbwortruine "Tolikum", "Koronarbund"-Halluzination aus
"Corona-Baum" — beides braucht Gazetteer-Erweiterung oder
Tokenizer-Refactor, separate PRs.

Umstellung auf Rust-Raw-String macht die Prompt-Datei deutlich
lesbarer (keine \n-Escapes, keine \"-Escapes mehr).
2026-04-27 22:48:44 +02:00
Brummel 10d0a6ba94 Add experiments sandbox: prompt iteration tooling
Nested Cargo-Workspace unter experiments/ als Pfad-Dependency auf
../server. Reproduziert die Production-Pipeline 1:1 durch direkte
Wiederverwendung der Server-Module (transcribe::whisper, analyze::llm,
gazetteer, ffmpeg-Remux) — kein eigenes HTTP-Re-Implementieren, kein
Drift-Risiko zur Live-Pipeline.

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 existierenden,
  gazetteer-bereinigten Transkripten — spart Whisper-Calls
- diff_runs: qualitativer Vergleich zwischen zwei Run-Verzeichnissen

Repo-Hygiene: case_*/ (Patientendaten, Symlinks zu tmpdata) und _data/
(Run-Outputs) sind komplett gitignored. Nur Code und fall-übergreifende
Prompt-Hypothesen (prompts/) werden versioniert.

Erste Prompt-Varianten zur Treue-Klausel und Konflikt-Auflösung:
prompts/llm/v1_treue.txt, v2_treue.txt.
2026-04-27 22:43:02 +02:00
Brummel 99f77b666d Feat: Add replay-gain to audio playback
The `RecordingView` struct now includes `gain_db` to represent
replay-gain. This value is read from the recording's metadata (`.json`
sidecar) and passed to the frontend.

The JavaScript in `case_recordings.html` uses this `data-gain-db`
attribute to apply replay-gain using the Web Audio API. This ensures
consistent playback loudness across different recordings. The
implementation uses a shared `AudioContext` to manage resources
efficiently and handles cases where the browser might not support
`AudioContext`.

Additionally, the audio recording on Wear OS now uses
`MediaRecorder.AudioSource.VOICE_RECOGNITION` instead of `MIC`. This
leverages platform noise and echo cancellation, aiming for a cleaner
signal before server-side gain adjustment.
2026-04-27 17:08:10 +02:00
Brummel 710b60bb16 Extract analysis mapping to pure function
This commit extracts the logic for mapping audio analysis results into
the `RecordingMeta` fields into a new, pure function
`analysis_to_meta_fields`. This improves testability by separating the
core mapping logic from logging and other side effects.

The previous implementation directly handled logging and conditional
logic within the `run` function. The new function encapsulates these
mapping rules, including handling finite values, silent audio, and error
propagation. The caller, `run`, is now responsible for logging any
analysis failures before calling the helper. Unit tests have been added
to verify the behavior of `analysis_to_meta_fields` in various
scenarios.
2026-04-27 16:23:25 +02:00
Brummel 4a7f84d455 Run audio analysis and Whisper concurrently 2026-04-27 16:18:45 +02:00
Brummel 427a28ac6b Add loudness data to recording metadata
This commit introduces the `Loudness` struct to store replay-gain data
(mean, max, and calculated gain in dB) derived from `ffmpeg`'s
`volumedetect`.

The `RecordingMeta` struct is updated to include an optional `loudness`
field. This allows for consistent playback volume across recordings with
varying microphone levels by applying gain in the browser.

Additionally, new `server/src/loudness.rs` and
`server/src/transcribe/ffmpeg.rs` modules are added. The former defines
constants for replay-gain targets and logic to compute gain, while the
latter handles audio analysis using `ffmpeg` to extract loudness
metrics.

The `#[serde(default)]` attribute on the `loudness` field in
`RecordingMeta` ensures backward compatibility with older metadata files
that do not contain this field. Tests are included to verify round-trip
serialization and parsing of older formats.
2026-04-27 16:11:02 +02:00
Brummel 8a531144af Update projektplan.md 2026-04-27 15:31:11 +02:00
Brummel cd662141e0 Add input validation for web endpoints
Introduces a new `validate` module to centralize input validation for
web
requests. This module contains functions to check the shape and format
of
various user-supplied strings before they are processed by the main
application logic.

Specifically, this commit adds validation for:
- Login slugs: Ensures slugs conform to a strict pattern (lowercase
  alphanumerics, `_`, `-`) to prevent path traversal and other attacks.
- Magic link tokens: Validates token length and character set to ensure
  they are URL-safe and within expected bounds.
- Recorded at timestamps: Enforces a strict RFC3339 format with second
  granularity (`YYYY-MM-DDTHH:MM:SSZ`) to prevent filename parsing
  issues
  and ensure consistent data grouping.

These validators are integrated into the `handle_login_submit`,
`handle_consume` (magic link), and `handle_upload` routes. This change
enhances security by preventing malformed inputs from reaching sensitive
parts of the application and improves robustness by catching data format
errors early.

Unit and integration tests have been added to verify the functionality
of
these validators and their integration into the respective endpoints.
2026-04-27 15:19:23 +02:00
Brummel 9cc0946f90 Refactor timestamp generation to use helper function
Introduces `PendingStore.nowRfc3339()` to consistently generate RFC3339
timestamps with second granularity. This avoids potential issues with
sub-second precision in filenames and aligns with common timestamp
formatting practices. The helper function is used in `CaseDetailScreen`
and `RecordingViewModel` to replace direct calls to
`Instant.now().toString()`. A new unit test verifies the expected
behavior of the helper function.
2026-04-27 14:53:35 +02:00
Brummel c15590f3e0 Refactor transcript file handling to use JSON metadata
This commit changes the way transcriptions are stored and accessed.
Instead of using plain text files (`.transcript.txt`), transcriptions
will now be part of a JSON metadata file (`<stem>.json`). This allows
for richer metadata to be stored alongside the transcript, such as
duration, and provides a more robust mechanism for tracking
transcription states.

The changes include:
- Updating documentation and code to reflect the new `.json` file
  extension.
- Modifying file handling logic to read and write JSON metadata.
- Adjusting tests to accommodate the new file format.
2026-04-27 13:08:36 +02:00
Brummel 66b3b7e4c8 Refactor recording metadata to JSON sidecar
Replaces the `.transcript.txt` sidecar with a structured `.json` file
for recording metadata. This change consolidates transcript text,
duration, and other potential metadata into a single, extensible JSON
object.

This also refactors the `TranscriptState` enum to better represent the
on-disk state (absence of file means pending) and the in-memory
representation. The `Transcript` enum now specifically models the
terminal outcomes of the transcriber (`Silent` or `Content`).

The commit includes updates to documentation, data structures, path
handling, and various tests to align with the new metadata format.
2026-04-27 12:48:25 +02:00
Brummel a510c20e75 refactor(server): plumb Arc<Settings> through workers and routes
Config sheds its 14 Bucket-B fields (retention, whisper, ollama, llm) and
gains a sibling Arc<Settings> in AppState, loaded from settings.toml at
startup via Settings::load_or_default. Workers (transcribe, analyze,
recovery, auto_trigger) and routes (health, bulk, case_actions,
user_web) take settings as a separate parameter or State<> extractor;
Config::llm_configured moves to Settings::llm_configured.

TestConfig::build_pair() returns (Arc<Config>, Arc<Settings>); the new
create_router_with_settings / create_router_and_session_store_with_settings
helpers wire both into integration tests that exercise LLM/Whisper/Ollama.
The legacy single-Arc create_router falls back to Settings::default()
so the 17 non-LLM tests stay untouched.

Verified: cargo build clean, clippy --all-targets clean, 318 tests pass.
2026-04-27 11:48:41 +02:00
Brummel 6bbbf10f77 feat(server): split runtime knobs out of .env into settings.toml
.env shrinks to bootstrap values only (port, paths, log, auth, deploy
flags). The 14 admin-tunable values (retention, whisper, ollama, llm)
move to a new settings.toml with serde(default)-based per-section
defaults and an empty-prompt fallback to the code default. settings.toml
is gitignored alongside .env; settings.toml.example is the template.

This commit fixes the file layout. The server-side wiring (Config shrink,
AppState integration, consumer migration) follows separately.
2026-04-27 11:21:23 +02:00
Brummel 0205301018 feat(watch): push manual oneliner edits to server + 👤 indicator
Tap-to-edit on the case detail now does both halves:
1. Optimistic local update (caseStore.setOnelinerLocal) — UI flips to
   Manual immediately, no waiting for the network round-trip.
2. PUT /api/cases/{case_id}/oneliner with X-API-Key — fire-and-forget
   so an offline edit doesn't block the UI. On HTTP failure we log;
   the local Manual sticks and the next manual edit (presumably online)
   gets pushed up. Edits before the case has been uploaded server-side
   will 404 here — accepted divergence.

OnelinerOverrideClient mirrors the server contract from
server/src/routes/oneliner_override.rs:108-124 exactly: PUT body is
{text: string}, response is OnelinerState (Manual variant). 5 new
MockWebServer tests cover happy path, 400/404/network classification,
and the on-the-wire request shape.

UI: 👤 prefix on doctor-authored OneLiners across all three surfaces
(CaseListScreen row, CaseDetailScreen large text, Tile center). Auto-
generated (Ready) and unset (Empty/Error/null) states render without
the prefix, so the doctor can tell at a glance whether they wrote
this themselves. Verified live on emulator-5556 against existing
server Manual cases ('Kastanienbaum', 'eigener Bezeichner').

78 tests green total.
2026-04-27 00:16:10 +02:00
Brummel 1016788514 fix(watch): kick sync service unconditionally on app start
Without this, the watch would only run a poll cycle if the pending
queue happened to be non-empty — meaning the case list was empty
forever for users who didn't dictate before opening the app. Kicking
on every launch makes the foreground service start, primes the case
store from the snapshot cache, then polls /api/oneliners.

Verified end-to-end on Wear OS 36 emulator-5556 against a live local
server:
- 4 server cases populate the list within ~150 ms of app start
- Tap Neu → mic permission → record → stop completes in <3 s of UI
- Sync service uploads, server transcribes (whisper) + classifies
  (ollama), watch's burst-poll hits within 2 s of upload-success and
  merges the OnelinerState.Empty (silence rule) back into the marker
- unsynced/ ends empty (deletePair after ACK), cases/ has 5 markers
  with synced_to_server=true, snapshot_cache.json on disk
2026-04-27 00:03:40 +02:00
Brummel 20706bcf0c feat(watch): post-stop burst polling for sub-five-second oneliner
After a successful upload the worker self-kicks a BurstPoll(caseId, now+60s)
into its own kick channel. While a burst is active, currentPollIntervalMs()
returns 2 s instead of the 30 s default, giving the doctor a chance to see
the LLM-generated oneliner before they swipe away from the case.

Burst exits on either of:
- the response carries a non-null oneliner for the named case
- the 60 s budget elapses

Implemented as worker-internal state — no Service plumbing changes, the
existing kick channel is the medium. SyncService still receives external
BurstPoll kicks via Intent (already wired in Phase 4) so the launcher path
remains usable, but the post-upload self-kick covers the primary use case.

2 new tests (kick-on-upload + budget-expiry-resets-interval). 73 tests
green total.
2026-04-26 23:57:43 +02:00
Brummel a82a63f696 feat(watch): conditional-GET oneliner polling with snapshot cache
OnelinersClient does GET /api/oneliners with X-API-Key + If-None-Match,
classifies 200/304/transient/network. Mirrors
doctate-client-core::server_sync::poll_once exactly. JSON parsing lives
in domain.OnelinersJson (org.json) so the wire DTOs are first-class
in-memory types.

SnapshotCache persists the last successful response + its ETag in
filesDir/recordings/snapshot_cache.json. SyncWorkerLoop primes the
case store from this cache on cold start before the first network
call, so the watch never flashes an empty list while the service
boots.

Worker loop integration: when the pending queue is empty and a poller
is wired, runOnce polls instead of just publishing Idle. Success runs
mergeServerSnapshot + reconcileWithServerSnapshot, then writes the
new snapshot to the cache. Phase 4's poller-null behaviour remains as
the test/skeleton path.

13 new tests (OnelinersJsonTest 3, OnelinersClientTest 6, SnapshotCacheTest
4) covering parse round-trip, 200/304/transient/network classification,
If-None-Match header presence, corrupted-cache resilience. 71 tests
green total.
2026-04-26 23:56:02 +02:00
Brummel 68a77322d0 feat(watch): sync indicator UI + POST_NOTIFICATIONS prompt
CaseListScreen shows a small ☁↑N at the top whenever the worker has
queue items; per-row ↑ next to the timestamp marks individual cases
that are still mid-upload. Both surface the SyncStateProvider directly
so the user sees the queue draining in real time.

MainActivity prompts for POST_NOTIFICATIONS on Wear OS 13+ — the
foreground service runs either way, but on API 33+ the persistent
notification only renders if the runtime grant is given. Result is
informational; we don't gate the recording flow on it (the doctor
still needs to be able to dictate even after a 'deny').
2026-04-26 23:52:59 +02:00
Brummel 491b093b86 feat(watch): foreground SyncService with backoff worker loop
SyncWorkerLoop is the upload+poll core, mirrored from
doctate-client-core::server_sync::run. Pending uploads have priority
over polling (no point asking the server for state if we still hold
the doctor's audio). Backoff is in-process state — after restart it
drops back to 2s and gives the server one fresh chance, which beats
'wait 60s because we cancelled mid-backoff'.

Architecturally split into runOnce (single iteration, suspends only
on real I/O) and run (infinite loop, suspends on idle). The split
makes the loop directly testable on JVM virtual time without dealing
with advanceUntilIdle vs. infinite-while semantics — tests call
runOnce N times and assert in between.

Wired through:
- SyncService (foregroundServiceType=dataSync, low-importance silent
  notification, ServiceCompat.startForeground for API 34+)
- SyncServiceLauncher replaces NoopSyncTrigger in DoctateApp
- DoctateApp.onCreate kicks the service after StartupCleanup if the
  pending queue is non-empty (recovery from crash/reboot)
- AndroidManifest: FOREGROUND_SERVICE / FOREGROUND_SERVICE_DATA_SYNC /
  POST_NOTIFICATIONS perms; service declaration

6 SyncWorkerLoopTest cases (success/transient/terminal/idle/fifo plus
backoff-resets-after-success). 58 tests green total.
2026-04-26 23:51:58 +02:00
Brummel 11bbeeaefb feat(watch): startup cleanup for stale markers and orphan audio
Two retention sweeps run once on app launch before the sync worker
spawns. Mirrors doctate-client-core::startup + ::pending_cleanup.

- Marker sweep: synced markers older than 72h are dropped via
  caseStore.cleanupStale; unsynced markers (guarding pending uploads)
  are preserved indefinitely.
- Orphan audio sweep: m4a without sidecar (or vice versa) older than
  24h goes; tmp leftovers go unconditionally. Paired files survive
  regardless of age — the upload worker owns deletion of valid pairs.

Best-effort: failures are logged but never thrown. A hostile filesystem
must not stop the watch from starting.

9 new tests (PendingCleanupTest 6, StartupCleanupTest 3) covering the
window, sibling, and tmp-leftover edges. 52 tests green total.
2026-04-26 23:39:54 +02:00
Brummel 5e4af12822 feat(watch): persistent upload queue with sidecar pairs
Recording flow no longer uploads inline. AudioRecorder writes the m4a
straight into filesDir/recordings/unsynced/{caseId}_{utc}.m4a; the VM
follows up with an atomic sidecar (.meta.json) holding case_id + recorded_at.
Sync trigger is a SyncTrigger seam (no-op for now, fleshed out in Phase 4)
so the screen pops back to the case detail immediately after the queue
write — uploads happen out-of-band.

PendingStore mirrors doctate-client-core::upload (sidecar shape, atomic
tmp+rename, scan FIFO sorted by recorded_at, deletePair idempotent).
Audio file naming uses recorded_at_to_filename_stem semantics — colons
swapped for dashes — so paths round-trip with the desktop client and
filesystems that disallow colons.

8 new PendingStoreTest cases (atomic write, FIFO order, orphan skip,
unreadable sidecar resilience, idempotent delete). 43 tests green total.
2026-04-26 23:38:25 +02:00
Brummel 24644a0144 feat(watch): replace CaseStoreStub with disk-backed CaseStore
Per-case JSON markers under filesDir/recordings/cases/, atomic tmp+rename
writes, kotlinx.coroutines Mutex serialising mutations. Wire format mirrors
doctate-client-core::CaseMarker exactly (snake_case keys, RFC3339 strings,
internally-tagged OnelinerState) — markers round-trip with the desktop client.

mergeServerSnapshot adds/updates only; reconcileWithServerSnapshot removes
synced markers inside the response window that the server stopped reporting.
Pending markers are never touched. Local Manual oneliners stick against
non-Manual server overrides.

DoctateApp now exposes caseStore as a lazy property and wires a
distinct-head flow collector to TileService.requestUpdate, decoupling
the store from Android Tile APIs (testable on the JVM).

JVM test infra: real org.json:json:20240303 plus
testOptions.unitTests.isReturnDefaultValues=true to bypass android.jar
'not mocked' stubs for Log/JSONObject. 35 unit tests green incl. 13 new
DiskCaseStoreTest cases (bootstrap, merge/reconcile semantics, mutex
ordering).
2026-04-26 23:35:00 +02:00
Brummel 6d47b6b99b feat(watch): lift CaseEntry to OnelinerState sealed type
Mirrors doctate-common/src/oneliners.rs::OnelinerState (Ready/Empty/Error/Manual)
in Kotlin. CaseEntry gains syncedToServer; manualOneliner boolean is gone — the
Manual variant carries that semantic. UI render sites use displayText() so the
'…' placeholder shows up consistently for null/Empty/Error states.

Phase 0 of the Wear-OS full-implementation plan: shape the data, no behaviour
change. All 30 JVM tests green.
2026-04-26 23:27:35 +02:00
Brummel 9a00b592f0 fix: Preserve manual oneliner across delete and reset paths
Consolidate the sticky-Manual rule for oneliner.json into a single
helper paths::delete_oneliner_unless_manual that holds the per-case
lock, reads the state, keeps OnelinerState::Manual, and removes any
LLM-derived variant. Three previously-direct deletion paths now route
through it: handle_delete_recording, reset_case_artefacts (admin
single-reset), and bulk_reset (admin bulk action). Before this fix a
clinician's manual override was wiped by a recording delete, letting
the LLM auto-regen path overwrite it on the next view.

Tests: 4 unit tests for the wrapper contract, integration tests for
the recording-delete and case-reset endpoints (Manual preserved,
Ready wiped), and a static-scan invariant that fails on direct
remove_file(... ONELINER_FILENAME) calls outside paths.rs.
2026-04-26 22:09:08 +02:00
Brummel eaed8f0dcd fix: Inline recording-delete confirm + stable redirects
- Replace native confirm() in the recording list with an inline
  two-step confirm row; hide the player via visibility:hidden
  while open so the row height stays stable.
- Hard-redirect recording delete back to /web/cases/{id}/recordings
  instead of relying on the Referer header (which silently fell
  back to /web/cases when stripped).
- Switch analyze/reset to a hidden return_to form field so the
  source page (case detail vs. case list) is preserved reliably,
  with same-origin /web/ prefix validation.
- Tighten delete_recording_test on the concrete Location header;
  adjust analyze/reset redirect expectations to the new
  case-detail fallback.
2026-04-26 17:52:29 +02:00
Brummel 03736b6993 feat: Add web UI for manual oneliner overrides
Adds a new POST endpoint for web-based oneliner overrides and updates
the UI to allow manual editing. The person icon replaces the pencil icon
for doctor-authored titles, signifying manual authorship rather than
editability.
2026-04-26 17:17:09 +02:00
Brummel d732fdd8ec feat: Add inline oneliner editing
Introduces inline editing for oneliner text within the case list. This
feature allows users to directly modify oneliner entries in the UI, with
changes being optimistically applied locally and then asynchronously
sent to the server.

Includes:
- New `OnelinerEditState` to manage the editing buffer and focus.
- `SaveResult` struct to communicate the outcome of background save
  operations.
- UI logic to toggle between read and edit modes for oneliner entries.
- Handling of keyboard inputs (Enter, Esc) and button clicks for saving
  or canceling edits.
- Toast banner to display save errors to the user.
- Asynchronous `put_oneliner` calls for background server updates.
- Local storage update for optimistic UI updates.
2026-04-26 16:35:14 +02:00
Brummel d5e6c334c9 feat: Add function to update oneliner locally
Introduces `set_oneliner_local` to `CaseStore` for optimistic UI
updates.
Also adds `case_update` module with `put_oneliner` to handle server-side
persistence.
2026-04-26 16:12:03 +02:00
Brummel c769abe3d5 feat: Add per-case dir oneliner locks
Introduces `OnelinerLocks` to serialize read-modify-write operations on
`oneliner.json`. This prevents race conditions between the manual
override API (`PUT /api/cases/{case_id}/oneliner`) and the transcription
worker's auto-regeneration process.

The manual override now acquires a lock specific to the `case_dir`
before writing. The transcription worker's `update_oneliner` function
also acquires the same lock around its post-LLM re-read-and-write
sequence. This ensures that a doctor's manual edit always takes
precedence, even if it arrives while an auto-regeneration is in
progress.

This change also includes:
- A new `routes::oneliner_override` module for the PUT handler.
- Validation for the oneliner text length and emptiness.
- Integration tests for the override functionality, covering happy path,
  error conditions, early latching, race conditions, and parallel PUTs.
2026-04-26 16:05:01 +02:00
Brummel 73fa099b51 Add OnelinerState::Manual variant
This commit introduces a new `Manual` variant to the `OnelinerState`
enum, allowing for manual overrides of the oneliner text. This change
affects the client and server components to handle and display this new
state.

A `OnelinerOverrideRequest` struct is also added for API requests to set
manual oneliners. New tests are included to ensure the `Manual` variant
serializes and deserializes correctly.

The `compute_oneliner_display` function in
`server/src/routes/user_web.rs` is updated to treat `Manual` states the
same as `Ready` states for display purposes. The
`cases_needing_oneliner_in` function in
`server/src/transcribe/recovery.rs` is updated to not retry cases that
are in a `Manual` state.
2026-04-26 15:44:13 +02:00
Brummel 1b689d1c8c feat: Add scheduled_tasks.lock file
The newly created `scheduled_tasks.lock` file tracks scheduled tasks for
Claude, ensuring efficient and organized task management.
2026-04-26 14:36:22 +02:00
Brummel 506804e4cd Add tap-to-edit oneliner on case detail screen
Tap opens the Wear OS system input picker (voice/keyboard/handwriting),
pinned to de-DE for medical German vocabulary. Doctor-authored oneliners
latch a manual flag that blocks the simulated LLM burst from overwriting
them; a subsequent manual edit still wins.
2026-04-24 16:22:32 +02:00
Brummel edcee399b4 Split recording flow into case detail + recording screens
Tap on a case in the list (or Tile continue-case) now opens a new
CaseDetailScreen showing date + oneliner + record EdgeButton. Only
hitting record enters RecordingScreen, which is itself trimmed to
just a live mm:ss counter and a stop EdgeButton. Swipe-right on the
recording screen discards the in-flight recording (no confirmation).
"New case" button still bypasses the detail step.

State machine: Recording now counts elapsedSeconds (up) instead of
secondsLeft. New OnStopTap and OnDiscardTap events, new reducer
transitions. Safety cap at 300 s.

ViewModel: startRecordingFlow split into a cancellable ticker job
plus a separate finalizeAndUpload run in applicationScope so the
upload survives the pop that follows a clean stop. AtomicBoolean
finalizationStarted guards against double-finalization between the
stop, discard, and auto-stop paths.

Display: FLAG_KEEP_SCREEN_ON on the recording screen via
view.keepScreenOn. DisposableEffect.onDispose doubles as the
discard trigger for swipe-right / back / host destruction;
discardIfStillRecording() is a no-op after a clean stop.

CaseDetailScreen lays out three vertical thirds: date/time at top
(Heute/Gestern/dd.MM.yy + HH:mm:ss), oneliner in the middle,
EdgeButton at the bottom. formatTime extracted to TimeFormat.kt
so list and detail never drift apart.
2026-04-24 15:42:52 +02:00
Brummel 028342d4a4 Add "minutes ago" time formatting
Introduces a `minutesAgo` helper function and uses it in `CaseStoreStub`
to populate `CaseEntry` objects with relative timestamps for recent
activities. Also updates `formatTime` in `CaseListScreen` to display
"Gerade eben", "Vor 1 Minute", or "Vor X Minuten" for entries younger
than an hour. This provides a more user-friendly display for recent
cases.
2026-04-24 14:34:55 +02:00