From 73fa099b51b31170477c3236dfc937845285ce5e Mon Sep 17 00:00:00 2001 From: Brummel Date: Sun, 26 Apr 2026 15:44:13 +0200 Subject: [PATCH] Add `OnelinerState::Manual` variant 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. --- client-desktop/src/app.rs | 4 ++- doctate-common/src/oneliners.rs | 54 +++++++++++++++++++++++++++++++ server/src/paths.rs | 15 +++++++++ server/src/routes/user_web.rs | 8 +++-- server/src/transcribe/recovery.rs | 6 +++- server/src/transcribe/worker.rs | 18 ++--------- 6 files changed, 86 insertions(+), 19 deletions(-) diff --git a/client-desktop/src/app.rs b/client-desktop/src/app.rs index d5b77a2..f226d1a 100644 --- a/client-desktop/src/app.rs +++ b/client-desktop/src/app.rs @@ -521,7 +521,9 @@ impl DoctateApp { let time_str = extract_hhmm(&marker.last_activity_at); let oneliner = match &marker.oneliner { - Some(OnelinerState::Ready { text, .. }) => text.clone(), + Some(OnelinerState::Ready { text, .. } | OnelinerState::Manual { text, .. }) => { + text.clone() + } Some(OnelinerState::Empty { .. }) => "∅".to_owned(), Some(OnelinerState::Error { .. }) => "⚠".to_owned(), None => "⏳".to_owned(), diff --git a/doctate-common/src/oneliners.rs b/doctate-common/src/oneliners.rs index c9fd079..c8e8b54 100644 --- a/doctate-common/src/oneliners.rs +++ b/doctate-common/src/oneliners.rs @@ -41,6 +41,10 @@ pub enum OnelinerState { /// server logs on purpose; the UI surfaces only "error", and /// startup recovery retries this variant. Error { generated_at: String }, + /// Manual override set by the doctor via tap-to-edit. Sticky: + /// blocks all auto-regeneration until a new manual edit replaces + /// it. `set_at` is server-side RFC3339 UTC (avoids client clock skew). + Manual { text: String, set_at: String }, } /// Full response body of `GET /api/oneliners`. @@ -81,3 +85,53 @@ pub struct OnelinerEntry { pub last_recording_at: Option, pub updated_at: Option, } + +/// HTTP path template for setting a manual oneliner override. +/// `{case_id}` is replaced by the actual case id by routers and clients; +/// shared so both sides agree on the exact URL shape. +pub const ONELINER_OVERRIDE_PATH_TEMPLATE: &str = "/api/cases/{case_id}/oneliner"; + +/// Request body for `PUT /api/cases/{case_id}/oneliner`. +/// +/// Only `text` travels over the wire — `set_at` is set server-side from +/// the server clock, so clients with skewed/unset clocks (watch on +/// battery save, etc.) cannot poison the timestamp. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OnelinerOverrideRequest { + pub text: String, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn manual_variant_roundtrips() { + let original = OnelinerState::Manual { + text: "55 J., Knieschmerz links".to_owned(), + set_at: "2026-04-26T18:33:00Z".to_owned(), + }; + let json = serde_json::to_string(&original).expect("serialize"); + let parsed: OnelinerState = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(original, parsed); + } + + #[test] + fn manual_serializes_with_kind_tag() { + let state = OnelinerState::Manual { + text: "abc".to_owned(), + set_at: "2026-04-26T18:33:00Z".to_owned(), + }; + let json = serde_json::to_value(&state).expect("serialize"); + assert_eq!(json["kind"], "manual"); + assert_eq!(json["text"], "abc"); + assert_eq!(json["set_at"], "2026-04-26T18:33:00Z"); + } + + #[test] + fn override_request_deserializes_minimal_body() { + let body = r#"{"text":"hello"}"#; + let req: OnelinerOverrideRequest = serde_json::from_str(body).expect("deserialize"); + assert_eq!(req.text, "hello"); + } +} diff --git a/server/src/paths.rs b/server/src/paths.rs index 7a22468..e5fffc8 100644 --- a/server/src/paths.rs +++ b/server/src/paths.rs @@ -83,6 +83,21 @@ pub async fn read_oneliner_state( (Some(state), mtime) } +/// Write `state` atomically to `case_dir/oneliner.json` via +/// `write(tmp) + rename(tmp, final)`. Rename is atomic within a single +/// filesystem, so concurrent readers never see a half-serialized JSON +/// document. +pub async fn write_oneliner_state( + case_dir: &Path, + state: &OnelinerState, +) -> std::io::Result<()> { + let final_path = case_dir.join(ONELINER_FILENAME); + let tmp_path = case_dir.join(format!("{ONELINER_FILENAME}.tmp")); + let payload = serde_json::to_vec(state).expect("OnelinerState is infallibly serializable"); + tokio::fs::write(&tmp_path, &payload).await?; + tokio::fs::rename(&tmp_path, &final_path).await +} + /// Load the `.transcript.txt` sidecar for a recording as a /// [`TranscriptState`]. /// diff --git a/server/src/routes/user_web.rs b/server/src/routes/user_web.rs index a17bedd..4d66b9b 100644 --- a/server/src/routes/user_web.rs +++ b/server/src/routes/user_web.rs @@ -980,7 +980,9 @@ async fn compute_oneliner_display( // Missing state collapses to Empty ("unbenannt"): no title is // expected, regardless of why. match state { - Some(OnelinerState::Ready { text, .. }) => OnelinerDisplay::Ready(text), + Some(OnelinerState::Ready { text, .. } | OnelinerState::Manual { text, .. }) => { + OnelinerDisplay::Ready(text) + } Some(OnelinerState::Error { .. }) => OnelinerDisplay::Error, Some(OnelinerState::Empty { .. }) | None => OnelinerDisplay::Empty, } @@ -989,7 +991,9 @@ async fn compute_oneliner_display( // between transcript-write and state-write where the worker is // still producing its result. match state { - Some(OnelinerState::Ready { text, .. }) => OnelinerDisplay::Ready(text), + Some(OnelinerState::Ready { text, .. } | OnelinerState::Manual { text, .. }) => { + OnelinerDisplay::Ready(text) + } Some(OnelinerState::Empty { .. }) => OnelinerDisplay::Empty, Some(OnelinerState::Error { .. }) => OnelinerDisplay::Error, None => OnelinerDisplay::Generating, diff --git a/server/src/transcribe/recovery.rs b/server/src/transcribe/recovery.rs index 0294781..3bbb02f 100644 --- a/server/src/transcribe/recovery.rs +++ b/server/src/transcribe/recovery.rs @@ -143,7 +143,11 @@ pub(crate) async fn cases_needing_oneliner_in(user_root: &Path) -> Vec let (state, _) = paths::read_oneliner_state(&case_dir).await; let needs_retry = match state { None | Some(OnelinerState::Error { .. }) => true, - Some(OnelinerState::Ready { .. } | OnelinerState::Empty { .. }) => false, + Some( + OnelinerState::Ready { .. } + | OnelinerState::Empty { .. } + | OnelinerState::Manual { .. }, + ) => false, }; if !needs_retry { continue; diff --git a/server/src/transcribe/worker.rs b/server/src/transcribe/worker.rs index 79fee95..9ab701a 100644 --- a/server/src/transcribe/worker.rs +++ b/server/src/transcribe/worker.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use std::time::Duration; use doctate_common::TranscriptState; -use doctate_common::oneliners::{ONELINER_FILENAME, OnelinerState}; +use doctate_common::oneliners::OnelinerState; use time::OffsetDateTime; use time::format_description::well_known::Rfc3339; use tracing::{error, info, warn}; @@ -237,7 +237,7 @@ pub(crate) async fn update_oneliner( .format(&Rfc3339) .unwrap_or_default(); let state = OnelinerState::Empty { generated_at }; - if let Err(e) = write_oneliner_state(case_dir, &state).await { + if let Err(e) = paths::write_oneliner_state(case_dir, &state).await { error!( case = %case_dir.display(), error = %e, @@ -298,7 +298,7 @@ pub(crate) async fn update_oneliner( } }; - if let Err(e) = write_oneliner_state(case_dir, &state).await { + if let Err(e) = paths::write_oneliner_state(case_dir, &state).await { error!(case = %case_dir.display(), error = %e, "writing oneliner.json failed"); return; } @@ -310,18 +310,6 @@ pub(crate) async fn update_oneliner( ); } -/// Write `state` atomically to `case_dir/oneliner.json` via -/// `write(tmp) + rename(tmp, final)`. Rename is atomic within a single -/// filesystem, so concurrent readers never see a half-serialized JSON -/// document. -async fn write_oneliner_state(case_dir: &Path, state: &OnelinerState) -> std::io::Result<()> { - let final_path = case_dir.join(ONELINER_FILENAME); - let tmp_path = case_dir.join(format!("{ONELINER_FILENAME}.tmp")); - let payload = serde_json::to_vec(state).expect("OnelinerState is infallibly serializable"); - tokio::fs::write(&tmp_path, &payload).await?; - tokio::fs::rename(&tmp_path, &final_path).await -} - /// Return true if `case_dir` contains at least one `.m4a` recording /// whose transcript is still `Pending` (no sidecar file yet). Used to /// gate the oneliner-regenerate call: we only run it after the *last*