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:
+106
-48
@@ -2,6 +2,7 @@ use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use doctate_common::TranscriptState;
|
||||
use doctate_common::oneliners::{ONELINER_FILENAME, OnelinerState};
|
||||
use time::OffsetDateTime;
|
||||
use time::format_description::well_known::Rfc3339;
|
||||
@@ -11,6 +12,7 @@ use super::{TranscribeReceiver, ffmpeg, ollama, whisper};
|
||||
use crate::config::{Config, WhisperUserSettings};
|
||||
use crate::events::{self, CaseEventKind, EventSender};
|
||||
use crate::gazetteer::Gazetteer;
|
||||
use crate::paths;
|
||||
use crate::{BusyGuard, WorkerBusy};
|
||||
|
||||
const ONELINER_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
@@ -181,14 +183,23 @@ async fn write_duration_sidecar(audio_path: &Path, probe_path: &Path) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Regenerate `case_dir/oneliner.json` from **all** non-empty transcripts
|
||||
/// Regenerate `case_dir/oneliner.json` from **all** `Content` transcripts
|
||||
/// in the case, joined chronologically. Called after every successful
|
||||
/// transcript write so later recordings can correct earlier ones (mirror
|
||||
/// of the analyze-LLM rule "later recordings override"). Every outcome —
|
||||
/// generated text, deliberate empty ("no medical content"), or call error
|
||||
/// — is persisted as an [`OnelinerState`] variant, overwriting any
|
||||
/// previous state. Silent case (no non-empty transcript) is a no-op; the
|
||||
/// next transcript write retries.
|
||||
/// previous state.
|
||||
///
|
||||
/// Three terminal shapes:
|
||||
/// - At least one `Content` transcript → call the LLM; persist `Ready`,
|
||||
/// `Empty`, or `Error` based on the response.
|
||||
/// - No `Content` but all other recordings are `Silent` (nothing more
|
||||
/// coming from the transcriber) → persist `Empty` directly and skip
|
||||
/// the LLM call. Without this, silent-only cases would leave the UI
|
||||
/// stuck on "Generating" forever.
|
||||
/// - Still-`Pending` recordings present → no-op; the next transcript
|
||||
/// write re-enters this function with more information.
|
||||
pub(crate) async fn update_oneliner(
|
||||
case_dir: &Path,
|
||||
user_slug: &str,
|
||||
@@ -199,7 +210,42 @@ pub(crate) async fn update_oneliner(
|
||||
) {
|
||||
let transcript = match all_transcripts_joined(case_dir).await {
|
||||
Some(t) => t,
|
||||
None => return,
|
||||
None => {
|
||||
// No `Content` transcripts in the case. Two distinct reasons:
|
||||
// a) Some recording is still `Pending` — transcriber will
|
||||
// trigger us again when the last one lands. Skip for now.
|
||||
// b) Every recording is `Silent` — transcriber is done and
|
||||
// will never produce text. Without a persisted state
|
||||
// the UI would stick on `Generating` forever (bug that
|
||||
// motivated this code path). Persist `Empty` so the UI
|
||||
// settles and recovery treats it as terminal.
|
||||
if !has_pending_recordings(case_dir).await {
|
||||
let generated_at = OffsetDateTime::now_utc()
|
||||
.format(&Rfc3339)
|
||||
.unwrap_or_default();
|
||||
let state = OnelinerState::Empty { generated_at };
|
||||
if let Err(e) = write_oneliner_state(case_dir, &state).await {
|
||||
error!(
|
||||
case = %case_dir.display(),
|
||||
error = %e,
|
||||
"writing oneliner.json (all-silent empty) failed"
|
||||
);
|
||||
return;
|
||||
}
|
||||
info!(
|
||||
user = %user_slug,
|
||||
case = %case_dir.display(),
|
||||
"oneliner empty — all recordings silent (no content transcripts)"
|
||||
);
|
||||
events::emit(
|
||||
events_tx,
|
||||
user_slug,
|
||||
events::case_id_of(case_dir),
|
||||
CaseEventKind::OnelinerUpdated,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let generated_at = OffsetDateTime::now_utc()
|
||||
@@ -264,12 +310,15 @@ async fn write_oneliner_state(case_dir: &Path, state: &OnelinerState) -> std::io
|
||||
}
|
||||
|
||||
/// Return true if `case_dir` contains at least one `.m4a` recording
|
||||
/// that has no corresponding `.transcript.txt`. Used to gate the
|
||||
/// oneliner-regenerate call: we only run it after the *last* pending
|
||||
/// transcription in a batch, not after every single one. `.m4a.failed`
|
||||
/// entries are skipped — they don't block the oneliner. Fail-open on
|
||||
/// read errors: if we can't scan, return false so the oneliner runs
|
||||
/// anyway (one redundant call beats never).
|
||||
/// whose transcript is still `Pending` (no sidecar file yet). Used to
|
||||
/// gate the oneliner-regenerate call: we only run it after the *last*
|
||||
/// pending transcription in a batch, not after every single one.
|
||||
/// `.m4a.failed` entries are skipped — they don't block the oneliner.
|
||||
/// `Silent` recordings count as finished, not pending — that's the
|
||||
/// whole point of the three-state model: the transcriber already ran,
|
||||
/// it just didn't produce text.
|
||||
/// Fail-open on read errors: if we can't scan, return false so the
|
||||
/// oneliner runs anyway (one redundant call beats never).
|
||||
pub(crate) async fn has_pending_recordings(case_dir: &Path) -> bool {
|
||||
let Ok(mut entries) = tokio::fs::read_dir(case_dir).await else {
|
||||
return false;
|
||||
@@ -279,32 +328,33 @@ pub(crate) async fn has_pending_recordings(case_dir: &Path) -> bool {
|
||||
if path.extension().and_then(|s| s.to_str()) != Some("m4a") {
|
||||
continue;
|
||||
}
|
||||
if !path.with_extension("transcript.txt").exists() {
|
||||
if paths::read_transcript_state(&path).await.is_pending() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Read every non-empty `*.transcript.txt` in `case_dir`, ordered
|
||||
/// chronologically (ISO-8601 filename sort equals chronological order), and
|
||||
/// join them with `\n\n---\n\n`. Empty / whitespace-only transcripts (silent
|
||||
/// recordings) are skipped. Returns None if no non-empty transcript exists.
|
||||
/// Join every `Content` transcript in `case_dir` chronologically with
|
||||
/// `\n\n---\n\n` separators. `Pending` (no sidecar) and `Silent` (empty
|
||||
/// sidecar) recordings are skipped. Returns None if no `Content`
|
||||
/// transcript exists — the caller decides whether that means "wait for
|
||||
/// more transcripts" or "this case is terminal silent" by consulting
|
||||
/// [`has_pending_recordings`] afterwards.
|
||||
pub(crate) async fn all_transcripts_joined(case_dir: &Path) -> Option<String> {
|
||||
let mut entries = tokio::fs::read_dir(case_dir).await.ok()?;
|
||||
let mut files: Vec<std::path::PathBuf> = Vec::new();
|
||||
let mut m4as: Vec<std::path::PathBuf> = Vec::new();
|
||||
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||
let name = entry.file_name();
|
||||
let Some(s) = name.to_str() else { continue };
|
||||
if s.ends_with(".transcript.txt") {
|
||||
files.push(entry.path());
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|s| s.to_str()) == Some("m4a") {
|
||||
m4as.push(path);
|
||||
}
|
||||
}
|
||||
files.sort();
|
||||
m4as.sort();
|
||||
|
||||
let mut parts: Vec<String> = Vec::with_capacity(files.len());
|
||||
for path in files {
|
||||
if let Ok(text) = tokio::fs::read_to_string(&path).await {
|
||||
let mut parts: Vec<String> = Vec::with_capacity(m4as.len());
|
||||
for path in m4as {
|
||||
if let TranscriptState::Content(text) = paths::read_transcript_state(&path).await {
|
||||
let trimmed = text.trim();
|
||||
if !trimmed.is_empty() {
|
||||
parts.push(trimmed.to_string());
|
||||
@@ -322,24 +372,39 @@ mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
/// Helper: seed an m4a plus an optional transcript sidecar, matching
|
||||
/// how the worker lays files out on disk. `all_transcripts_joined`
|
||||
/// iterates `.m4a` files and reads the sidecar through the shared
|
||||
/// reader, so bare transcripts without m4as would be ignored.
|
||||
async fn seed(dir: &Path, stem: &str, transcript: Option<&str>) {
|
||||
tokio::fs::write(dir.join(format!("{stem}.m4a")), b"audio")
|
||||
.await
|
||||
.unwrap();
|
||||
if let Some(text) = transcript {
|
||||
tokio::fs::write(dir.join(format!("{stem}.transcript.txt")), text)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// Regression guard for the Womax bug: the joined transcript must
|
||||
/// include ALL recordings in chronological order, so the LLM sees
|
||||
/// both the initial term and any later correction.
|
||||
#[tokio::test]
|
||||
async fn all_transcripts_joined_includes_all_in_order() {
|
||||
let dir = tempdir().unwrap();
|
||||
tokio::fs::write(
|
||||
dir.path().join("2026-04-16T10-05-00Z.transcript.txt"),
|
||||
"Korrektur. Das Mittel heißt Vomex.",
|
||||
seed(
|
||||
dir.path(),
|
||||
"2026-04-16T10-05-00Z",
|
||||
Some("Korrektur. Das Mittel heißt Vomex."),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
tokio::fs::write(
|
||||
dir.path().join("2026-04-16T10-00-00Z.transcript.txt"),
|
||||
"Jetzt weiß ich es wieder. Es heißt Womax.",
|
||||
.await;
|
||||
seed(
|
||||
dir.path(),
|
||||
"2026-04-16T10-00-00Z",
|
||||
Some("Jetzt weiß ich es wieder. Es heißt Womax."),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
.await;
|
||||
|
||||
let got = all_transcripts_joined(dir.path()).await.expect("Some");
|
||||
assert_eq!(
|
||||
@@ -353,15 +418,13 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn all_transcripts_joined_skips_empty_recordings() {
|
||||
let dir = tempdir().unwrap();
|
||||
tokio::fs::write(dir.path().join("2026-04-16T10-00-00Z.transcript.txt"), "")
|
||||
.await
|
||||
.unwrap();
|
||||
tokio::fs::write(
|
||||
dir.path().join("2026-04-16T10-05-00Z.transcript.txt"),
|
||||
"non-empty content",
|
||||
seed(dir.path(), "2026-04-16T10-00-00Z", Some("")).await;
|
||||
seed(
|
||||
dir.path(),
|
||||
"2026-04-16T10-05-00Z",
|
||||
Some("non-empty content"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
.await;
|
||||
|
||||
let got = all_transcripts_joined(dir.path()).await.expect("Some");
|
||||
assert_eq!(got, "non-empty content");
|
||||
@@ -370,12 +433,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn all_transcripts_joined_is_none_when_all_empty() {
|
||||
let dir = tempdir().unwrap();
|
||||
tokio::fs::write(
|
||||
dir.path().join("2026-04-16T10-00-00Z.transcript.txt"),
|
||||
" \n ",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
seed(dir.path(), "2026-04-16T10-00-00Z", Some(" \n ")).await;
|
||||
assert!(all_transcripts_joined(dir.path()).await.is_none());
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user