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"));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
Reference in New Issue
Block a user