From 2698c988882618db75021730ce8955f8b01adbc2 Mon Sep 17 00:00:00 2001 From: iven Date: Mon, 18 May 2026 02:10:59 +0800 Subject: [PATCH] =?UTF-8?q?docs(ai):=20Phase=200=20=E5=AE=9E=E6=96=BD?= =?UTF-8?q?=E8=AE=A1=E5=88=92=20=E2=80=94=2012=20Tasks=EF=BC=8C=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20Review=20R1=20=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 复用已有 TokenUsage(修复编译冲突) - AppointmentSummaryDto.id 改为 Uuid + scheduled_at 改为 DateTime - Orchestrator 达到上限时用 User role(而非 Assistant) - 添加路由说明(Phase 0 复用 /ai/chat,Phase 2 变更) - 添加模型选择说明(Phase 0 硬编码 auto) --- .../2026-05-18-ai-agent-breakthrough-plan.md | 989 ++++++++++++++++++ 1 file changed, 989 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-18-ai-agent-breakthrough-plan.md diff --git a/docs/superpowers/plans/2026-05-18-ai-agent-breakthrough-plan.md b/docs/superpowers/plans/2026-05-18-ai-agent-breakthrough-plan.md new file mode 100644 index 0000000..4958668 --- /dev/null +++ b/docs/superpowers/plans/2026-05-18-ai-agent-breakthrough-plan.md @@ -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>, + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_call_id: Option, +} + +#[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, + pub tool_calls: Option>, + /// 复用已有的 TokenUsage(dto/mod.rs 中的定义:input/output u32) + pub usage: Option, +} +``` + +- [ ] **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, + tools: Vec, + system_prompt: &str, + model: &str, + temperature: f32, + max_tokens: u32, + ) -> crate::error::AiResult { + 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, + system: String, + messages: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + tools: Option>, + 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, + tools: Vec, + system_prompt: &str, + model: &str, + temperature: f32, + max_tokens: u32, +) -> crate::error::AiResult { + let claude_messages: Vec = messages.iter().map(|m| { + // 根据角色和内容构建 Anthropic 格式消息 + // assistant 带 tool_calls 时构造 tool_use content blocks + // tool 角色时构造 tool_result content block + // ... 完整转换逻辑 + }).collect(); + + let claude_tools: Vec = 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, + messages: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + tools: Option>, + stream: bool, +} + +#[derive(Debug, Serialize, Deserialize)] +struct OpenAiMessage { + role: String, + #[serde(skip_serializing_if = "Option::is_none")] + content: Option, + #[serde(skip_serializing_if = "Option::is_none")] + tool_calls: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + tool_call_id: Option, +} + +#[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, + _tools: Vec, + _system_prompt: &str, + _model: &str, + _temperature: f32, + _max_tokens: u32, +) -> crate::error::AiResult { + 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>; + + /// 获取患者当前用药列表 + async fn get_medication_list( + &self, + tenant_id: Uuid, + patient_id: Uuid, + ) -> AppResult>; +``` + +新增 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, + 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, + pub db: DatabaseConnection, + pub health_provider: std::sync::Arc, +} + +pub struct ToolResult { + pub output: String, + pub display_hint: Option, +} + +#[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>, +} + +impl ToolRegistry { + pub fn new() -> Self { + Self { tools: HashMap::new() } + } + + pub fn register(&mut self, tool: Arc) { + self.tools.insert(tool.name().to_string(), tool); + } + + pub fn get(&self, name: &str) -> Option<&Arc> { + self.tools.get(name) + } + + pub fn all_tools(&self) -> Vec<&Arc> { + self.tools.values().collect() + } + + /// 生成传给 LLM 的 ToolDefinition 列表 + pub fn tool_definitions(&self) -> Vec { + 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, + tool_registry: Arc, + max_iterations: usize, // 默认 5 +} + +impl AgentOrchestrator { + pub fn new(provider: Arc, tool_registry: Arc) -> Self { + Self { provider, tool_registry, max_iterations: 5 } + } + + /// 执行 Agent ReAct 循环 + pub async fn run( + &self, + system_prompt: &str, + messages: &mut Vec, + ctx: &ToolContext, + ) -> AiResult { + 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 = 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, +``` + +- [ ] **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 能查到患者体征数据并自然回复 +- [ ] 代码已提交并推送 + +---