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.
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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<Message<'a>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct Options {
|
||||
temperature: f32,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct Message<'a> {
|
||||
role: &'a str,
|
||||
content: &'a str,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ChatResponse {
|
||||
message: Option<ResponseMessage>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ResponseMessage {
|
||||
content: Option<String>,
|
||||
}
|
||||
|
||||
/// 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<String, OllamaError> {
|
||||
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::<String>() + 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 "), "");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user