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
+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;