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:
iven
2026-05-19 10:56:09 +08:00
parent 6f088347ce
commit 8b59f2d7d9
5 changed files with 259 additions and 0 deletions

View File

@@ -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,

View File

@@ -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 {

View 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");
}
}

View File

@@ -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;

View File

@@ -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!(