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

55 KiB
Raw Blame History

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.rscrates/erp-ai/src/dto/mod.rs

  • Create: crates/erp-ai/src/dto/suggestion.rs

  • Step 0: 将 dto.rs 重构为 dto/ 目录模块

Rust 不允许同时存在 dto.rsdto/ 目录。需要:

  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 文件
// 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.rsdto/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_outputrisk_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_suggestionai_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 流程定义 + 行动分发)。


Chunk 2: 事件集成 + BPMN 流程 + 行动分发Phase 2

Task 12: AI 行动分发服务

Files:

  • Create: crates/erp-health/src/service/ai_action_dispatcher.rs

  • Step 1: 编写分发器测试TDD RED

#[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: 实现行动分发器

// 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: 提交

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.rsregister_handlers_with_state 函数中,在已有的 ai.analysis.completed 通知消费者之后,新增行动分发消费者:

// 在现有的 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 表读取建议列表:

// 通过 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: 提交

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: 提交
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: 提交

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: 提交

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 流程实例:

// 根据 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: 提交

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: 启动后端服务
cd crates/erp-server && cargo run
  • Step 2: 触发 AI 分析

POST /api/v1/ai/analyze/trends → 确认结构化建议已创建

  • Step 3: 验证事件链

检查日志确认:

  • ai.analysis.completed 事件已发布

  • health.ai_action.pending_approvalhealth.ai_alert.sent 事件已触发

  • BPMN 流程实例已创建

  • Step 4: 验证医生审批

通过 Task API 完成医生审批任务,确认随访/预约已创建

  • Step 5: 推送
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_taskcontent_template 字段包含 ai_suggestion_id 判断):

// 在 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: 提交
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: 实现对比报告生成

// 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: 提交
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: 提交
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: 提交
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: 提交
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: 启动全栈服务
cd crates/erp-server && cargo run
cd apps/web && pnpm dev
  • Step 2: 完整闭环测试
  1. 发起 AI 趋势分析 → 确认结构化建议生成
  2. 医生在 Web 端审批建议 → 确认随访计划已创建
  3. 患者小程序查看 AI 建议卡片 → 确认显示正常
  4. 模拟随访完成 → 确认再分析触发
  5. 查看前后对比报告 → 确认趋势对比正确
  • Step 3: 运行全量测试
cargo check
cargo test --workspace
pnpm --filter web build
  • Step 4: 最终推送
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