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:
@@ -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};
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user