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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user