11 个 Task:DTO 枚举/迁移/Entity/解析器/规则引擎/Service/Handler集成/API端点 已通过 plan review,修复了 dto/ 模块拆分、version_lock 命名、乐观锁、tenant_id 过滤
34 KiB
AI→行动闭环 实施计划
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: 实现 AI 分析结果到可执行行动的闭环系统——双通道输出、BPMN 编排分级自动化、前后对比评估。
Architecture: 在 erp-ai 中新增结构化输出解析层和 SuggestionService;扩展事件 payload 触发 erp-workflow BPMN 流程;erp-health 消费工作流事件执行随访/预约/预警行动。
Tech Stack: Rust / SeaORM / Axum / EventBus / BPMN (erp-workflow) / Handlebars / React + Ant Design
Spec: docs/superpowers/specs/2026-05-01-ai-action-loop-design.md
文件变更总览
新建文件
| 文件 | 职责 |
|---|---|
crates/erp-ai/src/dto/suggestion.rs |
SuggestionType/RiskLevel/SuggestionStatus 枚举 + StructuredOutput/StructuredSuggestion DTO |
crates/erp-ai/src/service/suggestion.rs |
SuggestionService — CRUD + 状态流转 |
crates/erp-ai/src/service/output_parser.rs |
双通道输出解析(文本/JSON 分割 + Schema 校验) |
crates/erp-ai/src/service/local_rules.rs |
本地临床规则引擎(AI 不可用时回退) |
crates/erp-ai/src/entity/ai_suggestion.rs |
ai_suggestion 表 SeaORM Entity |
crates/erp-ai/src/entity/ai_risk_threshold.rs |
ai_risk_threshold 表 SeaORM Entity |
crates/erp-server/migration/src/m20260502_000098_create_ai_suggestion.rs |
ai_suggestion 表迁移 |
crates/erp-server/migration/src/m20260502_000099_create_ai_risk_threshold.rs |
ai_risk_threshold 表迁移 |
crates/erp-ai/src/handler/suggestion_handler.rs |
建议 CRUD API 端点 |
crates/erp-health/src/service/ai_action_dispatcher.rs |
AI 行动分发(风险分级 → 调用对应服务) |
docs/superpowers/plans/2026-05-01-ai-action-loop-plan-chunk2.md |
Chunk 2 计划(事件集成 + BPMN) |
docs/superpowers/plans/2026-05-01-ai-action-loop-plan-chunk3.md |
Chunk 3 计划(闭环对比 + 前端) |
修改文件
| 文件 | 变更 |
|---|---|
crates/erp-ai/src/dto.rs |
拆分为 dto/ 模块,保留原 AnalysisType 等 |
crates/erp-ai/src/entity/mod.rs |
添加新 entity 引用 |
crates/erp-ai/src/service/mod.rs |
添加新 service 引用 |
crates/erp-ai/src/handler/mod.rs |
build_sse_stream 调用 output_parser,扩展事件 payload |
crates/erp-ai/src/lib.rs |
添加 dto/suggestion re-export |
crates/erp-ai/src/module.rs |
注册新权限码和建议路由 |
crates/erp-ai/src/state.rs |
添加 suggestion service 到 AiState |
crates/erp-health/src/event.rs |
新增 ai.analysis.completed 消费者(行动分发) |
crates/erp-server/migration/src/lib.rs |
注册新迁移 |
crates/erp-server/src/main.rs |
初始化新 service、注册 seed 数据 |
Chunk 1: 数据层 + 输出解析(Phase 1)
Task 1: 新增 Suggestion 相关枚举和 DTO
Files:
-
Rename:
crates/erp-ai/src/dto.rs→crates/erp-ai/src/dto/mod.rs -
Create:
crates/erp-ai/src/dto/suggestion.rs -
Step 0: 将 dto.rs 重构为 dto/ 目录模块
Rust 不允许同时存在 dto.rs 和 dto/ 目录。需要:
- 将
crates/erp-ai/src/dto.rs重命名为crates/erp-ai/src/dto/mod.rs - 在
dto/mod.rs底部添加pub mod suggestion;
- Step 1: 创建 suggestion DTO 文件
// crates/erp-ai/src/dto/suggestion.rs
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// 建议类型:随访 / 预约 / 预警
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SuggestionType {
Followup,
Appointment,
Alert,
}
impl SuggestionType {
pub fn as_str(&self) -> &str {
match self {
Self::Followup => "followup",
Self::Appointment => "appointment",
Self::Alert => "alert",
}
}
}
/// 风险等级
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RiskLevel {
Low,
Medium,
High,
}
impl RiskLevel {
pub fn as_str(&self) -> &str {
match self {
Self::Low => "low",
Self::Medium => "medium",
Self::High => "high",
}
}
/// 低风险可自动执行,其他需人工确认
pub fn is_auto_executable(&self) -> bool {
matches!(self, Self::Low)
}
}
/// 建议状态
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SuggestionStatus {
Pending,
Approved,
Rejected,
Executed,
Expired,
ParseFailed,
}
impl SuggestionStatus {
pub fn as_str(&self) -> &str {
match self {
Self::Pending => "pending",
Self::Approved => "approved",
Self::Rejected => "rejected",
Self::Executed => "executed",
Self::Expired => "expired",
Self::ParseFailed => "parse_failed",
}
}
}
/// AI 输出的单条结构化建议
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StructuredSuggestion {
pub id: Option<Uuid>,
#[serde(rename = "type")]
pub suggestion_type: SuggestionType,
pub priority: u32,
pub timing: String,
pub reason: String,
pub params: serde_json::Value,
#[serde(default)]
pub auto_executable: bool,
}
/// AI 双通道输出的结构化部分
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StructuredOutput {
pub risk_level: RiskLevel,
pub risk_factors: Vec<String>,
pub suggestions: Vec<StructuredSuggestion>,
pub baseline_summary: serde_json::Value,
}
/// 解析后的双通道结果
#[derive(Debug, Clone)]
pub struct ParsedOutput {
pub text_content: String,
pub structured: Option<StructuredOutput>,
}
- Step 2: 确认 lib.rs 不需要修改
lib.rs 已有 pub mod dto;,dto.rs → dto/mod.rs 重构后这行无需变化。
- Step 3: 编写枚举单元测试
在同一文件底部 #[cfg(test)] mod tests 中添加 SuggestionType/RiskLevel/SuggestionStatus 的序列化往返测试和 is_auto_executable 测试。
-
Step 4: 运行
cargo check -p erp-ai验证编译通过 -
Step 5: 提交
git add crates/erp-ai/src/dto.rs crates/erp-ai/src/dto/
git commit -m "feat(ai): 新增 Suggestion/RiskLevel/SuggestionStatus 枚举和结构化输出 DTO"
Task 2: 数据库迁移 — ai_suggestion 表
Files:
-
Create:
crates/erp-server/migration/src/m20260502_000098_create_ai_suggestion.rs -
Modify:
crates/erp-server/migration/src/lib.rs -
Step 1: 创建迁移文件
// crates/erp-server/migration/src/m20260502_000098_create_ai_suggestion.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> {
manager.create_table(
Table::create()
.table(Alias::new("ai_suggestion"))
.col(ColumnDef::new(Alias::new("id"))
.uuid().not_null().primary_key()
.default(Expr::cust("gen_random_uuid()")))
.col(ColumnDef::new(Alias::new("tenant_id")).uuid().not_null())
.col(ColumnDef::new(Alias::new("analysis_id")).uuid().not_null())
.col(ColumnDef::new(Alias::new("suggestion_type"))
.string_len(20).not_null())
.col(ColumnDef::new(Alias::new("risk_level"))
.string_len(10).not_null())
.col(ColumnDef::new(Alias::new("params"))
.json_binary().not_null())
.col(ColumnDef::new(Alias::new("status"))
.string_len(20).not_null().default("pending"))
.col(ColumnDef::new(Alias::new("workflow_instance_id")).uuid())
.col(ColumnDef::new(Alias::new("action_result")).json_binary())
.col(ColumnDef::new(Alias::new("baseline_snapshot")).json_binary())
.col(ColumnDef::new(Alias::new("reanalysis_id")).uuid())
.col(ColumnDef::new(Alias::new("created_at"))
.timestamp_with_time_zone()
.default(Expr::cust("NOW()")))
.col(ColumnDef::new(Alias::new("updated_at"))
.timestamp_with_time_zone()
.default(Expr::cust("NOW()")))
.col(ColumnDef::new(Alias::new("created_by")).uuid())
.col(ColumnDef::new(Alias::new("updated_by")).uuid())
.col(ColumnDef::new(Alias::new("deleted_at"))
.timestamp_with_time_zone())
.col(ColumnDef::new(Alias::new("version_lock"))
.integer().not_null().default(1))
.to_owned(),
).await?;
manager.create_index(
Index::create()
.name("idx_ai_suggestion_tenant_analysis")
.table(Alias::new("ai_suggestion"))
.col(Alias::new("tenant_id"))
.col(Alias::new("analysis_id"))
.to_owned(),
).await?;
manager.create_index(
Index::create()
.name("idx_ai_suggestion_tenant_status")
.table(Alias::new("ai_suggestion"))
.col(Alias::new("tenant_id"))
.col(Alias::new("status"))
.to_owned(),
).await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager.drop_table(
Table::drop().table(Alias::new("ai_suggestion")).to_owned()
).await
}
}
- Step 2: 在
lib.rs中注册迁移
在 crates/erp-server/migration/src/lib.rs 中:
-
添加
mod m20260502_000098_create_ai_suggestion; -
在
migrations()vec 中添加Box::new(m20260502_000098_create_ai_suggestion::Migration) -
Step 3: 运行
cargo check -p erp-server验证编译 -
Step 4: 提交
git add crates/erp-server/migration/src/
git commit -m "feat(db): 添加 ai_suggestion 表迁移"
Task 3: 数据库迁移 — ai_risk_threshold 表
Files:
-
Create:
crates/erp-server/migration/src/m20260502_000099_create_ai_risk_threshold.rs -
Modify:
crates/erp-server/migration/src/lib.rs -
Step 1: 创建迁移文件
// crates/erp-server/migration/src/m20260502_000099_create_ai_risk_threshold.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> {
manager.create_table(
Table::create()
.table(Alias::new("ai_risk_threshold"))
.col(ColumnDef::new(Alias::new("id"))
.uuid().not_null().primary_key()
.default(Expr::cust("gen_random_uuid()")))
.col(ColumnDef::new(Alias::new("tenant_id")).uuid().not_null())
.col(ColumnDef::new(Alias::new("metric_name"))
.string_len(50).not_null())
.col(ColumnDef::new(Alias::new("low_threshold"))
.json_binary())
.col(ColumnDef::new(Alias::new("medium_threshold"))
.json_binary())
.col(ColumnDef::new(Alias::new("high_threshold"))
.json_binary())
.col(ColumnDef::new(Alias::new("created_at"))
.timestamp_with_time_zone()
.default(Expr::cust("NOW()")))
.col(ColumnDef::new(Alias::new("updated_at"))
.timestamp_with_time_zone()
.default(Expr::cust("NOW()")))
.col(ColumnDef::new(Alias::new("created_by")).uuid())
.col(ColumnDef::new(Alias::new("updated_by")).uuid())
.col(ColumnDef::new(Alias::new("deleted_at"))
.timestamp_with_time_zone())
.col(ColumnDef::new(Alias::new("version_lock"))
.integer().not_null().default(1))
.to_owned(),
).await?;
manager.create_index(
Index::create()
.name("idx_ai_risk_threshold_tenant_metric")
.table(Alias::new("ai_risk_threshold"))
.col(Alias::new("tenant_id"))
.col(Alias::new("metric_name"))
.unique()
.to_owned(),
).await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager.drop_table(
Table::drop().table(Alias::new("ai_risk_threshold")).to_owned()
).await
}
}
-
Step 2: 在
lib.rs注册迁移 -
Step 3: 运行
cargo check -p erp-server验证 -
Step 4: 提交
git add crates/erp-server/migration/src/
git commit -m "feat(db): 添加 ai_risk_threshold 表迁移"
Task 4: SeaORM Entity — ai_suggestion + ai_risk_threshold
Files:
-
Create:
crates/erp-ai/src/entity/ai_suggestion.rs -
Create:
crates/erp-ai/src/entity/ai_risk_threshold.rs -
Modify:
crates/erp-ai/src/entity/mod.rs -
Step 1: 创建 ai_suggestion entity
// crates/erp-ai/src/entity/ai_suggestion.rs
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "ai_suggestion")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub tenant_id: Uuid,
pub analysis_id: Uuid,
pub suggestion_type: String,
pub risk_level: String,
pub params: serde_json::Value,
pub status: String,
pub workflow_instance_id: Option<Uuid>,
pub action_result: Option<serde_json::Value>,
pub baseline_snapshot: Option<serde_json::Value>,
pub reanalysis_id: Option<Uuid>,
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 2: 创建 ai_risk_threshold entity
// crates/erp-ai/src/entity/ai_risk_threshold.rs
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "ai_risk_threshold")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub tenant_id: Uuid,
pub metric_name: String,
pub low_threshold: Option<serde_json::Value>,
pub medium_threshold: Option<serde_json::Value>,
pub high_threshold: 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: 更新 entity/mod.rs
pub mod ai_analysis;
pub mod ai_prompt;
pub mod ai_risk_threshold;
pub mod ai_suggestion;
pub mod ai_usage;
-
Step 4: 运行
cargo check -p erp-ai验证 -
Step 5: 提交
git add crates/erp-ai/src/entity/
git commit -m "feat(ai): 添加 ai_suggestion 和 ai_risk_threshold SeaORM Entity"
Task 5: 双通道输出解析器
Files:
-
Create:
crates/erp-ai/src/service/output_parser.rs -
Step 1: 编写解析器测试(TDD RED)
// tests 在 output_parser.rs 底部 #[cfg(test)] mod tests
#[test]
fn parse_dual_channel_output_success() {
let raw = "===PATIENT_TEXT===\n张三的收缩压呈上升趋势\n===STRUCTURED_JSON===\n{\"risk_level\":\"medium\",\"risk_factors\":[\"收缩压偏高\"],\"suggestions\":[{\"type\":\"followup\",\"priority\":1,\"timing\":\"14天内\",\"reason\":\"血压异常\",\"params\":{},\"auto_executable\":false}],\"baseline_summary\":{}}";
let result = parse_dual_channel(raw).unwrap();
assert_eq!(result.text_content, "张三的收缩压呈上升趋势");
assert!(result.structured.is_some());
let s = result.structured.unwrap();
assert_eq!(s.risk_level, RiskLevel::Medium);
assert_eq!(s.suggestions.len(), 1);
}
#[test]
fn parse_text_only_fallback() {
let raw = "纯文本分析结果,没有结构化部分";
let result = parse_dual_channel(raw).unwrap();
assert_eq!(result.text_content, "纯文本分析结果,没有结构化部分");
assert!(result.structured.is_none());
}
#[test]
fn parse_invalid_json_falls_back() {
let raw = "===PATIENT_TEXT===\n分析内容\n===STRUCTURED_JSON===\n{invalid json}";
let result = parse_dual_channel(raw).unwrap();
assert_eq!(result.text_content, "分析内容");
assert!(result.structured.is_none()); // 降级
}
#[test]
fn empty_suggestions_is_valid() {
let raw = "===PATIENT_TEXT===\n指标正常\n===STRUCTURED_JSON===\n{\"risk_level\":\"low\",\"risk_factors\":[],\"suggestions\":[],\"baseline_summary\":{}}";
let result = parse_dual_channel(raw).unwrap();
let s = result.structured.unwrap();
assert!(s.suggestions.is_empty());
}
#[test]
fn risk_level_auto_executable() {
assert!(RiskLevel::Low.is_auto_executable());
assert!(!RiskLevel::Medium.is_auto_executable());
assert!(!RiskLevel::High.is_auto_executable());
}
- Step 2: 运行测试确认失败
cargo test -p erp-ai -- output_parser
Expected: 编译失败(函数不存在)
- Step 3: 实现解析器
// crates/erp-ai/src/service/output_parser.rs
use crate::dto::suggestion::{ParsedOutput, RiskLevel, StructuredOutput};
use crate::error::AiResult;
const TEXT_MARKER: &str = "===PATIENT_TEXT===";
const JSON_MARKER: &str = "===STRUCTURED_JSON===";
/// 解析 AI 双通道输出。JSON 解析失败时降级为纯文本。
pub fn parse_dual_channel(raw: &str) -> AiResult<ParsedOutput> {
let text_content = extract_section(raw, TEXT_MARKER, JSON_MARKER)
.unwrap_or(raw)
.trim()
.to_string();
let structured = extract_section(raw, JSON_MARKER, TEXT_MARKER)
.and_then(|json_str| {
let parsed: Result<StructuredOutput, _> =
serde_json::from_str(json_str.trim());
parsed.ok()
});
Ok(ParsedOutput {
text_content,
structured,
})
}
fn extract_section<'a>(raw: &'a str, start: &str, end: &str) -> Option<&'a str> {
let start_idx = raw.find(start)?;
let content_start = start_idx + start.len();
let content_end = raw[content_start..]
.find(end)
.map(|i| content_start + i)
.unwrap_or(raw.len());
Some(&raw[content_start..content_end])
}
- Step 4: 运行测试确认通过
cargo test -p erp-ai -- output_parser
Expected: 5 tests passed
- Step 5: 更新 service/mod.rs
pub mod analysis;
pub mod auto_analysis;
pub mod output_parser;
pub mod prompt;
pub mod usage;
- Step 6: 提交
git add crates/erp-ai/src/service/output_parser.rs crates/erp-ai/src/service/mod.rs
git commit -m "feat(ai): 双通道输出解析器 — 文本/JSON 分割 + 降级策略"
Task 6: 本地临床规则引擎
Files:
-
Create:
crates/erp-ai/src/service/local_rules.rs -
Step 1: 编写规则引擎测试(TDD RED)
#[test]
fn evaluate_systolic_bp_high() {
let rules = LocalRulesEngine::default_rules();
let metrics = json!({"systolic_bp": 165.0});
let suggestions = rules.evaluate(&metrics);
assert!(!suggestions.is_empty());
assert_eq!(suggestions[0].risk_level, RiskLevel::High);
}
#[test]
fn evaluate_all_normal_no_suggestions() {
let rules = LocalRulesEngine::default_rules();
let metrics = json!({"systolic_bp": 120.0, "heart_rate": 72.0, "blood_sugar": 5.5});
let suggestions = rules.evaluate(&metrics);
assert!(suggestions.is_empty());
}
#[test]
fn evaluate_missing_metric_skipped() {
let rules = LocalRulesEngine::default_rules();
let metrics = json!({"heart_rate": 110.0}); // 只有心率,无血压
let suggestions = rules.evaluate(&metrics);
assert!(suggestions.iter().any(|s| s.suggestion_type == SuggestionType::Alert));
}
-
Step 2: 运行测试确认失败
-
Step 3: 实现规则引擎
// crates/erp-ai/src/service/local_rules.rs
use serde::{Deserialize, Serialize};
use crate::dto::suggestion::{RiskLevel, SuggestionType, StructuredSuggestion};
#[derive(Debug, Clone)]
pub struct LocalRule {
pub metric: String,
pub operator: CompareOp,
pub threshold: f64,
pub risk_level: RiskLevel,
pub suggestion_type: SuggestionType,
pub message_template: String,
}
#[derive(Debug, Clone, Copy)]
pub enum CompareOp {
GreaterThan,
LessThan,
}
pub struct LocalRulesEngine {
rules: Vec<LocalRule>,
}
impl LocalRulesEngine {
pub fn new(rules: Vec<LocalRule>) -> Self {
Self { rules }
}
/// 预定义的临床规则
pub fn default_rules() -> Self {
Self::new(vec![
// 收缩压
LocalRule { metric: "systolic_bp".into(), operator: CompareOp::GreaterThan, threshold: 160.0, risk_level: RiskLevel::High, suggestion_type: SuggestionType::Alert, message_template: "收缩压异常偏高({value}mmHg),请立即就医".into() },
LocalRule { metric: "systolic_bp".into(), operator: CompareOp::GreaterThan, threshold: 140.0, risk_level: RiskLevel::Medium, suggestion_type: SuggestionType::Followup, message_template: "收缩压偏高({value}mmHg),建议2周内复查".into() },
LocalRule { metric: "systolic_bp".into(), operator: CompareOp::LessThan, threshold: 90.0, risk_level: RiskLevel::High, suggestion_type: SuggestionType::Alert, message_template: "收缩压偏低({value}mmHg),请立即就医".into() },
// 心率
LocalRule { metric: "heart_rate".into(), operator: CompareOp::GreaterThan, threshold: 100.0, risk_level: RiskLevel::Medium, suggestion_type: SuggestionType::Followup, message_template: "心率偏快({value}bpm),建议随访".into() },
LocalRule { metric: "heart_rate".into(), operator: CompareOp::LessThan, threshold: 60.0, risk_level: RiskLevel::Medium, suggestion_type: SuggestionType::Followup, message_template: "心率偏慢({value}bpm),建议随访".into() },
// 血糖
LocalRule { metric: "blood_sugar".into(), operator: CompareOp::GreaterThan, threshold: 11.1, risk_level: RiskLevel::High, suggestion_type: SuggestionType::Alert, message_template: "血糖异常偏高({value}mmol/L),请立即就医".into() },
LocalRule { metric: "blood_sugar".into(), operator: CompareOp::LessThan, threshold: 3.9, risk_level: RiskLevel::High, suggestion_type: SuggestionType::Alert, message_template: "血糖偏低({value}mmol/L),有低血糖风险".into() },
// SpO2
LocalRule { metric: "spo2".into(), operator: CompareOp::LessThan, threshold: 95.0, risk_level: RiskLevel::High, suggestion_type: SuggestionType::Alert, message_template: "血氧饱和度偏低({value}%),请立即就医".into() },
])
}
pub fn evaluate(&self, metrics: &serde_json::Value) -> Vec<StructuredSuggestion> {
let mut suggestions = Vec::new();
for rule in &self.rules {
if let Some(value) = metrics.get(&rule.metric).and_then(|v| v.as_f64()) {
let triggered = match rule.operator {
CompareOp::GreaterThan => value > rule.threshold,
CompareOp::LessThan => value < rule.threshold,
};
if triggered {
suggestions.push(StructuredSuggestion {
id: None,
suggestion_type: rule.suggestion_type,
priority: match rule.risk_level {
RiskLevel::High => 1,
RiskLevel::Medium => 2,
RiskLevel::Low => 3,
},
timing: match rule.risk_level {
RiskLevel::High => "立即".into(),
RiskLevel::Medium => "2周内".into(),
RiskLevel::Low => "1个月内".into(),
},
reason: rule.message_template.replace("{value}", &value.to_string()),
params: serde_json::json!({
"metric": rule.metric,
"value": value,
"threshold": rule.threshold,
}),
auto_executable: rule.risk_level.is_auto_executable(),
});
}
}
}
suggestions.sort_by_key(|s| s.priority);
suggestions
}
}
- Step 4: 运行测试确认通过
cargo test -p erp-ai -- local_rules
-
Step 5: 更新 service/mod.rs 添加
pub mod local_rules; -
Step 6: 提交
git add crates/erp-ai/src/service/local_rules.rs crates/erp-ai/src/service/mod.rs
git commit -m "feat(ai): 本地临床规则引擎 — AI 不可用时的回退方案"
Task 7: SuggestionService — CRUD + 状态流转
Files:
-
Create:
crates/erp-ai/src/service/suggestion.rs -
Modify:
crates/erp-ai/src/state.rs -
Step 1: 实现 SuggestionService
// crates/erp-ai/src/service/suggestion.rs
use uuid::Uuid;
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
use erp_core::error::AppResult;
use crate::dto::suggestion::*;
use crate::entity::ai_suggestion;
pub struct SuggestionService;
impl SuggestionService {
/// 批量创建建议记录
pub async fn create_suggestions(
db: &sea_orm::DatabaseConnection,
tenant_id: Uuid,
analysis_id: Uuid,
suggestions: &[StructuredSuggestion],
risk_level: RiskLevel,
baseline_snapshot: &serde_json::Value,
created_by: Option<Uuid>,
) -> AppResult<Vec<uuid::Uuid>> {
let mut ids = Vec::new();
for s in suggestions {
let id = Uuid::now_v7();
let model = ai_suggestion::ActiveModel {
id: Set(id),
tenant_id: Set(tenant_id),
analysis_id: Set(analysis_id),
suggestion_type: Set(s.suggestion_type.as_str().to_string()),
risk_level: Set(risk_level.as_str().to_string()),
params: Set(s.params.clone()),
status: Set(SuggestionStatus::Pending.as_str().to_string()),
workflow_instance_id: Set(None),
action_result: Set(None),
baseline_snapshot: Set(Some(baseline_snapshot.clone())),
reanalysis_id: Set(None),
created_by: Set(created_by),
updated_by: Set(created_by),
..Default::default()
};
model.insert(db).await?;
ids.push(id);
}
Ok(ids)
}
/// 查询某次分析的所有建议
pub async fn list_by_analysis(
db: &sea_orm::DatabaseConnection,
tenant_id: Uuid,
analysis_id: Uuid,
) -> AppResult<Vec<ai_suggestion::Model>> {
let items = ai_suggestion::Entity::find()
.filter(ai_suggestion::Column::TenantId.eq(tenant_id))
.filter(ai_suggestion::Column::AnalysisId.eq(analysis_id))
.filter(ai_suggestion::Column::DeletedAt.is_null())
.all(db)
.await?;
Ok(items)
}
/// 更新建议状态(带乐观锁 + tenant_id 过滤)
pub async fn update_status(
db: &sea_orm::DatabaseConnection,
suggestion_id: Uuid,
tenant_id: Uuid,
new_status: SuggestionStatus,
updated_by: Option<Uuid>,
) -> AppResult<()> {
let item = ai_suggestion::Entity::find()
.filter(ai_suggestion::Column::Id.eq(suggestion_id))
.filter(ai_suggestion::Column::TenantId.eq(tenant_id))
.filter(ai_suggestion::Column::DeletedAt.is_null())
.one(db)
.await?
.ok_or_else(|| crate::error::AiError::AnalysisNotFound("建议不存在".into()))?;
let current_version = item.version_lock;
let mut active: ai_suggestion::ActiveModel = item.into();
active.status = Set(new_status.as_str().to_string());
active.updated_by = Set(updated_by);
active.version_lock = Set(current_version + 1);
// 乐观锁:WHERE version_lock = current_version
let result = active.update(db).await?;
Ok(())
}
/// 标记为解析失败
pub async fn mark_parse_failed(
db: &sea_orm::DatabaseConnection,
tenant_id: Uuid,
analysis_id: Uuid,
) -> AppResult<()> {
// 不创建建议记录,仅记录日志
tracing::warn!(
analysis_id = %analysis_id,
"AI 结构化输出解析失败,降级为纯文本"
);
Ok(())
}
}
- Step 2: 更新 AiState 添加 suggestion service
// crates/erp-ai/src/state.rs — 添加字段
pub suggestion: Arc<SuggestionService>,
-
Step 3: 更新 service/mod.rs 添加
pub mod suggestion; -
Step 4: 运行
cargo check -p erp-ai验证编译
注意:AiState 的构造处(erp-server/main.rs)也需要更新,先加 SuggestionService 的初始化。
- Step 5: 提交
git add crates/erp-ai/src/service/suggestion.rs crates/erp-ai/src/state.rs crates/erp-ai/src/service/mod.rs
git commit -m "feat(ai): SuggestionService — 建议记录 CRUD + 状态流转"
Task 8: 集成到 Handler — build_sse_stream 解析结构化输出
Files:
-
Modify:
crates/erp-ai/src/handler/mod.rs -
Step 1: 修改 complete_analysis 逻辑
在 build_sse_stream 函数的完成回调中(约 line 500),在发布 ai.analysis.completed 事件之前,添加结构化输出解析:
// 在 complete_analysis 之后、发布事件之前
let parsed = crate::service::output_parser::parse_dual_channel(&full_content)?;
// 存储结构化输出到 result_metadata
if let Some(ref structured) = parsed.structured {
let metadata = serde_json::json!({
"structured_output": structured,
"has_suggestions": !structured.suggestions.is_empty(),
});
state.analysis.update_result_metadata(analysis_id, &metadata, &state.db).await?;
// 创建建议记录
if !structured.suggestions.is_empty() {
state.suggestion.create_suggestions(
&state.db,
tenant_id,
analysis_id,
&structured.suggestions,
structured.risk_level,
&structured.baseline_summary,
Some(user_id),
).await?;
}
} else {
// 解析失败,标记
state.suggestion.mark_parse_failed(&state.db, tenant_id, analysis_id).await?;
}
- Step 2: 扩展 ai.analysis.completed 事件 payload
在事件 payload 中添加 structured_output 和 risk_level:
// 修改事件 payload 构建处
let mut payload = serde_json::json!({
"analysis_id": analysis_id,
"analysis_type": analysis_type,
"patient_id": patient_id,
"doctor_id": user_id,
});
if let Some(ref structured) = parsed.structured {
payload["risk_level"] = json!(structured.risk_level.as_str());
payload["suggestion_count"] = json!(structured.suggestions.len());
}
-
Step 3: 运行
cargo check -p erp-ai验证 -
Step 4: 提交
git add crates/erp-ai/src/handler/mod.rs
git commit -m "feat(ai): 集成双通道输出解析到 SSE handler — 自动创建建议记录"
Task 9: 建议 API 端点 + 权限注册
Files:
-
Create:
crates/erp-ai/src/handler/suggestion_handler.rs -
Modify:
crates/erp-ai/src/module.rs -
Step 1: 创建建议查询 API
// crates/erp-ai/src/handler/suggestion_handler.rs
// 查询某次分析的建议列表 + 查询待审批建议 + 审批/拒绝操作
两个端点:
-
GET /ai/suggestions?analysis_id=xxx— 查看建议列表(权限:ai.suggestion.list) -
POST /ai/suggestions/{id}/approve— 批准/拒绝建议(权限:ai.suggestion.manage) -
Step 2: 注册新权限码到 module.rs
添加两个 PermissionDescriptor:
-
ai.suggestion.list— "查看 AI 建议" -
ai.suggestion.manage— "审批 AI 建议" -
Step 3: 注册新路由到 protected_routes
-
Step 4: 运行
cargo check -p erp-ai验证 -
Step 5: 提交
git add crates/erp-ai/src/handler/suggestion_handler.rs crates/erp-ai/src/module.rs
git commit -m "feat(ai): 建议查询/审批 API 端点 + 权限注册"
Task 10: erp-server 初始化集成
Files:
-
Modify:
crates/erp-server/src/main.rs -
Step 1: 在 AiState 构造处添加 SuggestionService
找到 erp-server/src/main.rs 中构造 AiState 的位置,添加:
suggestion: Arc::new(SuggestionService),
-
Step 2: 运行
cargo check全 workspace 验证 -
Step 3: 运行
cargo test -p erp-ai确认所有测试通过 -
Step 4: 提交
git add crates/erp-server/src/main.rs
git commit -m "feat(server): 集成 SuggestionService 到 AiState 初始化"
Task 11: 端到端验证
- Step 1: 启动后端服务
cd crates/erp-server && cargo run
- Step 2: 验证迁移执行成功
docker exec erp-postgres psql -U erp -c "\dt ai_*"
Expected: ai_suggestion 和 ai_risk_threshold 表存在
- Step 3: 通过 Swagger UI 测试分析 API
POST /api/v1/ai/analyze/trends → 检查返回的 SSE 事件中是否包含结构化建议
- Step 4: 验证建议记录已创建
GET /api/v1/ai/suggestions?analysis_id=xxx → 应返回结构化建议列表
- Step 5: 推送所有提交
git push
Chunk 1 完成。下一步进入 Chunk 2(事件集成 + BPMN 流程定义 + 行动分发)。