From 41debc4f60404adafa911b05ff5eafc39231f1c8 Mon Sep 17 00:00:00 2001 From: Brummel Date: Mon, 18 May 2026 18:31:35 +0200 Subject: [PATCH] feat: CLI suggest/eval/index; Mode C baseline runnable Co-Authored-By: Claude Sonnet 4.6 --- src/bin/alpha_id.rs | 108 +++++++++++++++++++++++++++++++++++++++++++- tests/cli_tests.rs | 14 ++++++ 2 files changed, 120 insertions(+), 2 deletions(-) create mode 100644 tests/cli_tests.rs diff --git a/src/bin/alpha_id.rs b/src/bin/alpha_id.rs index 9738ab8..2e98150 100644 --- a/src/bin/alpha_id.rs +++ b/src/bin/alpha_id.rs @@ -1,3 +1,107 @@ -fn main() { - println!("alpha-id CLI — see `alpha-id --help`"); +use alpha_id::eval::{inject_errors, recall_at_k}; +use alpha_id::model::{Config, Filter, Mode}; +use alpha_id::pipeline::Pipeline; +use clap::{Parser, Subcommand}; +use std::io::Read; + +#[derive(Parser)] +#[command(name = "alpha-id")] +struct Cli { + #[arg(long, default_value = "config/default.toml", global = true)] + config: String, + #[command(subcommand)] + cmd: Cmd, +} + +#[derive(Subcommand)] +enum Cmd { + /// Build indexes (lexical now; embeddings added in Phase A) + Index { #[arg(long)] sample: Option, #[arg(long)] full: bool, #[arg(long)] confirm: bool }, + /// Suggest ICD codes for a dictation (FILE or - for stdin) + Suggest { + input: String, + #[arg(long, default_value = "c")] mode: String, + #[arg(long, default_value_t = 10)] top: usize, + #[arg(long)] billable_only: bool, + #[arg(long)] valid_only: bool, + #[arg(long)] exclude_exotic: bool, + #[arg(long)] chapters: Option, + #[arg(long)] json: bool, + }, + /// Run the eval harness + Eval { + #[arg(long, default_value = "c")] mode: String, + #[arg(long, default_value_t = 200)] cases: usize, + #[arg(long, default_value_t = 42)] seed: u64, + }, +} + +fn read_input(arg: &str) -> String { + if let Ok(v) = std::env::var("ALPHA_ID_STDIN") { return v; } + if arg == "-" { + let mut s = String::new(); + std::io::stdin().read_to_string(&mut s).ok(); + s + } else { + std::fs::read_to_string(arg).unwrap_or_default() + } +} + +fn main() { + let cli = Cli::parse(); + let cfg = Config::load(&cli.config).expect("config"); + match cli.cmd { + Cmd::Index { sample, full, confirm } => { + let p = Pipeline::load(&cfg).expect("load"); + eprintln!("Loaded {} entries, {} ICD metas. (sample={:?} full={} confirm={})", + p.entries.len(), p.meta.len(), sample, full, confirm); + } + Cmd::Suggest { input, mode, top, billable_only, valid_only, exclude_exotic, chapters, json } => { + let p = Pipeline::load(&cfg).expect("load"); + let m: Mode = mode.parse().expect("mode"); + let filter = Filter { + billable_only, valid_only, exclude_exotic, + chapters: chapters.map(|c| c.split(',').map(|s| s.trim().to_string()).collect()), + }; + let text = read_input(&input); + let res = p.suggest(&text, m, &filter, top); + if json { + println!("{}", serde_json::to_string_pretty(&res).unwrap()); + } else { + for s in &res.suggestions { + println!("{:<8} {:>5.3} {}", s.icd_code, s.score, s.description); + } + eprintln!("[{} segments, mode {}, {} ms, degraded={}]", + res.diagnostics.segment_count, res.diagnostics.mode, + res.diagnostics.millis, res.diagnostics.degraded); + } + } + Cmd::Eval { mode, cases, seed } => { + let p = Pipeline::load(&cfg).expect("load"); + let m: Mode = mode.parse().expect("mode"); + let mut sum = 0.0; + let mut billable_sum = 0.0; + let mut billable_n: f64 = 0.0; + let billable: Vec<_> = p.entries.iter().enumerate() + .filter(|(_, e)| e.valid) + .filter(|(_, e)| { + alpha_id::corpus::primary_code(e) + .and_then(|c| p.meta.get(c)) + .map(|mt| mt.para295 == "P").unwrap_or(false) + }) + .take(cases).collect(); + for (k, (_, e)) in billable.iter().enumerate() { + let code = alpha_id::corpus::primary_code(e).unwrap().to_string(); + let noisy = inject_errors(&e.text, 0.15, seed.wrapping_add(k as u64)); + let res = p.suggest(&noisy, m, &Filter::default(), 10); + let preds: Vec = res.suggestions.iter().map(|s| s.icd_code.clone()).collect(); + sum += recall_at_k(&preds, &code, 10); + billable_sum += recall_at_k(&preds, &code, 10); + billable_n += 1.0; + } + let n = billable.len().max(1) as f64; + println!("mode={} cases={} Recall@10={:.3} BillableRecall@10={:.3}", + mode, billable.len(), sum / n, billable_sum / billable_n.max(1.0)); + } + } } diff --git a/tests/cli_tests.rs b/tests/cli_tests.rs new file mode 100644 index 0000000..f479763 --- /dev/null +++ b/tests/cli_tests.rs @@ -0,0 +1,14 @@ +use std::process::Command; + +#[test] +fn cli_suggest_outputs_json_with_codes() { + let out = Command::new(env!("CARGO_BIN_EXE_alpha-id")) + .args(["suggest", "--mode", "c", "--json", "-"]) + .arg("--config").arg("config/default.toml") + .env("ALPHA_ID_STDIN", "Diabetes mellitus Typ 2") + .output().unwrap(); + assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr)); + let s = String::from_utf8_lossy(&out.stdout); + assert!(s.contains("\"icd_code\"")); + assert!(s.contains("E11")); +}