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:
@@ -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);
|
||||
|
||||
@@ -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 `<html>`; 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<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
|
||||
/// 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<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(
|
||||
@@ -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<UserCaseView> {
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user