Refactor document handling to JSON envelope
The `document.md` file has been replaced by `document.json`. This new format acts as a JSON envelope, containing the original markdown content along with metadata such as `covered_mtime`, `written_at`, and `backend_id`. This change addresses a bug where new recordings arriving during an LLM analysis call could be ignored. The `covered_mtime` field in the new JSON envelope ensures that the document's metadata accurately reflects the state of recordings at the time of analysis, preventing incorrect "analysis current" states. Additionally, the `analysis_input.json` file is now removed upon successful analysis, and a failure marker is used to indicate analysis failures. This ensures that a case is re-analyzed if necessary. The `analysis_idle_threshold_secs` configuration option has been introduced to control the delay before a case in the `Required` state is automatically promoted to `Idle` and enqueued for LLM analysis.
This commit is contained in:
@@ -529,6 +529,50 @@ mod tests {
|
||||
assert_eq!(evaluate_case(dir.path()).await, AutoDecision::Enqueue);
|
||||
}
|
||||
|
||||
/// Burst race: when a recording arrives DURING the LLM call for an
|
||||
/// earlier recording, the worker writes a `document.md` whose mtime
|
||||
/// is later than the new recording's mtime — even though the
|
||||
/// document content cannot include that recording (its
|
||||
/// `analysis_input.json` snapshot was frozen before the recording
|
||||
/// existed). The current `doc_mtime >= latest_mtime` check would
|
||||
/// then incorrectly report "analysis current" and the new recording
|
||||
/// would never make it into a document.
|
||||
///
|
||||
/// This test pins the desired behaviour: `evaluate_case` must return
|
||||
/// `Enqueue` so the late recording is folded into a fresh document.
|
||||
#[tokio::test]
|
||||
async fn recording_arrived_during_llm_call_re_enqueues() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
|
||||
let base = SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000);
|
||||
|
||||
// A: dictated and transcribed before the LLM call started.
|
||||
let a_m4a = dir.path().join("2026-01-01T10-00-00Z.m4a");
|
||||
touch(&a_m4a).await;
|
||||
seed_meta_with(dir.path(), "2026-01-01T10-00-00Z", "alpha").await;
|
||||
set_mtime(&a_m4a, base);
|
||||
|
||||
// B: arrived 5s later, while A's LLM call was already in flight.
|
||||
let b_m4a = dir.path().join("2026-01-01T10-00-05Z.m4a");
|
||||
touch(&b_m4a).await;
|
||||
seed_meta_with(dir.path(), "2026-01-01T10-00-05Z", "bravo").await;
|
||||
set_mtime(&b_m4a, base + Duration::from_secs(5));
|
||||
|
||||
// document.md: written when the LLM responded for A's snapshot.
|
||||
// Its mtime is newer than both recordings — but its content only
|
||||
// covers A. analysis_input.json has already been removed by the
|
||||
// worker on success, and there is no failure marker.
|
||||
let doc = dir.path().join(DOCUMENT_FILE);
|
||||
touch(&doc).await;
|
||||
set_mtime(&doc, base + Duration::from_secs(30));
|
||||
|
||||
assert_eq!(
|
||||
evaluate_case(dir.path()).await,
|
||||
AutoDecision::Enqueue,
|
||||
"B was added during A's LLM call; the document cannot include it, so a re-analysis must be enqueued"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn analysis_input_present_skips() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
|
||||
@@ -12,10 +12,27 @@ pub mod render;
|
||||
pub mod worker;
|
||||
|
||||
/// Single-document-per-case filenames. A re-analysis overwrites the
|
||||
/// document in place (handler deletes it first; worker writes the fresh one).
|
||||
pub const DOCUMENT_FILE: &str = "document.md";
|
||||
/// document in place; the file is a typed JSON envelope so the
|
||||
/// `covered_mtime` snapshot stays glued to the markdown body it was
|
||||
/// produced from.
|
||||
pub const DOCUMENT_FILE: &str = "document.json";
|
||||
pub const ANALYSIS_INPUT_FILE: &str = "analysis_input.json";
|
||||
|
||||
/// Persisted analysis output for a case. The `covered_mtime` field is
|
||||
/// the snapshot mtime of the recordings the LLM saw — *not* the
|
||||
/// wallclock time at which the file was written. Comparing it to the
|
||||
/// current `latest_mtime` on disk tells `evaluate_state` whether the
|
||||
/// stored markdown still describes the case faithfully or whether a
|
||||
/// new recording arrived after the snapshot was frozen and a fresh
|
||||
/// analysis is required.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Document {
|
||||
pub covered_mtime: String,
|
||||
pub written_at: String,
|
||||
pub backend_id: String,
|
||||
pub content_md: String,
|
||||
}
|
||||
|
||||
/// A single request to analyze a case. Payload is tiny (a path), so the
|
||||
/// channel is unbounded — persistence lives in `analysis_input.json` on
|
||||
/// disk, not in the queue. Recovery re-enqueues any inputs the server may
|
||||
|
||||
@@ -82,10 +82,25 @@ async fn input_already_failed(case_dir: &Path) -> bool {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::analyze::auto_trigger::{FAILURE_MARKER_FILE, FailureMarker};
|
||||
use crate::analyze::{RecordingInput, channel as analyze_channel};
|
||||
use crate::analyze::{Document, RecordingInput, channel as analyze_channel};
|
||||
use tempfile::TempDir;
|
||||
use tokio::fs;
|
||||
|
||||
async fn write_document(case_dir: &Path, content: &str) {
|
||||
let doc = Document {
|
||||
covered_mtime: "1970-01-01T00:00:00Z".into(),
|
||||
written_at: "1970-01-01T00:00:00Z".into(),
|
||||
backend_id: "test".into(),
|
||||
content_md: content.into(),
|
||||
};
|
||||
fs::write(
|
||||
case_dir.join(DOCUMENT_FILE),
|
||||
serde_json::to_vec(&doc).unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn make_input(mtime: &str) -> AnalysisInput {
|
||||
AnalysisInput {
|
||||
last_recording_mtime: mtime.to_owned(),
|
||||
@@ -158,9 +173,7 @@ mod tests {
|
||||
.join("22222222-2222-2222-2222-222222222222");
|
||||
fs::create_dir_all(&case).await.unwrap();
|
||||
write_input(&case, &make_input("2026-05-03T10:00:00Z")).await;
|
||||
fs::write(case.join(DOCUMENT_FILE), b"already analysed")
|
||||
.await
|
||||
.unwrap();
|
||||
write_document(&case, "already analysed").await;
|
||||
|
||||
let (tx, rx) = analyze_channel();
|
||||
let sent = enqueue_pending_for_user(user_root.path(), &tx).await;
|
||||
|
||||
@@ -64,8 +64,16 @@ async fn process(
|
||||
|
||||
// Silent-only case: no usable transcripts. Skip LLM, write a stub.
|
||||
if input.recordings.is_empty() {
|
||||
let stub = b"_Keine verwertbaren Aufnahmen (alle Aufnahmen still)._\n";
|
||||
if let Err(e) = write_atomic(&document_path, stub).await {
|
||||
let stub_md = "_Keine verwertbaren Aufnahmen (alle Aufnahmen still)._\n";
|
||||
let doc = super::Document {
|
||||
covered_mtime: input.last_recording_mtime.clone(),
|
||||
written_at: doctate_common::timestamp::now_rfc3339(),
|
||||
backend_id: String::new(),
|
||||
content_md: stub_md.to_owned(),
|
||||
};
|
||||
let stub_bytes =
|
||||
serde_json::to_vec_pretty(&doc).expect("Document with String fields always serializes");
|
||||
if let Err(e) = write_atomic(&document_path, &stub_bytes).await {
|
||||
error!(path = %document_path.display(), error = %e, "writing stub document failed");
|
||||
let _ = auto_trigger::failure_marker(
|
||||
case_dir,
|
||||
@@ -150,10 +158,10 @@ async fn process(
|
||||
|
||||
// Silent-deletion guard: schema-aware backends can return formally valid
|
||||
// but empty envelopes (`{"document": ""}`) when the prompt is incompatible
|
||||
// with the format constraint. Without this guard the worker would write
|
||||
// an empty document.md, drop the failure marker, and the user would see
|
||||
// a blank note with no diagnostic. Observed on Llama 3.1 405B FP8
|
||||
// (test T4, 2026-05-03) and previously on Mistral 24B.
|
||||
// with the format constraint. Without this guard the worker would persist
|
||||
// a Document with empty `content_md`, drop the failure marker, and the
|
||||
// user would see a blank note with no diagnostic. Observed on Llama 3.1
|
||||
// 405B FP8 (test T4, 2026-05-03) and previously on Mistral 24B.
|
||||
if document.trim().is_empty() {
|
||||
error!(
|
||||
case = %case_dir.display(),
|
||||
@@ -173,7 +181,15 @@ async fn process(
|
||||
return;
|
||||
}
|
||||
|
||||
if let Err(e) = write_atomic(&document_path, document.as_bytes()).await {
|
||||
let doc = super::Document {
|
||||
covered_mtime: input.last_recording_mtime.clone(),
|
||||
written_at: doctate_common::timestamp::now_rfc3339(),
|
||||
backend_id: backend.id.clone(),
|
||||
content_md: document.clone(),
|
||||
};
|
||||
let doc_bytes =
|
||||
serde_json::to_vec_pretty(&doc).expect("Document with String fields always serializes");
|
||||
if let Err(e) = write_atomic(&document_path, &doc_bytes).await {
|
||||
error!(path = %document_path.display(), error = %e, "writing document failed");
|
||||
let _ = auto_trigger::failure_marker(
|
||||
case_dir,
|
||||
|
||||
@@ -142,6 +142,12 @@ pub struct Config {
|
||||
/// env var at startup; default `Whisper`. Worker uses this to dispatch
|
||||
/// the transcription call to the matching endpoint.
|
||||
pub asr_backend: AsrBackend,
|
||||
/// Seconds of inactivity (no new recording mtime) after which a case
|
||||
/// in the `Required` analysis state is auto-promoted to `Idle` and
|
||||
/// enqueued for LLM analysis. Default `900` (15 min). Set lower in
|
||||
/// tests via `Config::test_default()` (`= 0`, eager) or via the
|
||||
/// `ANALYSIS_IDLE_THRESHOLD_SECS` env var.
|
||||
pub analysis_idle_threshold_secs: u64,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
@@ -168,6 +174,7 @@ impl Config {
|
||||
asr_backend: optional_env("ASR_BACKEND", "whisper")
|
||||
.parse::<AsrBackend>()
|
||||
.unwrap_or_else(|e| panic!("Invalid value for ASR_BACKEND: {e}")),
|
||||
analysis_idle_threshold_secs: optional_env_parsed("ANALYSIS_IDLE_THRESHOLD_SECS", 900),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,6 +206,7 @@ impl Config {
|
||||
session_timeout_hours: 8,
|
||||
cookie_secure: false,
|
||||
asr_backend: AsrBackend::Whisper,
|
||||
analysis_idle_threshold_secs: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -377,11 +377,20 @@ pub(crate) async fn write_input_create_new(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read `case_dir/document.md`. Returns `None` if no document exists.
|
||||
/// Read `case_dir/document.json` and return the embedded markdown
|
||||
/// body. Returns `None` if no document exists or the file is not valid
|
||||
/// JSON for the [`Document`] envelope. A parse error is logged so a
|
||||
/// corrupt file does not silently appear as "no document".
|
||||
pub(crate) async fn read_document(case_dir: &Path) -> Option<String> {
|
||||
tokio::fs::read_to_string(case_dir.join(DOCUMENT_FILE))
|
||||
.await
|
||||
.ok()
|
||||
let path = case_dir.join(DOCUMENT_FILE);
|
||||
let bytes = tokio::fs::read(&path).await.ok()?;
|
||||
match serde_json::from_slice::<crate::analyze::Document>(&bytes) {
|
||||
Ok(d) => Some(d.content_md),
|
||||
Err(e) => {
|
||||
tracing::warn!(path = %path.display(), error = %e, "document parse failed");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset a case to its raw audio: delete derived artefacts
|
||||
|
||||
Reference in New Issue
Block a user