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;
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
pub mod ffmpeg;
|
pub mod ffmpeg;
|
||||||
|
pub mod ollama;
|
||||||
pub mod recovery;
|
pub mod recovery;
|
||||||
pub mod whisper;
|
pub mod whisper;
|
||||||
pub mod worker;
|
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