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:
2026-04-21 14:39:31 +02:00
parent 4531f85b13
commit 1f32d4dd23
10 changed files with 502 additions and 18 deletions
+6 -3
View File
@@ -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>