Update hunspell filter to use min token length
The `filter-hunspell-de.sh` script now uses a `MIN_LEN` variable that is synchronized with the `MIN_TOKEN_LEN` constant in `src/gazetteer/mod.rs`. This ensures that short tokens, which are prone to false positives due to collisions with common German words, are filtered out by both the gazetteer loading and the Hunspell dictionary processing. Additionally, the script's dependency instructions have been updated to be more comprehensive, listing package manager commands for Arch/CachyOS, Debian/Ubuntu, and Fedora. The `awk` command has been introduced to filter out tokens shorter than `MIN_LEN` before sorting and deduplicating. The `Gazetteer::insert` method has been updated to skip entries shorter than `MIN_TOKEN_LEN` to prevent noisy phonetic collisions. The `best_candidate` helper function has been extracted to improve readability and structure. The test cases have been updated to reflect the change in `MIN_TOKEN_LEN` from 4 to 5 and to use more relevant examples for the updated functionality, such as "Cerebrum" and "Zerebrum". The `anatomy.txt` file has been updated to reflect that anatomical terms are currently disabled. The `medications.txt` file has been significantly expanded with a curated list of German medication brand and active ingredient names.
This commit is contained in:
+108
-41
@@ -17,9 +17,12 @@ use std::path::Path;
|
||||
|
||||
use strsim::damerau_levenshtein;
|
||||
|
||||
/// Tokens below this char count are never annotated. Short tokens produce
|
||||
/// too many false positives (ACE, EKG, TAV).
|
||||
const MIN_TOKEN_LEN: usize = 4;
|
||||
/// Minimum char count for *both* gazetteer entries and transcript tokens.
|
||||
/// Calibrated against curated medication lists: 5 keeps prototypical
|
||||
/// 5-char brand names (Vomex, Tavor, Lasix, Nasic) while still blocking
|
||||
/// the 4-char deutsche Alltagswort ↔ eponym collisions that unkurated
|
||||
/// crawls produce (Bein↔Behn, nach↔Nuck).
|
||||
const MIN_TOKEN_LEN: usize = 5;
|
||||
|
||||
/// Maximum Damerau-Levenshtein distance for a candidate to qualify as
|
||||
/// a typo of a gazetteer entry. Distance 0 is excluded — the `exact`
|
||||
@@ -78,6 +81,11 @@ impl Gazetteer {
|
||||
}
|
||||
|
||||
fn insert(&mut self, entry: &str) {
|
||||
// Skip short entries — they cause noisy phonetic collisions with
|
||||
// ordinary German words. Same threshold as token matching.
|
||||
if entry.chars().count() < MIN_TOKEN_LEN {
|
||||
return;
|
||||
}
|
||||
let lower = entry.to_lowercase();
|
||||
if !self.exact.insert(lower.into_boxed_str()) {
|
||||
// Duplicate (case-insensitive) — don't add to phonetic index either.
|
||||
@@ -127,20 +135,46 @@ impl Gazetteer {
|
||||
if self.exact.contains(lower.as_str()) {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Stage 1: phonetic-indexed lookup. Fast, filters out the vast
|
||||
// majority of non-matches in O(1). Catches same-code neighbours
|
||||
// like Klexane↔Clexane, Diacepam↔Diazepam.
|
||||
let code = koelner::encode(token);
|
||||
if code.is_empty() {
|
||||
return None;
|
||||
if !code.is_empty()
|
||||
&& let Some(candidates) = self.by_phonetic.get(&code)
|
||||
&& let Some(hit) = best_candidate(&lower, candidates.iter().map(|c| c.as_ref()))
|
||||
{
|
||||
return Some(hit);
|
||||
}
|
||||
let candidates = self.by_phonetic.get(&code)?;
|
||||
candidates
|
||||
.iter()
|
||||
.map(|c| (damerau_levenshtein(&lower, &c.to_lowercase()), c))
|
||||
.filter(|(d, _)| *d >= 1 && *d <= MAX_EDIT_DISTANCE)
|
||||
.min_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.as_ref().cmp(b.1.as_ref())))
|
||||
.map(|(_, c)| c.as_ref())
|
||||
|
||||
// Stage 2: brute-force edit-distance over every entry. Catches
|
||||
// ASR errors where Whisper substitutes phonetically-distinct
|
||||
// consonants (Carvenilol↔Carvedilol: N↔D) or drops a letter
|
||||
// that shifts the Kölner code (Acoxia↔Arcoxia: missing R).
|
||||
// O(n) per unknown token; at n≈200 entries and ~50 tokens per
|
||||
// transcript the absolute cost stays well under a millisecond.
|
||||
best_candidate(
|
||||
&lower,
|
||||
self.by_phonetic.values().flatten().map(|c| c.as_ref()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Select the canonical entry from `candidates` that is closest to
|
||||
/// `lower_token` by Damerau-Levenshtein distance, within the allowed
|
||||
/// threshold. Distance 0 is rejected because that's already handled by
|
||||
/// the `exact` short-circuit. Ties break alphabetically for determinism.
|
||||
fn best_candidate<'a>(
|
||||
lower_token: &str,
|
||||
candidates: impl Iterator<Item = &'a str>,
|
||||
) -> Option<&'a str> {
|
||||
candidates
|
||||
.map(|c| (damerau_levenshtein(lower_token, &c.to_lowercase()), c))
|
||||
.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)
|
||||
}
|
||||
|
||||
/// A slice of the input text — either a word or a non-word run.
|
||||
enum Segment<'a> {
|
||||
Word(&'a str),
|
||||
@@ -191,28 +225,30 @@ mod tests {
|
||||
#[test]
|
||||
fn empty_gazetteer_is_pass_through() {
|
||||
let g = Gazetteer::empty();
|
||||
let input = "Patient nahm Womax gegen Übelkeit.";
|
||||
let input = "Patient nahm Zerebrum-Medikation.";
|
||||
assert_eq!(g.annotate(input), input);
|
||||
}
|
||||
|
||||
/// Core regression: the Womax/Vomex use case. A token that's a
|
||||
/// phonetic neighbor at edit distance ≤ 2 must get a hint appended.
|
||||
/// Core regression: a token that's a phonetic neighbor at edit
|
||||
/// distance ≤ 2 gets a hint appended. Cerebrum and Zerebrum both
|
||||
/// encode to Kölner `87176` and differ by one char — a plausible
|
||||
/// Whisper-style transcription slip.
|
||||
#[test]
|
||||
fn annotates_typo_for_phonetic_neighbor() {
|
||||
let g = from_entries(&["Vomex"]);
|
||||
let out = g.annotate("Patient nahm Womax gegen Übelkeit.");
|
||||
let g = from_entries(&["Cerebrum"]);
|
||||
let out = g.annotate("Patient mit Blutung im Zerebrum.");
|
||||
assert!(
|
||||
out.contains("Womax [?Vomex]"),
|
||||
out.contains("Zerebrum [?Cerebrum]"),
|
||||
"expected annotation, got: {out:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn noop_on_exact_match() {
|
||||
let g = from_entries(&["Vomex"]);
|
||||
let g = from_entries(&["Cerebrum"]);
|
||||
assert_eq!(
|
||||
g.annotate("Patient nahm Vomex."),
|
||||
"Patient nahm Vomex.",
|
||||
g.annotate("Patient mit Blutung im Cerebrum."),
|
||||
"Patient mit Blutung im Cerebrum.",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -220,45 +256,76 @@ mod tests {
|
||||
/// the LLM with a hint for a token that's only case-different.
|
||||
#[test]
|
||||
fn noop_on_case_only_difference() {
|
||||
let g = from_entries(&["Vomex"]);
|
||||
let g = from_entries(&["Cerebrum"]);
|
||||
assert_eq!(
|
||||
g.annotate("Patient nahm vomex."),
|
||||
"Patient nahm vomex.",
|
||||
g.annotate("Patient mit Blutung im cerebrum."),
|
||||
"Patient mit Blutung im cerebrum.",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn noop_on_unrelated_token() {
|
||||
let g = from_entries(&["Vomex"]);
|
||||
let g = from_entries(&["Cerebrum"]);
|
||||
let input = "Patient hat Husten und Fieber.";
|
||||
assert_eq!(g.annotate(input), input);
|
||||
}
|
||||
|
||||
/// Tokens shorter than MIN_TOKEN_LEN are never annotated, even if
|
||||
/// the gazetteer contains a matching short entry.
|
||||
/// Gazetteer entries shorter than MIN_TOKEN_LEN are rejected at load
|
||||
/// time. This prevents 4-char eponyms (Nuck, Behn, Bell, …) from
|
||||
/// generating noisy false-positive matches against everyday German
|
||||
/// words — the empirical reason MIN_TOKEN_LEN exists.
|
||||
#[test]
|
||||
fn noop_on_short_token() {
|
||||
let g = from_entries(&["ACE"]);
|
||||
let input = "ACE und AGE und APE";
|
||||
fn rejects_gazetteer_entries_below_min_length() {
|
||||
let g = from_entries(&["Nuck", "Bein", "Behn"]);
|
||||
assert_eq!(g.len(), 0, "short entries must be dropped");
|
||||
// Input with a matching short word passes through untouched.
|
||||
let input = "Patient klagt nach schwerem Heben.";
|
||||
assert_eq!(g.annotate(input), input);
|
||||
}
|
||||
|
||||
/// Edit-distance fallback: Whisper substitutes phonetically-distinct
|
||||
/// consonants (here N↔D in Carvenilol↔Carvedilol). The Kölner codes
|
||||
/// differ (N=6 vs D=2), so stage-1 misses — the brute-force stage
|
||||
/// must catch the distance-1 match.
|
||||
#[test]
|
||||
fn edit_distance_fallback_catches_consonant_substitution() {
|
||||
let g = from_entries(&["Carvedilol"]);
|
||||
let out = g.annotate("Patient nimmt Carvenilol.");
|
||||
assert!(
|
||||
out.contains("Carvenilol [?Carvedilol]"),
|
||||
"expected fallback annotation, got: {out:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Edit-distance fallback: Whisper drops a consonant (leading B in
|
||||
/// Berodual). Kölner codes are completely different — only the
|
||||
/// linear edit-distance scan saves the match.
|
||||
#[test]
|
||||
fn edit_distance_fallback_catches_dropped_consonant() {
|
||||
let g = from_entries(&["Berodual"]);
|
||||
let out = g.annotate("Patient bekommt Erodual.");
|
||||
assert!(
|
||||
out.contains("Erodual [?Berodual]"),
|
||||
"expected fallback annotation, got: {out:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// With two phonetically-colliding candidates, the one with smaller
|
||||
/// Damerau-Levenshtein distance wins. Womax↔Wamax is 1,
|
||||
/// Womax↔Vomex is 2 → Wamax is chosen.
|
||||
/// Damerau-Levenshtein distance wins. Zerebrum↔Cerebrum = 1,
|
||||
/// Zerebrum↔Cerebrom = 2 → Cerebrum is chosen.
|
||||
#[test]
|
||||
fn picks_closest_candidate_by_edit_distance() {
|
||||
let g = from_entries(&["Vomex", "Wamax"]);
|
||||
let out = g.annotate("Patient nahm Womax.");
|
||||
let g = from_entries(&["Cerebrum", "Cerebrom"]);
|
||||
let out = g.annotate("Patient mit Blutung im Zerebrum.");
|
||||
assert!(
|
||||
out.contains("Womax [?Wamax]"),
|
||||
"expected Wamax as closer candidate, got: {out:?}"
|
||||
out.contains("Zerebrum [?Cerebrum]"),
|
||||
"expected Cerebrum as closer candidate, got: {out:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_whitespace_and_punctuation() {
|
||||
let g = from_entries(&["Zebra"]);
|
||||
let g = from_entries(&["Cerebrum"]);
|
||||
let input = "Line 1.\n\nLine 2: foo-bar; baz!";
|
||||
assert_eq!(g.annotate(input), input);
|
||||
}
|
||||
@@ -266,7 +333,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn load_dir_reads_all_txt_files() {
|
||||
let dir = tempdir().unwrap();
|
||||
tokio::fs::write(dir.path().join("meds.txt"), "Vomex\nAspirin\n")
|
||||
tokio::fs::write(dir.path().join("meds.txt"), "Aspirin\nIbuprofen\n")
|
||||
.await
|
||||
.unwrap();
|
||||
tokio::fs::write(dir.path().join("anatomy.txt"), "Coracoideus\n")
|
||||
@@ -281,7 +348,7 @@ mod tests {
|
||||
let dir = tempdir().unwrap();
|
||||
tokio::fs::write(
|
||||
dir.path().join("v.txt"),
|
||||
"# medications\nVomex\n\n \n# another\nAspirin\n",
|
||||
"# medications\nAspirin\n\n \n# another\nIbuprofen\n",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -292,10 +359,10 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn load_dir_ignores_non_txt_files() {
|
||||
let dir = tempdir().unwrap();
|
||||
tokio::fs::write(dir.path().join("v.txt"), "Vomex\n")
|
||||
tokio::fs::write(dir.path().join("v.txt"), "Aspirin\n")
|
||||
.await
|
||||
.unwrap();
|
||||
tokio::fs::write(dir.path().join("readme.md"), "Aspirin\n")
|
||||
tokio::fs::write(dir.path().join("readme.md"), "Ibuprofen\n")
|
||||
.await
|
||||
.unwrap();
|
||||
let g = Gazetteer::load_dir(dir.path()).await.unwrap();
|
||||
|
||||
Reference in New Issue
Block a user