Introduce `display_name` field to `User` struct for human-readable names
in the UI.
This falls back to the user's `slug` if not provided.
Update `AuthenticatedWebUser` and `MyCasesTemplate` to use this new
field for rendering.
Add tests for parsing `display_name` and ensure fallback logic works
correctly.
Update `users.toml.example` to document the new field.
Multi-stage Dockerfile, host-network compose, idempotent deploy script.
Configs and persistent data bind-mounted from /opt/stacks/doctate-server.
deploy-server.sh handles bootstrap + re-deploy in one command with
auto-prune (keep 5 last SHA-tagged images).
See docs/deployment.md for bootstrap, rollback, and TLS-proxy notes.
Add Fachrichtung to prompt and refine medical terms.
Added Fachrichtung to the system prompt, instructing the LLM to consider
the medical specialty for precise terminology.
Also, added "Abdomensonographie" to the `medical_terms.txt` file.
Increase `max_completion_tokens` for `gpt_oss_120b` from 8192 to 16384
to prevent incomplete responses.
Navigate to the detail screen before the recording screen when starting
a new case, ensuring correct back navigation.
When a previous analysis attempt fails and leaves a failure marker, the
retry mechanism now correctly identifies this state. It removes the
stale
input file, allowing the retry to proceed without a conflict error. This
ensures users can recover from analysis failures more gracefully.
An empty `backend` form field now correctly falls back to the default. A
non-empty field submitted by a non-admin user is rejected with a 403
error, preventing potential tampering or issues arising from stale
sessions after role changes.
This commit adjusts the configuration for the `gpt-oss-120b` model to
run in plain-text mode. This is necessary because when
`reasoning_effort` is set to "medium", the reasoning tokens combined
with schema-constrained decoding can exhaust the `max_completion_tokens`
budget, leading to an empty content response.
The change restores the previous behavior where `gpt-oss-120b` was
configured without `response_format` or `top_p`, ensuring the request
body sent to Ionos remains byte-identical to the pre-multi-backend
state. This prevents regressions and maintains stability for existing
production workflows.
Configure Llama 3.1 405B with specific temperature, top_p, and a format
instruction. This addresses issues with infinite repetition and empty
document generation observed with this model. The format instruction is
added as a separate system message to avoid modifying the core medical
prompt.
`recovery::enqueue_pending_for_user` previously enqueued any case with
`analysis_input.json` and no `document.md`, ignoring the failure marker
that `auto_trigger::evaluate_case` already honours. A timed-out LLM
call (e.g. Llama 3.1 405B at ~20 tok/s with default 4096 max_tokens)
left the input in place, the next case-list reload re-enqueued it, and
the worker hung another full timeout window — starving fresh user
clicks behind a failed retry loop.
Recovery now mirrors the same skip: if a marker exists whose
`last_recording_mtime` matches the input's, treat it as "already
tried, do not retry until input changes". Fresh recordings bump the
mtime and recovery picks the case back up.
Replace the single [llm] block in settings.toml with a curated catalog
of LLM "backends" (provider + model + sampling params + system prompt)
in server/src/analyze/backend.rs. Two backends ship initially:
gpt_oss_120b (default, reasoning_effort=medium) and llama_3_1_405b
(uses response_format: json_schema to sidestep the <|eot_id|> leak).
The case page now renders one submit button per available backend; the
clicked button's name=backend value rides through AnalysisInput.backend_id
to the worker, which looks it up via find_backend() with default fallback.
A submitted unknown backend id is rejected as 400. .analysis_failed.json
records the failed backend and the failure banner surfaces its label.
The API key now comes from IONOS_API_KEY (env), not settings.toml; the
[llm] section is read into a discarded field so old configs still load.
Backends without a satisfied requires_api_key are filtered from the UI
(any_backend_available()). Three end-to-end tests that mocked the LLM
endpoint via settings.llm.url are #[ignore]'d with a clear note — they
need per-test backend injection (Arc<Vec<LlmBackend>> through the worker)
before they can be re-enabled.
When an LLM analysis fails, a `.analysis_failed.json` marker is created.
If no document exists yet and the auto-trigger is blocked by this
marker,
the case page must display a banner. This banner provides the failure
reason and a button to retry the analysis, ensuring users are not left
in a dead-end state.
The `read_failure_marker` function is made public to allow the web layer
to access this failure information. The `CasePageTemplate` is updated to
include `analysis_failed` data, which conditionally renders the new
`.failure-banner` HTML.
This change prevents cases from becoming unrecoverable due to transient
LLM errors.
Includes a button for administrators to copy all transcripts and the
document markdown to the clipboard.
The data is embedded as JSON in a script tag and pre-sanitized to
prevent
`</script>` tag injection.
The JavaScript handles copying to the clipboard using the modern
Clipboard API,
with a fallback for older browsers.
Add [workspace] / [workspace.package] / [workspace.dependencies]
blocks to server/Cargo.toml and clients/desktop/Cargo.toml so each
becomes a self-contained workspace with its own Cargo.lock.
Remove the root Cargo.toml and Cargo.lock; the repo now hosts
three independent build worlds: server, clients/desktop, experiments.
Convert common/Cargo.toml to concrete versions (was workspace-
inherited from the root). common/ is consumed via path-dep from
all three worlds, but is not itself a workspace -- consumers
unify versions via their own lockfiles. Add common/Cargo.lock to
.gitignore per Cargo lib-only-crate convention.
Verification:
- server/Cargo.lock has 0 eframe/egui/winit entries
- cargo build --release --locked passes in server/
- test split: server 385 + desktop 89 + common 34 = 508 total
- bin output: clients/desktop/target/debug/doctate (named "doctate")
Move three directories into their target topology:
- doctate-common/ -> common/
- client-desktop/ -> clients/desktop/
- watch/wearos/ -> clients/wearos/
Update doctate-common path-dependency in 3 Cargo.toml files
(server, clients/desktop, experiments) and the workspace
members list in the root Cargo.toml. Crate names unchanged
(doctate-server, doctate-common, doctate-desktop).
Workspace still in single-Cargo.lock form; isolation into
standalone workspaces follows in stage 3. All 508 tests pass,
experiments standalone-workspace also resolves the new path.
Introduce a new field `reasoning_effort` to the `ChatRequest` struct.
This field, set to "medium" by default, allows tuning LLM reasoning
depth to balance hallucination risk against latency and token cost. This
setting is specifically for Ionos' GPT-OSS reasoning models and is
ignored by other OpenAI-compatible models.
The system prompt for the medical assistant has been refactored to
improve clarity and precision. Key changes include:
- Enhanced instructions on handling transcribed errors, emphasizing
verbatim transcription and the use of `==...==` for uncertain parts.
- More explicit guidance on the formatting of dosage schemes, including
the conversion of numerical representations to hyphenated formats
(e.g., "1-0-2").
- Introduction of specific examples for common medical abbreviations and
terms, such as "CDAI > 450", "per os", and "iv.".
- A new critical rule to enclose any output not part of the transcript
in `[...]`.
- The addition of "Ileus" to `medical_terms.txt` and "Adalimumab",
"Infliximab" to `vocabulary.txt`.
The `Gazetteer` struct has been refactored to store `Entry` structs,
where each entry contains a canonical form and a set of bypass aliases.
This change enables the bypassing of Hunspell's dictionary veto for
specific, explicitly defined aliases.
The `parse_line` function has been introduced to handle the new line
format, which allows for `-`-prefixed aliases. Malformed lines with
secondary tokens not prefixed by `-` now result in a hard error, failing
server startup with `io::ErrorKind::InvalidData`.
The `replace` method has been updated to check bypass aliases before
consulting the dictionary, ensuring that explicitly allowed aliases are
used for rewriting. This addresses issues where Hunspell incorrectly
identifies compounds, blocking necessary corrections.
The `README.md` in the `vocab` directory has been updated to document
the new bypass-alias functionality.
This commit removes the `whisper_variant` and `whisper_hotwords_variant`
fields from the `run_id` generation and the `print_meta_summary`
function.
These variants are no longer used as the project is shifting focus to
LLM-based generation. The `run_full_case.rs` example has also been
updated to reflect this change.
Sandbox-validated against case c414cf52 (3 runs each, fair pre+post-LLM
gazetteer pipeline): the new prompt eliminates two hallucination classes
the prior version produced — unmarked "Hypotonie" when the dictation said
"Hypertonie" (0/3 vs 2/3) and inventing units like "35 ng/l" for values
without unit (0/3 vs 2/3) — while keeping Latin terms (Punctum Maximum
etc.) intact in 3/3 runs vs 2/3.
Block layout reorganized so each rule appears exactly once: AUFGABE /
QUELLE / KORREKTUR-POLITIK / TREUE / CHRONOLOGIE / DOSIERUNGSSCHEMA /
MARKIERUNGEN. The removed "AKTIVE KORREKTUR (nicht durchreichen)" hammer
block is no longer load-bearing because the gazetteer's pre-LLM pass now
catches the typical drug-name typos before the LLM sees them.
Vocabulary additions (single-token, alphabetical, ≥5 chars):
- vocabulary.txt: Enoxaparin (was missing — Inoxaparin→Enoxaparin is a
recurrent ASR error and the prior pipeline relied on the LLM alone)
- medical_terms.txt (new file, picked up by the dir-glob loader):
Holosystolikum, Klappenvitien, Koronarbaum, Pumpfunktion
Sandbox-iteration unter experiments/case_c414cf52 (3 Runs je Variante,
gpt-oss-120b @ Ionos, temperature=0). v2_treue gewinnt gegen Baseline
und v1_treue auf folgenden Achsen ohne Curve-Fitting im Prompt:
- Active drug-name correction (Inoxaparin→Enoxaparin, Thorazemit→
Torasemid, AFREF→HFrEF) durchgesetzt: 3/3 statt 0/3
- Schema 1-0-0 / 1-0-1 zuverlässiger: 3/3 statt 2/3
- Troponin-T fehlende Einheit als ==35== markiert (vorher unkommuniziert)
- Hypo/Hyper-Verlaufsgeschichte aus widersprüchlichen Aufnahmen
unterdrückt: 0/3 (war 1/3 in v1_treue)
- Anatomische Lokalisationen, "adipöser Ernährungszustand" und
Diktat-Reihenfolge bleiben treu (vorher selten aber gelegentlich
weggelassen oder umsortiert)
- Format als Fließtext explizit, Pseudo-Headings reduziert
Strukturell offen (kein Prompt-Fix möglich): "Holosystolikum" aus
Whispers Halbwortruine "Tolikum", "Koronarbund"-Halluzination aus
"Corona-Baum" — beides braucht Gazetteer-Erweiterung oder
Tokenizer-Refactor, separate PRs.
Umstellung auf Rust-Raw-String macht die Prompt-Datei deutlich
lesbarer (keine \n-Escapes, keine \"-Escapes mehr).
The `RecordingView` struct now includes `gain_db` to represent
replay-gain. This value is read from the recording's metadata (`.json`
sidecar) and passed to the frontend.
The JavaScript in `case_recordings.html` uses this `data-gain-db`
attribute to apply replay-gain using the Web Audio API. This ensures
consistent playback loudness across different recordings. The
implementation uses a shared `AudioContext` to manage resources
efficiently and handles cases where the browser might not support
`AudioContext`.
Additionally, the audio recording on Wear OS now uses
`MediaRecorder.AudioSource.VOICE_RECOGNITION` instead of `MIC`. This
leverages platform noise and echo cancellation, aiming for a cleaner
signal before server-side gain adjustment.
This commit extracts the logic for mapping audio analysis results into
the `RecordingMeta` fields into a new, pure function
`analysis_to_meta_fields`. This improves testability by separating the
core mapping logic from logging and other side effects.
The previous implementation directly handled logging and conditional
logic within the `run` function. The new function encapsulates these
mapping rules, including handling finite values, silent audio, and error
propagation. The caller, `run`, is now responsible for logging any
analysis failures before calling the helper. Unit tests have been added
to verify the behavior of `analysis_to_meta_fields` in various
scenarios.
This commit introduces the `Loudness` struct to store replay-gain data
(mean, max, and calculated gain in dB) derived from `ffmpeg`'s
`volumedetect`.
The `RecordingMeta` struct is updated to include an optional `loudness`
field. This allows for consistent playback volume across recordings with
varying microphone levels by applying gain in the browser.
Additionally, new `server/src/loudness.rs` and
`server/src/transcribe/ffmpeg.rs` modules are added. The former defines
constants for replay-gain targets and logic to compute gain, while the
latter handles audio analysis using `ffmpeg` to extract loudness
metrics.
The `#[serde(default)]` attribute on the `loudness` field in
`RecordingMeta` ensures backward compatibility with older metadata files
that do not contain this field. Tests are included to verify round-trip
serialization and parsing of older formats.
Introduces a new `validate` module to centralize input validation for
web
requests. This module contains functions to check the shape and format
of
various user-supplied strings before they are processed by the main
application logic.
Specifically, this commit adds validation for:
- Login slugs: Ensures slugs conform to a strict pattern (lowercase
alphanumerics, `_`, `-`) to prevent path traversal and other attacks.
- Magic link tokens: Validates token length and character set to ensure
they are URL-safe and within expected bounds.
- Recorded at timestamps: Enforces a strict RFC3339 format with second
granularity (`YYYY-MM-DDTHH:MM:SSZ`) to prevent filename parsing
issues
and ensure consistent data grouping.
These validators are integrated into the `handle_login_submit`,
`handle_consume` (magic link), and `handle_upload` routes. This change
enhances security by preventing malformed inputs from reaching sensitive
parts of the application and improves robustness by catching data format
errors early.
Unit and integration tests have been added to verify the functionality
of
these validators and their integration into the respective endpoints.
This commit changes the way transcriptions are stored and accessed.
Instead of using plain text files (`.transcript.txt`), transcriptions
will now be part of a JSON metadata file (`<stem>.json`). This allows
for richer metadata to be stored alongside the transcript, such as
duration, and provides a more robust mechanism for tracking
transcription states.
The changes include:
- Updating documentation and code to reflect the new `.json` file
extension.
- Modifying file handling logic to read and write JSON metadata.
- Adjusting tests to accommodate the new file format.
Replaces the `.transcript.txt` sidecar with a structured `.json` file
for recording metadata. This change consolidates transcript text,
duration, and other potential metadata into a single, extensible JSON
object.
This also refactors the `TranscriptState` enum to better represent the
on-disk state (absence of file means pending) and the in-memory
representation. The `Transcript` enum now specifically models the
terminal outcomes of the transcriber (`Silent` or `Content`).
The commit includes updates to documentation, data structures, path
handling, and various tests to align with the new metadata format.
Config sheds its 14 Bucket-B fields (retention, whisper, ollama, llm) and
gains a sibling Arc<Settings> in AppState, loaded from settings.toml at
startup via Settings::load_or_default. Workers (transcribe, analyze,
recovery, auto_trigger) and routes (health, bulk, case_actions,
user_web) take settings as a separate parameter or State<> extractor;
Config::llm_configured moves to Settings::llm_configured.
TestConfig::build_pair() returns (Arc<Config>, Arc<Settings>); the new
create_router_with_settings / create_router_and_session_store_with_settings
helpers wire both into integration tests that exercise LLM/Whisper/Ollama.
The legacy single-Arc create_router falls back to Settings::default()
so the 17 non-LLM tests stay untouched.
Verified: cargo build clean, clippy --all-targets clean, 318 tests pass.
.env shrinks to bootstrap values only (port, paths, log, auth, deploy
flags). The 14 admin-tunable values (retention, whisper, ollama, llm)
move to a new settings.toml with serde(default)-based per-section
defaults and an empty-prompt fallback to the code default. settings.toml
is gitignored alongside .env; settings.toml.example is the template.
This commit fixes the file layout. The server-side wiring (Config shrink,
AppState integration, consumer migration) follows separately.
Consolidate the sticky-Manual rule for oneliner.json into a single
helper paths::delete_oneliner_unless_manual that holds the per-case
lock, reads the state, keeps OnelinerState::Manual, and removes any
LLM-derived variant. Three previously-direct deletion paths now route
through it: handle_delete_recording, reset_case_artefacts (admin
single-reset), and bulk_reset (admin bulk action). Before this fix a
clinician's manual override was wiped by a recording delete, letting
the LLM auto-regen path overwrite it on the next view.
Tests: 4 unit tests for the wrapper contract, integration tests for
the recording-delete and case-reset endpoints (Manual preserved,
Ready wiped), and a static-scan invariant that fails on direct
remove_file(... ONELINER_FILENAME) calls outside paths.rs.
- Replace native confirm() in the recording list with an inline
two-step confirm row; hide the player via visibility:hidden
while open so the row height stays stable.
- Hard-redirect recording delete back to /web/cases/{id}/recordings
instead of relying on the Referer header (which silently fell
back to /web/cases when stripped).
- Switch analyze/reset to a hidden return_to form field so the
source page (case detail vs. case list) is preserved reliably,
with same-origin /web/ prefix validation.
- Tighten delete_recording_test on the concrete Location header;
adjust analyze/reset redirect expectations to the new
case-detail fallback.
Adds a new POST endpoint for web-based oneliner overrides and updates
the UI to allow manual editing. The person icon replaces the pencil icon
for doctor-authored titles, signifying manual authorship rather than
editability.
Introduces `OnelinerLocks` to serialize read-modify-write operations on
`oneliner.json`. This prevents race conditions between the manual
override API (`PUT /api/cases/{case_id}/oneliner`) and the transcription
worker's auto-regeneration process.
The manual override now acquires a lock specific to the `case_dir`
before writing. The transcription worker's `update_oneliner` function
also acquires the same lock around its post-LLM re-read-and-write
sequence. This ensures that a doctor's manual edit always takes
precedence, even if it arrives while an auto-regeneration is in
progress.
This change also includes:
- A new `routes::oneliner_override` module for the PUT handler.
- Validation for the oneliner text length and emptiness.
- Integration tests for the override functionality, covering happy path,
error conditions, early latching, race conditions, and parallel PUTs.
This commit introduces a new `Manual` variant to the `OnelinerState`
enum, allowing for manual overrides of the oneliner text. This change
affects the client and server components to handle and display this new
state.
A `OnelinerOverrideRequest` struct is also added for API requests to set
manual oneliners. New tests are included to ensure the `Manual` variant
serializes and deserializes correctly.
The `compute_oneliner_display` function in
`server/src/routes/user_web.rs` is updated to treat `Manual` states the
same as `Ready` states for display purposes. The
`cases_needing_oneliner_in` function in
`server/src/transcribe/recovery.rs` is updated to not retry cases that
are in a `Manual` state.
Pulls three pattern groups into shared helpers so a rename or wire-format
tweak edits one file instead of 10+:
- BulkAction enum in doctate-common replaces the "close"/"analyze"/"reset"
string literals that were duplicated between the bulk handler and 3
attack/CSRF test files. FromStr preserves the exact "Unbekannte Aktion"
error shape; the handler match is now exhaustive over the enum.
- join_url helper in doctate-common absorbs 7 identical
trim_end_matches+format! call sites across client-core, client-desktop,
server/transcribe (ollama, whisper), and server/analyze (llm).
- server/tests/common/artefacts.rs re-exports ONELINER_FILENAME
(doctate-common), DOCUMENT_FILE + ANALYSIS_INPUT_FILE
(doctate-server::analyze), and CLOSE_MARKER (doctate-server::paths) so
test files reference the canonical name instead of inlining literals.
Also lifts client-desktop local duplication:
- paths.rs: project_path helper collapses 4 identical ProjectDirs chains
- main.rs: or_die helper replaces 4 eprintln!+exit(1) blocks
- app.rs: named RecordingContext struct replaces (Uuid, String) tuple
at 3 sites around the ffmpeg-flush finalization path
Verification: 397 tests pass (baseline was 389; +8 for new unit tests on
BulkAction and join_url), 0 failed, 4 ignored (unchanged). clippy clean.
Finishes the lift of shared helpers into `tests/common/`. Covered:
- health, web, oneliners_api, upload (31 tests; upload exercises the
new `multipart_upload_body` helper driven by doctate_common field
constants)
- sse_integration, sse_cleanup, transcribe, oneliner_heal_decoupled,
silent_case_empty, failed_only_case_empty_oneliner,
transient_failure_retries (24 tests)
Also applied cargo fmt across the test tree and fixed one clippy
needless_borrows_for_generic_args warning in analyze_test.
All 387 tests pass; 3 ignored (as before).
Side effect: health_test previously used a hardcoded `/tmp/doctate-test`
data path, which parallel `cargo test` runs could collide on. The
migration replaces it with the common unique-tmpdir pattern, removing
a latent flake.
Lift duplicated test-harness code (User factories, TestConfig builder,
login/CSRF flow, form/json helpers, case-directory seeding, URL path
builders) into `server/tests/common/` so future API changes touch one
file instead of every integration test.
Migrated batches: csrf_attack, auth, login, magic_link, security_headers
(28 tests); analyze, case_page, delete_recording, close_watermark,
retention_sweep (67 tests). All 95 tests still green.
Net line delta across these 10 files: +1113 / -1896 (~780 lines removed),
plus ~500 lines of new shared infrastructure under tests/common/.
Adds the csrf_token hidden field to every POST form in the web UI
(logout, bulk, purge-closed, close, reopen, analyze, reset,
delete-recording). Login stays without a token — SameSite=Strict
already blocks the relevant attack shapes and forced-login CSRF
has no impact here.
Shape:
- New askama macro partials/csrf_field.html renders the hidden
input; 3 templates import + {% call csrf::field(csrf_token) %}
inside each <form method="POST">.
- AuthenticatedWebUser carries csrf_token (cloned out of the
session once during extraction) so render handlers don't need a
second store lookup.
- 3 template structs (MyCasesTemplate, CasePageTemplate,
CaseRecordingsTemplate) gain the field; render sites pass
user.csrf_token through.
Safety-net tests:
- Each rendered page must contain the exact session csrf_token in
a hidden input — catches anyone adding a new form without the
macro.
- Happy-path round-trip: fetch page, parse token from HTML, POST
with token → 303. Catches drift between rendered and accepted
token formats.
Introduces CsrfForm<T>: a FromRequest extractor that looks up the
session's csrf_token and constant-time-compares it to a csrf_token
field in the form body. Drop-in replacement for Form<T> on every
state-changing /web/ POST handler. Missing or expired session →
redirect to /web/login; malformed body → 400; token mismatch → 403.
WebSession carries csrf_token, minted alongside the session token at
login and magic-link consume. Not rotated per request — rotation
would break multi-tab use and buys little over SameSite=Strict
cookies.
Handlers: bulk, purge-closed, reset, close, reopen, analyze,
delete-recording, logout all now require CsrfForm<_>. Login stays
unprotected (SameSite=Strict alone is sufficient — forced-login CSRF
has no impact on this codebase). /api/... is header-auth, exempt.
Constant-time compare via subtle::ConstantTimeEq avoids timing
oracles on the token.
Template work is NOT in this commit — browser forms still post
without csrf_token, so the live web UI will 403 until Schritt 6
(templates render the hidden field).
Tests: all 12 red specs in csrf_attack_test.rs now green, #[ignore]
removed. Integration tests that POSTed to protected endpoints
switched to a new create_router_and_session_store entrypoint which
returns a handle to the store so tests can read csrf_token for the
active session.
Attaches a SetResponseHeaderLayer stack to create_router_with_state
(not main.rs) so tests observe the same response shape as production.
`if_not_present` mode so per-route overrides (magic.rs already sets
its own Referrer-Policy) are preserved.
Sets: X-Content-Type-Options, X-Frame-Options, Referrer-Policy,
Content-Security-Policy, Permissions-Policy. HSTS is deliberately
omitted until TLS termination is in place — a cached max-age on a
plain-HTTP deployment is irreversible.
CSP uses 'unsafe-inline' for script/style since templates contain
inline scripts; revisit if any user input ever renders unescaped.
Removes #[ignore] from the 10 attack-confirming header tests; two
regression anchors (no-HSTS, no-duplicated magic header) were already
green.
Move admin checks from individual bulk actions to the main handler for
`bulk.rs` and `case_actions.rs`. This consolidates the authorization
logic for these sensitive operations.
Additionally, refine the HTML template to conditionally render bulk
action UI elements and the purge form only when the user is an admin.
This ensures that non-admin users do not see or have access to these
administrative functions.
Update tests to reflect these changes, ensuring that non-admin users are
correctly rejected for these actions and that the UI is properly hidden.
Replaces `analysis_preview` with `analysis_html` to display the full
markdown-rendered document content instead of just a plain-text preview.
This allows for richer content presentation and enables client-side
expansion for detailed views.
The HTML template has been updated to include a new `.analysis`
container that handles the expand/collapse functionality using
JavaScript. This approach avoids server-side truncation and relies on
CSS for presentation.
Introduce a dedicated case-link class for clickable case titles.
Enhance the line1 div to use flexbox for better alignment of time,
title, and recordings count.
Add a separate link for recordings, improving the structure and user
experience of the case list.
Introduce `count_closed_by_date` and `most_recent_date_label` to
efficiently count closed cases for display purposes when `show_closed`
is false.
Modify `group_by_utc_date` to accept a `closed_extra` map, allowing it
to correctly calculate both open and total case counts per day.
Update the `my_cases.html` template to display the new
`open_count/total_count` format for case groups.
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`.
The /api/oneliners time window is now read per-user from users.toml
(window_hours, default 72h). Clients no longer carry a window:
client.toml oneliner_window_hours, SyncConfig.window_hours, the
?hours=N query param, and the render_case_list cutoff filter are
gone. ETag suffix keeps the effective hours so an admin edit to
users.toml invalidates client caches on the next request.
OnelinersResponse.window_hours stays in the wire format, but now
exists solely to anchor client reconciliation.