feat: Add web UI for manual oneliner overrides

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.
This commit is contained in:
2026-04-26 17:17:09 +02:00
parent d732fdd8ec
commit 03736b6993
9 changed files with 531 additions and 49 deletions
+39 -9
View File
@@ -69,6 +69,12 @@ struct UserCaseView {
/// the server's, e.g. server on Bahamas, doctor in Germany).
recorded_at_iso: String,
oneliner: OnelinerDisplay,
/// True iff the persisted oneliner is `OnelinerState::Manual { .. }`
/// AND the displayed text actually shows that manual content (i.e.
/// not while we're rendering a Pending/Generating placeholder). The
/// template uses this to render a 👤 marker next to the title so the
/// doctor can tell at a glance which titles they themselves wrote.
oneliner_is_manual: bool,
recordings_count: usize,
analyzing: bool,
has_document: bool,
@@ -307,6 +313,9 @@ struct CasePageTemplate {
case_id: String,
case_id_short: String,
oneliner: OnelinerDisplay,
/// Mirror of `UserCaseView::oneliner_is_manual`. See that field for
/// the suppression-during-progress rationale.
oneliner_is_manual: bool,
/// RFC3339 UTC timestamp of the most recent recording. `None` iff the
/// case has no recordings at all. Drives the datetime header under the
/// title; browser JS formats it to "Heute 11:34" / "13.12.2022 11:34".
@@ -622,7 +631,7 @@ pub async fn handle_case_page(
.map(|md| crate::analyze::render::md_to_html(&md));
let recordings = scan_recordings(&case_dir).await;
let oneliner = compute_oneliner_display(&case_dir, &recordings).await;
let (oneliner, oneliner_is_manual) = compute_oneliner_display(&case_dir, &recordings).await;
let a_busy = pipeline.analyze_busy.0.load(Ordering::Acquire);
let flags = compute_flags(&case_dir, &recordings, config.llm_configured(), a_busy).await;
@@ -648,6 +657,7 @@ pub async fn handle_case_page(
case_id: case_id_str,
case_id_short,
oneliner,
oneliner_is_manual,
recorded_at_iso,
time_hms_utc,
document_html,
@@ -884,7 +894,7 @@ async fn compute_case_view(
.filter(|r| !r.failed)
.all(|r| r.transcript.has_file());
let oneliner = compute_oneliner_display(case_path, &recordings).await;
let (oneliner, oneliner_is_manual) = compute_oneliner_display(case_path, &recordings).await;
let has_document = any_document_exists(case_path).await;
let analyzing = !has_document && worker_busy && any_analysis_input_exists(case_path).await;
@@ -913,6 +923,7 @@ async fn compute_case_view(
time_hms_utc,
recorded_at_iso,
oneliner,
oneliner_is_manual,
recordings_count,
analyzing,
has_document,
@@ -961,14 +972,21 @@ async fn ready_text(case_dir: &Path) -> Option<String> {
}
}
/// Compute the 5-state UI display for the oneliner. Identical logic to
/// what `compute_case_view` uses for the case list — extracted so the
/// case page can render the same states instead of a plain "Fall"
/// fallback.
/// Compute the 5-state UI display for the oneliner plus a flag
/// indicating manual authorship. Identical logic to what
/// `compute_case_view` uses for the case list — extracted so the case
/// page can render the same states instead of a plain "Fall" fallback.
///
/// Returns `(display, is_manual)`. `is_manual` is `true` iff the
/// persisted state is `OnelinerState::Manual { .. }` AND the case is
/// in a stable state where that text is actually being shown
/// (i.e. not `Pending`/`Generating` — those override the persisted
/// content with progress placeholders, so the manual flag would be
/// misleading there).
async fn compute_oneliner_display(
case_dir: &Path,
recordings: &[RecordingView],
) -> OnelinerDisplay {
) -> (OnelinerDisplay, bool) {
let non_failed_count = recordings.iter().filter(|r| !r.failed).count();
let all_transcribed = non_failed_count > 0
&& recordings
@@ -976,8 +994,9 @@ async fn compute_oneliner_display(
.filter(|r| !r.failed)
.all(|r| r.transcript.has_file());
let (state, _) = paths::read_oneliner_state(case_dir).await;
let is_manual = matches!(state, Some(OnelinerState::Manual { .. }));
if non_failed_count == 0 {
let display = if non_failed_count == 0 {
// All recordings failed — stable end state, no LLM will ever run.
// Missing state collapses to Empty ("unbenannt"): no title is
// expected, regardless of why.
@@ -1007,7 +1026,17 @@ async fn compute_oneliner_display(
None => OnelinerDisplay::Pending,
Some(_) => OnelinerDisplay::Generating,
}
}
};
// Suppress the manual marker while we're showing a progress
// placeholder ("transkribiere…", "generiere Titel…"): the displayed
// string isn't the manual text anyway, so a 👤 next to it would lie.
let show_manual = is_manual
&& !matches!(
display,
OnelinerDisplay::Pending | OnelinerDisplay::Generating
);
(display, show_manual)
}
#[cfg(test)]
@@ -1021,6 +1050,7 @@ mod tests {
time_hms_utc: String::new(),
recorded_at_iso: String::new(),
oneliner: OnelinerDisplay::Empty,
oneliner_is_manual: false,
recordings_count: 1,
analyzing: false,
has_document: false,