//! Shared DTOs for the `/api/oneliners` endpoint. //! //! Server serializes [`OnelinersResponse`]; clients deserialize the same //! type. Keeping this in a client-agnostic crate avoids drift between //! server output and client parsing. use serde::{Deserialize, Serialize}; /// HTTP path of the oneliners endpoint. pub const ONELINERS_PATH: &str = "/api/oneliners"; /// Filename used by the server to persist [`OnelinerState`] per case /// (inside the case directory). Shared so both writer and readers /// agree on the exact path. pub const ONELINER_FILENAME: &str = "oneliner.json"; /// Outcome of the LLM oneliner generation for a single case. /// /// Three disjoint outcomes used to collapse onto the same filesystem /// representation (missing file). Making them first-class lets the UI /// distinguish "LLM said nothing medical" (valid empty result) from /// "LLM call errored" (transient failure worth retrying) from "file /// simply isn't there yet" (no state persisted). /// /// Serialized as internally-tagged JSON: `{"kind":"ready","text":...}`. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(tag = "kind", rename_all = "lowercase")] pub enum OnelinerState { /// LLM produced a usable one-sentence summary. Ready { text: String, /// RFC3339 UTC timestamp of the LLM call that produced this /// text. Diagnostic only — the file mtime drives ETag/sort. generated_at: String, }, /// LLM deliberately returned an empty answer — the transcript /// contained no medical content. This is a valid outcome, not a /// failure, and recovery must not retry it. Empty { generated_at: String }, /// LLM call failed (timeout, HTTP, parse, …). Details stay in the /// 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`. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct OnelinersResponse { /// RFC3339 UTC timestamp at which the server built this response. pub as_of: String, /// Server-authoritative window the response covers, in hours. /// Derived from the user's `window_hours` entry in `users.toml`. /// **Clients must not use this value to apply a display filter** — /// the oneliners list is already exactly what the server wants shown. /// The field exists solely so reconciliation logic can anchor its /// "outside the window" rule (see /// `doctate-client-core::CaseStore::reconcile_with_server_snapshot`). pub window_hours: u32, /// Oneliners of cases whose earliest recording sits inside the window, /// sorted newest-first by `created_at`. pub oneliners: Vec, } /// One case summary. /// /// - `created_at` — earliest `.m4a` mtime in the case (first dictation /// in this case). /// - `last_recording_at` — latest `.m4a` mtime (most recent dictation, /// including addenda). This is the **primary sort key** for clients: /// "when did the doctor last work on this case?". Absent only if the /// case has no recordings at all (shouldn't happen in normal flow). /// - `updated_at` — `oneliner.json` mtime, i.e. when the oneliner state /// was last written. Diagnostic only — not for sort or display. /// - `oneliner` — the persisted state; `None` while no state file has /// been written yet for this case (fresh case, nothing transcribed). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct OnelinerEntry { pub case_id: String, pub oneliner: Option, pub created_at: String, 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"); } }