diff --git a/client-desktop/src/app.rs b/client-desktop/src/app.rs index bf58d99..90e2cd6 100644 --- a/client-desktop/src/app.rs +++ b/client-desktop/src/app.rs @@ -524,7 +524,8 @@ impl DoctateApp { return; }; let snap = worker_rx.borrow().clone(); - let last_age = snap.last_success.map(|i| i.elapsed()); + let last_success_age = snap.last_success.map(|i| i.elapsed()); + let last_failure_age = snap.last_failure.map(|i| i.elapsed()); let threshold = self .config .as_ref() @@ -535,7 +536,8 @@ impl DoctateApp { self.finalizing.is_some(), snap.state, snap.queue_len, - last_age, + last_success_age, + last_failure_age, threshold, ); diff --git a/client-desktop/src/state.rs b/client-desktop/src/state.rs index 203cb13..ec0e087 100644 --- a/client-desktop/src/state.rs +++ b/client-desktop/src/state.rs @@ -61,28 +61,45 @@ pub enum FooterStatus { /// Decide which footer status to show. Pure function — no side effects, /// fully table-testable. Inputs chosen to match what `DoctateApp` can /// observe cheaply per egui frame. +/// +/// Decision rules for the `Idle` branch: +/// - A failure younger than the last success (or a failure with no +/// prior success at all) wins → `Offline`. +/// - Otherwise fall back to success-age vs. threshold (classic staleness). +/// +/// `last_*_age` arguments are `Option` = "seconds since that +/// event, or `None` if it never happened". Smaller Duration = more recent. pub fn pick_footer_status( finalizing: bool, worker_state: WorkerState, queue_len: usize, last_success_age: Option, + last_failure_age: Option, offline_threshold: Duration, ) -> FooterStatus { if finalizing { return FooterStatus::Finalizing; } - if queue_len > 0 || worker_state == WorkerState::Uploading { - return FooterStatus::Uploading { + match worker_state { + WorkerState::Uploading => FooterStatus::Uploading { count: queue_len.max(1), - }; - } - if worker_state == WorkerState::Polling { - return FooterStatus::Synchronizing; - } - match last_success_age { - None => FooterStatus::NeverConnected, - Some(age) if age > offline_threshold => FooterStatus::Offline, - Some(_) => FooterStatus::Online, + }, + WorkerState::Polling => FooterStatus::Synchronizing, + WorkerState::Idle => { + let failure_more_recent = match (last_success_age, last_failure_age) { + (Some(s), Some(f)) => f < s, // smaller age = younger + (None, Some(_)) => true, // only a failure on record + _ => false, + }; + if failure_more_recent { + return FooterStatus::Offline; + } + match last_success_age { + None => FooterStatus::NeverConnected, + Some(age) if age > offline_threshold => FooterStatus::Offline, + Some(_) => FooterStatus::Online, + } + } } } @@ -92,6 +109,16 @@ mod tests { const THRESHOLD: Duration = Duration::from_secs(30); + /// Convenience: most tests don't care about the failure field. + fn status( + finalizing: bool, + worker_state: WorkerState, + queue_len: usize, + last_success_age: Option, + ) -> FooterStatus { + pick_footer_status(finalizing, worker_state, queue_len, last_success_age, None, THRESHOLD) + } + #[test] fn finalizing_wins_over_everything() { let s = pick_footer_status( @@ -99,6 +126,7 @@ mod tests { WorkerState::Uploading, 5, Some(Duration::from_millis(1)), + Some(Duration::from_millis(1)), THRESHOLD, ); assert_eq!(s, FooterStatus::Finalizing); @@ -106,84 +134,131 @@ mod tests { #[test] fn uploading_when_queue_non_empty() { - let s = pick_footer_status( + let s = status( false, WorkerState::Uploading, 3, Some(Duration::from_millis(1)), - THRESHOLD, ); assert_eq!(s, FooterStatus::Uploading { count: 3 }); } #[test] fn uploading_reports_at_least_one_even_if_scan_raced() { - // Edge case: WorkerState says Uploading, but scan returned 0 - // (just finished a file and the next scan hasn't happened yet). - // We still want to show "Uploading" to avoid a flicker back to - // Online right before the next file starts. - let s = pick_footer_status( + let s = status( false, WorkerState::Uploading, 0, Some(Duration::from_millis(1)), - THRESHOLD, ); assert_eq!(s, FooterStatus::Uploading { count: 1 }); } #[test] fn synchronizing_when_polling_and_no_queue() { - let s = pick_footer_status( - false, - WorkerState::Polling, - 0, - Some(Duration::from_millis(1)), - THRESHOLD, - ); + let s = status(false, WorkerState::Polling, 0, Some(Duration::from_millis(1))); assert_eq!(s, FooterStatus::Synchronizing); } #[test] fn never_connected_without_last_success() { - let s = pick_footer_status(false, WorkerState::Idle, 0, None, THRESHOLD); + let s = status(false, WorkerState::Idle, 0, None); assert_eq!(s, FooterStatus::NeverConnected); } #[test] fn offline_when_last_success_is_stale() { - let s = pick_footer_status( + let s = status( false, WorkerState::Idle, 0, Some(Duration::from_secs(120)), - THRESHOLD, ); assert_eq!(s, FooterStatus::Offline); } #[test] fn online_when_last_success_is_fresh() { - let s = pick_footer_status( - false, - WorkerState::Idle, - 0, - Some(Duration::from_secs(5)), - THRESHOLD, - ); + let s = status(false, WorkerState::Idle, 0, Some(Duration::from_secs(5))); assert_eq!(s, FooterStatus::Online); } #[test] fn offline_threshold_is_inclusive_of_equal_age_as_online() { - // Exactly at threshold: still online (strict ">" in logic). + let s = status(false, WorkerState::Idle, 0, Some(THRESHOLD)); + assert_eq!(s, FooterStatus::Online); + } + + #[test] + fn offline_while_queue_has_items_but_worker_backing_off() { + let s = status( + false, + WorkerState::Idle, + 3, + Some(Duration::from_secs(120)), + ); + assert_eq!(s, FooterStatus::Offline); + } + + #[test] + fn online_while_queue_has_items_but_worker_idle_and_recent_success() { + let s = status(false, WorkerState::Idle, 3, Some(Duration::from_secs(2))); + assert_eq!(s, FooterStatus::Online); + } + + #[test] + fn never_connected_while_queue_has_items_and_no_success_ever() { + let s = status(false, WorkerState::Idle, 3, None); + assert_eq!(s, FooterStatus::NeverConnected); + } + + // --- last_failure-driven scenarios --- + + /// Core regression this fix addresses: server was reachable (fresh + /// last_success), then went offline mid-session; a single failed + /// upload must flip the footer to Offline immediately, without + /// waiting for the success-age threshold. + #[test] + fn failure_younger_than_success_forces_offline() { + let s = pick_footer_status( + false, + WorkerState::Idle, + 1, + Some(Duration::from_secs(10)), // was online 10s ago + Some(Duration::from_secs(1)), // but failed 1s ago + THRESHOLD, + ); + assert_eq!(s, FooterStatus::Offline); + } + + /// Complement: success younger than the last failure → the server + /// recovered. Show Online. + #[test] + fn success_younger_than_failure_wins_back_online() { let s = pick_footer_status( false, WorkerState::Idle, 0, - Some(THRESHOLD), + Some(Duration::from_secs(1)), // succeeded 1s ago + Some(Duration::from_secs(10)), // previous failure 10s ago THRESHOLD, ); assert_eq!(s, FooterStatus::Online); } + + /// Only a failure ever, no success: treat as Offline, not + /// NeverConnected — we tried and got rejected, which is a stronger + /// signal than "silent cold start". + #[test] + fn only_failure_no_success_is_offline() { + let s = pick_footer_status( + false, + WorkerState::Idle, + 0, + None, + Some(Duration::from_secs(5)), + THRESHOLD, + ); + assert_eq!(s, FooterStatus::Offline); + } } diff --git a/doctate-client-core/src/server_sync.rs b/doctate-client-core/src/server_sync.rs index 5c33e96..e494af5 100644 --- a/doctate-client-core/src/server_sync.rs +++ b/doctate-client-core/src/server_sync.rs @@ -65,8 +65,13 @@ pub struct WorkerSnapshot { /// `Uploading`. pub queue_len: usize, /// Set to `Some(now)` after any successful upload or poll (200/304). - /// `None` before the first success. Drives Online/Offline heuristics. + /// `None` before the first success. Drives Online/Offline heuristics + /// together with `last_failure`. pub last_success: Option, + /// Set to `Some(now)` after any failed upload or poll. Compared to + /// `last_success` in the UI: if `last_failure` is younger, the + /// footer shows Offline regardless of the success's age. + pub last_failure: Option, } impl Default for WorkerSnapshot { @@ -75,6 +80,7 @@ impl Default for WorkerSnapshot { state: WorkerState::Idle, queue_len: 0, last_success: None, + last_failure: None, } } } @@ -197,12 +203,17 @@ async fn run_loop( let mut backoff = INITIAL_BACKOFF; let mut last_success: Option = None; + let mut last_failure: Option = None; - let publish = |state: WorkerState, queue_len: usize, last: Option| { + let publish = |state: WorkerState, + queue_len: usize, + last_ok: Option, + last_fail: Option| { let snap = WorkerSnapshot { state, queue_len, - last_success: last, + last_success: last_ok, + last_failure: last_fail, }; let _ = snapshot_tx.send(snap); }; @@ -216,7 +227,7 @@ async fn run_loop( let next = files.into_iter().next(); if let Some(upload) = next { - publish(WorkerState::Uploading, queue_len, last_success); + publish(WorkerState::Uploading, queue_len, last_success, last_failure); let _ = events_tx.send(UploadEvent::Started(upload.case_id)); match post_upload(&http, &config, &upload).await { UploadOutcome::Succeeded(status) => { @@ -235,17 +246,24 @@ async fn run_loop( } UploadOutcome::Transient(reason) => { warn!(case_id = %upload.case_id, reason = %reason, "upload failed transiently"); + last_failure = Some(Instant::now()); let _ = events_tx.send(UploadEvent::Failed { case_id: upload.case_id, reason, will_retry: true, }); + // Flip back to Idle before the backoff sleep so the UI + // doesn't misreport "Uploading" during a passive wait. + // The footer derives "Offline" from `last_failure` being + // younger than `last_success`. + publish(WorkerState::Idle, queue_len, last_success, last_failure); tokio::time::sleep(backoff).await; backoff = (backoff * 2).min(MAX_BACKOFF); } UploadOutcome::Terminal(reason) => { warn!(case_id = %upload.case_id, reason = %reason, "upload failed terminally — deleting files"); delete_pair(&upload.file).await; + last_failure = Some(Instant::now()); backoff = INITIAL_BACKOFF; let _ = events_tx.send(UploadEvent::Failed { case_id: upload.case_id, @@ -255,7 +273,7 @@ async fn run_loop( } } } else { - publish(WorkerState::Polling, 0, last_success); + publish(WorkerState::Polling, 0, last_success, last_failure); match poll_once(&http, &config, &store, &cache, &mut etag).await { Ok(PollOutcome::Success) | Ok(PollOutcome::NotModified) => { let now = Instant::now(); @@ -264,13 +282,15 @@ async fn run_loop( } Ok(PollOutcome::TransientHttp(code)) => { warn!(code, "poll returned transient HTTP error"); + last_failure = Some(Instant::now()); } Err(e) => { warn!(error = %e, "poll error"); + last_failure = Some(Instant::now()); } } - publish(WorkerState::Idle, 0, last_success); + publish(WorkerState::Idle, 0, last_success, last_failure); // Wait for next tick OR kick. Cancel-safe on both arms. tokio::select! { diff --git a/server/src/transcribe/ollama.rs b/server/src/transcribe/ollama.rs index 611d2e7..bc26434 100644 --- a/server/src/transcribe/ollama.rs +++ b/server/src/transcribe/ollama.rs @@ -7,7 +7,7 @@ use tracing::debug; /// the transcript itself (no hallucination) so the doctor can recognize the /// case from the watch face. English instructions for small-model /// robustness, German output. -const SYSTEM_PROMPT: &str = "You are a keyword-extraction assistant for a German medical dictation app. Given a transcript, output a short German one-line label for the case.\n\nCRITICAL — empty-string rule:\nIf the transcript is empty, silent, or contains no identifiable medical keyword, respond with an EMPTY STRING. Do not guess, do not produce filler, do not write \"Unbekannt\" or \"Kein Befund\". An empty answer is correct.\n\nRules:\n- 2-4 words, at most 50 characters, single line.\n- Start with the most important noun from the transcript: body region, organ, imaging modality, or key finding.\n- Use ONLY terms that appear in the transcript. Do not invent, translate, or paraphrase. Do not add diagnoses the doctor did not mention.\n- No filler prefixes: \"Patient\", \"Fall\", \"Verdacht auf\", \"Untersuchung\", \"Diktat\", articles.\n- No intensifiers (\"massiv\", \"ausgeprägt\", \"deutlich\", \"hochgradig\").\n- No quotes, no trailing period, no introductory phrase.\n- If the doctor explicitly names the case (\"Bezeichnung: X\", \"Fall-ID: X\"), start with that exact string.\n- Respond in German. One line only.\n\nExamples:\n\nTranscript: \"Bezeichnung: Schulter links. Patient klagt über...\"\nOutput: Schulter links\n\nTranscript: \"...V.a. Bandscheibenvorfall in der LWS, Schmerzen strahlen...\"\nOutput: LWS, V.a. Bandscheibenvorfall\n\nREMINDER: For silent, empty, or non-medical transcripts, respond with nothing at all — an empty string. Never invent content."; +const SYSTEM_PROMPT: &str = "You are a keyword-extraction assistant for a German medical dictation app. Given a transcript, output a short German one-line label for the case.\n\nCRITICAL — silence rule:\nIf the transcript is empty, silent, or contains no identifiable medical keyword, return no text at all. Leave your answer completely blank. Do NOT echo the words 'empty', 'nothing', 'none', 'N/A', 'null', '-', a placeholder, or any description of emptiness. Emit zero characters.\n\nRules:\n- 2-4 words, at most 50 characters, single line.\n- Start with the most important noun from the transcript: body region, organ, imaging modality, or key finding.\n- Use ONLY terms that appear in the transcript. Do not invent, translate, or paraphrase. Do not add diagnoses the doctor did not mention.\n- No filler prefixes: \"Patient\", \"Fall\", \"Verdacht auf\", \"Untersuchung\", \"Diktat\", articles.\n- No intensifiers (\"massiv\", \"ausgeprägt\", \"deutlich\", \"hochgradig\").\n- No quotes, no trailing period, no introductory phrase.\n- If the doctor explicitly names the case (\"Bezeichnung: X\", \"Fall-ID: X\"), start with that exact string.\n- Respond in German. One line only.\n\nExamples:\n\nTranscript: \"Bezeichnung: Schulter links. Patient klagt über...\"\nOutput: Schulter links\n\nTranscript: \"...V.a. Bandscheibenvorfall in der LWS, Schmerzen strahlen...\"\nOutput: LWS, V.a. Bandscheibenvorfall\n\nREMINDER: For silent, empty, or non-medical transcripts, emit zero characters. Do not write the words 'empty', 'nothing', 'none', or any placeholder — just produce no output."; /// Deterministic leading-filler prefixes stripped after the LLM response. /// The trailing space is part of the match — we only strip whole tokens.