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:
2026-04-21 13:37:51 +02:00
parent 3d42d1da82
commit 4531f85b13
13 changed files with 520 additions and 99 deletions
+53 -18
View File
@@ -8,6 +8,7 @@ use crate::config::Config;
use crate::events::EventSender;
use crate::gazetteer::Gazetteer;
use crate::paths;
/// Walk `data_path/*/` and enqueue every recording pending transcription for
/// every user. Intended for startup; the same primitive backs the per-user
/// self-heal triggered by web handlers.
@@ -57,7 +58,10 @@ pub async fn enqueue_pending_for_user(
if path.extension().and_then(|s| s.to_str()) != Some("m4a") {
continue;
}
if path.with_extension("transcript.txt").exists() {
// Silent recordings (empty transcript on disk) are terminal —
// the transcriber already ran. Only true `Pending` recordings
// (no sidecar at all) need enqueuing.
if !paths::read_transcript_state(&path).await.is_pending() {
continue;
}
pending.push(path);
@@ -84,11 +88,16 @@ pub async fn enqueue_pending_for_user(
sent
}
/// Walk `data_path/*/*` and return every case directory that has at least
/// one non-empty `*.transcript.txt` and whose persisted oneliner state
/// warrants (re-)generation, paired with the user-slug (parent directory
/// name). Pure filesystem scan, no LLM calls — separated from the
/// regeneration wrapper so it can be unit-tested without mocking Ollama.
/// Walk `data_path/*/*` and return every case directory that has at
/// least one finished transcript (content or silent) and whose persisted
/// oneliner state warrants (re-)generation, paired with the user-slug
/// (parent directory name). Pure filesystem scan, no LLM calls —
/// separated from the regeneration wrapper so it can be unit-tested
/// without mocking Ollama.
///
/// Silent-only cases are intentionally included: `update_oneliner` then
/// settles them to [`OnelinerState::Empty`] instead of leaving the UI
/// stuck on "Generating" forever.
///
/// Retry policy by persisted state:
/// - missing file → retry (first generation, or crash before write)
@@ -139,7 +148,7 @@ pub(crate) async fn cases_needing_oneliner_in(user_root: &Path) -> Vec<PathBuf>
if !needs_retry {
continue;
}
if has_non_empty_transcript(&case_dir).await {
if has_any_transcript(&case_dir).await {
out.push(case_dir);
}
}
@@ -147,20 +156,24 @@ pub(crate) async fn cases_needing_oneliner_in(user_root: &Path) -> Vec<PathBuf>
out
}
async fn has_non_empty_transcript(case_dir: &Path) -> bool {
/// True iff at least one `.transcript.txt` sidecar exists in `case_dir`
/// — i.e., at least one recording is either [`TranscriptState::Silent`]
/// or [`TranscriptState::Content`]. Cases where every recording is
/// still `Pending` return false: there's nothing for the oneliner
/// worker to act on yet.
///
/// Silent transcripts count here because `update_oneliner` needs to
/// settle them to `OnelinerState::Empty`. The previous implementation
/// required *content* specifically, which left silent-only cases stuck
/// with no oneliner state — and the UI stuck on "Generating".
async fn has_any_transcript(case_dir: &Path) -> bool {
let Ok(mut files) = tokio::fs::read_dir(case_dir).await else {
return false;
};
while let Ok(Some(f)) = files.next_entry().await {
let path = f.path();
let Some(name) = path.file_name().and_then(|s| s.to_str()) else {
continue;
};
if !name.ends_with(".transcript.txt") {
continue;
}
if let Ok(text) = tokio::fs::read_to_string(&path).await
&& !text.trim().is_empty()
if f.file_name()
.to_str()
.is_some_and(|s| s.ends_with(".transcript.txt"))
{
return true;
}
@@ -295,8 +308,13 @@ mod tests {
assert_eq!(got, vec![(case, "user".to_owned())]);
}
/// Silent-only cases (transcript file exists but is empty/whitespace)
/// are now picked up by recovery so `update_oneliner` can settle
/// them to [`OnelinerState::Empty`]. Previously they were skipped,
/// which left the UI stuck on "Generating" forever (the bug this
/// enum refactor fixes).
#[tokio::test]
async fn cases_needing_oneliner_skips_cases_with_only_empty_transcripts() {
async fn cases_needing_oneliner_picks_up_silent_only_cases_for_empty_settlement() {
let data = tempdir().unwrap();
let case = data.path().join("user").join("case1");
tokio::fs::create_dir_all(&case).await.unwrap();
@@ -304,6 +322,23 @@ mod tests {
.await
.unwrap();
let got = cases_needing_oneliner(data.path()).await;
assert_eq!(got, vec![(case, "user".to_owned())]);
}
/// Fully-pending cases (no transcript file yet) are skipped —
/// `update_oneliner` has nothing to act on until the transcriber
/// writes a sidecar.
#[tokio::test]
async fn cases_needing_oneliner_skips_fully_pending_cases() {
let data = tempdir().unwrap();
let case = data.path().join("user").join("case1");
tokio::fs::create_dir_all(&case).await.unwrap();
tokio::fs::write(case.join("2026-04-16T10-00-00Z.m4a"), b"audio")
.await
.unwrap();
// No .transcript.txt — the recording is still Pending.
assert!(cases_needing_oneliner(data.path()).await.is_empty());
}
+106 -48
View File
@@ -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());
}