Introduce boot_time to evaluate_state
The `boot_time` parameter is introduced to `evaluate_state` and its callers. This parameter acts as a lower bound for the "seen at" timestamp used in determining the `Idle` state for cases. Previously, the `elapsed` duration was calculated as `now.duration_since(latest_mtime)`. This meant that a server restart could immediately cause old recordings to be classified as `Idle` if their modification times were significantly in the past, potentially triggering a large number of LLM calls upon the first render of the `/cases` page. By using `seen_at = std::cmp::max(latest_mtime, boot_time)`, the idle calculation now ensures that a fresh server start respects a grace period. Old recordings will not satisfy the idle threshold until the elapsed time since the server's boot (or the recording's modification time, whichever is later) exceeds the `idle_threshold`. `SystemTime::UNIX_EPOCH` is used as the default `boot_time` in tests and in `create_router_and_session_store_with_settings`. This preserves existing test behavior where the idle grace period is effectively disabled. For production, `main.rs` now captures the server's actual boot time and passes it down. This change also refactors `evaluate_case` and `try_enqueue` to accept the new `boot_time` parameter. `try_enqueue_all_for_user` is updated to pass `boot_time` along. `boot_scan_state` and `compute_state_for_user` in `recovery.rs` also receive `boot_time`. Additionally, a new test case `analyze_required_endpoint_enqueues_only_required_cases` is added to verify the behavior of the `/cases/analyze-required` endpoint. This endpoint now correctly enqueues only `Required`
This commit is contained in:
@@ -629,6 +629,7 @@ async fn recovery_does_not_enqueue_at_boot() {
|
||||
&tmp,
|
||||
std::time::Duration::ZERO,
|
||||
std::time::SystemTime::now(),
|
||||
std::time::SystemTime::UNIX_EPOCH,
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -1243,6 +1244,7 @@ async fn recovery_skips_closed_cases() {
|
||||
&tmp,
|
||||
std::time::Duration::ZERO,
|
||||
std::time::SystemTime::now(),
|
||||
std::time::SystemTime::UNIX_EPOCH,
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -1509,3 +1511,104 @@ async fn analyze_works_against_ollama_style_endpoint_without_api_key() {
|
||||
drop(tx);
|
||||
let _ = tokio::time::timeout(Duration::from_secs(2), handle).await;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Bulk "Aktualisieren" endpoint
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/// `POST /cases/analyze-required` walks every UUID-named case and force-
|
||||
/// enqueues those in `Required`/`Idle`. `Failed` and `Current` cases must
|
||||
/// stay untouched. We probe the resulting state via the on-disk
|
||||
/// `analysis_input.json` marker — written before the channel send, so its
|
||||
/// presence is the canonical "would have been enqueued" signal.
|
||||
#[tokio::test]
|
||||
async fn analyze_required_endpoint_enqueues_only_required_cases() {
|
||||
use doctate_server::analyze::auto_trigger::FailureMarker;
|
||||
use time::OffsetDateTime;
|
||||
use time::format_description::well_known::Rfc3339;
|
||||
|
||||
fn to_rfc3339(t: std::time::SystemTime) -> String {
|
||||
OffsetDateTime::from(t).format(&Rfc3339).unwrap()
|
||||
}
|
||||
|
||||
let (cfg, settings) = cfg_llm("ar-1");
|
||||
let user_root = cfg.data_path.join("dr_a");
|
||||
|
||||
// Required: transcribed recording, no document.
|
||||
let req_id = "11111111-1111-1111-1111-111111111111";
|
||||
let req_dir = seed_case(&cfg.data_path, "dr_a", req_id);
|
||||
seed_recording(&req_dir, "2026-04-15T10-00-00Z", Some("erste Aufnahme"));
|
||||
|
||||
// Current: document covers the recording's mtime exactly.
|
||||
let cur_id = "22222222-2222-2222-2222-222222222222";
|
||||
let cur_dir = seed_case(&cfg.data_path, "dr_a", cur_id);
|
||||
seed_recording(&cur_dir, "2026-04-15T10-00-00Z", Some("alles okay"));
|
||||
let cur_m4a_mtime = std::fs::metadata(cur_dir.join("2026-04-15T10-00-00Z.m4a"))
|
||||
.unwrap()
|
||||
.modified()
|
||||
.unwrap();
|
||||
write_document_for_test(&cur_dir, "fertig", &to_rfc3339(cur_m4a_mtime));
|
||||
|
||||
// Failed: failure marker matches latest recording mtime.
|
||||
let fail_id = "33333333-3333-3333-3333-333333333333";
|
||||
let fail_dir = seed_case(&cfg.data_path, "dr_a", fail_id);
|
||||
seed_recording(&fail_dir, "2026-04-15T10-00-00Z", Some("kaputt"));
|
||||
let fail_m4a_mtime = std::fs::metadata(fail_dir.join("2026-04-15T10-00-00Z.m4a"))
|
||||
.unwrap()
|
||||
.modified()
|
||||
.unwrap();
|
||||
let marker = FailureMarker {
|
||||
last_recording_mtime: to_rfc3339(fail_m4a_mtime),
|
||||
reason: "test".into(),
|
||||
failed_at: "2026-04-15T10-05-00Z".into(),
|
||||
backend_id: None,
|
||||
details: None,
|
||||
};
|
||||
std::fs::write(
|
||||
fail_dir.join(".analysis_failed.json"),
|
||||
serde_json::to_vec(&marker).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
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 resp = app
|
||||
.oneshot(csrf_form_post(paths::ANALYZE_REQUIRED, &cookie, &csrf, ""))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
|
||||
assert_eq!(
|
||||
resp.headers().get(header::LOCATION).unwrap(),
|
||||
"/cases",
|
||||
"must redirect back to the case list"
|
||||
);
|
||||
|
||||
// Required → analysis_input.json was written, the case is now Queued.
|
||||
assert!(
|
||||
user_root.join(req_id).join(ANALYSIS_INPUT_FILE).exists(),
|
||||
"Required case must have been enqueued"
|
||||
);
|
||||
// Current → no enqueue, document.json untouched.
|
||||
assert!(
|
||||
!user_root.join(cur_id).join(ANALYSIS_INPUT_FILE).exists(),
|
||||
"Current case must not be enqueued"
|
||||
);
|
||||
assert!(
|
||||
user_root.join(cur_id).join(DOCUMENT_FILE).exists(),
|
||||
"Current case's document must remain in place"
|
||||
);
|
||||
// Failed → no enqueue. Marker stays so the user sees the failure
|
||||
// banner on the case page and can retry per-backend manually.
|
||||
assert!(
|
||||
!user_root.join(fail_id).join(ANALYSIS_INPUT_FILE).exists(),
|
||||
"Failed case must not be enqueued"
|
||||
);
|
||||
assert!(
|
||||
user_root
|
||||
.join(fail_id)
|
||||
.join(".analysis_failed.json")
|
||||
.exists(),
|
||||
"failure marker must persist"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,9 @@ pub const LOGOUT: &str = "/logout";
|
||||
pub const CASES: &str = "/cases";
|
||||
/// `POST /cases/bulk` — admin bulk action (close/analyze/reset).
|
||||
pub const BULK: &str = "/cases/bulk";
|
||||
/// `POST /cases/analyze-required` — user-facing bulk "Aktualisieren"
|
||||
/// button: enqueues every Required/Idle case for the authenticated user.
|
||||
pub const ANALYZE_REQUIRED: &str = "/cases/analyze-required";
|
||||
/// `POST /cases/purge-closed` — admin hard-delete of closed cases.
|
||||
pub const PURGE_CLOSED: &str = "/cases/purge-closed";
|
||||
/// `GET /events` — SSE stream (per-user scoped).
|
||||
|
||||
@@ -71,6 +71,7 @@ fn build_state_with_stores() -> AppState {
|
||||
events_tx: doctate_server::events::channel(),
|
||||
http_client: reqwest::Client::new(),
|
||||
vocab: Arc::new(doctate_server::gazetteer::Gazetteer::empty()),
|
||||
boot_time: doctate_server::BootTime(std::time::SystemTime::UNIX_EPOCH),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -84,6 +84,7 @@ fn build_app_with_events_tx(
|
||||
events_tx,
|
||||
http_client: reqwest::Client::new(),
|
||||
vocab: Arc::new(doctate_server::gazetteer::Gazetteer::empty()),
|
||||
boot_time: doctate_server::BootTime(std::time::SystemTime::UNIX_EPOCH),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user