feat(ai): KDIGO 透析专用风险评分器 — Phase 1 关怀引擎 MVP 第二步
新增 DialysisRiskScorer:12 条 KDIGO 规则覆盖 Kt/V、血磷、血钾、血红蛋白、 体重增长、eGFR、白蛋白,含 KDIGO CKD G1-G5 分期。同步暴露 POST /ai/dialysis/risk-assessment 端点。76 个测试全部通过。
This commit is contained in:
@@ -487,6 +487,22 @@ where
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
// === 透析风险评估(KDIGO 规则) ===
|
||||
|
||||
pub async fn assess_dialysis_risk<S>(
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Json(body): Json<crate::service::dialysis_risk_scorer::DialysisLabInput>,
|
||||
) -> Result<Json<ApiResponse<crate::service::dialysis_risk_scorer::DialysisRiskAssessment>>, erp_core::error::AppError>
|
||||
where
|
||||
AiState: FromRef<S>,
|
||||
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(
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
356
crates/erp-ai/src/service/dialysis_risk_scorer.rs
Normal file
356
crates/erp-ai/src/service/dialysis_risk_scorer.rs
Normal file
@@ -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<f64>,
|
||||
/// 血磷 mg/dL
|
||||
pub phosphorus: Option<f64>,
|
||||
/// 血钾 mEq/L
|
||||
pub potassium: Option<f64>,
|
||||
/// 血红蛋白 g/dL
|
||||
pub hemoglobin: Option<f64>,
|
||||
/// 透析间期体重增长占干体重百分比 (%)
|
||||
pub weight_gain_pct: Option<f64>,
|
||||
/// eGFR mL/min/1.73m²
|
||||
pub egfr: Option<f64>,
|
||||
/// 白蛋白 g/dL
|
||||
pub albumin: Option<f64>,
|
||||
}
|
||||
|
||||
/// 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<KdigoStage>,
|
||||
pub risk_factors: Vec<String>,
|
||||
pub suggestions: Vec<StructuredSuggestion>,
|
||||
}
|
||||
|
||||
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<String> = 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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user