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
+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