feat(ai): 改造 chat_handler 接入 AgentOrchestrator — ReAct Agent 首次跑通 + 新增会话权限码

This commit is contained in:
iven
2026-05-18 03:12:33 +08:00
parent f42669f934
commit aab4dfea79
2 changed files with 125 additions and 50 deletions

View File

@@ -4,7 +4,10 @@ use erp_core::rbac::require_permission;
use erp_core::types::{ApiResponse, TenantContext};
use serde::{Deserialize, Serialize};
use crate::dto::GenerateRequest;
use crate::agent::tool::ToolContext;
use crate::agent::tools::QueryPatientVitalsTool;
use crate::agent::{AgentOrchestrator, ToolRegistry};
use crate::dto::{ChatMessage, ChatMessageRole};
use crate::state::AiState;
// === 请求 / 响应 ===
@@ -13,6 +16,8 @@ use crate::state::AiState;
pub struct ChatRequest {
pub message: String,
pub history: Option<Vec<ChatHistoryItem>>,
/// 可选:关联患者 ID从用户档案中获取
pub patient_id: Option<uuid::Uuid>,
}
#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)]
@@ -25,26 +30,46 @@ pub struct ChatHistoryItem {
pub struct ChatResponse {
pub reply: String,
pub message_id: String,
pub iterations: usize,
}
const SYSTEM_PROMPT: &str = r#"你是 HMS 健康管理平台的 AI 客服助手"小华"。你的职责是:
1. 回答用户的健康咨询问题
2. 帮助用户了解体检报告指标
3. 提供预约挂号、用药提醒等服务指导
4. 推荐健康生活方式
const SYSTEM_PROMPT: &str = r#"你是 HMS 健康管理平台的 AI 健康顾问"小华"。
注意:
- 你不能替代医生的诊断,遇到需要诊断的问题请建议用户就医
- 不能推荐具体药物,只能提供一般性健康建议
- 语气要亲切、专业、耐心
- 回复要简洁明了,避免过长
- 如果用户问的问题超出健康范围,礼貌引导回到健康话题"#;
## 核心策略
根据用户表达的内容和情绪,自然地采用以下策略方向:
1. 【情绪安抚】当用户表达焦虑、恐惧、沮丧时:
- 先共情认可感受,不急于给建议
- 用通俗语言解释,避免医学术语
- 分享积极案例,降低恐惧感
2. 【医疗科普】当用户询问指标含义、疾病知识时:
- 调用 search_medical_knowledge 获取准确信息(如可用)
- 用比喻和类比让老年患者也能理解
- 强调"具体请以医生诊断为准"
3. 【服务推荐】当用户表达就医需求或身体不适时:
- 调用 query_appointments 查看已有预约(如可用)
- 主动提出帮用户预约
4. 【风险预警】当用户描述的症状或数据异常时:
- 调用 query_patient_vitals 查看体征数据
- 明确告知风险等级和需要注意的事项
- 高风险时建议尽快就医
5. 【引导到院】当用户有明确就诊意向或高风险预警时:
- 提供科室位置、出诊医生信息
- 建议用户联系前台预约
## 策略不是互斥的,你可以在一轮对话中自然切换。
## 永远不要:推荐具体药物、给出明确诊断、替代医生建议。
## 如果没有可用的工具数据,就基于常识回答,并建议用户咨询医生。"#;
#[utoipa::path(
post,
path = "/ai/chat",
request_body = ChatRequest,
responses((status = 200, description = "AI 客服回复")),
responses((status = 200, description = "AI Agent 回复")),
tag = "AI 客服",
security(("bearer_auth" = [])),
)]
@@ -69,30 +94,41 @@ where
));
}
let user_prompt = match body.history {
Some(ref hist) if !hist.is_empty() => {
let filtered: Vec<&ChatHistoryItem> = hist
.iter()
.filter(|h| h.role == "user" || h.role == "assistant")
.collect();
let start = filtered.len().saturating_sub(10);
let ctx: String = filtered[start..]
.iter()
.map(|h| {
format!(
"{}: {}",
if h.role == "user" { "用户" } else { "助手" },
h.content
)
})
.collect::<Vec<_>>()
.join("\n");
format!("历史对话:\n{}\n\n用户最新消息: {}", ctx, message)
}
_ => message.to_string(),
};
let ai_state = AiState::from_ref(&state);
// 构建 Agent 消息历史
let mut messages = vec![];
// 将前端传来的历史转换为 Agent ChatMessage
if let Some(ref hist) = body.history {
let filtered: Vec<&ChatHistoryItem> = hist
.iter()
.filter(|h| h.role == "user" || h.role == "assistant")
.collect();
let start = filtered.len().saturating_sub(10);
for h in &filtered[start..] {
messages.push(ChatMessage {
role: if h.role == "user" {
ChatMessageRole::User
} else {
ChatMessageRole::Assistant
},
content: h.content.clone(),
tool_calls: None,
tool_call_id: None,
});
}
}
// 添加当前用户消息
messages.push(ChatMessage {
role: ChatMessageRole::User,
content: message.to_string(),
tool_calls: None,
tool_call_id: None,
});
// 解析 Provider
let resolved = ai_state
.provider_registry
.resolve("auto")
@@ -102,37 +138,51 @@ where
erp_core::error::AppError::Internal("AI 服务暂时不可用,请稍后再试".into())
})?;
let req = GenerateRequest {
system_prompt: SYSTEM_PROMPT.to_string(),
user_prompt,
model: String::new(),
temperature: 0.7,
max_tokens: 1024,
// 构建 ToolRegistry — Phase 0 只有 query_patient_vitals
let mut registry = ToolRegistry::new();
registry.register(std::sync::Arc::new(QueryPatientVitalsTool));
let tool_ctx = ToolContext {
tenant_id: ctx.tenant_id,
user_id: ctx.user_id,
patient_id: body.patient_id,
db: ai_state.db.clone(),
health_provider: ai_state.health_provider.clone(),
};
tracing::info!(
tenant_id = %ctx.tenant_id,
user_id = %ctx.user_id,
patient_id = ?body.patient_id,
msg_len = message.len(),
"AI chat request"
"AI Agent chat request"
);
let resp = resolved.provider().generate(req).await.map_err(|e| {
tracing::error!(error = %e, "AI chat generate failed");
erp_core::error::AppError::Internal("AI 服务暂时不可用,请稍后再试".into())
})?;
// 执行 Agent ReAct 循环
let provider_arc = resolved.into_arc();
let orchestrator = AgentOrchestrator::new(provider_arc, std::sync::Arc::new(registry));
let result = orchestrator
.run(SYSTEM_PROMPT, &mut messages, &tool_ctx)
.await
.map_err(|e| {
tracing::error!(error = %e, "AI Agent run failed");
erp_core::error::AppError::Internal("AI 服务暂时不可用,请稍后再试".into())
})?;
let message_id = uuid::Uuid::now_v7().to_string();
tracing::info!(
tenant_id = %ctx.tenant_id,
message_id = %message_id,
tokens = resp.output_tokens,
"AI chat response sent"
iterations = result.iterations,
input_tokens = result.total_input_tokens,
output_tokens = result.total_output_tokens,
"AI Agent response sent"
);
Ok(Json(ApiResponse::ok(ChatResponse {
reply: resp.content,
reply: result.reply,
message_id,
iterations: result.iterations,
})))
}

View File

@@ -100,6 +100,31 @@ impl ErpModule for AiModule {
description: "创建/编辑/删除 Copilot 规则".into(),
module: "ai".into(),
},
// AI 客服会话权限
PermissionDescriptor {
code: "ai.chat.send".into(),
name: "AI 客服对话".into(),
description: "向 AI 客服发送消息".into(),
module: "ai".into(),
},
PermissionDescriptor {
code: "ai.chat.session.list".into(),
name: "查看 AI 会话列表".into(),
description: "查看用户的 AI 客服会话列表".into(),
module: "ai".into(),
},
PermissionDescriptor {
code: "ai.chat.session.manage".into(),
name: "管理 AI 会话".into(),
description: "创建/关闭 AI 客服会话".into(),
module: "ai".into(),
},
PermissionDescriptor {
code: "ai.chat.session.history".into(),
name: "查看 AI 会话历史".into(),
description: "查看 AI 客服会话消息历史".into(),
module: "ai".into(),
},
]
}