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
+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())
}