# 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, #[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, pub suggestions: Vec, pub baseline_summary: serde_json::Value, } /// 解析后的双通道结果 #[derive(Debug, Clone)] pub struct ParsedOutput { pub text_content: String, pub structured: Option, } ``` - [ ] **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, pub action_result: Option, pub baseline_snapshot: Option, pub reanalysis_id: Option, pub created_at: DateTimeUtc, pub updated_at: DateTimeUtc, pub created_by: Option, pub updated_by: Option, pub deleted_at: Option, 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, pub medium_threshold: Option, pub high_threshold: Option, pub created_at: DateTimeUtc, pub updated_at: DateTimeUtc, pub created_by: Option, pub updated_by: Option, pub deleted_at: Option, 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 { 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 = 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, } impl LocalRulesEngine { pub fn new(rules: Vec) -> 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 { 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, ) -> AppResult> { 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> { 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, ) -> 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, ``` - [ ] **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, } /// 根据风险等级和建议类型生成执行决策 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, 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, 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> { // 使用 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-alt(Action 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, 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