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:
@@ -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(),
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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`].
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,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*
|
||||||
|
|||||||
Reference in New Issue
Block a user