9a00b592f0
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.
244 lines
9.1 KiB
Rust
244 lines
9.1 KiB
Rust
use std::path::{Path, PathBuf};
|
|
|
|
use doctate_common::TranscriptState;
|
|
use doctate_common::oneliners::{ONELINER_FILENAME, OnelinerState};
|
|
use serde::{Deserialize, Serialize};
|
|
use time::OffsetDateTime;
|
|
use tracing::info;
|
|
|
|
use crate::oneliner_locks::OnelinerLocks;
|
|
|
|
/// Absolute on-disk location of a case directory.
|
|
///
|
|
/// Single source of truth for the layout `<data_path>/<slug>/<case_id>/`.
|
|
/// Use everywhere instead of inlining `.join(slug).join(case_id)` so the
|
|
/// layout can evolve in one place.
|
|
pub fn case_dir(data_path: &Path, slug: &str, case_id: &str) -> PathBuf {
|
|
data_path.join(slug).join(case_id)
|
|
}
|
|
|
|
/// Filename of the close marker inside a case directory. A case with
|
|
/// this marker is "closed": hidden from the default listing, reversible
|
|
/// via a new upload or the explicit reopen action, and eligible for
|
|
/// lazy auto-purge after `auto_delete_days`.
|
|
pub const CLOSE_MARKER: &str = ".closed";
|
|
|
|
/// Close-marker payload. `closed_at` is an RFC3339 timestamp and the
|
|
/// authoritative input for purge eligibility (`now - closed_at > auto_delete_days`).
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct CloseMarker {
|
|
pub closed_at: String,
|
|
}
|
|
|
|
/// True iff the case directory contains a `.closed` marker file.
|
|
pub async fn is_closed(case_dir: &Path) -> bool {
|
|
tokio::fs::try_exists(case_dir.join(CLOSE_MARKER))
|
|
.await
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
/// Read and parse the `.closed` marker. Returns `None` if absent or
|
|
/// malformed (treated identically — a malformed marker still means
|
|
/// "closed" via `is_closed`, but cannot participate in timestamp-based
|
|
/// grouping or purge checks).
|
|
pub async fn read_close_marker(case_dir: &Path) -> Option<CloseMarker> {
|
|
let bytes = tokio::fs::read(case_dir.join(CLOSE_MARKER)).await.ok()?;
|
|
serde_json::from_slice(&bytes).ok()
|
|
}
|
|
|
|
/// Write the `.closed` marker as JSON. Overwrites any existing marker.
|
|
pub async fn write_close_marker(case_dir: &Path, marker: &CloseMarker) -> std::io::Result<()> {
|
|
let bytes = serde_json::to_vec(marker)
|
|
.map_err(|e| std::io::Error::other(format!("serialize close marker: {e}")))?;
|
|
tokio::fs::write(case_dir.join(CLOSE_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)
|
|
}
|
|
|
|
/// 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.
|
|
pub 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
|
|
}
|
|
|
|
/// Delete `case_dir/oneliner.json` unless it persists a manual override.
|
|
///
|
|
/// Acquires the per-case oneliner lock, reads the current state, and
|
|
/// removes the file only when the on-disk state is missing, malformed,
|
|
/// or one of the LLM-derived variants (`Ready`, `Empty`, `Error`).
|
|
/// `OnelinerState::Manual` is preserved untouched — once a clinician
|
|
/// has set the oneliner by hand, it must survive every server-driven
|
|
/// artefact wipe (recording delete, admin reset). The only legitimate
|
|
/// path that may remove a manual oneliner is the user-initiated
|
|
/// `purge-closed`, which removes the entire case directory and is
|
|
/// therefore not routed through this helper.
|
|
///
|
|
/// Returns `Ok(true)` if the file was deleted (or was already absent),
|
|
/// `Ok(false)` if a manual override was preserved.
|
|
pub async fn delete_oneliner_unless_manual(
|
|
case_dir: &Path,
|
|
locks: &OnelinerLocks,
|
|
) -> std::io::Result<bool> {
|
|
// Hold the per-case lock across read+decide+delete so a concurrent
|
|
// worker cannot flip Manual <-> Ready between our check and our
|
|
// remove. Worker write paths take the same lock around their own
|
|
// re-check window, so the two paths serialize cleanly.
|
|
let _guard = locks.lock_for(case_dir).await;
|
|
|
|
let (state, _) = read_oneliner_state(case_dir).await;
|
|
if matches!(state, Some(OnelinerState::Manual { .. })) {
|
|
info!(
|
|
case_dir = %case_dir.display(),
|
|
"manual oneliner preserved across artefact wipe"
|
|
);
|
|
return Ok(false);
|
|
}
|
|
|
|
match tokio::fs::remove_file(case_dir.join(ONELINER_FILENAME)).await {
|
|
Ok(_) => Ok(true),
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(true),
|
|
Err(e) => Err(e),
|
|
}
|
|
}
|
|
|
|
/// Load the `.transcript.txt` sidecar for a recording as a
|
|
/// [`TranscriptState`].
|
|
///
|
|
/// `audio_stem_path` is the non-`.failed` stem path of the recording
|
|
/// (e.g. `case_dir/2026-04-16T16-23-38Z.m4a`) — the sidecar sits next
|
|
/// to it with extension `.transcript.txt`.
|
|
///
|
|
/// This is the single reader for that sidecar. Every call-site that
|
|
/// used to do `tokio::fs::read_to_string(...).ok()` or
|
|
/// `with_extension("transcript.txt").exists()` should go through here
|
|
/// so the three-state semantics stay consistent across the codebase.
|
|
pub async fn read_transcript_state(audio_stem_path: &Path) -> TranscriptState {
|
|
let transcript_path = audio_stem_path.with_extension("transcript.txt");
|
|
let raw = tokio::fs::read_to_string(&transcript_path).await.ok();
|
|
TranscriptState::from_raw(raw.as_deref())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use tempfile::TempDir;
|
|
|
|
fn manual_state() -> OnelinerState {
|
|
OnelinerState::Manual {
|
|
text: "manuell-arzt".to_owned(),
|
|
set_at: "2026-04-26T10:00:00Z".to_owned(),
|
|
}
|
|
}
|
|
|
|
fn ready_state() -> OnelinerState {
|
|
OnelinerState::Ready {
|
|
text: "knee, left, pain".to_owned(),
|
|
generated_at: "2026-04-26T10:00:00Z".to_owned(),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn delete_oneliner_unless_manual_keeps_manual() {
|
|
let dir = TempDir::new().unwrap();
|
|
write_oneliner_state(dir.path(), &manual_state())
|
|
.await
|
|
.unwrap();
|
|
|
|
let locks = OnelinerLocks::new();
|
|
let removed = delete_oneliner_unless_manual(dir.path(), &locks)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert!(!removed, "Manual must be preserved");
|
|
let (state, _) = read_oneliner_state(dir.path()).await;
|
|
assert!(
|
|
matches!(state, Some(OnelinerState::Manual { ref text, .. }) if text == "manuell-arzt"),
|
|
"Manual state must remain identical on disk, got {state:?}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn delete_oneliner_unless_manual_removes_ready() {
|
|
let dir = TempDir::new().unwrap();
|
|
write_oneliner_state(dir.path(), &ready_state())
|
|
.await
|
|
.unwrap();
|
|
|
|
let locks = OnelinerLocks::new();
|
|
let removed = delete_oneliner_unless_manual(dir.path(), &locks)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert!(removed, "Ready must be deleted");
|
|
assert!(!dir.path().join(ONELINER_FILENAME).exists());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn delete_oneliner_unless_manual_handles_missing_file() {
|
|
let dir = TempDir::new().unwrap();
|
|
let locks = OnelinerLocks::new();
|
|
|
|
let removed = delete_oneliner_unless_manual(dir.path(), &locks)
|
|
.await
|
|
.expect("missing file must not be an error");
|
|
|
|
assert!(removed, "absent file is reported as deleted");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn delete_oneliner_unless_manual_handles_malformed_json() {
|
|
// Malformed JSON must not lock the case in place forever — the
|
|
// file is treated like a non-Manual state and removed so the
|
|
// pipeline can re-derive a fresh oneliner.
|
|
let dir = TempDir::new().unwrap();
|
|
tokio::fs::write(dir.path().join(ONELINER_FILENAME), b"{ not json")
|
|
.await
|
|
.unwrap();
|
|
|
|
let locks = OnelinerLocks::new();
|
|
let removed = delete_oneliner_unless_manual(dir.path(), &locks)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert!(removed, "malformed file must be cleared");
|
|
assert!(!dir.path().join(ONELINER_FILENAME).exists());
|
|
}
|
|
}
|