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:
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user