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)
This commit is contained in:
989
docs/superpowers/plans/2026-05-18-ai-agent-breakthrough-plan.md
Normal file
989
docs/superpowers/plans/2026-05-18-ai-agent-breakthrough-plan.md
Normal file
@@ -0,0 +1,989 @@
|
||||
# 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 Call),Tool 通过已有的 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>>,
|
||||
/// 复用已有的 TokenUsage(dto/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-only:tenant_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_calls,Agent 给出最终回复
|
||||
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 能查到患者体征数据并自然回复
|
||||
- [ ] 代码已提交并推送
|
||||
|
||||
---
|
||||
Reference in New Issue
Block a user