Refactor oneliner storage to use enum
The oneliner is now stored as `OnelinerState` enum which can represent three states: `Ready`, `Empty` or `Error`. This allows the client to differentiate between a case with no medical content and a case where the oneliner generation failed. The `OnelinerState` enum is serialized to JSON and stored in `oneliner.json` file. The `OnelinerEntry` struct has been updated to reflect this change. The client-desktop application has been updated to handle the new `OnelinerState` enum and display appropriate UI elements for each state. The server-side code has also been updated to read and write the `OnelinerState` enum, and to handle the new file format. The `reset_case_artefacts` function in `server/src/routes/case_actions.rs` has been updated to remove `oneliner.txt` and create `oneliner.json` instead. The tests have been updated to reflect these changes.
This commit is contained in:
@@ -18,6 +18,7 @@ use doctate_client_core::{
|
|||||||
PendingUpload, ServerSync, SnapshotCache, SyncConfig, UploadEvent, WorkerSnapshot, WorkerState,
|
PendingUpload, ServerSync, SnapshotCache, SyncConfig, UploadEvent, WorkerSnapshot, WorkerState,
|
||||||
pick_footer_status, run_startup_cleanup, write_sidecar,
|
pick_footer_status, run_startup_cleanup, write_sidecar,
|
||||||
};
|
};
|
||||||
|
use doctate_common::oneliners::OnelinerState;
|
||||||
use doctate_common::timestamp::{
|
use doctate_common::timestamp::{
|
||||||
extract_hhmm, now_rfc3339, parse_rfc3339, recorded_at_to_filename_stem,
|
extract_hhmm, now_rfc3339, parse_rfc3339, recorded_at_to_filename_stem,
|
||||||
};
|
};
|
||||||
@@ -506,11 +507,12 @@ impl DoctateApp {
|
|||||||
ui.add_space(4.0);
|
ui.add_space(4.0);
|
||||||
|
|
||||||
let time_str = extract_hhmm(&marker.last_activity_at);
|
let time_str = extract_hhmm(&marker.last_activity_at);
|
||||||
let oneliner = marker
|
let oneliner = match &marker.oneliner {
|
||||||
.oneliner
|
Some(OnelinerState::Ready { text, .. }) => text.clone(),
|
||||||
.as_deref()
|
Some(OnelinerState::Empty { .. }) => "∅".to_owned(),
|
||||||
.map(str::to_owned)
|
Some(OnelinerState::Error { .. }) => "⚠".to_owned(),
|
||||||
.unwrap_or_else(|| "⏳".to_owned());
|
None => "⏳".to_owned(),
|
||||||
|
};
|
||||||
let has_pending = pending_ids.contains(&marker.case_id);
|
let has_pending = pending_ids.contains(&marker.case_id);
|
||||||
let line = if has_pending {
|
let line = if has_pending {
|
||||||
format!("⬆ {time_str} · {oneliner}")
|
format!("⬆ {time_str} · {oneliner}")
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ use std::collections::{HashMap, HashSet};
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use doctate_common::oneliners::OnelinersResponse;
|
use doctate_common::oneliners::{OnelinerState, OnelinersResponse};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
use time::format_description::well_known::Rfc3339;
|
use time::format_description::well_known::Rfc3339;
|
||||||
@@ -48,9 +48,12 @@ pub struct CaseMarker {
|
|||||||
/// (`= false`) markers guard unsent uploads and are never touched by
|
/// (`= false`) markers guard unsent uploads and are never touched by
|
||||||
/// either — losing one would silently erase a patient recording.
|
/// either — losing one would silently erase a patient recording.
|
||||||
pub synced_to_server: bool,
|
pub synced_to_server: bool,
|
||||||
/// `None` while transcription / oneliner generation is pending; set
|
/// Persisted oneliner state mirrored from the server snapshot.
|
||||||
/// from the server snapshot when available.
|
/// `None` while no state file has been written server-side yet (fresh
|
||||||
pub oneliner: Option<String>,
|
/// case, nothing transcribed). The variant distinguishes usable text
|
||||||
|
/// from "no medical content" and "generation failed" — clients render
|
||||||
|
/// each case as they see fit.
|
||||||
|
pub oneliner: Option<OnelinerState>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
@@ -443,10 +446,24 @@ mod tests {
|
|||||||
OffsetDateTime::parse(s, &Rfc3339).unwrap()
|
OffsetDateTime::parse(s, &Rfc3339).unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn ready(text: &str) -> OnelinerState {
|
||||||
|
OnelinerState::Ready {
|
||||||
|
text: text.to_owned(),
|
||||||
|
generated_at: "2026-04-18T10:00:00Z".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn text_of(o: &Option<OnelinerState>) -> Option<&str> {
|
||||||
|
match o {
|
||||||
|
Some(OnelinerState::Ready { text, .. }) => Some(text.as_str()),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn server_entry(case_id: Uuid, oneliner: Option<&str>, created: &str) -> OnelinerEntry {
|
fn server_entry(case_id: Uuid, oneliner: Option<&str>, created: &str) -> OnelinerEntry {
|
||||||
OnelinerEntry {
|
OnelinerEntry {
|
||||||
case_id: case_id.to_string(),
|
case_id: case_id.to_string(),
|
||||||
oneliner: oneliner.map(str::to_owned),
|
oneliner: oneliner.map(ready),
|
||||||
created_at: created.to_owned(),
|
created_at: created.to_owned(),
|
||||||
last_recording_at: Some(created.to_owned()),
|
last_recording_at: Some(created.to_owned()),
|
||||||
updated_at: Some(created.to_owned()),
|
updated_at: Some(created.to_owned()),
|
||||||
@@ -535,7 +552,7 @@ mod tests {
|
|||||||
let list = store.list().await;
|
let list = store.list().await;
|
||||||
assert_eq!(list.len(), 1);
|
assert_eq!(list.len(), 1);
|
||||||
assert_eq!(list[0].case_id, id);
|
assert_eq!(list[0].case_id, id);
|
||||||
assert_eq!(list[0].oneliner.as_deref(), Some("Kniegelenk re."));
|
assert_eq!(text_of(&list[0].oneliner), Some("Kniegelenk re."));
|
||||||
assert!(list[0].synced_to_server);
|
assert!(list[0].synced_to_server);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -559,7 +576,7 @@ mod tests {
|
|||||||
let list = store.list().await;
|
let list = store.list().await;
|
||||||
assert_eq!(list.len(), 1);
|
assert_eq!(list.len(), 1);
|
||||||
assert!(list[0].synced_to_server);
|
assert!(list[0].synced_to_server);
|
||||||
assert_eq!(list[0].oneliner.as_deref(), Some("Hypertonie"));
|
assert_eq!(text_of(&list[0].oneliner), Some("Hypertonie"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -604,7 +621,7 @@ mod tests {
|
|||||||
let list = store.list().await;
|
let list = store.list().await;
|
||||||
assert_eq!(list.len(), 1);
|
assert_eq!(list.len(), 1);
|
||||||
assert!(list[0].synced_to_server);
|
assert!(list[0].synced_to_server);
|
||||||
assert_eq!(list[0].oneliner.as_deref(), Some("old"));
|
assert_eq!(text_of(&list[0].oneliner), Some("old"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -670,7 +687,7 @@ mod tests {
|
|||||||
// oneliner regenerated just now in a recovery scan.
|
// oneliner regenerated just now in a recovery scan.
|
||||||
let entry = OnelinerEntry {
|
let entry = OnelinerEntry {
|
||||||
case_id: id.to_string(),
|
case_id: id.to_string(),
|
||||||
oneliner: Some("x".into()),
|
oneliner: Some(ready("x")),
|
||||||
created_at: "2026-04-17T09:00:00Z".into(),
|
created_at: "2026-04-17T09:00:00Z".into(),
|
||||||
last_recording_at: Some("2026-04-18T09:30:00Z".into()),
|
last_recording_at: Some("2026-04-18T09:30:00Z".into()),
|
||||||
updated_at: Some("2026-04-18T10:00:00Z".into()),
|
updated_at: Some("2026-04-18T10:00:00Z".into()),
|
||||||
@@ -694,7 +711,7 @@ mod tests {
|
|||||||
let id = Uuid::new_v4();
|
let id = Uuid::new_v4();
|
||||||
let entry = OnelinerEntry {
|
let entry = OnelinerEntry {
|
||||||
case_id: id.to_string(),
|
case_id: id.to_string(),
|
||||||
oneliner: Some("x".into()),
|
oneliner: Some(ready("x")),
|
||||||
created_at: "2026-04-17T09:00:00Z".into(),
|
created_at: "2026-04-17T09:00:00Z".into(),
|
||||||
last_recording_at: None,
|
last_recording_at: None,
|
||||||
updated_at: Some("2026-04-18T10:00:00Z".into()),
|
updated_at: Some("2026-04-18T10:00:00Z".into()),
|
||||||
@@ -724,7 +741,7 @@ mod tests {
|
|||||||
store
|
store
|
||||||
.merge_server_snapshot(&server_snapshot(vec![OnelinerEntry {
|
.merge_server_snapshot(&server_snapshot(vec![OnelinerEntry {
|
||||||
case_id: old_synced.to_string(),
|
case_id: old_synced.to_string(),
|
||||||
oneliner: Some("old".into()),
|
oneliner: Some(ready("old")),
|
||||||
created_at: "2026-04-14T10:00:00Z".into(),
|
created_at: "2026-04-14T10:00:00Z".into(),
|
||||||
last_recording_at: Some("2026-04-14T10:00:00Z".into()),
|
last_recording_at: Some("2026-04-14T10:00:00Z".into()),
|
||||||
updated_at: Some("2026-04-14T10:00:00Z".into()),
|
updated_at: Some("2026-04-14T10:00:00Z".into()),
|
||||||
@@ -740,7 +757,7 @@ mod tests {
|
|||||||
store
|
store
|
||||||
.merge_server_snapshot(&server_snapshot(vec![OnelinerEntry {
|
.merge_server_snapshot(&server_snapshot(vec![OnelinerEntry {
|
||||||
case_id: young_synced.to_string(),
|
case_id: young_synced.to_string(),
|
||||||
oneliner: Some("young".into()),
|
oneliner: Some(ready("young")),
|
||||||
created_at: "2026-04-18T10:00:00Z".into(),
|
created_at: "2026-04-18T10:00:00Z".into(),
|
||||||
last_recording_at: Some("2026-04-18T10:00:00Z".into()),
|
last_recording_at: Some("2026-04-18T10:00:00Z".into()),
|
||||||
updated_at: Some("2026-04-18T10:00:00Z".into()),
|
updated_at: Some("2026-04-18T10:00:00Z".into()),
|
||||||
|
|||||||
@@ -515,12 +515,17 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn oneliner_body(case_id: &str, oneliner: Option<&str>) -> OnelinersResponse {
|
fn oneliner_body(case_id: &str, oneliner: Option<&str>) -> OnelinersResponse {
|
||||||
|
use doctate_common::oneliners::OnelinerState;
|
||||||
|
let state = oneliner.map(|t| OnelinerState::Ready {
|
||||||
|
text: t.to_owned(),
|
||||||
|
generated_at: "2026-04-18T10:00:00Z".into(),
|
||||||
|
});
|
||||||
OnelinersResponse {
|
OnelinersResponse {
|
||||||
as_of: "2026-04-18T10:00:00Z".into(),
|
as_of: "2026-04-18T10:00:00Z".into(),
|
||||||
window_hours: 72,
|
window_hours: 72,
|
||||||
oneliners: vec![OnelinerEntry {
|
oneliners: vec![OnelinerEntry {
|
||||||
case_id: case_id.into(),
|
case_id: case_id.into(),
|
||||||
oneliner: oneliner.map(str::to_owned),
|
oneliner: state,
|
||||||
created_at: "2026-04-18T09:30:00Z".into(),
|
created_at: "2026-04-18T09:30:00Z".into(),
|
||||||
last_recording_at: Some("2026-04-18T09:45:00Z".into()),
|
last_recording_at: Some("2026-04-18T09:45:00Z".into()),
|
||||||
updated_at: Some("2026-04-18T09:45:00Z".into()),
|
updated_at: Some("2026-04-18T09:45:00Z".into()),
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ fn tmp_extension(path: &Path) -> String {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use doctate_common::oneliners::OnelinerEntry;
|
use doctate_common::oneliners::{OnelinerEntry, OnelinerState};
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
fn sample() -> CachedSnapshot {
|
fn sample() -> CachedSnapshot {
|
||||||
@@ -104,7 +104,10 @@ mod tests {
|
|||||||
window_hours: 72,
|
window_hours: 72,
|
||||||
oneliners: vec![OnelinerEntry {
|
oneliners: vec![OnelinerEntry {
|
||||||
case_id: "550e8400-e29b-41d4-a716-446655440000".into(),
|
case_id: "550e8400-e29b-41d4-a716-446655440000".into(),
|
||||||
oneliner: Some("Kniegelenk".into()),
|
oneliner: Some(OnelinerState::Ready {
|
||||||
|
text: "Kniegelenk".into(),
|
||||||
|
generated_at: "2026-04-18T10:30:00Z".into(),
|
||||||
|
}),
|
||||||
created_at: "2026-04-18T10:00:00Z".into(),
|
created_at: "2026-04-18T10:00:00Z".into(),
|
||||||
last_recording_at: Some("2026-04-18T10:30:00Z".into()),
|
last_recording_at: Some("2026-04-18T10:30:00Z".into()),
|
||||||
updated_at: Some("2026-04-18T10:30:00Z".into()),
|
updated_at: Some("2026-04-18T10:30:00Z".into()),
|
||||||
@@ -123,10 +126,11 @@ mod tests {
|
|||||||
let loaded = cache.load().await.expect("cache present");
|
let loaded = cache.load().await.expect("cache present");
|
||||||
assert_eq!(loaded.etag, s.etag);
|
assert_eq!(loaded.etag, s.etag);
|
||||||
assert_eq!(loaded.response.oneliners.len(), 1);
|
assert_eq!(loaded.response.oneliners.len(), 1);
|
||||||
assert_eq!(
|
let text = match &loaded.response.oneliners[0].oneliner {
|
||||||
loaded.response.oneliners[0].oneliner.as_deref(),
|
Some(OnelinerState::Ready { text, .. }) => Some(text.as_str()),
|
||||||
Some("Kniegelenk")
|
_ => None,
|
||||||
);
|
};
|
||||||
|
assert_eq!(text, Some("Kniegelenk"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -9,6 +9,40 @@ use serde::{Deserialize, Serialize};
|
|||||||
/// HTTP path of the oneliners endpoint.
|
/// HTTP path of the oneliners endpoint.
|
||||||
pub const ONELINERS_PATH: &str = "/api/oneliners";
|
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 },
|
||||||
|
}
|
||||||
|
|
||||||
/// Full response body of `GET /api/oneliners`.
|
/// Full response body of `GET /api/oneliners`.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct OnelinersResponse {
|
pub struct OnelinersResponse {
|
||||||
@@ -29,14 +63,14 @@ pub struct OnelinersResponse {
|
|||||||
/// including addenda). This is the **primary sort key** for clients:
|
/// including addenda). This is the **primary sort key** for clients:
|
||||||
/// "when did the doctor last work on this case?". Absent only if the
|
/// "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).
|
/// case has no recordings at all (shouldn't happen in normal flow).
|
||||||
/// - `updated_at` — `oneliner.txt` mtime, i.e. when the oneliner text
|
/// - `updated_at` — `oneliner.json` mtime, i.e. when the oneliner state
|
||||||
/// was last regenerated. Diagnostic only — not for sort or display.
|
/// was last written. Diagnostic only — not for sort or display.
|
||||||
/// - `oneliner` — the text itself; `None` while transcription / LLM
|
/// - `oneliner` — the persisted state; `None` while no state file has
|
||||||
/// generation is still pending.
|
/// been written yet for this case (fresh case, nothing transcribed).
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct OnelinerEntry {
|
pub struct OnelinerEntry {
|
||||||
pub case_id: String,
|
pub case_id: String,
|
||||||
pub oneliner: Option<String>,
|
pub oneliner: Option<OnelinerState>,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
pub last_recording_at: Option<String>,
|
pub last_recording_at: Option<String>,
|
||||||
pub updated_at: Option<String>,
|
pub updated_at: Option<String>,
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use doctate_common::oneliners::{ONELINER_FILENAME, OnelinerState};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use time::OffsetDateTime;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
/// Absolute on-disk location of a case directory.
|
/// Absolute on-disk location of a case directory.
|
||||||
@@ -46,3 +48,36 @@ pub async fn write_delete_marker(case_dir: &Path, marker: &DeleteMarker) -> std:
|
|||||||
.map_err(|e| std::io::Error::other(format!("serialize delete marker: {e}")))?;
|
.map_err(|e| std::io::Error::other(format!("serialize delete marker: {e}")))?;
|
||||||
tokio::fs::write(case_dir.join(DELETE_MARKER), bytes).await
|
tokio::fs::write(case_dir.join(DELETE_MARKER), bytes).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Read the persisted oneliner state for a case.
|
||||||
|
///
|
||||||
|
/// Returns `(Some(state), Some(mtime))` when `oneliner.json` exists and
|
||||||
|
/// parses. Missing file → `(None, None)`. A file that exists but fails
|
||||||
|
/// to parse is treated as missing and logged — the next transcription
|
||||||
|
/// job will overwrite it with a well-formed state.
|
||||||
|
pub async fn read_oneliner_state(
|
||||||
|
case_dir: &Path,
|
||||||
|
) -> (Option<OnelinerState>, Option<OffsetDateTime>) {
|
||||||
|
let path = case_dir.join(ONELINER_FILENAME);
|
||||||
|
let bytes = match tokio::fs::read(&path).await {
|
||||||
|
Ok(b) => b,
|
||||||
|
Err(_) => return (None, None),
|
||||||
|
};
|
||||||
|
let state: OnelinerState = match serde_json::from_slice(&bytes) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(
|
||||||
|
path = %path.display(),
|
||||||
|
error = %e,
|
||||||
|
"malformed oneliner.json; treating as missing"
|
||||||
|
);
|
||||||
|
return (None, None);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mtime = tokio::fs::metadata(&path)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.and_then(|m| m.modified().ok())
|
||||||
|
.map(OffsetDateTime::from);
|
||||||
|
(Some(state), mtime)
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ use time::format_description::well_known::Rfc3339;
|
|||||||
use tokio::io::AsyncWriteExt;
|
use tokio::io::AsyncWriteExt;
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
use doctate_common::oneliners::ONELINER_FILENAME;
|
||||||
use doctate_common::timestamp::{filename_stem_to_recorded_at, now_rfc3339};
|
use doctate_common::timestamp::{filename_stem_to_recorded_at, now_rfc3339};
|
||||||
|
|
||||||
use crate::analyze::{
|
use crate::analyze::{
|
||||||
@@ -252,7 +253,7 @@ pub(crate) async fn read_document(case_dir: &Path) -> Option<String> {
|
|||||||
/// `*.m4a.failed` back to `*.m4a` so the transcribe self-heal picks it
|
/// `*.m4a.failed` back to `*.m4a` so the transcribe self-heal picks it
|
||||||
/// up again. Idempotent: missing files are no-ops.
|
/// up again. Idempotent: missing files are no-ops.
|
||||||
pub(crate) async fn reset_case_artefacts(case_dir: &Path) -> std::io::Result<()> {
|
pub(crate) async fn reset_case_artefacts(case_dir: &Path) -> std::io::Result<()> {
|
||||||
for name in [DOCUMENT_FILE, ANALYSIS_INPUT_FILE, "oneliner.txt"] {
|
for name in [DOCUMENT_FILE, ANALYSIS_INPUT_FILE, ONELINER_FILENAME] {
|
||||||
match tokio::fs::remove_file(case_dir.join(name)).await {
|
match tokio::fs::remove_file(case_dir.join(name)).await {
|
||||||
Ok(_) => {}
|
Ok(_) => {}
|
||||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
//!
|
//!
|
||||||
//! Time window is caller-controlled via `?hours=N` (default 16, clamped
|
//! Time window is caller-controlled via `?hours=N` (default 16, clamped
|
||||||
//! to [1, MAX_HOURS]). Each entry carries the case-id, the oneliner
|
//! to [1, MAX_HOURS]). Each entry carries the case-id, the oneliner
|
||||||
//! text (or null if not yet generated), and UTC timestamps for when the
|
//! state (or null if no state file has been written yet), and UTC
|
||||||
//! case started and when its oneliner was last written.
|
//! timestamps for when the case started and when its oneliner was last
|
||||||
|
//! written.
|
||||||
//!
|
//!
|
||||||
//! Conditional-GET: the response carries a weak ETag
|
//! Conditional-GET: the response carries a weak ETag
|
||||||
//! `W/"<fingerprint_hex>-<hours>"`. The fingerprint is a deterministic
|
//! `W/"<fingerprint_hex>-<hours>"`. The fingerprint is a deterministic
|
||||||
@@ -32,6 +33,7 @@ use tracing::warn;
|
|||||||
|
|
||||||
use crate::auth::AuthenticatedUser;
|
use crate::auth::AuthenticatedUser;
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
|
use crate::paths;
|
||||||
|
|
||||||
const DEFAULT_HOURS: u32 = 16;
|
const DEFAULT_HOURS: u32 = 16;
|
||||||
const MAX_HOURS: u32 = 168;
|
const MAX_HOURS: u32 = 168;
|
||||||
@@ -154,7 +156,7 @@ async fn scan_cases(user_data_dir: &Path, since: OffsetDateTime) -> ScanResult {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let (oneliner_text, updated_at) = read_oneliner(&case_dir).await;
|
let (oneliner_state, updated_at) = paths::read_oneliner_state(&case_dir).await;
|
||||||
let Ok(created_at_str) = created_at.format(&Rfc3339) else {
|
let Ok(created_at_str) = created_at.format(&Rfc3339) else {
|
||||||
warn!(case = %case_dir.display(), "format created_at failed");
|
warn!(case = %case_dir.display(), "format created_at failed");
|
||||||
continue;
|
continue;
|
||||||
@@ -168,7 +170,7 @@ async fn scan_cases(user_data_dir: &Path, since: OffsetDateTime) -> ScanResult {
|
|||||||
|
|
||||||
entries.push(OnelinerEntry {
|
entries.push(OnelinerEntry {
|
||||||
case_id: case_id.to_owned(),
|
case_id: case_id.to_owned(),
|
||||||
oneliner: oneliner_text,
|
oneliner: oneliner_state,
|
||||||
created_at: created_at_str,
|
created_at: created_at_str,
|
||||||
last_recording_at: last_recording_at_str,
|
last_recording_at: last_recording_at_str,
|
||||||
updated_at: updated_at_str,
|
updated_at: updated_at_str,
|
||||||
@@ -274,23 +276,3 @@ async fn m4a_mtime_range(case_dir: &Path) -> Option<(SystemTime, SystemTime)> {
|
|||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read `oneliner.txt` (trimmed, non-empty) plus its mtime. Missing file
|
|
||||||
/// yields `(None, None)`.
|
|
||||||
async fn read_oneliner(case_dir: &Path) -> (Option<String>, Option<OffsetDateTime>) {
|
|
||||||
let path = case_dir.join("oneliner.txt");
|
|
||||||
let text = tokio::fs::read_to_string(&path)
|
|
||||||
.await
|
|
||||||
.ok()
|
|
||||||
.map(|s| s.trim().to_string())
|
|
||||||
.filter(|s| !s.is_empty());
|
|
||||||
if text.is_none() {
|
|
||||||
return (None, None);
|
|
||||||
}
|
|
||||||
let mtime = tokio::fs::metadata(&path)
|
|
||||||
.await
|
|
||||||
.ok()
|
|
||||||
.and_then(|m| m.modified().ok())
|
|
||||||
.map(OffsetDateTime::from);
|
|
||||||
(text, mtime)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ use std::sync::atomic::Ordering;
|
|||||||
use askama::Template;
|
use askama::Template;
|
||||||
use axum::extract::State;
|
use axum::extract::State;
|
||||||
use axum::response::Html;
|
use axum::response::Html;
|
||||||
|
use doctate_common::oneliners::OnelinerState;
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
|
|
||||||
use time::{Date, OffsetDateTime, format_description::well_known::Rfc3339};
|
use time::{Date, OffsetDateTime, format_description::well_known::Rfc3339};
|
||||||
@@ -18,10 +19,34 @@ use crate::case_id::{CaseId, CaseIdPath};
|
|||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::events::EventSender;
|
use crate::events::EventSender;
|
||||||
|
use crate::paths;
|
||||||
use crate::routes::case_actions::read_document;
|
use crate::routes::case_actions::read_document;
|
||||||
use crate::routes::web::{RecordingView, scan_recordings};
|
use crate::routes::web::{RecordingView, scan_recordings};
|
||||||
use crate::transcribe::recovery as transcribe_recovery;
|
use crate::transcribe::recovery as transcribe_recovery;
|
||||||
|
|
||||||
|
/// UI-layer oneliner status for the case list. Combines the persisted
|
||||||
|
/// [`OnelinerState`] with derived transit states that only make sense
|
||||||
|
/// in the list context (transcription still pending, or no state file
|
||||||
|
/// despite completed transcription).
|
||||||
|
///
|
||||||
|
/// Kept local to the server — not part of the public API DTO, which
|
||||||
|
/// exposes the raw [`OnelinerState`] and lets each client decide how
|
||||||
|
/// to render transit states.
|
||||||
|
enum OnelinerDisplay {
|
||||||
|
Ready(String),
|
||||||
|
Empty,
|
||||||
|
Error,
|
||||||
|
/// No state file on disk + at least one non-failed recording is
|
||||||
|
/// still awaiting its transcript. The oneliner will appear once
|
||||||
|
/// transcription finishes.
|
||||||
|
Pending,
|
||||||
|
/// No state file on disk and no pending transcription. Rare in
|
||||||
|
/// practice — the worker writes a state at the end of every job —
|
||||||
|
/// but possible right after a server crash between transcript and
|
||||||
|
/// oneliner write.
|
||||||
|
Missing,
|
||||||
|
}
|
||||||
|
|
||||||
struct UserCaseView {
|
struct UserCaseView {
|
||||||
case_id: String,
|
case_id: String,
|
||||||
most_recent: String,
|
most_recent: String,
|
||||||
@@ -34,7 +59,7 @@ struct UserCaseView {
|
|||||||
/// render it in the doctor's actual timezone (which may differ from
|
/// render it in the doctor's actual timezone (which may differ from
|
||||||
/// the server's, e.g. server on Bahamas, doctor in Germany).
|
/// the server's, e.g. server on Bahamas, doctor in Germany).
|
||||||
recorded_at_iso: String,
|
recorded_at_iso: String,
|
||||||
oneliner: Option<String>,
|
oneliner: OnelinerDisplay,
|
||||||
recordings_count: usize,
|
recordings_count: usize,
|
||||||
analyzing: bool,
|
analyzing: bool,
|
||||||
has_document: bool,
|
has_document: bool,
|
||||||
@@ -307,11 +332,7 @@ pub async fn handle_case_page(
|
|||||||
.map(|md| crate::analyze::render::md_to_html(&md));
|
.map(|md| crate::analyze::render::md_to_html(&md));
|
||||||
|
|
||||||
let recordings = scan_recordings(&case_dir).await;
|
let recordings = scan_recordings(&case_dir).await;
|
||||||
let oneliner = tokio::fs::read_to_string(case_dir.join("oneliner.txt"))
|
let oneliner = ready_text(&case_dir).await;
|
||||||
.await
|
|
||||||
.ok()
|
|
||||||
.map(|s| s.trim().to_string())
|
|
||||||
.filter(|s| !s.is_empty());
|
|
||||||
|
|
||||||
let a_busy = pipeline.analyze_busy.0.load(Ordering::Acquire);
|
let a_busy = pipeline.analyze_busy.0.load(Ordering::Acquire);
|
||||||
let flags = compute_flags(&case_dir, &recordings, config.llm_configured(), a_busy).await;
|
let flags = compute_flags(&case_dir, &recordings, config.llm_configured(), a_busy).await;
|
||||||
@@ -371,11 +392,7 @@ pub async fn handle_case_recordings(
|
|||||||
info!(slug = %user.slug, case_id = %case_id, "case recordings viewed");
|
info!(slug = %user.slug, case_id = %case_id, "case recordings viewed");
|
||||||
|
|
||||||
let recordings = scan_recordings(&case_dir).await;
|
let recordings = scan_recordings(&case_dir).await;
|
||||||
let oneliner = tokio::fs::read_to_string(case_dir.join("oneliner.txt"))
|
let oneliner = ready_text(&case_dir).await;
|
||||||
.await
|
|
||||||
.ok()
|
|
||||||
.map(|s| s.trim().to_string())
|
|
||||||
.filter(|s| !s.is_empty());
|
|
||||||
|
|
||||||
let t_busy = pipeline.transcribe_busy.0.load(Ordering::Acquire);
|
let t_busy = pipeline.transcribe_busy.0.load(Ordering::Acquire);
|
||||||
let case_id_short = case_id_str.chars().take(8).collect();
|
let case_id_short = case_id_str.chars().take(8).collect();
|
||||||
@@ -509,11 +526,14 @@ async fn compute_case_view(
|
|||||||
.filter(|r| !r.failed)
|
.filter(|r| !r.failed)
|
||||||
.all(|r| r.transcript.is_some());
|
.all(|r| r.transcript.is_some());
|
||||||
|
|
||||||
let oneliner = tokio::fs::read_to_string(case_path.join("oneliner.txt"))
|
let (state, _) = paths::read_oneliner_state(case_path).await;
|
||||||
.await
|
let oneliner = match state {
|
||||||
.ok()
|
Some(OnelinerState::Ready { text, .. }) => OnelinerDisplay::Ready(text),
|
||||||
.map(|s| s.trim().to_string())
|
Some(OnelinerState::Empty { .. }) => OnelinerDisplay::Empty,
|
||||||
.filter(|s| !s.is_empty());
|
Some(OnelinerState::Error { .. }) => OnelinerDisplay::Error,
|
||||||
|
None if !all_transcribed => OnelinerDisplay::Pending,
|
||||||
|
None => OnelinerDisplay::Missing,
|
||||||
|
};
|
||||||
|
|
||||||
let has_document = any_document_exists(case_path).await;
|
let has_document = any_document_exists(case_path).await;
|
||||||
let analyzing = !has_document && worker_busy && any_analysis_input_exists(case_path).await;
|
let analyzing = !has_document && worker_busy && any_analysis_input_exists(case_path).await;
|
||||||
@@ -533,3 +553,14 @@ async fn compute_case_view(
|
|||||||
can_analyze,
|
can_analyze,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Extract only the text from a `Ready` state; other states (Empty,
|
||||||
|
/// Error, missing file) collapse to `None`. Used by single-case pages
|
||||||
|
/// whose headings simply fall back to a generic placeholder — they do
|
||||||
|
/// not need the full five-state UI treatment.
|
||||||
|
async fn ready_text(case_dir: &Path) -> Option<String> {
|
||||||
|
match paths::read_oneliner_state(case_dir).await.0 {
|
||||||
|
Some(OnelinerState::Ready { text, .. }) => Some(text),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use doctate_common::oneliners::OnelinerState;
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
|
|
||||||
use super::{TranscribeJob, TranscribeSender};
|
use super::{TranscribeJob, TranscribeSender};
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
use crate::events::EventSender;
|
use crate::events::EventSender;
|
||||||
use crate::gazetteer::Gazetteer;
|
use crate::gazetteer::Gazetteer;
|
||||||
|
use crate::paths;
|
||||||
/// Walk `data_path/*/` and enqueue every recording pending transcription for
|
/// Walk `data_path/*/` and enqueue every recording pending transcription for
|
||||||
/// every user. Intended for startup; the same primitive backs the per-user
|
/// every user. Intended for startup; the same primitive backs the per-user
|
||||||
/// self-heal triggered by web handlers.
|
/// self-heal triggered by web handlers.
|
||||||
@@ -83,10 +85,18 @@ pub async fn enqueue_pending_for_user(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Walk `data_path/*/*` and return every case directory that has at least
|
/// Walk `data_path/*/*` and return every case directory that has at least
|
||||||
/// one non-empty `*.transcript.txt` but no `oneliner.txt`, paired with the
|
/// one non-empty `*.transcript.txt` and whose persisted oneliner state
|
||||||
/// user-slug (parent directory name). Pure filesystem scan, no LLM calls —
|
/// warrants (re-)generation, paired with the user-slug (parent directory
|
||||||
/// separated from the regeneration wrapper so it can be unit-tested without
|
/// name). Pure filesystem scan, no LLM calls — separated from the
|
||||||
/// mocking Ollama.
|
/// regeneration wrapper so it can be unit-tested without mocking Ollama.
|
||||||
|
///
|
||||||
|
/// Retry policy by persisted state:
|
||||||
|
/// - missing file → retry (first generation, or crash before write)
|
||||||
|
/// - [`OnelinerState::Error`] → retry (transient LLM failure last time)
|
||||||
|
/// - [`OnelinerState::Ready`] or [`OnelinerState::Empty`] → do **not**
|
||||||
|
/// retry; `Empty` is a valid outcome and `Ready` is already correct.
|
||||||
|
/// A fresh transcript write will trigger regeneration through the
|
||||||
|
/// normal worker path.
|
||||||
pub(crate) async fn cases_needing_oneliner(data_path: &Path) -> Vec<(PathBuf, String)> {
|
pub(crate) async fn cases_needing_oneliner(data_path: &Path) -> Vec<(PathBuf, String)> {
|
||||||
let mut out: Vec<(PathBuf, String)> = Vec::new();
|
let mut out: Vec<(PathBuf, String)> = Vec::new();
|
||||||
let Ok(mut users) = tokio::fs::read_dir(data_path).await else {
|
let Ok(mut users) = tokio::fs::read_dir(data_path).await else {
|
||||||
@@ -105,10 +115,15 @@ pub(crate) async fn cases_needing_oneliner(data_path: &Path) -> Vec<(PathBuf, St
|
|||||||
};
|
};
|
||||||
while let Ok(Some(case_entry)) = cases.next_entry().await {
|
while let Ok(Some(case_entry)) = cases.next_entry().await {
|
||||||
let case_dir = case_entry.path();
|
let case_dir = case_entry.path();
|
||||||
if !case_dir.is_dir() || crate::paths::is_deleted(&case_dir).await {
|
if !case_dir.is_dir() || paths::is_deleted(&case_dir).await {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if case_dir.join("oneliner.txt").exists() {
|
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,
|
||||||
|
};
|
||||||
|
if !needs_retry {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if has_non_empty_transcript(&case_dir).await {
|
if has_non_empty_transcript(&case_dir).await {
|
||||||
@@ -141,10 +156,11 @@ async fn has_non_empty_transcript(case_dir: &Path) -> bool {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Regenerate `oneliner.txt` for every case that has transcripts but no
|
/// Regenerate `oneliner.json` for every case that has transcripts but no
|
||||||
/// oneliner (crash between transcript-write and oneliner-write). Called at
|
/// state file (crash between transcript-write and oneliner-write) or a
|
||||||
/// startup after `scan_and_enqueue`. Sequential on purpose — the oneliner
|
/// previously-errored state. Called at startup after `scan_and_enqueue`.
|
||||||
/// model is light, but spamming it in parallel is not worth it.
|
/// Sequential on purpose — the oneliner model is light, but spamming it
|
||||||
|
/// in parallel is not worth it.
|
||||||
pub async fn regenerate_missing_oneliners(
|
pub async fn regenerate_missing_oneliners(
|
||||||
data_path: &Path,
|
data_path: &Path,
|
||||||
client: &reqwest::Client,
|
client: &reqwest::Client,
|
||||||
@@ -180,21 +196,72 @@ mod tests {
|
|||||||
assert_eq!(got, vec![(case, "user".to_owned())]);
|
assert_eq!(got, vec![(case, "user".to_owned())]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn write_state(case: &Path, state: &OnelinerState) {
|
||||||
|
let bytes = serde_json::to_vec(state).unwrap();
|
||||||
|
tokio::fs::write(case.join("oneliner.json"), bytes)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn cases_needing_oneliner_skips_cases_with_oneliner() {
|
async fn cases_needing_oneliner_skips_ready_state() {
|
||||||
let data = tempdir().unwrap();
|
let data = tempdir().unwrap();
|
||||||
let case = data.path().join("user").join("case1");
|
let case = data.path().join("user").join("case1");
|
||||||
tokio::fs::create_dir_all(&case).await.unwrap();
|
tokio::fs::create_dir_all(&case).await.unwrap();
|
||||||
tokio::fs::write(case.join("2026-04-16T10-00-00Z.transcript.txt"), "content")
|
tokio::fs::write(case.join("2026-04-16T10-00-00Z.transcript.txt"), "content")
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
tokio::fs::write(case.join("oneliner.txt"), "Existing")
|
write_state(
|
||||||
.await
|
&case,
|
||||||
.unwrap();
|
&OnelinerState::Ready {
|
||||||
|
text: "Existing".into(),
|
||||||
|
generated_at: "2026-04-16T10:01:00Z".into(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
assert!(cases_needing_oneliner(data.path()).await.is_empty());
|
assert!(cases_needing_oneliner(data.path()).await.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn cases_needing_oneliner_skips_empty_state() {
|
||||||
|
let data = tempdir().unwrap();
|
||||||
|
let case = data.path().join("user").join("case1");
|
||||||
|
tokio::fs::create_dir_all(&case).await.unwrap();
|
||||||
|
tokio::fs::write(case.join("2026-04-16T10-00-00Z.transcript.txt"), "content")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
write_state(
|
||||||
|
&case,
|
||||||
|
&OnelinerState::Empty {
|
||||||
|
generated_at: "2026-04-16T10:01:00Z".into(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(cases_needing_oneliner(data.path()).await.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn cases_needing_oneliner_retries_error_state() {
|
||||||
|
let data = tempdir().unwrap();
|
||||||
|
let case = data.path().join("user").join("case1");
|
||||||
|
tokio::fs::create_dir_all(&case).await.unwrap();
|
||||||
|
tokio::fs::write(case.join("2026-04-16T10-00-00Z.transcript.txt"), "content")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
write_state(
|
||||||
|
&case,
|
||||||
|
&OnelinerState::Error {
|
||||||
|
generated_at: "2026-04-16T10:01:00Z".into(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let got = cases_needing_oneliner(data.path()).await;
|
||||||
|
assert_eq!(got, vec![(case, "user".to_owned())]);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn cases_needing_oneliner_skips_cases_with_only_empty_transcripts() {
|
async fn cases_needing_oneliner_skips_cases_with_only_empty_transcripts() {
|
||||||
let data = tempdir().unwrap();
|
let data = tempdir().unwrap();
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ use std::path::Path;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use doctate_common::oneliners::{ONELINER_FILENAME, OnelinerState};
|
||||||
|
use time::OffsetDateTime;
|
||||||
|
use time::format_description::well_known::Rfc3339;
|
||||||
use tracing::{error, info, warn};
|
use tracing::{error, info, warn};
|
||||||
|
|
||||||
use super::{TranscribeReceiver, ffmpeg, ollama, whisper};
|
use super::{TranscribeReceiver, ffmpeg, ollama, whisper};
|
||||||
@@ -178,13 +181,14 @@ async fn write_duration_sidecar(audio_path: &Path, probe_path: &Path) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Regenerate `case_dir/oneliner.txt` from **all** non-empty transcripts in
|
/// Regenerate `case_dir/oneliner.json` from **all** non-empty transcripts
|
||||||
/// the case, joined chronologically. Called after every successful transcript
|
/// in the case, joined chronologically. Called after every successful
|
||||||
/// write so later recordings can correct earlier ones (mirror of the
|
/// transcript write so later recordings can correct earlier ones (mirror
|
||||||
/// analyze-LLM rule "later recordings override"). Overwrites any existing
|
/// of the analyze-LLM rule "later recordings override"). Every outcome —
|
||||||
/// oneliner on success; LLM errors are non-fatal and leave the previous
|
/// generated text, deliberate empty ("no medical content"), or call error
|
||||||
/// oneliner (if any) untouched. Silent case (no non-empty transcript) is a
|
/// — is persisted as an [`OnelinerState`] variant, overwriting any
|
||||||
/// no-op — next transcript write retries.
|
/// previous state. Silent case (no non-empty transcript) is a no-op; the
|
||||||
|
/// next transcript write retries.
|
||||||
pub(crate) async fn update_oneliner(
|
pub(crate) async fn update_oneliner(
|
||||||
case_dir: &Path,
|
case_dir: &Path,
|
||||||
user_slug: &str,
|
user_slug: &str,
|
||||||
@@ -193,14 +197,16 @@ pub(crate) async fn update_oneliner(
|
|||||||
vocab: &Gazetteer,
|
vocab: &Gazetteer,
|
||||||
events_tx: &EventSender,
|
events_tx: &EventSender,
|
||||||
) {
|
) {
|
||||||
let path = case_dir.join("oneliner.txt");
|
|
||||||
|
|
||||||
let transcript = match all_transcripts_joined(case_dir).await {
|
let transcript = match all_transcripts_joined(case_dir).await {
|
||||||
Some(t) => t,
|
Some(t) => t,
|
||||||
None => return,
|
None => return,
|
||||||
};
|
};
|
||||||
|
|
||||||
match ollama::generate_oneliner(
|
let generated_at = OffsetDateTime::now_utc()
|
||||||
|
.format(&Rfc3339)
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let state = match ollama::generate_oneliner(
|
||||||
client,
|
client,
|
||||||
&config.ollama_url,
|
&config.ollama_url,
|
||||||
&config.ollama_model,
|
&config.ollama_model,
|
||||||
@@ -211,34 +217,50 @@ pub(crate) async fn update_oneliner(
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(line) => {
|
Ok(line) => {
|
||||||
let line = vocab.replace(&line);
|
let text = vocab.replace(&line);
|
||||||
if let Err(e) = tokio::fs::write(&path, &line).await {
|
info!(
|
||||||
error!(path = %path.display(), error = %e, "writing oneliner failed");
|
user = %user_slug,
|
||||||
} else {
|
case = %case_dir.display(),
|
||||||
info!(
|
chars = text.chars().count(),
|
||||||
user = %user_slug,
|
"Oneliner ready"
|
||||||
path = %path.display(),
|
);
|
||||||
chars = line.chars().count(),
|
OnelinerState::Ready { text, generated_at }
|
||||||
"Oneliner updated"
|
|
||||||
);
|
|
||||||
events::emit(
|
|
||||||
events_tx,
|
|
||||||
user_slug,
|
|
||||||
events::case_id_of(case_dir),
|
|
||||||
CaseEventKind::OnelinerUpdated,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Err(ollama::OllamaError::EmptyResponse) => {
|
Err(ollama::OllamaError::EmptyResponse) => {
|
||||||
warn!(
|
warn!(
|
||||||
case = %case_dir.display(),
|
case = %case_dir.display(),
|
||||||
"oneliner skipped — model returned empty (no medical keyword in transcript)"
|
"oneliner empty — model returned nothing (no medical keyword in transcript)"
|
||||||
);
|
);
|
||||||
|
OnelinerState::Empty { generated_at }
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!(case = %case_dir.display(), error = %e, "oneliner generation failed");
|
error!(case = %case_dir.display(), error = %e, "oneliner generation failed");
|
||||||
|
OnelinerState::Error { generated_at }
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(e) = write_oneliner_state(case_dir, &state).await {
|
||||||
|
error!(case = %case_dir.display(), error = %e, "writing oneliner.json failed");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
events::emit(
|
||||||
|
events_tx,
|
||||||
|
user_slug,
|
||||||
|
events::case_id_of(case_dir),
|
||||||
|
CaseEventKind::OnelinerUpdated,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ section { margin: 1.5em 0; }
|
|||||||
section h2 { font-size: 1.1em; color: #555; border-bottom: 1px solid #ddd; padding-bottom: 0.2em; display: flex; justify-content: space-between; align-items: baseline; gap: 1em; }
|
section h2 { font-size: 1.1em; color: #555; border-bottom: 1px solid #ddd; padding-bottom: 0.2em; display: flex; justify-content: space-between; align-items: baseline; gap: 1em; }
|
||||||
section h2 .count { font-weight: normal; font-size: 0.85em; color: #888; }
|
section h2 .count { font-weight: normal; font-size: 0.85em; color: #888; }
|
||||||
.empty { color: #888; font-style: italic; }
|
.empty { color: #888; font-style: italic; }
|
||||||
|
.pending { color: #888; font-style: italic; }
|
||||||
|
|
||||||
.case-list { list-style: none; padding: 0; margin: 0; }
|
.case-list { list-style: none; padding: 0; margin: 0; }
|
||||||
.case-row { display: grid; grid-template-columns: auto 1fr auto; gap: 0.6em 1em; align-items: center; padding: 0.7em 1em; border: 1px solid #ccc; border-radius: 4px; margin: 0.5em 0; }
|
.case-row { display: grid; grid-template-columns: auto 1fr auto; gap: 0.6em 1em; align-items: center; padding: 0.7em 1em; border: 1px solid #ccc; border-radius: 4px; margin: 0.5em 0; }
|
||||||
@@ -85,7 +86,7 @@ try {
|
|||||||
<div class="check"><input type="checkbox" name="case_id" value="{{ case.case_id }}"></div>
|
<div class="check"><input type="checkbox" name="case_id" value="{{ case.case_id }}"></div>
|
||||||
<div class="body">
|
<div class="body">
|
||||||
<a href="/web/cases/{{ case.case_id }}">
|
<a href="/web/cases/{{ case.case_id }}">
|
||||||
<div class="line1"><span class="time"><time datetime="{{ case.recorded_at_iso }}">{{ case.time_hms_utc }}</time></span>{% match case.oneliner %}{% when Some with (t) %} — {{ t }}{% when None %} — <span class="empty">kein Oneliner</span>{% endmatch %}</div>
|
<div class="line1"><span class="time"><time datetime="{{ case.recorded_at_iso }}">{{ case.time_hms_utc }}</time></span>{% match case.oneliner %}{% when OnelinerDisplay::Ready with (t) %} — {{ t }}{% when OnelinerDisplay::Empty %} — <span class="empty">kein medizinischer Inhalt</span>{% when OnelinerDisplay::Error %} — <span class="empty">Oneliner-Fehler</span>{% when OnelinerDisplay::Pending %} — <span class="pending">wird transkribiert …</span>{% when OnelinerDisplay::Missing %} — <span class="empty">unbenannt</span>{% endmatch %}</div>
|
||||||
<div class="line2">{{ case.recordings_count }} Aufnahmen{% if case.has_document %} — <span class="status-badge done">ausgewertet</span>{% else %} — <span class="status-badge open">offen</span>{% endif %}{% if case.analyzing %} <span class="label analyzing">wird analysiert</span>{% endif %}</div>
|
<div class="line2">{{ case.recordings_count }} Aufnahmen{% if case.has_document %} — <span class="status-badge done">ausgewertet</span>{% else %} — <span class="status-badge open">offen</span>{% endif %}{% if case.analyzing %} <span class="label analyzing">wird analysiert</span>{% endif %}</div>
|
||||||
{% if is_admin %}<div class="uuid admin-only">{{ case.case_id }}</div>{% endif %}
|
{% if is_admin %}<div class="uuid admin-only">{{ case.case_id }}</div>{% endif %}
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -837,7 +837,11 @@ async fn reset_case_clears_derived_and_unfails_audio() {
|
|||||||
// A failed recording: raw audio written, but filename carries `.failed`.
|
// A failed recording: raw audio written, but filename carries `.failed`.
|
||||||
std::fs::write(dir.join("2026-04-15T09-05-00Z.m4a.failed"), b"audio-bytes").unwrap();
|
std::fs::write(dir.join("2026-04-15T09-05-00Z.m4a.failed"), b"audio-bytes").unwrap();
|
||||||
// Derived artefacts the reset must wipe.
|
// Derived artefacts the reset must wipe.
|
||||||
std::fs::write(dir.join("oneliner.txt"), "Knie re.").unwrap();
|
std::fs::write(
|
||||||
|
dir.join("oneliner.json"),
|
||||||
|
br#"{"kind":"ready","text":"Knie re.","generated_at":"2026-04-18T10:00:00Z"}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
std::fs::write(dir.join("document.md"), "# Doc\n").unwrap();
|
std::fs::write(dir.join("document.md"), "# Doc\n").unwrap();
|
||||||
std::fs::write(dir.join("analysis_input.json"), "{}").unwrap();
|
std::fs::write(dir.join("analysis_input.json"), "{}").unwrap();
|
||||||
|
|
||||||
@@ -854,7 +858,7 @@ async fn reset_case_clears_derived_and_unfails_audio() {
|
|||||||
assert!(!dir.join("2026-04-15T09-05-00Z.m4a.failed").exists());
|
assert!(!dir.join("2026-04-15T09-05-00Z.m4a.failed").exists());
|
||||||
// Derived artefacts gone.
|
// Derived artefacts gone.
|
||||||
assert!(!dir.join("2026-04-15T09-00-00Z.transcript.txt").exists());
|
assert!(!dir.join("2026-04-15T09-00-00Z.transcript.txt").exists());
|
||||||
assert!(!dir.join("oneliner.txt").exists());
|
assert!(!dir.join("oneliner.json").exists());
|
||||||
assert!(!dir.join("document.md").exists());
|
assert!(!dir.join("document.md").exists());
|
||||||
assert!(!dir.join("analysis_input.json").exists());
|
assert!(!dir.join("analysis_input.json").exists());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use axum::body::Body;
|
use axum::body::Body;
|
||||||
use axum::http::{Request, StatusCode, header};
|
use axum::http::{Request, StatusCode, header};
|
||||||
|
use doctate_common::oneliners::OnelinerState;
|
||||||
use doctate_server::config::{Config, User};
|
use doctate_server::config::{Config, User};
|
||||||
use tower::util::ServiceExt;
|
use tower::util::ServiceExt;
|
||||||
|
|
||||||
@@ -32,7 +33,17 @@ fn make_admin(slug: &str) -> User {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn seed_oneliner(case_dir: &Path, text: &str) {
|
fn seed_oneliner(case_dir: &Path, text: &str) {
|
||||||
std::fs::write(case_dir.join("oneliner.txt"), text).unwrap();
|
let state = OnelinerState::Ready {
|
||||||
|
text: text.to_owned(),
|
||||||
|
generated_at: "2026-04-18T10:00:00Z".into(),
|
||||||
|
};
|
||||||
|
let bytes = serde_json::to_vec(&state).unwrap();
|
||||||
|
std::fs::write(case_dir.join("oneliner.json"), bytes).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn seed_oneliner_state(case_dir: &Path, state: &OnelinerState) {
|
||||||
|
let bytes = serde_json::to_vec(state).unwrap();
|
||||||
|
std::fs::write(case_dir.join("oneliner.json"), bytes).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn config_with_llm_users(data_path: PathBuf, llm_url: String, users: Vec<User>) -> Arc<Config> {
|
fn config_with_llm_users(data_path: PathBuf, llm_url: String, users: Vec<User>) -> Arc<Config> {
|
||||||
@@ -326,6 +337,34 @@ async fn case_page_uses_oneliner_as_h1_when_present() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn case_page_falls_back_to_generic_title_for_empty_state() {
|
||||||
|
// An `Empty` state (LLM deliberately returned no medical content) is
|
||||||
|
// not a text; the H1 must fall back to the generic "Fall" label, not
|
||||||
|
// leak the kind string or crash the template.
|
||||||
|
let config = config_with_llm(unique_tmp("cp-empty"));
|
||||||
|
let case_id = "22222222-2222-2222-2222-222222222222";
|
||||||
|
let case_dir = seed_case(&config.data_path, "dr_a", case_id);
|
||||||
|
seed_oneliner_state(
|
||||||
|
&case_dir,
|
||||||
|
&OnelinerState::Empty {
|
||||||
|
generated_at: "2026-04-18T10:00:00Z".into(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let app = doctate_server::create_router(config);
|
||||||
|
let cookie = login(app.clone(), "dr_a").await;
|
||||||
|
|
||||||
|
let resp = app.oneshot(get_page(case_id, &cookie, "")).await.unwrap();
|
||||||
|
let body = body_string(resp).await;
|
||||||
|
|
||||||
|
assert!(body.contains("Fall"), "expected generic 'Fall' fallback");
|
||||||
|
assert!(
|
||||||
|
!body.contains("\"kind\""),
|
||||||
|
"must not leak JSON kind discriminator into HTML"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn case_page_admin_sees_admin_view_toggle() {
|
async fn case_page_admin_sees_admin_view_toggle() {
|
||||||
let data_path = unique_tmp("cp-toggle");
|
let data_path = unique_tmp("cp-toggle");
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use axum::http::{Request, StatusCode};
|
|||||||
use filetime::FileTime;
|
use filetime::FileTime;
|
||||||
use tower::util::ServiceExt;
|
use tower::util::ServiceExt;
|
||||||
|
|
||||||
|
use doctate_common::oneliners::OnelinerState;
|
||||||
use doctate_server::config::{Config, User};
|
use doctate_server::config::{Config, User};
|
||||||
|
|
||||||
const TEST_KEY: &str = "test-key-oneliners";
|
const TEST_KEY: &str = "test-key-oneliners";
|
||||||
@@ -50,13 +51,29 @@ async fn seed_case(
|
|||||||
filetime::set_file_mtime(&m4a, FileTime::from_system_time(target_mtime)).unwrap();
|
filetime::set_file_mtime(&m4a, FileTime::from_system_time(target_mtime)).unwrap();
|
||||||
|
|
||||||
if let Some(text) = oneliner_text {
|
if let Some(text) = oneliner_text {
|
||||||
tokio::fs::write(case_dir.join("oneliner.txt"), text)
|
write_ready_state(&case_dir, text).await;
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
}
|
}
|
||||||
case_dir
|
case_dir
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn write_ready_state(case_dir: &Path, text: &str) {
|
||||||
|
let state = OnelinerState::Ready {
|
||||||
|
text: text.to_owned(),
|
||||||
|
generated_at: "2026-04-18T10:00:00Z".into(),
|
||||||
|
};
|
||||||
|
let bytes = serde_json::to_vec(&state).unwrap();
|
||||||
|
tokio::fs::write(case_dir.join("oneliner.json"), bytes)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn write_state(case_dir: &Path, state: &OnelinerState) {
|
||||||
|
let bytes = serde_json::to_vec(state).unwrap();
|
||||||
|
tokio::fs::write(case_dir.join("oneliner.json"), bytes)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
async fn mark_deleted(case_dir: &Path) {
|
async fn mark_deleted(case_dir: &Path) {
|
||||||
tokio::fs::write(case_dir.join(".deleted"), "{}")
|
tokio::fs::write(case_dir.join(".deleted"), "{}")
|
||||||
.await
|
.await
|
||||||
@@ -155,7 +172,8 @@ async fn recent_case_with_oneliner_shows_up() {
|
|||||||
let arr = body["oneliners"].as_array().unwrap();
|
let arr = body["oneliners"].as_array().unwrap();
|
||||||
assert_eq!(arr.len(), 1);
|
assert_eq!(arr.len(), 1);
|
||||||
assert_eq!(arr[0]["case_id"], case_id);
|
assert_eq!(arr[0]["case_id"], case_id);
|
||||||
assert_eq!(arr[0]["oneliner"], "Kniegelenk re., V.a. Meniskus");
|
assert_eq!(arr[0]["oneliner"]["kind"], "ready");
|
||||||
|
assert_eq!(arr[0]["oneliner"]["text"], "Kniegelenk re., V.a. Meniskus");
|
||||||
assert!(arr[0]["created_at"].is_string());
|
assert!(arr[0]["created_at"].is_string());
|
||||||
assert!(arr[0]["updated_at"].is_string());
|
assert!(arr[0]["updated_at"].is_string());
|
||||||
|
|
||||||
@@ -365,8 +383,8 @@ async fn multiple_cases_sorted_newest_first() {
|
|||||||
let body = body_json(app.oneshot(get("/api/oneliners")).await.unwrap()).await;
|
let body = body_json(app.oneshot(get("/api/oneliners")).await.unwrap()).await;
|
||||||
let arr = body["oneliners"].as_array().unwrap();
|
let arr = body["oneliners"].as_array().unwrap();
|
||||||
assert_eq!(arr.len(), 2);
|
assert_eq!(arr.len(), 2);
|
||||||
assert_eq!(arr[0]["oneliner"], "newer");
|
assert_eq!(arr[0]["oneliner"]["text"], "newer");
|
||||||
assert_eq!(arr[1]["oneliner"], "older");
|
assert_eq!(arr[1]["oneliner"]["text"], "older");
|
||||||
|
|
||||||
let _ = std::fs::remove_dir_all(&dp);
|
let _ = std::fs::remove_dir_all(&dp);
|
||||||
}
|
}
|
||||||
@@ -398,9 +416,7 @@ async fn sorts_by_last_recording_not_created() {
|
|||||||
FileTime::from_system_time(SystemTime::now() - Duration::from_secs(60)),
|
FileTime::from_system_time(SystemTime::now() - Duration::from_secs(60)),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
tokio::fs::write(dir_a.join("oneliner.txt"), "A (fresh addendum)")
|
write_ready_state(&dir_a, "A (fresh addendum)").await;
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
// Case B: single recording 10h ago — created_at newer than A's, but
|
// Case B: single recording 10h ago — created_at newer than A's, but
|
||||||
// last_recording_at older than A's fresh addendum.
|
// last_recording_at older than A's fresh addendum.
|
||||||
@@ -421,3 +437,58 @@ async fn sorts_by_last_recording_not_created() {
|
|||||||
|
|
||||||
let _ = std::fs::remove_dir_all(&dp);
|
let _ = std::fs::remove_dir_all(&dp);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn empty_state_serializes_as_kind_empty() {
|
||||||
|
let (config, dp) = test_config();
|
||||||
|
let case_dir = seed_case(
|
||||||
|
&dp,
|
||||||
|
"550e8400-e29b-41d4-a716-000000000030",
|
||||||
|
Duration::from_secs(60),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
write_state(
|
||||||
|
&case_dir,
|
||||||
|
&OnelinerState::Empty {
|
||||||
|
generated_at: "2026-04-18T10:00:00Z".into(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let app = doctate_server::create_router(config);
|
||||||
|
|
||||||
|
let body = body_json(app.oneshot(get("/api/oneliners")).await.unwrap()).await;
|
||||||
|
let arr = body["oneliners"].as_array().unwrap();
|
||||||
|
assert_eq!(arr.len(), 1);
|
||||||
|
assert_eq!(arr[0]["oneliner"]["kind"], "empty");
|
||||||
|
assert!(arr[0]["oneliner"].get("text").is_none());
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(&dp);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn error_state_serializes_as_kind_error() {
|
||||||
|
let (config, dp) = test_config();
|
||||||
|
let case_dir = seed_case(
|
||||||
|
&dp,
|
||||||
|
"550e8400-e29b-41d4-a716-000000000031",
|
||||||
|
Duration::from_secs(60),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
write_state(
|
||||||
|
&case_dir,
|
||||||
|
&OnelinerState::Error {
|
||||||
|
generated_at: "2026-04-18T10:00:00Z".into(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let app = doctate_server::create_router(config);
|
||||||
|
|
||||||
|
let body = body_json(app.oneshot(get("/api/oneliners")).await.unwrap()).await;
|
||||||
|
let arr = body["oneliners"].as_array().unwrap();
|
||||||
|
assert_eq!(arr.len(), 1);
|
||||||
|
assert_eq!(arr[0]["oneliner"]["kind"], "error");
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(&dp);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user