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.
This commit is contained in:
2026-04-20 12:37:48 +02:00
parent 224ee60363
commit d65720c671
15 changed files with 440 additions and 125 deletions
+35
View File
@@ -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<OnelinerState>, Option<OffsetDateTime>) {
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)
}
+2 -1
View File
@@ -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<String> {
/// `*.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 => {}
+6 -24
View File
@@ -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/"<fingerprint_hex>-<hours>"`. 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<String>, Option<OffsetDateTime>) {
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)
}
+47 -16
View File
@@ -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<String>,
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<String> {
match paths::read_oneliner_state(case_dir).await.0 {
Some(OnelinerState::Ready { text, .. }) => Some(text),
_ => None,
}
}
+81 -14
View File
@@ -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();
+50 -28
View File
@@ -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