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
+2
View File
@@ -1,6 +1,7 @@
pub mod ack;
pub mod constants;
pub mod oneliners;
pub mod recordings;
pub mod timestamp;
pub use ack::{AckResponse, AckStatus};
@@ -9,4 +10,5 @@ pub use constants::{
UPLOAD_PATH,
};
pub use oneliners::{ONELINERS_PATH, OnelinerEntry, OnelinersResponse};
pub use recordings::TranscriptState;
pub use timestamp::{filename_stem_to_recorded_at, now_rfc3339, recorded_at_to_filename_stem};
+135
View File
@@ -0,0 +1,135 @@
//! Recording-side domain types shared between server and clients.
//!
//! The recording lifecycle on disk has three disjoint states for the
//! `.transcript.txt` sidecar, which is easy to conflate because the
//! filesystem collapses two of them onto "file exists":
//!
//! 1. `Pending` — no file yet, the transcriber hasn't run (or hasn't
//! finished) for this recording.
//! 2. `Silent` — file exists but is empty / whitespace-only. The
//! transcriber classified the audio as silence. Terminal: the next
//! transcribe pass will not overwrite it unless the user re-uploads.
//! 3. `Content` — file exists with real text.
//!
//! Call-sites that collapse this onto a 2-state view (`Option<String>`,
//! `.exists()`, `is_empty()`) drift apart and have caused real bugs:
//! e.g. the UI treating `Silent` like `Content` while the oneliner
//! worker treated it like `Pending`, leaving cases stuck in
//! "Generating" forever.
//!
//! `TranscriptState` is the canonical representation. It is parse-only
//! (no I/O) so this crate stays dependency-free; the server does the
//! actual file read via `server::paths::read_transcript_state`.
/// Three-way state of a recording's `.transcript.txt` sidecar.
///
/// Introduced to force every call-site to decide explicitly how it
/// treats `Silent` recordings via `match` exhaustiveness.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TranscriptState {
/// No `.transcript.txt` file yet — transcriber hasn't produced
/// a result. Callers should typically wait or enqueue.
Pending,
/// File exists but is empty or whitespace-only. Terminal state:
/// the transcriber already ran and classified this recording as
/// silence. Treat as "transcribed, no medical content".
Silent,
/// File exists with non-empty content.
Content(String),
}
impl TranscriptState {
/// Build a `TranscriptState` from raw read-result bytes.
///
/// - `None` (file missing / read error) → [`Self::Pending`].
/// - `Some(s)` with `s.trim().is_empty()` → [`Self::Silent`].
/// - `Some(s)` otherwise → [`Self::Content`] (stores the full,
/// untrimmed content — downstream joiners may want the original
/// whitespace around the text).
pub fn from_raw(raw: Option<&str>) -> Self {
match raw {
None => Self::Pending,
Some(s) if s.trim().is_empty() => Self::Silent,
Some(s) => Self::Content(s.to_owned()),
}
}
/// True when the transcriber has finished for this recording,
/// regardless of whether it produced content or silence. Use this
/// for UI "is the transcription done?" checks — `Silent` counts.
pub fn has_file(&self) -> bool {
!matches!(self, Self::Pending)
}
/// True when there is no file yet and the transcriber still owes
/// us a result. Use this for "should I enqueue transcription?" or
/// "is a oneliner worth deferring?" gates.
pub fn is_pending(&self) -> bool {
matches!(self, Self::Pending)
}
/// Non-empty transcript text, if any. Returns `None` for both
/// `Pending` and `Silent` — callers that need to distinguish must
/// `match` directly.
pub fn as_content(&self) -> Option<&str> {
match self {
Self::Content(s) => Some(s.as_str()),
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn missing_file_is_pending() {
assert_eq!(TranscriptState::from_raw(None), TranscriptState::Pending);
}
#[test]
fn empty_string_is_silent() {
assert_eq!(TranscriptState::from_raw(Some("")), TranscriptState::Silent);
}
#[test]
fn whitespace_only_is_silent() {
assert_eq!(
TranscriptState::from_raw(Some(" \n\t \n")),
TranscriptState::Silent
);
}
#[test]
fn text_is_content() {
assert_eq!(
TranscriptState::from_raw(Some("Hallo")),
TranscriptState::Content("Hallo".to_owned())
);
}
#[test]
fn text_with_trailing_whitespace_preserved() {
// Downstream join logic may rely on the original bytes;
// from_raw keeps them verbatim.
assert_eq!(
TranscriptState::from_raw(Some("Hallo Welt.\n")),
TranscriptState::Content("Hallo Welt.\n".to_owned())
);
}
#[test]
fn has_file_distinguishes_pending() {
assert!(!TranscriptState::Pending.has_file());
assert!(TranscriptState::Silent.has_file());
assert!(TranscriptState::Content("x".into()).has_file());
}
#[test]
fn as_content_only_for_content() {
assert_eq!(TranscriptState::Pending.as_content(), None);
assert_eq!(TranscriptState::Silent.as_content(), None);
assert_eq!(TranscriptState::Content("x".into()).as_content(), Some("x"));
}
}
+9 -2
View File
@@ -21,6 +21,7 @@ use tracing::warn;
use super::{ANALYSIS_INPUT_FILE, AnalysisInput, AnalyzeJob, AnalyzeSender, DOCUMENT_FILE};
use crate::config::Config;
use crate::events::{self, CaseEventKind, EventSender};
use crate::paths;
use crate::routes::case_actions::build_analysis_input;
/// Filename of the per-case failure marker. Written by the worker on
@@ -253,8 +254,14 @@ async fn scan_m4as(case_dir: &Path) -> M4aScan {
});
}
let transcript = entry.path().with_extension("transcript.txt");
if !tokio::fs::try_exists(&transcript).await.unwrap_or(false) {
// Analyze gate: "all transcribed" means every m4a has passed
// through the transcriber, regardless of whether that produced
// `Content` or `Silent` output. Only `Pending` (no sidecar)
// holds the trigger back.
if paths::read_transcript_state(&entry.path())
.await
.is_pending()
{
scan.all_transcribed = false;
}
}
+18
View File
@@ -1,5 +1,6 @@
use std::path::{Path, PathBuf};
use doctate_common::TranscriptState;
use doctate_common::oneliners::{ONELINER_FILENAME, OnelinerState};
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
@@ -81,3 +82,20 @@ pub async fn read_oneliner_state(
.map(OffsetDateTime::from);
(Some(state), mtime)
}
/// Load the `.transcript.txt` sidecar for a recording as a
/// [`TranscriptState`].
///
/// `audio_stem_path` is the non-`.failed` stem path of the recording
/// (e.g. `case_dir/2026-04-16T16-23-38Z.m4a`) — the sidecar sits next
/// to it with extension `.transcript.txt`.
///
/// This is the single reader for that sidecar. Every call-site that
/// used to do `tokio::fs::read_to_string(...).ok()` or
/// `with_extension("transcript.txt").exists()` should go through here
/// so the three-state semantics stay consistent across the codebase.
pub async fn read_transcript_state(audio_stem_path: &Path) -> TranscriptState {
let transcript_path = audio_stem_path.with_extension("transcript.txt");
let raw = tokio::fs::read_to_string(&transcript_path).await.ok();
TranscriptState::from_raw(raw.as_deref())
}
+20 -14
View File
@@ -11,6 +11,7 @@ use time::format_description::well_known::Rfc3339;
use tokio::io::AsyncWriteExt;
use tracing::{info, warn};
use doctate_common::TranscriptState;
use doctate_common::oneliners::ONELINER_FILENAME;
use doctate_common::timestamp::{filename_stem_to_recorded_at, now_rfc3339};
@@ -22,7 +23,9 @@ use crate::case_id::CaseIdPath;
use crate::config::Config;
use crate::error::AppError;
use crate::events::{self, CaseEventKind, EventSender};
use crate::paths::{CLOSE_MARKER, CloseMarker, read_close_marker, write_close_marker};
use crate::paths::{
CLOSE_MARKER, CloseMarker, read_close_marker, read_transcript_state, write_close_marker,
};
use crate::routes::user_web::locate_case_or_404;
use crate::routes::web::validate_filename;
@@ -185,22 +188,25 @@ fn compute_last_mtime(m4as: &[(PathBuf, SystemTime)]) -> SystemTime {
.unwrap_or(SystemTime::UNIX_EPOCH)
}
/// Read the `.transcript.txt` sidecar for every m4a. Any missing
/// transcript aborts with the legacy 400 "Nicht alle Aufnahmen sind
/// transkribiert" body. Blank transcripts are silently skipped — they
/// represent recordings the transcriber classified as silence and must
/// not be pushed to the LLM.
/// Build the analyze-worker input from each m4a's three-way transcript
/// state. `Pending` (no sidecar yet) is the only fatal case — the
/// analyze endpoint requires a settled batch — and surfaces as the
/// legacy 400 "Nicht alle Aufnahmen sind transkribiert" body. `Silent`
/// recordings are silently skipped: the transcriber deliberately
/// produced no text, so there's nothing to feed the LLM. Only
/// `Content` transcripts contribute to the input.
async fn read_recordings(m4as: &[(PathBuf, SystemTime)]) -> Result<Vec<RecordingInput>, AppError> {
let mut recordings: Vec<RecordingInput> = Vec::new();
for (m4a_path, _) in m4as {
let transcript_path = m4a_path.with_extension("transcript.txt");
let text = tokio::fs::read_to_string(&transcript_path)
.await
.map_err(|_| AppError::BadRequest("Nicht alle Aufnahmen sind transkribiert".into()))?;
if text.trim().is_empty() {
continue;
}
let text = match read_transcript_state(m4a_path).await {
TranscriptState::Pending => {
return Err(AppError::BadRequest(
"Nicht alle Aufnahmen sind transkribiert".into(),
));
}
TranscriptState::Silent => continue,
TranscriptState::Content(t) => t,
};
let stem = m4a_path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
recordings.push(RecordingInput {
+8 -4
View File
@@ -5,6 +5,7 @@ use std::sync::atomic::Ordering;
use askama::Template;
use axum::extract::{Query, State};
use axum::response::Html;
use doctate_common::TranscriptState;
use doctate_common::oneliners::OnelinerState;
use serde::Deserialize;
use tracing::{info, warn};
@@ -258,7 +259,7 @@ async fn compute_flags(
let non_failed: Vec<&RecordingView> = recordings.iter().filter(|r| !r.failed).collect();
let all_transcribed =
!non_failed.is_empty() && non_failed.iter().all(|r| r.transcript.is_some());
!non_failed.is_empty() && non_failed.iter().all(|r| r.transcript.has_file());
let analyzable = !analyzing && all_transcribed;
@@ -486,7 +487,10 @@ pub async fn handle_case_page(
let flags = compute_flags(&case_dir, &recordings, config.llm_configured(), a_busy).await;
let recordings_count = recordings.len();
let transcribed_count = recordings.iter().filter(|r| r.transcript.is_some()).count();
let transcribed_count = recordings
.iter()
.filter(|r| r.transcript.has_file())
.count();
let case_id_short = case_id_str.chars().take(8).collect();
let is_admin = user.is_admin();
@@ -732,7 +736,7 @@ async fn compute_case_view(
&& recordings
.iter()
.filter(|r| !r.failed)
.all(|r| r.transcript.is_some());
.all(|r| r.transcript.has_file());
let oneliner = compute_oneliner_display(case_path, &recordings).await;
@@ -809,7 +813,7 @@ async fn compute_oneliner_display(
&& recordings
.iter()
.filter(|r| !r.failed)
.all(|r| r.transcript.is_some());
.all(|r| r.transcript.has_file());
let (state, _) = paths::read_oneliner_state(case_dir).await;
if non_failed_count == 0 {
+12 -5
View File
@@ -6,6 +6,7 @@ use axum::body::Body;
use axum::extract::{Path as AxumPath, State};
use axum::http::{HeaderMap, StatusCode, header};
use axum::response::Response;
use doctate_common::TranscriptState;
use doctate_common::timestamp::filename_stem_to_recorded_at;
use tokio::io::{AsyncReadExt, AsyncSeekExt};
use tokio::task::JoinSet;
@@ -13,6 +14,7 @@ use tracing::debug;
use crate::config::Config;
use crate::error::AppError;
use crate::paths;
use crate::transcribe::ffmpeg;
pub(crate) struct RecordingView {
@@ -24,7 +26,13 @@ pub(crate) struct RecordingView {
/// sidecar. `None` means neither the worker nor the lazy-backfill path
/// produced one — the UI then falls back to browser `preload="metadata"`.
pub(crate) duration_seconds: Option<u32>,
pub(crate) transcript: Option<String>,
/// Three-way transcript state — `Pending` (not yet transcribed),
/// `Silent` (transcriber classified as silence), or `Content(text)`.
/// Consumers must not collapse `Silent` and `Content` onto the same
/// "is there text?" branch — use [`TranscriptState::has_file`] for
/// "transcription finished" checks and [`TranscriptState::as_content`]
/// for actual text lookup.
pub(crate) transcript: TranscriptState,
/// True if the file has been renamed to `<ts>.m4a.failed` by the worker
/// after a non-recoverable ffmpeg or whisper error.
pub(crate) failed: bool,
@@ -162,7 +170,7 @@ fn stem_of(filename: &str) -> &str {
struct RawRecording {
filename: String,
failed: bool,
transcript: Option<String>,
transcript: TranscriptState,
duration_seconds: Option<u32>,
audio_path: PathBuf,
sidecar_path: PathBuf,
@@ -189,8 +197,7 @@ pub(crate) async fn scan_recordings(case_dir: &Path) -> Vec<RecordingView> {
// a `.failed` file never produced a transcript but the lookup still works.
let stem_file = filename.trim_end_matches(".failed");
let stem_path = case_dir.join(stem_file);
let transcript_path = stem_path.with_extension("transcript.txt");
let transcript = tokio::fs::read_to_string(&transcript_path).await.ok();
let transcript = paths::read_transcript_state(&stem_path).await;
let sidecar_path = stem_path.with_extension("duration.txt");
let duration_seconds = tokio::fs::read_to_string(&sidecar_path)
@@ -279,7 +286,7 @@ mod tests {
assert_eq!(got[0].filename, "2026-04-19T10-00-00Z.m4a");
assert_eq!(got[0].recorded_at_iso, "2026-04-19T10:00:00Z");
assert_eq!(got[0].duration_seconds, Some(42));
assert_eq!(got[0].transcript.as_deref(), Some("hallo"));
assert_eq!(got[0].transcript.as_content(), Some("hallo"));
assert!(!got[0].failed);
}
+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());
}
+4 -6
View File
@@ -117,13 +117,11 @@ try {
<div class="failed">Transkription fehlgeschlagen — .failed-Suffix entfernen zum Erneut-Versuchen.</div>
{% else %}
{% match rec.transcript %}
{% when Some with (t) %}
{% if t.is_empty() %}
<div class="pending">(stille Aufnahme)</div>
{% else %}
{% when TranscriptState::Content with (t) %}
<div class="transcript">{{ t }}</div>
{% endif %}
{% when None %}
{% when TranscriptState::Silent %}
<div class="pending">(stille Aufnahme)</div>
{% when TranscriptState::Pending %}
{% if transcribe_busy %}
<div class="pending">Transkription läuft…</div>
{% else %}
+39
View File
@@ -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
+11 -2
View File
@@ -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]
+103
View File
@@ -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.
}