From 6f088347ce11cdd9046228847e1d1ffb8ccf45d2 Mon Sep 17 00:00:00 2001 From: iven Date: Tue, 19 May 2026 10:45:32 +0800 Subject: [PATCH] =?UTF-8?q?feat(ai):=20Agent=20=E5=88=86=E6=9E=90=20Tool?= =?UTF-8?q?=20=E2=80=94=20AnalyzeLabReport=20+=20AnalyzeHealthTrends?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AnalyzeLabReportTool: 获取化验报告详细指标(异常标记+参考范围) - AnalyzeHealthTrendsTool: 趋势分析(回归方向/日变化/异常检测) - 沙箱: MedicalStaff 专属分析 Tool,Patient 不可用 --- crates/erp-ai/src/agent/sandbox.rs | 2 + .../src/agent/tools/analyze_health_trends.rs | 181 ++++++++++++++++++ .../src/agent/tools/analyze_lab_report.rs | 122 ++++++++++++ crates/erp-ai/src/agent/tools/mod.rs | 4 + crates/erp-ai/src/handler/chat_handler.rs | 4 + 5 files changed, 313 insertions(+) create mode 100644 crates/erp-ai/src/agent/tools/analyze_health_trends.rs create mode 100644 crates/erp-ai/src/agent/tools/analyze_lab_report.rs diff --git a/crates/erp-ai/src/agent/sandbox.rs b/crates/erp-ai/src/agent/sandbox.rs index b554721..6c604de 100644 --- a/crates/erp-ai/src/agent/sandbox.rs +++ b/crates/erp-ai/src/agent/sandbox.rs @@ -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 { diff --git a/crates/erp-ai/src/agent/tools/analyze_health_trends.rs b/crates/erp-ai/src/agent/tools/analyze_health_trends.rs new file mode 100644 index 0000000..aa42f8c --- /dev/null +++ b/crates/erp-ai/src/agent/tools/analyze_health_trends.rs @@ -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 = 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 = 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); + } +} diff --git a/crates/erp-ai/src/agent/tools/analyze_lab_report.rs b/crates/erp-ai/src/agent/tools/analyze_lab_report.rs new file mode 100644 index 0000000..aa209d9 --- /dev/null +++ b/crates/erp-ai/src/agent/tools/analyze_lab_report.rs @@ -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")) + ); + } +} diff --git a/crates/erp-ai/src/agent/tools/mod.rs b/crates/erp-ai/src/agent/tools/mod.rs index 2e8b6fa..3a6248a 100644 --- a/crates/erp-ai/src/agent/tools/mod.rs +++ b/crates/erp-ai/src/agent/tools/mod.rs @@ -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; diff --git a/crates/erp-ai/src/handler/chat_handler.rs b/crates/erp-ai/src/handler/chat_handler.rs index dbf6cf2..59d1824 100644 --- a/crates/erp-ai/src/handler/chat_handler.rs +++ b/crates/erp-ai/src/handler/chat_handler.rs @@ -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);