Compare commits
4 Commits
6f088347ce
...
b03ea47fed
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b03ea47fed | ||
|
|
bcff978ea0 | ||
|
|
8064db3475 | ||
|
|
8b59f2d7d9 |
@@ -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,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
180
apps/web/src/components/ai/RichMessage.tsx
Normal file
180
apps/web/src/components/ai/RichMessage.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 作为首次对话的开场工具,获取全局概览后再深入。
|
||||
如果同时有多个相关工具可用,选择信息量最大的那个,避免冗余调用。
|
||||
|
||||
## 策略不是互斥的,你可以在一轮对话中自然切换。
|
||||
## 永远不要:推荐具体药物、给出明确诊断、替代医生建议。
|
||||
## 如果没有可用的工具数据,就基于常识回答,并建议用户咨询医生。"#
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
})))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user