Compare commits

...

4 Commits

Author SHA1 Message Date
iven
b03ea47fed test(ai): Day 5.3 — 补充 5 个老 Tool 单元测试
- query_vitals: tool_name + schema_has_days_param
- query_lab_reports: tool_name
- query_appointments: tool_name
- query_medications: tool_name
- search_medical_knowledge: tool_name + schema_requires_query
2026-05-19 11:15:22 +08:00
iven
bcff978ea0 feat(ai): Day 5 — ChatResponse display_hints + Web RichMessage 渲染
- ChatResponse 增加 display_hints 字段,Orchestrator 收集 Tool 产生的 DisplayHint
- DisplayHint 实现 utoipa::ToSchema
- Web ChatResponse 类型同步,DisplayHint 8 种联合类型
- RichMessage 组件:InsightCard/RiskAlert/LabReportCard/TrendChart/PatientProfile/VitalCard
- AiSidebar 消息中渲染 display_hints 富消息
- 小程序 AiChatResponse 类型同步
2026-05-19 11:10:07 +08:00
iven
8064db3475 feat(ai): Day 4 — 策略 Prompt 优化 + Tool 调用日志
- System Prompt 增加 10 个 Tool 的使用时机指引,Agent 自动选择最合适的 Tool
- 优先使用 get_health_insights 作为首次对话开场工具
- AgentRunResult 新增 tool_calls: Vec<ToolCallLog>,记录每次调用名称/耗时/成功状态
- ToolCallLog 将在 Phase 2 写入 ai_tool_call_logs 表
2026-05-19 11:01:03 +08:00
iven
8b59f2d7d9 feat(ai): Day 3 — GetHealthInsightsTool + 配额前置检查 + Token 预算限制
- 新增 GetHealthInsightsTool:聚合档案摘要+化验异常+体征异常,输出 InsightCard
- 注册到 Patient/MedicalStaff 沙箱(10 个 Tool 全部就位)
- chat_handler 添加 QuotaService 配额前置检查(月度 Token/患者日限额)
- AgentRunParams 新增 token_budget 字段,Orchestrator 每轮累计检查超预算强制结束
2026-05-19 10:56:09 +08:00
16 changed files with 637 additions and 12 deletions

View File

@@ -10,8 +10,19 @@ export interface AiChatMessage {
export interface AiChatResponse {
reply: string;
message_id: string;
display_hints?: DisplayHint[];
}
export type DisplayHint =
| { type: 'vital_card'; indicator_type: string; values: [string, number][]; unit: string }
| { type: 'lab_report_card'; report_date: string; abnormal_count: number }
| { type: 'action_confirm'; action_type: string; summary: string; confirm_payload: unknown }
| { type: 'risk_alert'; level: string; message: string }
| { type: 'trend_chart'; metrics: string[]; period: string; summary: string }
| { type: 'insight_card'; title: string; severity: string; items: string[] }
| { type: 'patient_profile'; chronic_conditions: string[]; medication_count: number }
| { type: 'text' };
/** 发送消息给 AI 客服 */
export async function sendAiMessage(
message: string,

View File

@@ -5,10 +5,53 @@ export interface ChatHistoryItem {
content: string;
}
export type DisplayHint =
| {
type: 'vital_card';
indicator_type: string;
values: [string, number][];
unit: string;
}
| {
type: 'lab_report_card';
report_date: string;
abnormal_count: number;
}
| {
type: 'action_confirm';
action_type: string;
summary: string;
confirm_payload: unknown;
}
| {
type: 'risk_alert';
level: string;
message: string;
}
| {
type: 'trend_chart';
metrics: string[];
period: string;
summary: string;
}
| {
type: 'insight_card';
title: string;
severity: string;
items: string[];
}
| {
type: 'patient_profile';
chronic_conditions: string[];
medication_count: number;
}
| { type: 'text' };
export interface ChatResponse {
reply: string;
message_id: string;
iterations: number;
display_hints?: DisplayHint[];
}
export const aiChatApi = {

View File

@@ -18,9 +18,10 @@ import {
SafetyCertificateOutlined,
} from '@ant-design/icons';
import { useLocation } from 'react-router-dom';
import { aiChatApi, type ChatHistoryItem } from '../../api/ai/chat';
import { aiChatApi, type ChatHistoryItem, type DisplayHint } from '../../api/ai/chat';
import { analysisApi, type HealthSummaryResponse } from '../../api/ai/analysis';
import { useAuthStore } from '../../stores/auth';
import RichMessage from './RichMessage';
const { Text } = Typography;
const { TextArea } = Input;
@@ -29,6 +30,7 @@ interface ChatMessage {
id: string;
role: 'user' | 'assistant';
content: string;
displayHints?: DisplayHint[];
}
function extractPatientId(pathname: string): string | null {
@@ -146,8 +148,9 @@ export default function AiSidebar({
...prev,
{
id: resp.message_id,
role: 'assistant',
role: 'assistant' as const,
content: resp.reply,
displayHints: resp.display_hints,
},
]);
} catch {
@@ -333,6 +336,9 @@ export default function AiSidebar({
}}
>
{msg.content}
{msg.displayHints && msg.displayHints.length > 0 && (
<RichMessage hints={msg.displayHints} />
)}
</div>
</div>
))}

View File

@@ -0,0 +1,180 @@
import { Card, Tag, Typography, theme } from 'antd';
import {
WarningOutlined,
HeartOutlined,
LineChartOutlined,
ExperimentOutlined,
UserOutlined,
} from '@ant-design/icons';
import type { DisplayHint } from '../../api/ai/chat';
const { Text } = Typography;
const SEVERITY_COLOR: Record<string, string> = {
high: 'red',
medium: 'orange',
low: 'green',
};
const RISK_LEVEL_COLOR: Record<string, string> = {
critical: '#cf1322',
high: 'red',
medium: 'orange',
low: 'green',
};
export default function RichMessage({ hints }: { hints: DisplayHint[] }) {
const { token } = theme.useToken();
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginTop: 8 }}>
{hints.map((hint, i) => (
<RichHint key={i} hint={hint} token={token} />
))}
</div>
);
}
function RichHint({ hint, token }: { hint: DisplayHint; token: { colorBorderSecondary: string; colorTextSecondary: string; colorPrimary: string } }) {
switch (hint.type) {
case 'insight_card':
return (
<Card
size="small"
title={
<span style={{ fontSize: 12 }}>
<HeartOutlined style={{ marginRight: 4, color: token.colorPrimary }} />
{hint.title}
</span>
}
styles={{ body: { padding: '6px 12px' } }}
>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{hint.items.map((item, j) => (
<Tag key={j} color={SEVERITY_COLOR[hint.severity] ?? 'blue'} style={{ fontSize: 11, margin: 0 }}>
{item}
</Tag>
))}
</div>
</Card>
);
case 'risk_alert':
return (
<div
style={{
padding: '8px 12px',
borderRadius: 8,
border: `1px solid ${RISK_LEVEL_COLOR[hint.level] ?? token.colorBorderSecondary}`,
background: `${RISK_LEVEL_COLOR[hint.level] ?? token.colorBorderSecondary}10`,
fontSize: 13,
}}
>
<WarningOutlined style={{ color: RISK_LEVEL_COLOR[hint.level] ?? token.colorPrimary, marginRight: 6 }} />
{hint.message}
</div>
);
case 'lab_report_card':
return (
<Card
size="small"
title={
<span style={{ fontSize: 12 }}>
<ExperimentOutlined style={{ marginRight: 4 }} />
{hint.report_date}
</span>
}
styles={{ body: { padding: '6px 12px', fontSize: 12 } }}
>
{hint.abnormal_count > 0 ? (
<Tag color="red" style={{ fontSize: 11 }}>
{hint.abnormal_count}
</Tag>
) : (
<Tag color="green" style={{ fontSize: 11 }}></Tag>
)}
</Card>
);
case 'trend_chart':
return (
<Card
size="small"
title={
<span style={{ fontSize: 12 }}>
<LineChartOutlined style={{ marginRight: 4 }} />
{hint.period}
</span>
}
styles={{ body: { padding: '6px 12px', fontSize: 12 } }}
>
<div style={{ marginBottom: 4 }}>
{hint.metrics.map((m, j) => (
<Tag key={j} style={{ fontSize: 11, margin: '0 4px 2px 0' }}>{m}</Tag>
))}
</div>
<Text type="secondary" style={{ fontSize: 11 }}>{hint.summary}</Text>
</Card>
);
case 'patient_profile':
return (
<Card
size="small"
title={
<span style={{ fontSize: 12 }}>
<UserOutlined style={{ marginRight: 4 }} />
</span>
}
styles={{ body: { padding: '6px 12px', fontSize: 12 } }}
>
{hint.chronic_conditions.length > 0 && (
<div style={{ marginBottom: 4 }}>
{hint.chronic_conditions.map((c, j) => (
<Tag key={j} color="orange" style={{ fontSize: 11, margin: '0 4px 2px 0' }}>{c}</Tag>
))}
</div>
)}
{hint.medication_count > 0 && (
<Text type="secondary" style={{ fontSize: 11 }}> {hint.medication_count} </Text>
)}
</Card>
);
case 'vital_card':
return (
<Card
size="small"
title={
<span style={{ fontSize: 12 }}>
<HeartOutlined style={{ marginRight: 4, color: token.colorPrimary }} />
</span>
}
styles={{ body: { padding: '6px 12px', fontSize: 12 } }}
>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{hint.values.slice(-5).map(([date, val], j) => (
<span key={j}>
<Text type="secondary" style={{ fontSize: 11 }}>{date.slice(5)}:</Text>{' '}
{val} {hint.unit}
</span>
))}
</div>
</Card>
);
case 'action_confirm':
return (
<Card size="small" styles={{ body: { padding: '8px 12px', fontSize: 13 } }}>
<Text>{hint.summary}</Text>
</Card>
);
case 'text':
default:
return null;
}
}

View File

@@ -6,12 +6,22 @@ use crate::dto::{ChatMessage, ChatMessageRole};
use crate::error::AiResult;
use crate::provider::AiProvider;
/// 单次 Tool 调用日志
#[derive(Debug, Clone)]
pub struct ToolCallLog {
pub tool_name: String,
pub duration_ms: u64,
pub success: bool,
}
/// Agent 运行时参数
pub struct AgentRunParams {
pub model: String,
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 +31,7 @@ impl Default for AgentRunParams {
temperature: 0.7,
max_tokens: 2048,
max_iterations: 5,
token_budget: None,
}
}
}
@@ -37,6 +48,8 @@ pub struct AgentRunResult {
pub total_input_tokens: u32,
pub total_output_tokens: u32,
pub iterations: usize,
pub tool_calls: Vec<ToolCallLog>,
pub display_hints: Vec<super::tool::DisplayHint>,
}
impl AgentOrchestrator {
@@ -63,6 +76,8 @@ impl AgentOrchestrator {
let mut iterations = 0;
let mut total_input_tokens = 0u32;
let mut total_output_tokens = 0u32;
let mut tool_call_logs: Vec<ToolCallLog> = Vec::new();
let mut display_hints: Vec<super::tool::DisplayHint> = Vec::new();
loop {
iterations += 1;
@@ -93,6 +108,8 @@ impl AgentOrchestrator {
total_input_tokens,
total_output_tokens,
iterations,
tool_calls: tool_call_logs,
display_hints,
});
}
};
@@ -109,6 +126,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,
@@ -119,23 +157,38 @@ impl AgentOrchestrator {
// 执行每个 Tool Call受沙箱 allowed_tools 约束)
for tc in &tool_calls {
let tool_result = match self.tool_registry.get(&tc.name) {
let start = std::time::Instant::now();
let (tool_result, success, hint) = match self.tool_registry.get(&tc.name) {
Some(tool) => {
// 沙箱过滤:如果 allowed_tools 存在且不包含此 Tool拒绝执行
if let Some(allowed) = allowed_tools {
if !allowed.contains(tc.name.as_str()) {
format!("Tool '{}' 在当前角色下不可用", tc.name)
(
format!("Tool '{}' 在当前角色下不可用", tc.name),
false,
None,
)
} else {
let result = tool.execute(ctx, tc.arguments.clone()).await;
result.output
(result.output, true, result.display_hint)
}
} else {
let result = tool.execute(ctx, tc.arguments.clone()).await;
result.output
(result.output, true, result.display_hint)
}
}
None => format!("未知 Tool: {}", tc.name),
None => (format!("未知 Tool: {}", tc.name), false, None),
};
let duration = start.elapsed();
tool_call_logs.push(ToolCallLog {
tool_name: tc.name.clone(),
duration_ms: duration.as_millis() as u64,
success,
});
if let Some(h) = hint {
display_hints.push(h);
}
messages.push(ChatMessage {
role: ChatMessageRole::Tool,

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

@@ -30,7 +30,7 @@ pub struct ToolResult {
}
/// 前端渲染提示 — 告诉前端如何富化展示 Tool 返回的数据
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum DisplayHint {
VitalCard {

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

@@ -66,3 +66,14 @@ impl AgentTool for QueryAppointmentsTool {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tool_name() {
let tool = QueryAppointmentsTool;
assert_eq!(tool.name(), "query_patient_appointments");
}
}

View File

@@ -83,3 +83,14 @@ impl AgentTool for QueryLabReportsTool {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tool_name() {
let tool = QueryLabReportsTool;
assert_eq!(tool.name(), "query_patient_lab_reports");
}
}

View File

@@ -63,3 +63,14 @@ impl AgentTool for QueryMedicationsTool {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tool_name() {
let tool = QueryMedicationsTool;
assert_eq!(tool.name(), "query_patient_medications");
}
}

View File

@@ -94,3 +94,21 @@ impl AgentTool for QueryPatientVitalsTool {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tool_name() {
let tool = QueryPatientVitalsTool;
assert_eq!(tool.name(), "query_patient_vitals");
}
#[test]
fn schema_has_days_param() {
let tool = QueryPatientVitalsTool;
let schema = tool.parameters_schema();
assert!(schema["properties"]["days"].is_object());
}
}

View File

@@ -110,3 +110,26 @@ impl AgentTool for SearchMedicalKnowledgeTool {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tool_name() {
let tool = SearchMedicalKnowledgeTool;
assert_eq!(tool.name(), "search_medical_knowledge");
}
#[test]
fn schema_requires_query() {
let tool = SearchMedicalKnowledgeTool;
let schema = tool.parameters_schema();
assert!(
schema["required"]
.as_array()
.unwrap()
.contains(&serde_json::json!("query"))
);
}
}

View File

@@ -574,16 +574,16 @@ fn default_system_prompt() -> String {
- 分享积极案例,降低恐惧感
2. 【医疗科普】当用户询问指标含义、疾病知识时:
- 调用 search_medical_knowledge 获取准确信息(如可用)
- 调用 search_medical_knowledge 获取准确信息
- 用比喻和类比让老年患者也能理解
- 强调"具体请以医生诊断为准"
3. 【服务推荐】当用户表达就医需求或身体不适时:
- 调用 query_appointments 查看已有预约(如可用)
- 调用 query_patient_appointments 查看已有预约
- 主动提出帮用户预约
4. 【风险预警】当用户描述的症状或数据异常时:
- 调用 query_patient_vitals 查看体征数据
- 调用 get_health_insights 获取综合健康洞察
- 明确告知风险等级和需要注意的事项
- 高风险时建议尽快就医
@@ -591,6 +591,22 @@ fn default_system_prompt() -> String {
- 提供科室位置、出诊医生信息
- 建议用户联系前台预约
## 工具使用指引
根据用户意图选择合适的工具,不要一次调用所有工具:
- 用户首次对话或询问总体健康 → get_health_insights综合洞察
- 询问"我的血压/血糖怎么样" → query_patient_vitals体征数据
- 询问"化验结果/报告" → query_patient_lab_reports化验报告列表
- 拿到具体报告 ID 后追问详情 → analyze_lab_report单份报告详细指标
- 询问"趋势/最近变化" → analyze_health_trends趋势分析
- 询问"吃什么药" → query_patient_medications用药列表
- 询问"预约/挂号" → query_patient_appointments预约列表
- 询问疾病/指标知识 → search_medical_knowledge医学知识搜索
- 询问"我的档案/基本信息" → query_patient_profile患者档案
优先使用 get_health_insights 作为首次对话的开场工具,获取全局概览后再深入。
如果同时有多个相关工具可用,选择信息量最大的那个,避免冗余调用。
## 策略不是互斥的,你可以在一轮对话中自然切换。
## 永远不要:推荐具体药物、给出明确诊断、替代医生建议。
## 如果没有可用的工具数据,就基于常识回答,并建议用户咨询医生。"#

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;
@@ -39,6 +40,8 @@ pub struct ChatResponse {
pub reply: String,
pub message_id: String,
pub iterations: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub display_hints: Option<Vec<crate::agent::tool::DisplayHint>>,
}
#[utoipa::path(
@@ -75,6 +78,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 +149,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 +182,7 @@ where
temperature: config.agent.temperature,
max_tokens: config.agent.max_tokens,
max_iterations: config.agent.max_iterations,
token_budget: None,
};
tracing::info!(
@@ -232,5 +254,10 @@ where
reply: result.reply,
message_id,
iterations: result.iterations,
display_hints: if result.display_hints.is_empty() {
None
} else {
Some(result.display_hints)
},
})))
}