Compare commits

...

2 Commits

Author SHA1 Message Date
iven
6f088347ce feat(ai): Agent 分析 Tool — AnalyzeLabReport + AnalyzeHealthTrends
- AnalyzeLabReportTool: 获取化验报告详细指标(异常标记+参考范围)
- AnalyzeHealthTrendsTool: 趋势分析(回归方向/日变化/异常检测)
- 沙箱: MedicalStaff 专属分析 Tool,Patient 不可用
2026-05-19 10:45:32 +08:00
iven
7edf1ed1d3 feat(ai): Agent Tool 扩展 — QueryPatientProfile + DisplayHint 新增 3 变体
- QueryPatientProfileTool: 查询患者档案摘要(年龄/性别/慢性病/用药/家族史)
- DisplayHint 新增 TrendChart/InsightCard/PatientProfile 变体
- 沙箱: Patient + MedicalStaff 添加 query_patient_profile
2026-05-19 10:41:29 +08:00
7 changed files with 440 additions and 0 deletions

View File

@@ -45,6 +45,7 @@ pub fn get_sandbox_config(role: &UserRole) -> SandboxConfig {
"query_patient_lab_reports".into(),
"query_patient_medications".into(),
"search_medical_knowledge".into(),
"query_patient_profile".into(),
]),
system_prompt_suffix: PATIENT_PROMPT_SUFFIX,
output_filter: OutputFilter {
@@ -61,6 +62,9 @@ pub fn get_sandbox_config(role: &UserRole) -> SandboxConfig {
"query_patient_appointments".into(),
"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

@@ -51,5 +51,19 @@ pub enum DisplayHint {
level: String,
message: String,
},
TrendChart {
metrics: Vec<String>,
period: String,
summary: String,
},
InsightCard {
title: String,
severity: String,
items: Vec<String>,
},
PatientProfile {
chronic_conditions: Vec<String>,
medication_count: usize,
},
Text,
}

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,13 +1,19 @@
// 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;
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;
pub use query_patient_profile::QueryPatientProfileTool;
pub use query_vitals::QueryPatientVitalsTool;
pub use search_medical_knowledge::SearchMedicalKnowledgeTool;

View File

@@ -0,0 +1,107 @@
use async_trait::async_trait;
use crate::agent::tool::{AgentTool, DisplayHint, ToolContext, ToolResult};
/// 查询患者档案摘要(年龄/性别/慢性病/用药/家族史)
pub struct QueryPatientProfileTool;
#[async_trait]
impl AgentTool for QueryPatientProfileTool {
fn name(&self) -> &str {
"query_patient_profile"
}
fn description(&self) -> &str {
"查询患者健康档案摘要,包括年龄组、性别、慢性疾病、当前用药、家族病史和最近体检日期。"
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {}
})
}
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,
};
}
};
match ctx
.health_provider
.get_patient_summary(ctx.tenant_id, patient_id)
.await
{
Ok(summary) => {
let mut output = String::from("患者档案摘要:\n");
output.push_str(&format!("- 年龄组: {}\n", summary.age_group));
output.push_str(&format!("- 性别: {}\n", summary.sex));
if summary.chronic_conditions.is_empty() {
output.push_str("- 慢性疾病: 无\n");
} else {
output.push_str(&format!(
"- 慢性疾病: {}\n",
summary.chronic_conditions.join("")
));
}
if summary.medications.is_empty() {
output.push_str("- 当前用药: 无\n");
} else {
output.push_str(&format!("- 当前用药: {}\n", summary.medications.join("")));
}
if summary.family_history.is_empty() {
output.push_str("- 家族病史: 无\n");
} else {
output.push_str(&format!(
"- 家族病史: {}\n",
summary.family_history.join("")
));
}
output.push_str(&format!("- 最近体检: {}\n", summary.last_checkup_date));
let display_hint = DisplayHint::PatientProfile {
chronic_conditions: summary.chronic_conditions.clone(),
medication_count: summary.medications.len(),
};
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 = QueryPatientProfileTool;
assert_eq!(tool.name(), "query_patient_profile");
}
#[test]
fn parameters_schema_is_empty_object() {
let tool = QueryPatientProfileTool;
let schema = tool.parameters_schema();
assert_eq!(schema["type"], "object");
assert!(schema["properties"].as_object().unwrap().is_empty());
}
}

View File

@@ -7,6 +7,9 @@ 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;
use crate::agent::tools::{QueryAppointmentsTool, QueryLabReportsTool, QueryMedicationsTool};
@@ -123,6 +126,9 @@ where
registry.register(std::sync::Arc::new(QueryAppointmentsTool));
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);