Files
hms/docs/superpowers/plans/2026-04-25-erp-ai-phase1-mvp.md
iven 2963e7ce63
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
docs(ai): 实施计划 Chunk 6 (SSE Handler + 端到端验证 + 后续任务)
2026-04-25 13:41:23 +08:00

64 KiB
Raw Blame History

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 目录

mkdir -p crates/erp-ai/src
  • Step 2: 创建 Cargo.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
// 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<AiError> 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<sea_orm::DbErr> for AiError {
    fn from(e: sea_orm::DbErr) -> Self {
        AiError::DbError(e.to_string())
    }
}

pub type AiResult<T> = Result<T, AiError>;

注意: 检查 AppError 是否有 ServiceUnavailable 变体。如果没有,使用 AppError::Internal 替代。

  • Step 4: 创建 lib.rs (最小骨架)
// 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] 中添加:

erp-ai = { path = "crates/erp-ai" }

同时确认以下依赖在 workspace dependencies 中存在(如不存在则添加):

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: 验证编译
cargo check -p erp-ai

Expected: 编译通过,无错误

  • Step 7: 提交
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
// 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<LabReportDto>;

    /// 获取生命体征趋势数据
    async fn get_vital_signs(
        &self,
        tenant_id: Uuid,
        patient_id: Uuid,
        metrics: &[String],
        range: &TimeRange,
    ) -> AppResult<Vec<VitalSignDto>>;

    /// 获取患者摘要(用于个性化方案)
    async fn get_patient_summary(
        &self,
        tenant_id: Uuid,
        patient_id: Uuid,
    ) -> AppResult<PatientSummaryDto>;

    /// 获取完整健康报告(用于摘要生成)
    async fn get_full_report(
        &self,
        tenant_id: Uuid,
        report_id: Uuid,
    ) -> AppResult<HealthReportDto>;
}

// === DTO 定义 ===

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TimeRange {
    pub start: chrono::DateTime<chrono::Utc>,
    pub end: chrono::DateTime<chrono::Utc>,
}

#[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<LabItemDto>,
}

#[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<String>,
    pub medications: Vec<String>,
    pub family_history: Vec<String>,
    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<ReportSectionDto>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReportSectionDto {
    pub title: String,
    pub findings: Vec<String>,
    pub abnormal_items: Vec<String>,
}
  • Step 2: 在 erp-core/src/lib.rs 中添加 pub mod
pub mod health_provider;

并添加 re-export:

pub use health_provider::{
    HealthDataProvider, LabItemDto, LabReportDto, PatientSummaryDto,
    HealthReportDto, ReportSectionDto, TimeRange, VitalSignDto,
};
  • Step 3: 验证 erp-core 编译
cargo check -p erp-core
  • Step 4: 提交 erp-core 扩展
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 实现
// 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<LabReportDto> {
        todo!("Chunk 5: 实现化验报告数据查询")
    }

    async fn get_vital_signs(
        &self,
        _tenant_id: Uuid,
        _patient_id: Uuid,
        _metrics: &[String],
        _range: &TimeRange,
    ) -> AppResult<Vec<VitalSignDto>> {
        todo!("Chunk 5: 实现生命体征趋势查询")
    }

    async fn get_patient_summary(
        &self,
        _tenant_id: Uuid,
        _patient_id: Uuid,
    ) -> AppResult<PatientSummaryDto> {
        todo!("Chunk 5: 实现患者摘要查询")
    }

    async fn get_full_report(
        &self,
        _tenant_id: Uuid,
        _report_id: Uuid,
    ) -> AppResult<HealthReportDto> {
        todo!("Chunk 5: 实现完整报告查询")
    }
}
  • Step 2: 在 erp-health/src/lib.rs 添加 pub mod
pub mod health_provider_impl;
pub use health_provider_impl::HealthDataProviderImpl;
  • Step 3: 验证全 workspace 编译
cargo check --workspace

Expected: 编译通过stub 的 todo! 不影响编译)

  • Step 4: 提交
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: 创建迁移文件
// 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![] 末尾添加:

Box::new(m20260425_000050_create_ai_tables::Migration),
  • Step 3: 验证迁移编译
cargo check -p erp-server
  • Step 4: 提交
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

// crates/erp-ai/src/entity/mod.rs
pub mod ai_analysis;
pub mod ai_prompt;
pub mod ai_usage;
  • Step 2: 创建 ai_prompt.rs
// 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<serde_json::Value>,
    pub model_config: serde_json::Value,
    pub version: i32,
    pub is_active: bool,
    pub category: String,
    pub tags: Option<serde_json::Value>,
    pub created_at: DateTimeUtc,
    pub updated_at: DateTimeUtc,
    pub created_by: Option<Uuid>,
    pub updated_by: Option<Uuid>,
    pub deleted_at: Option<DateTimeUtc>,
    pub version_lock: i32,
}

#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}

impl ActiveModelBehavior for ActiveModel {}
  • Step 3: 创建 ai_analysis.rs
// 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<serde_json::Value>,
    pub result_content: Option<String>,
    pub result_metadata: Option<serde_json::Value>,
    pub status: String,
    pub error_message: Option<String>,
    pub created_at: DateTimeUtc,
    pub updated_at: DateTimeUtc,
    pub created_by: Option<Uuid>,
    pub updated_by: Option<Uuid>,
    pub deleted_at: Option<DateTimeUtc>,
    pub version_lock: i32,
}

#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}

impl ActiveModelBehavior for ActiveModel {}
  • Step 4: 创建 ai_usage.rs
// 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 中添加:

pub mod entity;
  • Step 6: 验证编译
cargo check -p erp-ai
  • Step 7: 提交
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 定义

// 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<String>,  // patient_friendly | professional
    pub language: Option<String>,      // 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
// 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<Box<dyn Stream>> 避免 async_trait + impl Trait 不兼容
    async fn stream_generate(
        &self,
        req: GenerateRequest,
    ) -> AiResult<Pin<Box<dyn Stream<Item = AiResult<String>> + Send>>>;

    /// 非流式生成
    async fn generate(&self, req: GenerateRequest) -> AiResult<crate::dto::GenerateResponse>;

    /// 提供商名称
    fn name(&self) -> &str;

    /// 健康检查
    async fn health_check(&self) -> AiResult<bool>;
}

注意: 需要在 Cargo.toml 中添加 pin-project-lite 或使用 futurespin_mut! 宏。检查 futures 是否已导出 Stream trait。

  • Step 3: 创建 provider/claude.rs (Claude API SSE 实现)
// 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<ClaudeMessage>,
    stream: bool,
}

#[derive(Serialize)]
struct ClaudeMessage {
    role: String,
    content: String,
}

#[derive(Deserialize)]
struct ClaudeStreamEvent {
    #[serde(rename = "type")]
    event_type: String,
    delta: Option<ClaudeDelta>,
    message: Option<ClaudeMessageResp>,
}

#[derive(Deserialize)]
struct ClaudeDelta {
    text: Option<String>,
}

#[derive(Deserialize)]
struct ClaudeMessageResp {
    usage: Option<ClaudeUsage>,
}

#[derive(Deserialize)]
struct ClaudeUsage {
    input_tokens: u32,
    output_tokens: u32,
}

#[async_trait]
impl AiProvider for ClaudeProvider {
    async fn stream_generate(
        &self,
        req: GenerateRequest,
    ) -> AiResult<Pin<Box<dyn Stream<Item = AiResult<String>> + 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::<ClaudeStreamEvent>(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<crate::dto::GenerateResponse> {
        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<bool> {
        // 简单检查: 发一个最小请求验证 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 添加新模块
// 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: 验证编译
cargo check -p erp-ai

如果 pin-project-lite 缺失,在 Cargo.toml 中添加,或直接使用 std::pin::Pin<Box<...>> 无需 pin-project。

  • Step 6: 提交
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

// 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<Value> {
        // 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<Value> {
        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<Value> {
        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<Value> {
        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
// 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<String> {
        self.registry
            .render_template(template, data)
            .map_err(|e| AiError::TemplateError(format!("模板渲染失败: {e}")))
    }
}
  • Step 3: 更新 lib.rs
pub mod prompt;
pub mod sanitization;
  • Step 4: 验证编译
cargo check -p erp-ai
  • Step 5: 提交
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

// crates/erp-ai/src/service/mod.rs
pub mod analysis;
pub mod prompt;
pub mod usage;
  • Step 2: 创建 service/prompt.rs — Prompt CRUD
// 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::Model> {
        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<ai_prompt::Model> {
        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 — 用量记录
// 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<ai_usage::Model> {
        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 — 核心编排
// 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<dyn AiProvider>,
    pub sanitizer: SanitizationService,
    pub renderer: PromptRenderer,
    pub db: sea_orm::DatabaseConnection,
}

impl AnalysisService {
    pub fn new(
        provider: Box<dyn AiProvider>,
        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<Box<dyn Stream<Item = AiResult<String>> + 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::Model> = 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::Model> = 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<Option<ai_analysis::Model>> {
        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
pub mod service;
  • Step 6: 验证编译
cargo check -p erp-ai
  • Step 7: 提交
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

// 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<AnalysisService>,
    pub prompt: Arc<PromptService>,
    pub usage: Arc<UsageService>,
}
  • Step 2: 创建 module.rs — ErpModule + 路由注册
// 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<PermissionDescriptor> {
        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<S>() -> Router<S>
    where
        crate::state::AiState: axum::extract::FromRef<S>,
        S: Clone + Send + Sync + 'static,
    {
        Router::new()
    }

    pub fn protected_routes<S>() -> Router<S>
    where
        crate::state::AiState: axum::extract::FromRef<S>,
        S: Clone + Send + Sync + 'static,
    {
        Router::new()
            .route("/ai/analyze/lab-report", axum::routing::post(handler::stream_lab_report::<S>))
            .route("/ai/analyze/trends", axum::routing::post(handler::stream_trends::<S>))
            .route("/ai/analyze/checkup-plan", axum::routing::post(handler::stream_checkup_plan::<S>))
            .route("/ai/analyze/report-summary", axum::routing::post(handler::stream_report_summary::<S>))
            .route("/ai/analysis/history", axum::routing::get(handler::list_analysis::<S>))
            .route("/ai/analysis/{id}", axum::routing::get(handler::get_analysis::<S>))
    }
}
  • Step 3: 更新 lib.rs 添加 re-export
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:

// crates/erp-ai/src/handler/mod.rs — stubChunk 6 完善
use axum::response::IntoResponse;

pub async fn stream_lab_report<S>() -> impl IntoResponse { "stub" }
pub async fn stream_trends<S>() -> impl IntoResponse { "stub" }
pub async fn stream_checkup_plan<S>() -> impl IntoResponse { "stub" }
pub async fn stream_report_summary<S>() -> impl IntoResponse { "stub" }
pub async fn list_analysis<S>() -> impl IntoResponse { "stub" }
pub async fn get_analysis<S>() -> impl IntoResponse { "stub" }
cargo check -p erp-ai
  • Step 5: 提交
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

erp-ai.workspace = true
  • Step 2: config.rs 添加 AiConfig
// 在 AppConfig 结构体中添加:
pub ai: AiConfig,

// 新增结构体:
#[derive(Debug, Clone, Deserialize)]
pub struct AiConfig {
    pub default_provider: String,
    pub api_key: String,
    pub base_url: Option<String>,
    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] 段
[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
impl FromRef<AppState> 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 注册模块 + 路由
// 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 编译
cargo check --workspace
  • Step 7: 提交
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 流式分析

// 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<uuid::Uuid>,
    pub patient_id: Option<uuid::Uuid>,
    pub metrics: Option<Vec<String>>,
    pub options: Option<AnalyzeOptions>,
}

// === SSE 分析端点 ===

pub async fn stream_lab_report<S>(
    State(state): State<AiState>,
    Extension(ctx): Extension<TenantContext>,
    Json(body): Json<AnalyzeBody>,
) -> Result<Sse<impl futures::Stream<Item = Result<Event, Infallible>>>, erp_core::AppError>
where
    AiState: FromRef<S>,
    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<S>(
            State(state): State<AiState>,
            Extension(ctx): Extension<TenantContext>,
            Json(body): Json<AnalyzeBody>,
        ) -> Result<Sse<impl futures::Stream<Item = Result<Event, Infallible>>>, erp_core::AppError>
        where
            AiState: FromRef<S>,
            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<uuid::Uuid>,
    pub analysis_type: Option<String>,
    pub page: Option<u64>,
    pub page_size: Option<u64>,
}

pub async fn list_analysis<S>(
    State(_state): State<AiState>,
    Extension(ctx): Extension<TenantContext>,
    Query(_params): Query<ListAnalysisQuery>,
) -> Result<Json<erp_core::ApiResponse<()>>, erp_core::AppError>
where
    AiState: FromRef<S>,
    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<S>(
    State(_state): State<AiState>,
    Extension(ctx): Extension<TenantContext>,
    Path(_id): Path<uuid::Uuid>,
) -> Result<Json<erp_core::ApiResponse<()>>, erp_core::AppError>
where
    AiState: FromRef<S>,
    S: Clone + Send + Sync + 'static,
{
    require_permission(&ctx, "ai.analysis.list")?;
    // TODO: 查询单条分析结果
    Ok(Json(erp_core::ApiResponse::success(())))
}

注意: erp_core::ApiResponse::successerp_core::rbac::require_permission 的实际签名需参照现有代码。宏 analyze_endpoint! 展开的函数暂时用 todo!(),实际实现时从 stream_lab_report 复制并修改参数。

  • Step 2: 验证编译
cargo check --workspace
  • Step 3: 提交
git add crates/erp-ai/src/handler/
git commit -m "feat(ai): SSE 流式分析 Handler 实现"

Task 12: 端到端验证

  • Step 1: 启动 PostgreSQL + 后端服务
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: 数据库验证
SELECT table_name FROM information_schema.tables WHERE table_name LIKE 'ai_%';

Expected: ai_prompts, ai_analysis_results, ai_usage_logs

  • Step 4: 提交最终状态 + 推送
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 渲染)