Defer oneliner regeneration until batch end

Avoid redundant LLM calls by waiting until all recordings in a batch
have been processed before regenerating the oneliner. This change
introduces a helper function `has_pending_recordings` to check for any
`.m4a` files without a corresponding transcript, and the
`update_oneliner` logic now only proceeds if no pending recordings are
found.
This commit is contained in:
2026-04-16 21:10:55 +02:00
parent e64204b757
commit 44bcc076b4
+90 -5
View File
@@ -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);
}
}