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 ack;
|
||||||
pub mod constants;
|
pub mod constants;
|
||||||
pub mod oneliners;
|
pub mod oneliners;
|
||||||
|
pub mod recordings;
|
||||||
pub mod timestamp;
|
pub mod timestamp;
|
||||||
|
|
||||||
pub use ack::{AckResponse, AckStatus};
|
pub use ack::{AckResponse, AckStatus};
|
||||||
@@ -9,4 +10,5 @@ pub use constants::{
|
|||||||
UPLOAD_PATH,
|
UPLOAD_PATH,
|
||||||
};
|
};
|
||||||
pub use oneliners::{ONELINERS_PATH, OnelinerEntry, OnelinersResponse};
|
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};
|
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 super::{ANALYSIS_INPUT_FILE, AnalysisInput, AnalyzeJob, AnalyzeSender, DOCUMENT_FILE};
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
use crate::events::{self, CaseEventKind, EventSender};
|
use crate::events::{self, CaseEventKind, EventSender};
|
||||||
|
use crate::paths;
|
||||||
use crate::routes::case_actions::build_analysis_input;
|
use crate::routes::case_actions::build_analysis_input;
|
||||||
|
|
||||||
/// Filename of the per-case failure marker. Written by the worker on
|
/// 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");
|
// Analyze gate: "all transcribed" means every m4a has passed
|
||||||
if !tokio::fs::try_exists(&transcript).await.unwrap_or(false) {
|
// 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;
|
scan.all_transcribed = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use doctate_common::TranscriptState;
|
||||||
use doctate_common::oneliners::{ONELINER_FILENAME, OnelinerState};
|
use doctate_common::oneliners::{ONELINER_FILENAME, OnelinerState};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use time::OffsetDateTime;
|
use time::OffsetDateTime;
|
||||||
@@ -81,3 +82,20 @@ pub async fn read_oneliner_state(
|
|||||||
.map(OffsetDateTime::from);
|
.map(OffsetDateTime::from);
|
||||||
(Some(state), mtime)
|
(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 tokio::io::AsyncWriteExt;
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
use doctate_common::TranscriptState;
|
||||||
use doctate_common::oneliners::ONELINER_FILENAME;
|
use doctate_common::oneliners::ONELINER_FILENAME;
|
||||||
use doctate_common::timestamp::{filename_stem_to_recorded_at, now_rfc3339};
|
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::config::Config;
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::events::{self, CaseEventKind, EventSender};
|
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::user_web::locate_case_or_404;
|
||||||
use crate::routes::web::validate_filename;
|
use crate::routes::web::validate_filename;
|
||||||
|
|
||||||
@@ -185,22 +188,25 @@ fn compute_last_mtime(m4as: &[(PathBuf, SystemTime)]) -> SystemTime {
|
|||||||
.unwrap_or(SystemTime::UNIX_EPOCH)
|
.unwrap_or(SystemTime::UNIX_EPOCH)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read the `.transcript.txt` sidecar for every m4a. Any missing
|
/// Build the analyze-worker input from each m4a's three-way transcript
|
||||||
/// transcript aborts with the legacy 400 "Nicht alle Aufnahmen sind
|
/// state. `Pending` (no sidecar yet) is the only fatal case — the
|
||||||
/// transkribiert" body. Blank transcripts are silently skipped — they
|
/// analyze endpoint requires a settled batch — and surfaces as the
|
||||||
/// represent recordings the transcriber classified as silence and must
|
/// legacy 400 "Nicht alle Aufnahmen sind transkribiert" body. `Silent`
|
||||||
/// not be pushed to the LLM.
|
/// 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> {
|
async fn read_recordings(m4as: &[(PathBuf, SystemTime)]) -> Result<Vec<RecordingInput>, AppError> {
|
||||||
let mut recordings: Vec<RecordingInput> = Vec::new();
|
let mut recordings: Vec<RecordingInput> = Vec::new();
|
||||||
for (m4a_path, _) in m4as {
|
for (m4a_path, _) in m4as {
|
||||||
let transcript_path = m4a_path.with_extension("transcript.txt");
|
let text = match read_transcript_state(m4a_path).await {
|
||||||
let text = tokio::fs::read_to_string(&transcript_path)
|
TranscriptState::Pending => {
|
||||||
.await
|
return Err(AppError::BadRequest(
|
||||||
.map_err(|_| AppError::BadRequest("Nicht alle Aufnahmen sind transkribiert".into()))?;
|
"Nicht alle Aufnahmen sind transkribiert".into(),
|
||||||
|
));
|
||||||
if text.trim().is_empty() {
|
}
|
||||||
continue;
|
TranscriptState::Silent => continue,
|
||||||
}
|
TranscriptState::Content(t) => t,
|
||||||
|
};
|
||||||
|
|
||||||
let stem = m4a_path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
|
let stem = m4a_path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
|
||||||
recordings.push(RecordingInput {
|
recordings.push(RecordingInput {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ use std::sync::atomic::Ordering;
|
|||||||
use askama::Template;
|
use askama::Template;
|
||||||
use axum::extract::{Query, State};
|
use axum::extract::{Query, State};
|
||||||
use axum::response::Html;
|
use axum::response::Html;
|
||||||
|
use doctate_common::TranscriptState;
|
||||||
use doctate_common::oneliners::OnelinerState;
|
use doctate_common::oneliners::OnelinerState;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use tracing::{info, warn};
|
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 non_failed: Vec<&RecordingView> = recordings.iter().filter(|r| !r.failed).collect();
|
||||||
let all_transcribed =
|
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;
|
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 flags = compute_flags(&case_dir, &recordings, config.llm_configured(), a_busy).await;
|
||||||
|
|
||||||
let recordings_count = recordings.len();
|
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 case_id_short = case_id_str.chars().take(8).collect();
|
||||||
let is_admin = user.is_admin();
|
let is_admin = user.is_admin();
|
||||||
|
|
||||||
@@ -732,7 +736,7 @@ async fn compute_case_view(
|
|||||||
&& recordings
|
&& recordings
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|r| !r.failed)
|
.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;
|
let oneliner = compute_oneliner_display(case_path, &recordings).await;
|
||||||
|
|
||||||
@@ -809,7 +813,7 @@ async fn compute_oneliner_display(
|
|||||||
&& recordings
|
&& recordings
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|r| !r.failed)
|
.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;
|
let (state, _) = paths::read_oneliner_state(case_dir).await;
|
||||||
|
|
||||||
if non_failed_count == 0 {
|
if non_failed_count == 0 {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use axum::body::Body;
|
|||||||
use axum::extract::{Path as AxumPath, State};
|
use axum::extract::{Path as AxumPath, State};
|
||||||
use axum::http::{HeaderMap, StatusCode, header};
|
use axum::http::{HeaderMap, StatusCode, header};
|
||||||
use axum::response::Response;
|
use axum::response::Response;
|
||||||
|
use doctate_common::TranscriptState;
|
||||||
use doctate_common::timestamp::filename_stem_to_recorded_at;
|
use doctate_common::timestamp::filename_stem_to_recorded_at;
|
||||||
use tokio::io::{AsyncReadExt, AsyncSeekExt};
|
use tokio::io::{AsyncReadExt, AsyncSeekExt};
|
||||||
use tokio::task::JoinSet;
|
use tokio::task::JoinSet;
|
||||||
@@ -13,6 +14,7 @@ use tracing::debug;
|
|||||||
|
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
|
use crate::paths;
|
||||||
use crate::transcribe::ffmpeg;
|
use crate::transcribe::ffmpeg;
|
||||||
|
|
||||||
pub(crate) struct RecordingView {
|
pub(crate) struct RecordingView {
|
||||||
@@ -24,7 +26,13 @@ pub(crate) struct RecordingView {
|
|||||||
/// sidecar. `None` means neither the worker nor the lazy-backfill path
|
/// sidecar. `None` means neither the worker nor the lazy-backfill path
|
||||||
/// produced one — the UI then falls back to browser `preload="metadata"`.
|
/// produced one — the UI then falls back to browser `preload="metadata"`.
|
||||||
pub(crate) duration_seconds: Option<u32>,
|
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
|
/// True if the file has been renamed to `<ts>.m4a.failed` by the worker
|
||||||
/// after a non-recoverable ffmpeg or whisper error.
|
/// after a non-recoverable ffmpeg or whisper error.
|
||||||
pub(crate) failed: bool,
|
pub(crate) failed: bool,
|
||||||
@@ -162,7 +170,7 @@ fn stem_of(filename: &str) -> &str {
|
|||||||
struct RawRecording {
|
struct RawRecording {
|
||||||
filename: String,
|
filename: String,
|
||||||
failed: bool,
|
failed: bool,
|
||||||
transcript: Option<String>,
|
transcript: TranscriptState,
|
||||||
duration_seconds: Option<u32>,
|
duration_seconds: Option<u32>,
|
||||||
audio_path: PathBuf,
|
audio_path: PathBuf,
|
||||||
sidecar_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.
|
// a `.failed` file never produced a transcript but the lookup still works.
|
||||||
let stem_file = filename.trim_end_matches(".failed");
|
let stem_file = filename.trim_end_matches(".failed");
|
||||||
let stem_path = case_dir.join(stem_file);
|
let stem_path = case_dir.join(stem_file);
|
||||||
let transcript_path = stem_path.with_extension("transcript.txt");
|
let transcript = paths::read_transcript_state(&stem_path).await;
|
||||||
let transcript = tokio::fs::read_to_string(&transcript_path).await.ok();
|
|
||||||
|
|
||||||
let sidecar_path = stem_path.with_extension("duration.txt");
|
let sidecar_path = stem_path.with_extension("duration.txt");
|
||||||
let duration_seconds = tokio::fs::read_to_string(&sidecar_path)
|
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].filename, "2026-04-19T10-00-00Z.m4a");
|
||||||
assert_eq!(got[0].recorded_at_iso, "2026-04-19T10:00:00Z");
|
assert_eq!(got[0].recorded_at_iso, "2026-04-19T10:00:00Z");
|
||||||
assert_eq!(got[0].duration_seconds, Some(42));
|
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);
|
assert!(!got[0].failed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use crate::config::Config;
|
|||||||
use crate::events::EventSender;
|
use crate::events::EventSender;
|
||||||
use crate::gazetteer::Gazetteer;
|
use crate::gazetteer::Gazetteer;
|
||||||
use crate::paths;
|
use crate::paths;
|
||||||
|
|
||||||
/// Walk `data_path/*/` and enqueue every recording pending transcription for
|
/// Walk `data_path/*/` and enqueue every recording pending transcription for
|
||||||
/// every user. Intended for startup; the same primitive backs the per-user
|
/// every user. Intended for startup; the same primitive backs the per-user
|
||||||
/// self-heal triggered by web handlers.
|
/// 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") {
|
if path.extension().and_then(|s| s.to_str()) != Some("m4a") {
|
||||||
continue;
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
pending.push(path);
|
pending.push(path);
|
||||||
@@ -84,11 +88,16 @@ pub async fn enqueue_pending_for_user(
|
|||||||
sent
|
sent
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Walk `data_path/*/*` and return every case directory that has at least
|
/// Walk `data_path/*/*` and return every case directory that has at
|
||||||
/// one non-empty `*.transcript.txt` and whose persisted oneliner state
|
/// least one finished transcript (content or silent) and whose persisted
|
||||||
/// warrants (re-)generation, paired with the user-slug (parent directory
|
/// oneliner state warrants (re-)generation, paired with the user-slug
|
||||||
/// name). Pure filesystem scan, no LLM calls — separated from the
|
/// (parent directory name). Pure filesystem scan, no LLM calls —
|
||||||
/// regeneration wrapper so it can be unit-tested without mocking Ollama.
|
/// 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:
|
/// Retry policy by persisted state:
|
||||||
/// - missing file → retry (first generation, or crash before write)
|
/// - 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 {
|
if !needs_retry {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if has_non_empty_transcript(&case_dir).await {
|
if has_any_transcript(&case_dir).await {
|
||||||
out.push(case_dir);
|
out.push(case_dir);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -147,20 +156,24 @@ pub(crate) async fn cases_needing_oneliner_in(user_root: &Path) -> Vec<PathBuf>
|
|||||||
out
|
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 {
|
let Ok(mut files) = tokio::fs::read_dir(case_dir).await else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
while let Ok(Some(f)) = files.next_entry().await {
|
while let Ok(Some(f)) = files.next_entry().await {
|
||||||
let path = f.path();
|
if f.file_name()
|
||||||
let Some(name) = path.file_name().and_then(|s| s.to_str()) else {
|
.to_str()
|
||||||
continue;
|
.is_some_and(|s| s.ends_with(".transcript.txt"))
|
||||||
};
|
|
||||||
if !name.ends_with(".transcript.txt") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if let Ok(text) = tokio::fs::read_to_string(&path).await
|
|
||||||
&& !text.trim().is_empty()
|
|
||||||
{
|
{
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -295,8 +308,13 @@ mod tests {
|
|||||||
assert_eq!(got, vec![(case, "user".to_owned())]);
|
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]
|
#[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 data = tempdir().unwrap();
|
||||||
let case = data.path().join("user").join("case1");
|
let case = data.path().join("user").join("case1");
|
||||||
tokio::fs::create_dir_all(&case).await.unwrap();
|
tokio::fs::create_dir_all(&case).await.unwrap();
|
||||||
@@ -304,6 +322,23 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.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());
|
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::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use doctate_common::TranscriptState;
|
||||||
use doctate_common::oneliners::{ONELINER_FILENAME, OnelinerState};
|
use doctate_common::oneliners::{ONELINER_FILENAME, OnelinerState};
|
||||||
use time::OffsetDateTime;
|
use time::OffsetDateTime;
|
||||||
use time::format_description::well_known::Rfc3339;
|
use time::format_description::well_known::Rfc3339;
|
||||||
@@ -11,6 +12,7 @@ use super::{TranscribeReceiver, ffmpeg, ollama, whisper};
|
|||||||
use crate::config::{Config, WhisperUserSettings};
|
use crate::config::{Config, WhisperUserSettings};
|
||||||
use crate::events::{self, CaseEventKind, EventSender};
|
use crate::events::{self, CaseEventKind, EventSender};
|
||||||
use crate::gazetteer::Gazetteer;
|
use crate::gazetteer::Gazetteer;
|
||||||
|
use crate::paths;
|
||||||
use crate::{BusyGuard, WorkerBusy};
|
use crate::{BusyGuard, WorkerBusy};
|
||||||
|
|
||||||
const ONELINER_TIMEOUT: Duration = Duration::from_secs(60);
|
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
|
/// in the case, joined chronologically. Called after every successful
|
||||||
/// transcript write so later recordings can correct earlier ones (mirror
|
/// transcript write so later recordings can correct earlier ones (mirror
|
||||||
/// of the analyze-LLM rule "later recordings override"). Every outcome —
|
/// of the analyze-LLM rule "later recordings override"). Every outcome —
|
||||||
/// generated text, deliberate empty ("no medical content"), or call error
|
/// generated text, deliberate empty ("no medical content"), or call error
|
||||||
/// — is persisted as an [`OnelinerState`] variant, overwriting any
|
/// — is persisted as an [`OnelinerState`] variant, overwriting any
|
||||||
/// previous state. Silent case (no non-empty transcript) is a no-op; the
|
/// previous state.
|
||||||
/// next transcript write retries.
|
///
|
||||||
|
/// 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(
|
pub(crate) async fn update_oneliner(
|
||||||
case_dir: &Path,
|
case_dir: &Path,
|
||||||
user_slug: &str,
|
user_slug: &str,
|
||||||
@@ -199,7 +210,42 @@ pub(crate) async fn update_oneliner(
|
|||||||
) {
|
) {
|
||||||
let transcript = match all_transcripts_joined(case_dir).await {
|
let transcript = match all_transcripts_joined(case_dir).await {
|
||||||
Some(t) => t,
|
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()
|
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
|
/// Return true if `case_dir` contains at least one `.m4a` recording
|
||||||
/// that has no corresponding `.transcript.txt`. Used to gate the
|
/// whose transcript is still `Pending` (no sidecar file yet). Used to
|
||||||
/// oneliner-regenerate call: we only run it after the *last* pending
|
/// gate the oneliner-regenerate call: we only run it after the *last*
|
||||||
/// transcription in a batch, not after every single one. `.m4a.failed`
|
/// pending transcription in a batch, not after every single one.
|
||||||
/// entries are skipped — they don't block the oneliner. Fail-open on
|
/// `.m4a.failed` entries are skipped — they don't block the oneliner.
|
||||||
/// read errors: if we can't scan, return false so the oneliner runs
|
/// `Silent` recordings count as finished, not pending — that's the
|
||||||
/// anyway (one redundant call beats never).
|
/// 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 {
|
pub(crate) async fn has_pending_recordings(case_dir: &Path) -> bool {
|
||||||
let Ok(mut entries) = tokio::fs::read_dir(case_dir).await else {
|
let Ok(mut entries) = tokio::fs::read_dir(case_dir).await else {
|
||||||
return false;
|
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") {
|
if path.extension().and_then(|s| s.to_str()) != Some("m4a") {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if !path.with_extension("transcript.txt").exists() {
|
if paths::read_transcript_state(&path).await.is_pending() {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read every non-empty `*.transcript.txt` in `case_dir`, ordered
|
/// Join every `Content` transcript in `case_dir` chronologically with
|
||||||
/// chronologically (ISO-8601 filename sort equals chronological order), and
|
/// `\n\n---\n\n` separators. `Pending` (no sidecar) and `Silent` (empty
|
||||||
/// join them with `\n\n---\n\n`. Empty / whitespace-only transcripts (silent
|
/// sidecar) recordings are skipped. Returns None if no `Content`
|
||||||
/// recordings) are skipped. Returns None if no non-empty transcript exists.
|
/// 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> {
|
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 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 {
|
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||||
let name = entry.file_name();
|
let path = entry.path();
|
||||||
let Some(s) = name.to_str() else { continue };
|
if path.extension().and_then(|s| s.to_str()) == Some("m4a") {
|
||||||
if s.ends_with(".transcript.txt") {
|
m4as.push(path);
|
||||||
files.push(entry.path());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
files.sort();
|
m4as.sort();
|
||||||
|
|
||||||
let mut parts: Vec<String> = Vec::with_capacity(files.len());
|
let mut parts: Vec<String> = Vec::with_capacity(m4as.len());
|
||||||
for path in files {
|
for path in m4as {
|
||||||
if let Ok(text) = tokio::fs::read_to_string(&path).await {
|
if let TranscriptState::Content(text) = paths::read_transcript_state(&path).await {
|
||||||
let trimmed = text.trim();
|
let trimmed = text.trim();
|
||||||
if !trimmed.is_empty() {
|
if !trimmed.is_empty() {
|
||||||
parts.push(trimmed.to_string());
|
parts.push(trimmed.to_string());
|
||||||
@@ -322,24 +372,39 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use tempfile::tempdir;
|
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
|
/// Regression guard for the Womax bug: the joined transcript must
|
||||||
/// include ALL recordings in chronological order, so the LLM sees
|
/// include ALL recordings in chronological order, so the LLM sees
|
||||||
/// both the initial term and any later correction.
|
/// both the initial term and any later correction.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn all_transcripts_joined_includes_all_in_order() {
|
async fn all_transcripts_joined_includes_all_in_order() {
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
tokio::fs::write(
|
seed(
|
||||||
dir.path().join("2026-04-16T10-05-00Z.transcript.txt"),
|
dir.path(),
|
||||||
"Korrektur. Das Mittel heißt Vomex.",
|
"2026-04-16T10-05-00Z",
|
||||||
|
Some("Korrektur. Das Mittel heißt Vomex."),
|
||||||
)
|
)
|
||||||
.await
|
.await;
|
||||||
.unwrap();
|
seed(
|
||||||
tokio::fs::write(
|
dir.path(),
|
||||||
dir.path().join("2026-04-16T10-00-00Z.transcript.txt"),
|
"2026-04-16T10-00-00Z",
|
||||||
"Jetzt weiß ich es wieder. Es heißt Womax.",
|
Some("Jetzt weiß ich es wieder. Es heißt Womax."),
|
||||||
)
|
)
|
||||||
.await
|
.await;
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let got = all_transcripts_joined(dir.path()).await.expect("Some");
|
let got = all_transcripts_joined(dir.path()).await.expect("Some");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -353,15 +418,13 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn all_transcripts_joined_skips_empty_recordings() {
|
async fn all_transcripts_joined_skips_empty_recordings() {
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
tokio::fs::write(dir.path().join("2026-04-16T10-00-00Z.transcript.txt"), "")
|
seed(dir.path(), "2026-04-16T10-00-00Z", Some("")).await;
|
||||||
.await
|
seed(
|
||||||
.unwrap();
|
dir.path(),
|
||||||
tokio::fs::write(
|
"2026-04-16T10-05-00Z",
|
||||||
dir.path().join("2026-04-16T10-05-00Z.transcript.txt"),
|
Some("non-empty content"),
|
||||||
"non-empty content",
|
|
||||||
)
|
)
|
||||||
.await
|
.await;
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let got = all_transcripts_joined(dir.path()).await.expect("Some");
|
let got = all_transcripts_joined(dir.path()).await.expect("Some");
|
||||||
assert_eq!(got, "non-empty content");
|
assert_eq!(got, "non-empty content");
|
||||||
@@ -370,12 +433,7 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn all_transcripts_joined_is_none_when_all_empty() {
|
async fn all_transcripts_joined_is_none_when_all_empty() {
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
tokio::fs::write(
|
seed(dir.path(), "2026-04-16T10-00-00Z", Some(" \n ")).await;
|
||||||
dir.path().join("2026-04-16T10-00-00Z.transcript.txt"),
|
|
||||||
" \n ",
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert!(all_transcripts_joined(dir.path()).await.is_none());
|
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>
|
<div class="failed">Transkription fehlgeschlagen — .failed-Suffix entfernen zum Erneut-Versuchen.</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
{% match rec.transcript %}
|
{% match rec.transcript %}
|
||||||
{% when Some with (t) %}
|
{% when TranscriptState::Content with (t) %}
|
||||||
{% if t.is_empty() %}
|
|
||||||
<div class="pending">(stille Aufnahme)</div>
|
|
||||||
{% else %}
|
|
||||||
<div class="transcript">{{ t }}</div>
|
<div class="transcript">{{ t }}</div>
|
||||||
{% endif %}
|
{% when TranscriptState::Silent %}
|
||||||
{% when None %}
|
<div class="pending">(stille Aufnahme)</div>
|
||||||
|
{% when TranscriptState::Pending %}
|
||||||
{% if transcribe_busy %}
|
{% if transcribe_busy %}
|
||||||
<div class="pending">Transkription läuft…</div>
|
<div class="pending">Transkription läuft…</div>
|
||||||
{% else %}
|
{% 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]
|
#[tokio::test]
|
||||||
async fn case_page_falls_back_to_generic_title_for_empty_state() {
|
async fn case_page_falls_back_to_generic_title_for_empty_state() {
|
||||||
// An `Empty` state (LLM deliberately returned no medical content) is
|
// 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};
|
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||||
|
|
||||||
fn write_transcript(case_dir: &Path) {
|
fn write_transcript(case_dir: &Path) {
|
||||||
let p = case_dir.join("2026-04-16T10-00-00Z.transcript.txt");
|
// Seed an m4a + transcript pair, matching the worker's on-disk
|
||||||
std::fs::write(p, "Brustschmerz links, seit heute morgen.").unwrap();
|
// 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]
|
#[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