fix: separate transient from permanent transcribe failures
Before, every Whisper error (5xx, timeout, Minerva down, corrupt audio,
4xx) renamed `<ts>.m4a` to `<ts>.m4a.failed` uniformly, turning transient
outages into permanent sackgassen that only a manual admin reset could
undo. Cases whose every recording was `.m4a.failed` stuck without a
persisted oneliner state; the UI masked them via a fallback in
`compute_oneliner_display`.
The core insight: a recoverable error is not an error. Transient Whisper
failures now leave the audio as plain `.m4a` so the existing page-load
heal (`enqueue_pending_for_user`) re-enqueues it on the next refresh —
which is exactly what happens when Minerva comes back. No new sidecar,
no retry count, no scheduler.
Changes:
- `WhisperError::is_transient` classifies `Http`, 5xx, 408, 429 as
transient; `Io` and other 4xx as permanent.
- Transcribe worker: transient → info log + continue (audio stays .m4a);
permanent → `mark_failed` as before.
- `has_any_transcript` counts `.m4a.failed` as terminal, so
`update_oneliner` runs for failed-only cases and settles
`OnelinerState::Empty` (analogous to the silent-only fix in 4531f85).
- New verdrängender `fehler`-Badge (#c00) in case list and detail when
at least one `.m4a.failed` exists; recording-level message shortened
to plain "Transkription fehlgeschlagen".
Tests:
- New integration: transient 503 leaves `.m4a` intact, heal recovers.
- New integration: `.m4a.failed`-only case settles to
`OnelinerState::Empty` without calling Ollama.
- New unit: `is_transient` table test across relevant status codes.
- New unit: `has_any_transcript` returns true for `.m4a.failed`-only
case and false for pending-only case.
- Existing worker test retargeted from 500 to 400 and renamed; added
companion `worker_leaves_m4a_intact_on_transient_whisper_error`.
This commit is contained in:
@@ -67,6 +67,9 @@
|
||||
.status-badge.done {
|
||||
background: #7ed321;
|
||||
}
|
||||
.status-badge.fehler {
|
||||
background: #c00;
|
||||
}
|
||||
.actions {
|
||||
margin: 1em 0 1.5em;
|
||||
display: flex;
|
||||
@@ -255,9 +258,9 @@
|
||||
</header>
|
||||
<h1>
|
||||
<span class="title">
|
||||
{% call ol::render(oneliner) %} {% if has_document %}<span
|
||||
class="status-badge done"
|
||||
>ausgewertet</span
|
||||
{% call ol::render(oneliner) %} {% if has_failed_recording
|
||||
%}<span class="status-badge fehler">fehler</span>{% else if
|
||||
has_document %}<span class="status-badge done">ausgewertet</span
|
||||
>{% else %}<span class="status-badge open">offen</span>{% endif
|
||||
%}
|
||||
</span>
|
||||
|
||||
@@ -114,7 +114,7 @@ try {
|
||||
</div>
|
||||
{% if is_admin %}<div class="filename admin-only">{{ rec.filename }}</div>{% endif %}
|
||||
{% if rec.failed %}
|
||||
<div class="failed">Transkription fehlgeschlagen — .failed-Suffix entfernen zum Erneut-Versuchen.</div>
|
||||
<div class="failed">Transkription fehlgeschlagen</div>
|
||||
{% else %}
|
||||
{% match rec.transcript %}
|
||||
{% when TranscriptState::Content with (t) %}
|
||||
|
||||
@@ -32,6 +32,7 @@ section h2 .count { font-weight: normal; font-size: 0.85em; color: #888; }
|
||||
.status-badge { display: inline-block; padding: 0.05em 0.5em; border-radius: 999px; font-size: 0.75em; font-weight: bold; color: white; }
|
||||
.status-badge.open { background: #e24a4a; }
|
||||
.status-badge.done { background: #7ed321; }
|
||||
.status-badge.fehler { background: #c00; }
|
||||
.case-row .actions { display: flex; gap: 0.4em; align-items: center; }
|
||||
.case-row .actions button { font-size: 0.85em; padding: 0.35em 0.8em; }
|
||||
.case-row .actions .delete-btn { padding: 0.3em; background: transparent; border: none; color: #888; cursor: pointer; display: inline-flex; align-items: center; line-height: 0; border-radius: 4px; transition: color 0.15s, background 0.15s; }
|
||||
@@ -96,7 +97,7 @@ try {
|
||||
<div class="body">
|
||||
<a href="/web/cases/{{ case.case_id }}{% if case.is_closed %}?show_closed=1{% endif %}">
|
||||
<div class="line1"><span class="time"><time datetime="{{ case.recorded_at_iso }}">{{ case.time_hms_utc }}</time></span> — {% call ol::render(case.oneliner) %}</div>
|
||||
<div class="line2">{{ case.recordings_count }} Aufnahmen{% if case.has_document %} — <span class="status-badge done">ausgewertet</span>{% else if !case.is_closed %} — <span class="status-badge open">offen</span>{% endif %}{% if case.analyzing %} <span class="label analyzing">wird analysiert</span>{% endif %}{% if case.is_closed %}<span class="closed-badge">geschlossen{% match case.days_until_purge %}{% when Some with (d) %} — wird in {{ d }} Tagen entfernt{% when None %}{% endmatch %}</span>{% endif %}</div>
|
||||
<div class="line2">{{ case.recordings_count }} Aufnahmen{% if case.has_failed_recording %} — <span class="status-badge fehler">fehler</span>{% else if case.has_document %} — <span class="status-badge done">ausgewertet</span>{% else if !case.is_closed %} — <span class="status-badge open">offen</span>{% endif %}{% if case.analyzing %} <span class="label analyzing">wird analysiert</span>{% endif %}{% if case.is_closed %}<span class="closed-badge">geschlossen{% match case.days_until_purge %}{% when Some with (d) %} — wird in {{ d }} Tagen entfernt{% when None %}{% endmatch %}</span>{% endif %}</div>
|
||||
{% if is_admin %}<div class="uuid admin-only">{{ case.case_id }}</div>{% endif %}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user