70425939b2
Introduces a new module `auto_trigger` responsible for opportunistically initiating LLM analysis jobs. This mechanism scans case directories for new or updated recordings and transcripts, automatically creating and queuing analysis jobs when appropriate. Key components: - `evaluate_case`: Pure function to determine if a case is ready for analysis based on recording status, transcriptions, document modification times, and existing job/failure markers. - `try_enqueue`: Orchestrates the analysis job creation and queuing process if `evaluate_case` returns `AutoDecision::Enqueue`. - `try_enqueue_all_for_user`: Iterates through all user cases to trigger `try_enqueue` for eligible ones. - `write_failure_marker`/`remove_failure_marker`: Handles persistent recording of analysis failures and their cleanup. This feature aims to reduce manual intervention by automatically analyzing new or changed case data.
522 lines
18 KiB
Rust
522 lines
18 KiB
Rust
use std::path::Path;
|
|
use std::sync::Arc;
|
|
use std::sync::atomic::Ordering;
|
|
|
|
use askama::Template;
|
|
use axum::extract::State;
|
|
use axum::response::Html;
|
|
use tracing::{info, warn};
|
|
|
|
use time::{Date, OffsetDateTime, format_description::well_known::Rfc3339};
|
|
|
|
use crate::PipelineState;
|
|
use crate::analyze::{
|
|
ANALYSIS_INPUT_FILE, DOCUMENT_FILE, auto_trigger, recovery as analyze_recovery,
|
|
};
|
|
use crate::auth::AuthenticatedWebUser;
|
|
use crate::case_id::{CaseId, CaseIdPath};
|
|
use crate::config::Config;
|
|
use crate::error::AppError;
|
|
use crate::events::EventSender;
|
|
use crate::routes::case_actions::read_document;
|
|
use crate::routes::web::{RecordingView, scan_recordings};
|
|
use crate::transcribe::recovery as transcribe_recovery;
|
|
|
|
struct UserCaseView {
|
|
case_id: String,
|
|
most_recent: String,
|
|
/// HH:MM of the most recent recording in UTC — no-JS fallback.
|
|
/// Browser script replaces the displayed text with the browser-local
|
|
/// equivalent via the parallel `recorded_at_iso` field.
|
|
time_hms_utc: String,
|
|
/// Full RFC3339 UTC timestamp of the most recent recording. Fed into
|
|
/// the HTML `<time datetime="...">` attribute so browser JS can
|
|
/// render it in the doctor's actual timezone (which may differ from
|
|
/// the server's, e.g. server on Bahamas, doctor in Germany).
|
|
recorded_at_iso: String,
|
|
oneliner: Option<String>,
|
|
recordings_count: usize,
|
|
analyzing: bool,
|
|
has_document: bool,
|
|
/// True iff the inline "Analysieren"-button should be enabled.
|
|
/// Requires: not currently analyzing, all non-failed recordings
|
|
/// transcribed, and an LLM is configured.
|
|
can_analyze: bool,
|
|
}
|
|
|
|
/// Parse a recording filename `YYYY-MM-DDTHH-MM-SSZ.m4a[.failed]` as UTC
|
|
/// and return `(Date, Rfc3339String)`. None on malformed input.
|
|
fn utc_date_and_iso_of(filename: &str) -> Option<(Date, String)> {
|
|
let s = filename.get(..20)?;
|
|
if s.as_bytes().get(19) != Some(&b'Z') {
|
|
return None;
|
|
}
|
|
// Filename uses `-` between H/M/S; Rfc3339 wants `:`. Patch two bytes.
|
|
let mut buf = [0u8; 20];
|
|
buf.copy_from_slice(s.as_bytes());
|
|
buf[13] = b':';
|
|
buf[16] = b':';
|
|
let fixed = std::str::from_utf8(&buf).ok()?;
|
|
let dt = OffsetDateTime::parse(fixed, &Rfc3339).ok()?;
|
|
Some((dt.date(), fixed.to_owned()))
|
|
}
|
|
|
|
/// Group cases (already sorted newest-first) into UTC-date buckets. The
|
|
/// label is the raw ISO date string (e.g. `2026-04-18`); browser JS
|
|
/// re-labels the current/previous UTC date to "Heute"/"Gestern". Note:
|
|
/// grouping is by UTC day, which may drift from the doctor's local-day
|
|
/// perception around midnight — acceptable tradeoff; full Browser-TZ
|
|
/// grouping would require a larger client-side restructure.
|
|
fn group_by_utc_date(cases: Vec<UserCaseView>) -> Vec<DateGroup> {
|
|
let today = OffsetDateTime::now_utc().date();
|
|
let mut groups: Vec<DateGroup> = Vec::new();
|
|
for case in cases {
|
|
let d = utc_date_and_iso_of(&case.most_recent)
|
|
.map(|(d, _)| d)
|
|
.unwrap_or(today);
|
|
let label = d.to_string();
|
|
match groups.last_mut() {
|
|
Some(g) if g.label == label => g.cases.push(case),
|
|
_ => groups.push(DateGroup {
|
|
label,
|
|
cases: vec![case],
|
|
}),
|
|
}
|
|
}
|
|
groups
|
|
}
|
|
|
|
/// Extract `HH:MM` from a recording filename of the form
|
|
/// `YYYY-MM-DDTHH-MM-SSZ.m4a[.failed]`. Returns `""` on any mismatch.
|
|
/// Used only as a no-JS fallback — the canonical value rendered in the
|
|
/// browser comes from the `<time datetime>`-based script.
|
|
fn extract_hhmm_utc(filename: &str) -> String {
|
|
let (Some(h), Some(m)) = (filename.get(11..13), filename.get(14..16)) else {
|
|
return String::new();
|
|
};
|
|
if h.bytes().all(|b| b.is_ascii_digit()) && m.bytes().all(|b| b.is_ascii_digit()) {
|
|
format!("{h}:{m}")
|
|
} else {
|
|
String::new()
|
|
}
|
|
}
|
|
|
|
/// Build an RFC3339 UTC timestamp string from a recording filename.
|
|
/// Returns empty string on malformed input.
|
|
fn recorded_at_iso_of(filename: &str) -> String {
|
|
utc_date_and_iso_of(filename)
|
|
.map(|(_, iso)| iso)
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
struct DateGroup {
|
|
/// "Heute", "Gestern", or ISO date "YYYY-MM-DD".
|
|
label: String,
|
|
cases: Vec<UserCaseView>,
|
|
}
|
|
|
|
#[derive(Template)]
|
|
#[template(path = "my_cases.html")]
|
|
struct MyCasesTemplate {
|
|
slug: String,
|
|
groups: Vec<DateGroup>,
|
|
total: usize,
|
|
/// Number of cases that "Undo last delete" would restore. 0 hides the
|
|
/// undo button entirely.
|
|
undo_count: usize,
|
|
/// True iff the session user is an admin — toggles full case_id display.
|
|
is_admin: bool,
|
|
}
|
|
|
|
#[derive(Template)]
|
|
#[template(path = "case_page.html")]
|
|
struct CasePageTemplate {
|
|
case_id: String,
|
|
case_id_short: String,
|
|
oneliner: Option<String>,
|
|
/// Rendered document HTML (already passed through `md_to_html`). `Some`
|
|
/// iff `document.md` exists at render time — template prioritises this
|
|
/// over status/analyzing placeholders.
|
|
document_html: Option<String>,
|
|
recordings_count: usize,
|
|
transcribed_count: usize,
|
|
can_analyze: bool,
|
|
llm_missing: bool,
|
|
analyzing: bool,
|
|
has_document: bool,
|
|
is_admin: bool,
|
|
}
|
|
|
|
#[derive(Template)]
|
|
#[template(path = "case_recordings.html")]
|
|
struct CaseRecordingsTemplate {
|
|
slug: String,
|
|
case_id: String,
|
|
case_id_short: String,
|
|
oneliner: Option<String>,
|
|
recordings: Vec<RecordingView>,
|
|
transcribe_busy: bool,
|
|
is_admin: bool,
|
|
}
|
|
|
|
/// Flags derived from filesystem state + config.
|
|
/// `can_analyze` covers both first analysis and re-analysis — same precondition
|
|
/// (all non-failed recordings transcribed, LLM configured, no analysis in
|
|
/// flight). `llm_missing` is shown to the user as an explanation when an
|
|
/// analysis would otherwise be possible but no LLM is configured.
|
|
struct CaseFlags {
|
|
has_document: bool,
|
|
analyzing: bool,
|
|
can_analyze: bool,
|
|
llm_missing: bool,
|
|
}
|
|
|
|
async fn compute_flags(
|
|
case_dir: &Path,
|
|
recordings: &[RecordingView],
|
|
llm_configured: bool,
|
|
worker_busy: bool,
|
|
) -> CaseFlags {
|
|
let has_document = any_document_exists(case_dir).await;
|
|
// Honest status: an input file alone does not mean a job is in flight.
|
|
// The worker may be idle and the file an orphan from a crash. Page-level
|
|
// self-heal (see `heal_orphans_if_idle`) re-enqueues those.
|
|
let analyzing = !has_document && worker_busy && any_analysis_input_exists(case_dir).await;
|
|
|
|
let non_failed: Vec<&RecordingView> = recordings.iter().filter(|r| !r.failed).collect();
|
|
let all_transcribed =
|
|
!non_failed.is_empty() && non_failed.iter().all(|r| r.transcript.is_some());
|
|
|
|
let analyzable = !analyzing && all_transcribed;
|
|
|
|
CaseFlags {
|
|
has_document,
|
|
analyzing,
|
|
can_analyze: analyzable && llm_configured,
|
|
llm_missing: analyzable && !llm_configured,
|
|
}
|
|
}
|
|
|
|
/// True iff `analysis_input.json` exists. The worker removes it after a
|
|
/// successful document write, so existence implies a pending or in-flight job.
|
|
pub(crate) async fn any_analysis_input_exists(case_dir: &Path) -> bool {
|
|
tokio::fs::try_exists(case_dir.join(ANALYSIS_INPUT_FILE))
|
|
.await
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
/// True iff `document.md` exists.
|
|
pub(crate) async fn any_document_exists(case_dir: &Path) -> bool {
|
|
tokio::fs::try_exists(case_dir.join(DOCUMENT_FILE))
|
|
.await
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
/// Self-heal: if a worker is idle but orphans exist on disk for this user,
|
|
/// re-enqueue them. Page-load is the trigger; no cron, no periodic task.
|
|
/// Runs for both pipelines so a page-load on either view cleans both.
|
|
impl PipelineState {
|
|
pub async fn heal_orphans_if_idle(&self, user_root: &Path, slug: &str) {
|
|
if !self.analyze_busy.0.load(Ordering::Acquire) {
|
|
analyze_recovery::enqueue_pending_for_user(user_root, &self.analyze_tx).await;
|
|
}
|
|
if !self.transcribe_busy.0.load(Ordering::Acquire) {
|
|
transcribe_recovery::enqueue_pending_for_user(user_root, slug, &self.transcribe_tx)
|
|
.await;
|
|
}
|
|
}
|
|
}
|
|
|
|
pub async fn handle_my_cases(
|
|
user: AuthenticatedWebUser,
|
|
State(config): State<Arc<Config>>,
|
|
State(pipeline): State<PipelineState>,
|
|
State(events_tx): State<EventSender>,
|
|
) -> Result<Html<String>, AppError> {
|
|
let user_root = config.data_path.join(&user.slug);
|
|
pipeline.heal_orphans_if_idle(&user_root, &user.slug).await;
|
|
// Auto-analysis: hand every eligible case to the worker before we
|
|
// render. The common case is "nothing to do" and costs a handful of
|
|
// stat-calls per case; actual enqueues happen only when pre-conditions
|
|
// flip (new transcripts in, stale document, ...).
|
|
auto_trigger::try_enqueue_all_for_user(&user_root, &pipeline.analyze_tx, &config, &events_tx)
|
|
.await;
|
|
|
|
let busy = pipeline.analyze_busy.0.load(Ordering::Acquire);
|
|
let cases = scan_user_cases(&config, &user.slug, busy).await;
|
|
let total = cases.len();
|
|
let groups = group_by_utc_date(cases);
|
|
let undo_count = crate::routes::case_actions::summarize_latest_batch(&user_root)
|
|
.await
|
|
.map(|(_, n)| n)
|
|
.unwrap_or(0);
|
|
let is_admin = user.is_admin();
|
|
MyCasesTemplate {
|
|
slug: user.slug,
|
|
groups,
|
|
total,
|
|
undo_count,
|
|
is_admin,
|
|
}
|
|
.render()
|
|
.map(Html)
|
|
.map_err(|e| AppError::Internal(format!("Template render failed: {e}")))
|
|
}
|
|
|
|
/// GET /web/cases/{case_id}
|
|
///
|
|
/// Canonical case page. Renders the document inline if present; otherwise
|
|
/// shows status (analyzing / placeholder for empty / recordings-summary).
|
|
/// Action buttons (Analyze/Reanalyze/Reset/Delete) live here, not on the
|
|
/// recordings sub-page.
|
|
pub async fn handle_case_page(
|
|
user: AuthenticatedWebUser,
|
|
State(config): State<Arc<Config>>,
|
|
State(pipeline): State<PipelineState>,
|
|
State(events_tx): State<EventSender>,
|
|
CaseIdPath(case_id): CaseIdPath,
|
|
) -> Result<Html<String>, AppError> {
|
|
let user_root = config.data_path.join(&user.slug);
|
|
pipeline.heal_orphans_if_idle(&user_root, &user.slug).await;
|
|
|
|
let case_dir = locate_case_or_404(
|
|
&user_root,
|
|
&case_id,
|
|
&user.slug,
|
|
"case page (possible IDOR probe)",
|
|
)
|
|
.await?;
|
|
// Auto-analysis trigger for deep-link navigation: same evaluation as
|
|
// the list view. Document render below still wins the race if the
|
|
// worker happens to finish synchronously.
|
|
auto_trigger::try_enqueue(&case_dir, &pipeline.analyze_tx, &config, &events_tx).await;
|
|
let case_id_str = case_id.to_string();
|
|
info!(slug = %user.slug, case_id = %case_id, "case page viewed");
|
|
|
|
// Read document first; if it's on disk we display it regardless of what
|
|
// the `analyzing` flag would otherwise say. Resolves the narrow race
|
|
// where the worker finishes between flag check and template render.
|
|
let document_html = read_document(&case_dir)
|
|
.await
|
|
.map(|md| crate::analyze::render::md_to_html(&md));
|
|
|
|
let recordings = scan_recordings(&case_dir).await;
|
|
let oneliner = tokio::fs::read_to_string(case_dir.join("oneliner.txt"))
|
|
.await
|
|
.ok()
|
|
.map(|s| s.trim().to_string())
|
|
.filter(|s| !s.is_empty());
|
|
|
|
let a_busy = pipeline.analyze_busy.0.load(Ordering::Acquire);
|
|
let flags = compute_flags(&case_dir, &recordings, config.llm_configured(), a_busy).await;
|
|
|
|
let recordings_count = recordings.len();
|
|
let transcribed_count = recordings.iter().filter(|r| r.transcript.is_some()).count();
|
|
let case_id_short = case_id_str.chars().take(8).collect();
|
|
let is_admin = user.is_admin();
|
|
|
|
CasePageTemplate {
|
|
case_id: case_id_str,
|
|
case_id_short,
|
|
oneliner,
|
|
document_html,
|
|
recordings_count,
|
|
transcribed_count,
|
|
can_analyze: flags.can_analyze,
|
|
llm_missing: flags.llm_missing,
|
|
analyzing: flags.analyzing,
|
|
has_document: flags.has_document,
|
|
is_admin,
|
|
}
|
|
.render()
|
|
.map(Html)
|
|
.map_err(|e| AppError::Internal(format!("Template render failed: {e}")))
|
|
}
|
|
|
|
/// GET /web/cases/{case_id}/recordings
|
|
///
|
|
/// Read-only sub-page showing all `.m4a` + transcript pairs for the case.
|
|
/// Useful for power-users/admins; not part of the day-to-day workflow.
|
|
pub async fn handle_case_recordings(
|
|
user: AuthenticatedWebUser,
|
|
State(config): State<Arc<Config>>,
|
|
State(pipeline): State<PipelineState>,
|
|
CaseIdPath(case_id): CaseIdPath,
|
|
) -> Result<Html<String>, AppError> {
|
|
let user_root = config.data_path.join(&user.slug);
|
|
pipeline.heal_orphans_if_idle(&user_root, &user.slug).await;
|
|
|
|
let case_dir = locate_case_or_404(
|
|
&user_root,
|
|
&case_id,
|
|
&user.slug,
|
|
"case recordings (possible IDOR probe)",
|
|
)
|
|
.await?;
|
|
let case_id_str = case_id.to_string();
|
|
info!(slug = %user.slug, case_id = %case_id, "case recordings viewed");
|
|
|
|
let recordings = scan_recordings(&case_dir).await;
|
|
let oneliner = tokio::fs::read_to_string(case_dir.join("oneliner.txt"))
|
|
.await
|
|
.ok()
|
|
.map(|s| s.trim().to_string())
|
|
.filter(|s| !s.is_empty());
|
|
|
|
let t_busy = pipeline.transcribe_busy.0.load(Ordering::Acquire);
|
|
let case_id_short = case_id_str.chars().take(8).collect();
|
|
let is_admin = user.is_admin();
|
|
|
|
CaseRecordingsTemplate {
|
|
slug: user.slug,
|
|
case_id: case_id_str,
|
|
case_id_short,
|
|
oneliner,
|
|
recordings,
|
|
transcribe_busy: t_busy,
|
|
is_admin,
|
|
}
|
|
.render()
|
|
.map(Html)
|
|
.map_err(|e| AppError::Internal(format!("Template render failed: {e}")))
|
|
}
|
|
|
|
/// Locate a case directory under `<user_root>/<case_id>/`. Returns `None`
|
|
/// if the directory does not exist OR is soft-deleted (`.deleted` marker
|
|
/// present). Both are treated as 404 / IDOR probe by callers.
|
|
pub(crate) async fn locate_case(user_root: &Path, case_id: &str) -> Option<std::path::PathBuf> {
|
|
let p = user_root.join(case_id);
|
|
if !tokio::fs::try_exists(&p).await.unwrap_or(false) {
|
|
return None;
|
|
}
|
|
if crate::paths::is_deleted(&p).await {
|
|
return None;
|
|
}
|
|
Some(p)
|
|
}
|
|
|
|
/// Resolve a case directory for a handler, mapping any miss to
|
|
/// `AppError::NotFound("Case not found")` — matches the legacy 404 body
|
|
/// byte-for-byte. The `context` tag distinguishes log lines from the
|
|
/// different handlers (analyze, delete, reset, ...). Callers that need
|
|
/// a different not-found policy (silent skip in bulk ops) should keep
|
|
/// using `locate_case` directly.
|
|
pub(crate) async fn locate_case_or_404(
|
|
user_root: &Path,
|
|
case_id: &CaseId,
|
|
user_slug: &str,
|
|
context: &str,
|
|
) -> Result<std::path::PathBuf, AppError> {
|
|
match locate_case(user_root, &case_id.to_string()).await {
|
|
Some(p) => Ok(p),
|
|
None => {
|
|
warn!(
|
|
slug = %user_slug,
|
|
case_id = %case_id,
|
|
context = %context,
|
|
"case not found",
|
|
);
|
|
Err(AppError::NotFound("Case not found".into()))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Scan all of the given user's cases. Returned vec is sorted by most
|
|
/// recent recording filename (lexicographic ≈ chronological since
|
|
/// timestamps are ISO-8601), newest first. Soft-deleted cases (with
|
|
/// `.deleted` marker) are excluded.
|
|
async fn scan_user_cases(config: &Config, slug: &str, worker_busy: bool) -> Vec<UserCaseView> {
|
|
let user_root = config.data_path.join(slug);
|
|
let llm_configured = config.llm_configured();
|
|
let mut cases = Vec::new();
|
|
|
|
let mut entries = match tokio::fs::read_dir(&user_root).await {
|
|
Ok(r) => r,
|
|
Err(_) => return cases,
|
|
};
|
|
|
|
while let Ok(Some(entry)) = entries.next_entry().await {
|
|
let Some((case_id, case_path)) = filter_valid_case_dir(entry).await else {
|
|
continue;
|
|
};
|
|
if let Some(view) =
|
|
compute_case_view(case_id, &case_path, llm_configured, worker_busy).await
|
|
{
|
|
cases.push(view);
|
|
}
|
|
}
|
|
|
|
cases.sort_by(|a, b| b.most_recent.cmp(&a.most_recent));
|
|
cases
|
|
}
|
|
|
|
/// Accept an entry only if it is a non-deleted directory whose name is
|
|
/// a valid UUID. Returns the (case_id-string, absolute path) tuple so
|
|
/// the caller does not need to recompute either. Non-UUID names are
|
|
/// logged at WARN level; other rejections are silent (common case).
|
|
async fn filter_valid_case_dir(entry: tokio::fs::DirEntry) -> Option<(String, std::path::PathBuf)> {
|
|
let case_id = entry.file_name().into_string().ok()?;
|
|
if uuid::Uuid::parse_str(&case_id).is_err() {
|
|
warn!(case_id = %case_id, "Skipping non-UUID directory");
|
|
return None;
|
|
}
|
|
let case_path = entry.path();
|
|
if !case_path.is_dir() {
|
|
return None;
|
|
}
|
|
if crate::paths::is_deleted(&case_path).await {
|
|
return None;
|
|
}
|
|
Some((case_id, case_path))
|
|
}
|
|
|
|
/// Build a `UserCaseView` for a single case directory. Returns `None`
|
|
/// for empty cases (no recordings yet) so the caller can skip them.
|
|
async fn compute_case_view(
|
|
case_id: String,
|
|
case_path: &Path,
|
|
llm_configured: bool,
|
|
worker_busy: bool,
|
|
) -> Option<UserCaseView> {
|
|
let recordings = scan_recordings(case_path).await;
|
|
if recordings.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
let most_recent = recordings
|
|
.last()
|
|
.map(|r| r.filename.clone())
|
|
.unwrap_or_default();
|
|
let recordings_count = recordings.len();
|
|
let non_failed_count = recordings.iter().filter(|r| !r.failed).count();
|
|
let all_transcribed = non_failed_count > 0
|
|
&& recordings
|
|
.iter()
|
|
.filter(|r| !r.failed)
|
|
.all(|r| r.transcript.is_some());
|
|
|
|
let oneliner = tokio::fs::read_to_string(case_path.join("oneliner.txt"))
|
|
.await
|
|
.ok()
|
|
.map(|s| s.trim().to_string())
|
|
.filter(|s| !s.is_empty());
|
|
|
|
let has_document = any_document_exists(case_path).await;
|
|
let analyzing = !has_document && worker_busy && any_analysis_input_exists(case_path).await;
|
|
let can_analyze = !analyzing && all_transcribed && llm_configured;
|
|
|
|
let time_hms_utc = extract_hhmm_utc(&most_recent);
|
|
let recorded_at_iso = recorded_at_iso_of(&most_recent);
|
|
Some(UserCaseView {
|
|
case_id,
|
|
most_recent,
|
|
time_hms_utc,
|
|
recorded_at_iso,
|
|
oneliner,
|
|
recordings_count,
|
|
analyzing,
|
|
has_document,
|
|
can_analyze,
|
|
})
|
|
}
|