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:
2026-05-05 14:01:43 +02:00
parent c09a74cef3
commit 9e8db7518d
12 changed files with 180 additions and 38 deletions
+44
View File
@@ -529,6 +529,50 @@ mod tests {
assert_eq!(evaluate_case(dir.path()).await, AutoDecision::Enqueue); 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] #[tokio::test]
async fn analysis_input_present_skips() { async fn analysis_input_present_skips() {
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
+19 -2
View File
@@ -12,10 +12,27 @@ pub mod render;
pub mod worker; pub mod worker;
/// Single-document-per-case filenames. A re-analysis overwrites the /// Single-document-per-case filenames. A re-analysis overwrites the
/// document in place (handler deletes it first; worker writes the fresh one). /// document in place; the file is a typed JSON envelope so the
pub const DOCUMENT_FILE: &str = "document.md"; /// `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"; 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 /// A single request to analyze a case. Payload is tiny (a path), so the
/// channel is unbounded — persistence lives in `analysis_input.json` on /// channel is unbounded — persistence lives in `analysis_input.json` on
/// disk, not in the queue. Recovery re-enqueues any inputs the server may /// disk, not in the queue. Recovery re-enqueues any inputs the server may
+17 -4
View File
@@ -82,10 +82,25 @@ async fn input_already_failed(case_dir: &Path) -> bool {
mod tests { mod tests {
use super::*; use super::*;
use crate::analyze::auto_trigger::{FAILURE_MARKER_FILE, FailureMarker}; 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 tempfile::TempDir;
use tokio::fs; 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 { fn make_input(mtime: &str) -> AnalysisInput {
AnalysisInput { AnalysisInput {
last_recording_mtime: mtime.to_owned(), last_recording_mtime: mtime.to_owned(),
@@ -158,9 +173,7 @@ mod tests {
.join("22222222-2222-2222-2222-222222222222"); .join("22222222-2222-2222-2222-222222222222");
fs::create_dir_all(&case).await.unwrap(); fs::create_dir_all(&case).await.unwrap();
write_input(&case, &make_input("2026-05-03T10:00:00Z")).await; write_input(&case, &make_input("2026-05-03T10:00:00Z")).await;
fs::write(case.join(DOCUMENT_FILE), b"already analysed") write_document(&case, "already analysed").await;
.await
.unwrap();
let (tx, rx) = analyze_channel(); let (tx, rx) = analyze_channel();
let sent = enqueue_pending_for_user(user_root.path(), &tx).await; let sent = enqueue_pending_for_user(user_root.path(), &tx).await;
+23 -7
View File
@@ -64,8 +64,16 @@ async fn process(
// Silent-only case: no usable transcripts. Skip LLM, write a stub. // Silent-only case: no usable transcripts. Skip LLM, write a stub.
if input.recordings.is_empty() { if input.recordings.is_empty() {
let stub = b"_Keine verwertbaren Aufnahmen (alle Aufnahmen still)._\n"; let stub_md = "_Keine verwertbaren Aufnahmen (alle Aufnahmen still)._\n";
if let Err(e) = write_atomic(&document_path, stub).await { 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"); error!(path = %document_path.display(), error = %e, "writing stub document failed");
let _ = auto_trigger::failure_marker( let _ = auto_trigger::failure_marker(
case_dir, case_dir,
@@ -150,10 +158,10 @@ async fn process(
// Silent-deletion guard: schema-aware backends can return formally valid // Silent-deletion guard: schema-aware backends can return formally valid
// but empty envelopes (`{"document": ""}`) when the prompt is incompatible // but empty envelopes (`{"document": ""}`) when the prompt is incompatible
// with the format constraint. Without this guard the worker would write // with the format constraint. Without this guard the worker would persist
// an empty document.md, drop the failure marker, and the user would see // a Document with empty `content_md`, drop the failure marker, and the
// a blank note with no diagnostic. Observed on Llama 3.1 405B FP8 // user would see a blank note with no diagnostic. Observed on Llama 3.1
// (test T4, 2026-05-03) and previously on Mistral 24B. // 405B FP8 (test T4, 2026-05-03) and previously on Mistral 24B.
if document.trim().is_empty() { if document.trim().is_empty() {
error!( error!(
case = %case_dir.display(), case = %case_dir.display(),
@@ -173,7 +181,15 @@ async fn process(
return; 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"); error!(path = %document_path.display(), error = %e, "writing document failed");
let _ = auto_trigger::failure_marker( let _ = auto_trigger::failure_marker(
case_dir, case_dir,
+8
View File
@@ -142,6 +142,12 @@ pub struct Config {
/// env var at startup; default `Whisper`. Worker uses this to dispatch /// env var at startup; default `Whisper`. Worker uses this to dispatch
/// the transcription call to the matching endpoint. /// the transcription call to the matching endpoint.
pub asr_backend: AsrBackend, 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 { impl Config {
@@ -168,6 +174,7 @@ impl Config {
asr_backend: optional_env("ASR_BACKEND", "whisper") asr_backend: optional_env("ASR_BACKEND", "whisper")
.parse::<AsrBackend>() .parse::<AsrBackend>()
.unwrap_or_else(|e| panic!("Invalid value for ASR_BACKEND: {e}")), .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, session_timeout_hours: 8,
cookie_secure: false, cookie_secure: false,
asr_backend: AsrBackend::Whisper, asr_backend: AsrBackend::Whisper,
analysis_idle_threshold_secs: 0,
} }
} }
} }
+13 -4
View File
@@ -377,11 +377,20 @@ pub(crate) async fn write_input_create_new(
Ok(()) 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> { pub(crate) async fn read_document(case_dir: &Path) -> Option<String> {
tokio::fs::read_to_string(case_dir.join(DOCUMENT_FILE)) let path = case_dir.join(DOCUMENT_FILE);
.await let bytes = tokio::fs::read(&path).await.ok()?;
.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 /// Reset a case to its raw audio: delete derived artefacts
+8 -8
View File
@@ -16,7 +16,7 @@ use wiremock::{Mock, MockServer, ResponseTemplate};
use common::{ use common::{
ANALYSIS_INPUT_FILE, CLOSE_MARKER, DOCUMENT_FILE, ONELINER_FILENAME, TestConfig, ANALYSIS_INPUT_FILE, CLOSE_MARKER, DOCUMENT_FILE, ONELINER_FILENAME, TestConfig,
csrf_form_post, get_with_cookie, login_with_csrf, paths, seed_case, seed_recording, test_admin, csrf_form_post, get_with_cookie, login_with_csrf, paths, seed_case, seed_recording, test_admin,
test_user, unique_tmpdir, test_user, unique_tmpdir, write_document_for_test,
}; };
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
@@ -634,7 +634,7 @@ async fn recovery_skips_completed_analysis() {
let case_dir = tmp.join("dr_a/11111111-1111-1111-1111-111111111111"); let case_dir = tmp.join("dr_a/11111111-1111-1111-1111-111111111111");
std::fs::create_dir_all(&case_dir).unwrap(); std::fs::create_dir_all(&case_dir).unwrap();
std::fs::write(case_dir.join(ANALYSIS_INPUT_FILE), "{}").unwrap(); std::fs::write(case_dir.join(ANALYSIS_INPUT_FILE), "{}").unwrap();
std::fs::write(case_dir.join(DOCUMENT_FILE), "done").unwrap(); write_document_for_test(&case_dir, "done", "1970-01-01T00:00:00Z");
let (tx, mut rx) = analyze::channel(); let (tx, mut rx) = analyze::channel();
analyze::recovery::scan_and_enqueue(&tmp, &tx).await; analyze::recovery::scan_and_enqueue(&tmp, &tx).await;
@@ -654,7 +654,7 @@ async fn analyze_deletes_old_document_and_writes_fresh_input() {
let (cfg, settings) = cfg_llm("rn-5"); let (cfg, settings) = cfg_llm("rn-5");
let case_id = "11111111-1111-1111-1111-111111111111"; let case_id = "11111111-1111-1111-1111-111111111111";
let case_dir = seed_case(&cfg.data_path, "dr_a", case_id); let case_dir = seed_case(&cfg.data_path, "dr_a", case_id);
std::fs::write(case_dir.join(DOCUMENT_FILE), "altes Dokument").unwrap(); write_document_for_test(&case_dir, "altes Dokument", "1970-01-01T00:00:00Z");
seed_recording(&case_dir, "2026-04-15T10-00-00Z", Some("erste Aufnahme")); seed_recording(&case_dir, "2026-04-15T10-00-00Z", Some("erste Aufnahme"));
seed_recording(&case_dir, "2026-04-15T10-05-00Z", Some("zweite Aufnahme")); seed_recording(&case_dir, "2026-04-15T10-05-00Z", Some("zweite Aufnahme"));
@@ -1272,7 +1272,7 @@ async fn reset_case_clears_derived_and_unfails_audio() {
br#"{"kind":"ready","text":"Knie re.","generated_at":"2026-04-18T10:00:00Z"}"#, br#"{"kind":"ready","text":"Knie re.","generated_at":"2026-04-18T10:00:00Z"}"#,
) )
.unwrap(); .unwrap();
std::fs::write(dir.join(DOCUMENT_FILE), "# Doc\n").unwrap(); write_document_for_test(&dir, "# Doc\n", "1970-01-01T00:00:00Z");
std::fs::write(dir.join(ANALYSIS_INPUT_FILE), "{}").unwrap(); std::fs::write(dir.join(ANALYSIS_INPUT_FILE), "{}").unwrap();
let (app, store) = doctate_server::create_router_and_session_store_with_settings(cfg, settings); let (app, store) = doctate_server::create_router_and_session_store_with_settings(cfg, settings);
@@ -1311,7 +1311,7 @@ async fn reset_case_requires_admin() {
let case_id = "11111111-1111-1111-1111-111111111111"; let case_id = "11111111-1111-1111-1111-111111111111";
let dir = seed_case(&cfg.data_path, "dr_a", case_id); let dir = seed_case(&cfg.data_path, "dr_a", case_id);
seed_recording(&dir, "2026-04-15T09-00-00Z", Some("hallo")); seed_recording(&dir, "2026-04-15T09-00-00Z", Some("hallo"));
std::fs::write(dir.join(DOCUMENT_FILE), "# Doc\n").unwrap(); write_document_for_test(&dir, "# Doc\n", "1970-01-01T00:00:00Z");
let (app, store) = doctate_server::create_router_and_session_store_with_settings(cfg, settings); let (app, store) = doctate_server::create_router_and_session_store_with_settings(cfg, settings);
let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await; let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await;
@@ -1346,13 +1346,13 @@ async fn bulk_reset_processes_multiple_cases_admin_only() {
let dir_b = seed_case(&cfg.data_path, "dr_a", case_b); let dir_b = seed_case(&cfg.data_path, "dr_a", case_b);
seed_recording(&dir_a, "2026-04-15T10-00-00Z", Some("a")); seed_recording(&dir_a, "2026-04-15T10-00-00Z", Some("a"));
seed_recording(&dir_b, "2026-04-15T10-05-00Z", Some("b")); seed_recording(&dir_b, "2026-04-15T10-05-00Z", Some("b"));
std::fs::write(dir_a.join(DOCUMENT_FILE), "A").unwrap(); write_document_for_test(&dir_a, "A", "1970-01-01T00:00:00Z");
std::fs::write(dir_b.join(DOCUMENT_FILE), "B").unwrap(); write_document_for_test(&dir_b, "B", "1970-01-01T00:00:00Z");
// dr_b gets its own case to verify non-admin branch without clobbering dr_a. // dr_b gets its own case to verify non-admin branch without clobbering dr_a.
let case_c = "33333333-3333-3333-3333-333333333333"; let case_c = "33333333-3333-3333-3333-333333333333";
let dir_c = seed_case(&cfg.data_path, "dr_b", case_c); let dir_c = seed_case(&cfg.data_path, "dr_b", case_c);
seed_recording(&dir_c, "2026-04-15T11-00-00Z", Some("c")); seed_recording(&dir_c, "2026-04-15T11-00-00Z", Some("c"));
std::fs::write(dir_c.join(DOCUMENT_FILE), "C").unwrap(); write_document_for_test(&dir_c, "C", "1970-01-01T00:00:00Z");
let (app, store) = doctate_server::create_router_and_session_store_with_settings(cfg, settings); let (app, store) = doctate_server::create_router_and_session_store_with_settings(cfg, settings);
+6 -6
View File
@@ -6,8 +6,8 @@ use tower::util::ServiceExt;
use doctate_common::oneliners::OnelinerState; use doctate_common::oneliners::OnelinerState;
use common::{ use common::{
DOCUMENT_FILE, TestConfig, body_string, get_with_cookie, paths, seed_case, seed_oneliner_ready, TestConfig, body_string, get_with_cookie, paths, seed_case, seed_oneliner_ready,
seed_oneliner_state, seed_recording, test_admin, test_user, seed_oneliner_state, seed_recording, test_admin, test_user, write_document_for_test,
}; };
/// Password "s" is the shared test default; wrap `login` so call sites /// Password "s" is the shared test default; wrap `login` so call sites
@@ -29,7 +29,7 @@ async fn case_page_shows_document_when_present() {
.build(); .build();
let case_id = "11111111-1111-1111-1111-111111111111"; let case_id = "11111111-1111-1111-1111-111111111111";
let case_dir = seed_case(&cfg.data_path, "dr_a", case_id); let case_dir = seed_case(&cfg.data_path, "dr_a", case_id);
std::fs::write(case_dir.join(DOCUMENT_FILE), "der Inhalt").unwrap(); write_document_for_test(&case_dir, "der Inhalt", "1970-01-01T00:00:00Z");
let app = doctate_server::create_router(cfg); let app = doctate_server::create_router(cfg);
let cookie = login_dr_a(&app).await; let cookie = login_dr_a(&app).await;
@@ -57,7 +57,7 @@ async fn case_page_document_has_copy_button() {
.build(); .build();
let case_id = "11111111-1111-1111-1111-111111111111"; let case_id = "11111111-1111-1111-1111-111111111111";
let case_dir = seed_case(&cfg.data_path, "dr_a", case_id); let case_dir = seed_case(&cfg.data_path, "dr_a", case_id);
std::fs::write(case_dir.join(DOCUMENT_FILE), "kopiere mich").unwrap(); write_document_for_test(&case_dir, "kopiere mich", "1970-01-01T00:00:00Z");
let app = doctate_server::create_router(cfg); let app = doctate_server::create_router(cfg);
let cookie = login_dr_a(&app).await; let cookie = login_dr_a(&app).await;
@@ -307,7 +307,7 @@ async fn case_page_foreign_user_returns_404() {
let case_id = "22222222-2222-2222-2222-222222222222"; let case_id = "22222222-2222-2222-2222-222222222222";
// Case exists under dr_b, but session is dr_a → 404. // Case exists under dr_b, but session is dr_a → 404.
let case_dir = seed_case(&cfg.data_path, "dr_b", case_id); let case_dir = seed_case(&cfg.data_path, "dr_b", case_id);
std::fs::write(case_dir.join(DOCUMENT_FILE), "fremdes Dokument").unwrap(); write_document_for_test(&case_dir, "fremdes Dokument", "1970-01-01T00:00:00Z");
let app = doctate_server::create_router(cfg); let app = doctate_server::create_router(cfg);
let cookie = login_dr_a(&app).await; let cookie = login_dr_a(&app).await;
@@ -387,7 +387,7 @@ async fn case_page_uses_oneliner_as_h1_when_present() {
let case_id = "11111111-1111-1111-1111-111111111111"; let case_id = "11111111-1111-1111-1111-111111111111";
let case_dir = seed_case(&cfg.data_path, "dr_a", case_id); let case_dir = seed_case(&cfg.data_path, "dr_a", case_id);
seed_oneliner_ready(&case_dir, "Herzkatheter unauffällig"); seed_oneliner_ready(&case_dir, "Herzkatheter unauffällig");
std::fs::write(case_dir.join(DOCUMENT_FILE), "Inhalt").unwrap(); write_document_for_test(&case_dir, "Inhalt", "1970-01-01T00:00:00Z");
let app = doctate_server::create_router(cfg); let app = doctate_server::create_router(cfg);
let cookie = login_dr_a(&app).await; let cookie = login_dr_a(&app).await;
+19 -2
View File
@@ -2,12 +2,29 @@
//! canonical source-of-truth locations. //! canonical source-of-truth locations.
//! //!
//! Tests that need to read, write, or assert the absence of per-case //! Tests that need to read, write, or assert the absence of per-case
//! artefacts (`oneliner.json`, `.closed`, `document.md`, //! artefacts (`oneliner.json`, `.closed`, `document.json`,
//! `analysis_input.json`) pull these names from here instead of //! `analysis_input.json`) pull these names from here instead of
//! inlining string literals. If the server renames a sidecar in the //! inlining string literals. If the server renames a sidecar in the
//! future, test call-sites fail to compile instead of silently //! future, test call-sites fail to compile instead of silently
//! asserting against the old name. //! asserting against the old name.
use std::path::Path;
pub use doctate_common::ONELINER_FILENAME; pub use doctate_common::ONELINER_FILENAME;
pub use doctate_server::analyze::{ANALYSIS_INPUT_FILE, DOCUMENT_FILE}; pub use doctate_server::analyze::{ANALYSIS_INPUT_FILE, DOCUMENT_FILE, Document};
pub use doctate_server::paths::CLOSE_MARKER; pub use doctate_server::paths::CLOSE_MARKER;
/// Seed a `document.json` for tests. `covered_mtime` is the RFC3339
/// snapshot mtime the document is supposed to "cover" — for tests that
/// don't care about the auto-trigger state machine, pass any RFC3339
/// string (e.g. `"1970-01-01T00:00:00Z"`).
pub fn write_document_for_test(case_dir: &Path, content_md: &str, covered_mtime: &str) {
let doc = Document {
covered_mtime: covered_mtime.to_owned(),
written_at: doctate_common::timestamp::now_rfc3339(),
backend_id: "test".to_owned(),
content_md: content_md.to_owned(),
};
let bytes = serde_json::to_vec_pretty(&doc).expect("serialize test Document");
std::fs::write(case_dir.join(DOCUMENT_FILE), bytes).expect("write test document.json");
}
+17 -2
View File
@@ -66,6 +66,7 @@ pub struct TestConfig {
whisper_url: Option<String>, whisper_url: Option<String>,
whisper_timeout_seconds: Option<u64>, whisper_timeout_seconds: Option<u64>,
ollama_url: Option<String>, ollama_url: Option<String>,
idle_threshold_secs: Option<u64>,
label: &'static str, label: &'static str,
} }
@@ -85,6 +86,7 @@ impl TestConfig {
whisper_url: None, whisper_url: None,
whisper_timeout_seconds: None, whisper_timeout_seconds: None,
ollama_url: None, ollama_url: None,
idle_threshold_secs: None,
label: "common", label: "common",
} }
} }
@@ -152,6 +154,15 @@ impl TestConfig {
self self
} }
/// Idle threshold in seconds: how long after the latest recording mtime
/// a `Required` case is auto-promoted to `Idle` (and enqueued). Default
/// is `Config::test_default()` (= `0`, eager). Tests that exercise the
/// "wait for quiet period" branch set a positive value.
pub fn with_idle_threshold(mut self, secs: u64) -> Self {
self.idle_threshold_secs = Some(secs);
self
}
/// Materialize the config alone. Bucket-B overrides (`with_llm`, /// Materialize the config alone. Bucket-B overrides (`with_llm`,
/// `with_whisper`, `with_ollama`) are silently dropped — call /// `with_whisper`, `with_ollama`) are silently dropped — call
/// [`build_pair`] when those need to take effect. /// [`build_pair`] when those need to take effect.
@@ -170,12 +181,16 @@ impl TestConfig {
.map(|u| (u.api_key.clone(), u.slug.clone())) .map(|u| (u.api_key.clone(), u.slug.clone()))
.collect(); .collect();
let config = Arc::new(Config { let mut config = Config {
data_path, data_path,
users: self.users, users: self.users,
api_keys, api_keys,
..Config::test_default() ..Config::test_default()
}); };
if let Some(v) = self.idle_threshold_secs {
config.analysis_idle_threshold_secs = v;
}
let config = Arc::new(config);
let mut settings = Settings::default(); let mut settings = Settings::default();
// LLM-related fields used to live on `Settings`; with the multi-backend // LLM-related fields used to live on `Settings`; with the multi-backend
+4 -1
View File
@@ -25,7 +25,10 @@ pub mod session;
pub mod users; pub mod users;
// Re-exports for ergonomic `use common::*;` in most test files. // Re-exports for ergonomic `use common::*;` in most test files.
pub use artefacts::{ANALYSIS_INPUT_FILE, CLOSE_MARKER, DOCUMENT_FILE, ONELINER_FILENAME}; pub use artefacts::{
ANALYSIS_INPUT_FILE, CLOSE_MARKER, DOCUMENT_FILE, Document, ONELINER_FILENAME,
write_document_for_test,
};
pub use config::{TestConfig, unique_tmpdir}; pub use config::{TestConfig, unique_tmpdir};
pub use doctate_common::Transcript; pub use doctate_common::Transcript;
pub use http::{ pub use http::{
+2 -2
View File
@@ -16,7 +16,7 @@ use tower::util::ServiceExt;
use common::{ use common::{
ANALYSIS_INPUT_FILE, DOCUMENT_FILE, ONELINER_FILENAME, TestConfig, csrf_form_post, ANALYSIS_INPUT_FILE, DOCUMENT_FILE, ONELINER_FILENAME, TestConfig, csrf_form_post,
login_with_csrf, paths, seed_case, seed_oneliner_ready, seed_oneliner_state, login_with_csrf, paths, seed_case, seed_oneliner_ready, seed_oneliner_state,
seed_recording_with_sidecars, test_user, seed_recording_with_sidecars, test_user, write_document_for_test,
}; };
fn delete_body(filename: &str) -> String { fn delete_body(filename: &str) -> String {
@@ -37,7 +37,7 @@ async fn delete_recording_removes_file_and_sidecars_and_invalidates_derived() {
// Manual wrapper sees a non-Manual variant and proceeds with the // Manual wrapper sees a non-Manual variant and proceeds with the
// delete — keeps the original contract „auto state must be wiped". // delete — keeps the original contract „auto state must be wiped".
seed_oneliner_ready(&case_dir, "knee, left, pain"); seed_oneliner_ready(&case_dir, "knee, left, pain");
std::fs::write(case_dir.join(DOCUMENT_FILE), b"# stale").unwrap(); write_document_for_test(&case_dir, "# stale", "1970-01-01T00:00:00Z");
std::fs::write(case_dir.join(ANALYSIS_INPUT_FILE), b"{}").unwrap(); std::fs::write(case_dir.join(ANALYSIS_INPUT_FILE), b"{}").unwrap();
let (app, store) = doctate_server::create_router_and_session_store(cfg); let (app, store) = doctate_server::create_router_and_session_store(cfg);