23ef84d9d8
Replace the single [llm] block in settings.toml with a curated catalog of LLM "backends" (provider + model + sampling params + system prompt) in server/src/analyze/backend.rs. Two backends ship initially: gpt_oss_120b (default, reasoning_effort=medium) and llama_3_1_405b (uses response_format: json_schema to sidestep the <|eot_id|> leak). The case page now renders one submit button per available backend; the clicked button's name=backend value rides through AnalysisInput.backend_id to the worker, which looks it up via find_backend() with default fallback. A submitted unknown backend id is rejected as 400. .analysis_failed.json records the failed backend and the failure banner surfaces its label. The API key now comes from IONOS_API_KEY (env), not settings.toml; the [llm] section is read into a discarded field so old configs still load. Backends without a satisfied requires_api_key are filtered from the UI (any_backend_available()). Three end-to-end tests that mocked the LLM endpoint via settings.llm.url are #[ignore]'d with a clear note — they need per-test backend injection (Arc<Vec<LlmBackend>> through the worker) before they can be re-enabled.
1246 lines
48 KiB
Rust
1246 lines
48 KiB
Rust
use std::collections::HashMap;
|
|
use std::path::Path;
|
|
use std::sync::Arc;
|
|
use std::sync::atomic::Ordering;
|
|
|
|
use askama::Template;
|
|
use axum::extract::{Query, State};
|
|
use axum::response::Html;
|
|
use doctate_common::TranscriptState;
|
|
use doctate_common::oneliners::OnelinerState;
|
|
use serde::Deserialize;
|
|
use tracing::{info, warn};
|
|
|
|
use time::{Date, OffsetDateTime, format_description::well_known::Rfc3339};
|
|
|
|
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::gazetteer::Gazetteer;
|
|
use crate::paths;
|
|
use crate::routes::case_actions::read_document;
|
|
use crate::routes::web::{RecordingView, scan_recordings};
|
|
use crate::settings::Settings;
|
|
use crate::transcribe::recovery as transcribe_recovery;
|
|
use crate::{BusyGuard, PipelineState};
|
|
|
|
/// UI-layer oneliner status for the case list. Combines the persisted
|
|
/// [`OnelinerState`] with derived transit states that only make sense
|
|
/// in the list context (transcription still pending, or no state file
|
|
/// despite completed transcription).
|
|
///
|
|
/// Kept local to the server — not part of the public API DTO, which
|
|
/// exposes the raw [`OnelinerState`] and lets each client decide how
|
|
/// to render transit states.
|
|
enum OnelinerDisplay {
|
|
/// LLM produced a usable title.
|
|
Ready(String),
|
|
/// Stable "no title to expect" — either the LLM returned nothing
|
|
/// (no medical content in transcript) or every recording failed so
|
|
/// nothing can ever generate a title. UI treats both identically;
|
|
/// the reason is uninteresting to the doctor in the list view.
|
|
Empty,
|
|
/// LLM call failed with transcripts available.
|
|
Error,
|
|
/// Transcription still running — a title cannot exist yet.
|
|
Pending,
|
|
/// Transcripts are done (or about to land) but the LLM hasn't
|
|
/// written a state yet, OR a previous state is obsolete because new
|
|
/// recordings arrived. The worker is generating right now or will
|
|
/// generate as soon as the last transcript is in. Covers the
|
|
/// reset-just-clicked case and the post-restart recovery window.
|
|
Generating,
|
|
}
|
|
|
|
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: OnelinerDisplay,
|
|
/// True iff the persisted oneliner is `OnelinerState::Manual { .. }`
|
|
/// AND the displayed text actually shows that manual content (i.e.
|
|
/// not while we're rendering a Pending/Generating placeholder). The
|
|
/// template uses this to render a 👤 marker next to the title so the
|
|
/// doctor can tell at a glance which titles they themselves wrote.
|
|
oneliner_is_manual: bool,
|
|
recordings_count: usize,
|
|
analyzing: bool,
|
|
has_document: bool,
|
|
/// True iff at least one recording is `.m4a.failed` — a *permanent*
|
|
/// transcribe failure (ffmpeg remux or Whisper 4xx). Transient Whisper
|
|
/// errors never set this flag because they leave the file as plain
|
|
/// `.m4a` for the page-load heal to retry. Drives the verdrängende
|
|
/// `fehler`-Badge over `offen`/`ausgewertet`.
|
|
has_failed_recording: 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,
|
|
/// True iff the case carries a `.closed` marker. Drives the muted
|
|
/// styling, the Close/Reopen button swap, and the purge countdown.
|
|
is_closed: bool,
|
|
/// Remaining days before auto-purge. `Some(n)` only when the case
|
|
/// is closed AND `auto_delete_days > 0`; `None` otherwise (so the
|
|
/// template can render "wird in N Tagen entfernt" vs. plain
|
|
/// "geschlossen"). Clamped at 0 — cases eligible for purge this
|
|
/// sweep render as "0" briefly.
|
|
days_until_purge: Option<u32>,
|
|
/// Full `document.md` rendered through the same `md_to_html`
|
|
/// pipeline as the case-detail page. `None` when the case has no
|
|
/// document yet. The browser clamps this to `preview_lines` in the
|
|
/// collapsed state and shows it whole when expanded — single
|
|
/// rendering, two CSS layouts, no server-side truncation.
|
|
analysis_html: Option<String>,
|
|
}
|
|
|
|
/// 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.
|
|
///
|
|
/// `closed_extra` carries the count of *closed* cases per day-label that
|
|
/// are NOT present in `cases` (i.e. the default `show_closed=false`
|
|
/// view). Each group's `total_count` is then `cases_in_group +
|
|
/// closed_extra[label]`. Under `show_closed=true` the caller passes an
|
|
/// empty map — closed cases are already in `cases` with `is_closed=true`
|
|
/// and get counted directly.
|
|
fn group_by_utc_date(
|
|
cases: Vec<UserCaseView>,
|
|
closed_extra: &HashMap<String, usize>,
|
|
) -> 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],
|
|
open_count: 0,
|
|
total_count: 0,
|
|
}),
|
|
}
|
|
}
|
|
// Second pass: fill in the per-group counts. Doing this after all
|
|
// cases have landed in their groups keeps the push-logic above
|
|
// simple and avoids re-counting on every append.
|
|
for g in &mut groups {
|
|
g.open_count = g.cases.iter().filter(|c| !c.is_closed).count();
|
|
g.total_count = g.cases.len() + closed_extra.get(&g.label).copied().unwrap_or(0);
|
|
}
|
|
groups
|
|
}
|
|
|
|
/// Count closed cases per UTC-date label for the `show_closed=false`
|
|
/// listing — the default view where closed cases are hidden from the
|
|
/// case rows but still contribute to the `Z` in `Y/Z Fälle`.
|
|
///
|
|
/// Lightweight on purpose: per closed case we do exactly one `read_dir`
|
|
/// and pick the lexicographically largest `.m4a[.failed]` filename as
|
|
/// the most-recent recording, then extract the UTC date. No oneliner,
|
|
/// document, or flag computation — those aren't rendered for hidden
|
|
/// cases anyway.
|
|
async fn count_closed_by_date(user_root: &Path) -> HashMap<String, usize> {
|
|
let mut out: HashMap<String, usize> = HashMap::new();
|
|
let Ok(mut entries) = tokio::fs::read_dir(user_root).await else {
|
|
return out;
|
|
};
|
|
while let Ok(Some(entry)) = entries.next_entry().await {
|
|
let Ok(case_id) = entry.file_name().into_string() else {
|
|
continue;
|
|
};
|
|
if uuid::Uuid::parse_str(&case_id).is_err() {
|
|
continue;
|
|
}
|
|
let case_path = entry.path();
|
|
if !case_path.is_dir() {
|
|
continue;
|
|
}
|
|
if !crate::paths::is_closed(&case_path).await {
|
|
continue;
|
|
}
|
|
if let Some(label) = most_recent_date_label(&case_path).await {
|
|
*out.entry(label).or_insert(0) += 1;
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Return the UTC-date label (`YYYY-MM-DD`) of the most recent recording
|
|
/// in `case_path`, or `None` if the directory has no parseable recording
|
|
/// filenames. Matches the `group_by_utc_date` bucketing rule exactly so
|
|
/// a closed case counts toward the same day its visible siblings do.
|
|
async fn most_recent_date_label(case_path: &Path) -> Option<String> {
|
|
let mut entries = tokio::fs::read_dir(case_path).await.ok()?;
|
|
let mut best: Option<String> = None;
|
|
while let Ok(Some(entry)) = entries.next_entry().await {
|
|
// Non-UTF8 filenames skipped, not fatal — an unparseable entry
|
|
// must not shadow a valid recording sitting next to it.
|
|
let Ok(name) = entry.file_name().into_string() else {
|
|
continue;
|
|
};
|
|
// Accept both `.m4a` and `.m4a.failed` — both mark a recording
|
|
// slot, and failed files still carry a valid timestamp prefix.
|
|
if !(name.ends_with(".m4a") || name.ends_with(".m4a.failed")) {
|
|
continue;
|
|
}
|
|
if best.as_deref().map(|b| name.as_str() > b).unwrap_or(true) {
|
|
best = Some(name);
|
|
}
|
|
}
|
|
let name = best?;
|
|
utc_date_and_iso_of(&name).map(|(d, _)| d.to_string())
|
|
}
|
|
|
|
/// 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>,
|
|
/// Count of *open* (non-closed) cases in this group. Matches the
|
|
/// number of rows actually rendered in the default view (where
|
|
/// closed cases are hidden). Rendered as the `Y` in `Y/Z Fälle`.
|
|
open_count: usize,
|
|
/// Count of *all* cases whose most-recent recording falls on this
|
|
/// date — open plus closed, regardless of the show_closed toggle.
|
|
/// Rendered as the `Z` in `Y/Z Fälle` so doctors can see how many
|
|
/// cases were actually worked that day even when the list hides
|
|
/// the closed ones.
|
|
total_count: usize,
|
|
}
|
|
|
|
#[derive(Template)]
|
|
#[template(path = "my_cases.html")]
|
|
struct MyCasesTemplate {
|
|
slug: String,
|
|
groups: Vec<DateGroup>,
|
|
total: usize,
|
|
/// True iff the session user is an admin — toggles full case_id display.
|
|
is_admin: bool,
|
|
/// Reflects the `?show_closed=1` query param. Controls the toggle
|
|
/// link state and makes the bulk-purge button + per-case reopen
|
|
/// buttons visible.
|
|
show_closed: bool,
|
|
/// True iff at least one closed case is currently visible. Needed
|
|
/// to gate the bulk-purge bar so it does not appear on an
|
|
/// all-open list rendered with `show_closed=1`.
|
|
any_closed: bool,
|
|
/// Per-user cap on visible analysis-preview lines. Rendered as a
|
|
/// CSS `--preview-lines` custom property on `<html>`; the browser
|
|
/// applies it via `line-clamp`. Coming from `users.toml`.
|
|
preview_lines: u32,
|
|
/// Session CSRF token — rendered as a hidden field into the bulk,
|
|
/// purge-closed, and logout forms.
|
|
csrf_token: String,
|
|
}
|
|
|
|
/// Query params for GET /web/cases and /web/cases/{case_id}.
|
|
#[derive(Debug, Default, Deserialize)]
|
|
pub struct CaseListQuery {
|
|
/// `?show_closed=1` expands the list to include closed cases and
|
|
/// swaps per-case close buttons with reopen buttons. Any other
|
|
/// value (missing, "0", empty) keeps the default "open only" view.
|
|
#[serde(default)]
|
|
show_closed: Option<String>,
|
|
}
|
|
|
|
impl CaseListQuery {
|
|
fn include_closed(&self) -> bool {
|
|
matches!(self.show_closed.as_deref(), Some("1"))
|
|
}
|
|
}
|
|
|
|
#[derive(Template)]
|
|
#[template(path = "case_page.html")]
|
|
struct CasePageTemplate {
|
|
case_id: String,
|
|
case_id_short: String,
|
|
oneliner: OnelinerDisplay,
|
|
/// Mirror of `UserCaseView::oneliner_is_manual`. See that field for
|
|
/// the suppression-during-progress rationale.
|
|
oneliner_is_manual: bool,
|
|
/// RFC3339 UTC timestamp of the most recent recording. `None` iff the
|
|
/// case has no recordings at all. Drives the datetime header under the
|
|
/// title; browser JS formats it to "Heute 11:34" / "13.12.2022 11:34".
|
|
recorded_at_iso: Option<String>,
|
|
/// UTC `HH:MM` as a no-JS fallback inside the `<time>` element.
|
|
time_hms_utc: 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,
|
|
/// `Some` iff a previous LLM analysis failed AND no document exists.
|
|
/// Renders a dead-end-recovery banner with reason + retry button so
|
|
/// 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 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.
|
|
has_failed_recording: bool,
|
|
is_admin: bool,
|
|
/// True when the case carries a `.closed` marker. Toggles the
|
|
/// header action button between close and reopen, and appends
|
|
/// a "?show_closed=1" to the form action so a freshly reopened
|
|
/// case still lands on the detail page.
|
|
is_closed: bool,
|
|
/// Session CSRF token — rendered as a hidden field into every
|
|
/// state-changing form on the page (close/reopen/analyze/reset/logout).
|
|
csrf_token: String,
|
|
/// Admin-only debug copy bundle, pre-serialised as JSON for inline
|
|
/// embedding into a `<script type="application/json">` block. Shape:
|
|
/// `{"transcripts":["..."],"document":"..."|null}`. Empty string when
|
|
/// `is_admin` is false (template skips the block in that case anyway).
|
|
/// Pre-sanitised against `</script>` breakout via `<\/` substitution.
|
|
debug_copy_json: String,
|
|
/// Available LLM backends (filtered to those whose auth is satisfied).
|
|
/// One submit button per entry is rendered next to the analyze form.
|
|
/// The first entry is the default backend.
|
|
backends: Vec<&'static crate::analyze::backend::LlmBackend>,
|
|
}
|
|
|
|
#[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,
|
|
/// Session CSRF token — rendered as a hidden field into the
|
|
/// per-recording delete forms and the logout form.
|
|
csrf_token: String,
|
|
}
|
|
|
|
/// Information about a previous failed analysis run, surfaced in the
|
|
/// case page when no document exists yet so the user always has an
|
|
/// affordance to retry. Populated from `.analysis_failed.json` only
|
|
/// when `!has_document && !analyzing` — otherwise the marker is either
|
|
/// already obsolete (a successful run will overwrite it) or the worker
|
|
/// is currently producing a fresh result anyway.
|
|
struct FailureBanner {
|
|
/// Raw `reason` string from the marker. Shown verbatim inside a
|
|
/// `<details>` block — useful for admins, harmless for users.
|
|
reason: String,
|
|
/// RFC3339 UTC timestamp of the failed run. Browser JS reformats it
|
|
/// into the doctor's local timezone (same `time_format.js` that
|
|
/// formats `recorded_at_iso`).
|
|
failed_at: String,
|
|
/// UI label of the backend that produced the failure (resolved from
|
|
/// the marker's `backend_id`). Empty if the marker predates the
|
|
/// per-backend tracking or the id is no longer in the catalog.
|
|
backend_label: String,
|
|
}
|
|
|
|
/// 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,
|
|
/// `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>,
|
|
}
|
|
|
|
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.has_file());
|
|
|
|
let analyzable = !analyzing && all_transcribed;
|
|
|
|
// Only surface the failure marker when neither a document nor an in-flight
|
|
// analysis would otherwise occupy the same UI slot. Skips the FS read on
|
|
// the success hot-path (document already on disk).
|
|
let analysis_failed = if !has_document && !analyzing {
|
|
auto_trigger::read_failure_marker(case_dir)
|
|
.await
|
|
.map(|m| FailureBanner {
|
|
reason: m.reason,
|
|
failed_at: m.failed_at,
|
|
backend_label: m
|
|
.backend_id
|
|
.as_deref()
|
|
.and_then(crate::analyze::backend::find_backend)
|
|
.map(|b| b.label.clone())
|
|
.unwrap_or_default(),
|
|
})
|
|
} else {
|
|
None
|
|
};
|
|
|
|
CaseFlags {
|
|
has_document,
|
|
analyzing,
|
|
can_analyze: analyzable && llm_configured,
|
|
llm_missing: analyzable && !llm_configured,
|
|
analysis_failed,
|
|
}
|
|
}
|
|
|
|
/// 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.
|
|
///
|
|
/// Also opportunistically regenerates missing oneliners for the user — a
|
|
/// recording-delete leaves `oneliner.json` gone, and without this heal
|
|
/// call the stale state would only recover on server restart.
|
|
impl PipelineState {
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub async fn heal_orphans_if_idle(
|
|
&self,
|
|
user_root: &Path,
|
|
slug: &str,
|
|
http_client: &reqwest::Client,
|
|
settings: &Arc<Settings>,
|
|
vocab: &Arc<Gazetteer>,
|
|
events_tx: &EventSender,
|
|
) {
|
|
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;
|
|
// Oneliner self-heal: if the transcribe worker is busy, it
|
|
// will write a fresh oneliner at batch-end anyway — skip to
|
|
// avoid a redundant LLM call. Otherwise, fire the regen in a
|
|
// detached task so the request handler returns immediately;
|
|
// SSE `OnelinerUpdated` events deliver results to the browser.
|
|
// The CAS on `oneliner_heal_busy` guards against concurrent
|
|
// page-loads spawning their own parallel regen loops against
|
|
// the same cases.
|
|
if self
|
|
.oneliner_heal_busy
|
|
.0
|
|
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
|
|
.is_ok()
|
|
{
|
|
let user_root = user_root.to_path_buf();
|
|
let slug_owned = slug.to_owned();
|
|
let http_client = http_client.clone();
|
|
let settings = Arc::clone(settings);
|
|
let vocab = Arc::clone(vocab);
|
|
let events_tx = events_tx.clone();
|
|
let busy = Arc::clone(&self.oneliner_heal_busy.0);
|
|
let locks = self.oneliner_locks.clone();
|
|
info!(user = %slug, "oneliner heal spawned");
|
|
tokio::spawn(async move {
|
|
// `BusyGuard::Drop` resets the flag to `false` — even
|
|
// on panic inside the regen loop. The redundant
|
|
// `store(true)` inside `BusyGuard::new` is harmless;
|
|
// the CAS above already owns the flag transition.
|
|
let _guard = BusyGuard::new(busy);
|
|
transcribe_recovery::regenerate_missing_oneliners_for_user(
|
|
&user_root,
|
|
&slug_owned,
|
|
&http_client,
|
|
&settings,
|
|
&vocab,
|
|
&events_tx,
|
|
&locks,
|
|
)
|
|
.await;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub async fn handle_my_cases(
|
|
user: AuthenticatedWebUser,
|
|
State(config): State<Arc<Config>>,
|
|
State(settings): State<Arc<Settings>>,
|
|
State(pipeline): State<PipelineState>,
|
|
State(events_tx): State<EventSender>,
|
|
State(http_client): State<reqwest::Client>,
|
|
State(vocab): State<Arc<Gazetteer>>,
|
|
Query(query): Query<CaseListQuery>,
|
|
) -> Result<Html<String>, AppError> {
|
|
let user_root = config.data_path.join(&user.slug);
|
|
pipeline
|
|
.heal_orphans_if_idle(
|
|
&user_root,
|
|
&user.slug,
|
|
&http_client,
|
|
&settings,
|
|
&vocab,
|
|
&events_tx,
|
|
)
|
|
.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, &events_tx).await;
|
|
|
|
// Lazy retention sweep: at most one disk scan per /web/cases visit,
|
|
// reusing the user's retention policy from users.toml. Runs before
|
|
// the listing scan so any auto-close/auto-purge actions are
|
|
// reflected in the same response.
|
|
let retention = config
|
|
.users
|
|
.iter()
|
|
.find(|u| u.slug == user.slug)
|
|
.map(|u| u.retention.clone())
|
|
.unwrap_or_default();
|
|
crate::retention::sweep_user_retention(&user_root, &user.slug, &retention, &events_tx).await;
|
|
|
|
let show_closed = query.include_closed();
|
|
let busy = pipeline.analyze_busy.0.load(Ordering::Acquire);
|
|
let cases = scan_user_cases(
|
|
&config,
|
|
&user.slug,
|
|
busy,
|
|
show_closed,
|
|
retention.auto_delete_days,
|
|
crate::analyze::backend::any_backend_available(),
|
|
)
|
|
.await;
|
|
let total = cases.len();
|
|
let any_closed = cases.iter().any(|c| c.is_closed);
|
|
// 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
|
|
// closed case, no compute_case_view) and feed it into the grouper.
|
|
// Under `show_closed=true` closed cases are already in `cases`
|
|
// with their `is_closed` flag, so the map stays empty.
|
|
let closed_extra = if show_closed {
|
|
HashMap::new()
|
|
} else {
|
|
count_closed_by_date(&user_root).await
|
|
};
|
|
let groups = group_by_utc_date(cases, &closed_extra);
|
|
let is_admin = user.is_admin();
|
|
let preview_lines = config
|
|
.users
|
|
.iter()
|
|
.find(|u| u.slug == user.slug)
|
|
.map(|u| u.preview_lines)
|
|
.unwrap_or(2);
|
|
MyCasesTemplate {
|
|
csrf_token: user.csrf_token,
|
|
slug: user.slug,
|
|
groups,
|
|
total,
|
|
is_admin,
|
|
show_closed,
|
|
any_closed,
|
|
preview_lines,
|
|
}
|
|
.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.
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub async fn handle_case_page(
|
|
user: AuthenticatedWebUser,
|
|
State(config): State<Arc<Config>>,
|
|
State(settings): State<Arc<Settings>>,
|
|
State(pipeline): State<PipelineState>,
|
|
State(events_tx): State<EventSender>,
|
|
State(http_client): State<reqwest::Client>,
|
|
State(vocab): State<Arc<Gazetteer>>,
|
|
CaseIdPath(case_id): CaseIdPath,
|
|
Query(query): Query<CaseListQuery>,
|
|
) -> Result<Html<String>, AppError> {
|
|
let user_root = config.data_path.join(&user.slug);
|
|
pipeline
|
|
.heal_orphans_if_idle(
|
|
&user_root,
|
|
&user.slug,
|
|
&http_client,
|
|
&settings,
|
|
&vocab,
|
|
&events_tx,
|
|
)
|
|
.await;
|
|
|
|
let show_closed = query.include_closed();
|
|
let case_dir = if show_closed {
|
|
locate_closed_case_or_404(
|
|
&user_root,
|
|
&case_id,
|
|
&user.slug,
|
|
"case page (show_closed=1)",
|
|
)
|
|
.await?
|
|
} else {
|
|
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, &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.
|
|
// Keep the raw markdown around for the admin debug-copy bundle so we
|
|
// don't pay a second filesystem read.
|
|
let document_md = read_document(&case_dir).await;
|
|
let document_html = document_md
|
|
.as_deref()
|
|
.map(crate::analyze::render::md_to_html);
|
|
|
|
let recordings = scan_recordings(&case_dir).await;
|
|
let (oneliner, oneliner_is_manual) = compute_oneliner_display(&case_dir, &recordings).await;
|
|
|
|
let a_busy = pipeline.analyze_busy.0.load(Ordering::Acquire);
|
|
let flags = compute_flags(
|
|
&case_dir,
|
|
&recordings,
|
|
crate::analyze::backend::any_backend_available(),
|
|
a_busy,
|
|
)
|
|
.await;
|
|
|
|
let recordings_count = recordings.len();
|
|
let transcribed_count = recordings
|
|
.iter()
|
|
.filter(|r| r.transcript.has_file())
|
|
.count();
|
|
let has_failed_recording = recordings.iter().any(|r| r.failed);
|
|
let case_id_short = case_id_str.chars().take(8).collect();
|
|
let is_admin = user.is_admin();
|
|
|
|
let most_recent = recordings.last().map(|r| r.filename.as_str());
|
|
let recorded_at_iso = most_recent
|
|
.map(recorded_at_iso_of)
|
|
.filter(|s| !s.is_empty());
|
|
let time_hms_utc = most_recent.map(extract_hhmm_utc).unwrap_or_default();
|
|
|
|
let is_closed = crate::paths::is_closed(&case_dir).await;
|
|
|
|
// Admin debug-copy payload — only built when the viewer is an admin
|
|
// so non-admin renders skip the JSON serialisation entirely.
|
|
let debug_copy_json = if is_admin {
|
|
let transcripts: Vec<&str> = recordings
|
|
.iter()
|
|
.filter_map(|r| r.transcript.as_content())
|
|
.collect();
|
|
let payload = serde_json::json!({
|
|
"transcripts": transcripts,
|
|
"document": document_md,
|
|
});
|
|
// `</` breaks out of a `<script>` element; the inline JSON block
|
|
// would otherwise be vulnerable if a transcript or the document
|
|
// ever contained a literal `</script>`.
|
|
serde_json::to_string(&payload)
|
|
.unwrap_or_else(|_| "{}".to_owned())
|
|
.replace("</", "<\\/")
|
|
} else {
|
|
String::new()
|
|
};
|
|
|
|
CasePageTemplate {
|
|
case_id: case_id_str,
|
|
case_id_short,
|
|
oneliner,
|
|
oneliner_is_manual,
|
|
recorded_at_iso,
|
|
time_hms_utc,
|
|
document_html,
|
|
recordings_count,
|
|
transcribed_count,
|
|
can_analyze: flags.can_analyze,
|
|
llm_missing: flags.llm_missing,
|
|
analyzing: flags.analyzing,
|
|
has_document: flags.has_document,
|
|
analysis_failed: flags.analysis_failed,
|
|
has_failed_recording,
|
|
is_admin,
|
|
is_closed,
|
|
csrf_token: user.csrf_token,
|
|
debug_copy_json,
|
|
backends: crate::analyze::backend::available_backends(),
|
|
}
|
|
.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.
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub async fn handle_case_recordings(
|
|
user: AuthenticatedWebUser,
|
|
State(config): State<Arc<Config>>,
|
|
State(settings): State<Arc<Settings>>,
|
|
State(pipeline): State<PipelineState>,
|
|
State(events_tx): State<EventSender>,
|
|
State(http_client): State<reqwest::Client>,
|
|
State(vocab): State<Arc<Gazetteer>>,
|
|
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,
|
|
&http_client,
|
|
&settings,
|
|
&vocab,
|
|
&events_tx,
|
|
)
|
|
.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 = ready_text(&case_dir).await;
|
|
|
|
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 {
|
|
csrf_token: user.csrf_token,
|
|
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 closed (`.closed` 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_closed(&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()))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Like `locate_case_or_404` but accepts closed cases. Only the detail
|
|
/// view uses this, and only when the user navigated there with
|
|
/// `?show_closed=1`. A non-existent directory still 404s as expected.
|
|
pub(crate) async fn locate_closed_case_or_404(
|
|
user_root: &Path,
|
|
case_id: &CaseId,
|
|
user_slug: &str,
|
|
context: &str,
|
|
) -> Result<std::path::PathBuf, AppError> {
|
|
let p = user_root.join(case_id.to_string());
|
|
if !tokio::fs::try_exists(&p).await.unwrap_or(false) {
|
|
warn!(
|
|
slug = %user_slug,
|
|
case_id = %case_id,
|
|
context = %context,
|
|
"case not found (show_closed)",
|
|
);
|
|
return Err(AppError::NotFound("Case not found".into()));
|
|
}
|
|
Ok(p)
|
|
}
|
|
|
|
/// 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.
|
|
///
|
|
/// `include_closed` flips the default "closed cases are hidden"
|
|
/// behaviour for the show-closed view. `auto_delete_days` is used to
|
|
/// compute the per-case `days_until_purge` countdown; pass 0 to
|
|
/// disable the countdown (maps to `None` on the view).
|
|
async fn scan_user_cases(
|
|
config: &Config,
|
|
slug: &str,
|
|
worker_busy: bool,
|
|
include_closed: bool,
|
|
auto_delete_days: u32,
|
|
llm_configured: bool,
|
|
) -> Vec<UserCaseView> {
|
|
let user_root = config.data_path.join(slug);
|
|
let mut cases = Vec::new();
|
|
let now = OffsetDateTime::now_utc();
|
|
|
|
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, include_closed).await else {
|
|
continue;
|
|
};
|
|
if let Some(view) = compute_case_view(
|
|
case_id,
|
|
&case_path,
|
|
llm_configured,
|
|
worker_busy,
|
|
auto_delete_days,
|
|
now,
|
|
)
|
|
.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 directory whose name is a valid UUID.
|
|
/// Closed cases are rejected unless `include_closed` is set; then the
|
|
/// caller (the show-closed listing) will downstream-render them with
|
|
/// muted styling and a badge.
|
|
async fn filter_valid_case_dir(
|
|
entry: tokio::fs::DirEntry,
|
|
include_closed: bool,
|
|
) -> 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 !include_closed && crate::paths::is_closed(&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,
|
|
auto_delete_days: u32,
|
|
now: OffsetDateTime,
|
|
) -> 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 has_failed_recording = recordings.iter().any(|r| r.failed);
|
|
let all_transcribed = non_failed_count > 0
|
|
&& recordings
|
|
.iter()
|
|
.filter(|r| !r.failed)
|
|
.all(|r| r.transcript.has_file());
|
|
|
|
let (oneliner, oneliner_is_manual) = compute_oneliner_display(case_path, &recordings).await;
|
|
|
|
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 (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
|
|
// range — negligible next to the existing scan_recordings I/O — and
|
|
// we skip the read entirely when the stat above said no document.
|
|
// Rendered once, regardless of expand state: the browser handles
|
|
// clamp-vs-expand purely through CSS toggling.
|
|
let analysis_html = if has_document {
|
|
read_document(case_path)
|
|
.await
|
|
.map(|md| crate::analyze::render::md_to_html(&md))
|
|
} else {
|
|
None
|
|
};
|
|
|
|
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,
|
|
oneliner_is_manual,
|
|
recordings_count,
|
|
analyzing,
|
|
has_document,
|
|
has_failed_recording,
|
|
can_analyze,
|
|
is_closed,
|
|
days_until_purge,
|
|
analysis_html,
|
|
})
|
|
}
|
|
|
|
/// Read the close marker (if any) and compute the purge countdown.
|
|
/// Returns `(is_closed, days_until_purge)`. `days_until_purge` is
|
|
/// `None` whenever the purge sweep is disabled (`auto_delete_days = 0`)
|
|
/// or the case is still open — the template then suppresses the
|
|
/// countdown and falls back to the bare "geschlossen" badge.
|
|
async fn closed_state(
|
|
case_path: &Path,
|
|
auto_delete_days: u32,
|
|
now: OffsetDateTime,
|
|
) -> (bool, Option<u32>) {
|
|
let Some(marker) = crate::paths::read_close_marker(case_path).await else {
|
|
// No marker (open case) OR malformed marker. Malformed should
|
|
// have been caught by `is_closed` upstream; we treat it as open
|
|
// here to avoid a bogus countdown on a half-written file.
|
|
let open_but_marker_present = crate::paths::is_closed(case_path).await;
|
|
return (open_but_marker_present, None);
|
|
};
|
|
if auto_delete_days == 0 {
|
|
return (true, None);
|
|
}
|
|
let age_days = OffsetDateTime::parse(&marker.closed_at, &Rfc3339)
|
|
.map(|t| (now - t).whole_days().max(0))
|
|
.unwrap_or(0);
|
|
let remaining = (auto_delete_days as i64).saturating_sub(age_days).max(0);
|
|
(true, Some(remaining as u32))
|
|
}
|
|
|
|
/// Extract only the text from a `Ready` state; other states (Empty,
|
|
/// Error, missing file) collapse to `None`. Used by the recordings
|
|
/// sub-page, which only needs a plain title string as heading.
|
|
async fn ready_text(case_dir: &Path) -> Option<String> {
|
|
match paths::read_oneliner_state(case_dir).await.0 {
|
|
Some(OnelinerState::Ready { text, .. }) => Some(text),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Compute the 5-state UI display for the oneliner plus a flag
|
|
/// indicating manual authorship. Identical logic to what
|
|
/// `compute_case_view` uses for the case list — extracted so the case
|
|
/// page can render the same states instead of a plain "Fall" fallback.
|
|
///
|
|
/// Returns `(display, is_manual)`. `is_manual` is `true` iff the
|
|
/// persisted state is `OnelinerState::Manual { .. }` AND the case is
|
|
/// in a stable state where that text is actually being shown
|
|
/// (i.e. not `Pending`/`Generating` — those override the persisted
|
|
/// content with progress placeholders, so the manual flag would be
|
|
/// misleading there).
|
|
async fn compute_oneliner_display(
|
|
case_dir: &Path,
|
|
recordings: &[RecordingView],
|
|
) -> (OnelinerDisplay, bool) {
|
|
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.has_file());
|
|
let (state, _) = paths::read_oneliner_state(case_dir).await;
|
|
let is_manual = matches!(state, Some(OnelinerState::Manual { .. }));
|
|
|
|
let display = if non_failed_count == 0 {
|
|
// All recordings failed — stable end state, no LLM will ever run.
|
|
// Missing state collapses to Empty ("unbenannt"): no title is
|
|
// expected, regardless of why.
|
|
match state {
|
|
Some(OnelinerState::Ready { text, .. } | OnelinerState::Manual { text, .. }) => {
|
|
OnelinerDisplay::Ready(text)
|
|
}
|
|
Some(OnelinerState::Error { .. }) => OnelinerDisplay::Error,
|
|
Some(OnelinerState::Empty { .. }) | None => OnelinerDisplay::Empty,
|
|
}
|
|
} else if all_transcribed {
|
|
// Transcripts done — stable state, except for the brief window
|
|
// between transcript-write and state-write where the worker is
|
|
// still producing its result.
|
|
match state {
|
|
Some(OnelinerState::Ready { text, .. } | OnelinerState::Manual { text, .. }) => {
|
|
OnelinerDisplay::Ready(text)
|
|
}
|
|
Some(OnelinerState::Empty { .. }) => OnelinerDisplay::Empty,
|
|
Some(OnelinerState::Error { .. }) => OnelinerDisplay::Error,
|
|
None => OnelinerDisplay::Generating,
|
|
}
|
|
} else {
|
|
// Transcription still pending. Any persisted state is obsolete:
|
|
// the worker will regenerate once the last transcript lands.
|
|
match state {
|
|
None => OnelinerDisplay::Pending,
|
|
Some(_) => OnelinerDisplay::Generating,
|
|
}
|
|
};
|
|
|
|
// Suppress the manual marker while we're showing a progress
|
|
// placeholder ("transkribiere…", "generiere Titel…"): the displayed
|
|
// string isn't the manual text anyway, so a 👤 next to it would lie.
|
|
let show_manual = is_manual
|
|
&& !matches!(
|
|
display,
|
|
OnelinerDisplay::Pending | OnelinerDisplay::Generating
|
|
);
|
|
(display, show_manual)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn mk_view(most_recent: &str, is_closed: bool) -> UserCaseView {
|
|
UserCaseView {
|
|
case_id: "00000000-0000-0000-0000-000000000000".into(),
|
|
most_recent: most_recent.into(),
|
|
time_hms_utc: String::new(),
|
|
recorded_at_iso: String::new(),
|
|
oneliner: OnelinerDisplay::Empty,
|
|
oneliner_is_manual: false,
|
|
recordings_count: 1,
|
|
analyzing: false,
|
|
has_document: false,
|
|
has_failed_recording: false,
|
|
can_analyze: false,
|
|
is_closed,
|
|
days_until_purge: None,
|
|
analysis_html: None,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn group_by_utc_date_counts_open_only_cases_as_equal() {
|
|
// Default view: no closed cases at all. open == total.
|
|
let cases = vec![
|
|
mk_view("2026-04-18T10-00-00Z.m4a", false),
|
|
mk_view("2026-04-18T09-00-00Z.m4a", false),
|
|
];
|
|
let extra = HashMap::new();
|
|
let groups = group_by_utc_date(cases, &extra);
|
|
assert_eq!(groups.len(), 1);
|
|
assert_eq!(groups[0].open_count, 2);
|
|
assert_eq!(groups[0].total_count, 2);
|
|
}
|
|
|
|
#[test]
|
|
fn group_by_utc_date_adds_closed_extra_to_total_only() {
|
|
// show_closed=false path: visible cases are all open, closed ones
|
|
// come in via the extra-map and inflate `total` without touching
|
|
// `open`.
|
|
let cases = vec![mk_view("2026-04-18T10-00-00Z.m4a", false)];
|
|
let mut extra = HashMap::new();
|
|
extra.insert("2026-04-18".to_string(), 2);
|
|
let groups = group_by_utc_date(cases, &extra);
|
|
assert_eq!(groups[0].open_count, 1);
|
|
assert_eq!(groups[0].total_count, 3);
|
|
}
|
|
|
|
#[test]
|
|
fn group_by_utc_date_under_show_closed_distinguishes_open_from_total() {
|
|
// show_closed=true path: closed cases sit *inside* `cases` with
|
|
// is_closed=true; the extra-map is empty. `open` must still be
|
|
// the count of non-closed entries.
|
|
let cases = vec![
|
|
mk_view("2026-04-18T10-00-00Z.m4a", false),
|
|
mk_view("2026-04-18T09-00-00Z.m4a", true),
|
|
mk_view("2026-04-18T08-00-00Z.m4a", true),
|
|
];
|
|
let extra = HashMap::new();
|
|
let groups = group_by_utc_date(cases, &extra);
|
|
assert_eq!(groups[0].open_count, 1);
|
|
assert_eq!(groups[0].total_count, 3);
|
|
}
|
|
|
|
#[test]
|
|
fn group_by_utc_date_separate_buckets_independent_counts() {
|
|
// Two calendar days, one gets a closed-extra, the other doesn't.
|
|
// Cross-talk between buckets would be a bug.
|
|
let cases = vec![
|
|
mk_view("2026-04-18T10-00-00Z.m4a", false),
|
|
mk_view("2026-04-17T10-00-00Z.m4a", false),
|
|
mk_view("2026-04-17T09-00-00Z.m4a", false),
|
|
];
|
|
let mut extra = HashMap::new();
|
|
extra.insert("2026-04-18".to_string(), 1);
|
|
let groups = group_by_utc_date(cases, &extra);
|
|
assert_eq!(groups.len(), 2);
|
|
let g18 = groups.iter().find(|g| g.label == "2026-04-18").unwrap();
|
|
let g17 = groups.iter().find(|g| g.label == "2026-04-17").unwrap();
|
|
assert_eq!((g18.open_count, g18.total_count), (1, 2));
|
|
assert_eq!((g17.open_count, g17.total_count), (2, 2));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn most_recent_date_label_picks_largest_filename() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let p = dir.path();
|
|
std::fs::write(p.join("2026-04-17T10-00-00Z.m4a"), b"").unwrap();
|
|
std::fs::write(p.join("2026-04-18T09-00-00Z.m4a"), b"").unwrap();
|
|
std::fs::write(p.join("2026-04-18T08-00-00Z.m4a.failed"), b"").unwrap();
|
|
// Noise files that must be ignored entirely.
|
|
std::fs::write(p.join("document.md"), b"").unwrap();
|
|
std::fs::write(p.join("oneliner.json"), b"").unwrap();
|
|
let label = most_recent_date_label(p).await;
|
|
assert_eq!(label.as_deref(), Some("2026-04-18"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn most_recent_date_label_returns_none_without_recordings() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
std::fs::write(dir.path().join("document.md"), b"").unwrap();
|
|
assert_eq!(most_recent_date_label(dir.path()).await, None);
|
|
}
|
|
}
|