64 KiB
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或使用futures的pin_mut!宏。检查futures是否已导出Streamtrait。
- 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 — stub,Chunk 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::success和erp_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 渲染)