feat: Mode C pipeline end-to-end (lexical + tags + fusion)
Implement Pipeline::load/suggest for Mode C: lexical search per segment, cross-segment dedup via fusion, tag derivation, filter application. Add meta_lookup fallback (exact → strip trailing 0 → 3-char root) to bridge the ICD code granularity gap between the corpus (E11.90-style 6-char codes) and ClaML (which only stores E11, E11.0-style entries).
This commit is contained in:
+128
-1
@@ -1 +1,128 @@
|
|||||||
// implemented in a later task
|
use crate::claml;
|
||||||
|
use crate::corpus::{self};
|
||||||
|
use crate::fusion::cross_segment_dedupe;
|
||||||
|
use crate::lexical::LexicalIndex;
|
||||||
|
use crate::model::*;
|
||||||
|
use crate::segment::split_segments;
|
||||||
|
use crate::tags::tags_for;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
pub struct Pipeline {
|
||||||
|
pub entries: Vec<AlphaIdEntry>,
|
||||||
|
pub meta: HashMap<String, IcdMeta>,
|
||||||
|
pub lexical: LexicalIndex,
|
||||||
|
pub pool_size: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Pipeline {
|
||||||
|
pub fn load(cfg: &Config) -> Result<Self, AppError> {
|
||||||
|
let entries = corpus::load(&cfg.alpha_id_path)?;
|
||||||
|
let meta = claml::load(&cfg.claml_path)?;
|
||||||
|
let lexical = LexicalIndex::build_in_ram(&entries)?;
|
||||||
|
Ok(Self { entries, meta, lexical, pool_size: cfg.pool_size })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Look up ICD metadata with fallback: exact code → strip trailing '0' after
|
||||||
|
/// the decimal (e.g. "E11.90" → "E11.9") → 3-char root (e.g. "E11").
|
||||||
|
/// The corpus uses 5- and 6-char codes; ClaML uses 3-, 5-, and 6-char codes.
|
||||||
|
fn meta_lookup<'a>(&'a self, code: &str) -> Option<&'a IcdMeta> {
|
||||||
|
if let Some(m) = self.meta.get(code) {
|
||||||
|
return Some(m);
|
||||||
|
}
|
||||||
|
// Try stripping a trailing '0' from the last position after the dot
|
||||||
|
// e.g. "E11.90" → "E11.9", "I10.90" → "I10.9", "G40.00" → "G40.0"
|
||||||
|
if code.len() >= 5 {
|
||||||
|
if let Some(dot_pos) = code.find('.') {
|
||||||
|
let after_dot = &code[dot_pos + 1..];
|
||||||
|
if after_dot.ends_with('0') && after_dot.len() > 1 {
|
||||||
|
let trimmed = &code[..code.len() - 1];
|
||||||
|
if let Some(m) = self.meta.get(trimmed) {
|
||||||
|
return Some(m);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback to 3-char root (e.g. "E11")
|
||||||
|
if code.len() > 3 {
|
||||||
|
if let Some(m) = self.meta.get(&code[..3]) {
|
||||||
|
return Some(m);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_suggestion(&self, code: &str, phrase: &str, score: f32,
|
||||||
|
segs: Vec<usize>, alpha_valid: bool) -> Option<Suggestion> {
|
||||||
|
let m = self.meta_lookup(code)?;
|
||||||
|
Some(Suggestion {
|
||||||
|
icd_code: code.to_string(),
|
||||||
|
description: m.description.clone(),
|
||||||
|
score,
|
||||||
|
matched_phrase: phrase.to_string(),
|
||||||
|
source_segments: segs,
|
||||||
|
tags: tags_for(m, alpha_valid),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn suggest(&self, dictation: &str, mode: Mode, filter: &Filter, top_k: usize)
|
||||||
|
-> SuggestResult {
|
||||||
|
let t0 = Instant::now();
|
||||||
|
let segments = split_segments(dictation);
|
||||||
|
let mut candidates: Vec<Candidate> = Vec::new();
|
||||||
|
|
||||||
|
for (si, seg) in segments.iter().enumerate() {
|
||||||
|
let lex = self.lexical.search(seg, self.pool_size).unwrap_or_default();
|
||||||
|
for (entry_idx, score) in lex {
|
||||||
|
let e = &self.entries[entry_idx];
|
||||||
|
if let Some(code) = corpus::primary_code(e) {
|
||||||
|
candidates.push(Candidate {
|
||||||
|
icd_code: code.to_string(),
|
||||||
|
alpha_text: e.text.clone(),
|
||||||
|
segment_idx: si,
|
||||||
|
lexical: Some(score),
|
||||||
|
semantic: None,
|
||||||
|
rerank: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let valid_by_code: HashMap<String, bool> = {
|
||||||
|
let mut m = HashMap::new();
|
||||||
|
for c in &candidates {
|
||||||
|
m.entry(c.icd_code.clone()).or_insert(false);
|
||||||
|
}
|
||||||
|
for e in &self.entries {
|
||||||
|
if let Some(code) = corpus::primary_code(e) {
|
||||||
|
if let Some(v) = m.get_mut(code) { *v = *v || e.valid; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m
|
||||||
|
};
|
||||||
|
|
||||||
|
let merged = cross_segment_dedupe(candidates);
|
||||||
|
let mut suggestions = Vec::new();
|
||||||
|
for (d, score) in merged {
|
||||||
|
let alpha_valid = *valid_by_code.get(&d.icd_code).unwrap_or(&false);
|
||||||
|
if let Some(s) = self.to_suggestion(&d.icd_code, &d.best_phrase, score,
|
||||||
|
d.source_segments.clone(), alpha_valid) {
|
||||||
|
if filter.accepts(&s.tags) {
|
||||||
|
suggestions.push(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if suggestions.len() >= top_k { break; }
|
||||||
|
}
|
||||||
|
|
||||||
|
SuggestResult {
|
||||||
|
suggestions,
|
||||||
|
diagnostics: Diagnostics {
|
||||||
|
mode: format!("{:?}", mode),
|
||||||
|
degraded: false,
|
||||||
|
segment_count: segments.len(),
|
||||||
|
ionos_calls: 0,
|
||||||
|
millis: t0.elapsed().as_millis(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
use alpha_id::pipeline::Pipeline;
|
||||||
|
use alpha_id::model::{Config, Filter, Mode};
|
||||||
|
|
||||||
|
fn cfg() -> Config {
|
||||||
|
Config::load("config/default.toml").unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mode_c_end_to_end_real_data() {
|
||||||
|
let p = Pipeline::load(&cfg()).unwrap();
|
||||||
|
let dictation = "Patient mit Diabetes mellitus Typ 2\n\nArterielle Hypertonie";
|
||||||
|
let res = p.suggest(dictation, Mode::C, &Filter::default(), 10);
|
||||||
|
assert!(!res.suggestions.is_empty());
|
||||||
|
assert!(res.suggestions.len() <= 10);
|
||||||
|
assert!(res.suggestions.iter().any(|s| s.icd_code.starts_with("E11")));
|
||||||
|
assert!(res.suggestions.iter().any(|s| s.icd_code.starts_with("I10")));
|
||||||
|
assert_eq!(res.diagnostics.segment_count, 2);
|
||||||
|
assert_eq!(res.diagnostics.mode, "C");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn billable_only_filter_excludes_three_digit_v_codes() {
|
||||||
|
let p = Pipeline::load(&cfg()).unwrap();
|
||||||
|
let f = Filter { billable_only: true, valid_only: true, chapters: None, exclude_exotic: false };
|
||||||
|
let res = p.suggest("Cholera", Mode::C, &f, 10);
|
||||||
|
assert!(res.suggestions.iter().all(|s| s.tags.billable && s.tags.valid));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user