Refactor analysis input building

Extract the logic for building `AnalysisInput` into a new function
`build_analysis_input`. This function encapsulates the process of
collecting recordings, reading transcripts, and determining the last
recording modification time.

This change improves code organization and reusability by centralizing
the input building logic.
This commit is contained in:
2026-04-15 21:16:32 +02:00
parent 1fa737a2e6
commit 7bcd94f0d0
2 changed files with 71 additions and 52 deletions
+12
View File
@@ -25,6 +25,12 @@ pub struct AuthenticatedUser {
pub data_dir: PathBuf, pub data_dir: PathBuf,
} }
impl AuthenticatedUser {
pub fn is_admin(&self) -> bool {
self.role == "admin"
}
}
impl<S> FromRequestParts<S> for AuthenticatedUser impl<S> FromRequestParts<S> for AuthenticatedUser
where where
S: Send + Sync, S: Send + Sync,
@@ -72,6 +78,12 @@ pub struct AuthenticatedWebUser {
pub data_dir: PathBuf, pub data_dir: PathBuf,
} }
impl AuthenticatedWebUser {
pub fn is_admin(&self) -> bool {
self.role == "admin"
}
}
impl<S> FromRequestParts<S> for AuthenticatedWebUser impl<S> FromRequestParts<S> for AuthenticatedWebUser
where where
S: Send + Sync, S: Send + Sync,
+59 -52
View File
@@ -60,56 +60,7 @@ pub async fn handle_close_case(
)); ));
} }
// Collect all active `.m4a` recordings and the transcripts next to them. let input = build_analysis_input(&case_dir, 1).await?;
// `.m4a.failed` entries are deliberately skipped: they block nothing and
// carry no transcript. The doctor handles them manually.
let m4as = collect_m4as(&case_dir).await?;
if m4as.is_empty() {
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;
}
let transcript_path = m4a_path.with_extension("transcript.txt");
let text = tokio::fs::read_to_string(&transcript_path)
.await
.map_err(|_| {
AppError::BadRequest("Nicht alle Aufnahmen sind transkribiert".into())
})?;
if text.trim().is_empty() {
// Skip blank transcripts from the LLM input — they add noise
// without signal. The `.m4a` still contributes to `last_mtime`.
continue;
}
let stem = m4a_path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
recordings.push(RecordingInput {
recorded_at: filename_stem_to_recorded_at(stem),
text,
});
}
if recordings.is_empty() {
return Err(AppError::BadRequest(
"Keine nicht-leeren Transkripte im Fall".into(),
));
}
let last_recording_mtime = OffsetDateTime::from(last_mtime)
.format(&Rfc3339)
.map_err(|e| AppError::Internal(format!("format mtime: {e}")))?;
let input = AnalysisInput {
version: 1,
last_recording_mtime,
recordings,
};
write_input_create_new(&input_path, &input).await?; write_input_create_new(&input_path, &input).await?;
@@ -171,6 +122,62 @@ pub async fn handle_document_view(
.map_err(|e| AppError::Internal(format!("Template render failed: {e}"))) .map_err(|e| AppError::Internal(format!("Template render failed: {e}")))
} }
/// Build an `AnalysisInput` for the given case directory at the given version.
/// Collects all `.m4a` (excluding `.m4a.failed`), reads the matching
/// `.transcript.txt` for each, skips blank transcripts, and computes
/// `last_recording_mtime` as the max mtime over ALL `.m4a` (including blank
/// ones — a late blank addendum still counts as activity).
pub(crate) async fn build_analysis_input(
case_dir: &Path,
version: u32,
) -> Result<AnalysisInput, AppError> {
let m4as = collect_m4as(case_dir).await?;
if m4as.is_empty() {
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;
}
let transcript_path = m4a_path.with_extension("transcript.txt");
let text = tokio::fs::read_to_string(&transcript_path)
.await
.map_err(|_| {
AppError::BadRequest("Nicht alle Aufnahmen sind transkribiert".into())
})?;
if text.trim().is_empty() {
continue;
}
let stem = m4a_path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
recordings.push(RecordingInput {
recorded_at: filename_stem_to_recorded_at(stem),
text,
});
}
if recordings.is_empty() {
return Err(AppError::BadRequest(
"Keine nicht-leeren Transkripte im Fall".into(),
));
}
let last_recording_mtime = OffsetDateTime::from(last_mtime)
.format(&Rfc3339)
.map_err(|e| AppError::Internal(format!("format mtime: {e}")))?;
Ok(AnalysisInput {
version,
last_recording_mtime,
recordings,
})
}
/// Return `(path, mtime)` for every `.m4a` file directly under `case_dir`, /// Return `(path, mtime)` for every `.m4a` file directly under `case_dir`,
/// sorted by filename (chronological, since filenames are UTC timestamps). /// sorted by filename (chronological, since filenames are UTC timestamps).
/// `.m4a.failed` entries are skipped. /// `.m4a.failed` entries are skipped.
@@ -201,7 +208,7 @@ async fn collect_m4as(case_dir: &Path) -> Result<Vec<(PathBuf, SystemTime)>, App
/// a zero-byte or partially-written JSON remains. The worker will fail to /// a zero-byte or partially-written JSON remains. The worker will fail to
/// deserialize it and log an error — manual cleanup required. For MVP the /// deserialize it and log an error — manual cleanup required. For MVP the
/// simpler check-and-create is acceptable. /// simpler check-and-create is acceptable.
async fn write_input_create_new( pub(crate) async fn write_input_create_new(
path: &Path, path: &Path,
input: &AnalysisInput, input: &AnalysisInput,
) -> Result<(), AppError> { ) -> Result<(), AppError> {
@@ -239,7 +246,7 @@ fn filename_stem_to_recorded_at(stem: &str) -> String {
/// Scan `case_dir` for `document_v{N}.md`, pick the highest N, return its /// Scan `case_dir` for `document_v{N}.md`, pick the highest N, return its
/// content together with N. Returns `None` if no document exists. /// content together with N. Returns `None` if no document exists.
async fn find_latest_document(case_dir: &Path) -> Option<(u32, String)> { pub(crate) async fn find_latest_document(case_dir: &Path) -> Option<(u32, String)> {
let mut highest: Option<u32> = None; let mut highest: Option<u32> = None;
let mut entries = tokio::fs::read_dir(case_dir).await.ok()?; let mut entries = tokio::fs::read_dir(case_dir).await.ok()?;
while let Ok(Some(entry)) = entries.next_entry().await { while let Ok(Some(entry)) = entries.next_entry().await {