From 3b6f72d5c070c744d5dc2b475497afee3c53dd2a Mon Sep 17 00:00:00 2001 From: iven Date: Fri, 1 May 2026 08:08:48 +0800 Subject: [PATCH] =?UTF-8?q?feat(ai):=20=E6=9C=AC=E5=9C=B0=E4=B8=B4?= =?UTF-8?q?=E5=BA=8A=E8=A7=84=E5=88=99=E5=BC=95=E6=93=8E=20=E2=80=94=20AI?= =?UTF-8?q?=20=E4=B8=8D=E5=8F=AF=E7=94=A8=E6=97=B6=E7=9A=84=E5=9B=9E?= =?UTF-8?q?=E9=80=80=E6=96=B9=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LocalRulesEngine: 预定义 8 条临床规则(收缩压/心率/血糖/血氧) - CompareOp: GreaterThan/LessThan 比较运算 - evaluate(): 输入指标 JSON,输出 StructuredSuggestion 列表(按优先级排序) - 5 个单元测试覆盖:高值触发、正常无建议、缺失指标跳过、SpO2 低、优先级排序 --- crates/erp-ai/src/service/local_rules.rs | 192 +++++++++++++++++++++++ crates/erp-ai/src/service/mod.rs | 1 + 2 files changed, 193 insertions(+) create mode 100644 crates/erp-ai/src/service/local_rules.rs diff --git a/crates/erp-ai/src/service/local_rules.rs b/crates/erp-ai/src/service/local_rules.rs new file mode 100644 index 0000000..75d0365 --- /dev/null +++ b/crates/erp-ai/src/service/local_rules.rs @@ -0,0 +1,192 @@ +use crate::dto::suggestion::{RiskLevel, SuggestionType, StructuredSuggestion}; + +#[derive(Debug, Clone)] +pub struct LocalRule { + pub metric: String, + pub operator: CompareOp, + pub threshold: f64, + pub risk_level: RiskLevel, + pub suggestion_type: SuggestionType, + pub message_template: String, +} + +#[derive(Debug, Clone, Copy)] +pub enum CompareOp { + GreaterThan, + LessThan, +} + +pub struct LocalRulesEngine { + rules: Vec, +} + +impl LocalRulesEngine { + pub fn new(rules: Vec) -> Self { + Self { rules } + } + + pub fn default_rules() -> Self { + Self::new(vec![ + // 收缩压 + LocalRule { + metric: "systolic_bp".into(), + operator: CompareOp::GreaterThan, + threshold: 160.0, + risk_level: RiskLevel::High, + suggestion_type: SuggestionType::Alert, + message_template: "收缩压异常偏高({value}mmHg),请立即就医".into(), + }, + LocalRule { + metric: "systolic_bp".into(), + operator: CompareOp::GreaterThan, + threshold: 140.0, + risk_level: RiskLevel::Medium, + suggestion_type: SuggestionType::Followup, + message_template: "收缩压偏高({value}mmHg),建议2周内复查".into(), + }, + LocalRule { + metric: "systolic_bp".into(), + operator: CompareOp::LessThan, + threshold: 90.0, + risk_level: RiskLevel::High, + suggestion_type: SuggestionType::Alert, + message_template: "收缩压偏低({value}mmHg),请立即就医".into(), + }, + // 心率 + LocalRule { + metric: "heart_rate".into(), + operator: CompareOp::GreaterThan, + threshold: 100.0, + risk_level: RiskLevel::Medium, + suggestion_type: SuggestionType::Followup, + message_template: "心率偏快({value}bpm),建议随访".into(), + }, + LocalRule { + metric: "heart_rate".into(), + operator: CompareOp::LessThan, + threshold: 60.0, + risk_level: RiskLevel::Medium, + suggestion_type: SuggestionType::Followup, + message_template: "心率偏慢({value}bpm),建议随访".into(), + }, + // 血糖 + LocalRule { + metric: "blood_sugar".into(), + operator: CompareOp::GreaterThan, + threshold: 11.1, + risk_level: RiskLevel::High, + suggestion_type: SuggestionType::Alert, + message_template: "血糖异常偏高({value}mmol/L),请立即就医".into(), + }, + LocalRule { + metric: "blood_sugar".into(), + operator: CompareOp::LessThan, + threshold: 3.9, + risk_level: RiskLevel::High, + suggestion_type: SuggestionType::Alert, + message_template: "血糖偏低({value}mmol/L),有低血糖风险".into(), + }, + // SpO2 + LocalRule { + metric: "spo2".into(), + operator: CompareOp::LessThan, + threshold: 95.0, + risk_level: RiskLevel::High, + suggestion_type: SuggestionType::Alert, + message_template: "血氧饱和度偏低({value}%),请立即就医".into(), + }, + ]) + } + + pub fn evaluate(&self, metrics: &serde_json::Value) -> Vec { + let mut suggestions = Vec::new(); + for rule in &self.rules { + if let Some(value) = metrics.get(&rule.metric).and_then(|v| v.as_f64()) { + let triggered = match rule.operator { + CompareOp::GreaterThan => value > rule.threshold, + CompareOp::LessThan => value < rule.threshold, + }; + if triggered { + suggestions.push(StructuredSuggestion { + id: None, + suggestion_type: rule.suggestion_type, + priority: match rule.risk_level { + RiskLevel::High => 1, + RiskLevel::Medium => 2, + RiskLevel::Low => 3, + }, + timing: match rule.risk_level { + RiskLevel::High => "立即".into(), + RiskLevel::Medium => "2周内".into(), + RiskLevel::Low => "1个月内".into(), + }, + reason: rule + .message_template + .replace("{value}", &value.to_string()), + params: serde_json::json!({ + "metric": rule.metric, + "value": value, + "threshold": rule.threshold, + }), + auto_executable: rule.risk_level.is_auto_executable(), + }); + } + } + } + suggestions.sort_by_key(|s| s.priority); + suggestions + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn evaluate_systolic_bp_high() { + let rules = LocalRulesEngine::default_rules(); + let metrics = serde_json::json!({"systolic_bp": 165.0}); + let suggestions = rules.evaluate(&metrics); + assert!(!suggestions.is_empty()); + // 收缩压 > 160 触发 High 级别 Alert 规则 + assert_eq!(suggestions[0].suggestion_type, SuggestionType::Alert); + assert_eq!(suggestions[0].priority, 1); + } + + #[test] + fn evaluate_all_normal_no_suggestions() { + let rules = LocalRulesEngine::default_rules(); + let metrics = serde_json::json!({"systolic_bp": 120.0, "heart_rate": 72.0, "blood_sugar": 5.5}); + let suggestions = rules.evaluate(&metrics); + assert!(suggestions.is_empty()); + } + + #[test] + fn evaluate_missing_metric_skipped() { + let rules = LocalRulesEngine::default_rules(); + let metrics = serde_json::json!({"heart_rate": 110.0}); + let suggestions = rules.evaluate(&metrics); + assert!(suggestions + .iter() + .any(|s| s.suggestion_type == SuggestionType::Followup)); + } + + #[test] + fn evaluate_spo2_low() { + let rules = LocalRulesEngine::default_rules(); + let metrics = serde_json::json!({"spo2": 92.0}); + let suggestions = rules.evaluate(&metrics); + assert_eq!(suggestions.len(), 1); + assert_eq!(suggestions[0].suggestion_type, SuggestionType::Alert); + assert!(suggestions[0].reason.contains("92")); + } + + #[test] + fn evaluate_sorted_by_priority() { + let rules = LocalRulesEngine::default_rules(); + let metrics = serde_json::json!({"systolic_bp": 165.0, "heart_rate": 110.0}); + let suggestions = rules.evaluate(&metrics); + // High (priority 1) should come before Medium (priority 2) + assert!(suggestions[0].priority <= suggestions.last().unwrap().priority); + } +} diff --git a/crates/erp-ai/src/service/mod.rs b/crates/erp-ai/src/service/mod.rs index b7cd614..3dcf9a6 100644 --- a/crates/erp-ai/src/service/mod.rs +++ b/crates/erp-ai/src/service/mod.rs @@ -1,5 +1,6 @@ pub mod analysis; pub mod auto_analysis; +pub mod local_rules; pub mod output_parser; pub mod prompt; pub mod usage;