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:
2026-04-13 17:27:23 +02:00
parent 1e3cc9574c
commit 47829b953c
2 changed files with 234 additions and 0 deletions
+1
View File
@@ -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;
+233
View File
@@ -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 "), "");
}
}