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:
@@ -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),
|
||||
|
||||
@@ -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 30–80 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))
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user