From d65720c671a6cf4fb62ffb6b1243e7951caab712 Mon Sep 17 00:00:00 2001 From: Brummel Date: Mon, 20 Apr 2026 12:37:48 +0200 Subject: [PATCH] Refactor oneliner storage to use enum The oneliner is now stored as `OnelinerState` enum which can represent three states: `Ready`, `Empty` or `Error`. This allows the client to differentiate between a case with no medical content and a case where the oneliner generation failed. The `OnelinerState` enum is serialized to JSON and stored in `oneliner.json` file. The `OnelinerEntry` struct has been updated to reflect this change. The client-desktop application has been updated to handle the new `OnelinerState` enum and display appropriate UI elements for each state. The server-side code has also been updated to read and write the `OnelinerState` enum, and to handle the new file format. The `reset_case_artefacts` function in `server/src/routes/case_actions.rs` has been updated to remove `oneliner.txt` and create `oneliner.json` instead. The tests have been updated to reflect these changes. --- client-desktop/src/app.rs | 12 +-- doctate-client-core/src/case_store.rs | 41 +++++++--- doctate-client-core/src/server_sync.rs | 7 +- doctate-client-core/src/snapshot_cache.rs | 16 ++-- doctate-common/src/oneliners.rs | 44 +++++++++-- server/src/paths.rs | 35 +++++++++ server/src/routes/case_actions.rs | 3 +- server/src/routes/oneliners.rs | 30 ++----- server/src/routes/user_web.rs | 63 +++++++++++---- server/src/transcribe/recovery.rs | 95 +++++++++++++++++++---- server/src/transcribe/worker.rs | 78 ++++++++++++------- server/templates/my_cases.html | 3 +- server/tests/analyze_test.rs | 8 +- server/tests/case_page_test.rs | 41 +++++++++- server/tests/oneliners_api_test.rs | 89 ++++++++++++++++++--- 15 files changed, 440 insertions(+), 125 deletions(-) diff --git a/client-desktop/src/app.rs b/client-desktop/src/app.rs index dc90907..991dde7 100644 --- a/client-desktop/src/app.rs +++ b/client-desktop/src/app.rs @@ -18,6 +18,7 @@ use doctate_client_core::{ PendingUpload, ServerSync, SnapshotCache, SyncConfig, UploadEvent, WorkerSnapshot, WorkerState, pick_footer_status, run_startup_cleanup, write_sidecar, }; +use doctate_common::oneliners::OnelinerState; use doctate_common::timestamp::{ extract_hhmm, now_rfc3339, parse_rfc3339, recorded_at_to_filename_stem, }; @@ -506,11 +507,12 @@ impl DoctateApp { ui.add_space(4.0); let time_str = extract_hhmm(&marker.last_activity_at); - let oneliner = marker - .oneliner - .as_deref() - .map(str::to_owned) - .unwrap_or_else(|| "⏳".to_owned()); + let oneliner = match &marker.oneliner { + Some(OnelinerState::Ready { text, .. }) => text.clone(), + Some(OnelinerState::Empty { .. }) => "∅".to_owned(), + Some(OnelinerState::Error { .. }) => "⚠".to_owned(), + None => "⏳".to_owned(), + }; let has_pending = pending_ids.contains(&marker.case_id); let line = if has_pending { format!("⬆ {time_str} · {oneliner}") diff --git a/doctate-client-core/src/case_store.rs b/doctate-client-core/src/case_store.rs index cdcd2e2..616a6cf 100644 --- a/doctate-client-core/src/case_store.rs +++ b/doctate-client-core/src/case_store.rs @@ -22,7 +22,7 @@ use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::sync::Arc; -use doctate_common::oneliners::OnelinersResponse; +use doctate_common::oneliners::{OnelinerState, OnelinersResponse}; use serde::{Deserialize, Serialize}; use thiserror::Error; use time::format_description::well_known::Rfc3339; @@ -48,9 +48,12 @@ pub struct CaseMarker { /// (`= false`) markers guard unsent uploads and are never touched by /// either — losing one would silently erase a patient recording. pub synced_to_server: bool, - /// `None` while transcription / oneliner generation is pending; set - /// from the server snapshot when available. - pub oneliner: Option, + /// Persisted oneliner state mirrored from the server snapshot. + /// `None` while no state file has been written server-side yet (fresh + /// case, nothing transcribed). The variant distinguishes usable text + /// from "no medical content" and "generation failed" — clients render + /// each case as they see fit. + pub oneliner: Option, } #[derive(Debug, Error)] @@ -443,10 +446,24 @@ mod tests { OffsetDateTime::parse(s, &Rfc3339).unwrap() } + fn ready(text: &str) -> OnelinerState { + OnelinerState::Ready { + text: text.to_owned(), + generated_at: "2026-04-18T10:00:00Z".into(), + } + } + + fn text_of(o: &Option) -> Option<&str> { + match o { + Some(OnelinerState::Ready { text, .. }) => Some(text.as_str()), + _ => None, + } + } + fn server_entry(case_id: Uuid, oneliner: Option<&str>, created: &str) -> OnelinerEntry { OnelinerEntry { case_id: case_id.to_string(), - oneliner: oneliner.map(str::to_owned), + oneliner: oneliner.map(ready), created_at: created.to_owned(), last_recording_at: Some(created.to_owned()), updated_at: Some(created.to_owned()), @@ -535,7 +552,7 @@ mod tests { let list = store.list().await; assert_eq!(list.len(), 1); assert_eq!(list[0].case_id, id); - assert_eq!(list[0].oneliner.as_deref(), Some("Kniegelenk re.")); + assert_eq!(text_of(&list[0].oneliner), Some("Kniegelenk re.")); assert!(list[0].synced_to_server); } @@ -559,7 +576,7 @@ mod tests { let list = store.list().await; assert_eq!(list.len(), 1); assert!(list[0].synced_to_server); - assert_eq!(list[0].oneliner.as_deref(), Some("Hypertonie")); + assert_eq!(text_of(&list[0].oneliner), Some("Hypertonie")); } #[tokio::test] @@ -604,7 +621,7 @@ mod tests { let list = store.list().await; assert_eq!(list.len(), 1); assert!(list[0].synced_to_server); - assert_eq!(list[0].oneliner.as_deref(), Some("old")); + assert_eq!(text_of(&list[0].oneliner), Some("old")); } #[tokio::test] @@ -670,7 +687,7 @@ mod tests { // oneliner regenerated just now in a recovery scan. let entry = OnelinerEntry { case_id: id.to_string(), - oneliner: Some("x".into()), + oneliner: Some(ready("x")), created_at: "2026-04-17T09:00:00Z".into(), last_recording_at: Some("2026-04-18T09:30:00Z".into()), updated_at: Some("2026-04-18T10:00:00Z".into()), @@ -694,7 +711,7 @@ mod tests { let id = Uuid::new_v4(); let entry = OnelinerEntry { case_id: id.to_string(), - oneliner: Some("x".into()), + oneliner: Some(ready("x")), created_at: "2026-04-17T09:00:00Z".into(), last_recording_at: None, updated_at: Some("2026-04-18T10:00:00Z".into()), @@ -724,7 +741,7 @@ mod tests { store .merge_server_snapshot(&server_snapshot(vec![OnelinerEntry { case_id: old_synced.to_string(), - oneliner: Some("old".into()), + oneliner: Some(ready("old")), created_at: "2026-04-14T10:00:00Z".into(), last_recording_at: Some("2026-04-14T10:00:00Z".into()), updated_at: Some("2026-04-14T10:00:00Z".into()), @@ -740,7 +757,7 @@ mod tests { store .merge_server_snapshot(&server_snapshot(vec![OnelinerEntry { case_id: young_synced.to_string(), - oneliner: Some("young".into()), + oneliner: Some(ready("young")), created_at: "2026-04-18T10:00:00Z".into(), last_recording_at: Some("2026-04-18T10:00:00Z".into()), updated_at: Some("2026-04-18T10:00:00Z".into()), diff --git a/doctate-client-core/src/server_sync.rs b/doctate-client-core/src/server_sync.rs index 1a1cce5..e3e3489 100644 --- a/doctate-client-core/src/server_sync.rs +++ b/doctate-client-core/src/server_sync.rs @@ -515,12 +515,17 @@ mod tests { } fn oneliner_body(case_id: &str, oneliner: Option<&str>) -> OnelinersResponse { + use doctate_common::oneliners::OnelinerState; + let state = oneliner.map(|t| OnelinerState::Ready { + text: t.to_owned(), + generated_at: "2026-04-18T10:00:00Z".into(), + }); OnelinersResponse { as_of: "2026-04-18T10:00:00Z".into(), window_hours: 72, oneliners: vec![OnelinerEntry { case_id: case_id.into(), - oneliner: oneliner.map(str::to_owned), + oneliner: state, created_at: "2026-04-18T09:30:00Z".into(), last_recording_at: Some("2026-04-18T09:45:00Z".into()), updated_at: Some("2026-04-18T09:45:00Z".into()), diff --git a/doctate-client-core/src/snapshot_cache.rs b/doctate-client-core/src/snapshot_cache.rs index 58319f3..1858211 100644 --- a/doctate-client-core/src/snapshot_cache.rs +++ b/doctate-client-core/src/snapshot_cache.rs @@ -92,7 +92,7 @@ fn tmp_extension(path: &Path) -> String { #[cfg(test)] mod tests { use super::*; - use doctate_common::oneliners::OnelinerEntry; + use doctate_common::oneliners::{OnelinerEntry, OnelinerState}; use tempfile::TempDir; fn sample() -> CachedSnapshot { @@ -104,7 +104,10 @@ mod tests { window_hours: 72, oneliners: vec![OnelinerEntry { case_id: "550e8400-e29b-41d4-a716-446655440000".into(), - oneliner: Some("Kniegelenk".into()), + oneliner: Some(OnelinerState::Ready { + text: "Kniegelenk".into(), + generated_at: "2026-04-18T10:30:00Z".into(), + }), created_at: "2026-04-18T10:00:00Z".into(), last_recording_at: Some("2026-04-18T10:30:00Z".into()), updated_at: Some("2026-04-18T10:30:00Z".into()), @@ -123,10 +126,11 @@ mod tests { let loaded = cache.load().await.expect("cache present"); assert_eq!(loaded.etag, s.etag); assert_eq!(loaded.response.oneliners.len(), 1); - assert_eq!( - loaded.response.oneliners[0].oneliner.as_deref(), - Some("Kniegelenk") - ); + let text = match &loaded.response.oneliners[0].oneliner { + Some(OnelinerState::Ready { text, .. }) => Some(text.as_str()), + _ => None, + }; + assert_eq!(text, Some("Kniegelenk")); } #[tokio::test] diff --git a/doctate-common/src/oneliners.rs b/doctate-common/src/oneliners.rs index 067f339..ad4005d 100644 --- a/doctate-common/src/oneliners.rs +++ b/doctate-common/src/oneliners.rs @@ -9,6 +9,40 @@ use serde::{Deserialize, Serialize}; /// HTTP path of the oneliners endpoint. pub const ONELINERS_PATH: &str = "/api/oneliners"; +/// Filename used by the server to persist [`OnelinerState`] per case +/// (inside the case directory). Shared so both writer and readers +/// agree on the exact path. +pub const ONELINER_FILENAME: &str = "oneliner.json"; + +/// Outcome of the LLM oneliner generation for a single case. +/// +/// Three disjoint outcomes used to collapse onto the same filesystem +/// representation (missing file). Making them first-class lets the UI +/// distinguish "LLM said nothing medical" (valid empty result) from +/// "LLM call errored" (transient failure worth retrying) from "file +/// simply isn't there yet" (no state persisted). +/// +/// Serialized as internally-tagged JSON: `{"kind":"ready","text":...}`. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "kind", rename_all = "lowercase")] +pub enum OnelinerState { + /// LLM produced a usable one-sentence summary. + Ready { + text: String, + /// RFC3339 UTC timestamp of the LLM call that produced this + /// text. Diagnostic only — the file mtime drives ETag/sort. + generated_at: String, + }, + /// LLM deliberately returned an empty answer — the transcript + /// contained no medical content. This is a valid outcome, not a + /// failure, and recovery must not retry it. + Empty { generated_at: String }, + /// LLM call failed (timeout, HTTP, parse, …). Details stay in the + /// server logs on purpose; the UI surfaces only "error", and + /// startup recovery retries this variant. + Error { generated_at: String }, +} + /// Full response body of `GET /api/oneliners`. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct OnelinersResponse { @@ -29,14 +63,14 @@ pub struct OnelinersResponse { /// including addenda). This is the **primary sort key** for clients: /// "when did the doctor last work on this case?". Absent only if the /// case has no recordings at all (shouldn't happen in normal flow). -/// - `updated_at` — `oneliner.txt` mtime, i.e. when the oneliner text -/// was last regenerated. Diagnostic only — not for sort or display. -/// - `oneliner` — the text itself; `None` while transcription / LLM -/// generation is still pending. +/// - `updated_at` — `oneliner.json` mtime, i.e. when the oneliner state +/// was last written. Diagnostic only — not for sort or display. +/// - `oneliner` — the persisted state; `None` while no state file has +/// been written yet for this case (fresh case, nothing transcribed). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct OnelinerEntry { pub case_id: String, - pub oneliner: Option, + pub oneliner: Option, pub created_at: String, pub last_recording_at: Option, pub updated_at: Option, diff --git a/server/src/paths.rs b/server/src/paths.rs index c54e589..d056334 100644 --- a/server/src/paths.rs +++ b/server/src/paths.rs @@ -1,6 +1,8 @@ use std::path::{Path, PathBuf}; +use doctate_common::oneliners::{ONELINER_FILENAME, OnelinerState}; use serde::{Deserialize, Serialize}; +use time::OffsetDateTime; use uuid::Uuid; /// Absolute on-disk location of a case directory. @@ -46,3 +48,36 @@ pub async fn write_delete_marker(case_dir: &Path, marker: &DeleteMarker) -> std: .map_err(|e| std::io::Error::other(format!("serialize delete marker: {e}")))?; tokio::fs::write(case_dir.join(DELETE_MARKER), bytes).await } + +/// Read the persisted oneliner state for a case. +/// +/// Returns `(Some(state), Some(mtime))` when `oneliner.json` exists and +/// parses. Missing file → `(None, None)`. A file that exists but fails +/// to parse is treated as missing and logged — the next transcription +/// job will overwrite it with a well-formed state. +pub async fn read_oneliner_state( + case_dir: &Path, +) -> (Option, Option) { + let path = case_dir.join(ONELINER_FILENAME); + let bytes = match tokio::fs::read(&path).await { + Ok(b) => b, + Err(_) => return (None, None), + }; + let state: OnelinerState = match serde_json::from_slice(&bytes) { + Ok(s) => s, + Err(e) => { + tracing::warn!( + path = %path.display(), + error = %e, + "malformed oneliner.json; treating as missing" + ); + return (None, None); + } + }; + let mtime = tokio::fs::metadata(&path) + .await + .ok() + .and_then(|m| m.modified().ok()) + .map(OffsetDateTime::from); + (Some(state), mtime) +} diff --git a/server/src/routes/case_actions.rs b/server/src/routes/case_actions.rs index 77fa4e4..01c0390 100644 --- a/server/src/routes/case_actions.rs +++ b/server/src/routes/case_actions.rs @@ -10,6 +10,7 @@ use time::format_description::well_known::Rfc3339; use tokio::io::AsyncWriteExt; use tracing::{info, warn}; +use doctate_common::oneliners::ONELINER_FILENAME; use doctate_common::timestamp::{filename_stem_to_recorded_at, now_rfc3339}; use crate::analyze::{ @@ -252,7 +253,7 @@ pub(crate) async fn read_document(case_dir: &Path) -> Option { /// `*.m4a.failed` back to `*.m4a` so the transcribe self-heal picks it /// up again. Idempotent: missing files are no-ops. pub(crate) async fn reset_case_artefacts(case_dir: &Path) -> std::io::Result<()> { - for name in [DOCUMENT_FILE, ANALYSIS_INPUT_FILE, "oneliner.txt"] { + for name in [DOCUMENT_FILE, ANALYSIS_INPUT_FILE, ONELINER_FILENAME] { match tokio::fs::remove_file(case_dir.join(name)).await { Ok(_) => {} Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} diff --git a/server/src/routes/oneliners.rs b/server/src/routes/oneliners.rs index 6a2e587..1979ede 100644 --- a/server/src/routes/oneliners.rs +++ b/server/src/routes/oneliners.rs @@ -2,8 +2,9 @@ //! //! Time window is caller-controlled via `?hours=N` (default 16, clamped //! to [1, MAX_HOURS]). Each entry carries the case-id, the oneliner -//! text (or null if not yet generated), and UTC timestamps for when the -//! case started and when its oneliner was last written. +//! state (or null if no state file has been written yet), and UTC +//! timestamps for when the case started and when its oneliner was last +//! written. //! //! Conditional-GET: the response carries a weak ETag //! `W/"-"`. The fingerprint is a deterministic @@ -32,6 +33,7 @@ use tracing::warn; use crate::auth::AuthenticatedUser; use crate::error::AppError; +use crate::paths; const DEFAULT_HOURS: u32 = 16; const MAX_HOURS: u32 = 168; @@ -154,7 +156,7 @@ async fn scan_cases(user_data_dir: &Path, since: OffsetDateTime) -> ScanResult { continue; } - let (oneliner_text, updated_at) = read_oneliner(&case_dir).await; + let (oneliner_state, updated_at) = paths::read_oneliner_state(&case_dir).await; let Ok(created_at_str) = created_at.format(&Rfc3339) else { warn!(case = %case_dir.display(), "format created_at failed"); continue; @@ -168,7 +170,7 @@ async fn scan_cases(user_data_dir: &Path, since: OffsetDateTime) -> ScanResult { entries.push(OnelinerEntry { case_id: case_id.to_owned(), - oneliner: oneliner_text, + oneliner: oneliner_state, created_at: created_at_str, last_recording_at: last_recording_at_str, updated_at: updated_at_str, @@ -274,23 +276,3 @@ async fn m4a_mtime_range(case_dir: &Path) -> Option<(SystemTime, SystemTime)> { _ => None, } } - -/// Read `oneliner.txt` (trimmed, non-empty) plus its mtime. Missing file -/// yields `(None, None)`. -async fn read_oneliner(case_dir: &Path) -> (Option, Option) { - let path = case_dir.join("oneliner.txt"); - let text = tokio::fs::read_to_string(&path) - .await - .ok() - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()); - if text.is_none() { - return (None, None); - } - let mtime = tokio::fs::metadata(&path) - .await - .ok() - .and_then(|m| m.modified().ok()) - .map(OffsetDateTime::from); - (text, mtime) -} diff --git a/server/src/routes/user_web.rs b/server/src/routes/user_web.rs index 82c7585..2182ab0 100644 --- a/server/src/routes/user_web.rs +++ b/server/src/routes/user_web.rs @@ -5,6 +5,7 @@ use std::sync::atomic::Ordering; use askama::Template; use axum::extract::State; use axum::response::Html; +use doctate_common::oneliners::OnelinerState; use tracing::{info, warn}; use time::{Date, OffsetDateTime, format_description::well_known::Rfc3339}; @@ -18,10 +19,34 @@ use crate::case_id::{CaseId, CaseIdPath}; use crate::config::Config; use crate::error::AppError; use crate::events::EventSender; +use crate::paths; use crate::routes::case_actions::read_document; use crate::routes::web::{RecordingView, scan_recordings}; use crate::transcribe::recovery as transcribe_recovery; +/// UI-layer oneliner status for the case list. Combines the persisted +/// [`OnelinerState`] with derived transit states that only make sense +/// in the list context (transcription still pending, or no state file +/// despite completed transcription). +/// +/// Kept local to the server — not part of the public API DTO, which +/// exposes the raw [`OnelinerState`] and lets each client decide how +/// to render transit states. +enum OnelinerDisplay { + Ready(String), + Empty, + Error, + /// No state file on disk + at least one non-failed recording is + /// still awaiting its transcript. The oneliner will appear once + /// transcription finishes. + Pending, + /// No state file on disk and no pending transcription. Rare in + /// practice — the worker writes a state at the end of every job — + /// but possible right after a server crash between transcript and + /// oneliner write. + Missing, +} + struct UserCaseView { case_id: String, most_recent: String, @@ -34,7 +59,7 @@ struct UserCaseView { /// render it in the doctor's actual timezone (which may differ from /// the server's, e.g. server on Bahamas, doctor in Germany). recorded_at_iso: String, - oneliner: Option, + oneliner: OnelinerDisplay, recordings_count: usize, analyzing: bool, has_document: bool, @@ -307,11 +332,7 @@ pub async fn handle_case_page( .map(|md| crate::analyze::render::md_to_html(&md)); let recordings = scan_recordings(&case_dir).await; - let oneliner = tokio::fs::read_to_string(case_dir.join("oneliner.txt")) - .await - .ok() - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()); + let oneliner = ready_text(&case_dir).await; let a_busy = pipeline.analyze_busy.0.load(Ordering::Acquire); let flags = compute_flags(&case_dir, &recordings, config.llm_configured(), a_busy).await; @@ -371,11 +392,7 @@ pub async fn handle_case_recordings( info!(slug = %user.slug, case_id = %case_id, "case recordings viewed"); let recordings = scan_recordings(&case_dir).await; - let oneliner = tokio::fs::read_to_string(case_dir.join("oneliner.txt")) - .await - .ok() - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()); + let oneliner = ready_text(&case_dir).await; let t_busy = pipeline.transcribe_busy.0.load(Ordering::Acquire); let case_id_short = case_id_str.chars().take(8).collect(); @@ -509,11 +526,14 @@ async fn compute_case_view( .filter(|r| !r.failed) .all(|r| r.transcript.is_some()); - let oneliner = tokio::fs::read_to_string(case_path.join("oneliner.txt")) - .await - .ok() - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()); + let (state, _) = paths::read_oneliner_state(case_path).await; + let oneliner = match state { + Some(OnelinerState::Ready { text, .. }) => OnelinerDisplay::Ready(text), + Some(OnelinerState::Empty { .. }) => OnelinerDisplay::Empty, + Some(OnelinerState::Error { .. }) => OnelinerDisplay::Error, + None if !all_transcribed => OnelinerDisplay::Pending, + None => OnelinerDisplay::Missing, + }; let has_document = any_document_exists(case_path).await; let analyzing = !has_document && worker_busy && any_analysis_input_exists(case_path).await; @@ -533,3 +553,14 @@ async fn compute_case_view( can_analyze, }) } + +/// Extract only the text from a `Ready` state; other states (Empty, +/// Error, missing file) collapse to `None`. Used by single-case pages +/// whose headings simply fall back to a generic placeholder — they do +/// not need the full five-state UI treatment. +async fn ready_text(case_dir: &Path) -> Option { + match paths::read_oneliner_state(case_dir).await.0 { + Some(OnelinerState::Ready { text, .. }) => Some(text), + _ => None, + } +} diff --git a/server/src/transcribe/recovery.rs b/server/src/transcribe/recovery.rs index bec7be5..b70c717 100644 --- a/server/src/transcribe/recovery.rs +++ b/server/src/transcribe/recovery.rs @@ -1,11 +1,13 @@ use std::path::{Path, PathBuf}; +use doctate_common::oneliners::OnelinerState; use tracing::{info, warn}; use super::{TranscribeJob, TranscribeSender}; use crate::config::Config; use crate::events::EventSender; use crate::gazetteer::Gazetteer; +use crate::paths; /// Walk `data_path/*/` and enqueue every recording pending transcription for /// every user. Intended for startup; the same primitive backs the per-user /// self-heal triggered by web handlers. @@ -83,10 +85,18 @@ pub async fn enqueue_pending_for_user( } /// Walk `data_path/*/*` and return every case directory that has at least -/// one non-empty `*.transcript.txt` but no `oneliner.txt`, paired with the -/// user-slug (parent directory name). Pure filesystem scan, no LLM calls — -/// separated from the regeneration wrapper so it can be unit-tested without -/// mocking Ollama. +/// one non-empty `*.transcript.txt` and whose persisted oneliner state +/// warrants (re-)generation, paired with the user-slug (parent directory +/// name). Pure filesystem scan, no LLM calls — separated from the +/// regeneration wrapper so it can be unit-tested without mocking Ollama. +/// +/// Retry policy by persisted state: +/// - missing file → retry (first generation, or crash before write) +/// - [`OnelinerState::Error`] → retry (transient LLM failure last time) +/// - [`OnelinerState::Ready`] or [`OnelinerState::Empty`] → do **not** +/// retry; `Empty` is a valid outcome and `Ready` is already correct. +/// A fresh transcript write will trigger regeneration through the +/// normal worker path. pub(crate) async fn cases_needing_oneliner(data_path: &Path) -> Vec<(PathBuf, String)> { let mut out: Vec<(PathBuf, String)> = Vec::new(); let Ok(mut users) = tokio::fs::read_dir(data_path).await else { @@ -105,10 +115,15 @@ pub(crate) async fn cases_needing_oneliner(data_path: &Path) -> Vec<(PathBuf, St }; while let Ok(Some(case_entry)) = cases.next_entry().await { let case_dir = case_entry.path(); - if !case_dir.is_dir() || crate::paths::is_deleted(&case_dir).await { + if !case_dir.is_dir() || paths::is_deleted(&case_dir).await { continue; } - if case_dir.join("oneliner.txt").exists() { + let (state, _) = paths::read_oneliner_state(&case_dir).await; + let needs_retry = match state { + None | Some(OnelinerState::Error { .. }) => true, + Some(OnelinerState::Ready { .. } | OnelinerState::Empty { .. }) => false, + }; + if !needs_retry { continue; } if has_non_empty_transcript(&case_dir).await { @@ -141,10 +156,11 @@ async fn has_non_empty_transcript(case_dir: &Path) -> bool { false } -/// Regenerate `oneliner.txt` for every case that has transcripts but no -/// oneliner (crash between transcript-write and oneliner-write). Called at -/// startup after `scan_and_enqueue`. Sequential on purpose — the oneliner -/// model is light, but spamming it in parallel is not worth it. +/// Regenerate `oneliner.json` for every case that has transcripts but no +/// state file (crash between transcript-write and oneliner-write) or a +/// previously-errored state. Called at startup after `scan_and_enqueue`. +/// Sequential on purpose — the oneliner model is light, but spamming it +/// in parallel is not worth it. pub async fn regenerate_missing_oneliners( data_path: &Path, client: &reqwest::Client, @@ -180,21 +196,72 @@ mod tests { assert_eq!(got, vec![(case, "user".to_owned())]); } + async fn write_state(case: &Path, state: &OnelinerState) { + let bytes = serde_json::to_vec(state).unwrap(); + tokio::fs::write(case.join("oneliner.json"), bytes) + .await + .unwrap(); + } + #[tokio::test] - async fn cases_needing_oneliner_skips_cases_with_oneliner() { + async fn cases_needing_oneliner_skips_ready_state() { let data = tempdir().unwrap(); let case = data.path().join("user").join("case1"); tokio::fs::create_dir_all(&case).await.unwrap(); tokio::fs::write(case.join("2026-04-16T10-00-00Z.transcript.txt"), "content") .await .unwrap(); - tokio::fs::write(case.join("oneliner.txt"), "Existing") - .await - .unwrap(); + write_state( + &case, + &OnelinerState::Ready { + text: "Existing".into(), + generated_at: "2026-04-16T10:01:00Z".into(), + }, + ) + .await; assert!(cases_needing_oneliner(data.path()).await.is_empty()); } + #[tokio::test] + async fn cases_needing_oneliner_skips_empty_state() { + let data = tempdir().unwrap(); + let case = data.path().join("user").join("case1"); + tokio::fs::create_dir_all(&case).await.unwrap(); + tokio::fs::write(case.join("2026-04-16T10-00-00Z.transcript.txt"), "content") + .await + .unwrap(); + write_state( + &case, + &OnelinerState::Empty { + generated_at: "2026-04-16T10:01:00Z".into(), + }, + ) + .await; + + assert!(cases_needing_oneliner(data.path()).await.is_empty()); + } + + #[tokio::test] + async fn cases_needing_oneliner_retries_error_state() { + let data = tempdir().unwrap(); + let case = data.path().join("user").join("case1"); + tokio::fs::create_dir_all(&case).await.unwrap(); + tokio::fs::write(case.join("2026-04-16T10-00-00Z.transcript.txt"), "content") + .await + .unwrap(); + write_state( + &case, + &OnelinerState::Error { + generated_at: "2026-04-16T10:01:00Z".into(), + }, + ) + .await; + + let got = cases_needing_oneliner(data.path()).await; + assert_eq!(got, vec![(case, "user".to_owned())]); + } + #[tokio::test] async fn cases_needing_oneliner_skips_cases_with_only_empty_transcripts() { let data = tempdir().unwrap(); diff --git a/server/src/transcribe/worker.rs b/server/src/transcribe/worker.rs index a68f941..c15ea57 100644 --- a/server/src/transcribe/worker.rs +++ b/server/src/transcribe/worker.rs @@ -2,6 +2,9 @@ use std::path::Path; use std::sync::Arc; use std::time::Duration; +use doctate_common::oneliners::{ONELINER_FILENAME, OnelinerState}; +use time::OffsetDateTime; +use time::format_description::well_known::Rfc3339; use tracing::{error, info, warn}; use super::{TranscribeReceiver, ffmpeg, ollama, whisper}; @@ -178,13 +181,14 @@ async fn write_duration_sidecar(audio_path: &Path, probe_path: &Path) { } } -/// Regenerate `case_dir/oneliner.txt` from **all** non-empty transcripts in -/// the case, joined chronologically. Called after every successful transcript -/// write so later recordings can correct earlier ones (mirror of the -/// analyze-LLM rule "later recordings override"). Overwrites any existing -/// oneliner on success; LLM errors are non-fatal and leave the previous -/// oneliner (if any) untouched. Silent case (no non-empty transcript) is a -/// no-op — next transcript write retries. +/// Regenerate `case_dir/oneliner.json` from **all** non-empty transcripts +/// in the case, joined chronologically. Called after every successful +/// transcript write so later recordings can correct earlier ones (mirror +/// of the analyze-LLM rule "later recordings override"). Every outcome — +/// generated text, deliberate empty ("no medical content"), or call error +/// — is persisted as an [`OnelinerState`] variant, overwriting any +/// previous state. Silent case (no non-empty transcript) is a no-op; the +/// next transcript write retries. pub(crate) async fn update_oneliner( case_dir: &Path, user_slug: &str, @@ -193,14 +197,16 @@ pub(crate) async fn update_oneliner( vocab: &Gazetteer, events_tx: &EventSender, ) { - let path = case_dir.join("oneliner.txt"); - let transcript = match all_transcripts_joined(case_dir).await { Some(t) => t, None => return, }; - match ollama::generate_oneliner( + let generated_at = OffsetDateTime::now_utc() + .format(&Rfc3339) + .unwrap_or_default(); + + let state = match ollama::generate_oneliner( client, &config.ollama_url, &config.ollama_model, @@ -211,34 +217,50 @@ pub(crate) async fn update_oneliner( .await { Ok(line) => { - let line = vocab.replace(&line); - if let Err(e) = tokio::fs::write(&path, &line).await { - error!(path = %path.display(), error = %e, "writing oneliner failed"); - } else { - info!( - user = %user_slug, - path = %path.display(), - chars = line.chars().count(), - "Oneliner updated" - ); - events::emit( - events_tx, - user_slug, - events::case_id_of(case_dir), - CaseEventKind::OnelinerUpdated, - ); - } + let text = vocab.replace(&line); + info!( + user = %user_slug, + case = %case_dir.display(), + chars = text.chars().count(), + "Oneliner ready" + ); + OnelinerState::Ready { text, generated_at } } Err(ollama::OllamaError::EmptyResponse) => { warn!( case = %case_dir.display(), - "oneliner skipped — model returned empty (no medical keyword in transcript)" + "oneliner empty — model returned nothing (no medical keyword in transcript)" ); + OnelinerState::Empty { generated_at } } Err(e) => { error!(case = %case_dir.display(), error = %e, "oneliner generation failed"); + OnelinerState::Error { generated_at } } + }; + + if let Err(e) = write_oneliner_state(case_dir, &state).await { + error!(case = %case_dir.display(), error = %e, "writing oneliner.json failed"); + return; } + events::emit( + events_tx, + user_slug, + events::case_id_of(case_dir), + CaseEventKind::OnelinerUpdated, + ); +} + +/// Write `state` atomically to `case_dir/oneliner.json` via +/// `write(tmp) + rename(tmp, final)`. Rename is atomic within a single +/// filesystem, so concurrent readers never see a half-serialized JSON +/// document. +async fn write_oneliner_state(case_dir: &Path, state: &OnelinerState) -> std::io::Result<()> { + let final_path = case_dir.join(ONELINER_FILENAME); + let tmp_path = case_dir.join(format!("{ONELINER_FILENAME}.tmp")); + let payload = serde_json::to_vec(state).expect("OnelinerState is infallibly serializable"); + tokio::fs::write(&tmp_path, &payload).await?; + tokio::fs::rename(&tmp_path, &final_path).await } /// Return true if `case_dir` contains at least one `.m4a` recording diff --git a/server/templates/my_cases.html b/server/templates/my_cases.html index 7d50b0d..7414b0b 100644 --- a/server/templates/my_cases.html +++ b/server/templates/my_cases.html @@ -11,6 +11,7 @@ section { margin: 1.5em 0; } section h2 { font-size: 1.1em; color: #555; border-bottom: 1px solid #ddd; padding-bottom: 0.2em; display: flex; justify-content: space-between; align-items: baseline; gap: 1em; } section h2 .count { font-weight: normal; font-size: 0.85em; color: #888; } .empty { color: #888; font-style: italic; } +.pending { color: #888; font-style: italic; } .case-list { list-style: none; padding: 0; margin: 0; } .case-row { display: grid; grid-template-columns: auto 1fr auto; gap: 0.6em 1em; align-items: center; padding: 0.7em 1em; border: 1px solid #ccc; border-radius: 4px; margin: 0.5em 0; } @@ -85,7 +86,7 @@ try {
-
{% match case.oneliner %}{% when Some with (t) %} — {{ t }}{% when None %} — kein Oneliner{% endmatch %}
+
{% match case.oneliner %}{% when OnelinerDisplay::Ready with (t) %} — {{ t }}{% when OnelinerDisplay::Empty %} — kein medizinischer Inhalt{% when OnelinerDisplay::Error %} — Oneliner-Fehler{% when OnelinerDisplay::Pending %} — wird transkribiert …{% when OnelinerDisplay::Missing %} — unbenannt{% endmatch %}
{{ case.recordings_count }} Aufnahmen{% if case.has_document %} — ausgewertet{% else %} — offen{% endif %}{% if case.analyzing %} wird analysiert{% endif %}
{% if is_admin %}
{{ case.case_id }}
{% endif %}
diff --git a/server/tests/analyze_test.rs b/server/tests/analyze_test.rs index fbc2bba..514f552 100644 --- a/server/tests/analyze_test.rs +++ b/server/tests/analyze_test.rs @@ -837,7 +837,11 @@ async fn reset_case_clears_derived_and_unfails_audio() { // A failed recording: raw audio written, but filename carries `.failed`. std::fs::write(dir.join("2026-04-15T09-05-00Z.m4a.failed"), b"audio-bytes").unwrap(); // Derived artefacts the reset must wipe. - std::fs::write(dir.join("oneliner.txt"), "Knie re.").unwrap(); + std::fs::write( + dir.join("oneliner.json"), + br#"{"kind":"ready","text":"Knie re.","generated_at":"2026-04-18T10:00:00Z"}"#, + ) + .unwrap(); std::fs::write(dir.join("document.md"), "# Doc\n").unwrap(); std::fs::write(dir.join("analysis_input.json"), "{}").unwrap(); @@ -854,7 +858,7 @@ async fn reset_case_clears_derived_and_unfails_audio() { assert!(!dir.join("2026-04-15T09-05-00Z.m4a.failed").exists()); // Derived artefacts gone. assert!(!dir.join("2026-04-15T09-00-00Z.transcript.txt").exists()); - assert!(!dir.join("oneliner.txt").exists()); + assert!(!dir.join("oneliner.json").exists()); assert!(!dir.join("document.md").exists()); assert!(!dir.join("analysis_input.json").exists()); } diff --git a/server/tests/case_page_test.rs b/server/tests/case_page_test.rs index 0b04659..343ce19 100644 --- a/server/tests/case_page_test.rs +++ b/server/tests/case_page_test.rs @@ -4,6 +4,7 @@ use std::sync::Arc; use axum::body::Body; use axum::http::{Request, StatusCode, header}; +use doctate_common::oneliners::OnelinerState; use doctate_server::config::{Config, User}; use tower::util::ServiceExt; @@ -32,7 +33,17 @@ fn make_admin(slug: &str) -> User { } fn seed_oneliner(case_dir: &Path, text: &str) { - std::fs::write(case_dir.join("oneliner.txt"), text).unwrap(); + let state = OnelinerState::Ready { + text: text.to_owned(), + generated_at: "2026-04-18T10:00:00Z".into(), + }; + let bytes = serde_json::to_vec(&state).unwrap(); + std::fs::write(case_dir.join("oneliner.json"), bytes).unwrap(); +} + +fn seed_oneliner_state(case_dir: &Path, state: &OnelinerState) { + let bytes = serde_json::to_vec(state).unwrap(); + std::fs::write(case_dir.join("oneliner.json"), bytes).unwrap(); } fn config_with_llm_users(data_path: PathBuf, llm_url: String, users: Vec) -> Arc { @@ -326,6 +337,34 @@ async fn case_page_uses_oneliner_as_h1_when_present() { ); } +#[tokio::test] +async fn case_page_falls_back_to_generic_title_for_empty_state() { + // An `Empty` state (LLM deliberately returned no medical content) is + // not a text; the H1 must fall back to the generic "Fall" label, not + // leak the kind string or crash the template. + let config = config_with_llm(unique_tmp("cp-empty")); + let case_id = "22222222-2222-2222-2222-222222222222"; + let case_dir = seed_case(&config.data_path, "dr_a", case_id); + seed_oneliner_state( + &case_dir, + &OnelinerState::Empty { + generated_at: "2026-04-18T10:00:00Z".into(), + }, + ); + + let app = doctate_server::create_router(config); + let cookie = login(app.clone(), "dr_a").await; + + let resp = app.oneshot(get_page(case_id, &cookie, "")).await.unwrap(); + let body = body_string(resp).await; + + assert!(body.contains("Fall"), "expected generic 'Fall' fallback"); + assert!( + !body.contains("\"kind\""), + "must not leak JSON kind discriminator into HTML" + ); +} + #[tokio::test] async fn case_page_admin_sees_admin_view_toggle() { let data_path = unique_tmp("cp-toggle"); diff --git a/server/tests/oneliners_api_test.rs b/server/tests/oneliners_api_test.rs index 88ff2d8..8ed3d51 100644 --- a/server/tests/oneliners_api_test.rs +++ b/server/tests/oneliners_api_test.rs @@ -8,6 +8,7 @@ use axum::http::{Request, StatusCode}; use filetime::FileTime; use tower::util::ServiceExt; +use doctate_common::oneliners::OnelinerState; use doctate_server::config::{Config, User}; const TEST_KEY: &str = "test-key-oneliners"; @@ -50,13 +51,29 @@ async fn seed_case( filetime::set_file_mtime(&m4a, FileTime::from_system_time(target_mtime)).unwrap(); if let Some(text) = oneliner_text { - tokio::fs::write(case_dir.join("oneliner.txt"), text) - .await - .unwrap(); + write_ready_state(&case_dir, text).await; } case_dir } +async fn write_ready_state(case_dir: &Path, text: &str) { + let state = OnelinerState::Ready { + text: text.to_owned(), + generated_at: "2026-04-18T10:00:00Z".into(), + }; + let bytes = serde_json::to_vec(&state).unwrap(); + tokio::fs::write(case_dir.join("oneliner.json"), bytes) + .await + .unwrap(); +} + +async fn write_state(case_dir: &Path, state: &OnelinerState) { + let bytes = serde_json::to_vec(state).unwrap(); + tokio::fs::write(case_dir.join("oneliner.json"), bytes) + .await + .unwrap(); +} + async fn mark_deleted(case_dir: &Path) { tokio::fs::write(case_dir.join(".deleted"), "{}") .await @@ -155,7 +172,8 @@ async fn recent_case_with_oneliner_shows_up() { let arr = body["oneliners"].as_array().unwrap(); assert_eq!(arr.len(), 1); assert_eq!(arr[0]["case_id"], case_id); - assert_eq!(arr[0]["oneliner"], "Kniegelenk re., V.a. Meniskus"); + assert_eq!(arr[0]["oneliner"]["kind"], "ready"); + assert_eq!(arr[0]["oneliner"]["text"], "Kniegelenk re., V.a. Meniskus"); assert!(arr[0]["created_at"].is_string()); assert!(arr[0]["updated_at"].is_string()); @@ -365,8 +383,8 @@ async fn multiple_cases_sorted_newest_first() { let body = body_json(app.oneshot(get("/api/oneliners")).await.unwrap()).await; let arr = body["oneliners"].as_array().unwrap(); assert_eq!(arr.len(), 2); - assert_eq!(arr[0]["oneliner"], "newer"); - assert_eq!(arr[1]["oneliner"], "older"); + assert_eq!(arr[0]["oneliner"]["text"], "newer"); + assert_eq!(arr[1]["oneliner"]["text"], "older"); let _ = std::fs::remove_dir_all(&dp); } @@ -398,9 +416,7 @@ async fn sorts_by_last_recording_not_created() { FileTime::from_system_time(SystemTime::now() - Duration::from_secs(60)), ) .unwrap(); - tokio::fs::write(dir_a.join("oneliner.txt"), "A (fresh addendum)") - .await - .unwrap(); + write_ready_state(&dir_a, "A (fresh addendum)").await; // Case B: single recording 10h ago — created_at newer than A's, but // last_recording_at older than A's fresh addendum. @@ -421,3 +437,58 @@ async fn sorts_by_last_recording_not_created() { let _ = std::fs::remove_dir_all(&dp); } + +#[tokio::test] +async fn empty_state_serializes_as_kind_empty() { + let (config, dp) = test_config(); + let case_dir = seed_case( + &dp, + "550e8400-e29b-41d4-a716-000000000030", + Duration::from_secs(60), + None, + ) + .await; + write_state( + &case_dir, + &OnelinerState::Empty { + generated_at: "2026-04-18T10:00:00Z".into(), + }, + ) + .await; + let app = doctate_server::create_router(config); + + let body = body_json(app.oneshot(get("/api/oneliners")).await.unwrap()).await; + let arr = body["oneliners"].as_array().unwrap(); + assert_eq!(arr.len(), 1); + assert_eq!(arr[0]["oneliner"]["kind"], "empty"); + assert!(arr[0]["oneliner"].get("text").is_none()); + + let _ = std::fs::remove_dir_all(&dp); +} + +#[tokio::test] +async fn error_state_serializes_as_kind_error() { + let (config, dp) = test_config(); + let case_dir = seed_case( + &dp, + "550e8400-e29b-41d4-a716-000000000031", + Duration::from_secs(60), + None, + ) + .await; + write_state( + &case_dir, + &OnelinerState::Error { + generated_at: "2026-04-18T10:00:00Z".into(), + }, + ) + .await; + let app = doctate_server::create_router(config); + + let body = body_json(app.oneshot(get("/api/oneliners")).await.unwrap()).await; + let arr = body["oneliners"].as_array().unwrap(); + assert_eq!(arr.len(), 1); + assert_eq!(arr[0]["oneliner"]["kind"], "error"); + + let _ = std::fs::remove_dir_all(&dp); +}