From 47829b953ce294eb86cf4f7b88dfe2e19e8cce2e Mon Sep 17 00:00:00 2001 From: Brummel Date: Mon, 13 Apr 2026 17:27:23 +0200 Subject: [PATCH] Add ollama module for LLM summarization Introduces a new module `ollama` to handle communication with an Ollama LLM for generating concise, one-line summaries of medical transcripts. This module includes: - `generate_oneliner`: The main function to interact with the Ollama API. - `normalize`: A pure function for cleaning and formatting the LLM's output according to specific rules. - Error handling for API communication and response parsing. - Unit tests for the normalization logic. --- server/src/transcribe/mod.rs | 1 + server/src/transcribe/ollama.rs | 233 ++++++++++++++++++++++++++++++++ 2 files changed, 234 insertions(+) create mode 100644 server/src/transcribe/ollama.rs diff --git a/server/src/transcribe/mod.rs b/server/src/transcribe/mod.rs index 45ad2e5..cdd2bc4 100644 --- a/server/src/transcribe/mod.rs +++ b/server/src/transcribe/mod.rs @@ -3,6 +3,7 @@ use std::path::PathBuf; use tokio::sync::mpsc; pub mod ffmpeg; +pub mod ollama; pub mod recovery; pub mod whisper; pub mod worker; diff --git a/server/src/transcribe/ollama.rs b/server/src/transcribe/ollama.rs new file mode 100644 index 0000000..36ae422 --- /dev/null +++ b/server/src/transcribe/ollama.rs @@ -0,0 +1,233 @@ +use std::time::Duration; + +use serde::{Deserialize, Serialize}; +use tracing::debug; + +/// Smartwatch-aware system prompt: leading word must be the diagnosis/region +/// so the doctor can distinguish cases from the watch face. +const SYSTEM_PROMPT: &str = "Du bist ein Assistent für Ärzte. Fasse das folgende deutsche medizinische Diktat in genau einer Zeile (maximal 80 Zeichen) zusammen.\n\nDer Oneliner wird auf einem winzigen Smartwatch-Display angezeigt. Nur die ersten Zeichen sind sicher lesbar, deshalb zählt das ERSTE WORT am meisten.\n\nRegeln:\n- Beginne mit dem wichtigsten Begriff: Körperregion, Organ oder Hauptdiagnose (z.B. \"Kniegelenk\", \"Herzkatheter\", \"HOCM\", \"Thorax-CT\").\n- KEINE Füllwörter am Anfang: kein \"Patient\", \"Fall\", \"Verdacht auf\", \"Untersuchung von\", \"Diktat zu\", \"Zusammenfassung\", Artikel.\n- Keine Anführungszeichen, kein Punkt am Ende, keine Einleitung.\n- Wenn der Arzt im Diktat eine eigene Bezeichnung nennt (z.B. \"Bezeichnung: …\" oder \"Fall-ID: …\"), übernimm GENAU diese als Anfang.\n- Nur die eine Zeile, nichts davor oder danach.\n\nBeispiele:\nDiktat: \"Also, Patient kommt mit Schmerzen im rechten Knie, V.a. Meniskusriss...\"\n→ Kniegelenk re., V.a. Meniskusriss\n\nDiktat: \"Bezeichnung: Schulter links. Impingement-Syndrom...\"\n→ Schulter links, Impingement-Syndrom"; + +/// Deterministic leading-filler prefixes stripped after the LLM response. +/// The trailing space is part of the match — we only strip whole tokens. +const LEADING_FILLERS: &[&str] = &[ + "Der Patient ", + "Die Patientin ", + "Patient ", + "Patientin ", + "Fall: ", + "Fall ", + "Untersuchung ", + "Diktat zu ", + "Diktat ", + "Zusammenfassung: ", + "Zusammenfassung ", +]; + +const MAX_CHARS: usize = 120; + +#[derive(Debug)] +pub enum OllamaError { + Http(reqwest::Error), + Status { status: u16, body: String }, + Parse(String), +} + +impl std::fmt::Display for OllamaError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Http(e) => write!(f, "ollama http error: {e}"), + Self::Status { status, body } => write!(f, "ollama returned {status}: {body}"), + Self::Parse(msg) => write!(f, "ollama response parse error: {msg}"), + } + } +} + +impl std::error::Error for OllamaError {} + +#[derive(Serialize)] +struct ChatRequest<'a> { + model: &'a str, + stream: bool, + keep_alive: u32, + options: Options, + messages: Vec>, +} + +#[derive(Serialize)] +struct Options { + temperature: f32, +} + +#[derive(Serialize)] +struct Message<'a> { + role: &'a str, + content: &'a str, +} + +#[derive(Deserialize)] +struct ChatResponse { + message: Option, +} + +#[derive(Deserialize)] +struct ResponseMessage { + content: Option, +} + +/// Generate a one-line case title via Ollama chat API. The returned string is +/// already normalized (single line, no quotes, no trailing period, no leading +/// filler words, capped at 120 chars). +pub async fn generate_oneliner( + client: &reqwest::Client, + ollama_url: &str, + model: &str, + keep_alive_seconds: u32, + transcript: &str, + timeout: Duration, +) -> Result { + let url = format!("{}/api/chat", ollama_url.trim_end_matches('/')); + debug!(%url, model, "generating oneliner"); + + let body = ChatRequest { + model, + stream: false, + keep_alive: keep_alive_seconds, + options: Options { temperature: 0.2 }, + messages: vec![ + Message { role: "system", content: SYSTEM_PROMPT }, + Message { role: "user", content: transcript }, + ], + }; + + let response = client + .post(&url) + .timeout(timeout) + .json(&body) + .send() + .await + .map_err(OllamaError::Http)?; + + let status = response.status(); + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + return Err(OllamaError::Status { + status: status.as_u16(), + body, + }); + } + + let parsed: ChatResponse = response.json().await.map_err(OllamaError::Http)?; + let content = parsed + .message + .and_then(|m| m.content) + .ok_or_else(|| OllamaError::Parse("missing message.content".into()))?; + + let normalized = normalize(&content); + if normalized.is_empty() { + return Err(OllamaError::Parse("empty after normalization".into())); + } + Ok(normalized) +} + +/// Pure, unit-testable normalization. Order matters: +/// trim → first non-empty line → strip quote pair → strip trailing `.` → +/// strip leading filler → cap at MAX_CHARS chars (byte-safe) → trim again. +pub(crate) fn normalize(raw: &str) -> String { + let mut s: &str = raw.trim(); + + // First non-empty line. + s = s.lines().find(|l| !l.trim().is_empty()).unwrap_or("").trim(); + + // A single pair of surrounding quotes (' or "). + let bytes = s.as_bytes(); + if bytes.len() >= 2 + && ((bytes[0] == b'"' && bytes[bytes.len() - 1] == b'"') + || (bytes[0] == b'\'' && bytes[bytes.len() - 1] == b'\'')) + { + s = &s[1..s.len() - 1]; + } + + // Trailing period. + let s = s.trim(); + let s = s.strip_suffix('.').unwrap_or(s); + + // Leading filler — take the first matching prefix, then uppercase the new head. + let mut out: String = s.to_string(); + for prefix in LEADING_FILLERS { + if out + .get(..prefix.len()) + .is_some_and(|slice| slice.eq_ignore_ascii_case(prefix)) + { + out = out[prefix.len()..].to_string(); + out = uppercase_first(&out); + break; + } + } + + // Byte-safe cap to MAX_CHARS chars. + if let Some((byte_idx, _)) = out.char_indices().nth(MAX_CHARS) { + out.truncate(byte_idx); + } + + out.trim().to_string() +} + +fn uppercase_first(s: &str) -> String { + let mut chars = s.chars(); + match chars.next() { + Some(c) => c.to_uppercase().collect::() + chars.as_str(), + None => String::new(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalize_passes_clean_input() { + assert_eq!(normalize("HOCM mit Septum-Hypertrophie"), "HOCM mit Septum-Hypertrophie"); + } + + #[test] + fn normalize_strips_quotes_and_trailing_dot_and_extra_lines() { + assert_eq!( + normalize("\"Kniegelenk re., V.a. Meniskus.\"\nSonstiges"), + "Kniegelenk re., V.a. Meniskus" + ); + } + + #[test] + fn normalize_strips_leading_filler_table() { + let cases: &[(&str, &str)] = &[ + ("Fall: Thorax-CT", "Thorax-CT"), + ("Diktat zu Schulter rechts", "Schulter rechts"), + ("Zusammenfassung: HOCM", "HOCM"), + ("Der Patient mit Hüftfraktur links", "Mit Hüftfraktur links"), + ("patient mit HOCM", "Mit HOCM"), // case-insensitive + ]; + for (input, expected) in cases { + assert_eq!(normalize(input), *expected, "input={input}"); + } + } + + #[test] + fn normalize_leaves_va_prefix_alone() { + assert_eq!(normalize("V.a. Meniskusriss"), "V.a. Meniskusriss"); + } + + #[test] + fn normalize_caps_at_120_chars_unicode_safe() { + // 150 × 'ä' → 300 bytes but 150 chars; cap must not split a codepoint. + let input: String = "ä".repeat(150); + let out = normalize(&input); + assert_eq!(out.chars().count(), 120); + // Round-trip: must still be valid UTF-8 (would have panicked otherwise). + assert!(out.starts_with('ä')); + } + + #[test] + fn normalize_returns_empty_for_blank_input() { + assert_eq!(normalize(" \n "), ""); + } +}