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
+4
View File
@@ -59,6 +59,10 @@ pub fn api_router() -> Router<AppState> {
"/web/cases/{case_id}/reset",
post(case_actions::handle_reset_case),
)
.route(
"/web/cases/{case_id}/oneliner",
post(oneliner_override::handle_web_put_oneliner),
)
.route(
"/web/cases/purge-closed",
post(case_actions::handle_purge_closed),
+111 -35
View File
@@ -1,45 +1,65 @@
//! `PUT /api/cases/{case_id}/oneliner` — doctor's manual oneliner override.
//! Doctor's manual oneliner override — two entry points sharing one core.
//!
//! Writes [`OnelinerState::Manual`], which the transcribe worker's
//! `update_oneliner` treats as terminal: the manual text is sticky
//! across new recordings until another manual edit (or explicit reset)
//! replaces it. See plan
//! - `PUT /api/cases/{case_id}/oneliner` (X-API-Key) for native clients.
//! - `POST /web/cases/{case_id}/oneliner` (session + CSRF) for the
//! browser-based web UI.
//!
//! Both routes funnel into [`apply_manual_override`], which validates
//! the text, takes [`OnelinerLocks::lock_for`] for the duration of the
//! disk write, persists [`OnelinerState::Manual`], and emits an
//! `OnelinerUpdated` event so SSE listeners can reflect the change.
//!
//! `Manual` is treated as terminal by the transcribe worker's
//! `update_oneliner`: the manual text is sticky across new recordings
//! until another manual edit replaces it. See plan
//! `plane-die-erweiterung-oneliner-override-pure-goblet.md` for the
//! full concurrency rationale.
//!
//! Authentication: `X-API-Key` header, shared with `/api/upload` and
//! `/api/oneliners` (the GET counterpart).
//!
//! Concurrency: holds [`OnelinerLocks::lock_for`] for the duration of
//! the disk write. The worker's auto-regen path takes the same lock
//! around its post-LLM re-read-and-write sequence, so a doctor's PUT
//! arriving while the LLM is still thinking always wins.
use std::path::Path;
use std::sync::Arc;
use axum::Json;
use axum::extract::State;
use doctate_common::oneliners::{OnelinerOverrideRequest, OnelinerState};
use serde::Deserialize;
use tracing::info;
use crate::auth::AuthenticatedUser;
use crate::auth::{AuthenticatedUser, AuthenticatedWebUser};
use crate::case_id::CaseIdPath;
use crate::config::Config;
use crate::csrf::{CsrfForm, HasCsrfToken};
use crate::error::AppError;
use crate::events::{self, CaseEventKind, EventSender};
use crate::oneliner_locks::OnelinerLocks;
use crate::paths;
use crate::routes::user_web::locate_case_or_404;
/// Hard cap on accepted text length. A oneliner is a single sentence
/// (typical 3080 chars); 500 leaves comfortable headroom while
/// preventing accidental dumps from a runaway client.
const MAX_ONELINER_TEXT_LEN: usize = 500;
pub async fn handle_put_oneliner(
user: AuthenticatedUser,
State(locks): State<OnelinerLocks>,
State(events_tx): State<EventSender>,
CaseIdPath(case_id): CaseIdPath,
Json(req): Json<OnelinerOverrideRequest>,
) -> Result<Json<OnelinerState>, AppError> {
let trimmed = req.text.trim();
/// Persist a doctor's manual override for a case. Shared by the
/// X-API-Key API handler and the session-authenticated web handler.
///
/// Validates and trims `raw_text`, takes the per-case lock for the
/// disk-write window (so a concurrent auto-regen cannot overwrite the
/// manual edit), writes [`OnelinerState::Manual`], logs, and emits
/// `OnelinerUpdated`. Returns the persisted state so the caller can
/// echo it back to the client.
///
/// Pre-condition: `case_dir` must already exist. Both call sites check
/// for this — the API handler with `try_exists`, the web handler via
/// `locate_case_or_404` — so the helper itself stays focused on the
/// override semantics.
pub(crate) async fn apply_manual_override(
user_slug: &str,
case_dir: &Path,
raw_text: &str,
locks: &OnelinerLocks,
events_tx: &EventSender,
) -> Result<OnelinerState, AppError> {
let trimmed = raw_text.trim();
if trimmed.is_empty() {
return Err(AppError::BadRequest(
"oneliner text must not be empty".into(),
@@ -51,12 +71,6 @@ pub async fn handle_put_oneliner(
)));
}
let case_id_str = case_id.to_string();
let case_dir = user.data_dir.join(&case_id_str);
if !tokio::fs::try_exists(&case_dir).await.unwrap_or(false) {
return Err(AppError::NotFound(format!("case {case_id_str} not found")));
}
let state = OnelinerState::Manual {
text: trimmed.to_owned(),
set_at: doctate_common::now_rfc3339(),
@@ -65,22 +79,84 @@ pub async fn handle_put_oneliner(
// RAII guard — released on function exit. Same lock the worker takes
// for its re-check-then-write window, so a concurrent auto-regen
// cannot overwrite this manual edit.
let _guard = locks.lock_for(&case_dir).await;
paths::write_oneliner_state(&case_dir, &state).await?;
let _guard = locks.lock_for(case_dir).await;
paths::write_oneliner_state(case_dir, &state).await?;
let case_id = case_dir
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("?")
.to_owned();
info!(
user = %user.slug,
case_id = %case_id_str,
user = %user_slug,
case_id = %case_id,
len = trimmed.chars().count(),
"oneliner manual override set"
);
events::emit(
&events_tx,
user.slug.clone(),
case_id_str,
events_tx,
user_slug.to_owned(),
case_id,
CaseEventKind::OnelinerUpdated,
);
Ok(state)
}
/// `PUT /api/cases/{case_id}/oneliner` — X-API-Key authenticated.
pub async fn handle_put_oneliner(
user: AuthenticatedUser,
State(locks): State<OnelinerLocks>,
State(events_tx): State<EventSender>,
CaseIdPath(case_id): CaseIdPath,
Json(req): Json<OnelinerOverrideRequest>,
) -> Result<Json<OnelinerState>, AppError> {
let case_id_str = case_id.to_string();
let case_dir = user.data_dir.join(&case_id_str);
if !tokio::fs::try_exists(&case_dir).await.unwrap_or(false) {
return Err(AppError::NotFound(format!("case {case_id_str} not found")));
}
let state = apply_manual_override(&user.slug, &case_dir, &req.text, &locks, &events_tx).await?;
Ok(Json(state))
}
/// Form body for the browser-based web edit endpoint. CSRF token is
/// required; `text` carries the new oneliner content.
#[derive(Deserialize)]
pub struct OnelinerWebForm {
#[serde(default)]
pub text: String,
#[serde(default)]
pub csrf_token: String,
}
impl HasCsrfToken for OnelinerWebForm {
fn csrf_token(&self) -> &str {
&self.csrf_token
}
}
/// `POST /web/cases/{case_id}/oneliner` — session + CSRF authenticated.
///
/// Mirrors [`handle_put_oneliner`] but uses the web auth stack so the
/// browser never has to expose an X-API-Key. Closed cases are rejected
/// at `locate_case_or_404` (consistent with analyze/close/reopen).
pub async fn handle_web_put_oneliner(
user: AuthenticatedWebUser,
State(config): State<Arc<Config>>,
State(locks): State<OnelinerLocks>,
State(events_tx): State<EventSender>,
CaseIdPath(case_id): CaseIdPath,
CsrfForm(form): CsrfForm<OnelinerWebForm>,
) -> Result<Json<OnelinerState>, AppError> {
let user_root = config.data_path.join(&user.slug);
let case_dir =
locate_case_or_404(&user_root, &case_id, &user.slug, "oneliner override").await?;
let state =
apply_manual_override(&user.slug, &case_dir, &form.text, &locks, &events_tx).await?;
Ok(Json(state))
}
+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,