feat: Handle analyze retry with failure marker

When a previous analysis attempt fails and leaves a failure marker, the
retry mechanism now correctly identifies this state. It removes the
stale
input file, allowing the retry to proceed without a conflict error. This
ensures users can recover from analysis failures more gracefully.
This commit is contained in:
2026-05-03 19:33:09 +02:00
parent 366a8381c4
commit 1b6f4bde67
4 changed files with 146 additions and 16 deletions
+53
View File
@@ -272,6 +272,59 @@ async fn analyze_case_second_time_returns_409() {
assert_eq!(second.status(), StatusCode::CONFLICT);
}
/// Bug regression (case c414cf52, 2026-05-03): a prior LLM run failed,
/// leaving `analysis_input.json` AND `.analysis_failed.json` on disk.
/// The user clicks "Erneut versuchen" — the handler must accept the
/// retry instead of rejecting it as 409 Conflict ("Analyse läuft
/// bereits"). The failure marker is the explicit "worker has given up"
/// signal that legitimises overwriting the stale sentinel.
#[tokio::test]
async fn analyze_retry_after_failure_marker_overwrites_stale_input() {
let (cfg, settings) = cfg_llm("retry-after-fail");
let case_id = "11111111-1111-1111-1111-111111111111";
let case_dir = seed_case(&cfg.data_path, "dr_a", case_id);
seed_recording(&case_dir, "2026-04-15T10-00-00Z", Some("frischer Text"));
// Simulate the post-failure on-disk state: stale input from the
// attempt the worker gave up on, plus the matching failure marker.
std::fs::write(
case_dir.join(ANALYSIS_INPUT_FILE),
r#"{"last_recording_mtime":"2026-04-15T10:00:00Z","recordings":[{"recorded_at":"2026-04-15T10:00:00Z","text":"alter Text"}],"backend_id":""}"#,
)
.unwrap();
std::fs::write(
case_dir.join(".analysis_failed.json"),
r#"{"last_recording_mtime":"2026-04-15T10:00:00Z","reason":"llm: connect timeout","failed_at":"2026-04-15T10:05:00Z"}"#,
)
.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::case_analyze(case_id),
&cookie,
&csrf,
"",
))
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::SEE_OTHER,
"retry with failure marker present must succeed, not 409"
);
// Input was rewritten with the fresh transcript — the stale "alter Text"
// must be gone.
let raw = std::fs::read_to_string(case_dir.join(ANALYSIS_INPUT_FILE)).unwrap();
let parsed: Value = serde_json::from_str(&raw).unwrap();
let recs = parsed["recordings"].as_array().unwrap();
assert_eq!(recs.len(), 1);
assert_eq!(recs[0]["text"], "frischer Text");
}
#[tokio::test]
async fn analyze_case_skips_blank_transcript_from_recordings() {
let (cfg, settings) = cfg_llm("i");
+60 -2
View File
@@ -116,6 +116,9 @@ async fn case_page_shows_analyze_button_when_transcribed_without_document() {
/// and the auto-trigger refuses to retry as long as the marker matches the
/// current input. The case page must therefore surface a recovery banner
/// that exposes the reason and a retry form, otherwise the user is stuck.
///
/// Doctor variant: only the default-backend "Erneut versuchen" button is
/// shown; per-backend choice is admin-only (covered by the sibling test).
#[tokio::test]
async fn case_page_shows_failure_banner_with_retry_when_marker_present() {
let (cfg, settings) = TestConfig::new()
@@ -173,8 +176,12 @@ async fn case_page_shows_failure_banner_with_retry_when_marker_present() {
"retry form must POST to the analyze endpoint"
);
assert!(
body.contains("Erneut mit "),
"retry button label missing (expected 'Erneut mit <backend>')"
body.contains(">Erneut versuchen</button>"),
"default retry button missing (expected 'Erneut versuchen')"
);
assert!(
!body.contains("Erneut mit "),
"per-backend retry buttons must not be shown to non-admins"
);
assert!(
!body.contains("Wird analysiert"),
@@ -182,6 +189,57 @@ async fn case_page_shows_failure_banner_with_retry_when_marker_present() {
);
}
/// Admin variant of the failure-banner test: in addition to the default
/// "Erneut versuchen" button shown to every user, an admin sees one
/// "Erneut mit <backend>" button per configured backend, letting them
/// re-analyse with a different model when one is misbehaving. Pins the
/// `{% if is_admin %}` gate in `case_page.html` so a future refactor
/// can't silently expose backend choice to all users (matches the
/// `analyze` handler's 403 guard) or remove it from admins.
#[tokio::test]
async fn case_page_failure_banner_admin_can_choose_backend() {
let (cfg, settings) = TestConfig::new()
.with_label("cp-fail-banner-admin")
.with_user(test_admin("dr_a"))
.with_llm("http://unused")
.build_pair();
let case_id = "11111111-1111-1111-1111-111111111111";
let case_dir = seed_case(&cfg.data_path, "dr_a", case_id);
seed_recording(
&case_dir,
"2026-04-15T10-00-00Z",
Some("transkribierter Text"),
);
std::fs::write(
case_dir.join(".analysis_failed.json"),
r#"{"last_recording_mtime":"2026-04-15T10:00:00Z","reason":"llm: connect timeout","failed_at":"2026-04-15T10:05:00Z"}"#,
)
.unwrap();
let app = doctate_server::create_router_with_settings(cfg, settings);
let cookie = common::login(&app, "dr_a", "s").await;
let resp = app
.oneshot(get_with_cookie(paths::case_detail(case_id), &cookie))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = body_string(resp).await;
assert!(
body.contains(r#"class="failure-banner""#),
"failure-banner div missing for admin"
);
assert!(
body.contains(">Erneut versuchen</button>"),
"default retry button must remain visible for admin too"
);
assert!(
body.contains("Erneut mit "),
"admin must see at least one 'Erneut mit <backend>' button"
);
}
#[tokio::test]
#[ignore = "post-refactor any_backend_available() reads IONOS_API_KEY which TestConfig pre-sets; cannot be unset for one test in a shared process"]
async fn case_page_shows_llm_missing_hint_without_llm() {