Files
hms/docs/superpowers/plans/2026-05-01-ai-action-loop-plan.md
iven 2fb0535164
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): AI→行动闭环实施计划完成 — 25 Task / 3 Chunk
Chunk 1: 数据层+输出解析(Task 1-11)
Chunk 2: 事件集成+BPMN+行动分发(Task 12-19)
Chunk 3: 闭环对比+前端展示(Task 20-25)
2026-05-01 07:58:44 +08:00

1713 lines
55 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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/` 目录。需要:
1.`crates/erp-ai/src/dto.rs` 重命名为 `crates/erp-ai/src/dto/mod.rs`
2.`dto/mod.rs` 底部添加 `pub mod suggestion;`
- [ ] **Step 1: 创建 suggestion DTO 文件**
```rust
// 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: 提交**
```bash
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: 创建迁移文件**
```rust
// 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: 提交**
```bash
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: 创建迁移文件**
```rust
// 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: 提交**
```bash
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**
```rust
// 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**
```rust
// 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**
```rust
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: 提交**
```bash
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**
```rust
// 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: 运行测试确认失败**
```bash
cargo test -p erp-ai -- output_parser
```
Expected: 编译失败(函数不存在)
- [ ] **Step 3: 实现解析器**
```rust
// 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: 运行测试确认通过**
```bash
cargo test -p erp-ai -- output_parser
```
Expected: 5 tests passed
- [ ] **Step 5: 更新 service/mod.rs**
```rust
pub mod analysis;
pub mod auto_analysis;
pub mod output_parser;
pub mod prompt;
pub mod usage;
```
- [ ] **Step 6: 提交**
```bash
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**
```rust
#[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: 实现规则引擎**
```rust
// 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: 运行测试确认通过**
```bash
cargo test -p erp-ai -- local_rules
```
- [ ] **Step 5: 更新 service/mod.rs 添加 `pub mod local_rules;`**
- [ ] **Step 6: 提交**
```bash
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**
```rust
// 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**
```rust
// 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: 提交**
```bash
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` 事件之前,添加结构化输出解析:
```rust
// 在 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`
```rust
// 修改事件 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: 提交**
```bash
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**
```rust
// 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: 提交**
```bash
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` 的位置,添加:
```rust
suggestion: Arc::new(SuggestionService),
```
- [ ] **Step 2: 运行 `cargo check` 全 workspace 验证**
- [ ] **Step 3: 运行 `cargo test -p erp-ai` 确认所有测试通过**
- [ ] **Step 4: 提交**
```bash
git add crates/erp-server/src/main.rs
git commit -m "feat(server): 集成 SuggestionService 到 AiState 初始化"
```
---
### Task 11: 端到端验证
- [ ] **Step 1: 启动后端服务**
```bash
cd crates/erp-server && cargo run
```
- [ ] **Step 2: 验证迁移执行成功**
```bash
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: 推送所有提交**
```bash
git push
```
---
Chunk 1 完成。下一步进入 Chunk 2事件集成 + BPMN 流程定义 + 行动分发)。
---
## Chunk 2: 事件集成 + BPMN 流程 + 行动分发Phase 2
### Task 12: AI 行动分发服务
**Files:**
- Create: `crates/erp-health/src/service/ai_action_dispatcher.rs`
- [ ] **Step 1: 编写分发器测试TDD RED**
```rust
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn route_low_risk_to_auto_execute() {
let decision = dispatch_decision(&RiskLevel::Low, &SuggestionType::Alert);
assert_eq!(decision.execution_mode, ExecutionMode::AutoExecute);
assert_eq!(decision.response_timeout, None);
}
#[test]
fn route_medium_risk_to_doctor_review() {
let decision = dispatch_decision(&RiskLevel::Medium, &SuggestionType::Followup);
assert_eq!(decision.execution_mode, ExecutionMode::DoctorReview);
assert_eq!(decision.response_timeout, Some(Duration::from_secs(86400))); // 24h
}
#[test]
fn route_high_risk_to_urgent_confirm() {
let decision = dispatch_decision(&RiskLevel::High, &SuggestionType::Alert);
assert_eq!(decision.execution_mode, ExecutionMode::UrgentConfirm);
assert_eq!(decision.response_timeout, Some(Duration::from_secs(14400))); // 4h
}
}
```
- [ ] **Step 2: 运行测试确认失败**
- [ ] **Step 3: 实现行动分发器**
```rust
// crates/erp-health/src/service/ai_action_dispatcher.rs
use std::time::Duration;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use sea_orm::DatabaseConnection;
use erp_core::error::AppResult;
use erp_core::events::EventBus;
/// 执行模式
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ExecutionMode {
AutoExecute,
DoctorReview,
UrgentConfirm,
}
/// 分发决策
#[derive(Debug, Clone)]
pub struct DispatchDecision {
pub execution_mode: ExecutionMode,
pub response_timeout: Option<Duration>,
}
/// 根据风险等级和建议类型生成执行决策
pub fn dispatch_decision(risk_level: &str, suggestion_type: &str) -> DispatchDecision {
match risk_level {
"low" => DispatchDecision {
execution_mode: ExecutionMode::AutoExecute,
response_timeout: None,
},
"medium" => DispatchDecision {
execution_mode: ExecutionMode::DoctorReview,
response_timeout: Some(Duration::from_secs(86400)), // 24h
},
"high" => DispatchDecision {
execution_mode: ExecutionMode::UrgentConfirm,
response_timeout: Some(Duration::from_secs(14400)), // 4h
},
_ => DispatchDecision {
execution_mode: ExecutionMode::DoctorReview,
response_timeout: Some(Duration::from_secs(86400)),
},
}
}
/// 处理 AI 建议事件:根据风险等级分发到不同执行路径
pub async fn handle_ai_suggestions(
db: &DatabaseConnection,
event_bus: &EventBus,
tenant_id: Uuid,
analysis_id: Uuid,
patient_id: Uuid,
doctor_id: Option<Uuid>,
suggestions: &[serde_json::Value],
risk_level: &str,
) -> AppResult<()> {
for suggestion in suggestions {
let suggestion_type = suggestion["type"].as_str().unwrap_or("alert");
let decision = dispatch_decision(risk_level, suggestion_type);
match decision.execution_mode {
ExecutionMode::AutoExecute => {
execute_action(db, event_bus, tenant_id, patient_id, suggestion_type, suggestion).await?;
}
ExecutionMode::DoctorReview | ExecutionMode::UrgentConfirm => {
create_pending_action(
db, event_bus, tenant_id, patient_id, doctor_id,
suggestion_type, suggestion, risk_level, &decision,
).await?;
}
}
}
Ok(())
}
async fn execute_action(
db: &DatabaseConnection,
event_bus: &EventBus,
tenant_id: Uuid,
patient_id: Uuid,
action_type: &str,
params: &serde_json::Value,
) -> AppResult<()> {
match action_type {
"alert" => {
// 直接发送预警通知
let event = erp_core::events::DomainEvent::new(
"health.ai_alert.sent",
tenant_id,
serde_json::json!({
"patient_id": patient_id,
"alert_type": "ai_risk_warning",
"severity": params.get("severity").and_then(|v| v.as_str()).unwrap_or("warning"),
"message": params.get("message").and_then(|v| v.as_str()).unwrap_or(""),
"source": "ai_analysis",
}),
);
event_bus.publish(event, db).await?;
}
"followup" | "appointment" => {
// 低风险的随访/预约也可以自动创建,但仍通知医生
let event = erp_core::events::DomainEvent::new(
"health.ai_action.auto_executed",
tenant_id,
serde_json::json!({
"patient_id": patient_id,
"action_type": action_type,
"params": params,
}),
);
event_bus.publish(event, db).await?;
}
_ => {}
}
Ok(())
}
async fn create_pending_action(
db: &DatabaseConnection,
event_bus: &EventBus,
tenant_id: Uuid,
patient_id: Uuid,
doctor_id: Option<Uuid>,
action_type: &str,
params: &serde_json::Value,
risk_level: &str,
decision: &DispatchDecision,
) -> AppResult<()> {
let event = erp_core::events::DomainEvent::new(
"health.ai_action.pending_approval",
tenant_id,
serde_json::json!({
"patient_id": patient_id,
"doctor_id": doctor_id,
"action_type": action_type,
"risk_level": risk_level,
"timeout_seconds": decision.response_timeout.map(|d| d.as_secs()),
"params": params,
}),
);
event_bus.publish(event, db).await?;
Ok(())
}
```
- [ ] **Step 4: 运行测试确认通过**
- [ ] **Step 5: 提交**
```bash
git add crates/erp-health/src/service/ai_action_dispatcher.rs
git commit -m "feat(health): AI 行动分发器 — 风险分级路由到自动执行/医生审批/紧急确认"
```
---
### Task 13: erp-health 事件消费者 — 订阅 ai.analysis.completed
**Files:**
- Modify: `crates/erp-health/src/event.rs`
- [ ] **Step 1: 在 register_handlers_with_state 中新增消费者**
`crates/erp-health/src/event.rs``register_handlers_with_state` 函数中,在已有的 `ai.analysis.completed` 通知消费者之后,新增行动分发消费者:
```rust
// 在现有的 ai_analysis_notifier 消费者之后
// AI→行动闭环消费者
let action_db = state.db.clone();
let action_event_bus = state.event_bus.clone();
let (mut ai_action_rx, ai_action_handle) = event_bus.subscribe_filtered("ai.analysis.".to_string());
tokio::spawn(async move {
while let Some(event) = ai_action_rx.recv().await {
if event.event_type != "ai.analysis.completed" { continue; }
let consumer_id = "ai_action_dispatcher";
if let Ok(true) = erp_core::events::is_event_processed(&action_db, event.id, consumer_id).await {
continue;
}
let tenant_id = event.tenant_id;
let payload = &event.payload;
let analysis_id = payload.get("analysis_id").and_then(|v| v.as_str()).unwrap_or("");
let patient_id = payload.get("patient_id").and_then(|v| v.as_str()).unwrap_or("");
let doctor_id = payload.get("doctor_id").and_then(|v| v.as_str());
let risk_level = payload.get("risk_level").and_then(|v| v.as_str()).unwrap_or("medium");
let suggestion_count = payload.get("suggestion_count").and_then(|v| v.as_u64()).unwrap_or(0);
// 只有有建议时才触发行动分发
if suggestion_count > 0 {
// 从 ai_suggestion 表加载建议列表
if let Ok(suggestions) = crate::service::ai_suggestion_loader::load_by_analysis(
&action_db, tenant_id, analysis_id
).await {
let _ = crate::service::ai_action_dispatcher::handle_ai_suggestions(
&action_db,
&action_event_bus,
tenant_id,
analysis_id.parse().unwrap_or_default(),
patient_id.parse().unwrap_or_default(),
doctor_id.and_then(|s| s.parse().ok()),
&suggestions,
risk_level,
).await;
}
}
let _ = erp_core::events::mark_event_processed(&action_db, event.id, consumer_id).await;
}
});
```
- [ ] **Step 2: 创建建议加载辅助函数**
`crates/erp-health/src/service/` 中新建 `ai_suggestion_loader.rs`,从 erp-ai 的 `ai_suggestion` 表读取建议列表:
```rust
// 通过 raw SQL 跨 crate 读取 ai_suggestion 表
pub async fn load_by_analysis(
db: &DatabaseConnection,
tenant_id: Uuid,
analysis_id: &str,
) -> AppResult<Vec<serde_json::Value>> {
// 使用 sea_orm raw query 从 ai_suggestion 表查询
// ...
}
```
- [ ] **Step 3: 运行 `cargo check -p erp-health` 验证**
- [ ] **Step 4: 提交**
```bash
git add crates/erp-health/src/event.rs crates/erp-health/src/service/ai_suggestion_loader.rs
git commit -m "feat(health): 订阅 ai.analysis.completed — 行动分发事件消费者"
```
---
### Task 14: BPMN 流程定义 — 审计前置条件
**前置条件:** Spec 要求 Phase 2 启动前审计 erp-workflow BPMN 功能覆盖度。
- [ ] **Step 1: 审计 erp-workflow 支持的能力**
检查 `crates/erp-workflow/src/engine/``dto.rs`
- `ExclusiveGateway` — 条件分支:确认支持
- `UserTask` — 医生审批节点:确认支持
- `ServiceTask` — HTTP 调用节点:确认支持
- 定时器边界事件:检查 `timeout.rs` 支持情况
- 信号事件:检查是否支持
- [ ] **Step 2: 评估审计结果**
如果所有能力已支持 → 继续 Task 15-17
如果关键能力缺失 → 回退到 Task 15-altAction Registry 模式,不依赖 BPMN
---
### Task 15: BPMN 流程定义 — AI 随访流程
**Files:**
- Create: `crates/erp-server/src/seed/ai_followup_workflow.rs`(或通过 API seed
- Reference: `crates/erp-workflow/src/service/definition_service.rs`
- [ ] **Step 1: 定义随访流程 JSON**
通过 `DefinitionService::create()` API 创建流程定义。节点设计:
```
StartEvent → ExclusiveGateway(风险分级)
→ [low] → ServiceTask(自动创建随访) → EndEvent
→ [medium] → UserTask(医生审批) → [approve] → ServiceTask(创建随访) → EndEvent
→ [reject] → EndEvent
→ [high] → UserTask(紧急确认, 4h超时) → [confirm] → ServiceTask(创建随访) → EndEvent
→ [reject] → EndEvent
→ [timeout] → ServiceTask(升级通知) → EndEvent
```
- [ ] **Step 2: 通过 seed 或 migration 插入流程定义**
`erp-server/src/main.rs` 启动时检查是否存在 `ai_followup_workflow` 流程定义,不存在则创建。
- [ ] **Step 3: 提交**
```bash
git add crates/erp-server/src/seed/ai_followup_workflow.rs crates/erp-server/src/main.rs
git commit -m "feat(workflow): AI 随访流程 BPMN 定义 — 风险分级 + 医生审批 + 自动创建"
```
---
### Task 16: BPMN 流程定义 — AI 预约流程
**Files:**
- Create: `crates/erp-server/src/seed/ai_appointment_workflow.rs`
- [ ] **Step 1: 定义预约流程 JSON**
```
StartEvent → ExclusiveGateway(风险分级)
→ [low] → ServiceTask(推荐预约时段) → EndEvent
→ [medium] → UserTask(医生确认时段) → ServiceTask(创建预约) → EndEvent
→ [high] → UserTask(紧急预约, 4h超时) → ServiceTask(创建预约) → EndEvent
```
- [ ] **Step 2: Seed 到数据库**
- [ ] **Step 3: 提交**
```bash
git add crates/erp-server/src/seed/ai_appointment_workflow.rs crates/erp-server/src/main.rs
git commit -m "feat(workflow): AI 预约流程 BPMN 定义 — 智能时段推荐"
```
---
### Task 17: BPMN 流程定义 — AI 预警流程
**Files:**
- Create: `crates/erp-server/src/seed/ai_alert_workflow.rs`
- [ ] **Step 1: 定义预警流程 JSON**
```
StartEvent → ExclusiveGateway(风险分级)
→ [low] → ServiceTask(双通道发送通知) → EndEvent
→ [medium] → ServiceTask(推送给医生) → UserTask(24h响应) → EndEvent
→ [timeout] → ServiceTask(自动提醒) → EndEvent
→ [high] → ServiceTask(即时推送+仪表盘标红) → UserTask(4h确认) → EndEvent
→ [timeout] → ServiceTask(升级上级) → EndEvent
```
- [ ] **Step 2: Seed 到数据库**
- [ ] **Step 3: 提交**
```bash
git add crates/erp-server/src/seed/ai_alert_workflow.rs crates/erp-server/src/main.rs
git commit -m "feat(workflow): AI 预警流程 BPMN 定义 — 分级通知+超时升级"
```
---
### Task 18: 行动分发 → 工作流启动集成
**Files:**
- Modify: `crates/erp-health/src/service/ai_action_dispatcher.rs`
- [ ] **Step 1: 在分发器中集成工作流启动**
`create_pending_action` 和需要审批的路径中,调用 `InstanceService::start()` 启动对应的 BPMN 流程实例:
```rust
// 根据 suggestion_type 选择流程定义 key
let workflow_key = match action_type {
"followup" => "ai_followup_workflow",
"appointment" => "ai_appointment_workflow",
"alert" => "ai_alert_workflow",
_ => return Ok(()),
};
// 通过 event 触发工作流启动(解耦,不直接依赖 erp-workflow
let event = erp_core::events::DomainEvent::new(
"workflow.ai_action.start_requested",
tenant_id,
serde_json::json!({
"workflow_key": workflow_key,
"patient_id": patient_id,
"doctor_id": doctor_id,
"risk_level": risk_level,
"action_type": action_type,
"params": params,
}),
);
event_bus.publish(event, db).await?;
```
- [ ] **Step 2: 在 erp-workflow 事件处理器中消费启动请求**
`crates/erp-workflow/src/module.rs` 的事件注册中,新增 `workflow.ai_action.start_requested` 消费者,调用 `InstanceService::start()`
- [ ] **Step 3: 运行 `cargo check` 全 workspace 验证**
- [ ] **Step 4: 提交**
```bash
git add crates/erp-health/src/service/ai_action_dispatcher.rs crates/erp-workflow/src/module.rs
git commit -m "feat(health+workflow): 行动分发→工作流启动集成 — 事件驱动 BPMN 实例化"
```
---
### Task 19: Chunk 2 端到端验证
- [ ] **Step 1: 启动后端服务**
```bash
cd crates/erp-server && cargo run
```
- [ ] **Step 2: 触发 AI 分析**
POST `/api/v1/ai/analyze/trends` → 确认结构化建议已创建
- [ ] **Step 3: 验证事件链**
检查日志确认:
- `ai.analysis.completed` 事件已发布
- `health.ai_action.pending_approval``health.ai_alert.sent` 事件已触发
- BPMN 流程实例已创建
- [ ] **Step 4: 验证医生审批**
通过 Task API 完成医生审批任务,确认随访/预约已创建
- [ ] **Step 5: 推送**
```bash
git push
```
---
Chunk 2 完成。下一步进入 Chunk 3闭环对比 + 前端展示)。
---
## Chunk 3: 闭环对比 + 前端展示Phase 3-4
### Task 20: 随访完成 → 再分析触发
**Files:**
- Modify: `crates/erp-health/src/event.rs`
- [ ] **Step 1: 扩展 follow_up.completed 事件消费**
在现有的 `follow_up.completed` 事件处理中,检查该随访是否由 AI 建议触发(通过 `follow_up_task``content_template` 字段包含 `ai_suggestion_id` 判断):
```rust
// 在 follow_up.completed 消费者中添加
if let Some(ai_suggestion_id) = extract_ai_suggestion_id(&task) {
// 发布再分析请求
let event = erp_core::events::DomainEvent::new(
"ai.reanalysis.requested",
tenant_id,
serde_json::json!({
"original_suggestion_id": ai_suggestion_id,
"patient_id": task.patient_id,
"followup_id": task.id,
"trigger": "loop_closure",
}),
);
event_bus.publish(event, db).await?;
}
```
- [ ] **Step 2: 在 erp-ai 中消费再分析请求**
`crates/erp-ai/src/handler/mod.rs` 或独立的再分析服务中,订阅 `ai.reanalysis.requested` 事件:
1. 加载原始建议的 `baseline_snapshot`
2. 提取随访期间的新数据
3. 执行趋势分析(带 baseline
4. 生成对比报告
- [ ] **Step 3: 提交**
```bash
git add crates/erp-health/src/event.rs crates/erp-ai/src/service/reanalysis.rs
git commit -m "feat(health+ai): 随访完成→再分析触发 — 闭环核心链路"
```
---
### Task 21: 前后对比报告生成
**Files:**
- Create: `crates/erp-ai/src/service/comparison.rs`
- [ ] **Step 1: 实现对比报告生成**
```rust
// crates/erp-ai/src/service/comparison.rs
pub struct ComparisonReport {
pub baseline: serde_json::Value,
pub current: serde_json::Value,
pub changes: Vec<MetricChange>,
pub overall_trend: TrendDirection,
}
pub struct MetricChange {
pub metric: String,
pub baseline_value: f64,
pub current_value: f64,
pub change_percent: f64,
pub trend: TrendDirection,
}
pub enum TrendDirection { Improving, Stable, Worsening }
pub fn generate_comparison(
baseline: &serde_json::Value,
current: &serde_json::Value,
) -> ComparisonReport {
// 对比 baseline_summary 和当前指标
// 计算每个指标的变化百分比和趋势方向
// 综合判断整体趋势
}
```
- [ ] **Step 2: 在再分析流程中调用对比生成**
再分析完成后,调用 `generate_comparison()` 生成对比报告,存储到 `ai_analysis.result_metadata` 中,关联到原始建议的 `reanalysis_id`
- [ ] **Step 3: 新增对比报告 API 端点**
`GET /ai/suggestions/{id}/comparison` — 返回前后对比报告
- [ ] **Step 4: 提交**
```bash
git add crates/erp-ai/src/service/comparison.rs
git commit -m "feat(ai): 前后对比报告生成 — 闭环效果评估"
```
---
### Task 22: Web 前端 — AI 建议面板
**Files:**
- Modify: `apps/web/src/pages/health/AiAnalysisList.tsx`
- [ ] **Step 1: 在分析详情展开行中增加建议面板**
`AiAnalysisList.tsx` 的展开行中,当分析有结构化建议时:
- 显示风险等级标签(低/中/高,不同颜色)
- 显示建议列表(类型图标 + 原因 + 状态标签)
- 显示执行状态(待审批/已批准/已执行/已拒绝)
- [ ] **Step 2: 添加建议审批操作按钮**
对中/高风险建议,显示「批准」/「拒绝」按钮,调用 `POST /api/v1/ai/suggestions/{id}/approve`
- [ ] **Step 3: 提交**
```bash
git add apps/web/src/pages/health/AiAnalysisList.tsx
git commit -m "feat(web): AI 分析详情增加建议面板 — 风险等级+建议列表+审批操作"
```
---
### Task 23: Web 前端 — 医生 AI 待办区域
**Files:**
- Modify: `apps/web/src/pages/health/PatientDetail.tsx`(或新建 Dashboard widget
- [ ] **Step 1: 在患者详情页添加 AI 待办区域**
显示该患者待审批的 AI 建议,按风险等级排序,显示倒计时(中/高风险有超时)
- [ ] **Step 2: 添加前后对比报告展示**
当建议有闭环对比报告时,显示前后指标变化趋势图
- [ ] **Step 3: 提交**
```bash
git add apps/web/src/pages/health/PatientDetail.tsx
git commit -m "feat(web): 患者 AI 待办区域 + 前后对比趋势图"
```
---
### Task 24: 小程序 — AI 建议卡片 + 预警通知
**Files:**
- Modify: `apps/miniprogram/src/pages/health/index.tsx`
- Modify: `apps/miniprogram/src/services/ai-analysis.ts`
- [ ] **Step 1: 健康页增加 AI 建议卡片**
在健康页面顶部显示最新的 AI 建议摘要卡片:
- 风险等级颜色标签
- 建议文字摘要
- 点击跳转详情页
- [ ] **Step 2: 消息页增加风险预警通知类型**
在消息页面中识别 AI 预警通知,用醒目样式显示
- [ ] **Step 3: 提交**
```bash
git add apps/miniprogram/src/pages/health/index.tsx apps/miniprogram/src/services/ai-analysis.ts
git commit -m "feat(miniprogram): AI 建议卡片 + 风险预警通知"
```
---
### Task 25: 全流程端到端验证
- [ ] **Step 1: 启动全栈服务**
```bash
cd crates/erp-server && cargo run
cd apps/web && pnpm dev
```
- [ ] **Step 2: 完整闭环测试**
1. 发起 AI 趋势分析 → 确认结构化建议生成
2. 医生在 Web 端审批建议 → 确认随访计划已创建
3. 患者小程序查看 AI 建议卡片 → 确认显示正常
4. 模拟随访完成 → 确认再分析触发
5. 查看前后对比报告 → 确认趋势对比正确
- [ ] **Step 3: 运行全量测试**
```bash
cargo check
cargo test --workspace
pnpm --filter web build
```
- [ ] **Step 4: 最终推送**
```bash
git push
```
---
**计划完成。** 共 25 个 Task分 3 个 Chunk
- Chunk 1 (Task 1-11): 数据层 + 输出解析 — 可独立执行
- Chunk 2 (Task 12-19): 事件集成 + BPMN + 行动分发 — 依赖 Chunk 1
- Chunk 3 (Task 20-25): 闭环对比 + 前端展示 — 依赖 Chunk 2