fix(analyze): fold document output to ISO 8859-15 for Medical Office paste
German practice software (INDAMED MEDICAL OFFICE) stores free text in ISO 8859-15, an 8-bit code. Pasting our cleaned document there rendered typographic Unicode the LLM emits — en-/em-dashes, curly quotes, ellipses, fraction slashes — as replacement boxes, while umlauts, the micro sign, guillemets and the euro sign (all within 8859-15) pasted fine. The KBV xDT data standard confirms ISO 8859-15 as the canonical charset for German practice systems. Add `analyze::charset::fold_to_iso8859_15`, applied in the worker right after the gazetteer and before `content_md` is persisted: representable chars pass through verbatim, the residual is transliterated via the `deunicode` table (en-dash -> '-', ellipsis -> '...', '1/2' -> "1/2"), and unmapped chars are left untouched rather than dropped. Because representable chars never reach deunicode, its ASCII-only nature does not flatten umlauts or the micro sign. refs #13
This commit is contained in:
Generated
+7
@@ -319,6 +319,12 @@ dependencies = [
|
|||||||
"powerfmt",
|
"powerfmt",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "deunicode"
|
||||||
|
version = "1.6.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "displaydoc"
|
name = "displaydoc"
|
||||||
version = "0.2.5"
|
version = "0.2.5"
|
||||||
@@ -347,6 +353,7 @@ dependencies = [
|
|||||||
"axum",
|
"axum",
|
||||||
"axum-extra",
|
"axum-extra",
|
||||||
"bcrypt",
|
"bcrypt",
|
||||||
|
"deunicode",
|
||||||
"doctate-common",
|
"doctate-common",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
"filetime",
|
"filetime",
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ pulldown-cmark = { version = "0.13", default-features = false, features = ["html
|
|||||||
strsim = "0.11"
|
strsim = "0.11"
|
||||||
spellbook = "0.4.0"
|
spellbook = "0.4.0"
|
||||||
subtle = "2"
|
subtle = "2"
|
||||||
|
deunicode = "1.6.2"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tower = { version = "0.5", features = ["util"] }
|
tower = { version = "0.5", features = ["util"] }
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
//! Fold LLM output to the ISO 8859-15 representable subset.
|
||||||
|
//!
|
||||||
|
//! German practice-management systems (e.g. INDAMED MEDICAL OFFICE) store
|
||||||
|
//! free text in ISO 8859-15, an 8-bit code. When a doctor copies our cleaned
|
||||||
|
//! document and pastes it there, any codepoint outside that set renders as a
|
||||||
|
//! replacement box (□). The LLM, primed by typographic punctuation in the
|
||||||
|
//! prompt templates, emits en-/em-dashes, curly quotes, ellipses, fraction
|
||||||
|
//! slashes etc. — all absent from ISO 8859-15 — so those box out, while
|
||||||
|
//! umlauts, the micro sign `µ`, guillemets `« »` and the euro sign `€` (all
|
||||||
|
//! *within* 8859-15) paste cleanly.
|
||||||
|
//!
|
||||||
|
//! Strategy: keep every char already representable in 8859-15 verbatim, and
|
||||||
|
//! route only the residual through `deunicode`'s mature ASCII transliteration
|
||||||
|
//! table (`– → -`, `… → ...`, `½ → 1/2`, …). Because representable chars never
|
||||||
|
//! reach `deunicode`, its ASCII-only nature is harmless here — `ä`/`µ`/`€` are
|
||||||
|
//! kept, not flattened. Chars `deunicode` cannot map are left untouched (never
|
||||||
|
//! silently dropped); MO may box them, which is acceptable per design.
|
||||||
|
|
||||||
|
use deunicode::deunicode_char;
|
||||||
|
|
||||||
|
/// True when `c` can be encoded in ISO 8859-15.
|
||||||
|
///
|
||||||
|
/// ISO 8859-15 is Latin-1 (ISO 8859-1) with eight byte positions reassigned:
|
||||||
|
/// `¤ ¦ ¨ ´ ¸ ¼ ½ ¾` were dropped in favour of `€ Š š Ž ž Œ œ Ÿ`. The
|
||||||
|
/// representable set is therefore printable ASCII, plus the Latin-1 high range
|
||||||
|
/// minus those eight, plus the eight replacements. Structural whitespace
|
||||||
|
/// (`\n \r \t`) is treated as representable so the markdown body survives.
|
||||||
|
pub(crate) fn is_iso8859_15(c: char) -> bool {
|
||||||
|
if matches!(c, '\n' | '\r' | '\t') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
match c as u32 {
|
||||||
|
// Printable ASCII.
|
||||||
|
0x20..=0x7E => true,
|
||||||
|
// Latin-1 high range, minus the eight positions 8859-15 reassigned.
|
||||||
|
0xA0..=0xFF => !matches!(
|
||||||
|
c,
|
||||||
|
'\u{A4}' | '\u{A6}' | '\u{A8}' | '\u{B4}' | '\u{B8}' | '\u{BC}' | '\u{BD}' | '\u{BE}'
|
||||||
|
),
|
||||||
|
// The eight characters 8859-15 added on top of Latin-1.
|
||||||
|
_ => matches!(c, '€' | 'Š' | 'š' | 'Ž' | 'ž' | 'Œ' | 'œ' | 'Ÿ'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rewrite `s` so every character is representable in ISO 8859-15 where a
|
||||||
|
/// sensible equivalent exists; characters with no known mapping pass through
|
||||||
|
/// unchanged.
|
||||||
|
pub(crate) fn fold_to_iso8859_15(s: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(s.len());
|
||||||
|
for c in s.chars() {
|
||||||
|
if is_iso8859_15(c) {
|
||||||
|
// Already representable — keep verbatim (ä, µ, «», €, …).
|
||||||
|
out.push(c);
|
||||||
|
} else if let Some(ascii) = deunicode_char(c) {
|
||||||
|
// Residual: fold via the mature transliteration table.
|
||||||
|
out.push_str(ascii);
|
||||||
|
} else {
|
||||||
|
// No known mapping — leave it rather than drop content; MO may
|
||||||
|
// box it, which is acceptable.
|
||||||
|
out.push(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
// Test vectors below necessarily contain German and typographic
|
||||||
|
// characters: this module is about exactly those codepoints.
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn membership_matches_iso8859_15() {
|
||||||
|
// Representable: ASCII, umlauts, micro sign, guillemets, euro, etc.
|
||||||
|
for c in [
|
||||||
|
'a', 'Z', '9', ' ', 'ä', 'ö', 'ü', 'Ä', 'Ö', 'Ü', 'ß', 'µ', '«', '»', '€', '°', '²',
|
||||||
|
'³', '§', 'Œ', 'š',
|
||||||
|
] {
|
||||||
|
assert!(is_iso8859_15(c), "{c:?} should be representable in 8859-15");
|
||||||
|
}
|
||||||
|
// Not representable: typographic punctuation + the Latin-1 chars 8859-15 dropped.
|
||||||
|
for c in [
|
||||||
|
'\u{2013}', '\u{2014}', '\u{201E}', '\u{201D}', '\u{2018}', '\u{2026}', '\u{2044}',
|
||||||
|
'\u{2022}', '\u{2032}', '\u{00BD}', '\u{00BC}', '\u{00BE}', '\u{00A4}',
|
||||||
|
] {
|
||||||
|
assert!(!is_iso8859_15(c), "{c:?} is NOT in 8859-15");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn representable_text_passes_through_unchanged() {
|
||||||
|
// Every char here is in 8859-15, so folding must be the identity.
|
||||||
|
let keep = "Müller über 5 µg «Wert», 37 °C, m², 20 €";
|
||||||
|
assert_eq!(fold_to_iso8859_15(keep), keep);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn typographic_offenders_become_representable() {
|
||||||
|
// en-dash, em-dash, low/high curly quotes, ellipsis, ½, fraction slash.
|
||||||
|
let input = "3\u{2013}4 Tabletten \u{2014} \u{201E}morgens\u{201D} \u{2026} \u{00BD} Dosis \u{2044} Tag";
|
||||||
|
let out = fold_to_iso8859_15(input);
|
||||||
|
assert!(
|
||||||
|
out.chars().all(is_iso8859_15),
|
||||||
|
"residual char not in 8859-15: {out:?}"
|
||||||
|
);
|
||||||
|
for bad in [
|
||||||
|
'\u{2013}', '\u{2014}', '\u{201E}', '\u{201D}', '\u{2026}', '\u{00BD}', '\u{2044}',
|
||||||
|
] {
|
||||||
|
assert!(!out.contains(bad), "offender {bad:?} survived in {out:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn en_dash_folds_to_hyphen() {
|
||||||
|
// The headline issue #13 case: en-dash between numbers.
|
||||||
|
assert_eq!(fold_to_iso8859_15("3\u{2013}4"), "3-4");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ use tokio::sync::mpsc;
|
|||||||
|
|
||||||
pub mod auto_trigger;
|
pub mod auto_trigger;
|
||||||
pub mod backend;
|
pub mod backend;
|
||||||
|
pub mod charset;
|
||||||
pub mod in_flight;
|
pub mod in_flight;
|
||||||
pub mod llm;
|
pub mod llm;
|
||||||
pub mod prompt;
|
pub mod prompt;
|
||||||
|
|||||||
@@ -140,6 +140,13 @@ async fn process(
|
|||||||
// `Amiodarone` back to `Amiodaron`).
|
// `Amiodarone` back to `Amiodaron`).
|
||||||
let document = vocab.replace(&document);
|
let document = vocab.replace(&document);
|
||||||
|
|
||||||
|
// Charset normalization: fold typographic Unicode the LLM emits (en-/em-
|
||||||
|
// dashes, curly quotes, ellipses, fraction slashes) down to the ISO
|
||||||
|
// 8859-15 subset that German practice software (e.g. MEDICAL OFFICE) can
|
||||||
|
// render. Without this, a copy-paste from the case page produces
|
||||||
|
// replacement boxes for those codepoints. See `analyze::charset`.
|
||||||
|
let document = super::charset::fold_to_iso8859_15(&document);
|
||||||
|
|
||||||
// Silent-deletion guard: schema-aware backends can return formally valid
|
// Silent-deletion guard: schema-aware backends can return formally valid
|
||||||
// but empty envelopes (`{"document": ""}`) when the prompt is incompatible
|
// but empty envelopes (`{"document": ""}`) when the prompt is incompatible
|
||||||
// with the format constraint. Without this guard the worker would persist
|
// with the format constraint. Without this guard the worker would persist
|
||||||
|
|||||||
Reference in New Issue
Block a user