Refactor gazetteer annotation format

The gazetteer now annotates text with `Canonical [?original]`. This
prioritizes the corrected term for the LLM while keeping the original
transcription as a fallback.

This change aligns the gazetteer's annotation strategy with the LLM's
prompt, which expects corrections to be marked for review. Previously,
the format was `original [?Canonical]`, which could lead to the LLM
using the potentially incorrect original term.
This commit is contained in:
2026-04-16 20:16:42 +02:00
parent 32f6557d85
commit bacbcb90fe
3 changed files with 30 additions and 19 deletions
+1 -1
View File
@@ -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
+25 -14
View File
@@ -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:?}"
);
}
+4 -4
View File
@@ -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}",
);