1f32d4dd23
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`.
100 lines
3.9 KiB
Rust
100 lines
3.9 KiB
Rust
//! Regression test: a case whose recordings are all `.m4a.failed`
|
|
//! (permanent transcription failures — ffmpeg corrupt audio or Whisper
|
|
//! 4xx) must produce an `OnelinerState::Empty` automatically, so the UI
|
|
//! has a durable terminal state instead of relying on the on-render
|
|
//! `compute_oneliner_display` fallback.
|
|
//!
|
|
//! The historical bug path: `.m4a.failed`-only cases were skipped by the
|
|
//! recovery scan (`has_any_transcript` looked for `*.transcript.txt`
|
|
//! only), so `update_oneliner` never ran, so no `oneliner.json` was
|
|
//! written. The UI masked the symptom with its `non_failed_count == 0`
|
|
//! fallback — but a future refactor of that fallback would regress the
|
|
//! case. The fix makes `has_any_transcript` count `.m4a.failed` too, so
|
|
//! `update_oneliner` runs, sees no content transcripts, and settles the
|
|
//! case to `Empty` through the same terminal branch used for silent-only
|
|
//! cases.
|
|
|
|
use std::sync::Arc;
|
|
use std::sync::atomic::{AtomicBool, Ordering};
|
|
use std::time::Duration;
|
|
|
|
use doctate_common::oneliners::OnelinerState;
|
|
use doctate_server::config::Config;
|
|
use doctate_server::events;
|
|
use doctate_server::gazetteer::Gazetteer;
|
|
use doctate_server::{
|
|
AnalyzeBusy, OnelinerHealBusy, PipelineState, TranscribeBusy, WorkerBusy, analyze, transcribe,
|
|
};
|
|
use tempfile::tempdir;
|
|
use wiremock::matchers::{method, path as wm_path};
|
|
use wiremock::{Mock, MockServer, ResponseTemplate};
|
|
|
|
#[tokio::test]
|
|
async fn failed_only_case_settles_to_empty_without_llm_call() {
|
|
let data = tempdir().unwrap();
|
|
let slug = "dr_test";
|
|
let user_root = data.path().join(slug);
|
|
let case_dir = user_root.join("case-failed");
|
|
std::fs::create_dir_all(&case_dir).unwrap();
|
|
// Seed: only a `.m4a.failed` file. No transcript, no plain .m4a.
|
|
std::fs::write(case_dir.join("2026-04-15T20-30-00Z.m4a.failed"), b"x").unwrap();
|
|
|
|
// Ollama must NOT be called — nothing to summarize for a failed-only
|
|
// case. `.expect(0)` fails the test (on MockServer drop) if any
|
|
// request arrived.
|
|
let mock = MockServer::start().await;
|
|
Mock::given(method("POST"))
|
|
.and(wm_path("/api/chat"))
|
|
.respond_with(ResponseTemplate::new(200))
|
|
.expect(0)
|
|
.mount(&mock)
|
|
.await;
|
|
|
|
let mut cfg = Config::test_default();
|
|
cfg.data_path = data.path().to_path_buf();
|
|
cfg.ollama_url = mock.uri();
|
|
let config = Arc::new(cfg);
|
|
|
|
let vocab = Arc::new(Gazetteer::empty());
|
|
let events_tx = events::channel();
|
|
let http_client = reqwest::Client::new();
|
|
let (tx_a, _rx_a) = analyze::channel();
|
|
let (tx_t, _rx_t) = transcribe::channel();
|
|
|
|
let heal_busy: WorkerBusy = Arc::new(AtomicBool::new(false));
|
|
let pipeline = PipelineState {
|
|
analyze_busy: AnalyzeBusy(Arc::new(AtomicBool::new(false))),
|
|
analyze_tx: tx_a,
|
|
transcribe_busy: TranscribeBusy(Arc::new(AtomicBool::new(false))),
|
|
transcribe_tx: tx_t,
|
|
oneliner_heal_busy: OnelinerHealBusy(heal_busy.clone()),
|
|
};
|
|
|
|
pipeline
|
|
.heal_orphans_if_idle(&user_root, slug, &http_client, &config, &vocab, &events_tx)
|
|
.await;
|
|
|
|
// Heal spawn drops the busy flag on completion — poll until idle.
|
|
let start = std::time::Instant::now();
|
|
while heal_busy.load(Ordering::Acquire) {
|
|
if start.elapsed() > Duration::from_secs(5) {
|
|
panic!("oneliner heal did not finish within 5s");
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(25)).await;
|
|
}
|
|
|
|
let path = case_dir.join("oneliner.json");
|
|
assert!(
|
|
path.exists(),
|
|
"expected oneliner.json to be written for failed-only case"
|
|
);
|
|
let bytes = std::fs::read(&path).unwrap();
|
|
let state: OnelinerState = serde_json::from_slice(&bytes).unwrap();
|
|
assert!(
|
|
matches!(state, OnelinerState::Empty { .. }),
|
|
"expected OnelinerState::Empty, got {state:?}"
|
|
);
|
|
|
|
// Mock.drop() runs here — `.expect(0)` panics if Ollama was touched.
|
|
}
|