feat: streaming ClaML parser for ICD metadata and titles

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-18 16:25:46 +02:00
parent 2fe9656c84
commit bb42f07790
2 changed files with 170 additions and 1 deletions
+157 -1
View File
@@ -1 +1,157 @@
// implemented in a later task
use crate::model::{AppError, IcdMeta};
use quick_xml::events::Event;
use quick_xml::reader::Reader;
use std::collections::HashMap;
/// Convert a Roman numeral chapter code (I..XXII) to a zero-padded decimal string ("01".."22").
/// Returns `None` for unrecognised strings.
fn roman_to_padded(roman: &str) -> Option<String> {
let n: u32 = match roman {
"I" => 1, "II" => 2, "III" => 3, "IV" => 4, "V" => 5,
"VI" => 6, "VII" => 7, "VIII" => 8, "IX" => 9, "X" => 10,
"XI" => 11, "XII" => 12, "XIII" => 13, "XIV" => 14, "XV" => 15,
"XVI" => 16, "XVII" => 17, "XVIII" => 18, "XIX" => 19, "XX" => 20,
"XXI" => 21, "XXII" => 22,
_ => return None,
};
Some(format!("{n:02}"))
}
/// Stream-parse the ClaML XML into a map keyed by normalised ICD code.
///
/// The file is flat: `<Class kind="chapter">` elements close before their
/// associated `<Class kind="category">` siblings begin. We therefore track
/// the most-recently-seen chapter and assign it to every category that
/// follows, until the next chapter appears.
pub fn load(path: &str) -> Result<HashMap<String, IcdMeta>, AppError> {
let mut reader = Reader::from_file(path)
.map_err(|e| AppError::Io(format!("{path}: {e}")))?;
reader.config_mut().trim_text(true);
let mut map: HashMap<String, IcdMeta> = HashMap::new();
let mut buf = Vec::new();
// Active category being built; None when processing non-category Classes.
let mut cur: Option<IcdMeta> = None;
let mut cur_kind = String::new();
// The chapter we most recently entered (zero-padded Arabic, e.g. "01").
let mut current_chapter = String::new();
let mut in_preferred = false;
loop {
match reader.read_event_into(&mut buf) {
Err(e) => return Err(AppError::Parse(format!("claml: {e}"))),
Ok(Event::Eof) => break,
Ok(Event::Start(ref t)) | Ok(Event::Empty(ref t)) => {
let name = t.name();
match name.as_ref() {
b"Class" => {
let mut code = String::new();
cur_kind.clear();
for a in t.attributes().flatten() {
match a.key.as_ref() {
b"code" => code = String::from_utf8_lossy(&a.value).into_owned(),
b"kind" => cur_kind = String::from_utf8_lossy(&a.value).into_owned(),
_ => {}
}
}
if cur_kind == "chapter" {
// Update the running chapter for all subsequent categories.
if let Some(padded) = roman_to_padded(&code) {
current_chapter = padded;
}
cur = None;
} else if cur_kind == "category" {
let m = IcdMeta {
code,
chapter: current_chapter.clone(),
..IcdMeta::default()
};
cur = Some(m);
} else {
cur = None;
}
}
b"SuperClass" => {
if let Some(m) = cur.as_mut() {
for a in t.attributes().flatten() {
if a.key.as_ref() == b"code" {
let sup = String::from_utf8_lossy(&a.value);
if m.group.is_empty() {
m.group = sup.into_owned();
}
}
}
}
}
b"Meta" => {
if let Some(m) = cur.as_mut() {
let mut k = String::new();
let mut v = String::new();
for a in t.attributes().flatten() {
match a.key.as_ref() {
b"name" => k = String::from_utf8_lossy(&a.value).into_owned(),
b"value" => v = String::from_utf8_lossy(&a.value).into_owned(),
_ => {}
}
}
match k.as_str() {
"Para295" => m.para295 = v,
"Para301" => m.para301 = v,
"Exotic" => m.exotic = v == "J",
"Infectious" => m.ifsg = v == "J",
"Content" => m.content = v == "J",
// A `chapter` Meta overrides the stack-derived value if present.
"chapter" => m.chapter = v,
_ => {}
}
}
}
b"Rubric" => {
let mut rubric_kind = String::new();
for a in t.attributes().flatten() {
if a.key.as_ref() == b"kind" {
rubric_kind = String::from_utf8_lossy(&a.value).into_owned();
}
}
in_preferred = rubric_kind == "preferred";
}
_ => {}
}
}
Ok(Event::Text(txt)) => {
if in_preferred {
if let Some(m) = cur.as_mut() {
if m.description.is_empty() {
m.description = txt.unescape().unwrap_or_default().into_owned();
}
}
}
}
Ok(Event::End(ref t)) => match t.name().as_ref() {
b"Rubric" => in_preferred = false,
b"Class" => {
if let Some(m) = cur.take() {
if !m.code.is_empty() {
map.insert(m.code.clone(), m);
}
}
}
_ => {}
},
_ => {}
}
buf.clear();
}
if map.is_empty() {
return Err(AppError::Parse(format!("no ClaML categories in {path}")));
}
Ok(map)
}
+13
View File
@@ -0,0 +1,13 @@
use alpha_id::claml;
#[test]
fn parses_real_claml_meta_and_titles() {
let map = claml::load("icd-claml/Klassifikationsdateien/icd10gm2026syst_claml_20250912.xml").unwrap();
let a010 = map.get("A01.0").expect("A01.0 present");
assert_eq!(a010.para295, "P"); // billable primary
assert_eq!(a010.chapter, "01");
assert!(a010.description.contains("Typhus abdominalis"));
let a01 = map.get("A01").expect("A01 present");
assert_eq!(a01.para295, "V"); // 3-digit non-codable
assert!(map.len() > 10_000);
}