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
|
/// 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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user