refactor: rename to common/, clients/desktop, clients/wearos
Move three directories into their target topology: - doctate-common/ -> common/ - client-desktop/ -> clients/desktop/ - watch/wearos/ -> clients/wearos/ Update doctate-common path-dependency in 3 Cargo.toml files (server, clients/desktop, experiments) and the workspace members list in the root Cargo.toml. Crate names unchanged (doctate-server, doctate-common, doctate-desktop). Workspace still in single-Cargo.lock form; isolation into standalone workspaces follows in stage 3. All 508 tests pass, experiments standalone-workspace also resolves the new path.
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
//! 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<OnelinerEntry>,
|
||||
}
|
||||
|
||||
/// 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<OnelinerState>,
|
||||
pub created_at: String,
|
||||
pub last_recording_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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user