diff --git a/server/src/analyze/auto_trigger.rs b/server/src/analyze/auto_trigger.rs index 539507a..1936d0e 100644 --- a/server/src/analyze/auto_trigger.rs +++ b/server/src/analyze/auto_trigger.rs @@ -254,19 +254,30 @@ pub async fn evaluate_case(case_dir: &Path) -> AutoDecision { } } -/// Evaluate and, on `Enqueue`, prepare and send the job. Returns `true` +/// Evaluate and, on `Idle`, prepare and send the job. Returns `true` /// iff a job was actually sent. Silent on routine skips; `warn!` only on /// actual failures (build_input, write, send). /// +/// `idle_threshold` and `now` flow through to [`evaluate_state`]: only +/// `Idle` (= stale document AND quiet window elapsed) fires. `Required` +/// returns `false` — the user-facing "Aktualisieren"-bulk endpoint is +/// the manual override path for those. +/// /// LLM-availability is *not* checked here — callers (web handlers) check /// via `analyze::backend::any_backend_available()` before deciding to /// render the analyze affordance at all. If the worker dequeues a job /// without an available backend it will fail at the LLM call and write a /// failure marker, which is graceful degradation rather than data loss. -pub async fn try_enqueue(case_dir: &Path, tx: &AnalyzeSender, events_tx: &EventSender) -> bool { - match evaluate_case(case_dir).await { - AutoDecision::Skip(_) => return false, - AutoDecision::Enqueue => {} +pub async fn try_enqueue( + case_dir: &Path, + idle_threshold: std::time::Duration, + now: SystemTime, + tx: &AnalyzeSender, + events_tx: &EventSender, +) -> bool { + match evaluate_state(case_dir, idle_threshold, now).await { + CaseAnalysisState::Idle { .. } => {} + _ => return false, } let input = match build_analysis_input(case_dir, "").await { @@ -311,6 +322,8 @@ pub async fn try_enqueue(case_dir: &Path, tx: &AnalyzeSender, events_tx: &EventS /// `/cases` render — skips are cheap filesystem stats. pub async fn try_enqueue_all_for_user( user_root: &Path, + idle_threshold: std::time::Duration, + now: SystemTime, tx: &AnalyzeSender, events_tx: &EventSender, ) { @@ -331,7 +344,7 @@ pub async fn try_enqueue_all_for_user( if crate::paths::is_closed(&case_path).await { continue; } - try_enqueue(&case_path, tx, events_tx).await; + try_enqueue(&case_path, idle_threshold, now, tx, events_tx).await; } } @@ -786,7 +799,14 @@ mod tests { let (tx, mut rx) = analyze_channel(); let events_tx = events_channel(); - try_enqueue_all_for_user(user_root.path(), &tx, &events_tx).await; + try_enqueue_all_for_user( + user_root.path(), + std::time::Duration::ZERO, + SystemTime::now(), + &tx, + &events_tx, + ) + .await; // Drop the sender half so recv closes after draining. drop(tx); diff --git a/server/src/routes/user_web.rs b/server/src/routes/user_web.rs index 5e1d62e..46cd288 100644 --- a/server/src/routes/user_web.rs +++ b/server/src/routes/user_web.rs @@ -87,6 +87,11 @@ struct UserCaseView { /// Requires: not currently analyzing, all non-failed recordings /// transcribed, and an LLM is configured. can_analyze: bool, + /// True iff the typed `CaseAnalysisState` is `Required` or `Idle`, + /// i.e. the latest recording is not yet covered by `document.json`. + /// Drives the per-row "Analyse erforderlich"-badge AND feeds the + /// list-level `required_count` for the bulk "Aktualisieren"-button. + analysis_required: bool, /// True iff the case carries a `.closed` marker. Drives the muted /// styling, the Close/Reopen button swap, and the purge countdown. is_closed: bool, @@ -288,6 +293,10 @@ struct MyCasesTemplate { /// CSS `--preview-lines` custom property on ``; the browser /// applies it via `line-clamp`. Coming from `users.toml`. preview_lines: u32, + /// Number of cases whose state is `Required` (or `Idle`) — drives + /// the visibility of the top-of-list "N Fälle erfordern Analyse + /// [Aktualisieren]"-bar. Zero hides the bar entirely. + required_count: usize, /// Session CSRF token — rendered as a hidden field into the bulk, /// purge-closed, and logout forms. csrf_token: String, @@ -339,6 +348,10 @@ struct CasePageTemplate { /// the user is never stranded when a transient LLM error left only a /// `.analysis_failed.json` marker behind. See `compute_flags`. analysis_failed: Option, + /// True iff a document exists but does not yet cover the latest + /// recording. Drives a small info banner above the document body. + /// See [`CaseFlags::analysis_stale_with_doc`]. + analysis_stale_with_doc: bool, /// True iff at least one `.m4a.failed` recording exists. Drives the /// verdrängende `fehler`-Badge in the case header; see the same /// field on `UserCaseView` for semantics. @@ -427,6 +440,12 @@ struct CaseFlags { /// `Some` iff a previous LLM run failed AND no document exists yet. /// Drives the dead-end-recovery banner on the case page. analysis_failed: Option, + /// True iff the case state is `Required`/`Idle` AND a document + /// exists. Drives the "Analyse nicht aktuell — neue Aufnahmen seit + /// letztem Lauf"-info banner above the document on the case page. + /// `false` when no document exists (the failure-banner / placeholder + /// branches handle that visual state). + analysis_stale_with_doc: bool, } async fn compute_flags( @@ -434,6 +453,8 @@ async fn compute_flags( recordings: &[RecordingView], llm_configured: bool, worker_busy: bool, + idle_threshold: std::time::Duration, + now_systime: std::time::SystemTime, ) -> CaseFlags { let has_document = any_document_exists(case_dir).await; // Honest status: an input file alone does not mean a job is in flight. @@ -475,12 +496,20 @@ async fn compute_flags( None }; + let state = auto_trigger::evaluate_state(case_dir, idle_threshold, now_systime).await; + let analysis_stale_with_doc = matches!( + state, + auto_trigger::CaseAnalysisState::Required { has_document: true } + | auto_trigger::CaseAnalysisState::Idle { has_document: true } + ); + CaseFlags { has_document, analyzing, can_analyze: analyzable && llm_configured, llm_missing: analyzable && !llm_configured, analysis_failed, + analysis_stale_with_doc, } } @@ -591,7 +620,16 @@ pub async fn handle_my_cases( // 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, &events_tx).await; + let idle_threshold = std::time::Duration::from_secs(config.analysis_idle_threshold_secs); + let now = std::time::SystemTime::now(); + auto_trigger::try_enqueue_all_for_user( + &user_root, + idle_threshold, + now, + &pipeline.analyze_tx, + &events_tx, + ) + .await; // Single users.toml lookup feeds both the retention sweep below and // the per-user preview-lines cap further down. Cheaper than walking @@ -619,6 +657,12 @@ pub async fn handle_my_cases( .await; let total = cases.len(); let any_closed = cases.iter().any(|c| c.is_closed); + // List-level summary count for the bulk "Aktualisieren"-button. We + // intentionally include closed cases here too — the button operates + // on the same population the badge does. If a closed case has + // `analysis_required` it would not actually fire (the bulk handler + // skips closed), but in practice closed cases are `Current` or hidden. + let required_count = cases.iter().filter(|c| c.analysis_required).count(); // Under `show_closed=false` the case list skips closed entries // entirely — but the per-day `Y/Z Fälle` badge still needs to count // them for `Z`. Do a lightweight second scan (most-recent date per @@ -641,6 +685,7 @@ pub async fn handle_my_cases( show_closed, any_closed, preview_lines, + required_count, } .render() .map(Html) @@ -698,7 +743,16 @@ pub async fn handle_case_page( // 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, &events_tx).await; + let idle_threshold = std::time::Duration::from_secs(config.analysis_idle_threshold_secs); + let now = std::time::SystemTime::now(); + auto_trigger::try_enqueue( + &case_dir, + idle_threshold, + now, + &pipeline.analyze_tx, + &events_tx, + ) + .await; let case_id_str = case_id.to_string(); info!(slug = %user.slug, case_id = %case_id, "case page viewed"); @@ -721,6 +775,8 @@ pub async fn handle_case_page( &recordings, crate::analyze::backend::any_backend_available(), a_busy, + idle_threshold, + now, ) .await; @@ -777,6 +833,7 @@ pub async fn handle_case_page( analyzing: flags.analyzing, has_document: flags.has_document, analysis_failed: flags.analysis_failed, + analysis_stale_with_doc: flags.analysis_stale_with_doc, has_failed_recording, is_admin, is_closed, @@ -929,6 +986,8 @@ async fn scan_user_cases( let user_root = config.data_path.join(slug); let mut cases = Vec::new(); let now = OffsetDateTime::now_utc(); + let now_systime = std::time::SystemTime::now(); + let idle_threshold = std::time::Duration::from_secs(config.analysis_idle_threshold_secs); let mut entries = match tokio::fs::read_dir(&user_root).await { Ok(r) => r, @@ -946,6 +1005,8 @@ async fn scan_user_cases( worker_busy, auto_delete_days, now, + idle_threshold, + now_systime, ) .await { @@ -982,6 +1043,7 @@ async fn filter_valid_case_dir( /// Build a `UserCaseView` for a single case directory. Returns `None` /// for empty cases (no recordings yet) so the caller can skip them. +#[allow(clippy::too_many_arguments)] async fn compute_case_view( case_id: String, case_path: &Path, @@ -989,6 +1051,8 @@ async fn compute_case_view( worker_busy: bool, auto_delete_days: u32, now: OffsetDateTime, + idle_threshold: std::time::Duration, + now_systime: std::time::SystemTime, ) -> Option { let recordings = scan_recordings(case_path).await; if recordings.is_empty() { @@ -1014,6 +1078,13 @@ async fn compute_case_view( let analyzing = !has_document && worker_busy && any_analysis_input_exists(case_path).await; let can_analyze = !analyzing && all_transcribed && llm_configured; + let state = auto_trigger::evaluate_state(case_path, idle_threshold, now_systime).await; + let analysis_required = matches!( + state, + crate::analyze::auto_trigger::CaseAnalysisState::Required { .. } + | crate::analyze::auto_trigger::CaseAnalysisState::Idle { .. } + ); + let (is_closed, days_until_purge) = closed_state(case_path, auto_delete_days, now).await; // One extra read per case with a document. Documents are in the KB @@ -1043,6 +1114,7 @@ async fn compute_case_view( has_document, has_failed_recording, can_analyze, + analysis_required, is_closed, days_until_purge, analysis_html, @@ -1170,6 +1242,7 @@ mod tests { has_document: false, has_failed_recording: false, can_analyze: false, + analysis_required: false, is_closed, days_until_purge: None, analysis_html: None,