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.
This commit is contained in:
2026-04-26 15:44:13 +02:00
parent 1b689d1c8c
commit 73fa099b51
6 changed files with 86 additions and 19 deletions
+3 -1
View File
@@ -521,7 +521,9 @@ impl DoctateApp {
let time_str = extract_hhmm(&marker.last_activity_at); let time_str = extract_hhmm(&marker.last_activity_at);
let oneliner = match &marker.oneliner { 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::Empty { .. }) => "".to_owned(),
Some(OnelinerState::Error { .. }) => "".to_owned(), Some(OnelinerState::Error { .. }) => "".to_owned(),
None => "".to_owned(), None => "".to_owned(),
+54
View File
@@ -41,6 +41,10 @@ pub enum OnelinerState {
/// server logs on purpose; the UI surfaces only "error", and /// server logs on purpose; the UI surfaces only "error", and
/// startup recovery retries this variant. /// startup recovery retries this variant.
Error { generated_at: String }, 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`. /// Full response body of `GET /api/oneliners`.
@@ -81,3 +85,53 @@ pub struct OnelinerEntry {
pub last_recording_at: Option<String>, pub last_recording_at: Option<String>,
pub updated_at: Option<String>, pub updated_at: Option<String>,
} }
/// 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");
}
}
+15
View File
@@ -83,6 +83,21 @@ pub async fn read_oneliner_state(
(Some(state), mtime) (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 /// Load the `.transcript.txt` sidecar for a recording as a
/// [`TranscriptState`]. /// [`TranscriptState`].
/// ///
+6 -2
View File
@@ -980,7 +980,9 @@ async fn compute_oneliner_display(
// Missing state collapses to Empty ("unbenannt"): no title is // Missing state collapses to Empty ("unbenannt"): no title is
// expected, regardless of why. // expected, regardless of why.
match state { 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::Error { .. }) => OnelinerDisplay::Error,
Some(OnelinerState::Empty { .. }) | None => OnelinerDisplay::Empty, 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 // between transcript-write and state-write where the worker is
// still producing its result. // still producing its result.
match state { 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::Empty { .. }) => OnelinerDisplay::Empty,
Some(OnelinerState::Error { .. }) => OnelinerDisplay::Error, Some(OnelinerState::Error { .. }) => OnelinerDisplay::Error,
None => OnelinerDisplay::Generating, None => OnelinerDisplay::Generating,
+5 -1
View File
@@ -143,7 +143,11 @@ pub(crate) async fn cases_needing_oneliner_in(user_root: &Path) -> Vec<PathBuf>
let (state, _) = paths::read_oneliner_state(&case_dir).await; let (state, _) = paths::read_oneliner_state(&case_dir).await;
let needs_retry = match state { let needs_retry = match state {
None | Some(OnelinerState::Error { .. }) => true, None | Some(OnelinerState::Error { .. }) => true,
Some(OnelinerState::Ready { .. } | OnelinerState::Empty { .. }) => false, Some(
OnelinerState::Ready { .. }
| OnelinerState::Empty { .. }
| OnelinerState::Manual { .. },
) => false,
}; };
if !needs_retry { if !needs_retry {
continue; continue;
+3 -15
View File
@@ -3,7 +3,7 @@ use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use doctate_common::TranscriptState; use doctate_common::TranscriptState;
use doctate_common::oneliners::{ONELINER_FILENAME, OnelinerState}; use doctate_common::oneliners::OnelinerState;
use time::OffsetDateTime; use time::OffsetDateTime;
use time::format_description::well_known::Rfc3339; use time::format_description::well_known::Rfc3339;
use tracing::{error, info, warn}; use tracing::{error, info, warn};
@@ -237,7 +237,7 @@ pub(crate) async fn update_oneliner(
.format(&Rfc3339) .format(&Rfc3339)
.unwrap_or_default(); .unwrap_or_default();
let state = OnelinerState::Empty { generated_at }; 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!( error!(
case = %case_dir.display(), case = %case_dir.display(),
error = %e, 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"); error!(case = %case_dir.display(), error = %e, "writing oneliner.json failed");
return; 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 /// Return true if `case_dir` contains at least one `.m4a` recording
/// whose transcript is still `Pending` (no sidecar file yet). Used to /// whose transcript is still `Pending` (no sidecar file yet). Used to
/// gate the oneliner-regenerate call: we only run it after the *last* /// gate the oneliner-regenerate call: we only run it after the *last*