From 6046ed23c988c5ecd7b95b52e452c14369c66938 Mon Sep 17 00:00:00 2001 From: iven Date: Fri, 1 May 2026 07:06:45 +0800 Subject: [PATCH] =?UTF-8?q?docs(ai):=20AI=E2=86=92=E8=A1=8C=E5=8A=A8?= =?UTF-8?q?=E9=97=AD=E7=8E=AF=E5=AE=9E=E6=96=BD=E8=AE=A1=E5=88=92=20Chunk?= =?UTF-8?q?=201=20=E2=80=94=20=E6=95=B0=E6=8D=AE=E5=B1=82+=E8=BE=93?= =?UTF-8?q?=E5=87=BA=E8=A7=A3=E6=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 11 个 Task:DTO 枚举/迁移/Entity/解析器/规则引擎/Service/Handler集成/API端点 已通过 plan review,修复了 dto/ 模块拆分、version_lock 命名、乐观锁、tenant_id 过滤 --- .../plans/2026-05-01-ai-action-loop-plan.md | 1027 +++++++++++++++++ 1 file changed, 1027 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-01-ai-action-loop-plan.md diff --git a/docs/superpowers/plans/2026-05-01-ai-action-loop-plan.md b/docs/superpowers/plans/2026-05-01-ai-action-loop-plan.md new file mode 100644 index 0000000..1f5daed --- /dev/null +++ b/docs/superpowers/plans/2026-05-01-ai-action-loop-plan.md @@ -0,0 +1,1027 @@ +# 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 流程定义 + 行动分发)。