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,