diff --git a/crates/erp-ai/src/agent/orchestrator.rs b/crates/erp-ai/src/agent/orchestrator.rs index 4ff88ba..7f2ca55 100644 --- a/crates/erp-ai/src/agent/orchestrator.rs +++ b/crates/erp-ai/src/agent/orchestrator.rs @@ -12,6 +12,8 @@ pub struct AgentRunParams { pub temperature: f32, pub max_tokens: u32, pub max_iterations: usize, + /// 可选:累计 Token 预算(input + output),超出后强制结束 + pub token_budget: Option, } impl Default for AgentRunParams { @@ -21,6 +23,7 @@ impl Default for AgentRunParams { temperature: 0.7, max_tokens: 2048, max_iterations: 5, + token_budget: None, } } } @@ -109,6 +112,27 @@ impl AgentOrchestrator { continue; } + // Token 预算检查:超出后强制结束 + if let Some(budget) = params.token_budget { + let total = total_input_tokens + total_output_tokens; + if total >= budget { + tracing::warn!( + total_tokens = total, + budget = budget, + iterations = iterations, + "Token budget exhausted, forcing final reply" + ); + messages.push(ChatMessage { + role: ChatMessageRole::User, + content: "(系统提示:Token 预算已用尽,请立即基于已有信息总结回复用户,不要再调用工具)" + .to_string(), + tool_calls: None, + tool_call_id: None, + }); + continue; + } + } + // 将 assistant 的 tool_calls 加入消息历史 messages.push(ChatMessage { role: ChatMessageRole::Assistant, diff --git a/crates/erp-ai/src/agent/sandbox.rs b/crates/erp-ai/src/agent/sandbox.rs index 6c604de..8ed8e9c 100644 --- a/crates/erp-ai/src/agent/sandbox.rs +++ b/crates/erp-ai/src/agent/sandbox.rs @@ -46,6 +46,7 @@ pub fn get_sandbox_config(role: &UserRole) -> SandboxConfig { "query_patient_medications".into(), "search_medical_knowledge".into(), "query_patient_profile".into(), + "get_health_insights".into(), ]), system_prompt_suffix: PATIENT_PROMPT_SUFFIX, output_filter: OutputFilter { @@ -65,6 +66,7 @@ pub fn get_sandbox_config(role: &UserRole) -> SandboxConfig { "query_patient_profile".into(), "analyze_lab_report".into(), "analyze_health_trends".into(), + "get_health_insights".into(), ]), system_prompt_suffix: MEDICAL_STAFF_PROMPT_SUFFIX, output_filter: OutputFilter { diff --git a/crates/erp-ai/src/agent/tools/get_health_insights.rs b/crates/erp-ai/src/agent/tools/get_health_insights.rs new file mode 100644 index 0000000..344ab0d --- /dev/null +++ b/crates/erp-ai/src/agent/tools/get_health_insights.rs @@ -0,0 +1,211 @@ +use async_trait::async_trait; +use erp_core::health_provider::TimeRange; + +use crate::agent::tool::{AgentTool, DisplayHint, ToolContext, ToolResult}; + +/// 聚合多源数据生成健康洞察摘要 +pub struct GetHealthInsightsTool; + +#[async_trait] +impl AgentTool for GetHealthInsightsTool { + fn name(&self) -> &str { + "get_health_insights" + } + + 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, + }; + } + }; + + let mut output = String::from("患者综合健康洞察:\n\n"); + let mut insight_items: Vec = Vec::new(); + + // 1. 患者档案摘要 + match ctx + .health_provider + .get_patient_summary(ctx.tenant_id, patient_id) + .await + { + Ok(summary) => { + output.push_str("【档案摘要】\n"); + output.push_str(&format!("- 年龄组: {}\n", summary.age_group)); + if !summary.chronic_conditions.is_empty() { + output.push_str(&format!( + "- 慢性疾病: {}\n", + summary.chronic_conditions.join("、") + )); + insight_items + .push(format!("慢性病: {}", summary.chronic_conditions.join("、"))); + } + if !summary.medications.is_empty() { + output.push_str(&format!( + "- 当前用药({} 种): {}\n", + summary.medications.len(), + summary.medications.join("、") + )); + } + output.push('\n'); + } + Err(e) => { + output.push_str(&format!("【档案摘要】加载失败: {}\n\n", e)); + } + } + + // 2. 近期化验报告异常 + match ctx + .health_provider + .get_patient_lab_reports(ctx.tenant_id, patient_id, 3) + .await + { + Ok(reports) => { + let abnormal_reports: Vec<_> = + reports.iter().filter(|r| r.abnormal_count > 0).collect(); + if !abnormal_reports.is_empty() { + output.push_str(&format!( + "【近期化验异常】({} 份报告有异常)\n", + abnormal_reports.len() + )); + for r in &abnormal_reports { + output.push_str(&format!( + "- {}({},{} 项异常,ID: {})\n", + r.report_type, r.report_date, r.abnormal_count, r.id + )); + insight_items.push(format!( + "化验异常: {} {}项", + r.report_type, r.abnormal_count + )); + } + output.push('\n'); + } else { + output.push_str("【近期化验】无异常报告\n\n"); + } + } + Err(e) => { + output.push_str(&format!("【近期化验】加载失败: {}\n\n", e)); + } + } + + // 3. 最近 7 天体征异常 + let now = chrono::Utc::now(); + let start = now - chrono::Duration::days(7); + let range = TimeRange { start, end: now }; + let metrics = vec![ + "systolic_bp_morning".into(), + "diastolic_bp_morning".into(), + "heart_rate".into(), + "blood_sugar".into(), + ]; + + match ctx + .health_provider + .get_vital_signs(ctx.tenant_id, patient_id, &metrics, &range) + .await + { + Ok(vitals) => { + let high_bp: Vec<_> = vitals + .iter() + .filter(|v| { + v.metric.contains("systolic") + && v.values.iter().any(|(_, val)| *val > 140.0) + }) + .collect(); + + let high_sugar: Vec<_> = vitals + .iter() + .filter(|v| { + v.metric.contains("blood_sugar") + && v.values.iter().any(|(_, val)| *val > 11.1) + }) + .collect(); + + if !high_bp.is_empty() || !high_sugar.is_empty() { + output.push_str("【近期体征异常】\n"); + for v in &high_bp { + let max_val = v.values.iter().map(|(_, val)| *val).fold(0.0f64, f64::max); + output + .push_str(&format!("- 收缩压最高 {} {}(近 7 天)\n", max_val, v.unit)); + insight_items.push(format!("血压偏高: 最高{}", max_val as i32)); + } + for v in &high_sugar { + let max_val = v.values.iter().map(|(_, val)| *val).fold(0.0f64, f64::max); + output.push_str(&format!("- 血糖最高 {} {}(近 7 天)\n", max_val, v.unit)); + insight_items.push(format!("血糖偏高: 最高{}", max_val as i32)); + } + output.push('\n'); + } else { + output.push_str("【近期体征】无显著异常\n\n"); + } + } + Err(e) => { + output.push_str(&format!("【近期体征】加载失败: {}\n\n", e)); + } + } + + let severity = if insight_items.len() >= 3 { + "high" + } else if !insight_items.is_empty() { + "medium" + } else { + "low" + }; + + let display_hint = DisplayHint::InsightCard { + title: "健康洞察概览".into(), + severity: severity.into(), + items: insight_items, + }; + + ToolResult { + output, + display_hint: Some(display_hint), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tool_name() { + let tool = GetHealthInsightsTool; + assert_eq!(tool.name(), "get_health_insights"); + } + + #[test] + fn severity_high() { + let items: Vec = vec!["a".into(), "b".into(), "c".into()]; + let severity = if items.len() >= 3 { "high" } else { "medium" }; + assert_eq!(severity, "high"); + } + + #[test] + fn severity_medium() { + let items: Vec = vec!["a".into()]; + let severity = if items.len() >= 3 { + "high" + } else if !items.is_empty() { + "medium" + } else { + "low" + }; + assert_eq!(severity, "medium"); + } +} diff --git a/crates/erp-ai/src/agent/tools/mod.rs b/crates/erp-ai/src/agent/tools/mod.rs index 3a6248a..2251fde 100644 --- a/crates/erp-ai/src/agent/tools/mod.rs +++ b/crates/erp-ai/src/agent/tools/mod.rs @@ -2,6 +2,7 @@ pub mod analyze_health_trends; pub mod analyze_lab_report; +pub mod get_health_insights; pub mod query_appointments; pub mod query_lab_reports; pub mod query_medications; @@ -11,6 +12,7 @@ pub mod search_medical_knowledge; pub use analyze_health_trends::AnalyzeHealthTrendsTool; pub use analyze_lab_report::AnalyzeLabReportTool; +pub use get_health_insights::GetHealthInsightsTool; 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 59d1824..0c38f19 100644 --- a/crates/erp-ai/src/handler/chat_handler.rs +++ b/crates/erp-ai/src/handler/chat_handler.rs @@ -9,6 +9,7 @@ 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::GetHealthInsightsTool; use crate::agent::tools::QueryPatientProfileTool; use crate::agent::tools::QueryPatientVitalsTool; use crate::agent::tools::SearchMedicalKnowledgeTool; @@ -75,6 +76,23 @@ where // 从 settings 表加载 AI 配置(替代硬编码) let config = config_resolver::load_ai_config(ctx.tenant_id, &ai_state.db).await; + // 配额前置检查 + if let Err(e) = ai_state + .quota + .check_quota(ctx.tenant_id, body.patient_id) + .await + { + tracing::warn!( + tenant_id = %ctx.tenant_id, + patient_id = ?body.patient_id, + error = %e, + "Quota check failed" + ); + return Err(erp_core::error::AppError::Validation( + "AI 使用配额已用尽,请稍后再试或联系管理员".into(), + )); + } + // 构建 Agent 消息历史 let mut messages = vec![]; @@ -129,6 +147,7 @@ where registry.register(std::sync::Arc::new(QueryPatientProfileTool)); registry.register(std::sync::Arc::new(AnalyzeLabReportTool)); registry.register(std::sync::Arc::new(AnalyzeHealthTrendsTool)); + registry.register(std::sync::Arc::new(GetHealthInsightsTool)); // 根据用户角色获取沙箱配置 let user_role = resolve_role(&ctx.roles); @@ -161,6 +180,7 @@ where temperature: config.agent.temperature, max_tokens: config.agent.max_tokens, max_iterations: config.agent.max_iterations, + token_budget: None, }; tracing::info!(