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 analysis;
|
||||||
pub mod auto_analysis;
|
pub mod auto_analysis;
|
||||||
|
pub mod local_rules;
|
||||||
pub mod output_parser;
|
pub mod output_parser;
|
||||||
pub mod prompt;
|
pub mod prompt;
|
||||||
pub mod usage;
|
pub mod usage;
|
||||||
|
|||||||
Reference in New Issue
Block a user