feat(ai): 本地临床规则引擎 — AI 不可用时的回退方案
- LocalRulesEngine: 预定义 8 条临床规则(收缩压/心率/血糖/血氧) - CompareOp: GreaterThan/LessThan 比较运算 - evaluate(): 输入指标 JSON,输出 StructuredSuggestion 列表(按优先级排序) - 5 个单元测试覆盖:高值触发、正常无建议、缺失指标跳过、SpO2 低、优先级排序
This commit is contained in:
192
crates/erp-ai/src/service/local_rules.rs
Normal file
192
crates/erp-ai/src/service/local_rules.rs
Normal file
@@ -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<LocalRule>,
|
||||
}
|
||||
|
||||
impl LocalRulesEngine {
|
||||
pub fn new(rules: Vec<LocalRule>) -> 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<StructuredSuggestion> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user