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