# erp-ai Phase 1 MVP 实施计划 > **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:** 为 HMS 新建 erp-ai crate,实现 AI 智能分析流 SSE 端点,支持化验单解读/趋势分析/个性化方案/报告摘要 **Architecture:** 新建独立 erp-ai crate,通过 HealthDataProvider trait 从 erp-health 获取脱敏数据,AiProvider trait 抽象 AI 提供商(Phase 1 实现 Claude SSE),请求驱动管道 + SSE 流式返回 **Tech Stack:** Rust/Axum/SeaORM/PostgreSQL + futures/tokio-stream/async-stream (SSE) + serde_json/uuid/chrono/thiserror/utoipa **设计规格:** `docs/superpowers/specs/2026-04-25-erp-ai-module-design.md` --- ## Chunk 1: Crate 骨架 + 错误类型 + erp-core 扩展 ### Task 1: 创建 erp-ai crate 骨架 **Files:** - Create: `crates/erp-ai/Cargo.toml` - Create: `crates/erp-ai/src/lib.rs` - Create: `crates/erp-ai/src/error.rs` - Modify: `Cargo.toml` (workspace root) — 添加 erp-ai 到 workspace - [ ] **Step 1: 创建 crate 目录** ```bash mkdir -p crates/erp-ai/src ``` - [ ] **Step 2: 创建 Cargo.toml** ```toml # crates/erp-ai/Cargo.toml [package] name = "erp-ai" version.workspace = true edition.workspace = true [dependencies] erp-core.workspace = true tokio = { workspace = true, features = ["full"] } tokio-stream.workspace = true futures.workspace = true async-stream.workspace = true serde.workspace = true serde_json.workspace = true uuid.workspace = true chrono.workspace = true axum.workspace = true sea-orm.workspace = true tracing.workspace = true thiserror.workspace = true utoipa.workspace = true async-trait.workspace = true reqwest = { version = "0.12", features = ["stream", "json"] } handlebars = "6" sha2 = "0.10" hex = "0.4" ``` > 注意: `futures`, `tokio-stream`, `async-stream`, `reqwest`, `handlebars`, `sha2` 需要加入 workspace 依赖或在此声明版本。参照 `crates/erp-health/Cargo.toml` 模式。 - [ ] **Step 3: 创建 error.rs** ```rust // crates/erp-ai/src/error.rs use erp_core::AppError; #[derive(Debug, thiserror::Error)] pub enum AiError { #[error("验证失败: {0}")] Validation(String), #[error("分析未找到: {0}")] AnalysisNotFound(String), #[error("Prompt 模板未找到: {0}")] PromptNotFound(String), #[error("AI 提供商不可用: {0}")] ProviderUnavailable(String), #[error("AI 提供商错误: {0}")] ProviderError(String), #[error("数据脱敏失败: {0}")] SanitizationError(String), #[error("模板渲染失败: {0}")] TemplateError(String), #[error("速率超限")] RateLimitExceeded, #[error("版本不匹配")] VersionMismatch, #[error("数据库错误: {0}")] DbError(String), } impl From for AppError { fn from(e: AiError) -> Self { match e { AiError::Validation(msg) => AppError::Validation(msg), AiError::AnalysisNotFound(id) => AppError::NotFound(format!("分析结果: {id}")), AiError::PromptNotFound(name) => AppError::NotFound(format!("Prompt 模板: {name}")), AiError::ProviderUnavailable(p) => AppError::ServiceUnavailable(format!("AI 提供商 {p} 不可用")), AiError::RateLimitExceeded => AppError::TooManyRequests, AiError::VersionMismatch => AppError::VersionMismatch, AiError::DbError(msg) => AppError::Internal(msg), other => AppError::Internal(other.to_string()), } } } impl From for AiError { fn from(e: sea_orm::DbErr) -> Self { AiError::DbError(e.to_string()) } } pub type AiResult = Result; ``` > 注意: 检查 `AppError` 是否有 `ServiceUnavailable` 变体。如果没有,使用 `AppError::Internal` 替代。 - [ ] **Step 4: 创建 lib.rs (最小骨架)** ```rust // crates/erp-ai/src/lib.rs pub mod error; pub use error::{AiError, AiResult}; ``` - [ ] **Step 5: 注册到 workspace** 在根 `Cargo.toml` 的 `[workspace] members` 数组中添加 `"crates/erp-ai"`,在 `[workspace.dependencies]` 中添加: ```toml erp-ai = { path = "crates/erp-ai" } ``` 同时确认以下依赖在 workspace dependencies 中存在(如不存在则添加): ```toml futures = "0.3" tokio-stream = "0.1" async-stream = "0.3" reqwest = { version = "0.12", features = ["stream", "json"] } handlebars = "6" sha2 = "0.10" hex = "0.4" ``` - [ ] **Step 6: 验证编译** ```bash cargo check -p erp-ai ``` Expected: 编译通过,无错误 - [ ] **Step 7: 提交** ```bash git add crates/erp-ai/ Cargo.toml git commit -m "feat(ai): 创建 erp-ai crate 骨架 + 错误类型" ``` --- ### Task 2: erp-core 扩展 — HealthDataProvider trait + AI 权限码 + 事件类型 **Files:** - Create: `crates/erp-core/src/health_provider.rs` — trait + DTO 定义 - Modify: `crates/erp-core/src/lib.rs` — 添加 pub mod - Modify: `crates/erp-health/src/health_provider_impl.rs` — trait 实现 (stub) - Modify: `crates/erp-health/src/lib.rs` — 添加 pub mod - Modify: `crates/erp-health/src/module.rs` — permissions() 中声明 AI 权限 > 注意: AI 权限码放在 erp-ai 模块的 permissions() 中,不在 erp-health。此处仅做 erp-core 的 trait 扩展。 - [ ] **Step 1: 创建 HealthDataProvider trait + DTO** ```rust // crates/erp-core/src/health_provider.rs use async_trait::async_trait; use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::AppResult; /// 健康数据提供者 trait,由 erp-health 实现 /// 返回的 DTO 已脱去 PII(姓名、身份证号等),只包含年龄/性别/医疗数据 #[async_trait] pub trait HealthDataProvider: Send + Sync { /// 获取化验报告(指标列表) async fn get_lab_report( &self, tenant_id: Uuid, report_id: Uuid, ) -> AppResult; /// 获取生命体征趋势数据 async fn get_vital_signs( &self, tenant_id: Uuid, patient_id: Uuid, metrics: &[String], range: &TimeRange, ) -> AppResult>; /// 获取患者摘要(用于个性化方案) async fn get_patient_summary( &self, tenant_id: Uuid, patient_id: Uuid, ) -> AppResult; /// 获取完整健康报告(用于摘要生成) async fn get_full_report( &self, tenant_id: Uuid, report_id: Uuid, ) -> AppResult; } // === DTO 定义 === #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TimeRange { pub start: chrono::DateTime, pub end: chrono::DateTime, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LabReportDto { pub age_group: String, pub sex: String, pub department: String, pub report_date: String, pub items: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LabItemDto { pub name: String, pub value: f64, pub unit: String, pub reference_range: String, pub is_abnormal: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct VitalSignDto { pub metric: String, pub values: Vec<(String, f64)>, pub unit: String, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PatientSummaryDto { pub age_group: String, pub sex: String, pub chronic_conditions: Vec, pub medications: Vec, pub family_history: Vec, pub last_checkup_date: String, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct HealthReportDto { pub age_group: String, pub sex: String, pub department: String, pub report_date: String, pub sections: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ReportSectionDto { pub title: String, pub findings: Vec, pub abnormal_items: Vec, } ``` - [ ] **Step 2: 在 erp-core/src/lib.rs 中添加 pub mod** ```rust pub mod health_provider; ``` 并添加 re-export: ```rust pub use health_provider::{ HealthDataProvider, LabItemDto, LabReportDto, PatientSummaryDto, HealthReportDto, ReportSectionDto, TimeRange, VitalSignDto, }; ``` - [ ] **Step 3: 验证 erp-core 编译** ```bash cargo check -p erp-core ``` - [ ] **Step 4: 提交 erp-core 扩展** ```bash git add crates/erp-core/src/health_provider.rs crates/erp-core/src/lib.rs git commit -m "feat(core): 新增 HealthDataProvider trait + DTO 定义" ``` --- ### Task 3: erp-health 实现 HealthDataProvider (stub) **Files:** - Create: `crates/erp-health/src/health_provider_impl.rs` - Modify: `crates/erp-health/src/lib.rs` > 注意: Phase 1 先创建 stub 实现(返回 todo! 或空数据),确保编译通过。实际数据查询在 Chunk 5 集成时完善。 - [ ] **Step 1: 创建 stub 实现** ```rust // crates/erp-health/src/health_provider_impl.rs use async_trait::async_trait; use erp_core::{ AppResult, HealthDataProvider, LabReportDto, VitalSignDto, PatientSummaryDto, HealthReportDto, TimeRange, }; use uuid::Uuid; pub struct HealthDataProviderImpl { pub db: sea_orm::DatabaseConnection, } #[async_trait] impl HealthDataProvider for HealthDataProviderImpl { async fn get_lab_report( &self, _tenant_id: Uuid, _report_id: Uuid, ) -> AppResult { todo!("Chunk 5: 实现化验报告数据查询") } async fn get_vital_signs( &self, _tenant_id: Uuid, _patient_id: Uuid, _metrics: &[String], _range: &TimeRange, ) -> AppResult> { todo!("Chunk 5: 实现生命体征趋势查询") } async fn get_patient_summary( &self, _tenant_id: Uuid, _patient_id: Uuid, ) -> AppResult { todo!("Chunk 5: 实现患者摘要查询") } async fn get_full_report( &self, _tenant_id: Uuid, _report_id: Uuid, ) -> AppResult { todo!("Chunk 5: 实现完整报告查询") } } ``` - [ ] **Step 2: 在 erp-health/src/lib.rs 添加 pub mod** ```rust pub mod health_provider_impl; pub use health_provider_impl::HealthDataProviderImpl; ``` - [ ] **Step 3: 验证全 workspace 编译** ```bash cargo check --workspace ``` Expected: 编译通过(stub 的 todo! 不影响编译) - [ ] **Step 4: 提交** ```bash git add crates/erp-health/src/health_provider_impl.rs crates/erp-health/src/lib.rs git commit -m "feat(health): 添加 HealthDataProvider stub 实现" ``` --- ## Chunk 2: 数据库迁移 + SeaORM Entity ### Task 4: 创建 ai_prompts / ai_analysis_results / ai_usage_logs 迁移 **Files:** - Create: `crates/erp-server/migration/src/m20260425_000050_create_ai_tables.rs` - Modify: `crates/erp-server/migration/src/lib.rs` — 注册到 Migrator > 先查看 `migration/src/lib.rs` 最后一个 migration 编号,确保 `000050` 不冲突。 - [ ] **Step 1: 创建迁移文件** ```rust // crates/erp-server/migration/src/m20260425_000050_create_ai_tables.rs use sea_orm_migration::prelude::*; #[derive(DeriveMigrationName)] pub struct Migration; #[async_trait::async_trait] impl MigrationTrait for Migration { async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { // 1. ai_prompts — Prompt 模板存储 manager.create_table( Table::create() .table(AiPrompt::Table) .if_not_exists() .col(ColumnDef::new(AiPrompt::Id).uuid().not_null().primary_key()) .col(ColumnDef::new(AiPrompt::TenantId).uuid().not_null()) .col(ColumnDef::new(AiPrompt::Name).string_len(100).not_null()) .col(ColumnDef::new(AiPrompt::Description).text().not_null().default("")) .col(ColumnDef::new(AiPrompt::SystemPrompt).text().not_null()) .col(ColumnDef::new(AiPrompt::UserPromptTemplate).text().not_null()) .col(ColumnDef::new(AiPrompt::VariablesSchema).json().null()) .col(ColumnDef::new(AiPrompt::ModelConfig).json().not_null()) .col(ColumnDef::new(AiPrompt::Version).integer().not_null().default(1)) .col(ColumnDef::new(AiPrompt::IsActive).boolean().not_null().default(true)) .col(ColumnDef::new(AiPrompt::Category).string_len(50).not_null().default("analysis")) .col(ColumnDef::new(AiPrompt::Tags).json().null()) .col(ColumnDef::new(AiPrompt::CreatedAt).timestamp_with_time_zone().not_null().default(Expr::current_timestamp())) .col(ColumnDef::new(AiPrompt::UpdatedAt).timestamp_with_time_zone().not_null().default(Expr::current_timestamp())) .col(ColumnDef::new(AiPrompt::CreatedBy).uuid().null()) .col(ColumnDef::new(AiPrompt::UpdatedBy).uuid().null()) .col(ColumnDef::new(AiPrompt::DeletedAt).timestamp_with_time_zone().null()) .col(ColumnDef::new(AiPrompt::VersionLock).integer().not_null().default(1)) .to_owned(), ).await?; // ai_prompts 索引 manager.create_index( Index::create() .if_not_exists() .name("idx_ai_prompts_tenant_name") .table(AiPrompt::Table) .col(AiPrompt::TenantId) .col(AiPrompt::Name) .col(AiPrompt::IsActive) .to_owned(), ).await?; // 2. ai_analysis_results — 分析结果存储 manager.create_table( Table::create() .table(AiAnalysis::Table) .if_not_exists() .col(ColumnDef::new(AiAnalysis::Id).uuid().not_null().primary_key()) .col(ColumnDef::new(AiAnalysis::TenantId).uuid().not_null()) .col(ColumnDef::new(AiAnalysis::PatientId).uuid().not_null()) .col(ColumnDef::new(AiAnalysis::AnalysisType).string_len(50).not_null()) .col(ColumnDef::new(AiAnalysis::SourceRef).string_len(200).not_null()) .col(ColumnDef::new(AiAnalysis::PromptId).uuid().not_null()) .col(ColumnDef::new(AiAnalysis::PromptVersion).integer().not_null()) .col(ColumnDef::new(AiAnalysis::ModelUsed).string_len(100).not_null()) .col(ColumnDef::new(AiAnalysis::InputDataHash).string_len(64).not_null()) .col(ColumnDef::new(AiAnalysis::SanitizedInput).json().null()) .col(ColumnDef::new(AiAnalysis::ResultContent).text().null()) .col(ColumnDef::new(AiAnalysis::ResultMetadata).json().null()) .col(ColumnDef::new(AiAnalysis::Status).string_len(20).not_null().default("pending")) .col(ColumnDef::new(AiAnalysis::ErrorMessage).text().null()) .col(ColumnDef::new(AiAnalysis::CreatedAt).timestamp_with_time_zone().not_null().default(Expr::current_timestamp())) .col(ColumnDef::new(AiAnalysis::UpdatedAt).timestamp_with_time_zone().not_null().default(Expr::current_timestamp())) .col(ColumnDef::new(AiAnalysis::CreatedBy).uuid().null()) .col(ColumnDef::new(AiAnalysis::UpdatedBy).uuid().null()) .col(ColumnDef::new(AiAnalysis::DeletedAt).timestamp_with_time_zone().null()) .col(ColumnDef::new(AiAnalysis::VersionLock).integer().not_null().default(1)) .to_owned(), ).await?; // ai_analysis 索引 manager.create_index( Index::create() .if_not_exists() .name("idx_ai_analysis_tenant_patient") .table(AiAnalysis::Table) .col(AiAnalysis::TenantId) .col(AiAnalysis::PatientId) .to_owned(), ).await?; manager.create_index( Index::create() .if_not_exists() .name("idx_ai_analysis_cache_hash") .table(AiAnalysis::Table) .col(AiAnalysis::InputDataHash) .to_owned(), ).await?; // 3. ai_usage_logs — AI 调用计量 manager.create_table( Table::create() .table(AiUsage::Table) .if_not_exists() .col(ColumnDef::new(AiUsage::Id).uuid().not_null().primary_key()) .col(ColumnDef::new(AiUsage::TenantId).uuid().not_null()) .col(ColumnDef::new(AiUsage::Provider).string_len(50).not_null()) .col(ColumnDef::new(AiUsage::Model).string_len(100).not_null()) .col(ColumnDef::new(AiUsage::AnalysisType).string_len(50).not_null()) .col(ColumnDef::new(AiUsage::InputTokens).integer().not_null().default(0)) .col(ColumnDef::new(AiUsage::OutputTokens).integer().not_null().default(0)) .col(ColumnDef::new(AiUsage::DurationMs).integer().not_null().default(0)) .col(ColumnDef::new(AiUsage::CostCents).integer().not_null().default(0)) .col(ColumnDef::new(AiUsage::IsCacheHit).boolean().not_null().default(false)) .col(ColumnDef::new(AiUsage::CreatedAt).timestamp_with_time_zone().not_null().default(Expr::current_timestamp())) .to_owned(), ).await?; // ai_usage 索引 manager.create_index( Index::create() .if_not_exists() .name("idx_ai_usage_tenant_created") .table(AiUsage::Table) .col(AiUsage::TenantId) .col(AiUsage::CreatedAt) .to_owned(), ).await?; Ok(()) } async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { manager.drop_table(Table::drop().table(AiUsage::Table).to_owned()).await?; manager.drop_table(Table::drop().table(AiAnalysis::Table).to_owned()).await?; manager.drop_table(Table::drop().table(AiPrompt::Table).to_owned()).await?; Ok(()) } } #[derive(DeriveIden)] enum AiPrompt { Table, Id, TenantId, Name, Description, SystemPrompt, UserPromptTemplate, VariablesSchema, ModelConfig, Version, IsActive, Category, Tags, CreatedAt, UpdatedAt, CreatedBy, UpdatedBy, DeletedAt, VersionLock, } #[derive(DeriveIden)] enum AiAnalysis { Table, Id, TenantId, PatientId, AnalysisType, SourceRef, PromptId, PromptVersion, ModelUsed, InputDataHash, SanitizedInput, ResultContent, ResultMetadata, Status, ErrorMessage, CreatedAt, UpdatedAt, CreatedBy, UpdatedBy, DeletedAt, VersionLock, } #[derive(DeriveIden)] enum AiUsage { Table, Id, TenantId, Provider, Model, AnalysisType, InputTokens, OutputTokens, DurationMs, CostCents, IsCacheHit, CreatedAt, } ``` - [ ] **Step 2: 在 migration/src/lib.rs 注册** 在 `Migrator::migrations()` 的 `vec![]` 末尾添加: ```rust Box::new(m20260425_000050_create_ai_tables::Migration), ``` - [ ] **Step 3: 验证迁移编译** ```bash cargo check -p erp-server ``` - [ ] **Step 4: 提交** ```bash git add crates/erp-server/migration/src/ git commit -m "feat(db): 添加 ai_prompts / ai_analysis_results / ai_usage_logs 迁移" ``` --- ### Task 5: 创建 SeaORM Entity **Files:** - Create: `crates/erp-ai/src/entity/mod.rs` - Create: `crates/erp-ai/src/entity/ai_prompt.rs` - Create: `crates/erp-ai/src/entity/ai_analysis.rs` - Create: `crates/erp-ai/src/entity/ai_usage.rs` - [ ] **Step 1: 创建 entity/mod.rs** ```rust // crates/erp-ai/src/entity/mod.rs pub mod ai_analysis; pub mod ai_prompt; pub mod ai_usage; ``` - [ ] **Step 2: 创建 ai_prompt.rs** ```rust // crates/erp-ai/src/entity/ai_prompt.rs use sea_orm::entity::prelude::*; use serde::{Deserialize, Serialize}; #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] #[sea_orm(table_name = "ai_prompts")] pub struct Model { #[sea_orm(primary_key, auto_increment = false)] pub id: Uuid, pub tenant_id: Uuid, pub name: String, pub description: String, pub system_prompt: String, pub user_prompt_template: String, pub variables_schema: Option, pub model_config: serde_json::Value, pub version: i32, pub is_active: bool, pub category: String, pub tags: Option, pub created_at: DateTimeUtc, pub updated_at: DateTimeUtc, pub created_by: Option, pub updated_by: Option, pub deleted_at: Option, pub version_lock: i32, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] pub enum Relation {} impl ActiveModelBehavior for ActiveModel {} ``` - [ ] **Step 3: 创建 ai_analysis.rs** ```rust // crates/erp-ai/src/entity/ai_analysis.rs use sea_orm::entity::prelude::*; use serde::{Deserialize, Serialize}; #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] #[sea_orm(table_name = "ai_analysis_results")] pub struct Model { #[sea_orm(primary_key, auto_increment = false)] pub id: Uuid, pub tenant_id: Uuid, pub patient_id: Uuid, pub analysis_type: String, pub source_ref: String, pub prompt_id: Uuid, pub prompt_version: i32, pub model_used: String, pub input_data_hash: String, pub sanitized_input: Option, pub result_content: Option, pub result_metadata: Option, pub status: String, pub error_message: Option, pub created_at: DateTimeUtc, pub updated_at: DateTimeUtc, pub created_by: Option, pub updated_by: Option, pub deleted_at: Option, pub version_lock: i32, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] pub enum Relation {} impl ActiveModelBehavior for ActiveModel {} ``` - [ ] **Step 4: 创建 ai_usage.rs** ```rust // crates/erp-ai/src/entity/ai_usage.rs use sea_orm::entity::prelude::*; use serde::{Deserialize, Serialize}; #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] #[sea_orm(table_name = "ai_usage_logs")] pub struct Model { #[sea_orm(primary_key, auto_increment = false)] pub id: Uuid, pub tenant_id: Uuid, pub provider: String, pub model: String, pub analysis_type: String, pub input_tokens: i32, pub output_tokens: i32, pub duration_ms: i32, pub cost_cents: i32, pub is_cache_hit: bool, pub created_at: DateTimeUtc, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] pub enum Relation {} impl ActiveModelBehavior for ActiveModel {} ``` - [ ] **Step 5: 更新 lib.rs 添加 entity mod** 在 `crates/erp-ai/src/lib.rs` 中添加: ```rust pub mod entity; ``` - [ ] **Step 6: 验证编译** ```bash cargo check -p erp-ai ``` - [ ] **Step 7: 提交** ```bash git add crates/erp-ai/src/entity/ crates/erp-ai/src/lib.rs git commit -m "feat(ai): 添加 SeaORM Entity (ai_prompt/ai_analysis/ai_usage)" ``` --- ## Chunk 3: AI Provider 抽象 + Claude SSE + 数据脱敏 ### Task 6: AiProvider trait + Claude 实现 **Files:** - Create: `crates/erp-ai/src/provider/mod.rs` - Create: `crates/erp-ai/src/provider/claude.rs` - Create: `crates/erp-ai/src/dto.rs` - [ ] **Step 1: 创建 DTO 定义** ```rust // crates/erp-ai/src/dto.rs use serde::{Deserialize, Serialize}; // === 分析请求 === #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AnalyzeRequest { pub analysis_type: AnalysisType, pub source_ref: String, // report_id 或 patient_id + metrics pub options: AnalyzeOptions, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "snake_case")] pub enum AnalysisType { LabReport, Trends, CheckupPlan, ReportSummary, } impl AnalysisType { pub fn as_str(&self) -> &str { match self { Self::LabReport => "lab_report", Self::Trends => "trend", Self::CheckupPlan => "checkup_plan", Self::ReportSummary => "report_summary", } } pub fn prompt_name(&self) -> &str { match self { Self::LabReport => "lab_report_interpretation", Self::Trends => "health_trend_analysis", Self::CheckupPlan => "personalized_checkup_plan", Self::ReportSummary => "report_summary_generation", } } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AnalyzeOptions { pub detail_level: Option, // patient_friendly | professional pub language: Option, // zh-CN } impl Default for AnalyzeOptions { fn default() -> Self { Self { detail_level: Some("patient_friendly".into()), language: Some("zh-CN".into()), } } } // === AI Provider 请求/响应 === #[derive(Debug, Clone)] pub struct GenerateRequest { pub system_prompt: String, pub user_prompt: String, pub model: String, pub temperature: f32, pub max_tokens: u32, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GenerateResponse { pub content: String, pub model: String, pub input_tokens: u32, pub output_tokens: u32, pub duration_ms: u64, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct StreamChunk { pub content: String, pub index: u32, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct StreamDone { pub analysis_id: uuid::Uuid, pub status: String, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct StreamMetadata { pub model: String, pub tokens: TokenUsage, pub duration_ms: u64, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TokenUsage { pub input: u32, pub output: u32, } // === SSE 事件 === #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type")] pub enum AnalysisSseEvent { #[serde(rename = "chunk")] Chunk { content: String, index: u32 }, #[serde(rename = "metadata")] Metadata { model: String, tokens: TokenUsage, duration_ms: u64 }, #[serde(rename = "done")] Done { analysis_id: uuid::Uuid, status: String }, #[serde(rename = "error")] Error { message: String }, } ``` - [ ] **Step 2: 创建 provider/mod.rs** ```rust // crates/erp-ai/src/provider/mod.rs pub mod claude; use async_trait::async_trait; use futures::Stream; use pin_project_lite::pin_project; use std::pin::Pin; use crate::dto::GenerateRequest; use crate::error::AiResult; /// AI 提供商 trait #[async_trait] pub trait AiProvider: Send + Sync { /// 流式生成 — 返回 Pin> 避免 async_trait + impl Trait 不兼容 async fn stream_generate( &self, req: GenerateRequest, ) -> AiResult> + Send>>>; /// 非流式生成 async fn generate(&self, req: GenerateRequest) -> AiResult; /// 提供商名称 fn name(&self) -> &str; /// 健康检查 async fn health_check(&self) -> AiResult; } ``` > 注意: 需要在 Cargo.toml 中添加 `pin-project-lite` 或使用 `futures` 的 `pin_mut!` 宏。检查 `futures` 是否已导出 `Stream` trait。 - [ ] **Step 3: 创建 provider/claude.rs (Claude API SSE 实现)** ```rust // crates/erp-ai/src/provider/claude.rs use async_trait::async_trait; use async_stream::stream; use futures::{Stream, StreamExt}; use reqwest::Client; use serde::{Deserialize, Serialize}; use std::pin::Pin; use super::AiProvider; use crate::dto::GenerateRequest; use crate::error::{AiError, AiResult}; #[derive(Debug, Clone)] pub struct ClaudeProvider { client: Client, api_key: String, base_url: String, } impl ClaudeProvider { pub fn new(api_key: String) -> Self { Self { client: Client::new(), api_key, base_url: "https://api.anthropic.com".into(), } } pub fn with_base_url(mut self, url: String) -> Self { self.base_url = url; self } } // Claude API 请求/响应结构 #[derive(Serialize)] struct ClaudeRequest { model: String, max_tokens: u32, temperature: f32, system: String, messages: Vec, stream: bool, } #[derive(Serialize)] struct ClaudeMessage { role: String, content: String, } #[derive(Deserialize)] struct ClaudeStreamEvent { #[serde(rename = "type")] event_type: String, delta: Option, message: Option, } #[derive(Deserialize)] struct ClaudeDelta { text: Option, } #[derive(Deserialize)] struct ClaudeMessageResp { usage: Option, } #[derive(Deserialize)] struct ClaudeUsage { input_tokens: u32, output_tokens: u32, } #[async_trait] impl AiProvider for ClaudeProvider { async fn stream_generate( &self, req: GenerateRequest, ) -> AiResult> + Send>>> { let claude_req = ClaudeRequest { model: req.model, max_tokens: req.max_tokens, temperature: req.temperature, system: req.system_prompt, messages: vec![ClaudeMessage { role: "user".into(), content: req.user_prompt, }], stream: true, }; let response = self .client .post(format!("{}/v1/messages", self.base_url)) .header("x-api-key", &self.api_key) .header("anthropic-version", "2023-06-01") .header("content-type", "application/json") .json(&claude_req) .send() .await .map_err(|e| AiError::ProviderError(format!("Claude API 请求失败: {e}")))?; if !response.status().is_success() { let status = response.status(); let body = response.text().await.unwrap_or_default(); return Err(AiError::ProviderError(format!("Claude API 错误 {status}: {body}"))); } let stream = Box::pin(stream! { let mut stream = response.bytes_stream(); while let Some(chunk_result) = stream.next().await { let bytes = match chunk_result { Ok(b) => b, Err(e) => { yield Err(AiError::ProviderError(format!("流读取错误: {e}"))); break; } }; let text = String::from_utf8_lossy(&bytes); for line in text.lines() { if let Some(data) = line.strip_prefix("data: ") { if data == "[DONE]" { return; } if let Ok(event) = serde_json::from_str::(data) { if event.event_type == "content_block_delta" { if let Some(delta) = event.delta { if let Some(text) = delta.text { yield Ok(text); } } } } } } } }); Ok(stream) } async fn generate(&self, req: GenerateRequest) -> AiResult { let start = std::time::Instant::now(); let claude_req = ClaudeRequest { model: req.model.clone(), max_tokens: req.max_tokens, temperature: req.temperature, system: req.system_prompt, messages: vec![ClaudeMessage { role: "user".into(), content: req.user_prompt, }], stream: false, }; let resp = self .client .post(format!("{}/v1/messages", self.base_url)) .header("x-api-key", &self.api_key) .header("anthropic-version", "2023-06-01") .header("content-type", "application/json") .json(&claude_req) .send() .await .map_err(|e| AiError::ProviderError(e.to_string()))?; let status = resp.status(); let body = resp.text().await.map_err(|e| AiError::ProviderError(e.to_string()))?; if !status.is_success() { return Err(AiError::ProviderError(format!("Claude {status}: {body}"))); } let start_time = start; // 解析非流式响应 let parsed: serde_json::Value = serde_json::from_str(&body) .map_err(|e| AiError::ProviderError(format!("解析响应失败: {e}")))?; let content = parsed["content"][0]["text"] .as_str() .unwrap_or("") .to_string(); let input_tokens = parsed["usage"]["input_tokens"].as_u64().unwrap_or(0) as u32; let output_tokens = parsed["usage"]["output_tokens"].as_u64().unwrap_or(0) as u32; Ok(crate::dto::GenerateResponse { content, model: req.model, input_tokens, output_tokens, duration_ms: start_time.elapsed().as_millis() as u64, }) } fn name(&self) -> &str { "claude" } async fn health_check(&self) -> AiResult { // 简单检查: 发一个最小请求验证 API key 有效 let resp = self .client .post(format!("{}/v1/messages", self.base_url)) .header("x-api-key", &self.api_key) .header("anthropic-version", "2023-06-01") .header("content-type", "application/json") .json(&serde_json::json!({ "model": "claude-sonnet-4-6", "max_tokens": 1, "messages": [{"role": "user", "content": "hi"}] })) .send() .await; match resp { Ok(r) => Ok(r.status().is_success() || r.status().as_u16() == 400), Err(_) => Ok(false), } } } ``` - [ ] **Step 4: 更新 lib.rs 添加新模块** ```rust // crates/erp-ai/src/lib.rs pub mod dto; pub mod entity; pub mod error; pub mod provider; pub use error::{AiError, AiResult}; ``` - [ ] **Step 5: 验证编译** ```bash cargo check -p erp-ai ``` > 如果 `pin-project-lite` 缺失,在 Cargo.toml 中添加,或直接使用 `std::pin::Pin>` 无需 pin-project。 - [ ] **Step 6: 提交** ```bash git add crates/erp-ai/src/ git commit -m "feat(ai): AiProvider trait + Claude SSE 流式实现 + DTO 定义" ``` --- ### Task 7: 数据脱敏服务 + Prompt 模板引擎 **Files:** - Create: `crates/erp-ai/src/sanitization/mod.rs` - Create: `crates/erp-ai/src/prompt/mod.rs` - [ ] **Step 1: 创建 sanitization/mod.rs** ```rust // crates/erp-ai/src/sanitization/mod.rs use erp_core::{ HealthReportDto, LabReportDto, PatientSummaryDto, VitalSignDto, }; use serde_json::Value; use crate::error::{AiError, AiResult}; /// 数据脱敏服务 — 确保发送给 AI 的数据不含 PII /// HealthDataProvider 返回的 DTO 已经是脱敏的(只有年龄/性别/医疗数据) /// 此服务做二次检查和安全约束注入 pub struct SanitizationService; impl SanitizationService { pub fn new() -> Self { Self } /// 验证 DTO 中不包含意外泄漏的 PII 字段 /// 并生成安全的 JSON 数据供 Prompt 模板使用 pub fn sanitize_lab_report(&self, report: &LabReportDto) -> AiResult { // LabReportDto 已由 HealthDataProvider 脱敏 // 此处做二次验证 + 转换为模板友好的 JSON let sanitized = serde_json::to_value(report) .map_err(|e| AiError::SanitizationError(format!("序列化失败: {e}")))?; self.verify_no_pii(&sanitized)?; Ok(sanitized) } pub fn sanitize_vital_signs(&self, signs: &[VitalSignDto]) -> AiResult { let sanitized = serde_json::to_value(signs) .map_err(|e| AiError::SanitizationError(format!("序列化失败: {e}")))?; self.verify_no_pii(&sanitized)?; Ok(sanitized) } pub fn sanitize_patient_summary(&self, summary: &PatientSummaryDto) -> AiResult { let sanitized = serde_json::to_value(summary) .map_err(|e| AiError::SanitizationError(format!("序列化失败: {e}")))?; self.verify_no_pii(&sanitized)?; Ok(sanitized) } pub fn sanitize_health_report(&self, report: &HealthReportDto) -> AiResult { let sanitized = serde_json::to_value(report) .map_err(|e| AiError::SanitizationError(format!("序列化失败: {e}")))?; self.verify_no_pii(&sanitized)?; Ok(sanitized) } /// 二次验证: 确保没有意外泄漏的 PII fn verify_no_pii(&self, value: &Value) -> AiResult<()> { let pii_keys = ["name", "phone", "id_number", "address", "birth_date", "email"]; if let Value::Object(map) = value { for key in pii_keys { if map.contains_key(key) { return Err(AiError::SanitizationError( format!("检测到疑似 PII 字段: {key}"), )); } } } Ok(()) } } ``` - [ ] **Step 2: 创建 prompt/mod.rs** ```rust // crates/erp-ai/src/prompt/mod.rs use handlebars::Handlebars; use serde_json::Value; use crate::error::{AiError, AiResult}; /// Prompt 模板渲染引擎 pub struct PromptRenderer { registry: Handlebars<'static>, } impl PromptRenderer { pub fn new() -> Self { let mut registry = Handlebars::new(); registry.set_strict_mode(true); Self { registry } } /// 渲染 Prompt 模板 — 使用 Handlebars {{variable}} 语法 /// JSON 序列化注入,不做字符串拼接,防止 Prompt 注入 pub fn render(&self, template: &str, data: &Value) -> AiResult { self.registry .render_template(template, data) .map_err(|e| AiError::TemplateError(format!("模板渲染失败: {e}"))) } } ``` - [ ] **Step 3: 更新 lib.rs** ```rust pub mod prompt; pub mod sanitization; ``` - [ ] **Step 4: 验证编译** ```bash cargo check -p erp-ai ``` - [ ] **Step 5: 提交** ```bash git add crates/erp-ai/src/ git commit -m "feat(ai): 数据脱敏服务 + Prompt 模板渲染引擎" ``` --- ## Chunk 4: Service 层 — AnalysisService 核心编排 ### Task 8: AnalysisService — 分析管道核心逻辑 **Files:** - Create: `crates/erp-ai/src/service/mod.rs` - Create: `crates/erp-ai/src/service/analysis.rs` - Create: `crates/erp-ai/src/service/prompt.rs` - Create: `crates/erp-ai/src/service/usage.rs` - [ ] **Step 1: 创建 service/mod.rs** ```rust // crates/erp-ai/src/service/mod.rs pub mod analysis; pub mod prompt; pub mod usage; ``` - [ ] **Step 2: 创建 service/prompt.rs — Prompt CRUD** ```rust // crates/erp-ai/src/service/prompt.rs use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set}; use uuid::Uuid; use crate::entity::ai_prompt; use crate::error::{AiError, AiResult}; pub struct PromptService { pub db: sea_orm::DatabaseConnection, } impl PromptService { pub fn new(db: sea_orm::DatabaseConnection) -> Self { Self { db } } /// 获取当前激活的 Prompt 模板 pub async fn get_active_prompt( &self, tenant_id: Uuid, name: &str, ) -> AiResult { ai_prompt::Entity::find() .filter(ai_prompt::Column::TenantId.eq(tenant_id)) .filter(ai_prompt::Column::Name.eq(name)) .filter(ai_prompt::Column::IsActive.eq(true)) .filter(ai_prompt::Column::DeletedAt.is_null()) .one(&self.db) .await? .ok_or_else(|| AiError::PromptNotFound(name.into())) } /// 新建 Prompt 版本 pub async fn create_prompt( &self, tenant_id: Uuid, user_id: Uuid, name: String, system_prompt: String, user_prompt_template: String, model_config: serde_json::Value, category: String, ) -> AiResult { let id = Uuid::now_v7(); let now = chrono::Utc::now(); let active = ai_prompt::ActiveModel { id: Set(id), tenant_id: Set(tenant_id), name: Set(name), description: Set(String::new()), system_prompt: Set(system_prompt), user_prompt_template: Set(user_prompt_template), variables_schema: Set(None), model_config: Set(model_config), version: Set(1), is_active: Set(true), category: Set(category), tags: Set(None), created_at: Set(now), updated_at: Set(now), created_by: Set(Some(user_id)), updated_by: Set(Some(user_id)), deleted_at: Set(None), version_lock: Set(1), }; Ok(active.insert(&self.db).await?) } } ``` - [ ] **Step 3: 创建 service/usage.rs — 用量记录** ```rust // crates/erp-ai/src/service/usage.rs use sea_orm::ActiveModelTrait; use uuid::Uuid; use crate::entity::ai_usage; use crate::error::AiResult; pub struct UsageService { pub db: sea_orm::DatabaseConnection, } impl UsageService { pub fn new(db: sea_orm::DatabaseConnection) -> Self { Self { db } } pub async fn log_usage( &self, tenant_id: Uuid, provider: &str, model: &str, analysis_type: &str, input_tokens: u32, output_tokens: u32, duration_ms: u64, cost_cents: i32, is_cache_hit: bool, ) -> AiResult { let id = Uuid::now_v7(); let active = ai_usage::ActiveModel { id: Set(id), tenant_id: Set(tenant_id), provider: Set(provider.into()), model: Set(model.into()), analysis_type: Set(analysis_type.into()), input_tokens: Set(input_tokens as i32), output_tokens: Set(output_tokens as i32), duration_ms: Set(duration_ms as i32), cost_cents: Set(cost_cents), is_cache_hit: Set(is_cache_hit), created_at: Set(chrono::Utc::now()), }; Ok(active.insert(&self.db).await?) } } ``` > `Set` 需要 `use sea_orm::Set;` - [ ] **Step 4: 创建 service/analysis.rs — 核心编排** ```rust // crates/erp-ai/src/service/analysis.rs use async_stream::stream; use futures::{Stream, StreamExt}; use sha2::{Digest, Sha256}; use std::pin::Pin; use uuid::Uuid; use crate::dto::{AnalysisType, GenerateRequest, StreamChunk}; use crate::entity::ai_analysis; use crate::error::{AiError, AiResult}; use crate::prompt::PromptRenderer; use crate::provider::AiProvider; use crate::sanitization::SanitizationService; pub struct AnalysisService { pub provider: Box, pub sanitizer: SanitizationService, pub renderer: PromptRenderer, pub db: sea_orm::DatabaseConnection, } impl AnalysisService { pub fn new( provider: Box, db: sea_orm::DatabaseConnection, ) -> Self { Self { provider, sanitizer: SanitizationService::new(), renderer: PromptRenderer::new(), db, } } /// 执行流式分析 — 返回 SSE 事件流 pub async fn stream_analyze( &self, tenant_id: Uuid, user_id: Uuid, patient_id: Uuid, analysis_type: AnalysisType, source_ref: String, system_prompt: String, user_template: String, sanitized_data: serde_json::Value, model: String, temperature: f32, max_tokens: u32, ) -> AiResult<( Pin> + Send>>, uuid::Uuid, String, )> { let analysis_id = Uuid::now_v7(); let input_hash = self.compute_hash(&sanitized_data); let provider_name = self.provider.name().to_string(); // 1. 渲染 Prompt let user_prompt = self.renderer.render(&user_template, &sanitized_data)?; // 2. 创建分析记录 self.create_analysis_record( analysis_id, tenant_id, patient_id, analysis_type.as_str(), &source_ref, &input_hash, &provider_name, &model, ).await?; // 3. 调用 AI 流式生成 let req = GenerateRequest { system_prompt, user_prompt, model, temperature, max_tokens, }; let stream = self.provider.stream_generate(req).await?; Ok((stream, analysis_id, provider_name)) } /// 更新分析记录为完成 pub async fn complete_analysis( &self, analysis_id: Uuid, content: String, metadata: serde_json::Value, ) -> AiResult<()> { let entity: Option = ai_analysis::Entity::find_by_id(analysis_id) .one(&self.db) .await? .ok_or_else(|| AiError::AnalysisNotFound(analysis_id.to_string()))?; let mut active: ai_analysis::ActiveModel = entity.into(); active.status = sea_orm::Set("completed".into()); active.result_content = sea_orm::Set(Some(content)); active.result_metadata = sea_orm::Set(Some(metadata)); active.updated_at = sea_orm::Set(chrono::Utc::now()); active.update(&self.db).await?; Ok(()) } /// 标记分析失败 pub async fn fail_analysis(&self, analysis_id: Uuid, error: String) -> AiResult<()> { let entity: Option = ai_analysis::Entity::find_by_id(analysis_id) .one(&self.db) .await? .ok_or_else(|| AiError::AnalysisNotFound(analysis_id.to_string()))?; let mut active: ai_analysis::ActiveModel = entity.into(); active.status = sea_orm::Set("failed".into()); active.error_message = sea_orm::Set(Some(error)); active.updated_at = sea_orm::Set(chrono::Utc::now()); active.update(&self.db).await?; Ok(()) } /// 查找缓存 pub async fn find_cached( &self, tenant_id: Uuid, input_hash: &str, prompt_version: i32, ) -> AiResult> { let result = ai_analysis::Entity::find() .filter(ai_analysis::Column::TenantId.eq(tenant_id)) .filter(ai_analysis::Column::InputDataHash.eq(input_hash)) .filter(ai_analysis::Column::PromptVersion.eq(prompt_version)) .filter(ai_analysis::Column::Status.eq("completed")) .filter(ai_analysis::Column::DeletedAt.is_null()) .one(&self.db) .await?; Ok(result) } fn compute_hash(&self, data: &serde_json::Value) -> String { let canonical = serde_json::to_string(data).unwrap_or_default(); let mut hasher = Sha256::new(); hasher.update(canonical.as_bytes()); hex::encode(hasher.finalize()) } async fn create_analysis_record( &self, id: Uuid, tenant_id: Uuid, patient_id: Uuid, analysis_type: &str, source_ref: &str, input_hash: &str, provider: &str, model: &str, ) -> AiResult<()> { let now = chrono::Utc::now(); let active = ai_analysis::ActiveModel { id: sea_orm::Set(id), tenant_id: sea_orm::Set(tenant_id), patient_id: sea_orm::Set(patient_id), analysis_type: sea_orm::Set(analysis_type.into()), source_ref: sea_orm::Set(source_ref.into()), prompt_id: sea_orm::Set(Uuid::nil()), // Phase 1 填充 prompt_version: sea_orm::Set(1), model_used: sea_orm::Set(model.into()), input_data_hash: sea_orm::Set(input_hash.into()), sanitized_input: sea_orm::Set(None), result_content: sea_orm::Set(None), result_metadata: sea_orm::Set(None), status: sea_orm::Set("streaming".into()), error_message: sea_orm::Set(None), created_at: sea_orm::Set(now), updated_at: sea_orm::Set(now), created_by: sea_orm::Set(None), updated_by: sea_orm::Set(None), deleted_at: sea_orm::Set(None), version_lock: sea_orm::Set(1), }; active.insert(&self.db).await?; Ok(()) } } ``` > 注意: 需要添加 `use sea_orm::{ActiveModelTrait, EntityTrait, QueryFilter, Set, ColumnTrait};` - [ ] **Step 5: 更新 lib.rs** ```rust pub mod service; ``` - [ ] **Step 6: 验证编译** ```bash cargo check -p erp-ai ``` - [ ] **Step 7: 提交** ```bash git add crates/erp-ai/src/ git commit -m "feat(ai): AnalysisService 核心编排 + PromptService + UsageService" ``` --- ## Chunk 5: Handler + State + Module + erp-server 集成 ### Task 9: State + Module 定义 **Files:** - Create: `crates/erp-ai/src/state.rs` - Create: `crates/erp-ai/src/module.rs` - [ ] **Step 1: 创建 state.rs** ```rust // crates/erp-ai/src/state.rs use std::sync::Arc; use erp_core::EventBus; use sea_orm::DatabaseConnection; use crate::provider::AiProvider; use crate::service::analysis::AnalysisService; use crate::service::prompt::PromptService; use crate::service::usage::UsageService; #[derive(Clone)] pub struct AiState { pub db: DatabaseConnection, pub event_bus: EventBus, pub analysis: Arc, pub prompt: Arc, pub usage: Arc, } ``` - [ ] **Step 2: 创建 module.rs — ErpModule + 路由注册** ```rust // crates/erp-ai/src/module.rs use async_trait::async_trait; use axum::Router; use erp_core::module::{ErpModule, ModuleContext, ModuleType, PermissionDescriptor}; use erp_core::AppResult; use std::any::Any; use uuid::Uuid; use crate::handler; pub struct AiModule; #[async_trait] impl ErpModule for AiModule { fn name(&self) -> &str { "ai" } fn module_type(&self) -> ModuleType { ModuleType::Builtin } fn dependencies(&self) -> Vec<&str> { vec!["health"] } async fn on_startup(&self, _ctx: &ModuleContext) -> AppResult<()> { Ok(()) } fn permissions(&self) -> Vec { vec![ PermissionDescriptor { code: "ai.analysis.list".into(), name: "查看分析历史".into(), description: "查看 AI 分析结果历史记录".into(), module: "ai".into(), }, PermissionDescriptor { code: "ai.analysis.manage".into(), name: "请求分析".into(), description: "发起 AI 分析请求".into(), module: "ai".into(), }, PermissionDescriptor { code: "ai.prompt.list".into(), name: "查看 Prompt".into(), description: "查看 AI Prompt 模板列表".into(), module: "ai".into(), }, PermissionDescriptor { code: "ai.prompt.manage".into(), name: "管理 Prompt".into(), description: "创建/编辑/激活/回滚 Prompt 模板".into(), module: "ai".into(), }, PermissionDescriptor { code: "ai.usage.list".into(), name: "查看用量".into(), description: "查看 AI 用量统计".into(), module: "ai".into(), }, PermissionDescriptor { code: "ai.provider.manage".into(), name: "管理提供商".into(), description: "管理 AI 提供商配置".into(), module: "ai".into(), }, ] } fn as_any(&self) -> &dyn Any { self } } impl AiModule { pub fn public_routes() -> Router where crate::state::AiState: axum::extract::FromRef, S: Clone + Send + Sync + 'static, { Router::new() } pub fn protected_routes() -> Router where crate::state::AiState: axum::extract::FromRef, S: Clone + Send + Sync + 'static, { Router::new() .route("/ai/analyze/lab-report", axum::routing::post(handler::stream_lab_report::)) .route("/ai/analyze/trends", axum::routing::post(handler::stream_trends::)) .route("/ai/analyze/checkup-plan", axum::routing::post(handler::stream_checkup_plan::)) .route("/ai/analyze/report-summary", axum::routing::post(handler::stream_report_summary::)) .route("/ai/analysis/history", axum::routing::get(handler::list_analysis::)) .route("/ai/analysis/{id}", axum::routing::get(handler::get_analysis::)) } } ``` - [ ] **Step 3: 更新 lib.rs 添加 re-export** ```rust pub use module::AiModule; pub use state::AiState; ``` 以及 `pub mod module; pub mod state;` - [ ] **Step 4: 验证编译 (handler 还没写,先创建 stub)** 创建 `crates/erp-ai/src/handler/mod.rs`: ```rust // crates/erp-ai/src/handler/mod.rs — stub,Chunk 6 完善 use axum::response::IntoResponse; pub async fn stream_lab_report() -> impl IntoResponse { "stub" } pub async fn stream_trends() -> impl IntoResponse { "stub" } pub async fn stream_checkup_plan() -> impl IntoResponse { "stub" } pub async fn stream_report_summary() -> impl IntoResponse { "stub" } pub async fn list_analysis() -> impl IntoResponse { "stub" } pub async fn get_analysis() -> impl IntoResponse { "stub" } ``` ```bash cargo check -p erp-ai ``` - [ ] **Step 5: 提交** ```bash git add crates/erp-ai/src/ git commit -m "feat(ai): AiState + AiModule (ErpModule impl + 权限 + 路由骨架)" ``` --- ### Task 10: erp-server 集成 — Config + State + 路由注册 **Files:** - Modify: `crates/erp-server/src/config.rs` — 添加 AiConfig - Modify: `crates/erp-server/src/state.rs` — 添加 FromRef - Modify: `crates/erp-server/src/main.rs` — 注册模块 + 合并路由 - Modify: `crates/erp-server/Cargo.toml` — 添加 erp-ai 依赖 - Modify: `crates/erp-server/config/default.toml` — 添加 [ai] 段 - [ ] **Step 1: erp-server/Cargo.toml 添加 erp-ai** ```toml erp-ai.workspace = true ``` - [ ] **Step 2: config.rs 添加 AiConfig** ```rust // 在 AppConfig 结构体中添加: pub ai: AiConfig, // 新增结构体: #[derive(Debug, Clone, Deserialize)] pub struct AiConfig { pub default_provider: String, pub api_key: String, pub base_url: Option, pub model: String, pub max_tokens: u32, pub temperature: f32, pub cache_ttl_seconds: u64, pub rate_limit_patient_daily: u32, } ``` - [ ] **Step 3: config/default.toml 添加 [ai] 段** ```toml [ai] default_provider = "claude" api_key = "" base_url = "https://api.anthropic.com" model = "claude-sonnet-4-6" max_tokens = 2048 temperature = 0.3 cache_ttl_seconds = 604800 rate_limit_patient_daily = 10 ``` - [ ] **Step 4: state.rs 添加 FromRef** ```rust impl FromRef for erp_ai::AiState { fn from_ref(state: &AppState) -> Self { // 从 config 构建 ClaudeProvider let provider = erp_ai::provider::claude::ClaudeProvider::new( state.config.ai.api_key.clone(), ); let db = state.db.clone(); let event_bus = state.event_bus.clone(); let analysis = std::sync::Arc::new( erp_ai::service::analysis::AnalysisService::new( Box::new(provider), db.clone(), ) ); let prompt = std::sync::Arc::new( erp_ai::service::prompt::PromptService::new(db.clone()) ); let usage = std::sync::Arc::new( erp_ai::service::usage::UsageService::new(db.clone()) ); Self { db, event_bus, analysis, prompt, usage } } } ``` - [ ] **Step 5: main.rs 注册模块 + 路由** ```rust // 1. 创建 AiModule let ai_module = erp_ai::AiModule; // 2. 注册到 registry let registry = ModuleRegistry::new() .register(auth_module) // ...existing... .register(ai_module); // 3. 合并路由 (protected_routes 中) .merge(erp_ai::AiModule::protected_routes()) ``` - [ ] **Step 6: 验证全 workspace 编译** ```bash cargo check --workspace ``` - [ ] **Step 7: 提交** ```bash git add crates/erp-server/ git commit -m "feat(server): erp-ai 模块集成 — Config/State/路由注册" ``` --- ## Chunk 6: 完善 SSE Handler + 端到端验证 ### Task 11: 实现 SSE 分析 Handler **Files:** - Rewrite: `crates/erp-ai/src/handler/mod.rs` - [ ] **Step 1: 完善 handler/mod.rs — SSE 流式分析** ```rust // crates/erp-ai/src/handler/mod.rs use axum::{ extract::{Extension, Path, Query, State}, response::sse::{Event, KeepAlive, Sse}, Json, }; use axum::extract::FromRef; use erp_core::rbac::require_permission; use erp_core::tenant::TenantContext; use futures::StreamExt; use serde::Deserialize; use std::convert::Infallible; use crate::dto::{AnalyzeOptions, AnalysisSseEvent, AnalysisType, TokenUsage}; use crate::state::AiState; // === 分析请求 Query/Body === #[derive(Debug, Deserialize)] pub struct AnalyzeBody { pub report_id: Option, pub patient_id: Option, pub metrics: Option>, pub options: Option, } // === SSE 分析端点 === pub async fn stream_lab_report( State(state): State, Extension(ctx): Extension, Json(body): Json, ) -> Result>>, erp_core::AppError> where AiState: FromRef, S: Clone + Send + Sync + 'static, { require_permission(&ctx, "ai.analysis.manage")?; let report_id = body.report_id.ok_or_else(|| erp_core::AppError::Validation("report_id 必填".into()))?; let prompt = state.prompt.get_active_prompt(ctx.tenant_id(), "lab_report_interpretation").await?; let model_config: serde_json::Value = serde_json::from_str(&serde_json::to_string(&prompt.model_config).unwrap_or_default()).unwrap_or_default(); let model = model_config["model"].as_str().unwrap_or("claude-sonnet-4-6").to_string(); let temperature = model_config["temperature"].as_f32().unwrap_or(0.3); let max_tokens = model_config["max_tokens"].as_u64().unwrap_or(2048) as u32; let source_ref = report_id.to_string(); let (stream, analysis_id, provider_name) = state.analysis.stream_analyze( ctx.tenant_id(), ctx.user_id(), uuid::Uuid::nil(), AnalysisType::LabReport, source_ref, prompt.system_prompt, prompt.user_prompt_template, serde_json::json!({"placeholder": true}), model, temperature, max_tokens, ).await?; let analysis_id_clone = analysis_id; let provider_name_clone = provider_name; let model_clone = model.clone(); let state_clone = state.clone(); let sse_stream = async_stream::stream! { let mut full_content = String::new(); let mut index: u32 = 0; let mut stream = std::pin::pin!(stream); while let Some(result) = stream.next().await { match result { Ok(chunk) => { full_content.push_str(&chunk); index += 1; let event = AnalysisSseEvent::Chunk { content: chunk, index }; let data = serde_json::to_string(&event).unwrap_or_default(); yield Ok(Event::default().data(data)); } Err(e) => { let event = AnalysisSseEvent::Error { message: e.to_string() }; let data = serde_json::to_string(&event).unwrap_or_default(); yield Ok(Event::default().data(data)); let _ = state_clone.analysis.fail_analysis(analysis_id_clone, e.to_string()).await; return; } } } // 完成: 存储结果 let metadata = serde_json::json!({ "model": model_clone, "provider": provider_name_clone, }); let _ = state_clone.analysis.complete_analysis(analysis_id_clone, full_content, metadata).await; let done_event = AnalysisSseEvent::Done { analysis_id: analysis_id_clone, status: "completed".into(), }; let data = serde_json::to_string(&done_event).unwrap_or_default(); yield Ok(Event::default().data(data)); }; Ok(Sse::new(sse_stream).keep_alive(KeepAlive::default())) } // === 趋势/方案/摘要端点 — 结构类似,切换 analysis_type 和 prompt name === macro_rules! analyze_endpoint { ($fn_name:ident, $analysis_type:expr, $prompt_name:literal) => { pub async fn $fn_name( State(state): State, Extension(ctx): Extension, Json(body): Json, ) -> Result>>, erp_core::AppError> where AiState: FromRef, S: Clone + Send + Sync + 'static, { require_permission(&ctx, "ai.analysis.manage")?; // 与 stream_lab_report 相同的流程,区别: // - analysis_type: $analysis_type // - prompt name: $prompt_name // - source_ref 来自 body.patient_id 或 body.report_id // 实现时从 stream_lab_report 复制并修改对应参数 todo!("参照 stream_lab_report 实现") } }; } analyze_endpoint!(stream_trends, AnalysisType::Trends, "health_trend_analysis"); analyze_endpoint!(stream_checkup_plan, AnalysisType::CheckupPlan, "personalized_checkup_plan"); analyze_endpoint!(stream_report_summary, AnalysisType::ReportSummary, "report_summary_generation"); // === 分析历史 === #[derive(Debug, Deserialize)] pub struct ListAnalysisQuery { pub patient_id: Option, pub analysis_type: Option, pub page: Option, pub page_size: Option, } pub async fn list_analysis( State(_state): State, Extension(ctx): Extension, Query(_params): Query, ) -> Result>, erp_core::AppError> where AiState: FromRef, S: Clone + Send + Sync + 'static, { require_permission(&ctx, "ai.analysis.list")?; // TODO: 查询 ai_analysis_results 表 Ok(Json(erp_core::ApiResponse::success(()))) } pub async fn get_analysis( State(_state): State, Extension(ctx): Extension, Path(_id): Path, ) -> Result>, erp_core::AppError> where AiState: FromRef, S: Clone + Send + Sync + 'static, { require_permission(&ctx, "ai.analysis.list")?; // TODO: 查询单条分析结果 Ok(Json(erp_core::ApiResponse::success(()))) } ``` > 注意: `erp_core::ApiResponse::success` 和 `erp_core::rbac::require_permission` 的实际签名需参照现有代码。宏 `analyze_endpoint!` 展开的函数暂时用 `todo!()`,实际实现时从 `stream_lab_report` 复制并修改参数。 - [ ] **Step 2: 验证编译** ```bash cargo check --workspace ``` - [ ] **Step 3: 提交** ```bash git add crates/erp-ai/src/handler/ git commit -m "feat(ai): SSE 流式分析 Handler 实现" ``` --- ### Task 12: 端到端验证 - [ ] **Step 1: 启动 PostgreSQL + 后端服务** ```bash cd crates/erp-server && cargo run ``` 验证: - 迁移自动执行,`ai_prompts`/`ai_analysis_results`/`ai_usage_logs` 三张表创建成功 - 服务启动无错误日志 - [ ] **Step 2: 检查 API 文档** 访问 `http://localhost:3000/api/docs/openapi.json`,确认 `/api/v1/ai/analyze/*` 端点已注册 - [ ] **Step 3: 数据库验证** ```sql SELECT table_name FROM information_schema.tables WHERE table_name LIKE 'ai_%'; ``` Expected: `ai_prompts`, `ai_analysis_results`, `ai_usage_logs` - [ ] **Step 4: 提交最终状态 + 推送** ```bash git push ``` --- ## 验证清单 - [ ] `cargo check --workspace` — 全 workspace 编译通过 - [ ] `cargo test --workspace` — 所有测试通过 - [ ] PostgreSQL 三张 AI 表存在 - [ ] `/api/v1/ai/analyze/lab-report` 端点在 Swagger UI 可见 - [ ] AI 权限码已同步到数据库 `permissions` 表 - [ ] `AiModule` 出现在启动日志的已注册模块列表中 --- ## 后续计划 (Phase 1 MVP 剩余) 以下任务在实际实现阶段细化: - **Task 13:** HealthDataProvider 实际数据查询实现 (替换 erp-health stub) - **Task 14:** 种子 Prompt 模板数据 (4 个默认模板通过迁移插入) - **Task 15:** Redis 缓存层 (缓存命中时跳过 AI 调用) - **Task 16:** 降级规则引擎 (50 条常见指标本地规则) - **Task 17:** 速率限制中间件 - **Task 18:** 小程序 AI 解读页面 (报告详情页 + SSE 渲染)