Add analysis preview to case list
Introduce a `preview_lines` setting in `users.toml` to control the number of visible lines for the analysis preview in the case list. This feature extracts the first paragraph of the `document.md` file, strips markdown formatting, and displays it, capped by the configured `preview_lines`. The actual line clamping is handled client-side via CSS `line-clamp`.
This commit is contained in:
@@ -33,6 +33,14 @@ fn default_window_hours() -> u32 {
|
||||
72
|
||||
}
|
||||
|
||||
/// Default number of analysis-preview lines rendered under each case on
|
||||
/// the list view when the user entry omits `preview_lines`. Two lines
|
||||
/// show the first sentence of the LLM output in most cases without
|
||||
/// dominating the list row.
|
||||
fn default_preview_lines() -> u32 {
|
||||
2
|
||||
}
|
||||
|
||||
/// A single user entry from users.toml.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct User {
|
||||
@@ -51,6 +59,14 @@ pub struct User {
|
||||
/// apply no additional time filter.
|
||||
#[serde(default = "default_window_hours")]
|
||||
pub window_hours: u32,
|
||||
/// Number of analysis-preview lines rendered under each case row on
|
||||
/// the list view. The preview itself is always the first paragraph of
|
||||
/// `document.md`; this value only controls how many *visual* lines
|
||||
/// the browser will show before truncating with `…`. Applied via a
|
||||
/// CSS `line-clamp` custom property, so a single server value adapts
|
||||
/// to any viewport width.
|
||||
#[serde(default = "default_preview_lines")]
|
||||
pub preview_lines: u32,
|
||||
}
|
||||
|
||||
/// Wrapper for deserializing the [[user]] array from TOML.
|
||||
@@ -290,6 +306,7 @@ mod tests {
|
||||
whisper: WhisperUserSettings::default(),
|
||||
retention: RetentionUserSettings::default(),
|
||||
window_hours: default_window_hours(),
|
||||
preview_lines: default_preview_lines(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -344,6 +361,33 @@ mod tests {
|
||||
assert_eq!(r.auto_delete_days, 30);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_user_without_preview_lines_defaults_to_2() {
|
||||
let toml_str = r#"
|
||||
[[user]]
|
||||
slug = "a"
|
||||
api_key = "k1"
|
||||
web_password = ""
|
||||
role = "doctor"
|
||||
"#;
|
||||
let parsed: UsersFile = toml::from_str(toml_str).unwrap();
|
||||
assert_eq!(parsed.user[0].preview_lines, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_user_with_explicit_preview_lines() {
|
||||
let toml_str = r#"
|
||||
[[user]]
|
||||
slug = "a"
|
||||
api_key = "k1"
|
||||
web_password = ""
|
||||
role = "doctor"
|
||||
preview_lines = 5
|
||||
"#;
|
||||
let parsed: UsersFile = toml::from_str(toml_str).unwrap();
|
||||
assert_eq!(parsed.user[0].preview_lines, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_user_with_partial_retention_block() {
|
||||
let toml_str = r#"
|
||||
|
||||
@@ -90,6 +90,11 @@ struct UserCaseView {
|
||||
/// "geschlossen"). Clamped at 0 — cases eligible for purge this
|
||||
/// sweep render as "0" briefly.
|
||||
days_until_purge: Option<u32>,
|
||||
/// First paragraph of `document.md` as plain text (markdown
|
||||
/// formatting stripped). `None` when the case has no document yet.
|
||||
/// The browser CSS-clamps this to the user's configured number of
|
||||
/// lines; no server-side line counting happens.
|
||||
analysis_preview: Option<String>,
|
||||
}
|
||||
|
||||
/// Parse a recording filename `YYYY-MM-DDTHH-MM-SSZ.m4a[.failed]` as UTC
|
||||
@@ -157,6 +162,30 @@ fn recorded_at_iso_of(filename: &str) -> String {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Extract the first paragraph of a `document.md` body as plain text.
|
||||
///
|
||||
/// A "paragraph" here is the first non-empty block separated by a blank
|
||||
/// line (`\n\n`). Markdown decoration characters (`#`, `*`, `_`, `` ` ``,
|
||||
/// `=`, `>`) are stripped — the preview is meant to appear in a cramped
|
||||
/// list row where bold/italic/heading styling would only add noise.
|
||||
/// Returns `None` for input that ends up blank after stripping.
|
||||
///
|
||||
/// The visible line count is NOT enforced here: that is browser-side via
|
||||
/// CSS `line-clamp`. Server logic stays viewport-agnostic.
|
||||
fn preview_from_markdown(md: &str) -> Option<String> {
|
||||
let first = md.split("\n\n").map(str::trim).find(|p| !p.is_empty())?;
|
||||
let plain: String = first
|
||||
.chars()
|
||||
.filter(|c| !matches!(c, '#' | '*' | '_' | '`' | '=' | '>'))
|
||||
.collect();
|
||||
let trimmed = plain.trim();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
struct DateGroup {
|
||||
/// "Heute", "Gestern", or ISO date "YYYY-MM-DD".
|
||||
label: String,
|
||||
@@ -179,6 +208,10 @@ struct MyCasesTemplate {
|
||||
/// 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,
|
||||
}
|
||||
|
||||
/// Query params for GET /web/cases and /web/cases/{case_id}.
|
||||
@@ -416,6 +449,12 @@ pub async fn handle_my_cases(
|
||||
let any_closed = cases.iter().any(|c| c.is_closed);
|
||||
let groups = group_by_utc_date(cases);
|
||||
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 {
|
||||
slug: user.slug,
|
||||
groups,
|
||||
@@ -423,6 +462,7 @@ pub async fn handle_my_cases(
|
||||
is_admin,
|
||||
show_closed,
|
||||
any_closed,
|
||||
preview_lines,
|
||||
}
|
||||
.render()
|
||||
.map(Html)
|
||||
@@ -759,6 +799,17 @@ async fn compute_case_view(
|
||||
|
||||
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.
|
||||
let analysis_preview = if has_document {
|
||||
read_document(case_path)
|
||||
.await
|
||||
.and_then(|md| preview_from_markdown(&md))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let time_hms_utc = extract_hhmm_utc(&most_recent);
|
||||
let recorded_at_iso = recorded_at_iso_of(&most_recent);
|
||||
Some(UserCaseView {
|
||||
@@ -774,6 +825,7 @@ async fn compute_case_view(
|
||||
can_analyze,
|
||||
is_closed,
|
||||
days_until_purge,
|
||||
analysis_preview,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -858,3 +910,64 @@ async fn compute_oneliner_display(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn preview_from_markdown_takes_first_paragraph() {
|
||||
let md = "Erster Absatz mit Text.\n\nZweiter Absatz, irrelevant.";
|
||||
assert_eq!(
|
||||
preview_from_markdown(md).as_deref(),
|
||||
Some("Erster Absatz mit Text.")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_from_markdown_strips_formatting_chars() {
|
||||
let md = "# Überschrift\n\n**Patient** klagt über _Knie_ mit ==Schmerz==.";
|
||||
// Both the heading and the inline formatting survive only as plain text.
|
||||
assert_eq!(preview_from_markdown(md).as_deref(), Some("Überschrift"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_from_markdown_skips_leading_blank_paragraphs() {
|
||||
let md = "\n\n\n\nErster echter Absatz.";
|
||||
assert_eq!(
|
||||
preview_from_markdown(md).as_deref(),
|
||||
Some("Erster echter Absatz.")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_from_markdown_returns_none_for_blank_input() {
|
||||
assert_eq!(preview_from_markdown(""), None);
|
||||
assert_eq!(preview_from_markdown(" \n\n "), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_from_markdown_returns_none_when_only_formatting() {
|
||||
// An all-formatting "paragraph" (no actual letters/numbers after
|
||||
// strip) should collapse to None rather than an empty preview row.
|
||||
assert_eq!(preview_from_markdown("=== ## **"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_from_markdown_strips_inline_formatting_but_keeps_words() {
|
||||
let md = "**Fett** und _kursiv_ und `code` in einer Zeile.";
|
||||
assert_eq!(
|
||||
preview_from_markdown(md).as_deref(),
|
||||
Some("Fett und kursiv und code in einer Zeile.")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_from_markdown_drops_blockquote_marker() {
|
||||
let md = "> Patient berichtet über Schmerzen.";
|
||||
assert_eq!(
|
||||
preview_from_markdown(md).as_deref(),
|
||||
Some("Patient berichtet über Schmerzen.")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{%- import "partials/oneliner.html" as ol -%}
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<html lang="de" style="--preview-lines: {{ preview_lines }}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Doctate — Meine Fälle</title>
|
||||
@@ -28,6 +28,7 @@ section h2 .count { font-weight: normal; font-size: 0.85em; color: #888; }
|
||||
.case-row .line1 { font-size: 1em; margin: 0; }
|
||||
.case-row .line1 .time { font-family: monospace; font-weight: bold; color: #444; }
|
||||
.case-row .line2 { margin: 0.2em 0 0; color: #555; font-size: 0.95em; display: flex; align-items: center; gap: 0.5em; }
|
||||
.case-row .preview { margin: 0.3em 0 0; color: #666; font-size: 0.9em; display: -webkit-box; -webkit-line-clamp: var(--preview-lines); line-clamp: var(--preview-lines); -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.case-row .uuid { color: #999; font-family: monospace; font-size: 0.7em; margin-top: 0.2em; }
|
||||
.status-badge { display: inline-block; padding: 0.05em 0.5em; border-radius: 999px; font-size: 0.75em; font-weight: bold; color: white; }
|
||||
.status-badge.open { background: #4a90e2; }
|
||||
@@ -98,6 +99,7 @@ try {
|
||||
<a href="/web/cases/{{ case.case_id }}{% if case.is_closed %}?show_closed=1{% endif %}">
|
||||
<div class="line1"><span class="time"><time datetime="{{ case.recorded_at_iso }}">{{ case.time_hms_utc }}</time></span> — {% call ol::render(case.oneliner) %}</div>
|
||||
<div class="line2">{{ case.recordings_count }} Aufnahmen{% if case.has_failed_recording %} — <span class="status-badge fehler">Fehler</span>{% else if case.has_document %} — <span class="status-badge done">ausgewertet</span>{% else if !case.is_closed %} — <span class="status-badge open">offen</span>{% endif %}{% if case.analyzing %} <span class="label analyzing">wird analysiert</span>{% endif %}{% if case.is_closed %}<span class="closed-badge">geschlossen{% match case.days_until_purge %}{% when Some with (d) %} — wird in {{ d }} Tagen entfernt{% when None %}{% endmatch %}</span>{% endif %}</div>
|
||||
{% match case.analysis_preview %}{% when Some with (preview) %}<p class="preview">{{ preview }}</p>{% when None %}{% endmatch %}
|
||||
{% if is_admin %}<div class="uuid admin-only">{{ case.case_id }}</div>{% endif %}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -33,6 +33,7 @@ fn make_user(slug: &str) -> User {
|
||||
whisper: Default::default(),
|
||||
retention: Default::default(),
|
||||
window_hours: 72,
|
||||
preview_lines: 2,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ fn test_config() -> Arc<Config> {
|
||||
whisper: Default::default(),
|
||||
retention: Default::default(),
|
||||
window_hours: 72,
|
||||
preview_lines: 2,
|
||||
}],
|
||||
api_keys: HashMap::from([("test-key-123".into(), "dr_test".into())]),
|
||||
..Config::test_default()
|
||||
|
||||
@@ -25,6 +25,7 @@ fn make_user(slug: &str) -> User {
|
||||
whisper: Default::default(),
|
||||
retention: Default::default(),
|
||||
window_hours: 72,
|
||||
preview_lines: 2,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ fn make_user(slug: &str, password_plain: &str) -> User {
|
||||
whisper: Default::default(),
|
||||
retention: Default::default(),
|
||||
window_hours: 72,
|
||||
preview_lines: 2,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ fn make_user(slug: &str, password_plain: &str) -> User {
|
||||
whisper: Default::default(),
|
||||
retention: Default::default(),
|
||||
window_hours: 72,
|
||||
preview_lines: 2,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ fn make_user(slug: &str, password_plain: &str) -> User {
|
||||
whisper: Default::default(),
|
||||
retention: Default::default(),
|
||||
window_hours: 72,
|
||||
preview_lines: 2,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ fn make_user() -> User {
|
||||
whisper: Default::default(),
|
||||
retention: Default::default(),
|
||||
window_hours: 72,
|
||||
preview_lines: 2,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ fn test_config() -> (Arc<Config>, PathBuf) {
|
||||
whisper: Default::default(),
|
||||
retention: Default::default(),
|
||||
window_hours: 72,
|
||||
preview_lines: 2,
|
||||
}],
|
||||
api_keys: HashMap::from([(TEST_KEY.into(), TEST_SLUG.into())]),
|
||||
..Config::test_default()
|
||||
|
||||
@@ -36,6 +36,7 @@ fn user_with_retention(slug: &str, auto_close_days: u32, auto_delete_days: u32)
|
||||
auto_delete_days,
|
||||
},
|
||||
window_hours: 72,
|
||||
preview_lines: 2,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ fn make_user(slug: &str) -> User {
|
||||
whisper: Default::default(),
|
||||
retention: Default::default(),
|
||||
window_hours: 72,
|
||||
preview_lines: 2,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -267,6 +267,7 @@ fn test_config_with_whisper(whisper_url: String) -> Arc<Config> {
|
||||
whisper: Default::default(),
|
||||
retention: Default::default(),
|
||||
window_hours: 72,
|
||||
preview_lines: 2,
|
||||
}],
|
||||
whisper_url,
|
||||
whisper_timeout_seconds: 5,
|
||||
|
||||
@@ -76,6 +76,7 @@ async fn transient_whisper_failure_is_reenqueued_and_recovers() {
|
||||
whisper: Default::default(),
|
||||
retention: Default::default(),
|
||||
window_hours: 72,
|
||||
preview_lines: 2,
|
||||
}];
|
||||
let config = Arc::new(cfg);
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ fn test_config() -> Arc<Config> {
|
||||
whisper: Default::default(),
|
||||
retention: Default::default(),
|
||||
window_hours: 72,
|
||||
preview_lines: 2,
|
||||
}],
|
||||
api_keys: HashMap::from([("test-key-123".into(), "dr_test".into())]),
|
||||
..Config::test_default()
|
||||
|
||||
@@ -23,6 +23,7 @@ fn test_config() -> Arc<Config> {
|
||||
whisper: Default::default(),
|
||||
retention: Default::default(),
|
||||
window_hours: 72,
|
||||
preview_lines: 2,
|
||||
}],
|
||||
api_keys: HashMap::from([("test-key".into(), "dr_test".into())]),
|
||||
..Config::test_default()
|
||||
|
||||
@@ -8,6 +8,11 @@ slug = "dr_mueller"
|
||||
api_key = "change-me-to-a-secure-key"
|
||||
web_password = "$2b$12$..."
|
||||
role = "doctor"
|
||||
# Optional: how many visual lines of the LLM analysis preview the case
|
||||
# list renders under each row. The preview itself is always the first
|
||||
# paragraph of document.md; this value only caps the visible lines
|
||||
# before truncation with "…". Defaults to 2 if omitted.
|
||||
preview_lines = 2
|
||||
# Optional per-user retention. Both fields default to 0 (feature
|
||||
# disabled). auto_close_days = 7 + auto_delete_days = 30 means cases
|
||||
# without new recordings auto-close after 7 days and are irreversibly
|
||||
|
||||
Reference in New Issue
Block a user