refactor(server): split build_analysis_input into pure + I/O parts

Replace the imperative loop that mixed mtime-tracking, transcript
reading, and validation with three clearly-scoped functions:

- compute_last_mtime: pure reduction over all m4as, returns max mtime.
- read_recordings: I/O loop that reads each transcript sidecar,
  aborts on missing transcript (legacy "Nicht alle Aufnahmen sind
  transkribiert" 400 body), silently skips blank ones.
- build_analysis_input: orchestrator that composes the above with
  RFC3339 formatting.

Error bodies are preserved byte-for-byte. Replaces a one-pass loop
with two passes; n <= ~10 in practice - negligible.
This commit is contained in:
2026-04-19 15:22:45 +02:00
parent bce93811a0
commit 041f9015ca
+38 -17
View File
@@ -124,13 +124,44 @@ pub(crate) async fn build_analysis_input(case_dir: &Path) -> Result<AnalysisInpu
return Err(AppError::BadRequest("Keine Aufnahmen im Fall".into()));
}
let mut recordings: Vec<RecordingInput> = Vec::new();
let mut last_mtime: SystemTime = SystemTime::UNIX_EPOCH;
for (m4a_path, mtime) in &m4as {
if *mtime > last_mtime {
last_mtime = *mtime;
}
// last_mtime is derived from ALL m4as (including ones that turn out
// to have blank transcripts) — a late blank addendum still counts as
// case activity. Computed up-front so `read_recordings` can focus on
// I/O + validation.
let last_mtime = compute_last_mtime(&m4as);
let recordings = read_recordings(&m4as).await?;
// Silent-only case: `recordings` may be empty here. The analyze worker
// handles that by writing a stub document instead of calling the LLM.
let last_recording_mtime = OffsetDateTime::from(last_mtime)
.format(&Rfc3339)
.map_err(|e| AppError::Internal(format!("format mtime: {e}")))?;
Ok(AnalysisInput {
last_recording_mtime,
recordings,
})
}
/// Max modification time across all m4as. Falls back to UNIX_EPOCH for
/// an empty slice — callers should have already rejected that case.
fn compute_last_mtime(m4as: &[(PathBuf, SystemTime)]) -> SystemTime {
m4as.iter()
.map(|(_, mtime)| *mtime)
.max()
.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.
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
@@ -148,17 +179,7 @@ pub(crate) async fn build_analysis_input(case_dir: &Path) -> Result<AnalysisInpu
text,
});
}
// Silent-only case: `recordings` may be empty here. The analyze worker
// handles that by writing a stub document instead of calling the LLM.
let last_recording_mtime = OffsetDateTime::from(last_mtime)
.format(&Rfc3339)
.map_err(|e| AppError::Internal(format!("format mtime: {e}")))?;
Ok(AnalysisInput {
last_recording_mtime,
recordings,
})
Ok(recordings)
}
/// Return `(path, mtime)` for every `.m4a` file directly under `case_dir`,