diff --git a/server/src/config.rs b/server/src/config.rs index dd2f6b5..43a737e 100644 --- a/server/src/config.rs +++ b/server/src/config.rs @@ -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#" diff --git a/server/src/routes/user_web.rs b/server/src/routes/user_web.rs index 2527f1f..9ef0953 100644 --- a/server/src/routes/user_web.rs +++ b/server/src/routes/user_web.rs @@ -90,6 +90,11 @@ struct UserCaseView { /// "geschlossen"). Clamped at 0 — cases eligible for purge this /// sweep render as "0" briefly. days_until_purge: Option, + /// 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, } /// 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 { + 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 ``; 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.") + ); + } +} diff --git a/server/templates/my_cases.html b/server/templates/my_cases.html index edf48a9..aabdaa2 100644 --- a/server/templates/my_cases.html +++ b/server/templates/my_cases.html @@ -1,6 +1,6 @@ {%- import "partials/oneliner.html" as ol -%} - + Doctate — Meine Fälle @@ -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 {
— {% call ol::render(case.oneliner) %}
{{ case.recordings_count }} Aufnahmen{% if case.has_failed_recording %} — Fehler{% else if case.has_document %} — ausgewertet{% else if !case.is_closed %} — offen{% endif %}{% if case.analyzing %} wird analysiert{% endif %}{% if case.is_closed %}geschlossen{% match case.days_until_purge %}{% when Some with (d) %} — wird in {{ d }} Tagen entfernt{% when None %}{% endmatch %}{% endif %}
+{% match case.analysis_preview %}{% when Some with (preview) %}

{{ preview }}

{% when None %}{% endmatch %} {% if is_admin %}
{{ case.case_id }}
{% endif %}
diff --git a/server/tests/analyze_test.rs b/server/tests/analyze_test.rs index 67dddf9..e4cc0b7 100644 --- a/server/tests/analyze_test.rs +++ b/server/tests/analyze_test.rs @@ -33,6 +33,7 @@ fn make_user(slug: &str) -> User { whisper: Default::default(), retention: Default::default(), window_hours: 72, + preview_lines: 2, } } diff --git a/server/tests/auth_test.rs b/server/tests/auth_test.rs index 919b1ad..56bb6b8 100644 --- a/server/tests/auth_test.rs +++ b/server/tests/auth_test.rs @@ -19,6 +19,7 @@ fn test_config() -> Arc { 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() diff --git a/server/tests/case_page_test.rs b/server/tests/case_page_test.rs index 6b8ee03..73e7205 100644 --- a/server/tests/case_page_test.rs +++ b/server/tests/case_page_test.rs @@ -25,6 +25,7 @@ fn make_user(slug: &str) -> User { whisper: Default::default(), retention: Default::default(), window_hours: 72, + preview_lines: 2, } } diff --git a/server/tests/close_watermark_test.rs b/server/tests/close_watermark_test.rs index 8d50c96..112eaf0 100644 --- a/server/tests/close_watermark_test.rs +++ b/server/tests/close_watermark_test.rs @@ -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, } } diff --git a/server/tests/delete_recording_test.rs b/server/tests/delete_recording_test.rs index cbf6be9..8a5e342 100644 --- a/server/tests/delete_recording_test.rs +++ b/server/tests/delete_recording_test.rs @@ -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, } } diff --git a/server/tests/login_test.rs b/server/tests/login_test.rs index 47f3eb8..a898bd1 100644 --- a/server/tests/login_test.rs +++ b/server/tests/login_test.rs @@ -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, } } diff --git a/server/tests/magic_link_test.rs b/server/tests/magic_link_test.rs index 35d9606..2f7dc2c 100644 --- a/server/tests/magic_link_test.rs +++ b/server/tests/magic_link_test.rs @@ -26,6 +26,7 @@ fn make_user() -> User { whisper: Default::default(), retention: Default::default(), window_hours: 72, + preview_lines: 2, } } diff --git a/server/tests/oneliners_api_test.rs b/server/tests/oneliners_api_test.rs index 2850f11..92c4f17 100644 --- a/server/tests/oneliners_api_test.rs +++ b/server/tests/oneliners_api_test.rs @@ -30,6 +30,7 @@ fn test_config() -> (Arc, 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() diff --git a/server/tests/retention_sweep_test.rs b/server/tests/retention_sweep_test.rs index 20a516a..60a1950 100644 --- a/server/tests/retention_sweep_test.rs +++ b/server/tests/retention_sweep_test.rs @@ -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, } } diff --git a/server/tests/sse_integration.rs b/server/tests/sse_integration.rs index 1582c36..408f50b 100644 --- a/server/tests/sse_integration.rs +++ b/server/tests/sse_integration.rs @@ -39,6 +39,7 @@ fn make_user(slug: &str) -> User { whisper: Default::default(), retention: Default::default(), window_hours: 72, + preview_lines: 2, } } diff --git a/server/tests/transcribe_test.rs b/server/tests/transcribe_test.rs index 5a0c6bf..0965bef 100644 --- a/server/tests/transcribe_test.rs +++ b/server/tests/transcribe_test.rs @@ -267,6 +267,7 @@ fn test_config_with_whisper(whisper_url: String) -> Arc { whisper: Default::default(), retention: Default::default(), window_hours: 72, + preview_lines: 2, }], whisper_url, whisper_timeout_seconds: 5, diff --git a/server/tests/transient_failure_retries_test.rs b/server/tests/transient_failure_retries_test.rs index 24b9faa..2fa79ce 100644 --- a/server/tests/transient_failure_retries_test.rs +++ b/server/tests/transient_failure_retries_test.rs @@ -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); diff --git a/server/tests/upload_test.rs b/server/tests/upload_test.rs index fee191c..a4ba3aa 100644 --- a/server/tests/upload_test.rs +++ b/server/tests/upload_test.rs @@ -24,6 +24,7 @@ fn test_config() -> Arc { 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() diff --git a/server/tests/web_test.rs b/server/tests/web_test.rs index ac9dca7..563c35d 100644 --- a/server/tests/web_test.rs +++ b/server/tests/web_test.rs @@ -23,6 +23,7 @@ fn test_config() -> Arc { 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() diff --git a/server/users.toml.example b/server/users.toml.example index 2062f2c..b975b89 100644 --- a/server/users.toml.example +++ b/server/users.toml.example @@ -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