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
+7 -1
View File
@@ -269,7 +269,13 @@ async fn scan_m4as(case_dir: &Path) -> M4aScan {
scan
}
async fn read_failure_marker(case_dir: &Path) -> Option<FailureMarker> {
/// Read and parse the per-case failure marker. `pub(crate)` so the web
/// layer can surface the failure state in the case page (otherwise a
/// transport-level LLM error leaves the user with no UI affordance to
/// retry — the auto-trigger sees the marker and skips, but no document
/// exists yet either, so the existing `Neu analysieren` button is not
/// rendered).
pub(crate) async fn read_failure_marker(case_dir: &Path) -> Option<FailureMarker> {
let bytes = tokio::fs::read(case_dir.join(FAILURE_MARKER_FILE))
.await
.ok()?;
+40
View File
@@ -333,6 +333,11 @@ struct CasePageTemplate {
llm_missing: bool,
analyzing: bool,
has_document: bool,
/// `Some` iff a previous LLM analysis failed AND no document exists.
/// Renders a dead-end-recovery banner with reason + retry button so
/// the user is never stranded when a transient LLM error left only a
/// `.analysis_failed.json` marker behind. See `compute_flags`.
analysis_failed: Option<FailureBanner>,
/// True iff at least one `.m4a.failed` recording exists. Drives the
/// verdrängende `fehler`-Badge in the case header; see the same
/// field on `UserCaseView` for semantics.
@@ -369,6 +374,22 @@ struct CaseRecordingsTemplate {
csrf_token: String,
}
/// Information about a previous failed analysis run, surfaced in the
/// case page when no document exists yet so the user always has an
/// affordance to retry. Populated from `.analysis_failed.json` only
/// when `!has_document && !analyzing` — otherwise the marker is either
/// already obsolete (a successful run will overwrite it) or the worker
/// is currently producing a fresh result anyway.
struct FailureBanner {
/// Raw `reason` string from the marker. Shown verbatim inside a
/// `<details>` block — useful for admins, harmless for users.
reason: String,
/// RFC3339 UTC timestamp of the failed run. Browser JS reformats it
/// into the doctor's local timezone (same `time_format.js` that
/// formats `recorded_at_iso`).
failed_at: String,
}
/// Flags derived from filesystem state + config.
/// `can_analyze` covers both first analysis and re-analysis — same precondition
/// (all non-failed recordings transcribed, LLM configured, no analysis in
@@ -379,6 +400,9 @@ struct CaseFlags {
analyzing: bool,
can_analyze: bool,
llm_missing: bool,
/// `Some` iff a previous LLM run failed AND no document exists yet.
/// Drives the dead-end-recovery banner on the case page.
analysis_failed: Option<FailureBanner>,
}
async fn compute_flags(
@@ -399,11 +423,26 @@ async fn compute_flags(
let analyzable = !analyzing && all_transcribed;
// Only surface the failure marker when neither a document nor an in-flight
// analysis would otherwise occupy the same UI slot. Skips the FS read on
// the success hot-path (document already on disk).
let analysis_failed = if !has_document && !analyzing {
auto_trigger::read_failure_marker(case_dir)
.await
.map(|m| FailureBanner {
reason: m.reason,
failed_at: m.failed_at,
})
} else {
None
};
CaseFlags {
has_document,
analyzing,
can_analyze: analyzable && llm_configured,
llm_missing: analyzable && !llm_configured,
analysis_failed,
}
}
@@ -702,6 +741,7 @@ pub async fn handle_case_page(
llm_missing: flags.llm_missing,
analyzing: flags.analyzing,
has_document: flags.has_document,
analysis_failed: flags.analysis_failed,
has_failed_recording,
is_admin,
is_closed,
+94 -1
View File
@@ -164,6 +164,58 @@
background: #f6f6f6;
border-radius: 4px;
}
.failure-banner {
margin: 1em 0;
padding: 1em 1.2em;
background: #fdecea;
border: 1px solid #f5c6cb;
border-radius: 4px;
color: #842029;
}
.failure-banner strong {
font-size: 1.05em;
}
.failure-banner .failure-time {
display: block;
margin-top: 0.4em;
font-size: 0.9em;
color: #6b1f25;
}
.failure-banner details {
margin-top: 0.6em;
font-size: 0.9em;
}
.failure-banner details summary {
cursor: pointer;
color: #6b1f25;
}
.failure-banner pre.failure-reason {
white-space: pre-wrap;
word-break: break-word;
margin: 0.4em 0 0 0;
padding: 0.6em 0.8em;
background: #fff;
border: 1px solid #f5c6cb;
border-radius: 3px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.85em;
color: #4a1014;
}
.failure-banner form {
margin: 0.8em 0 0 0;
}
.failure-banner button {
padding: 0.4em 1em;
background: #b02a37;
color: #fff;
border: none;
border-radius: 3px;
cursor: pointer;
font-size: 0.95em;
}
.failure-banner button:hover {
background: #842029;
}
.recordings-link {
display: inline-block;
margin: 1em 0;
@@ -533,6 +585,33 @@
});
})();
</script>
{% when None %} {% match analysis_failed %} {% when Some with (failure) %}
<div class="failure-banner" role="alert">
<strong>Analyse fehlgeschlagen.</strong>
Bitte erneut versuchen.
<span class="failure-time"
>Zuletzt versucht:
<time datetime="{{ failure.failed_at }}"
>{{ failure.failed_at }}</time
></span
>
<details>
<summary>Technische Details</summary>
<pre class="failure-reason">{{ failure.reason }}</pre>
</details>
<form
method="post"
action="/web/cases/{{ case_id }}/analyze"
>
{% call csrf::field(csrf_token) %}
<input
type="hidden"
name="return_to"
value="/web/cases/{{ case_id }}"
/>
<button type="submit">Erneut analysieren</button>
</form>
</div>
{% when None %} {% if analyzing %}
<div class="placeholder">Wird analysiert …</div>
{% else if recordings_count == 0 %}
@@ -542,7 +621,7 @@
{{ recordings_count }} Aufnahme{% if recordings_count != 1 %}n{%
endif %}, davon {{ transcribed_count }} transkribiert.
</div>
{% endif %} {% endmatch %}
{% endif %} {% endmatch %} {% endmatch %}
<div>
<a
@@ -567,6 +646,20 @@
TimeFmt.dateLabel(d) + " - " + TimeFmt.timeLabel(d);
})();
// Same locale treatment for the failure-banner timestamp.
// No-JS fallback is the raw RFC3339 UTC string the server
// rendered into the <time> element.
(() => {
const el = document.querySelector(
".failure-banner time[datetime]",
);
if (!el) return;
const d = new Date(el.dateTime);
if (isNaN(d.getTime())) return;
el.textContent =
TimeFmt.dateLabel(d) + " " + TimeFmt.timeLabel(d);
})();
(() => {
const cb = document.getElementById("admin-view-toggle");
if (!cb) return;
+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()