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:
@@ -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()?;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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