Refactor auto-analysis to use CaseAnalysisState

The `evaluate_case` function has been replaced with `evaluate_state` in
the `auto_trigger` module. This change introduces a new enum,
`CaseAnalysisState`, which provides a more granular representation of a
case's analysis status.

The `try_enqueue` and `try_enqueue_all_for_user` functions now utilize
`evaluate_state` to determine if analysis should proceed. This ensures
that analysis is only triggered for cases in the `Idle` or `Required`
states.

Additionally, the `CaseFlags` struct has been updated to include
`analysis_stale_with_doc`, and corresponding logic has been added in
`compute_flags` and `compute_case_view` to accurately reflect the
analysis status for UI display. The `UserCaseView` and `MyCasesTemplate`
structs have also been updated to incorporate these new fields.
This commit is contained in:
2026-05-05 14:31:29 +02:00
parent 0660727faf
commit c5798eb877
2 changed files with 102 additions and 9 deletions
+27 -7
View File
@@ -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 /// iff a job was actually sent. Silent on routine skips; `warn!` only on
/// actual failures (build_input, write, send). /// 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 /// LLM-availability is *not* checked here — callers (web handlers) check
/// via `analyze::backend::any_backend_available()` before deciding to /// via `analyze::backend::any_backend_available()` before deciding to
/// render the analyze affordance at all. If the worker dequeues a job /// 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 /// without an available backend it will fail at the LLM call and write a
/// failure marker, which is graceful degradation rather than data loss. /// failure marker, which is graceful degradation rather than data loss.
pub async fn try_enqueue(case_dir: &Path, tx: &AnalyzeSender, events_tx: &EventSender) -> bool { pub async fn try_enqueue(
match evaluate_case(case_dir).await { case_dir: &Path,
AutoDecision::Skip(_) => return false, idle_threshold: std::time::Duration,
AutoDecision::Enqueue => {} 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 { 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. /// `/cases` render — skips are cheap filesystem stats.
pub async fn try_enqueue_all_for_user( pub async fn try_enqueue_all_for_user(
user_root: &Path, user_root: &Path,
idle_threshold: std::time::Duration,
now: SystemTime,
tx: &AnalyzeSender, tx: &AnalyzeSender,
events_tx: &EventSender, events_tx: &EventSender,
) { ) {
@@ -331,7 +344,7 @@ pub async fn try_enqueue_all_for_user(
if crate::paths::is_closed(&case_path).await { if crate::paths::is_closed(&case_path).await {
continue; 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 (tx, mut rx) = analyze_channel();
let events_tx = events_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 the sender half so recv closes after draining.
drop(tx); drop(tx);
+75 -2
View File
@@ -87,6 +87,11 @@ struct UserCaseView {
/// Requires: not currently analyzing, all non-failed recordings /// Requires: not currently analyzing, all non-failed recordings
/// transcribed, and an LLM is configured. /// transcribed, and an LLM is configured.
can_analyze: bool, 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 /// True iff the case carries a `.closed` marker. Drives the muted
/// styling, the Close/Reopen button swap, and the purge countdown. /// styling, the Close/Reopen button swap, and the purge countdown.
is_closed: bool, is_closed: bool,
@@ -288,6 +293,10 @@ struct MyCasesTemplate {
/// CSS `--preview-lines` custom property on `<html>`; the browser /// CSS `--preview-lines` custom property on `<html>`; the browser
/// applies it via `line-clamp`. Coming from `users.toml`. /// applies it via `line-clamp`. Coming from `users.toml`.
preview_lines: u32, 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, /// Session CSRF token — rendered as a hidden field into the bulk,
/// purge-closed, and logout forms. /// purge-closed, and logout forms.
csrf_token: String, csrf_token: String,
@@ -339,6 +348,10 @@ struct CasePageTemplate {
/// the user is never stranded when a transient LLM error left only a /// the user is never stranded when a transient LLM error left only a
/// `.analysis_failed.json` marker behind. See `compute_flags`. /// `.analysis_failed.json` marker behind. See `compute_flags`.
analysis_failed: Option<FailureBanner>, analysis_failed: Option<FailureBanner>,
/// 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 /// True iff at least one `.m4a.failed` recording exists. Drives the
/// verdrängende `fehler`-Badge in the case header; see the same /// verdrängende `fehler`-Badge in the case header; see the same
/// field on `UserCaseView` for semantics. /// field on `UserCaseView` for semantics.
@@ -427,6 +440,12 @@ struct CaseFlags {
/// `Some` iff a previous LLM run failed AND no document exists yet. /// `Some` iff a previous LLM run failed AND no document exists yet.
/// Drives the dead-end-recovery banner on the case page. /// Drives the dead-end-recovery banner on the case page.
analysis_failed: Option<FailureBanner>, analysis_failed: Option<FailureBanner>,
/// 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( async fn compute_flags(
@@ -434,6 +453,8 @@ async fn compute_flags(
recordings: &[RecordingView], recordings: &[RecordingView],
llm_configured: bool, llm_configured: bool,
worker_busy: bool, worker_busy: bool,
idle_threshold: std::time::Duration,
now_systime: std::time::SystemTime,
) -> CaseFlags { ) -> CaseFlags {
let has_document = any_document_exists(case_dir).await; let has_document = any_document_exists(case_dir).await;
// Honest status: an input file alone does not mean a job is in flight. // Honest status: an input file alone does not mean a job is in flight.
@@ -475,12 +496,20 @@ async fn compute_flags(
None 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 { CaseFlags {
has_document, has_document,
analyzing, analyzing,
can_analyze: analyzable && llm_configured, can_analyze: analyzable && llm_configured,
llm_missing: analyzable && !llm_configured, llm_missing: analyzable && !llm_configured,
analysis_failed, 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 // render. The common case is "nothing to do" and costs a handful of
// stat-calls per case; actual enqueues happen only when pre-conditions // stat-calls per case; actual enqueues happen only when pre-conditions
// flip (new transcripts in, stale document, ...). // 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 // Single users.toml lookup feeds both the retention sweep below and
// the per-user preview-lines cap further down. Cheaper than walking // the per-user preview-lines cap further down. Cheaper than walking
@@ -619,6 +657,12 @@ pub async fn handle_my_cases(
.await; .await;
let total = cases.len(); let total = cases.len();
let any_closed = cases.iter().any(|c| c.is_closed); 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 // Under `show_closed=false` the case list skips closed entries
// entirely — but the per-day `Y/Z Fälle` badge still needs to count // 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 // them for `Z`. Do a lightweight second scan (most-recent date per
@@ -641,6 +685,7 @@ pub async fn handle_my_cases(
show_closed, show_closed,
any_closed, any_closed,
preview_lines, preview_lines,
required_count,
} }
.render() .render()
.map(Html) .map(Html)
@@ -698,7 +743,16 @@ pub async fn handle_case_page(
// Auto-analysis trigger for deep-link navigation: same evaluation as // Auto-analysis trigger for deep-link navigation: same evaluation as
// the list view. Document render below still wins the race if the // the list view. Document render below still wins the race if the
// worker happens to finish synchronously. // 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(); let case_id_str = case_id.to_string();
info!(slug = %user.slug, case_id = %case_id, "case page viewed"); info!(slug = %user.slug, case_id = %case_id, "case page viewed");
@@ -721,6 +775,8 @@ pub async fn handle_case_page(
&recordings, &recordings,
crate::analyze::backend::any_backend_available(), crate::analyze::backend::any_backend_available(),
a_busy, a_busy,
idle_threshold,
now,
) )
.await; .await;
@@ -777,6 +833,7 @@ pub async fn handle_case_page(
analyzing: flags.analyzing, analyzing: flags.analyzing,
has_document: flags.has_document, has_document: flags.has_document,
analysis_failed: flags.analysis_failed, analysis_failed: flags.analysis_failed,
analysis_stale_with_doc: flags.analysis_stale_with_doc,
has_failed_recording, has_failed_recording,
is_admin, is_admin,
is_closed, is_closed,
@@ -929,6 +986,8 @@ async fn scan_user_cases(
let user_root = config.data_path.join(slug); let user_root = config.data_path.join(slug);
let mut cases = Vec::new(); let mut cases = Vec::new();
let now = OffsetDateTime::now_utc(); 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 { let mut entries = match tokio::fs::read_dir(&user_root).await {
Ok(r) => r, Ok(r) => r,
@@ -946,6 +1005,8 @@ async fn scan_user_cases(
worker_busy, worker_busy,
auto_delete_days, auto_delete_days,
now, now,
idle_threshold,
now_systime,
) )
.await .await
{ {
@@ -982,6 +1043,7 @@ async fn filter_valid_case_dir(
/// Build a `UserCaseView` for a single case directory. Returns `None` /// Build a `UserCaseView` for a single case directory. Returns `None`
/// for empty cases (no recordings yet) so the caller can skip them. /// for empty cases (no recordings yet) so the caller can skip them.
#[allow(clippy::too_many_arguments)]
async fn compute_case_view( async fn compute_case_view(
case_id: String, case_id: String,
case_path: &Path, case_path: &Path,
@@ -989,6 +1051,8 @@ async fn compute_case_view(
worker_busy: bool, worker_busy: bool,
auto_delete_days: u32, auto_delete_days: u32,
now: OffsetDateTime, now: OffsetDateTime,
idle_threshold: std::time::Duration,
now_systime: std::time::SystemTime,
) -> Option<UserCaseView> { ) -> Option<UserCaseView> {
let recordings = scan_recordings(case_path).await; let recordings = scan_recordings(case_path).await;
if recordings.is_empty() { 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 analyzing = !has_document && worker_busy && any_analysis_input_exists(case_path).await;
let can_analyze = !analyzing && all_transcribed && llm_configured; 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; 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 // 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_document,
has_failed_recording, has_failed_recording,
can_analyze, can_analyze,
analysis_required,
is_closed, is_closed,
days_until_purge, days_until_purge,
analysis_html, analysis_html,
@@ -1170,6 +1242,7 @@ mod tests {
has_document: false, has_document: false,
has_failed_recording: false, has_failed_recording: false,
can_analyze: false, can_analyze: false,
analysis_required: false,
is_closed, is_closed,
days_until_purge: None, days_until_purge: None,
analysis_html: None, analysis_html: None,