feat(ai): Day 3 — GetHealthInsightsTool + 配额前置检查 + Token 预算限制
- 新增 GetHealthInsightsTool:聚合档案摘要+化验异常+体征异常,输出 InsightCard - 注册到 Patient/MedicalStaff 沙箱(10 个 Tool 全部就位) - chat_handler 添加 QuotaService 配额前置检查(月度 Token/患者日限额) - AgentRunParams 新增 token_budget 字段,Orchestrator 每轮累计检查超预算强制结束
This commit is contained in:
@@ -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<u32>,
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
211
crates/erp-ai/src/agent/tools/get_health_insights.rs
Normal file
211
crates/erp-ai/src/agent/tools/get_health_insights.rs
Normal file
@@ -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<String> = 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<String> = 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<String> = vec!["a".into()];
|
||||
let severity = if items.len() >= 3 {
|
||||
"high"
|
||||
} else if !items.is_empty() {
|
||||
"medium"
|
||||
} else {
|
||||
"low"
|
||||
};
|
||||
assert_eq!(severity, "medium");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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!(
|
||||
|
||||
Reference in New Issue
Block a user