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:
@@ -16,7 +16,7 @@ use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
use common::{
|
||||
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,
|
||||
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");
|
||||
std::fs::create_dir_all(&case_dir).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();
|
||||
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 case_id = "11111111-1111-1111-1111-111111111111";
|
||||
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-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"}"#,
|
||||
)
|
||||
.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();
|
||||
|
||||
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 dir = seed_case(&cfg.data_path, "dr_a", case_id);
|
||||
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 (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);
|
||||
seed_recording(&dir_a, "2026-04-15T10-00-00Z", Some("a"));
|
||||
seed_recording(&dir_b, "2026-04-15T10-05-00Z", Some("b"));
|
||||
std::fs::write(dir_a.join(DOCUMENT_FILE), "A").unwrap();
|
||||
std::fs::write(dir_b.join(DOCUMENT_FILE), "B").unwrap();
|
||||
write_document_for_test(&dir_a, "A", "1970-01-01T00:00:00Z");
|
||||
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.
|
||||
let case_c = "33333333-3333-3333-3333-333333333333";
|
||||
let dir_c = seed_case(&cfg.data_path, "dr_b", case_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);
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@ use tower::util::ServiceExt;
|
||||
use doctate_common::oneliners::OnelinerState;
|
||||
|
||||
use common::{
|
||||
DOCUMENT_FILE, TestConfig, body_string, get_with_cookie, paths, seed_case, seed_oneliner_ready,
|
||||
seed_oneliner_state, seed_recording, test_admin, test_user,
|
||||
TestConfig, body_string, get_with_cookie, paths, seed_case, seed_oneliner_ready,
|
||||
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
|
||||
@@ -29,7 +29,7 @@ async fn case_page_shows_document_when_present() {
|
||||
.build();
|
||||
let case_id = "11111111-1111-1111-1111-111111111111";
|
||||
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 cookie = login_dr_a(&app).await;
|
||||
@@ -57,7 +57,7 @@ async fn case_page_document_has_copy_button() {
|
||||
.build();
|
||||
let case_id = "11111111-1111-1111-1111-111111111111";
|
||||
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 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";
|
||||
// Case exists under dr_b, but session is dr_a → 404.
|
||||
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 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_dir = seed_case(&cfg.data_path, "dr_a", case_id);
|
||||
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 cookie = login_dr_a(&app).await;
|
||||
|
||||
@@ -2,12 +2,29 @@
|
||||
//! canonical source-of-truth locations.
|
||||
//!
|
||||
//! 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
|
||||
//! inlining string literals. If the server renames a sidecar in the
|
||||
//! future, test call-sites fail to compile instead of silently
|
||||
//! asserting against the old name.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
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;
|
||||
|
||||
/// 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");
|
||||
}
|
||||
|
||||
@@ -66,6 +66,7 @@ pub struct TestConfig {
|
||||
whisper_url: Option<String>,
|
||||
whisper_timeout_seconds: Option<u64>,
|
||||
ollama_url: Option<String>,
|
||||
idle_threshold_secs: Option<u64>,
|
||||
label: &'static str,
|
||||
}
|
||||
|
||||
@@ -85,6 +86,7 @@ impl TestConfig {
|
||||
whisper_url: None,
|
||||
whisper_timeout_seconds: None,
|
||||
ollama_url: None,
|
||||
idle_threshold_secs: None,
|
||||
label: "common",
|
||||
}
|
||||
}
|
||||
@@ -152,6 +154,15 @@ impl TestConfig {
|
||||
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`,
|
||||
/// `with_whisper`, `with_ollama`) are silently dropped — call
|
||||
/// [`build_pair`] when those need to take effect.
|
||||
@@ -170,12 +181,16 @@ impl TestConfig {
|
||||
.map(|u| (u.api_key.clone(), u.slug.clone()))
|
||||
.collect();
|
||||
|
||||
let config = Arc::new(Config {
|
||||
let mut config = Config {
|
||||
data_path,
|
||||
users: self.users,
|
||||
api_keys,
|
||||
..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();
|
||||
// LLM-related fields used to live on `Settings`; with the multi-backend
|
||||
|
||||
@@ -25,7 +25,10 @@ pub mod session;
|
||||
pub mod users;
|
||||
|
||||
// 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 doctate_common::Transcript;
|
||||
pub use http::{
|
||||
|
||||
@@ -16,7 +16,7 @@ use tower::util::ServiceExt;
|
||||
use common::{
|
||||
ANALYSIS_INPUT_FILE, DOCUMENT_FILE, ONELINER_FILENAME, TestConfig, csrf_form_post,
|
||||
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 {
|
||||
@@ -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
|
||||
// delete — keeps the original contract „auto state must be wiped".
|
||||
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();
|
||||
|
||||
let (app, store) = doctate_server::create_router_and_session_store(cfg);
|
||||
|
||||
Reference in New Issue
Block a user