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"));
}
}