diff --git a/server/src/analyze/prompt.rs b/server/src/analyze/prompt.rs index 04b77c6..8061321 100644 --- a/server/src/analyze/prompt.rs +++ b/server/src/analyze/prompt.rs @@ -2,7 +2,7 @@ use super::AnalysisInput; /// System prompt for the consolidation LLM. From docs/projektplan.md:365-369. /// Temperature 0 is set on the request; the prompt enforces "no interpretation". -pub const SYSTEM_PROMPT: &str = "Du bist ein medizinischer Assistent. Fasse die folgenden diktierten Abschnitte zu einem zusammenhängenden Text zusammen. Bereinigen und strukturieren, nichts hinzufügen, keine medizinische Interpretation, keine Diagnose, keine Arztbrief-Struktur. Entferne Redundanzen. Spätere Aufnahmen haben Vorrang — Korrekturen, Nachträge und Widersprüche zugunsten der chronologisch letzten Aussage auflösen. Antworte auf Deutsch. Nur der zusammengefasste Text, keine Einleitung, kein Abschlusssatz.\n\nWICHTIG: Das Diktat wurde automatisch transkribiert und enthält typische ASR-Fehler — phonetisch ähnliche Verwechslungen (z.B. \"Corona\" statt \"Koronar\", \"Spolade\" statt \"Schokolade\", \"hochgradig in Faust\" statt \"hochgradig infaust\"), falsch geschriebene Medikamentennamen, zerschnittene Komposita. Korrigiere solche Fehler AKTIV: der Arzt hat das Original-Audio zur Verifikation und kann jede Korrektur gegenprüfen. Bei sicherer Korrektur: schreibe das korrigierte Wort. Bei Unsicherheit, wie die Korrektur lauten müsste: behalte das Original bei.\n\nMarkiere jede korrigierte oder zweifelhafte Stelle mit ==text== — das signalisiert dem Arzt \"bitte prüfen\". Markiere zusätzlich: unvollständige oder unsichere Angaben im Diktat (z.B. \"Dosis weiß ich nicht\", abgebrochene Sätze) sowie Zahlen ohne klaren Kontext oder Einheit. Sei sparsam — geläufige, klar gesprochene Fachbegriffe bleiben unmarkiert.\n\nDer Text kann Hinweise der Form `Wort [?Kandidat]` enthalten — das sind automatische Vorschläge aus einem Fach-Wörterbuch (Medikamente, Anatomie, Wirkstoffe). Prüfe mit dem Satzkontext: passt der Kandidat, verwende ihn und markiere mit ==. Passt er nicht, behalte das Original-Wort unverändert und ohne Markierung. Die eckige-Klammer-Annotation selbst darf niemals im Output erscheinen."; +pub const SYSTEM_PROMPT: &str = "Du bist ein medizinischer Assistent. Fasse die folgenden diktierten Abschnitte zu einem zusammenhängenden Text zusammen. Bereinigen und strukturieren, nichts hinzufügen, keine medizinische Interpretation, keine Diagnose, keine Arztbrief-Struktur. Entferne Redundanzen. Spätere Aufnahmen haben Vorrang — Korrekturen, Nachträge und Widersprüche zugunsten der chronologisch letzten Aussage auflösen. Antworte auf Deutsch. Nur der zusammengefasste Text, keine Einleitung, kein Abschlusssatz.\n\nWICHTIG: Das Diktat wurde automatisch transkribiert und enthält typische ASR-Fehler — phonetisch ähnliche Verwechslungen (z.B. \"Corona\" statt \"Koronar\", \"Spolade\" statt \"Schokolade\", \"hochgradig in Faust\" statt \"hochgradig infaust\"), falsch geschriebene Medikamentennamen, zerschnittene Komposita. Korrigiere solche Fehler AKTIV: der Arzt hat das Original-Audio zur Verifikation und kann jede Korrektur gegenprüfen. Bei sicherer Korrektur: schreibe das korrigierte Wort. Bei Unsicherheit, wie die Korrektur lauten müsste: behalte das Original bei.\n\nMarkiere jede korrigierte oder zweifelhafte Stelle mit ==text== — das signalisiert dem Arzt \"bitte prüfen\". Markiere zusätzlich: unvollständige oder unsichere Angaben im Diktat (z.B. \"Dosis weiß ich nicht\", abgebrochene Sätze) sowie Zahlen ohne klaren Kontext oder Einheit. Sei sparsam — geläufige, klar gesprochene Fachbegriffe bleiben unmarkiert.\n\nDer Text kann Annotationen der Form `Kandidat [?Original]` enthalten — das sind Korrekturen aus einem Fach-Wörterbuch. `Kandidat` ist die vorgeschlagene Korrektur, `Original` das, was Whisper ursprünglich transkribiert hat. Prüfe mit dem Satzkontext: passt der Kandidat, übernimm ihn und markiere mit ==. Passt er nicht, verwende stattdessen das Original ohne Markierung. Die eckige-Klammer-Annotation selbst darf niemals im Output erscheinen."; /// Render the user-content portion of the chat request from the persisted /// JSON input. Recordings with blank text are skipped — they carry no signal diff --git a/server/src/gazetteer/mod.rs b/server/src/gazetteer/mod.rs index c5ddea9..2db41f0 100644 --- a/server/src/gazetteer/mod.rs +++ b/server/src/gazetteer/mod.rs @@ -4,8 +4,10 @@ //! The LLM cannot know invented proper names (drug brands, uncommon //! technical terms). Whisper misrenders them, usually within a couple //! of edits of the real spelling. This module finds those tokens and -//! rewrites them as `token [?Canonical]`. The LLM decides in context -//! whether to adopt the hint. +//! rewrites them as `Canonical [?original]` — the vocabulary form +//! becomes the anchor, the original Whisper token sits in the bracket +//! as a veto option. The LLM decides in context whether to keep the +//! canonical or revert to the original. //! //! Matcher: single-stage linear Damerau-Levenshtein scan over all //! entries, filtered by distance ≤ MAX_EDIT_DISTANCE. At typical sizes @@ -91,9 +93,12 @@ impl Gazetteer { self.entries.push(entry.into()); } - /// Annotate `text` by appending ` [?Canonical]` after any word-token - /// that's within edit-distance 2 of a gazetteer entry (and not an - /// exact match). Non-word segments pass through byte-identical. + /// Annotate `text` by replacing any word-token within edit-distance + /// 2 of a gazetteer entry with `Canonical [?original]`. The + /// canonical takes the anchor position so the LLM's default action + /// is to adopt it; the original Whisper token stays available in + /// the bracket as a veto option. Exact matches and non-word + /// segments pass through unchanged. pub fn annotate(&self, text: &str) -> String { if self.is_empty() { return text.to_string(); @@ -102,10 +107,16 @@ impl Gazetteer { for seg in tokenize(text) { match seg { Segment::Word(w) => match self.find_candidate(w) { - Some(c) => { - out.push_str(w); - out.push_str(" [?"); + Some((c, d)) => { + tracing::info!( + token = w, + canonical = c, + distance = d, + "gazetteer hit" + ); out.push_str(c); + out.push_str(" [?"); + out.push_str(w); out.push(']'); } None => out.push_str(w), @@ -116,7 +127,7 @@ impl Gazetteer { out } - fn find_candidate(&self, token: &str) -> Option<&str> { + fn find_candidate(&self, token: &str) -> Option<(&str, usize)> { if token.chars().count() < MIN_TOKEN_LEN { return None; } @@ -130,7 +141,7 @@ impl Gazetteer { .map(|c| (damerau_levenshtein(&lower, &c.to_lowercase()), c.as_ref())) .filter(|(d, _)| *d >= 1 && *d <= MAX_EDIT_DISTANCE) .min_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(b.1))) - .map(|(_, c)| c) + .map(|(d, c)| (c, d)) } } @@ -191,7 +202,7 @@ mod tests { let g = from_entries(&["Cerebrum"]); let out = g.annotate("Patient mit Blutung im Zerebrum."); assert!( - out.contains("Zerebrum [?Cerebrum]"), + out.contains("Cerebrum [?Zerebrum]"), "expected annotation, got: {out:?}" ); } @@ -239,7 +250,7 @@ mod tests { let g = from_entries(&["Carvedilol"]); let out = g.annotate("Patient nimmt Carvenilol."); assert!( - out.contains("Carvenilol [?Carvedilol]"), + out.contains("Carvedilol [?Carvenilol]"), "got: {out:?}" ); } @@ -250,7 +261,7 @@ mod tests { let g = from_entries(&["Berodual"]); let out = g.annotate("Patient bekommt Erodual."); assert!( - out.contains("Erodual [?Berodual]"), + out.contains("Berodual [?Erodual]"), "got: {out:?}" ); } @@ -262,7 +273,7 @@ mod tests { let g = from_entries(&["Cerebrum", "Cerebrom"]); let out = g.annotate("Patient mit Blutung im Zerebrum."); assert!( - out.contains("Zerebrum [?Cerebrum]"), + out.contains("Cerebrum [?Zerebrum]"), "expected Cerebrum as closer candidate, got: {out:?}" ); } diff --git a/server/tests/analyze_test.rs b/server/tests/analyze_test.rs index b5c6a06..e932f5f 100644 --- a/server/tests/analyze_test.rs +++ b/server/tests/analyze_test.rs @@ -361,9 +361,9 @@ async fn analyze_worker_writes_document_via_wiremock() { } /// The analyze worker must route transcripts through the Gazetteer before -/// sending them to the LLM. "Zerebrum" shares a Kölner-Phonetik code with -/// the gazetteer entry "Cerebrum" (both encode to `87176`) and differs by -/// exactly one character — so the annotation `Zerebrum [?Cerebrum]` must +/// sending them to the LLM. "Zerebrum" differs from the gazetteer entry +/// "Cerebrum" by exactly one character — so the annotation +/// `Cerebrum [?Zerebrum]` (canonical anchor, original in bracket) must /// end up in the LLM request body. #[tokio::test] async fn analyze_worker_annotates_with_gazetteer() { @@ -432,7 +432,7 @@ async fn analyze_worker_annotates_with_gazetteer() { assert_eq!(requests.len(), 1, "expected exactly one LLM request"); let body = String::from_utf8(requests[0].body.clone()).unwrap(); assert!( - body.contains("Zerebrum [?Cerebrum]"), + body.contains("Cerebrum [?Zerebrum]"), "expected gazetteer annotation in LLM request body, got:\n{body}", );