feat(ai): KDIGO 透析专用风险评分器 — Phase 1 关怀引擎 MVP 第二步
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled

新增 DialysisRiskScorer:12 条 KDIGO 规则覆盖 Kt/V、血磷、血钾、血红蛋白、
体重增长、eGFR、白蛋白,含 KDIGO CKD G1-G5 分期。同步暴露
POST /ai/dialysis/risk-assessment 端点。76 个测试全部通过。
This commit is contained in:
iven
2026-05-04 18:44:22 +08:00
parent ef422f354d
commit 0a5290aee4
4 changed files with 377 additions and 0 deletions

View File

@@ -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(

View File

@@ -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),
)
}
}

View 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());
}
}

View File

@@ -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;