feat: Show LLM failure banner and retry button

When an LLM analysis fails, a `.analysis_failed.json` marker is created.
If no document exists yet and the auto-trigger is blocked by this
marker,
the case page must display a banner. This banner provides the failure
reason and a button to retry the analysis, ensuring users are not left
in a dead-end state.

The `read_failure_marker` function is made public to allow the web layer
to access this failure information. The `CasePageTemplate` is updated to
include `analysis_failed` data, which conditionally renders the new
`.failure-banner` HTML.

This change prevents cases from becoming unrecoverable due to transient
LLM errors.
This commit is contained in:
2026-05-03 13:53:55 +02:00
parent bf6464d7e1
commit cbb072d0cc
5 changed files with 473 additions and 2 deletions
+73
View File
@@ -109,6 +109,79 @@ async fn case_page_shows_analyze_button_when_transcribed_without_document() {
);
}
/// Bug regression: a previous LLM run that failed (e.g. transient HTTP
/// transport error to the inference endpoint) leaves a `.analysis_failed.json`
/// marker on disk. With no `document.md` yet, the existing "Neu analysieren"
/// button — which lives inside the document-rendered branch — is invisible,
/// 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.
#[tokio::test]
async fn case_page_shows_failure_banner_with_retry_when_marker_present() {
let (cfg, settings) = TestConfig::new()
.with_label("cp-fail-banner")
.with_user(test_user("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"),
);
// Seed the marker exactly as the analyze worker would on a transport
// failure. `last_recording_mtime` is intentionally close to (but not
// exactly equal to) the freshly-seeded recording's mtime — the banner
// path reads the marker unconditionally, so equality is irrelevant
// here; we only need the file to exist and parse.
std::fs::write(
case_dir.join(".analysis_failed.json"),
r#"{"last_recording_mtime":"2026-04-15T10:00:00Z","reason":"llm: connect timeout to https://example.invalid/v1/chat/completions","failed_at":"2026-04-15T10:05:00Z"}"#,
)
.unwrap();
let app = doctate_server::create_router_with_settings(cfg, settings);
let cookie = login_dr_a(&app).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"
);
assert!(
body.contains("Analyse fehlgeschlagen"),
"user-facing failure title missing"
);
assert!(
body.contains("connect timeout"),
"raw reason text not surfaced (expected inside <details>)"
);
assert!(
body.contains(r#"datetime="2026-04-15T10:05:00Z""#),
"failed_at not rendered as <time datetime=...> for browser locale formatting"
);
assert!(
body.contains(&format!(r#"action="{}""#, paths::case_analyze(case_id))),
"retry form must POST to the analyze endpoint"
);
assert!(
body.contains("Erneut analysieren"),
"retry button label missing"
);
assert!(
!body.contains("Wird analysiert"),
"the analyzing placeholder must not coexist with the failure banner"
);
}
#[tokio::test]
async fn case_page_shows_llm_missing_hint_without_llm() {
let cfg = TestConfig::new()