feat: streaming ClaML parser for ICD metadata and titles
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+157
-1
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user