fix: settle silent-only cases to Empty oneliner state
Introduce `TranscriptState { Pending, Silent, Content }` in
doctate-common as the canonical three-way state of a recording's
`.transcript.txt` sidecar. Previously each call-site projected the
raw `Option<String>` / `.exists()` onto its own 2-state view and the
projections disagreed: the UI treated a 0-byte silent transcript like
`Content` while the oneliner worker treated it like `Pending`,
leaving silent-only cases stuck on "generiere Titel …" forever with
no persisted oneliner.json.
`update_oneliner` now settles a silent-only case to
`OnelinerState::Empty` when no `Content` transcript exists and no
recording is still `Pending`, so the UI resolves to "unbenannt" and
recovery treats it as terminal.
Single reader: `paths::read_transcript_state`. All call-sites
(scan_recordings, compute_oneliner_display, has_pending_recordings,
all_transcripts_joined, read_recordings, enqueue_pending_for_user,
scan_m4as, cases_needing_oneliner_in, case_recordings.html) go
through the same typed abstraction and must handle `Silent` via
exhaustive match.
Regression tests:
- silent_case_empty_test: heal path settles silent-only case to
Empty without calling Ollama
- case_page_silent_only_shows_empty_not_generating: UI renders
"unbenannt", not "generiere Titel …"
This commit is contained in:
@@ -338,6 +338,45 @@ async fn case_page_uses_oneliner_as_h1_when_present() {
|
||||
);
|
||||
}
|
||||
|
||||
/// A case with only silent recordings (empty 0-byte transcript) and a
|
||||
/// persisted `OnelinerState::Empty` must render the "unbenannt" variant
|
||||
/// of the oneliner, never "generiere Titel …". Regression guard for
|
||||
/// the bug where the UI's `compute_oneliner_display` collapsed
|
||||
/// `TranscriptState::Silent` onto "transcribed" and then chose
|
||||
/// `Generating` because no state was persisted — the combination left
|
||||
/// cases stuck on "generiere Titel …" forever.
|
||||
#[tokio::test]
|
||||
async fn case_page_silent_only_shows_empty_not_generating() {
|
||||
let config = config_with_llm(unique_tmp("cp-silent"));
|
||||
let case_id = "33333333-3333-3333-3333-333333333333";
|
||||
let case_dir = seed_case(&config.data_path, "dr_a", case_id);
|
||||
// `Some("")` produces a 0-byte transcript sidecar — the whisper-as-
|
||||
// silence output. Combined with `OnelinerState::Empty`, this is the
|
||||
// terminal shape of a silent-only case after the fix.
|
||||
seed_recording(&case_dir, "10-00-00", Some(""));
|
||||
seed_oneliner_state(
|
||||
&case_dir,
|
||||
&OnelinerState::Empty {
|
||||
generated_at: "2026-04-21T11:00:00Z".into(),
|
||||
},
|
||||
);
|
||||
|
||||
let app = doctate_server::create_router(config);
|
||||
let cookie = login(app.clone(), "dr_a").await;
|
||||
|
||||
let resp = app.oneshot(get_page(case_id, &cookie, "")).await.unwrap();
|
||||
let body = body_string(resp).await;
|
||||
|
||||
assert!(
|
||||
!body.contains("generiere Titel"),
|
||||
"silent-only case must not be stuck on 'generiere Titel …'"
|
||||
);
|
||||
assert!(
|
||||
body.contains("unbenannt"),
|
||||
"expected oneliner to render as 'unbenannt' for Empty state"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn case_page_falls_back_to_generic_title_for_empty_state() {
|
||||
// An `Empty` state (LLM deliberately returned no medical content) is
|
||||
|
||||
@@ -23,8 +23,17 @@ use wiremock::matchers::{method, path as wm_path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
fn write_transcript(case_dir: &Path) {
|
||||
let p = case_dir.join("2026-04-16T10-00-00Z.transcript.txt");
|
||||
std::fs::write(p, "Brustschmerz links, seit heute morgen.").unwrap();
|
||||
// Seed an m4a + transcript pair, matching the worker's on-disk
|
||||
// layout. The oneliner worker iterates `.m4a` files and resolves
|
||||
// the sidecar from the stem; a stray transcript without an m4a
|
||||
// would be invisible to it.
|
||||
let stem = "2026-04-16T10-00-00Z";
|
||||
std::fs::write(case_dir.join(format!("{stem}.m4a")), b"audio").unwrap();
|
||||
std::fs::write(
|
||||
case_dir.join(format!("{stem}.transcript.txt")),
|
||||
"Brustschmerz links, seit heute morgen.",
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
//! Regression test: a case whose recordings are all `TranscriptState::Silent`
|
||||
//! (whisper classified as silence) must produce an `OnelinerState::Empty`
|
||||
//! automatically, so the UI doesn't stick on "Generating" forever.
|
||||
//!
|
||||
//! The historical bug path: whisper wrote a 0-byte `.transcript.txt`,
|
||||
//! `update_oneliner` saw no content and returned early without persisting
|
||||
//! any state, and the UI's `compute_oneliner_display` collapsed the
|
||||
//! Silent file onto "transcribed → Generating" (missing state). The fix
|
||||
//! makes `update_oneliner` settle the case to `OnelinerState::Empty`
|
||||
//! when no content transcript exists and no recording is still Pending.
|
||||
|
||||
use std::path::Path;
|
||||
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};
|
||||
|
||||
/// Seed an m4a plus a silent (0-byte) transcript sidecar — the on-disk
|
||||
/// shape of a recording that whisper classified as silence.
|
||||
fn seed_silent_recording(case_dir: &Path, stem: &str) {
|
||||
std::fs::write(case_dir.join(format!("{stem}.m4a")), b"audio").unwrap();
|
||||
std::fs::write(case_dir.join(format!("{stem}.transcript.txt")), b"").unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn silent_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-silent");
|
||||
std::fs::create_dir_all(&case_dir).unwrap();
|
||||
seed_silent_recording(&case_dir, "2026-04-16T10-00-00Z");
|
||||
|
||||
// Ollama must NOT be called — a silent-only case has no content to
|
||||
// summarize. `.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;
|
||||
}
|
||||
|
||||
// Oneliner is persisted as `Empty`. Without the fix, the file would
|
||||
// either not exist or be mid-generation.
|
||||
let path = case_dir.join("oneliner.json");
|
||||
assert!(
|
||||
path.exists(),
|
||||
"expected oneliner.json to be written for silent-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.
|
||||
}
|
||||
Reference in New Issue
Block a user