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 %} -