diff --git a/server/src/transcribe/worker.rs b/server/src/transcribe/worker.rs index 3fdf6a6..2f5e28d 100644 --- a/server/src/transcribe/worker.rs +++ b/server/src/transcribe/worker.rs @@ -78,11 +78,16 @@ pub async fn run( "Transcript written" ); - // 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() { + // Regenerate the oneliner, but only after the *last* pending + // recording in the batch — otherwise we'd burn LLM calls on + // incomplete inputs that the next job would redo. Later + // recordings still override earlier ones (symmetric to the + // analyze-LLM "Spätere Aufnahmen haben Vorrang" rule). Silent / + // empty cases are no-ops; LLM errors leave any existing + // oneliner untouched. + if let Some(case_dir) = audio_path.parent() + && !has_pending_recordings(case_dir).await + { update_oneliner(case_dir, &client, &config, &vocab).await; } } @@ -157,6 +162,29 @@ pub(crate) async fn update_oneliner( } } +/// 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). +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; + }; + while let Ok(Some(entry)) = entries.next_entry().await { + let path = entry.path(); + if path.extension().and_then(|s| s.to_str()) != Some("m4a") { + continue; + } + if !path.with_extension("transcript.txt").exists() { + 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 @@ -261,4 +289,61 @@ mod tests { .unwrap(); assert!(all_transcripts_joined(dir.path()).await.is_none()); } + + #[tokio::test] + async fn has_pending_recordings_true_when_m4a_without_transcript() { + let dir = tempdir().unwrap(); + tokio::fs::write(dir.path().join("2026-04-16T10-00-00Z.m4a"), b"a") + .await + .unwrap(); + tokio::fs::write( + dir.path().join("2026-04-16T10-00-00Z.transcript.txt"), + "text", + ) + .await + .unwrap(); + tokio::fs::write(dir.path().join("2026-04-16T10-05-00Z.m4a"), b"b") + .await + .unwrap(); + assert!(has_pending_recordings(dir.path()).await); + } + + #[tokio::test] + async fn has_pending_recordings_false_when_all_transcribed() { + let dir = tempdir().unwrap(); + tokio::fs::write(dir.path().join("2026-04-16T10-00-00Z.m4a"), b"a") + .await + .unwrap(); + tokio::fs::write( + dir.path().join("2026-04-16T10-00-00Z.transcript.txt"), + "text1", + ) + .await + .unwrap(); + tokio::fs::write(dir.path().join("2026-04-16T10-05-00Z.m4a"), b"b") + .await + .unwrap(); + tokio::fs::write( + dir.path().join("2026-04-16T10-05-00Z.transcript.txt"), + "text2", + ) + .await + .unwrap(); + assert!(!has_pending_recordings(dir.path()).await); + } + + #[tokio::test] + async fn has_pending_recordings_ignores_failed() { + let dir = tempdir().unwrap(); + tokio::fs::write(dir.path().join("2026-04-16T10-00-00Z.m4a.failed"), b"x") + .await + .unwrap(); + assert!(!has_pending_recordings(dir.path()).await); + } + + #[tokio::test] + async fn has_pending_recordings_false_for_empty_dir() { + let dir = tempdir().unwrap(); + assert!(!has_pending_recordings(dir.path()).await); + } }