Add recovery for missing oneliners

This commit introduces a recovery mechanism to regenerate `oneliner.txt`
files for cases where transcription was successful but the oneliner
generation failed due to a crash between writing the transcript and the
oneliner.

The `transcribe::recovery::regenerate_missing_oneliners` function is
added and called at startup after `scan_and_enqueue`. This function
identifies cases with non-empty transcripts but missing oneliners and
triggers their regeneration.

The `transcribe::worker::update_oneliner` function is refactored to
always regenerate the oneliner from all available transcripts in a case,
ensuring that later recordings can correct earlier ones. This replaces
the previous `ensure_oneliner` logic which only generated the oneliner
if it didn't exist.

New helper functions `cases_needing_oneliner` and
`all_transcripts_joined` are introduced to support these changes.
Comprehensive unit tests are included for the new recovery and file
processing logic.
This commit is contained in:
2026-04-16 14:43:54 +02:00
parent 3a656bd2a9
commit b1a8186fcd
3 changed files with 268 additions and 16 deletions
+127 -15
View File
@@ -71,12 +71,12 @@ pub async fn run(
"Transcript written"
);
// Silent recording: Whisper returned an empty string. Skip the
// oneliner step — otherwise the LLM echoes the system prompt back.
if !text.trim().is_empty()
&& let Some(case_dir) = audio_path.parent()
{
ensure_oneliner(case_dir, &text, &client, &config).await;
// Regenerate the oneliner from all transcripts of the case. Later
// recordings override earlier ones — symmetric to the analyze-LLM
// rule "Spätere Aufnahmen haben Vorrang". Silent / empty cases are
// no-ops; LLM errors leave any existing oneliner untouched.
if let Some(case_dir) = audio_path.parent() {
update_oneliner(case_dir, &client, &config).await;
}
}
@@ -106,25 +106,31 @@ async fn mark_failed(audio_path: &Path) {
}
}
/// Generate `case_dir/oneliner.txt` from the given transcript iff the file
/// does not already exist. Errors are non-fatal: logged and swallowed.
async fn ensure_oneliner(
/// Regenerate `case_dir/oneliner.txt` from **all** non-empty 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"). Overwrites any existing
/// oneliner on success; LLM errors are non-fatal and leave the previous
/// oneliner (if any) untouched. Silent case (no non-empty transcript) is a
/// no-op — next transcript write retries.
pub(crate) async fn update_oneliner(
case_dir: &Path,
transcript: &str,
client: &reqwest::Client,
config: &Config,
) {
let path = case_dir.join("oneliner.txt");
if path.exists() {
return;
}
let transcript = match all_transcripts_joined(case_dir).await {
Some(t) => t,
None => return,
};
match ollama::generate_oneliner(
client,
&config.ollama_url,
&config.ollama_model,
config.ollama_keep_alive,
transcript,
&transcript,
ONELINER_TIMEOUT,
)
.await
@@ -133,7 +139,7 @@ async fn ensure_oneliner(
if let Err(e) = tokio::fs::write(&path, &line).await {
error!(path = %path.display(), error = %e, "writing oneliner failed");
} else {
info!(path = %path.display(), chars = line.chars().count(), "Oneliner written");
info!(path = %path.display(), chars = line.chars().count(), "Oneliner updated");
}
}
Err(e) => {
@@ -141,3 +147,109 @@ async fn ensure_oneliner(
}
}
}
/// 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.
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();
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());
}
}
files.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 trimmed = text.trim();
if !trimmed.is_empty() {
parts.push(trimmed.to_string());
}
}
}
if parts.is_empty() {
return None;
}
Some(parts.join("\n\n---\n\n"))
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
/// 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.",
)
.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
.unwrap();
let got = all_transcripts_joined(dir.path()).await.expect("Some");
assert_eq!(
got,
"Jetzt weiß ich es wieder. Es heißt Womax.\n\n---\n\nKorrektur. Das Mittel heißt Vomex."
);
}
/// Silent recordings are skipped, not treated as end-of-stream — a
/// later non-empty recording still contributes.
#[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",
)
.await
.unwrap();
let got = all_transcripts_joined(dir.path()).await.expect("Some");
assert_eq!(got, "non-empty content");
}
#[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();
assert!(all_transcripts_joined(dir.path()).await.is_none());
}
#[tokio::test]
async fn all_transcripts_joined_is_none_when_no_transcripts() {
let dir = tempdir().unwrap();
tokio::fs::write(dir.path().join("2026-04-16T10-00-00Z.m4a"), b"audio")
.await
.unwrap();
assert!(all_transcripts_joined(dir.path()).await.is_none());
}
}