From 0a5290aee4f051bd9afa6a14d37b74634ef84699 Mon Sep 17 00:00:00 2001 From: iven Date: Mon, 4 May 2026 18:44:22 +0800 Subject: [PATCH] =?UTF-8?q?feat(ai):=20KDIGO=20=E9=80=8F=E6=9E=90=E4=B8=93?= =?UTF-8?q?=E7=94=A8=E9=A3=8E=E9=99=A9=E8=AF=84=E5=88=86=E5=99=A8=20?= =?UTF-8?q?=E2=80=94=20Phase=201=20=E5=85=B3=E6=80=80=E5=BC=95=E6=93=8E=20?= =?UTF-8?q?MVP=20=E7=AC=AC=E4=BA=8C=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 DialysisRiskScorer:12 条 KDIGO 规则覆盖 Kt/V、血磷、血钾、血红蛋白、 体重增长、eGFR、白蛋白,含 KDIGO CKD G1-G5 分期。同步暴露 POST /ai/dialysis/risk-assessment 端点。76 个测试全部通过。 --- crates/erp-ai/src/handler/mod.rs | 16 + crates/erp-ai/src/module.rs | 4 + .../src/service/dialysis_risk_scorer.rs | 356 ++++++++++++++++++ crates/erp-ai/src/service/mod.rs | 1 + 4 files changed, 377 insertions(+) create mode 100644 crates/erp-ai/src/service/dialysis_risk_scorer.rs diff --git a/crates/erp-ai/src/handler/mod.rs b/crates/erp-ai/src/handler/mod.rs index 3721cd2..c07aa5d 100644 --- a/crates/erp-ai/src/handler/mod.rs +++ b/crates/erp-ai/src/handler/mod.rs @@ -487,6 +487,22 @@ where Ok(Json(ApiResponse::ok(result))) } +// === 透析风险评估(KDIGO 规则) === + +pub async fn assess_dialysis_risk( + Extension(ctx): Extension, + Json(body): Json, +) -> Result>, erp_core::error::AppError> +where + AiState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "ai.analysis.manage")?; + let scorer = crate::service::dialysis_risk_scorer::DialysisRiskScorer::new(); + let result = scorer.assess(&body); + Ok(Json(ApiResponse::ok(result))) +} + // === SSE 流构建辅助 === fn build_sse_stream( diff --git a/crates/erp-ai/src/module.rs b/crates/erp-ai/src/module.rs index 84d7c1f..f129514 100644 --- a/crates/erp-ai/src/module.rs +++ b/crates/erp-ai/src/module.rs @@ -227,5 +227,9 @@ impl AiModule { "/ai/suggestions/{id}/comparison", axum::routing::get(crate::handler::suggestion_handler::get_comparison), ) + .route( + "/ai/dialysis/risk-assessment", + axum::routing::post(crate::handler::assess_dialysis_risk), + ) } } diff --git a/crates/erp-ai/src/service/dialysis_risk_scorer.rs b/crates/erp-ai/src/service/dialysis_risk_scorer.rs new file mode 100644 index 0000000..d965592 --- /dev/null +++ b/crates/erp-ai/src/service/dialysis_risk_scorer.rs @@ -0,0 +1,356 @@ +use crate::dto::suggestion::{RiskLevel, SuggestionType, StructuredSuggestion}; +use crate::service::local_rules::{CompareOp, LocalRule, LocalRulesEngine}; + +/// 透析患者实验室指标输入 +#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] +pub struct DialysisLabInput { + /// Kt/V(透析充分性指标) + pub kt_v: Option, + /// 血磷 mg/dL + pub phosphorus: Option, + /// 血钾 mEq/L + pub potassium: Option, + /// 血红蛋白 g/dL + pub hemoglobin: Option, + /// 透析间期体重增长占干体重百分比 (%) + pub weight_gain_pct: Option, + /// eGFR mL/min/1.73m² + pub egfr: Option, + /// 白蛋白 g/dL + pub albumin: Option, +} + +/// KDIGO CKD 分期 +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum KdigoStage { + G1, + G2, + G3a, + G3b, + G4, + G5, +} + +impl KdigoStage { + pub fn from_egfr(egfr: f64) -> Self { + if egfr >= 90.0 { + Self::G1 + } else if egfr >= 60.0 { + Self::G2 + } else if egfr >= 45.0 { + Self::G3a + } else if egfr >= 30.0 { + Self::G3b + } else if egfr >= 15.0 { + Self::G4 + } else { + Self::G5 + } + } + + pub fn label(&self) -> &str { + match self { + Self::G1 => "G1 (≥90)", + Self::G2 => "G2 (60-89)", + Self::G3a => "G3a (45-59)", + Self::G3b => "G3b (30-44)", + Self::G4 => "G4 (15-29)", + Self::G5 => "G5 (<15)", + } + } +} + +/// 透析风险评估结果 +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct DialysisRiskAssessment { + pub overall_risk: RiskLevel, + pub kdigo_stage: Option, + pub risk_factors: Vec, + pub suggestions: Vec, +} + +pub struct DialysisRiskScorer { + engine: LocalRulesEngine, +} + +impl DialysisRiskScorer { + pub fn new() -> Self { + let rules = vec![ + // Kt/V < 1.2:透析不充分 + LocalRule { + metric: "kt_v".into(), + operator: CompareOp::LessThan, + threshold: 1.2, + risk_level: RiskLevel::Medium, + suggestion_type: SuggestionType::Appointment, + message_template: "Kt/V={value}偏低(<1.2),透析充分性不足,建议评估处方调整" + .into(), + }, + // Kt/V < 0.9:严重不充分 + LocalRule { + metric: "kt_v".into(), + operator: CompareOp::LessThan, + threshold: 0.9, + risk_level: RiskLevel::High, + suggestion_type: SuggestionType::Alert, + message_template: "Kt/V={value}严重偏低(<0.9),透析严重不足,需立即调整处方" + .into(), + }, + // 血磷 > 5.5 mg/dL:高磷血症 + LocalRule { + metric: "phosphorus".into(), + operator: CompareOp::GreaterThan, + threshold: 5.5, + risk_level: RiskLevel::Medium, + suggestion_type: SuggestionType::Followup, + message_template: "血磷={value}mg/dL偏高(>5.5),建议加强磷结合剂和饮食控制" + .into(), + }, + // 血磷 > 7.0 mg/dL:严重高磷 + LocalRule { + metric: "phosphorus".into(), + operator: CompareOp::GreaterThan, + threshold: 7.0, + risk_level: RiskLevel::High, + suggestion_type: SuggestionType::Alert, + message_template: "血磷={value}mg/dL严重偏高(>7.0),需紧急评估钙磷代谢" + .into(), + }, + // 透前血钾 > 6.0 mEq/L:危急高钾 + LocalRule { + metric: "potassium".into(), + operator: CompareOp::GreaterThan, + threshold: 6.0, + risk_level: RiskLevel::High, + suggestion_type: SuggestionType::Alert, + message_template: "血钾={value}mEq/L危急偏高(>6.0),有心脏骤停风险,需紧急处理" + .into(), + }, + // 血钾 > 5.5 mEq/L:中度高钾 + LocalRule { + metric: "potassium".into(), + operator: CompareOp::GreaterThan, + threshold: 5.5, + risk_level: RiskLevel::Medium, + suggestion_type: SuggestionType::Followup, + message_template: "血钾={value}mEq/L偏高(>5.5),建议复查并调整饮食".into(), + }, + // 血红蛋白 < 10 g/dL:贫血 + LocalRule { + metric: "hemoglobin".into(), + operator: CompareOp::LessThan, + threshold: 10.0, + risk_level: RiskLevel::Medium, + suggestion_type: SuggestionType::Appointment, + message_template: "血红蛋白={value}g/dL偏低(<10),建议评估ESA治疗方案".into(), + }, + // 血红蛋白 < 8 g/dL:严重贫血 + LocalRule { + metric: "hemoglobin".into(), + operator: CompareOp::LessThan, + threshold: 8.0, + risk_level: RiskLevel::High, + suggestion_type: SuggestionType::Alert, + message_template: "血红蛋白={value}g/dL严重偏低(<8),需紧急评估输血或ESA治疗" + .into(), + }, + // 体重增长 > 5%:容量超负荷 + LocalRule { + metric: "weight_gain_pct".into(), + operator: CompareOp::GreaterThan, + threshold: 5.0, + risk_level: RiskLevel::High, + suggestion_type: SuggestionType::Alert, + message_template: "透析间期体重增长{value}%(>5%干体重),容量超负荷风险高" + .into(), + }, + // 体重增长 > 3.5%:需关注 + LocalRule { + metric: "weight_gain_pct".into(), + operator: CompareOp::GreaterThan, + threshold: 3.5, + risk_level: RiskLevel::Medium, + suggestion_type: SuggestionType::Followup, + message_template: "透析间期体重增长{value}%(>3.5%),建议加强水分控制".into(), + }, + // eGFR < 15:终末期 + LocalRule { + metric: "egfr".into(), + operator: CompareOp::LessThan, + threshold: 15.0, + risk_level: RiskLevel::High, + suggestion_type: SuggestionType::Appointment, + message_template: "eGFR={value}mL/min(<15),处于KDIGO G5期,需评估透析方案" + .into(), + }, + // 白蛋白 < 3.5 g/dL:营养不良 + LocalRule { + metric: "albumin".into(), + operator: CompareOp::LessThan, + threshold: 3.5, + risk_level: RiskLevel::Medium, + suggestion_type: SuggestionType::Followup, + message_template: "白蛋白={value}g/dL偏低(<3.5),建议营养评估和补充".into(), + }, + // 白蛋白 < 3.0 g/dL:严重营养不良 + LocalRule { + metric: "albumin".into(), + operator: CompareOp::LessThan, + threshold: 3.0, + risk_level: RiskLevel::High, + suggestion_type: SuggestionType::Alert, + message_template: "白蛋白={value}g/dL严重偏低(<3.0),营养不良增加死亡风险" + .into(), + }, + ]; + Self { + engine: LocalRulesEngine::new(rules), + } + } + + pub fn assess(&self, input: &DialysisLabInput) -> DialysisRiskAssessment { + let metrics = serde_json::json!({ + "kt_v": input.kt_v, + "phosphorus": input.phosphorus, + "potassium": input.potassium, + "hemoglobin": input.hemoglobin, + "weight_gain_pct": input.weight_gain_pct, + "egfr": input.egfr, + "albumin": input.albumin, + }); + + let suggestions = self.engine.evaluate(&metrics); + + let mut risk_factors: Vec = suggestions + .iter() + .map(|s| s.reason.clone()) + .collect(); + + let kdigo_stage = input.egfr.map(KdigoStage::from_egfr); + + if let Some(stage) = kdigo_stage { + if matches!(stage, KdigoStage::G4 | KdigoStage::G5) { + risk_factors.push(format!("KDIGO分期{},肾功能严重受损", stage.label())); + } + } + + let overall_risk = if suggestions.iter().any(|s| s.priority == 1) { + RiskLevel::High + } else if !suggestions.is_empty() { + RiskLevel::Medium + } else { + RiskLevel::Low + }; + + DialysisRiskAssessment { + overall_risk, + kdigo_stage, + risk_factors, + suggestions, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn assess_all_normal_returns_low() { + let scorer = DialysisRiskScorer::new(); + let input = DialysisLabInput { + kt_v: Some(1.4), + phosphorus: Some(4.0), + potassium: Some(4.5), + hemoglobin: Some(12.0), + weight_gain_pct: Some(2.0), + egfr: None, // 透析患者通常不测 eGFR + albumin: Some(4.0), + }; + let result = scorer.assess(&input); + assert_eq!(result.overall_risk, RiskLevel::Low); + assert!(result.suggestions.is_empty()); + } + + #[test] + fn assess_critical_potassium_returns_high() { + let scorer = DialysisRiskScorer::new(); + let input = DialysisLabInput { + potassium: Some(6.5), + ..Default::default() + }; + let result = scorer.assess(&input); + assert_eq!(result.overall_risk, RiskLevel::High); + assert!(result.risk_factors.iter().any(|f| f.contains("血钾"))); + assert!(result.risk_factors.iter().any(|f| f.contains("6.5"))); + } + + #[test] + fn assess_weight_gain_overload() { + let scorer = DialysisRiskScorer::new(); + let input = DialysisLabInput { + weight_gain_pct: Some(6.0), + ..Default::default() + }; + let result = scorer.assess(&input); + assert_eq!(result.overall_risk, RiskLevel::High); + assert!(result.risk_factors.iter().any(|f| f.contains("容量超负荷"))); + } + + #[test] + fn assess_ktv_insufficient() { + let scorer = DialysisRiskScorer::new(); + let input = DialysisLabInput { + kt_v: Some(1.0), + ..Default::default() + }; + let result = scorer.assess(&input); + assert_eq!(result.overall_risk, RiskLevel::Medium); + assert!(result.risk_factors.iter().any(|f| f.contains("Kt/V"))); + } + + #[test] + fn assess_kdigo_staging() { + let scorer = DialysisRiskScorer::new(); + let input = DialysisLabInput { + egfr: Some(25.0), + ..Default::default() + }; + let result = scorer.assess(&input); + assert_eq!(result.kdigo_stage, Some(KdigoStage::G4)); + assert!(result.risk_factors.iter().any(|f| f.contains("G4"))); + } + + #[test] + fn assess_multiple_risks_takes_highest() { + let scorer = DialysisRiskScorer::new(); + let input = DialysisLabInput { + phosphorus: Some(6.0), + hemoglobin: Some(7.5), + ..Default::default() + }; + let result = scorer.assess(&input); + assert_eq!(result.overall_risk, RiskLevel::High); + assert!(result.suggestions.len() >= 2); + } + + #[test] + fn kdigo_stage_classification() { + assert_eq!(KdigoStage::from_egfr(100.0), KdigoStage::G1); + assert_eq!(KdigoStage::from_egfr(75.0), KdigoStage::G2); + assert_eq!(KdigoStage::from_egfr(50.0), KdigoStage::G3a); + assert_eq!(KdigoStage::from_egfr(35.0), KdigoStage::G3b); + assert_eq!(KdigoStage::from_egfr(20.0), KdigoStage::G4); + assert_eq!(KdigoStage::from_egfr(8.0), KdigoStage::G5); + } + + #[test] + fn assess_empty_input_returns_low() { + let scorer = DialysisRiskScorer::new(); + let input = DialysisLabInput::default(); + let result = scorer.assess(&input); + assert_eq!(result.overall_risk, RiskLevel::Low); + assert!(result.suggestions.is_empty()); + assert!(result.kdigo_stage.is_none()); + } +} diff --git a/crates/erp-ai/src/service/mod.rs b/crates/erp-ai/src/service/mod.rs index e36fe35..3319347 100644 --- a/crates/erp-ai/src/service/mod.rs +++ b/crates/erp-ai/src/service/mod.rs @@ -1,6 +1,7 @@ pub mod analysis; pub mod auto_analysis; pub mod comparison; +pub mod dialysis_risk_scorer; pub mod local_rules; pub mod output_parser; pub mod post_process;