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.
This commit is contained in:
2026-04-16 01:10:52 +02:00
parent 2f62f11563
commit 990d166617
9 changed files with 115 additions and 26 deletions
+5 -6
View File
@@ -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
}
+11
View File
@@ -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",
+1 -1
View File
@@ -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"
+12 -3
View File
@@ -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(),
+2 -6
View File
@@ -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}")))?;
+66 -2
View File
@@ -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<Date> {
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<UserCaseView>) -> Vec<DateGroup> {
let offset = local_offset_or_utc();
let today = OffsetDateTime::now_utc().to_offset(offset).date();
let yesterday = today.previous_day();
let mut groups: Vec<DateGroup> = 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<UserCaseView>,
}
#[derive(Template)]
#[template(path = "my_cases.html")]
struct MyCasesTemplate {
slug: String,
cases: Vec<UserCaseView>,
groups: Vec<DateGroup>,
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,
}
+5 -1
View File
@@ -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;
}
}
+4
View File
@@ -67,7 +67,11 @@ header form { margin: 0; }
{% else %}
{% match rec.transcript %}
{% when Some with (t) %}
{% if t.is_empty() %}
<div class="pending">(stille Aufnahme)</div>
{% else %}
<div class="transcript">{{ t }}</div>
{% endif %}
{% when None %}
{% if transcribe_busy %}
<div class="pending">Transkription läuft…</div>
+9 -7
View File
@@ -55,19 +55,20 @@ section h2 { font-size: 1.1em; color: #555; border-bottom: 1px solid #ddd; paddi
</form>
{% endif %}
<section>
<h2>Fälle ({{ cases.len() }})</h2>
{% if cases.is_empty() %}
<p class="empty">Keine Fälle.</p>
{% if total == 0 %}
<section><p class="empty">Keine Fälle.</p></section>
{% else %}
<form method="post" action="/web/cases/bulk">
{% for g in groups %}
<section>
<h2>{{ g.label }} ({{ g.cases.len() }})</h2>
<ul class="case-list">
{% for case in cases %}
{% for case in g.cases %}
<li class="case-row {% if case.has_document %}done{% else %}open{% endif %}">
<div class="check"><input type="checkbox" name="case_id" value="{{ case.case_id }}"></div>
<div class="body">
{% if case.has_document %}<a href="/web/cases/{{ case.case_id }}/document">{% else %}<a href="/web/cases/{{ case.case_id }}">{% endif %}
<div class="line1"><span class="time">{{ case.time_hms }}</span>{% match case.oneliner %}{% when Some with (t) %} — {{ t }}{% when None %}{% endmatch %}</div>
<div class="line1"><span class="time">{{ case.time_hms }}</span>{% match case.oneliner %}{% when Some with (t) %} — {{ t }}{% when None %}<span class="empty">kein Oneliner</span>{% endmatch %}</div>
<div class="line2">{{ case.recordings_count }} Aufnahmen{% if case.has_document %} — <span class="status-badge done">ausgewertet</span>{% else %} — <span class="status-badge open">offen</span>{% endif %}{% if case.analyzing %} <span class="label analyzing">wird analysiert</span>{% endif %}</div>
{% if is_admin %}<div class="uuid">{{ case.case_id }}</div>{% endif %}
</a>
@@ -79,6 +80,8 @@ section h2 { font-size: 1.1em; color: #555; border-bottom: 1px solid #ddd; paddi
</li>
{% endfor %}
</ul>
</section>
{% endfor %}
<div class="bulk-bar">
<span>Auswahl:</span>
<button type="submit" name="action" value="analyze">Analysieren</button>
@@ -86,6 +89,5 @@ section h2 { font-size: 1.1em; color: #555; border-bottom: 1px solid #ddd; paddi
</div>
</form>
{% endif %}
</section>
</body>
</html>