diff --git a/doctate-common/src/lib.rs b/doctate-common/src/lib.rs index d3307fa..5c840f4 100644 --- a/doctate-common/src/lib.rs +++ b/doctate-common/src/lib.rs @@ -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}; diff --git a/doctate-common/src/recordings.rs b/doctate-common/src/recordings.rs new file mode 100644 index 0000000..5522f65 --- /dev/null +++ b/doctate-common/src/recordings.rs @@ -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`, +//! `.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")); + } +} diff --git a/server/src/analyze/auto_trigger.rs b/server/src/analyze/auto_trigger.rs index 84c81f3..14dc42c 100644 --- a/server/src/analyze/auto_trigger.rs +++ b/server/src/analyze/auto_trigger.rs @@ -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; } } diff --git a/server/src/paths.rs b/server/src/paths.rs index 85a50fd..7a22468 100644 --- a/server/src/paths.rs +++ b/server/src/paths.rs @@ -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()) +} diff --git a/server/src/routes/case_actions.rs b/server/src/routes/case_actions.rs index 9316531..5aa17c1 100644 --- a/server/src/routes/case_actions.rs +++ b/server/src/routes/case_actions.rs @@ -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, AppError> { let mut recordings: Vec = 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 { diff --git a/server/src/routes/user_web.rs b/server/src/routes/user_web.rs index 373ef55..c8de913 100644 --- a/server/src/routes/user_web.rs +++ b/server/src/routes/user_web.rs @@ -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 { diff --git a/server/src/routes/web.rs b/server/src/routes/web.rs index 5e44995..810de9a 100644 --- a/server/src/routes/web.rs +++ b/server/src/routes/web.rs @@ -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, - pub(crate) transcript: Option, + /// 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 `.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, + transcript: TranscriptState, duration_seconds: Option, audio_path: PathBuf, sidecar_path: PathBuf, @@ -189,8 +197,7 @@ pub(crate) async fn scan_recordings(case_dir: &Path) -> Vec { // 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); } diff --git a/server/src/transcribe/recovery.rs b/server/src/transcribe/recovery.rs index dd33480..96a0854 100644 --- a/server/src/transcribe/recovery.rs +++ b/server/src/transcribe/recovery.rs @@ -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 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 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()); } diff --git a/server/src/transcribe/worker.rs b/server/src/transcribe/worker.rs index c15ea57..bd07d88 100644 --- a/server/src/transcribe/worker.rs +++ b/server/src/transcribe/worker.rs @@ -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 { let mut entries = tokio::fs::read_dir(case_dir).await.ok()?; - let mut files: Vec = Vec::new(); + let mut m4as: Vec = 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 = Vec::with_capacity(files.len()); - for path in files { - if let Ok(text) = tokio::fs::read_to_string(&path).await { + let mut parts: Vec = 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()); } diff --git a/server/templates/case_recordings.html b/server/templates/case_recordings.html index 09ab27e..b62a2f0 100644 --- a/server/templates/case_recordings.html +++ b/server/templates/case_recordings.html @@ -117,13 +117,11 @@ try {
Transkription fehlgeschlagen — .failed-Suffix entfernen zum Erneut-Versuchen.
{% else %} {% match rec.transcript %} -{% when Some with (t) %} -{% if t.is_empty() %} -
(stille Aufnahme)
-{% else %} +{% when TranscriptState::Content with (t) %}
{{ t }}
-{% endif %} -{% when None %} +{% when TranscriptState::Silent %} +
(stille Aufnahme)
+{% when TranscriptState::Pending %} {% if transcribe_busy %}
Transkription läuft…
{% else %} diff --git a/server/tests/case_page_test.rs b/server/tests/case_page_test.rs index 3e873bf..a4736c8 100644 --- a/server/tests/case_page_test.rs +++ b/server/tests/case_page_test.rs @@ -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 diff --git a/server/tests/oneliner_heal_decoupled_test.rs b/server/tests/oneliner_heal_decoupled_test.rs index 90eefe1..f8c541b 100644 --- a/server/tests/oneliner_heal_decoupled_test.rs +++ b/server/tests/oneliner_heal_decoupled_test.rs @@ -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] diff --git a/server/tests/silent_case_empty_test.rs b/server/tests/silent_case_empty_test.rs new file mode 100644 index 0000000..5777d95 --- /dev/null +++ b/server/tests/silent_case_empty_test.rs @@ -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. +}