Files
doctate/server/src/routes/user_web.rs
T
Brummel 23ef84d9d8 feat: multi-backend LLM layer with per-request choice on case page
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.
2026-05-03 14:57:21 +02:00

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);
}
}