feat(ai): 本地临床规则引擎 — AI 不可用时的回退方案

- LocalRulesEngine: 预定义 8 条临床规则(收缩压/心率/血糖/血氧)
- CompareOp: GreaterThan/LessThan 比较运算
- evaluate(): 输入指标 JSON,输出 StructuredSuggestion 列表(按优先级排序)
- 5 个单元测试覆盖:高值触发、正常无建议、缺失指标跳过、SpO2 低、优先级排序
This commit is contained in:
iven
2026-05-01 08:08:48 +08:00
parent 92e6cf0c43
commit 3b6f72d5c0
2 changed files with 193 additions and 0 deletions

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

View File

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