7bcd94f0d0
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.
311 lines
10 KiB
Rust
311 lines
10 KiB
Rust
use std::path::{Path, PathBuf};
|
|
use std::sync::Arc;
|
|
use std::time::SystemTime;
|
|
|
|
use askama::Template;
|
|
use axum::extract::{Path as AxumPath, State};
|
|
use axum::response::{Html, Redirect};
|
|
use time::format_description::well_known::Rfc3339;
|
|
use time::OffsetDateTime;
|
|
use tokio::io::AsyncWriteExt;
|
|
use tracing::{info, warn};
|
|
|
|
use crate::analyze::{AnalysisInput, AnalyzeJob, AnalyzeSender, RecordingInput};
|
|
use crate::auth::AuthenticatedWebUser;
|
|
use crate::config::Config;
|
|
use crate::error::AppError;
|
|
use crate::routes::user_web::locate_case;
|
|
|
|
#[derive(Template)]
|
|
#[template(path = "document.html")]
|
|
struct DocumentTemplate {
|
|
case_id: String,
|
|
version: u32,
|
|
content: String,
|
|
}
|
|
|
|
/// POST /web/cases/{case_id}/close
|
|
///
|
|
/// Persist an `analysis_input_v1.json` for the case and enqueue an analyze
|
|
/// job. Redirects back to the case detail page where the UI will show
|
|
/// "wird analysiert …" until the worker writes `document_v1.md`.
|
|
pub async fn handle_close_case(
|
|
user: AuthenticatedWebUser,
|
|
State(config): State<Arc<Config>>,
|
|
State(analyze_tx): State<AnalyzeSender>,
|
|
AxumPath(case_id): AxumPath<String>,
|
|
) -> Result<Redirect, AppError> {
|
|
uuid::Uuid::parse_str(&case_id)
|
|
.map_err(|_| AppError::BadRequest("Invalid case_id".into()))?;
|
|
|
|
// Server-side guard mirroring the UI gate: no close without a configured LLM.
|
|
if !config.llm_configured() {
|
|
return Err(AppError::ServiceUnavailable(
|
|
"LLM-Analyse nicht konfiguriert".into(),
|
|
));
|
|
}
|
|
|
|
// Close only acts on cases in open/. A case in done/ is already finalized.
|
|
let user_root = config.data_path.join(&user.slug);
|
|
let case_dir = user_root.join("open").join(&case_id);
|
|
if !tokio::fs::try_exists(&case_dir).await.unwrap_or(false) {
|
|
warn!(slug = %user.slug, case_id = %case_id, "close: case not found in open/");
|
|
return Err(AppError::NotFound("Case not found".into()));
|
|
}
|
|
|
|
let input_path = case_dir.join("analysis_input_v1.json");
|
|
if tokio::fs::try_exists(&input_path).await.unwrap_or(false) {
|
|
return Err(AppError::Conflict(
|
|
"Analyse läuft bereits für diesen Fall".into(),
|
|
));
|
|
}
|
|
|
|
let input = build_analysis_input(&case_dir, 1).await?;
|
|
|
|
write_input_create_new(&input_path, &input).await?;
|
|
|
|
// Unbounded send: only fails if the worker has gone away (programmer error
|
|
// or shutdown race). Log but don't fail the request — the file is already
|
|
// on disk and recovery will pick it up on next startup.
|
|
if analyze_tx
|
|
.send(AnalyzeJob {
|
|
case_dir: case_dir.clone(),
|
|
version: 1,
|
|
})
|
|
.is_err()
|
|
{
|
|
warn!(case_id = %case_id, "analyze send failed (worker gone)");
|
|
}
|
|
|
|
info!(
|
|
slug = %user.slug,
|
|
case_id = %case_id,
|
|
recordings = input.recordings.len(),
|
|
"close requested; analysis enqueued"
|
|
);
|
|
|
|
Ok(Redirect::to(&format!("/web/cases/{case_id}")))
|
|
}
|
|
|
|
/// GET /web/cases/{case_id}/document
|
|
///
|
|
/// Render the latest `document_v{N}.md` for the case. The handler scans the
|
|
/// case directory and picks the highest N — no symlink, no separate pointer.
|
|
pub async fn handle_document_view(
|
|
user: AuthenticatedWebUser,
|
|
State(config): State<Arc<Config>>,
|
|
AxumPath(case_id): AxumPath<String>,
|
|
) -> Result<Html<String>, AppError> {
|
|
uuid::Uuid::parse_str(&case_id)
|
|
.map_err(|_| AppError::BadRequest("Invalid case_id".into()))?;
|
|
|
|
let user_root = config.data_path.join(&user.slug);
|
|
let (case_dir, _status) = match locate_case(&user_root, &case_id).await {
|
|
Some(v) => v,
|
|
None => {
|
|
warn!(slug = %user.slug, case_id = %case_id, "document view: case not found");
|
|
return Err(AppError::NotFound("Case not found".into()));
|
|
}
|
|
};
|
|
|
|
let (version, content) = find_latest_document(&case_dir)
|
|
.await
|
|
.ok_or_else(|| AppError::NotFound("Noch kein Dokument erzeugt".into()))?;
|
|
|
|
DocumentTemplate {
|
|
case_id,
|
|
version,
|
|
content,
|
|
}
|
|
.render()
|
|
.map(Html)
|
|
.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`,
|
|
/// sorted by filename (chronological, since filenames are UTC timestamps).
|
|
/// `.m4a.failed` entries are skipped.
|
|
async fn collect_m4as(case_dir: &Path) -> Result<Vec<(PathBuf, SystemTime)>, AppError> {
|
|
let mut out = Vec::new();
|
|
let mut entries = tokio::fs::read_dir(case_dir).await?;
|
|
while let Some(entry) = entries.next_entry().await? {
|
|
let filename = match entry.file_name().into_string() {
|
|
Ok(s) => s,
|
|
Err(_) => continue,
|
|
};
|
|
if !filename.ends_with(".m4a") {
|
|
continue;
|
|
}
|
|
let path = entry.path();
|
|
let mtime = entry.metadata().await?.modified()?;
|
|
out.push((path, mtime));
|
|
}
|
|
out.sort_by(|a, b| a.0.cmp(&b.0));
|
|
Ok(out)
|
|
}
|
|
|
|
/// Serialize `input` and write it to `path` using `create_new` to atomically
|
|
/// reserve the destination. Returns `Conflict` if another close already won
|
|
/// the race.
|
|
///
|
|
/// Trade-off: if the process crashes between `open` and `write_all`/`sync_all`,
|
|
/// 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
|
|
/// simpler check-and-create is acceptable.
|
|
pub(crate) async fn write_input_create_new(
|
|
path: &Path,
|
|
input: &AnalysisInput,
|
|
) -> Result<(), AppError> {
|
|
let bytes = serde_json::to_vec_pretty(input)
|
|
.map_err(|e| AppError::Internal(format!("serialize input: {e}")))?;
|
|
|
|
let mut f = match tokio::fs::OpenOptions::new()
|
|
.write(true)
|
|
.create_new(true)
|
|
.open(path)
|
|
.await
|
|
{
|
|
Ok(f) => f,
|
|
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
|
|
return Err(AppError::Conflict(
|
|
"Analyse läuft bereits für diesen Fall".into(),
|
|
));
|
|
}
|
|
Err(e) => return Err(AppError::Internal(e.to_string())),
|
|
};
|
|
f.write_all(&bytes).await?;
|
|
f.sync_all().await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Reverse the filesystem-safe timestamp mapping applied in `upload.rs`:
|
|
/// `:` in the time portion was replaced with `-`. Split at `T`, undo hyphens
|
|
/// only in the time part to avoid touching date hyphens.
|
|
fn filename_stem_to_recorded_at(stem: &str) -> String {
|
|
match stem.split_once('T') {
|
|
Some((date, time)) => format!("{}T{}", date, time.replace('-', ":")),
|
|
None => stem.to_string(),
|
|
}
|
|
}
|
|
|
|
/// Scan `case_dir` for `document_v{N}.md`, pick the highest N, return its
|
|
/// content together with N. Returns `None` if no document exists.
|
|
pub(crate) async fn find_latest_document(case_dir: &Path) -> Option<(u32, String)> {
|
|
let mut highest: Option<u32> = None;
|
|
let mut entries = tokio::fs::read_dir(case_dir).await.ok()?;
|
|
while let Ok(Some(entry)) = entries.next_entry().await {
|
|
let name = entry.file_name();
|
|
let Some(name_str) = name.to_str() else { continue };
|
|
let Some(version) = parse_document_version(name_str) else {
|
|
continue;
|
|
};
|
|
highest = match highest {
|
|
Some(h) if h >= version => Some(h),
|
|
_ => Some(version),
|
|
};
|
|
}
|
|
let v = highest?;
|
|
let content = tokio::fs::read_to_string(case_dir.join(format!("document_v{v}.md")))
|
|
.await
|
|
.ok()?;
|
|
Some((v, content))
|
|
}
|
|
|
|
fn parse_document_version(filename: &str) -> Option<u32> {
|
|
let rest = filename.strip_prefix("document_v")?;
|
|
let num = rest.strip_suffix(".md")?;
|
|
if num.is_empty() || (num.starts_with('0') && num.len() > 1) {
|
|
return None;
|
|
}
|
|
num.parse::<u32>().ok()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn filename_stem_roundtrip() {
|
|
assert_eq!(
|
|
filename_stem_to_recorded_at("2026-04-15T10-32-00Z"),
|
|
"2026-04-15T10:32:00Z"
|
|
);
|
|
// Date-only hyphens are preserved.
|
|
assert_eq!(
|
|
filename_stem_to_recorded_at("2026-04-15T10-32-00.123Z"),
|
|
"2026-04-15T10:32:00.123Z"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parses_valid_document_names() {
|
|
assert_eq!(parse_document_version("document_v1.md"), Some(1));
|
|
assert_eq!(parse_document_version("document_v42.md"), Some(42));
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_invalid_document_names() {
|
|
assert_eq!(parse_document_version("document_v01.md"), None);
|
|
assert_eq!(parse_document_version("document_v.md"), None);
|
|
assert_eq!(parse_document_version("document_vx.md"), None);
|
|
assert_eq!(parse_document_version("analysis_input_v1.json"), None);
|
|
assert_eq!(parse_document_version(""), None);
|
|
}
|
|
}
|