feat(ai): Agent 分析 Tool — AnalyzeLabReport + AnalyzeHealthTrends

- AnalyzeLabReportTool: 获取化验报告详细指标(异常标记+参考范围)
- AnalyzeHealthTrendsTool: 趋势分析(回归方向/日变化/异常检测)
- 沙箱: MedicalStaff 专属分析 Tool,Patient 不可用
This commit is contained in:
iven
2026-05-19 10:45:32 +08:00
parent 7edf1ed1d3
commit 6f088347ce
5 changed files with 313 additions and 0 deletions

View File

@@ -63,6 +63,8 @@ pub fn get_sandbox_config(role: &UserRole) -> SandboxConfig {
"query_patient_medications".into(),
"search_medical_knowledge".into(),
"query_patient_profile".into(),
"analyze_lab_report".into(),
"analyze_health_trends".into(),
]),
system_prompt_suffix: MEDICAL_STAFF_PROMPT_SUFFIX,
output_filter: OutputFilter {

View File

@@ -0,0 +1,181 @@
use async_trait::async_trait;
use erp_core::health_provider::TimeRange;
use crate::agent::tool::{AgentTool, DisplayHint, ToolContext, ToolResult};
/// 获取健康趋势分析数据(回归方向/日变化/异常点Agent 自行解读
pub struct AnalyzeHealthTrendsTool;
#[async_trait]
impl AgentTool for AnalyzeHealthTrendsTool {
fn name(&self) -> &str {
"analyze_health_trends"
}
fn description(&self) -> &str {
"获取患者健康指标的趋势分析数据,包括线性回归方向、每日变化量和异常检测。可指定天数范围和关注的指标类型。"
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"days": {
"type": "integer",
"description": "分析最近多少天的数据,默认 30 天"
},
"metrics": {
"type": "array",
"items": { "type": "string" },
"description": "可选:关注的指标列表,如 ['systolic_bp_morning', 'blood_sugar']"
}
}
})
}
async fn execute(&self, ctx: &ToolContext, params: serde_json::Value) -> ToolResult {
let patient_id = match ctx.patient_id {
Some(id) => id,
None => {
return ToolResult {
output: "未关联患者档案,无法分析健康趋势".to_string(),
display_hint: None,
};
}
};
let days = params["days"].as_i64().unwrap_or(30);
let metrics: Vec<String> = params["metrics"]
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_else(|| {
vec![
"systolic_bp_morning".into(),
"diastolic_bp_morning".into(),
"heart_rate".into(),
"blood_sugar".into(),
]
});
let now = chrono::Utc::now();
let start = now - chrono::Duration::days(days);
let range = TimeRange { start, end: now };
match ctx
.health_provider
.get_trend_analysis_data(ctx.tenant_id, patient_id, &metrics, &range)
.await
{
Ok(trend) => {
let mut output = format!(
"健康趋势分析({}{},共 {} 个指标):\n\n",
trend.period_start,
trend.period_end,
trend.metrics.len()
);
let mut metric_names = Vec::new();
let mut has_anomaly = false;
for m in &trend.metrics {
metric_names.push(m.metric.clone());
output.push_str(&format!(
"{}】({}{} 个数据点)\n",
m.metric, m.unit, m.data_point_count
));
if let Some(ref reg) = m.regression {
let direction = match reg.direction {
erp_core::health_provider::TrendDirection::Rising => "上升",
erp_core::health_provider::TrendDirection::Falling => "下降",
erp_core::health_provider::TrendDirection::Stable => "稳定",
};
output.push_str(&format!(
" 趋势: {}(日变化: {:.4},期间变化: {:.2}R²: {:.3}\n",
direction, reg.daily_change, reg.period_change, reg.r_squared
));
} else {
output.push_str(" 趋势: 数据不足,无法计算\n");
}
if !m.anomalies.is_empty() {
has_anomaly = true;
output.push_str(&format!(" ⚠ 异常值({} 个):\n", m.anomalies.len()));
for a in m.anomalies.iter().take(5) {
output.push_str(&format!(
" - {}:值 {:.1}(均值 {:.1}±{:.1},偏差 {:.1}σ\n",
a.date, a.value, a.mean, a.std_dev, a.deviation
));
}
}
output.push('\n');
}
let summary = if has_anomaly {
format!(
"发现 {} 个指标存在异常值",
trend
.metrics
.iter()
.filter(|m| !m.anomalies.is_empty())
.count()
)
} else {
"所有指标趋势正常".to_string()
};
let display_hint = DisplayHint::TrendChart {
metrics: metric_names,
period: format!("{}天", days),
summary: summary.clone(),
};
ToolResult {
output,
display_hint: Some(display_hint),
}
}
Err(e) => ToolResult {
output: format!("获取趋势分析数据失败: {}", e),
display_hint: None,
},
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tool_name() {
let tool = AnalyzeHealthTrendsTool;
assert_eq!(tool.name(), "analyze_health_trends");
}
#[test]
fn default_metrics() {
let params = serde_json::json!({});
let metrics: Vec<String> = params["metrics"]
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_else(|| {
vec![
"systolic_bp_morning".into(),
"diastolic_bp_morning".into(),
"heart_rate".into(),
"blood_sugar".into(),
]
});
assert_eq!(metrics.len(), 4);
}
}

View File

@@ -0,0 +1,122 @@
use async_trait::async_trait;
use crate::agent::tool::{AgentTool, DisplayHint, ToolContext, ToolResult};
/// 获取化验报告详细指标数据(名称/值/参考范围/异常标记Agent 自行解读
pub struct AnalyzeLabReportTool;
#[async_trait]
impl AgentTool for AnalyzeLabReportTool {
fn name(&self) -> &str {
"analyze_lab_report"
}
fn description(&self) -> &str {
"获取指定化验报告的详细指标数据,包括每个指标的名称、检测值、单位、参考范围和是否异常。需要提供报告 ID。"
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"required": ["report_id"],
"properties": {
"report_id": {
"type": "string",
"description": "化验报告 ID"
}
}
})
}
async fn execute(&self, ctx: &ToolContext, params: serde_json::Value) -> ToolResult {
let report_id = match params["report_id"]
.as_str()
.and_then(|s| uuid::Uuid::parse_str(s).ok())
{
Some(id) => id,
None => {
return ToolResult {
output: "请提供有效的化验报告 ID".to_string(),
display_hint: None,
};
}
};
match ctx
.health_provider
.get_lab_report(ctx.tenant_id, report_id)
.await
{
Ok(report) => {
let mut output = format!(
"化验报告详情({}{}{}\n\n",
report.department, report.report_date, report.sex
);
let abnormal_items: Vec<&erp_core::health_provider::LabItemDto> =
report.items.iter().filter(|i| i.is_abnormal).collect();
if !abnormal_items.is_empty() {
output.push_str(&format!("⚠ 异常指标({} 项):\n", abnormal_items.len()));
for item in &abnormal_items {
output.push_str(&format!(
" - {}{} {}(参考范围: {}\n",
item.name, item.value, item.unit, item.reference_range
));
}
output.push('\n');
}
output.push_str("所有检测指标:\n");
for item in &report.items {
let flag = if item.is_abnormal { "" } else { "" };
output.push_str(&format!(
"- {}{} {}(参考: {}{}\n",
item.name, item.value, item.unit, item.reference_range, flag
));
}
let display_hint = if !abnormal_items.is_empty() {
Some(DisplayHint::LabReportCard {
report_date: report.report_date.clone(),
abnormal_count: abnormal_items.len(),
})
} else {
None
};
ToolResult {
output,
display_hint,
}
}
Err(e) => ToolResult {
output: format!("获取化验报告失败: {}", e),
display_hint: None,
},
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tool_name() {
let tool = AnalyzeLabReportTool;
assert_eq!(tool.name(), "analyze_lab_report");
}
#[test]
fn parameters_require_report_id() {
let tool = AnalyzeLabReportTool;
let schema = tool.parameters_schema();
assert!(
schema["required"]
.as_array()
.unwrap()
.contains(&serde_json::json!("report_id"))
);
}
}

View File

@@ -1,5 +1,7 @@
// Agent Tool 实现
pub mod analyze_health_trends;
pub mod analyze_lab_report;
pub mod query_appointments;
pub mod query_lab_reports;
pub mod query_medications;
@@ -7,6 +9,8 @@ pub mod query_patient_profile;
pub mod query_vitals;
pub mod search_medical_knowledge;
pub use analyze_health_trends::AnalyzeHealthTrendsTool;
pub use analyze_lab_report::AnalyzeLabReportTool;
pub use query_appointments::QueryAppointmentsTool;
pub use query_lab_reports::QueryLabReportsTool;
pub use query_medications::QueryMedicationsTool;

View File

@@ -7,6 +7,8 @@ use serde::{Deserialize, Serialize};
use crate::agent::orchestrator::AgentRunParams;
use crate::agent::sandbox::{get_sandbox_config, resolve_role};
use crate::agent::tool::ToolContext;
use crate::agent::tools::AnalyzeHealthTrendsTool;
use crate::agent::tools::AnalyzeLabReportTool;
use crate::agent::tools::QueryPatientProfileTool;
use crate::agent::tools::QueryPatientVitalsTool;
use crate::agent::tools::SearchMedicalKnowledgeTool;
@@ -125,6 +127,8 @@ where
registry.register(std::sync::Arc::new(QueryMedicationsTool));
registry.register(std::sync::Arc::new(SearchMedicalKnowledgeTool));
registry.register(std::sync::Arc::new(QueryPatientProfileTool));
registry.register(std::sync::Arc::new(AnalyzeLabReportTool));
registry.register(std::sync::Arc::new(AnalyzeHealthTrendsTool));
// 根据用户角色获取沙箱配置
let user_role = resolve_role(&ctx.roles);