Files
hms/docs/superpowers/plans/2026-05-18-ai-agent-breakthrough-plan.md
iven 2698c98888 docs(ai): Phase 0 实施计划 — 12 Tasks,修复 Review R1 问题
- 复用已有 TokenUsage(修复编译冲突)
- AppointmentSummaryDto.id 改为 Uuid + scheduled_at 改为 DateTime
- Orchestrator 达到上限时用 User role(而非 Assistant)
- 添加路由说明(Phase 0 复用 /ai/chat,Phase 2 变更)
- 添加模型选择说明(Phase 0 硬编码 auto)
2026-05-18 02:10:59 +08:00

990 lines
30 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# AI Agent 突破口实施计划
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 将 erp-ai 的 AI 客服从简单问答升级为 ReAct Agent通过 Function Calling 串联后端分析能力,实现多策略主动关怀对话。
**Architecture:** 在 AiProvider trait 新增 `generate_with_tools()` 方法,实现 Agent Orchestrator 的 ReAct 循环(最多 5 轮 Tool CallTool 通过已有的 HealthDataProvider trait 访问 erp-health 数据。会话管理从本地 Storage 迁移到 DB 持久化。
**Tech Stack:** Rust (Axum + SeaORM + reqwest)、TypeScript/React (Taro 4.2 小程序 + Ant Design Web)、PostgreSQL、SSE
**Spec:** `docs/superpowers/specs/2026-05-18-ai-agent-breakthrough-design.md`
---
## Chunk 1: Phase 0 — 基础设施5-6 天)
> 目标Agent 核心循环跑通,能用一个 Tool 完成完整对话
### Task 0.1: Agent DTO — ChatMessage / ToolDefinition / ToolCall / AgentGenerateResponse
**Files:**
- Modify: `crates/erp-ai/src/dto/mod.rs:62-77` (GenerateRequest/GenerateResponse 之后)
- Test: `crates/erp-ai/src/dto/mod.rs` (编译检查即可,纯数据结构)
- [ ] **Step 1: 在 dto/mod.rs 末尾添加 Agent 相关 DTO**
`GenerateResponse` 定义之后,添加:
```rust
// === Agent Function Calling DTO ===
/// Agent 对话消息
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct ChatMessage {
pub role: ChatMessageRole,
pub content: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_calls: Option<Vec<ToolCall>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_call_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
#[serde(rename_all = "lowercase")]
pub enum ChatMessageRole {
User,
Assistant,
Tool,
}
/// Tool 定义(传给 LLM 的 Function Schema
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolDefinition {
pub name: String,
pub description: String,
pub parameters: serde_json::Value,
}
/// LLM 返回的 Tool Call
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolCall {
pub id: String,
pub name: String,
pub arguments: serde_json::Value,
}
/// Agent 专用生成响应
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentGenerateResponse {
pub content: Option<String>,
pub tool_calls: Option<Vec<ToolCall>>,
/// 复用已有的 TokenUsagedto/mod.rs 中的定义input/output u32
pub usage: Option<crate::dto::TokenUsage>,
}
```
- [ ] **Step 2: cargo check 验证编译**
Run: `cargo check -p erp-ai`
Expected: 编译通过(新增类型无外部依赖)
- [ ] **Step 3: Commit**
```bash
git add crates/erp-ai/src/dto/mod.rs
git commit -m "feat(ai): 添加 Agent Function Calling DTO — ChatMessage/ToolDefinition/ToolCall/AgentGenerateResponse"
```
---
### Task 0.2: AiProvider trait 新增 generate_with_tools 方法
**Files:**
- Modify: `crates/erp-ai/src/provider/mod.rs:1-30`
- Test: 编译检查
- [ ] **Step 1: 在 AiProvider trait 中新增 generate_with_tools 默认方法**
`crates/erp-ai/src/provider/mod.rs` 的 trait 定义中,`health_check` 之后添加:
```rust
/// Agent 专用生成方法 — 支持 Function Calling
/// 不支持 FC 的 Provider 使用默认实现(返回错误)
async fn generate_with_tools(
&self,
messages: Vec<crate::dto::ChatMessage>,
tools: Vec<crate::dto::ToolDefinition>,
system_prompt: &str,
model: &str,
temperature: f32,
max_tokens: u32,
) -> crate::error::AiResult<crate::dto::AgentGenerateResponse> {
Err(crate::error::AiError::UnsupportedOperation(
"Function Calling not supported by this provider".into(),
))
}
```
同时在 `src/error.rs` 中添加 `UnsupportedOperation` 变体(如果不存在):
```rust
#[error("unsupported operation: {0}")]
UnsupportedOperation(String),
```
- [ ] **Step 2: cargo check 验证**
Run: `cargo check -p erp-ai`
Expected: 编译通过(默认实现不破坏现有 Provider
- [ ] **Step 3: Commit**
```bash
git add crates/erp-ai/src/provider/mod.rs crates/erp-ai/src/error.rs
git commit -m "feat(ai): AiProvider trait 新增 generate_with_tools 默认方法"
```
---
### Task 0.3: Claude Provider 实现 generate_with_tools
**Files:**
- Modify: `crates/erp-ai/src/provider/claude.rs` (添加 tools 字段 + 响应解析)
- [ ] **Step 1: 扩展 ClaudeRequest 结构体**
`claude.rs``ClaudeRequest` struct 中添加 `tools``system` 字段(如无 system 字段则添加):
```rust
#[derive(Debug, Serialize)]
#[serde(rename_all = "snake_case")]
pub struct ClaudeTool {
name: String,
description: String,
input_schema: serde_json::Value,
}
#[derive(Debug, Serialize)]
struct ClaudeRequest {
model: String,
max_tokens: u32,
#[serde(skip_serializing_if = "Option::is_none")]
temperature: Option<f32>,
system: String,
messages: Vec<ClaudeMessage>,
#[serde(skip_serializing_if = "Option::is_none")]
tools: Option<Vec<ClaudeTool>>,
stream: bool,
}
#[derive(Debug, Serialize, Deserialize)]
struct ClaudeMessage {
role: String,
content: serde_json::Value, // 改为 Value 以支持 tool_use/tool_result 内容块
}
```
- [ ] **Step 2: 实现 generate_with_tools**
`impl AiProvider for ClaudeProvider` 中添加:
```rust
async fn generate_with_tools(
&self,
messages: Vec<crate::dto::ChatMessage>,
tools: Vec<crate::dto::ToolDefinition>,
system_prompt: &str,
model: &str,
temperature: f32,
max_tokens: u32,
) -> crate::error::AiResult<crate::dto::AgentGenerateResponse> {
let claude_messages: Vec<ClaudeMessage> = messages.iter().map(|m| {
// 根据角色和内容构建 Anthropic 格式消息
// assistant 带 tool_calls 时构造 tool_use content blocks
// tool 角色时构造 tool_result content block
// ... 完整转换逻辑
}).collect();
let claude_tools: Vec<ClaudeTool> = tools.iter().map(|t| ClaudeTool {
name: t.name.clone(),
description: t.description.clone(),
input_schema: t.parameters.clone(),
}).collect();
let req = ClaudeRequest {
model: model.to_string(),
max_tokens,
temperature: Some(temperature),
system: system_prompt.to_string(),
messages: claude_messages,
tools: Some(claude_tools),
stream: false,
};
let resp = self.client.post(&self.api_url)
.header("x-api-key", &self.api_key)
.header("anthropic-version", "2023-06-01")
.json(&req)
.send().await
.map_err(|e| AiError::ProviderError(e.to_string()))?;
let parsed: serde_json::Value = resp.json().await
.map_err(|e| AiError::ProviderError(e.to_string()))?;
// 解析 content blocks — 区分 text 和 tool_use
let mut content_text = None;
let mut tool_calls = None;
if let Some(blocks) = parsed["content"].as_array() {
for block in blocks {
match block["type"].as_str() {
Some("text") => {
content_text = block["text"].as_str().map(|s| s.to_string());
}
Some("tool_use") => {
let tc = ToolCall {
id: block["id"].as_str().unwrap_or_default().to_string(),
name: block["name"].as_str().unwrap_or_default().to_string(),
arguments: block["input"].clone(),
};
tool_calls.get_or_insert_with(Vec::new).push(tc);
}
_ => {}
}
}
}
let usage = parsed["usage"].as_object().map(|u| crate::dto::TokenUsage {
input: u["input_tokens"].as_u64().unwrap_or(0) as u32,
output: u["output_tokens"].as_u64().unwrap_or(0) as u32,
});
Ok(AgentGenerateResponse { content: content_text, tool_calls, usage })
}
```
- [ ] **Step 3: cargo check + cargo test -p erp-ai**
Run: `cargo check -p erp-ai && cargo test -p erp-ai`
Expected: 编译通过,现有测试不受影响
- [ ] **Step 4: Commit**
```bash
git add crates/erp-ai/src/provider/claude.rs
git commit -m "feat(ai): Claude Provider 实现 generate_with_tools — tool_use/tool_result 解析"
```
---
### Task 0.4: OpenAI Provider 实现 generate_with_tools
**Files:**
- Modify: `crates/erp-ai/src/provider/openai.rs`
- [ ] **Step 1: 扩展 ChatRequest 和 ChatMessageResp**
`openai.rs` 中:
```rust
#[derive(Debug, Serialize)]
struct ChatTool {
r#type: String, // "function"
function: ChatFunction,
}
#[derive(Debug, Serialize)]
struct ChatFunction {
name: String,
description: String,
parameters: serde_json::Value,
}
#[derive(Debug, Serialize)]
struct ChatRequest {
model: String,
max_tokens: u32,
#[serde(skip_serializing_if = "Option::is_none")]
temperature: Option<f32>,
messages: Vec<OpenAiMessage>,
#[serde(skip_serializing_if = "Option::is_none")]
tools: Option<Vec<ChatTool>>,
stream: bool,
}
#[derive(Debug, Serialize, Deserialize)]
struct OpenAiMessage {
role: String,
#[serde(skip_serializing_if = "Option::is_none")]
content: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
tool_calls: Option<Vec<OpenAiToolCall>>,
#[serde(skip_serializing_if = "Option::is_none")]
tool_call_id: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
struct OpenAiToolCall {
id: String,
r#type: String,
function: OpenAiFunction,
}
#[derive(Debug, Serialize, Deserialize)]
struct OpenAiFunction {
name: String,
arguments: String,
}
```
- [ ] **Step 2: 实现 generate_with_tools**
`impl AiProvider for OpenAiProvider`转换消息格式user→user, assistant+tool_calls→assistant, tool→tool发送请求解析 `choices[0].message.tool_calls`
- [ ] **Step 3: cargo check + cargo test -p erp-ai**
- [ ] **Step 4: Commit**
```bash
git add crates/erp-ai/src/provider/openai.rs
git commit -m "feat(ai): OpenAI Provider 实现 generate_with_tools — function calling 支持"
```
---
### Task 0.5: Ollama Provider 降级处理
**Files:**
- Modify: `crates/erp-ai/src/provider/ollama.rs`
- [ ] **Step 1: Ollama 使用默认的 generate_with_tools返回 UnsupportedOperation**
Ollama 的 Function Calling 支持不稳定Phase 0 不实现。依赖 trait 默认方法即可。
如果需要显式声明不支持(更好的错误信息),在 `impl AiProvider for OllamaProvider` 中添加:
```rust
async fn generate_with_tools(
&self,
_messages: Vec<crate::dto::ChatMessage>,
_tools: Vec<crate::dto::ToolDefinition>,
_system_prompt: &str,
_model: &str,
_temperature: f32,
_max_tokens: u32,
) -> crate::error::AiResult<crate::dto::AgentGenerateResponse> {
Err(crate::error::AiError::UnsupportedOperation(
"Ollama does not support Function Calling. Use Claude or OpenAI provider for Agent features.".into(),
))
}
```
- [ ] **Step 2: cargo check -p erp-ai**
- [ ] **Step 3: Commit**
```bash
git add crates/erp-ai/src/provider/ollama.rs
git commit -m "feat(ai): Ollama Provider 声明不支持 Function Calling"
```
---
### Task 0.6: HealthDataProvider 扩展 — 新增 appointments 和 medication 方法
**Files:**
- Modify: `crates/erp-core/src/health_provider.rs:10-42` (trait 定义)
- Create: `crates/erp-core/src/health_provider.rs` (新增 DTO: AppointmentSummaryDto, MedicationSummaryDto)
- Modify: `crates/erp-health/src/health_provider_impl.rs` (实现新方法)
- Test: `cargo test -p erp-health`
- [ ] **Step 1: 在 trait 中新增两个方法 + DTO**
`health_provider.rs` 的 trait 定义末尾添加:
```rust
/// 获取患者即将到来的预约
async fn get_upcoming_appointments(
&self,
tenant_id: Uuid,
patient_id: Uuid,
) -> AppResult<Vec<AppointmentSummaryDto>>;
/// 获取患者当前用药列表
async fn get_medication_list(
&self,
tenant_id: Uuid,
patient_id: Uuid,
) -> AppResult<Vec<MedicationSummaryDto>>;
```
新增 DTO
```rust
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppointmentSummaryDto {
pub id: Uuid,
pub department: String,
pub doctor_name: String,
pub scheduled_at: chrono::DateTime<chrono::Utc>,
pub status: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MedicationSummaryDto {
pub name: String,
pub dosage: String,
pub frequency: String,
}
```
- [ ] **Step 2: 在 erp-health 实现新方法**
`health_provider_impl.rs``impl HealthDataProvider for HealthDataProviderImpl` 中,基于现有的 `appointment_service``medication_record_service` 实现查询,返回脱敏后的 DTO。
- [ ] **Step 3: cargo check + cargo test -p erp-health**
Run: `cargo check -p erp-health && cargo test -p erp-health`
Expected: 编译通过
- [ ] **Step 4: Commit**
```bash
git add crates/erp-core/src/health_provider.rs crates/erp-health/src/health_provider_impl.rs
git commit -m "feat(core): HealthDataProvider 新增 get_upcoming_appointments + get_medication_list"
```
---
### Task 0.7: 数据库迁移 — 会话/消息/日志/用户画像 4 张表
**Files:**
- Create: `crates/erp-server/migration/src/m20260518_000148_create_ai_chat_tables.rs`
- Modify: `crates/erp-server/migration/src/lib.rs` (注册新迁移)
- [ ] **Step 1: 创建迁移文件**
参考现有迁移文件格式(如 m20260516_000147创建包含 4 张表的迁移:
- `ai_chat_sessions` — 会话表(含 tenant_id, user_id, patient_id, title, status, metadata + 标准字段)
- `ai_chat_messages` — 消息表(含 session_id FK, role, content, tool_calls JSONB, tool_call_id, token_count + 标准字段)
- `ai_tool_call_logs` — 日志表append-onlytenant_id, session_id, message_id, tool_name, parameters, result_summary, execution_ms, success, created_at, created_by
- `ai_user_profiles` — 用户画像表tenant_id, user_id UNIQUE, preferences JSONB, health_interests TEXT[], frequent_topics TEXT[], personality_summary, last_updated_at + 标准字段,省略 created_by/updated_by 由 Agent 自动维护)
- [ ] **Step 2: 在 lib.rs 注册迁移**
`migration/src/lib.rs``MigratorTrait` 列表中添加新迁移。
- [ ] **Step 3: cargo check -p erp-server**
- [ ] **Step 4: 启动后端验证迁移执行**
Run: `cd crates/erp-server && cargo run`
Expected: 日志显示迁移 000148 执行成功
- [ ] **Step 5: Commit**
```bash
git add crates/erp-server/migration/src/m20260518_000148_create_ai_chat_tables.rs crates/erp-server/migration/src/lib.rs
git commit -m "feat(db): 迁移 000148 — AI 聊天会话/消息/工具日志/用户画像 4 张表"
```
---
### Task 0.8: AgentTool trait + ToolRegistry + ToolContext + DisplayHint
**Files:**
- Create: `crates/erp-ai/src/agent/mod.rs` (模块入口)
- Create: `crates/erp-ai/src/agent/tool.rs` (AgentTool trait + ToolContext + ToolResult + DisplayHint)
- Create: `crates/erp-ai/src/agent/registry.rs` (ToolRegistry)
- Test: `crates/erp-ai/src/agent/tool_test.rs` (单元测试)
- [ ] **Step 1: 创建 agent 模块骨架**
`crates/erp-ai/src/agent/mod.rs`:
```rust
pub mod tool;
pub mod registry;
pub mod orchestrator;
pub use tool::{AgentTool, ToolContext, ToolResult, DisplayHint};
pub use registry::ToolRegistry;
pub use orchestrator::AgentOrchestrator;
```
- [ ] **Step 2: 实现 AgentTool trait + ToolContext + ToolResult + DisplayHint**
`crates/erp-ai/src/agent/tool.rs`:
```rust
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use erp_core::health_provider::HealthDataProvider;
use sea_orm::DatabaseConnection;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[async_trait]
pub trait AgentTool: Send + Sync {
fn name(&self) -> &str;
fn description(&self) -> &str;
fn parameters_schema(&self) -> serde_json::Value;
async fn execute(&self, ctx: &ToolContext, params: serde_json::Value) -> ToolResult;
}
pub struct ToolContext {
pub tenant_id: Uuid,
pub user_id: Uuid,
pub patient_id: Option<Uuid>,
pub db: DatabaseConnection,
pub health_provider: std::sync::Arc<dyn HealthDataProvider>,
}
pub struct ToolResult {
pub output: String,
pub display_hint: Option<DisplayHint>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum DisplayHint {
VitalCard {
indicator_type: String,
values: Vec<(String, f64)>,
unit: String,
},
LabReportCard {
report_date: String,
abnormal_count: usize,
},
ActionConfirm {
action_type: String,
summary: String,
confirm_payload: serde_json::Value,
},
RiskAlert {
level: String,
message: String,
},
Text,
}
```
- [ ] **Step 3: 实现 ToolRegistry**
`crates/erp-ai/src/agent/registry.rs`:
```rust
use std::collections::HashMap;
use std::sync::Arc;
use super::tool::AgentTool;
pub struct ToolRegistry {
tools: HashMap<String, Arc<dyn AgentTool>>,
}
impl ToolRegistry {
pub fn new() -> Self {
Self { tools: HashMap::new() }
}
pub fn register(&mut self, tool: Arc<dyn AgentTool>) {
self.tools.insert(tool.name().to_string(), tool);
}
pub fn get(&self, name: &str) -> Option<&Arc<dyn AgentTool>> {
self.tools.get(name)
}
pub fn all_tools(&self) -> Vec<&Arc<dyn AgentTool>> {
self.tools.values().collect()
}
/// 生成传给 LLM 的 ToolDefinition 列表
pub fn tool_definitions(&self) -> Vec<crate::dto::ToolDefinition> {
self.tools.values().map(|t| crate::dto::ToolDefinition {
name: t.name().to_string(),
description: t.description().to_string(),
parameters: t.parameters_schema(),
}).collect()
}
}
```
- [ ] **Step 4: 在 lib.rs 注册 agent 模块**
`crates/erp-ai/src/lib.rs` 添加 `pub mod agent;`
- [ ] **Step 5: cargo check -p erp-ai**
- [ ] **Step 6: Commit**
```bash
git add crates/erp-ai/src/agent/ crates/erp-ai/src/lib.rs
git commit -m "feat(ai): AgentTool trait + ToolRegistry + ToolContext + DisplayHint"
```
---
### Task 0.9: AgentOrchestrator — ReAct 循环
**Files:**
- Create: `crates/erp-ai/src/agent/orchestrator.rs`
- Test: `crates/erp-ai/src/agent/orchordinator_test.rs`
- [ ] **Step 1: 实现 AgentOrchestrator**
`crates/erp-ai/src/agent/orchestrator.rs`:
```rust
use crate::agent::registry::ToolRegistry;
use crate::agent::tool::{AgentTool, ToolContext, ToolResult};
use crate::dto::{AgentGenerateResponse, ChatMessage, ChatMessageRole, ToolCall};
use crate::error::AiResult;
use crate::provider::AiProvider;
use std::sync::Arc;
pub struct AgentOrchestrator {
provider: Arc<dyn AiProvider>,
tool_registry: Arc<ToolRegistry>,
max_iterations: usize, // 默认 5
}
impl AgentOrchestrator {
pub fn new(provider: Arc<dyn AiProvider>, tool_registry: Arc<ToolRegistry>) -> Self {
Self { provider, tool_registry, max_iterations: 5 }
}
/// 执行 Agent ReAct 循环
pub async fn run(
&self,
system_prompt: &str,
messages: &mut Vec<ChatMessage>,
ctx: &ToolContext,
) -> AiResult<AgentRunResult> {
let tools = self.tool_registry.tool_definitions();
let mut iterations = 0;
let mut total_input_tokens = 0u32;
let mut total_output_tokens = 0u32;
loop {
iterations += 1;
let response = self.provider.generate_with_tools(
messages.clone(),
tools.clone(),
system_prompt,
"auto", // 模型由 Provider 内部决定
0.7,
2048,
).await?;
if let Some(ref usage) = response.usage {
total_input_tokens += usage.input_tokens;
total_output_tokens += usage.output_tokens;
}
// 如果没有 tool_callsAgent 给出最终回复
let tool_calls = match response.tool_calls {
Some(tc) if !tc.is_empty() => tc,
_ => {
return Ok(AgentRunResult {
reply: response.content.unwrap_or_default(),
total_input_tokens,
total_output_tokens,
iterations,
});
}
};
// 达到上限:强制结束
if iterations >= self.max_iterations {
// 追加 User 角色指令让 LLM 基于已有信息生成最终回复
messages.push(ChatMessage {
role: ChatMessageRole::User,
content: "(系统提示:已收集足够信息,请直接总结回复用户,不要再调用工具)".to_string(),
tool_calls: None,
tool_call_id: None,
});
continue;
}
// 将 assistant 的 tool_calls 加入消息历史
messages.push(ChatMessage {
role: ChatMessageRole::Assistant,
content: response.content.unwrap_or_default(),
tool_calls: Some(tool_calls.clone()),
tool_call_id: None,
});
// 执行每个 Tool Call
for tc in &tool_calls {
let tool_result = match self.tool_registry.get(&tc.name) {
Some(tool) => {
match tool.execute(ctx, tc.arguments.clone()).await {
Ok(result) => result.output,
Err(e) => format!("Tool '{}' 执行失败: {}", tc.name, e),
}
}
None => format!("未知 Tool: {}", tc.name),
};
messages.push(ChatMessage {
role: ChatMessageRole::Tool,
content: tool_result,
tool_calls: None,
tool_call_id: Some(tc.id.clone()),
});
}
}
}
}
pub struct AgentRunResult {
pub reply: String,
pub total_input_tokens: u32,
pub total_output_tokens: u32,
pub iterations: usize,
}
```
- [ ] **Step 2: cargo check -p erp-ai**
- [ ] **Step 3: Commit**
```bash
git add crates/erp-ai/src/agent/orchestrator.rs
git commit -m "feat(ai): AgentOrchestrator — ReAct 循环(最多 5 轮 Tool Call + 强制终止)"
```
---
### Task 0.10: 实现 query_patient_vitals Tool — 端到端验证
**Files:**
- Create: `crates/erp-ai/src/agent/tools/mod.rs`
- Create: `crates/erp-ai/src/agent/tools/query_vitals.rs`
- Modify: `crates/erp-ai/src/agent/mod.rs` (注册 tools 子模块)
- [ ] **Step 1: 创建 tools 子模块**
`crates/erp-ai/src/agent/tools/mod.rs`:
```rust
pub mod query_vitals;
```
`crates/erp-ai/src/agent/tools/query_vitals.rs`:
```rust
use async_trait::async_trait;
use crate::agent::tool::{AgentTool, ToolContext, ToolResult, DisplayHint};
use serde::{Deserialize, Serialize};
use erp_core::health_provider::TimeRange;
use chrono::Utc;
pub struct QueryPatientVitalsTool;
#[async_trait]
impl AgentTool for QueryPatientVitalsTool {
fn name(&self) -> &str { "query_patient_vitals" }
fn description(&self) -> &str {
"查询患者最近的体征数据(血压、血糖、心率等)。需要提供患者 ID 和天数范围(默认 7 天)。"
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"days": {
"type": "integer",
"description": "查询最近多少天的数据,默认 7 天"
}
}
})
}
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 days = params["days"].as_i64().unwrap_or(7);
let now = Utc::now();
let start = now - chrono::Duration::days(days);
let range = TimeRange { start, end: now };
let metrics = vec![
"blood_pressure_systolic".into(),
"blood_pressure_diastolic".into(),
"heart_rate".into(),
"blood_glucose".into(),
];
match ctx.health_provider.get_vital_signs(ctx.tenant_id, patient_id, &metrics, &range).await {
Ok(vitals) => {
if vitals.is_empty() {
return ToolResult {
output: "该时间段内无体征数据".to_string(),
display_hint: None,
};
}
let mut output = String::from("最近体征数据:\n");
for v in &vitals {
output.push_str(&format!("- {}: ", v.metric));
let values_str: Vec<String> = v.values.iter()
.take(10)
.map(|(date, val)| format!("{}={}", date, val))
.collect();
output.push_str(&values_str.join(", "));
output.push_str(&format!(" ({})\n", v.unit));
}
ToolResult {
output,
display_hint: Some(DisplayHint::VitalCard {
indicator_type: vitals[0].metric.clone(),
values: vitals[0].values.iter().take(10)
.map(|(d, v)| (d.clone(), *v))
.collect(),
unit: vitals[0].unit.clone(),
}),
}
}
Err(e) => ToolResult {
output: format!("查询体征数据失败: {}", e),
display_hint: None,
},
}
}
}
```
- [ ] **Step 2: 更新 agent/mod.rs 注册 tools 子模块**
添加 `pub mod tools;` 并在 `pub use` 中导出。
- [ ] **Step 3: cargo check -p erp-ai**
- [ ] **Step 4: Commit**
```bash
git add crates/erp-ai/src/agent/tools/ crates/erp-ai/src/agent/mod.rs
git commit -m "feat(ai): 实现 query_patient_vitals Tool — 首个端到端 Agent Tool"
```
---
### Task 0.11: 改造 chat_handler — 接入 AgentOrchestrator
**Files:**
- Modify: `crates/erp-ai/src/handler/chat_handler.rs` (替换原有简单逻辑)
- Modify: `crates/erp-ai/src/state.rs` (添加 ToolRegistry 字段)
- Modify: `crates/erp-ai/src/module.rs` (注册新权限码 + 初始化 ToolRegistry)
- [ ] **Step 1: 在 AiState 中添加 ToolRegistry**
`state.rs` 新增字段:
```rust
pub tool_registry: Arc<crate::agent::ToolRegistry>,
```
- [ ] **Step 2: 在 module.rs 中初始化 ToolRegistry 并注入 AiState**
在模块初始化时:
```rust
let mut tool_registry = ToolRegistry::new();
tool_registry.register(Arc::new(QueryPatientVitalsTool));
// 后续 Phase 1 添加更多 Tool
```
- [ ] **Step 3: 重写 chat_handler 使用 AgentOrchestrator**
替换原有的 `chat()` 函数核心逻辑:
1. 从请求中获取 session_id或创建新会话
2. 从 DB 加载会话历史消息
3. 将用户消息保存到 DB
4. 构建 ToolContext从 AiState 获取 health_provider, db
5. 构建 system prompt多策略Phase 1 完善)
6. 创建 AgentOrchestrator 并调用 `run()`
7. 将 Agent 回复保存到 DB
8. 返回 ChatResponse
注意Phase 0 先用简化版 session 管理(直接传 session_id 参数),完整的 Session CRUD API 留到 Phase 2。
> **路由说明**Phase 0 复用现有 `POST /ai/chat` 路由module.rs:361 已注册),改造 handler 内部逻辑。Phase 2 会变更为 Spec 定义的 `/api/v1/ai/chat/sessions/{id}/messages`。
> **模型选择**Phase 0 硬编码 `"auto"` 由 Provider 内部决定模型。后续可通过 AiState.provider_registry 动态选择。
- [ ] **Step 4: 在 module.rs 注册新权限码**
```rust
// 现有权限码补充
("ai.chat.session.list", "查看 AI 会话列表"),
("ai.chat.session.manage", "创建/关闭 AI 会话"),
("ai.chat.session.history", "查看 AI 会话消息历史"),
```
- [ ] **Step 5: cargo check + cargo test --workspace**
- [ ] **Step 6: 功能验证 — 启动后端,用 Postman 测试**
```bash
cd crates/erp-server && cargo run
```
Postman 发送 POST `/api/v1/ai/chat`
```json
{
"message": "我最近血压怎么样",
"history": []
}
```
Expected: Agent 返回包含血压数据的自然语言回复。
- [ ] **Step 7: Commit**
```bash
git add crates/erp-ai/src/handler/chat_handler.rs crates/erp-ai/src/state.rs crates/erp-ai/src/module.rs
git commit -m "feat(ai): 改造 chat_handler 接入 AgentOrchestrator — ReAct Agent 首次跑通"
```
---
### Task 0.12: Phase 0 集成测试
**Files:**
- Create: `crates/erp-server/tests/integration/ai_agent_test.rs`
- [ ] **Step 1: 编写集成测试**
测试场景:
1. 发送简单问候 → Agent 直接回复(无 Tool Call
2. 发送体征查询 → Agent 调用 query_patient_vitals Tool → 回复包含数据
3. 达到 5 轮上限 → Agent 正常结束回复
4. 无关联患者 → Tool 返回提示信息
注意:集成测试需要 mock LLM Provider避免真实 API 调用),可创建 `MockProvider` 实现 `AiProvider` trait。
- [ ] **Step 2: cargo test --workspace**
- [ ] **Step 3: Commit**
```bash
git add crates/erp-server/tests/integration/ai_agent_test.rs
git commit -m "test(ai): Phase 0 集成测试 — Agent 循环 + Tool 执行 + 降级场景"
```
---
### Phase 0 完成标准
- [ ] `cargo check` 全 workspace 通过
- [ ] `cargo test --workspace` 全部通过
- [ ] Postman 调用 `/api/v1/ai/chat`Agent 能查到患者体征数据并自然回复
- [ ] 代码已提交并推送
---