Files
hms/docs/superpowers/plans/2026-05-18-ai-agent-breakthrough-plan.md

52 KiB
Raw Blame History

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 定义之后,添加:

// === 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
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 之后添加:

    /// 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 变体(如果不存在):

#[error("unsupported operation: {0}")]
UnsupportedOperation(String),
  • Step 2: cargo check 验证

Run: cargo check -p erp-ai Expected: 编译通过(默认实现不破坏现有 Provider

  • Step 3: Commit
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.rsClaudeRequest struct 中添加 toolssystem 字段(如无 system 字段则添加):

#[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 中添加:

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
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 中:

#[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

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 中添加:

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

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 定义末尾添加:

    /// 获取患者即将到来的预约
    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

#[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.rsimpl HealthDataProvider for HealthDataProviderImpl 中,基于现有的 appointment_servicemedication_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
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.rsMigratorTrait 列表中添加新迁移。

  • Step 3: cargo check -p erp-server

  • Step 4: 启动后端验证迁移执行

Run: cd crates/erp-server && cargo run Expected: 日志显示迁移 000148 执行成功

  • Step 5: Commit
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:

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:

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:

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

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:

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

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:

pub mod query_vitals;

crates/erp-ai/src/agent/tools/query_vitals.rs:

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

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 新增字段:

pub tool_registry: Arc<crate::agent::ToolRegistry>,
  • Step 2: 在 module.rs 中初始化 ToolRegistry 并注入 AiState

在模块初始化时:

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 注册新权限码
// 现有权限码补充
("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 测试

cd crates/erp-server && cargo run

Postman 发送 POST /api/v1/ai/chat

{
  "message": "我最近血压怎么样",
  "history": []
}

Expected: Agent 返回包含血压数据的自然语言回复。

  • Step 7: Commit
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

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/chatAgent 能查到患者体征数据并自然回复
  • 代码已提交并推送

Chunk 2: Phase 1 — Tool 扩展 + 策略 Prompt5-7 天)

目标:覆盖全部核心 Tool多策略对话流生效

Task 1.1: 数据查询类 Tool — query_lab_reports + query_patient_profile

Files:

  • Create: crates/erp-ai/src/agent/tools/query_lab_reports.rs

  • Create: crates/erp-ai/src/agent/tools/query_patient_profile.rs

  • Modify: crates/erp-ai/src/agent/tools/mod.rs (注册新模块)

  • Modify: crates/erp-ai/src/module.rs (注册新 Tool 到 ToolRegistry)

  • Step 1: 实现 query_lab_reports Tool

调用 ctx.health_provider.get_lab_report(),参数为 report_id。输出格式化的化验指标列表,标注异常项。

  • Step 2: 实现 query_patient_profile Tool

调用 ctx.health_provider.get_patient_summary(),返回脱敏后的患者摘要(年龄组、性别、慢性病、用药、家族史)。

  • Step 3: 注册到 ToolRegistry + cargo check

  • Step 4: Commit

git add crates/erp-ai/src/agent/tools/ crates/erp-ai/src/module.rs
git commit -m "feat(ai): 添加 query_lab_reports + query_patient_profile Tool"

Task 1.2: 数据查询类 Tool — query_appointments + query_medication

Files:

  • Create: crates/erp-ai/src/agent/tools/query_appointments.rs

  • Create: crates/erp-ai/src/agent/tools/query_medication.rs

  • Step 1: 实现 query_appointments Tool

调用 ctx.health_provider.get_upcoming_appointments()Task 0.6 新增的方法),返回患者即将到来的预约列表。

  • Step 2: 实现 query_medication Tool

调用 ctx.health_provider.get_medication_list()Task 0.6 新增的方法),返回患者当前用药列表。

  • Step 3: 注册到 ToolRegistry + cargo check + cargo test -p erp-ai

  • Step 4: Commit

git add crates/erp-ai/src/agent/tools/ crates/erp-ai/src/module.rs
git commit -m "feat(ai): 添加 query_appointments + query_medication Tool"

Files:

  • Create: crates/erp-ai/src/agent/tools/analyze_lab_report.rs

  • Create: crates/erp-ai/src/agent/tools/analyze_health_trends.rs

  • Step 1: 实现 analyze_lab_report Tool

调用 AiState.analysisAnalysisService的非流式分析方法。参数report_id。返回化验报告的 AI 解读摘要。

注意:现有 analysis_service 使用 SSE 流式输出Tool 内需要走同步路径。检查 analysis.rs 是否有 analyze_sync() 方法,如果没有需要添加。

  • Step 2: 实现 analyze_health_trends Tool

调用 ctx.health_provider.get_trend_analysis_data() 获取预计算的统计数据,再用 AnalysisService 做趋势解读。

  • Step 3: 注册到 ToolRegistry + cargo check

  • Step 4: Commit

git add crates/erp-ai/src/agent/tools/ crates/erp-ai/src/module.rs
git commit -m "feat(ai): 添加 analyze_lab_report + analyze_health_trends 分析类 Tool"

Task 1.4: AI 分析类 Tool — get_health_insights

Files:

  • Create: crates/erp-ai/src/agent/tools/get_health_insights.rs

  • Step 1: 实现 get_health_insights Tool

调用 AiState.insight_serviceInsightService+ AiState.risk_serviceRiskService获取患者的风险洞察和 AI 建议。参数:patient_id(默认使用当前患者)。

  • Step 2: 注册到 ToolRegistry + cargo check

  • Step 3: Commit

git add crates/erp-ai/src/agent/tools/ crates/erp-ai/src/module.rs
git commit -m "feat(ai): 添加 get_health_insights Tool — Copilot 风险洞察接入"

Task 1.5: 知识类 Tool — search_medical_knowledge + recommend_services + check_alert_rules

Files:

  • Create: crates/erp-ai/src/agent/tools/search_medical_knowledge.rs

  • Create: crates/erp-ai/src/agent/tools/recommend_services.rs

  • Create: crates/erp-ai/src/agent/tools/check_alert_rules.rs

  • Step 1: 实现 search_medical_knowledge Tool

调用 AiState 中的 knowledge_structured_source按关键词搜索医疗知识库KDIGO 规则、科室指南、科普文章)。参数:query(搜索关键词)、category(可选分类过滤)。

  • Step 2: 实现 recommend_services Tool

基于规则 + 知识库推荐科室或服务。Phase 1 用简化规则映射(如"头晕"→"神经内科/心内科""血压高"→"心内科")。参数:symptoms(症状列表)。

  • Step 3: 实现 check_alert_rules Tool

调用 AiState 中的 local_rules_engine,评估当前患者数据是否触发告警阈值。参数:patient_id

  • Step 4: 注册全部 3 个 Tool 到 ToolRegistry + cargo check

  • Step 5: Commit

git add crates/erp-ai/src/agent/tools/ crates/erp-ai/src/module.rs
git commit -m "feat(ai): 添加知识类 Tool — medical_knowledge + recommend_services + check_alert_rules"

Task 1.6: 多策略 System Prompt 设计 + 调优

Files:

  • Modify: crates/erp-ai/src/agent/prompt.rs (新建或修改现有 prompt 模块)

  • Step 1: 实现 build_system_prompt 函数

从 Spec §4.2 的 System Prompt 模板生成完整 prompt。函数签名

pub fn build_agent_system_prompt(
    user_profile: Option<&UserProfileSummary>,
    patient_profile: Option<&PatientSummaryDto>,
) -> String

动态注入:

  • 用户画像偏好(如有长期记忆)

  • 患者基本信息(如已关联患者)

  • 可用 Tool 列表描述

  • Step 2: 更新 chat_handler 使用新 prompt

替换 Phase 0 中的硬编码 prompt 为 build_agent_system_prompt() 调用。

  • Step 3: cargo check + 手动对话调优

启动后端,用不同场景测试 Agent 策略选择是否正确:

  • "我最近血压有点高"(应触发查询 → 分析 → 预警 → 推荐流程)

  • "糖尿病有什么并发症"(应触发知识搜索 → 科普)

  • "我很担心我的检查结果"(应先安抚 → 再查数据)

  • Step 4: Commit

git add crates/erp-ai/src/agent/prompt.rs crates/erp-ai/src/handler/chat_handler.rs
git commit -m "feat(ai): 多策略 System Prompt — 安抚/科普/推荐/预警/引导到院"

Task 1.7: 配额检查 + Token 计量

Files:

  • Modify: crates/erp-ai/src/agent/orchestrator.rs (添加配额检查)

  • Modify: crates/erp-ai/src/handler/chat_handler.rs (记录总 token 消耗)

  • Step 1: 在 Orchestrator 每轮 Tool Call 前添加配额检查

run() 循环的 generate_with_tools() 调用前,检查 QuotaService。配额不足时直接返回提示而非调用 LLM。

注意QuotaService 在 AiState.quota 中,需要在 ToolContext 或 Orchestrator 构造时传入。

  • Step 2: 在 chat_handler 中记录每轮 token 消耗到 usage_service

  • Step 3: cargo check + cargo test -p erp-ai

  • Step 4: Commit

git add crates/erp-ai/src/agent/orchestrator.rs crates/erp-ai/src/handler/chat_handler.rs
git commit -m "feat(ai): Agent 配额检查 + Token 计量"

Task 1.8: Phase 1 测试覆盖

Files:

  • Create: crates/erp-ai/src/agent/tools/query_lab_reports_test.rs (及其他 Tool 的单元测试)

  • Modify: crates/erp-server/tests/integration/ai_agent_test.rs (扩展集成测试)

  • Step 1: 每个 Tool 编写单元测试

测试模式mock HealthDataProvider(用 MockHealthDataProvider),验证 Tool 的参数解析、输出格式、错误处理。

  • Step 2: 扩展集成测试

新增场景:

  • 发送"我最近化验报告有什么问题" → Agent 调用 query_lab_reports + analyze_lab_report

  • 发送"帮我推荐个科室" → Agent 调用 recommend_services

  • 配额耗尽 → Agent 返回降级提示

  • Step 3: cargo test --workspace

  • Step 4: Commit

git add crates/erp-ai/src/agent/tools/ crates/erp-server/tests/integration/ai_agent_test.rs
git commit -m "test(ai): Phase 1 测试覆盖 — Tool 单元测试 + 5 策略集成测试"

Phase 1 完成标准

  • cargo check + cargo test --workspace 全部通过
  • 模拟 5 种典型场景(安抚/科普/推荐/预警/引导到院Agent 自主选择正确策略和 Tool
  • 配额检查和 Token 计量正常工作
  • 代码已提交并推送

Chunk 3: Phase 2 — 前端升级 + 流式输出7-9 天)

目标:小程序 + Web 都有完整 AI 客服体验

Task 2.1: 后端 — 会话 CRUD API

Files:

  • Create: crates/erp-ai/src/handler/chat_session_handler.rs (Session CRUD)

  • Create: crates/erp-ai/src/entity/ai_chat_session.rs (SeaORM Entity)

  • Create: crates/erp-ai/src/entity/ai_chat_message.rs (SeaORM Entity)

  • Create: crates/erp-ai/src/service/chat_session_service.rs (Service 层)

  • Modify: crates/erp-ai/src/module.rs (注册新路由)

  • Modify: crates/erp-ai/src/entity/mod.rs (导出新 Entity)

  • Modify: crates/erp-ai/src/handler/mod.rs (导出新 handler)

  • Step 1: 创建 SeaORM Entity

ai_chat_sessionsai_chat_messages 表创建 Entity 文件。参考现有 Entity 格式(如 entity/ai_analysis.rs),包含所有标准字段。

  • Step 2: 实现 ChatSessionService

CRUD 方法:

  • create_session(tenant_id, user_id, patient_id?) → 创建会话

  • list_sessions(tenant_id, user_id) → 列出用户会话

  • get_session(session_id, tenant_id) → 获取会话详情

  • close_session(session_id, tenant_id) → 软关闭

  • save_message(session_id, role, content, tool_calls?, tool_call_id?) → 保存消息

  • list_messages(session_id, tenant_id, limit, offset) → 分页获取消息

  • Step 3: 实现 Session Handler

4 个端点:POST /sessionsGET /sessionsDELETE /sessions/{id}GET /sessions/{id}/messages

每个端点添加对应权限守卫(ai.chat.session.manage/.list/.history)。

  • Step 4: 改造 chat_handler 使用会话模式

POST /sessions/{id}/messages 替代原有 POST /ai/chat。从 DB 加载会话历史,不再依赖前端传 history。

  • Step 5: 在 module.rs 注册新路由
// 新增会话管理路由
let session_routes = Router::new()
    .route("/", post(session_handler::create_session))
    .route("/", get(session_handler::list_sessions))
    .route("/{session_id}", delete(session_handler::close_session))
    .route("/{session_id}/messages", get(session_handler::list_messages))
    .route("/{session_id}/messages", post(chat_handler::send_message));

保留原 POST /ai/chat 兼容一段时间。

  • Step 6: cargo check + cargo test -p erp-ai

  • Step 7: Commit

git add crates/erp-ai/src/handler/chat_session_handler.rs crates/erp-ai/src/entity/ai_chat_session.rs crates/erp-ai/src/entity/ai_chat_message.rs crates/erp-ai/src/service/chat_session_service.rs crates/erp-ai/src/module.rs
git commit -m "feat(ai): 会话 CRUD API — sessions/messages 端点 + DB 持久化"

Task 2.2: 后端 — Agent 回复 SSE 流式输出

Files:

  • Modify: crates/erp-ai/src/handler/chat_handler.rs (send_message 支持 SSE)

  • Step 1: send_message 端点支持 SSE

当客户端请求 Accept: text/event-stream 时:

  1. Agent Orchestrator 的 Tool Call 过程在后台执行(不在 SSE 中传输)
  2. 最终回复生成后,通过 SSE 流式推送给客户端
  3. 复用现有 SSE 架构(Sse<impl Stream> 模式,参考 analysis_handler

当客户端请求 Accept: application/json 时,走原有同步模式。

  • Step 2: cargo check + Postman 测试 SSE

用 Postman 发送带 Accept: text/event-stream 头的请求,验证流式输出。

  • Step 3: Commit
git add crates/erp-ai/src/handler/chat_handler.rs
git commit -m "feat(ai): Agent 回复 SSE 流式输出 — Tool 过程后台执行"

Task 2.3: 小程序 — SSE 兼容层 + 会话列表页

Files:

  • Rewrite: apps/miniprogram/src/services/ai-chat.ts (从本地 Storage 迁移到 API)

  • Create: apps/miniprogram/src/pages/ai-chat/sessions/index.tsx (会话列表页)

  • Create: apps/miniprogram/src/pages/ai-chat/sessions/index.scss

  • Modify: apps/miniprogram/src/pages/messages/index.tsx (改造为使用会话 API)

  • Modify: apps/miniprogram/src/pages/messages/index.scss

  • Modify: apps/miniprogram/src/app.config.ts (注册新页面)

  • Step 1: 重写 ai-chat.ts 服务层

// 新 API
export async function createSession(patientId?: string): Promise<ChatSession>
export async function listSessions(): Promise<ChatSession[]>
export async function sendMessage(sessionId: string, message: string): Promise<ChatMessage>
export async function getMessageHistory(sessionId: string): Promise<ChatMessage[]>

// SSE 支持
export function sendMessageStream(sessionId: string, message: string): Promise<EventSource>

Taro 不支持原生 SSE使用轮询或 requestTask 长连接实现。

  • Step 2: 改造 messages 页面

将现有 messages/index.tsx 改造为使用新 API

  • 页面加载时创建或恢复会话

  • 发送消息调用 sendMessage(sessionId, text)

  • 消息列表从 getMessageHistory() 获取而非本地 Storage

  • 支持富消息渲染(基于 display_hint 字段)

  • Step 3: 创建会话列表页

新页面 ai-chat/sessions/:展示历史会话列表,点击进入对话。添加到 app.config.ts 的 subPackages 中。

  • Step 4: 旧数据迁移

首次打开新版本时,检测本地 ai_chat_history,如有数据则提示"历史记录已迁移到云端"并清除本地缓存。

  • Step 5: 编译 + 真机预览

Run: cd apps/miniprogram && pnpm build 在微信开发者工具中验证页面渲染和消息收发。

  • Step 6: Commit
git add apps/miniprogram/src/services/ai-chat.ts apps/miniprogram/src/pages/messages/ apps/miniprogram/src/pages/ai-chat/ apps/miniprogram/src/app.config.ts
git commit -m "feat(mp): AI 客服升级 — 会话 API + SSE 兼容 + 会话列表页"

Task 2.4: Web — AI 客服页面从零构建

Files:

  • Create: apps/web/src/services/ai-chat.ts (API 模块)

  • Create: apps/web/src/pages/ai/ChatPage.tsx (聊天主页面)

  • Create: apps/web/src/pages/ai/ChatPage.scss

  • Create: apps/web/src/components/ai/MessageBubble.tsx (消息气泡组件)

  • Create: apps/web/src/components/ai/RichMessageCard.tsx (富消息卡片)

  • Modify: apps/web/src/router/routeConfig.ts (注册新路由)

  • Step 1: 创建 Web 端 ai-chat.ts API 模块

与小程序相同的 API 接口,但使用 fetch + EventSource 实现 SSE。

  • Step 2: 创建 ChatPage

参考小程序 messages/index.tsx 的功能,使用 Ant Design 组件构建:

  • 左侧会话列表

  • 右侧聊天区域

  • 底部输入框

  • 消息气泡区分 user/assistant

  • Step 3: 实现富消息渲染

基于 display_hint 字段,渲染不同类型的富消息:

  • VitalCard → ECharts 小图表

  • LabReportCard → 指标异常高亮

  • ActionConfirm → 确认/取消按钮

  • RiskAlert → 彩色风险等级卡片

  • Step 4: 注册路由

routeConfig.ts 中添加 /ai/chat 路由,权限码 ai.chat.session.list

  • Step 5: 编译 + 浏览器验证

Run: cd apps/web && pnpm build 在浏览器中打开 /ai/chat,验证页面功能和 SSE 流式输出。

  • Step 6: Commit
git add apps/web/src/services/ai-chat.ts apps/web/src/pages/ai/ apps/web/src/components/ai/ apps/web/src/router/routeConfig.ts
git commit -m "feat(web): AI 客服页面 — 会话列表 + 聊天界面 + 富消息渲染 + SSE"

Task 2.5: 端到端测试

Files:

  • Modify: crates/erp-server/tests/integration/ai_agent_test.rs (扩展)

  • Create: apps/miniprogram/src/__tests__/ai-chat.test.ts (小程序服务层测试)

  • Step 1: 后端集成测试扩展

新增测试:

  • Session CRUD 全流程

  • 发送消息 → DB 持久化验证

  • SSE 流式输出格式验证

  • 权限守卫验证(无权限返回 403

  • Step 2: 小程序服务层测试

测试 ai-chat.ts 的 API 调用逻辑mock fetch

  • Step 3: cargo test --workspace + pnpm build

  • Step 4: Commit

git add crates/erp-server/tests/integration/ai_agent_test.rs apps/miniprogram/src/__tests__/
git commit -m "test(ai): Phase 2 端到端测试 — Session CRUD + SSE + 权限验证"

Phase 2 完成标准

  • cargo check + cargo test --workspace 全部通过
  • pnpm build(小程序 + Web通过
  • 小程序打开 AI 客服,能自然对话,能看到数据卡片
  • Web 端打开 AI 客服聊天界面正常SSE 流式输出正常
  • 会话历史持久化到 DB不再依赖本地 Storage
  • 代码已提交并推送

Chunk 4: Phase 3 — 行动类 Tool + 人机协作3-5 天)

目标AI 客服能帮用户预约、转接人工

Task 3.1: create_appointment Tool带二次确认

Files:

  • Create: crates/erp-ai/src/agent/tools/create_appointment.rs

  • Modify: crates/erp-ai/src/agent/tools/mod.rs

  • Step 1: 实现 create_appointment Tool

参数:department(科室)、preferred_date(偏好日期)、preferred_time(偏好时段)。

逻辑:

  1. 查询可用排班(调用 HealthDataProvider,需新增 get_available_slots 方法)
  2. 推荐最近可用时段
  3. 不直接创建,返回 DisplayHint::ActionConfirm,前端展示确认卡片
  4. 用户确认后,前端发送确认请求到独立端点 POST /api/v1/ai/chat/sessions/{id}/confirm-action

注意:二次确认机制确保 LLM 不能在用户不知情的情况下创建预约。

  • Step 2: 新增确认端点

POST /sessions/{id}/confirm-action:接收 action_type + confirm_payload,调用 appointment_service.create_appointment()

  • Step 3: cargo check + cargo test

  • Step 4: Commit

git add crates/erp-ai/src/agent/tools/create_appointment.rs crates/erp-ai/src/handler/
git commit -m "feat(ai): create_appointment Tool — 二次确认机制 + 预约创建"

Task 3.2: transfer_to_human Tool + WebSocket 通知

Files:

  • Create: crates/erp-ai/src/agent/tools/transfer_to_human.rs

  • Create: crates/erp-ai/src/handler/chat_transfer_handler.rs (WebSocket 通知)

  • Step 1: 实现 transfer_to_human Tool

参数:reason(转接原因)、urgency(紧急程度 low/medium/high

逻辑:

  1. 记录转接请求到会话 metadata
  2. 通过事件总线发布 ai.chat.transferred 事件
  3. 返回 DisplayHint::RiskAlert 提示用户"正在转接"
  4. 值班医护端收到通知(通过 WebSocket 或 polling
  • Step 2: WebSocket 通知值班医护

chat_transfer_handler.rs 中实现 WebSocket 端点:

  • WS /api/v1/ai/chat/notifications — 值班医护连接此端点接收转接通知
  • 收到转接请求时推送 JSON 消息(包含 session_id、患者信息、转接原因

注意WebSocket 基础设施需检查 Axum 是否已有 WebSocket 支持(项目依赖中已有 tokio-tungstenite

  • Step 3: cargo check + cargo test

  • Step 4: Commit

git add crates/erp-ai/src/agent/tools/transfer_to_human.rs crates/erp-ai/src/handler/chat_transfer_handler.rs
git commit -m "feat(ai): transfer_to_human Tool + WebSocket 通知值班医护"

Task 3.3: 前端 — 操作确认 UI + 转接状态

Files:

  • Modify: apps/miniprogram/src/pages/messages/index.tsx (确认卡片 + 转接状态)

  • Modify: apps/web/src/pages/ai/ChatPage.tsx (同上)

  • Modify: apps/web/src/components/ai/RichMessageCard.tsx (ActionConfirm + RiskAlert 渲染)

  • Step 1: 小程序实现操作确认卡片

当消息包含 display_hint.type === 'action_confirm' 时,渲染确认按钮。用户点击后调用 POST /sessions/{id}/confirm-action

  • Step 2: 小程序实现转接状态提示

display_hint.type === 'risk_alert' 且包含转接信息时,显示"正在转接值班医生"动画。

  • Step 3: Web 端同步实现

同样的确认卡片和转接状态提示。

  • Step 4: 编译 + 验证

Run: pnpm build (小程序 + Web)

  • Step 5: Commit
git add apps/miniprogram/src/pages/messages/ apps/web/src/pages/ai/ apps/web/src/components/ai/
git commit -m "feat: 操作确认 UI + 转接状态提示 — 小程序 + Web"

Task 3.4: 安全边界加固

Files:

  • Modify: crates/erp-ai/src/agent/orchestrator.rs (行动类 Tool 标记)

  • Modify: crates/erp-ai/src/agent/tool.rs (ToolCategory 枚举)

  • Modify: crates/erp-ai/src/handler/chat_handler.rs (审计日志增强)

  • Step 1: 添加 ToolCategory 枚举

pub enum ToolCategory {
    ReadOnly,     // 数据查询
    Analysis,     // AI 分析
    Knowledge,    // 知识检索
    Action,       // 写入操作(需更高权限)
}

AgentTool trait 中添加 fn category(&self) -> ToolCategory

  • Step 2: Orchestrator 对 Action 类 Tool 额外检查

Action Tool 只能在用户明确意图时调用。Orchestrator 记录 Action Tool 的调用chat_handler 写入审计日志。

  • Step 3: 审计日志增强

ai_tool_call_logs 表记录完整的 Tool 调用参数(已脱敏)和结果摘要,用于事后审计。

  • Step 4: cargo check + cargo test --workspace

  • Step 5: Commit

git add crates/erp-ai/src/agent/ crates/erp-ai/src/handler/
git commit -m "feat(ai): 安全边界加固 — ToolCategory + Action 权限标记 + 审计日志"

Task 3.5: 端到端验证

Files:

  • Modify: crates/erp-server/tests/integration/ai_agent_test.rs

  • Step 1: 端到端测试

完整流程测试:

  1. 用户说"帮我预约个号" → Agent 调用 query_appointments 查空档 → recommend_services 推荐科室 → create_appointment 返回确认卡片 → 用户确认 → 预约创建成功
  2. 用户说"我要找医生" → Agent 调用 transfer_to_human → WebSocket 通知发送 → 用户看到转接提示
  3. 用户说"帮我取消预约" → Agent 提示"暂不支持取消,请联系前台"
  • Step 2: 浏览器手动验证

启动后端 + Web 前端,在浏览器中走完整预约流程。

  • Step 3: 小程序真机验证

在微信开发者工具中测试预约确认和转接流程。

  • Step 4: Commit
git add crates/erp-server/tests/integration/ai_agent_test.rs
git commit -m "test(ai): Phase 3 端到端测试 — 预约创建 + 转接人工 + 完整对话流"

Phase 3 完成标准

  • cargo check + cargo test --workspace 全部通过
  • pnpm build(小程序 + Web通过
  • 用户对 AI 说"帮我预约个号",全流程跑通
  • 用户说"找医生",转接通知正常发送
  • 行动类 Tool 有权限标记和审计日志
  • 代码已提交并推送

全局完成标准

  • cargo check + cargo test --workspace + pnpm build 全部通过
  • AI 客服能自然处理 5 种策略场景(安抚/科普/推荐/预警/引导)
  • 小程序 + Web 两端 AI 客服功能完整
  • 所有 Tool 有单元测试,核心流程有集成测试
  • wiki 关键数字已更新(新增迁移数/实体数/路由数)
  • 所有代码已提交并推送