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)))
|
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 流构建辅助 ===
|
// === SSE 流构建辅助 ===
|
||||||
|
|
||||||
fn build_sse_stream(
|
fn build_sse_stream(
|
||||||
|
|||||||
@@ -227,5 +227,9 @@ impl AiModule {
|
|||||||
"/ai/suggestions/{id}/comparison",
|
"/ai/suggestions/{id}/comparison",
|
||||||
axum::routing::get(crate::handler::suggestion_handler::get_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 analysis;
|
||||||
pub mod auto_analysis;
|
pub mod auto_analysis;
|
||||||
pub mod comparison;
|
pub mod comparison;
|
||||||
|
pub mod dialysis_risk_scorer;
|
||||||
pub mod local_rules;
|
pub mod local_rules;
|
||||||
pub mod output_parser;
|
pub mod output_parser;
|
||||||
pub mod post_process;
|
pub mod post_process;
|
||||||
|
|||||||
Reference in New Issue
Block a user