From 990d166617fa1b922ce28c2027660e702b91253f Mon Sep 17 00:00:00 2001 From: Brummel Date: Thu, 16 Apr 2026 01:10:52 +0200 Subject: [PATCH] Refactor case listing and silent recording handling The case listing on the "My Cases" page is now grouped by local date, with labels for "Heute", "Gestern", or the ISO date. This improves readability and organization. Additionally, the handling of silent recordings has been refined. Previously, the absence of usable recordings would result in an error. Now, the analysis worker gracefully handles this by writing a stub document and skipping the LLM call. This prevents unnecessary errors and provides a clearer status for silent cases. The `dictate.sh` script has been updated to simplify the case directory lookup logic. Instead of iterating through `open` and `done` subdirectories, it now directly checks for the case ID and ensures it's not marked as deleted. This simplifies the script and improves efficiency. The `server/Cargo.toml` and `server/Cargo.lock` have been updated to include the `num_threads` dependency and enable additional features for the `time` crate, which are necessary for proper local time zone handling. --- scripts/dictate.sh | 11 +++-- server/Cargo.lock | 11 +++++ server/Cargo.toml | 2 +- server/src/analyze/worker.rs | 15 +++++-- server/src/routes/case_actions.rs | 8 +--- server/src/routes/user_web.rs | 68 ++++++++++++++++++++++++++++++- server/src/transcribe/worker.rs | 6 ++- server/templates/case_detail.html | 4 ++ server/templates/my_cases.html | 16 ++++---- 9 files changed, 115 insertions(+), 26 deletions(-) diff --git a/scripts/dictate.sh b/scripts/dictate.sh index 37a7df6..5187075 100755 --- a/scripts/dictate.sh +++ b/scripts/dictate.sh @@ -43,12 +43,11 @@ fi # Locate a case directory under open/ or done/. case_dir_for() { local case_id="$1" - for sub in open done; do - if [ -d "$DATA_PATH/$SLUG/$sub/$case_id" ]; then - echo "$DATA_PATH/$SLUG/$sub/$case_id" - return 0 - fi - done + local d="$DATA_PATH/$SLUG/$case_id" + if [ -d "$d" ] && [ ! -f "$d/.deleted" ]; then + echo "$d" + return 0 + fi return 1 } diff --git a/server/Cargo.lock b/server/Cargo.lock index 5c0acd4..957f6f4 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -1057,6 +1057,15 @@ dependencies = [ "libc", ] +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -1701,7 +1710,9 @@ checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", + "libc", "num-conv", + "num_threads", "powerfmt", "serde_core", "time-core", diff --git a/server/Cargo.toml b/server/Cargo.toml index 8b3def4..7355d7b 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -22,7 +22,7 @@ tower-http = { version = "0.6", features = ["limit", "trace"] } rand = "0.8" bcrypt = "0.15" axum-extra = { version = "0.12", features = ["cookie", "form"] } -time = "0.3.47" +time = { version = "0.3.47", features = ["local-offset", "parsing", "formatting", "macros"] } toml_edit = "0.25.11" rpassword = "7.4.0" diff --git a/server/src/analyze/worker.rs b/server/src/analyze/worker.rs index 0980e9a..9ca1f7b 100644 --- a/server/src/analyze/worker.rs +++ b/server/src/analyze/worker.rs @@ -59,12 +59,21 @@ async fn process( } }; - let user_content = prompt::render_prompt(&input); - if user_content.is_empty() { - error!(case = %case_dir.display(), "analysis input has no usable recordings"); + // Silent-only case: no usable transcripts. Skip LLM, write a stub. + if input.recordings.is_empty() { + let stub = b"_Keine verwertbaren Aufnahmen (alle Aufnahmen still)._\n"; + if let Err(e) = write_atomic(&document_path, stub).await { + error!(path = %document_path.display(), error = %e, "writing stub document failed"); + return; + } + if let Err(e) = tokio::fs::remove_file(&input_path).await { + warn!(path = %input_path.display(), error = %e, "removing analysis input failed"); + } + info!(case = %case_dir.display(), version, "analysis done (stub, no recordings)"); return; } + let user_content = prompt::render_prompt(&input); let total_chars = user_content.chars().count(); info!( case = %case_dir.display(), diff --git a/server/src/routes/case_actions.rs b/server/src/routes/case_actions.rs index 67ed4f6..6d691f3 100644 --- a/server/src/routes/case_actions.rs +++ b/server/src/routes/case_actions.rs @@ -163,12 +163,8 @@ pub(crate) async fn build_analysis_input( }); } - if recordings.is_empty() { - return Err(AppError::BadRequest( - "Keine nicht-leeren Transkripte im Fall".into(), - )); - } - + // Silent-only case: `recordings` may be empty here. The analyze worker + // handles that by writing a stub document instead of calling the LLM. let last_recording_mtime = OffsetDateTime::from(last_mtime) .format(&Rfc3339) .map_err(|e| AppError::Internal(format!("format mtime: {e}")))?; diff --git a/server/src/routes/user_web.rs b/server/src/routes/user_web.rs index 799a4a3..978b5ad 100644 --- a/server/src/routes/user_web.rs +++ b/server/src/routes/user_web.rs @@ -7,6 +7,8 @@ use axum::extract::{Path as AxumPath, State}; use axum::response::Html; use tracing::{info, warn}; +use time::{format_description::well_known::Rfc3339, Date, OffsetDateTime, UtcOffset}; + use crate::analyze::{recovery as analyze_recovery, AnalyzeSender}; use crate::auth::AuthenticatedWebUser; use crate::config::Config; @@ -31,6 +33,58 @@ struct UserCaseView { can_analyze: bool, } +/// Parse a recording filename `YYYY-MM-DDTHH-MM-SSZ.m4a[.failed]` and return +/// the date in the given offset. Returns `None` on any mismatch. +fn local_date_of(filename: &str, offset: UtcOffset) -> Option { + 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.to_offset(offset).date()) +} + +/// Best-effort local offset. Falls back to UTC if the runtime can't resolve +/// it (e.g. multi-threaded tokio on Unix without `TZ` set). Near-midnight +/// labels may drift in that case — acceptable tradeoff vs. caching at startup. +fn local_offset_or_utc() -> UtcOffset { + UtcOffset::current_local_offset().unwrap_or(UtcOffset::UTC) +} + +/// Group cases (already sorted newest-first) into date buckets. Label is +/// "Heute" / "Gestern" / ISO date. +fn group_by_local_date(cases: Vec) -> Vec { + let offset = local_offset_or_utc(); + let today = OffsetDateTime::now_utc().to_offset(offset).date(); + let yesterday = today.previous_day(); + + let mut groups: Vec = Vec::new(); + for case in cases { + let d = local_date_of(&case.most_recent, offset).unwrap_or(today); + let label = if d == today { + "Heute".to_string() + } else if Some(d) == yesterday { + "Gestern".to_string() + } else { + d.to_string() + }; + match groups.last_mut() { + Some(g) if g.label == label => g.cases.push(case), + _ => groups.push(DateGroup { + label, + cases: vec![case], + }), + } + } + groups +} + /// Extract `HH:MM` from a recording filename of the form /// `YYYY-MM-DDTHH-MM-SSZ.m4a[.failed]`. Returns `""` on any mismatch. fn extract_hhmm(filename: &str) -> String { @@ -44,11 +98,18 @@ fn extract_hhmm(filename: &str) -> String { } } +struct DateGroup { + /// "Heute", "Gestern", or ISO date "YYYY-MM-DD". + label: String, + cases: Vec, +} + #[derive(Template)] #[template(path = "my_cases.html")] struct MyCasesTemplate { slug: String, - cases: Vec, + groups: Vec, + total: usize, /// Number of cases that "Undo last delete" would restore. 0 hides the /// undo button entirely. undo_count: usize, @@ -187,6 +248,8 @@ pub async fn handle_my_cases( let busy = analyze_busy.0.load(Ordering::Acquire); let cases = scan_user_cases(&config, &user.slug, busy).await; + let total = cases.len(); + let groups = group_by_local_date(cases); let undo_count = crate::routes::case_actions::summarize_latest_batch(&user_root) .await .map(|(_, n)| n) @@ -194,7 +257,8 @@ pub async fn handle_my_cases( let is_admin = user.is_admin(); MyCasesTemplate { slug: user.slug, - cases, + groups, + total, undo_count, is_admin, } diff --git a/server/src/transcribe/worker.rs b/server/src/transcribe/worker.rs index 815ec31..414717e 100644 --- a/server/src/transcribe/worker.rs +++ b/server/src/transcribe/worker.rs @@ -71,7 +71,11 @@ pub async fn run( "Transcript written" ); - if let Some(case_dir) = audio_path.parent() { + // Silent recording: Whisper returned an empty string. Skip the + // oneliner step — otherwise the LLM echoes the system prompt back. + if !text.trim().is_empty() + && let Some(case_dir) = audio_path.parent() + { ensure_oneliner(case_dir, &text, &client, &config).await; } } diff --git a/server/templates/case_detail.html b/server/templates/case_detail.html index 464dee6..7868f08 100644 --- a/server/templates/case_detail.html +++ b/server/templates/case_detail.html @@ -67,7 +67,11 @@ header form { margin: 0; } {% else %} {% match rec.transcript %} {% when Some with (t) %} +{% if t.is_empty() %} +
(stille Aufnahme)
+{% else %}
{{ t }}
+{% endif %} {% when None %} {% if transcribe_busy %}
Transkription läuft…
diff --git a/server/templates/my_cases.html b/server/templates/my_cases.html index aead603..4d6e459 100644 --- a/server/templates/my_cases.html +++ b/server/templates/my_cases.html @@ -55,19 +55,20 @@ section h2 { font-size: 1.1em; color: #555; border-bottom: 1px solid #ddd; paddi {% endif %} -
-

Fälle ({{ cases.len() }})

-{% if cases.is_empty() %} -

Keine Fälle.

+{% if total == 0 %} +

Keine Fälle.

{% else %}
+{% for g in groups %} +
+

{{ g.label }} ({{ g.cases.len() }})

+
+{% endfor %}
Auswahl: @@ -86,6 +89,5 @@ section h2 { font-size: 1.1em; color: #555; border-bottom: 1px solid #ddd; paddi
{% endif %} -