# Copilot 基因化实施计划 > **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:** 将 HMS 的 AI 从独立工具转变为弥漫在系统每个交互点的 Copilot 智能层,覆盖医护端 4 触点闭环和患者端合规 AI 客服。 **Architecture:** 扩展现有 erp-ai crate,在其内部新增 copilot/ 子模块(规则引擎、评分、意图识别、合规审查)。通过事件总线订阅 erp-health 事件驱动异步洞察生成。前端嵌入 Copilot 组件到现有页面。患者端小程序新增独立对话分包。 **Tech Stack:** Rust / SeaORM / Axum / Tokio / JSONLogic / React + Ant Design / Taro 4.2 **Spec:** `docs/superpowers/specs/2026-05-11-copilot-gene-design.md` **Pattern Reference:** - Entity: `crates/erp-ai/src/entity/ai_suggestion.rs` - Service: `crates/erp-ai/src/service/suggestion.rs` - Handler: `crates/erp-ai/src/handler/suggestion_handler.rs` - Migration: `crates/erp-server/migration/src/m20260510_000136_create_banner.rs` - Module: `crates/erp-ai/src/module.rs` - Event Consumer: `crates/erp-health/src/event/ai.rs` --- ## Chunk 1: Phase 0 — 基础设施(地基) > **目标:** 搭建 Copilot 引擎骨架,规则引擎可对患者数据跑通评分,API 可查询。 > **验收:** `cargo check` 通过 + 内置规则评分逻辑单元测试通过 + API 可返回空洞察列表。 ### Task 1: 数据库迁移(copilot_rules) **Files:** - Create: `crates/erp-server/migration/src/m20260512_000138_create_copilot_rules.rs` - Modify: `crates/erp-server/migration/src/lib.rs` - [x] **Step 1: 创建迁移文件** 文件 `m20260512_000138_create_copilot_rules.rs`,参照 `m20260510_000136_create_banner.rs` 模式: ```rust 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(CopilotRules::Table) .col(ColumnDef::new(CopilotRules::Id).uuid().not_null().primary_key()) .col(ColumnDef::new(CopilotRules::TenantId).uuid().not_null()) .col(ColumnDef::new(CopilotRules::Name).string_len(200).not_null()) .col(ColumnDef::new(CopilotRules::Category).string_len(50).not_null()) .col(ColumnDef::new(CopilotRules::ConditionExpr).json().not_null()) .col(ColumnDef::new(CopilotRules::Score).small_integer().not_null()) .col(ColumnDef::new(CopilotRules::Severity).string_len(20).not_null()) .col(ColumnDef::new(CopilotRules::Suggestion).text()) .col(ColumnDef::new(CopilotRules::Enabled).boolean().not_null().default(true)) .col(ColumnDef::new(CopilotRules::SortOrder).integer().not_null().default(0)) .col(ColumnDef::new(CopilotRules::CreatedAt).timestamp_with_time_zone().not_null().default(Expr::current_timestamp())) .col(ColumnDef::new(CopilotRules::UpdatedAt).timestamp_with_time_zone().not_null().default(Expr::current_timestamp())) .col(ColumnDef::new(CopilotRules::CreatedBy).uuid().null()) .col(ColumnDef::new(CopilotRules::UpdatedBy).uuid().null()) .col(ColumnDef::new(CopilotRules::DeletedAt).timestamp_with_time_zone().null()) .col(ColumnDef::new(CopilotRules::VersionLock).integer().not_null().default(1)) .to_owned(), ) .await?; manager .create_index( Index::create() .name("idx_copilot_rules_tenant_category") .table(CopilotRules::Table) .col(CopilotRules::TenantId) .col(CopilotRules::Category) .to_owned(), ) .await } async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { manager.drop_table(Table::drop().table(CopilotRules::Table).to_owned()).await } } #[derive(DeriveIden)] enum CopilotRules { Table, Id, TenantId, Name, Category, ConditionExpr, Score, Severity, Suggestion, Enabled, SortOrder, CreatedAt, UpdatedAt, CreatedBy, UpdatedBy, DeletedAt, VersionLock, } ``` - [x] **Step 2: 注册迁移** 在 `migration/src/lib.rs` 中: - 顶部添加 `mod m20260512_000138_create_copilot_rules;` - `migrations()` vec 中添加 `Box::new(m20260512_000138_create_copilot_rules::Migration)` - [x] **Step 3: 编译验证** Run: `cargo check -p erp-server` Expected: 编译通过 - [x] **Step 4: 提交** ```bash git add crates/erp-server/migration/src/m20260512_000138_create_copilot_rules.rs crates/erp-server/migration/src/lib.rs git commit -m "feat(db): copilot_rules 表迁移" ``` ### Task 2: 数据库迁移(copilot_insights + copilot_risk_snapshots + copilot_chat_logs) **Files:** - Create: `crates/erp-server/migration/src/m20260512_000139_create_copilot_insights.rs` - Create: `crates/erp-server/migration/src/m20260512_000140_create_copilot_risk_snapshots.rs` - Create: `crates/erp-server/migration/src/m20260512_000141_create_copilot_chat_logs.rs` - Modify: `crates/erp-server/migration/src/lib.rs` - [x] **Step 1: 创建 copilot_insights 迁移(m20260512_000139)** 参照 Task 1 模式,字段按 spec §6.2 DDL。关键列: - `patient_id UUID NOT NULL`(无 REFERENCES,逻辑关联) - `insight_type VARCHAR(50) NOT NULL` - `source VARCHAR(20) NOT NULL` - `severity VARCHAR(20)` - `title VARCHAR(500) NOT NULL` - `content JSONB NOT NULL` - `rule_matches JSONB` - `llm_supplement TEXT` - `expires_at TIMESTAMPTZ NOT NULL` - `is_read BOOLEAN DEFAULT false` - `is_dismissed BOOLEAN DEFAULT false` - 索引:`idx_copilot_insights_tenant_patient` on (tenant_id, patient_id) - 索引:`idx_copilot_insights_expires` on (expires_at) - [x] **Step 2: 创建 copilot_risk_snapshots 迁移(m20260512_000140)** 关键列: - `patient_id UUID NOT NULL`(无 REFERENCES) - `risk_score SMALLINT NOT NULL` - `risk_level VARCHAR(20) NOT NULL` - `rule_details JSONB NOT NULL` - `llm_summary TEXT` - `computed_at TIMESTAMPTZ NOT NULL` - `data_freshness JSONB` - 唯一索引:`idx_copilot_risk_snapshots_tenant_patient` UNIQUE on (tenant_id, patient_id) - [x] **Step 3: 创建 copilot_chat_logs 迁移(m20260512_000141)** 关键列: - `patient_id UUID NOT NULL`(无 REFERENCES) - `session_id UUID NOT NULL` - `user_message TEXT NOT NULL` - `intent_classification VARCHAR(30)` - `ai_raw_response TEXT` - `layer1_result JSONB` - `layer2_result JSONB` - `violations_found JSONB` - `fix_strategy VARCHAR(30)` - `final_response TEXT NOT NULL` - 索引:`idx_copilot_chat_logs_session` on (tenant_id, session_id) - 索引:`idx_copilot_chat_logs_patient` on (tenant_id, patient_id) - [x] **Step 4: 注册 3 个迁移** 在 `migration/src/lib.rs` 中注册 m139、m140、m141。 - [x] **Step 5: 编译验证** Run: `cargo check -p erp-server` Expected: 编译通过 - [x] **Step 6: 提交** ```bash git add crates/erp-server/migration/src/ git commit -m "feat(db): copilot_insights/risk_snapshots/chat_logs 表迁移" ``` ### Task 3: SeaORM Entity(4 个实体) **Files:** - Create: `crates/erp-ai/src/entity/copilot_rules.rs` - Create: `crates/erp-ai/src/entity/copilot_insights.rs` - Create: `crates/erp-ai/src/entity/copilot_risk_snapshots.rs` - Create: `crates/erp-ai/src/entity/copilot_chat_logs.rs` - Modify: `crates/erp-ai/src/entity/mod.rs` - [x] **Step 1: 创建 copilot_rules entity** 参照 `crates/erp-ai/src/entity/ai_suggestion.rs` 模式: ```rust use sea_orm::entity::prelude::*; use serde::{Deserialize, Serialize}; #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] #[sea_orm(table_name = "copilot_rules")] pub struct Model { #[sea_orm(primary_key, auto_increment = false)] pub id: Uuid, pub tenant_id: Uuid, pub name: String, pub category: String, pub condition_expr: serde_json::Value, pub score: i16, pub severity: String, pub suggestion: Option, pub enabled: bool, pub sort_order: i32, 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 {} ``` - [x] **Step 2: 创建其余 3 个 entity** 同样模式,字段对应 spec §6.2 DDL。 - [x] **Step 3: 注册 entity 模块** 在 `crates/erp-ai/src/entity/mod.rs` 中添加: ```rust pub mod copilot_rules; pub mod copilot_insights; pub mod copilot_risk_snapshots; pub mod copilot_chat_logs; ``` - [x] **Step 4: 编译验证** Run: `cargo check -p erp-ai` Expected: 编译通过 - [x] **Step 5: 提交** ```bash git add crates/erp-ai/src/entity/ git commit -m "feat(ai): copilot 4 个 SeaORM entity" ``` ### Task 4: 规则引擎核心(JSONLogic 解释器) **Files:** - Create: `crates/erp-ai/src/copilot/mod.rs` - Create: `crates/erp-ai/src/copilot/rules.rs` - [x] **Step 1: 创建 copilot 模块入口(仅 rules,其他 Task 5 再加)** `crates/erp-ai/src/copilot/mod.rs`: ```rust pub mod rules; // scoring 和 engine 将在 Task 5 中添加 ``` - [x] **Step 2: 编写规则引擎失败的测试** `crates/erp-ai/src/copilot/rules.rs` 底部: ```rust #[cfg(test)] mod tests { use super::*; #[test] fn test_simple_comparison_gt() { let expr = serde_json::json!({ ">": [{"var": "systolic"}, 140] }); let data = serde_json::json!({"systolic": 155}); assert!(evaluate(&expr, &data)); } #[test] fn test_simple_comparison_lt() { let expr = serde_json::json!({ "<": [{"var": "egfr"}, 60] }); let data = serde_json::json!({"egfr": 45}); assert!(evaluate(&expr, &data)); } #[test] fn test_and_combination() { let expr = serde_json::json!({ "and": [ { ">=": [{"var": "systolic.prev1"}, 140] }, { ">=": [{"var": "systolic.prev2"}, 140] } ] }); let data = serde_json::json!({"systolic": {"prev1": 145, "prev2": 150}}); assert!(evaluate(&expr, &data)); } #[test] fn test_change_pct() { let expr = serde_json::json!({ ">": [{"var": "creatinine.change_pct"}, 20] }); let data = serde_json::json!({"creatinine": {"change_pct": 25}}); assert!(evaluate(&expr, &data)); } #[test] fn test_not_matching() { let expr = serde_json::json!({ "<": [{"var": "egfr"}, 60] }); let data = serde_json::json!({"egfr": 75}); assert!(!evaluate(&expr, &data)); } } ``` - [x] **Step 3: 运行测试确认失败** Run: `cargo test -p erp-ai -- copilot::rules::tests` Expected: 编译失败(函数不存在) - [x] **Step 4: 实现 JSONLogic 解释器** ```rust use serde_json::Value; /// 评估 JSONLogic 表达式,支持子集:> >= < <= == != and or ! in var /// 对畸形规则表达式返回 false 而非 panic(规则存储在数据库中,不应导致服务崩溃) pub fn evaluate(expr: &Value, data: &Value) -> bool { match expr { Value::Object(map) => { if let Some(op) = map.get(">") { let args = match op.as_array() { Some(a) if a.len() == 2 => a, _ => return false }; let a = resolve_value(&args[0], data); let b = resolve_value(&args[1], data); return compare_f64(&a, &b) == std::cmp::Ordering::Greater; } if let Some(op) = map.get(">=") { let args = match op.as_array() { Some(a) if a.len() == 2 => a, _ => return false }; let a = resolve_value(&args[0], data); let b = resolve_value(&args[1], data); return matches!(compare_f64(&a, &b), std::cmp::Ordering::Greater | std::cmp::Ordering::Equal); } if let Some(op) = map.get("<") { let args = match op.as_array() { Some(a) if a.len() == 2 => a, _ => return false }; let a = resolve_value(&args[0], data); let b = resolve_value(&args[1], data); return compare_f64(&a, &b) == std::cmp::Ordering::Less; } if let Some(op) = map.get("<=") { let args = match op.as_array() { Some(a) if a.len() == 2 => a, _ => return false }; let a = resolve_value(&args[0], data); let b = resolve_value(&args[1], data); return matches!(compare_f64(&a, &b), std::cmp::Ordering::Less | std::cmp::Ordering::Equal); } if let Some(op) = map.get("==") { let args = match op.as_array() { Some(a) if a.len() == 2 => a, _ => return false }; let a = resolve_value(&args[0], data); let b = resolve_value(&args[1], data); return a == b; } if let Some(op) = map.get("!=") { let args = match op.as_array() { Some(a) if a.len() == 2 => a, _ => return false }; let a = resolve_value(&args[0], data); let b = resolve_value(&args[1], data); return a != b; } if let Some(op) = map.get("and") { return match op.as_array() { Some(arr) => arr.iter().all(|e| evaluate(e, data)), None => false, }; } if let Some(op) = map.get("or") { return match op.as_array() { Some(arr) => arr.iter().any(|e| evaluate(e, data)), None => false, }; } if let Some(op) = map.get("!") { return !evaluate(op, data); } if let Some(op) = map.get("in") { let args = match op.as_array() { Some(a) if a.len() == 2 => a, _ => return false }; let val = resolve_value(&args[0], data); let collection = resolve_value(&args[1], data); return match collection.as_array() { Some(arr) => arr.contains(&val), None => false, }; } false } Value::Bool(b) => *b, _ => false, } } /// 解析 {"var": "path.to.field"} 引用,支持点分路径 fn resolve_value(expr: &Value, data: &Value) -> Value { if let Value::Object(map) = expr { if let Some(var_path) = map.get("var").and_then(|v| v.as_str()) { return var_path.split('.').fold(data.clone(), |acc, key| { acc.get(key).cloned().unwrap_or(Value::Null) }); } } expr.clone() } fn compare_f64(a: &Value, b: &Value) -> std::cmp::Ordering { let a_num = value_to_f64(a); let b_num = value_to_f64(b); a_num.partial_cmp(&b_num).unwrap_or(std::cmp::Ordering::Equal) } fn value_to_f64(v: &Value) -> f64 { v.as_f64().or_else(|| v.as_i64().map(|n| n as f64)).unwrap_or(0.0) } /// 对患者数据评估所有启用的规则,返回匹配的规则和总分 pub fn evaluate_rules( rules: &[(uuid::Uuid, String, serde_json::Value, i16, String, Option)], patient_data: &Value, ) -> Vec<(uuid::Uuid, String, i16, String, Option)> { rules.iter() .filter(|(_, _, cond, _, _, _)| evaluate(cond, patient_data)) .map(|(id, name, _, score, severity, suggestion)| { (*id, name.clone(), *score, severity.clone(), suggestion.clone()) }) .collect() } ``` - [x] **Step 5: 运行测试确认通过** Run: `cargo test -p erp-ai -- copilot::rules::tests` Expected: 5 tests PASS - [ ] **Step 6: 在 lib.rs 注册 copilot 模块** 在 `crates/erp-ai/src/lib.rs` 的 mod 声明中添加 `pub mod copilot;` - [ ] **Step 7: 编译验证** Run: `cargo check -p erp-ai` Expected: 编译通过 - [ ] **Step 8: 提交** ```bash git add crates/erp-ai/src/copilot/ crates/erp-ai/src/lib.rs git commit -m "feat(ai): JSONLogic 规则引擎 + 单元测试" ``` ### Task 5: 评分 + 洞察 Service **Files:** - Create: `crates/erp-ai/src/copilot/scoring.rs` - Create: `crates/erp-ai/src/copilot/engine.rs` - Create: `crates/erp-ai/src/service/insight_service.rs` - Create: `crates/erp-ai/src/service/risk_service.rs` - Modify: `crates/erp-ai/src/service/mod.rs` - [ ] **Step 1: 创建 scoring.rs — 混合评分** `crates/erp-ai/src/copilot/scoring.rs`: ```rust use crate::copilot::rules::evaluate_rules; /// 风险评分结果 #[derive(Debug, Clone, serde::Serialize)] pub struct RiskScore { pub score: i16, pub level: String, pub matched_rules: Vec, } #[derive(Debug, Clone, serde::Serialize)] pub struct MatchedRule { pub rule_id: uuid::Uuid, pub name: String, pub score: i16, pub severity: String, pub suggestion: Option, } /// 根据匹配规则计算风险评分 pub fn calculate_risk( matched: Vec<(uuid::Uuid, String, i16, String, Option)>, ) -> RiskScore { let total: i16 = matched.iter().map(|(_, _, s, _, _)| *s).sum(); let clamped = total.clamp(0, 10); let level = match clamped { 0..=2 => "low".to_string(), 3..=5 => "medium".to_string(), 6..=8 => "high".to_string(), _ => "critical".to_string(), }; let matched_rules = matched.into_iter().map(|(id, name, score, severity, suggestion)| { MatchedRule { rule_id: id, name, score, severity, suggestion } }).collect(); RiskScore { score: clamped, level, matched_rules } } ``` - [ ] **Step 2: 创建 engine.rs — 洞察调度器** `crates/erp-ai/src/copilot/engine.rs`: ```rust use crate::copilot::rules::evaluate_rules; use crate::copilot::scoring::{calculate_risk, RiskScore}; use serde_json::Value; /// Copilot 引擎:协调规则评估和评分 pub struct CopilotEngine; impl CopilotEngine { /// 对患者数据运行所有规则并生成风险评分 pub fn assess_patient( rules: &[(uuid::Uuid, String, Value, i16, String, Option)], patient_data: &Value, ) -> RiskScore { let matched = evaluate_rules(rules, patient_data); calculate_risk(matched) } } ``` - [ ] **Step 3: 创建 risk_service.rs** `crates/erp-ai/src/service/risk_service.rs`: 参照 `crates/erp-ai/src/service/suggestion.rs` 的无状态 unit struct 模式。方法: - `compute_risk(db, tenant_id, patient_id) -> AppResult` — 加载规则 → 查询患者数据 → 调用 CopilotEngine::assess_patient → **UPSERT** copilot_risk_snapshots(使用 ON CONFLICT tenant_id+patient_id DO UPDATE,因为唯一索引保证每个患者只有一条快照) - `get_latest_risk(db, tenant_id, patient_id) -> AppResult>` — 查询最新快照 - `refresh_all_patients(db, tenant_id) -> AppResult` — 批量刷新所有在管患者 **实现要点:** 所有查询必须带 `tenant_id` 过滤 + `deleted_at IS NULL` 条件。更新操作检查 `version_lock` 乐观锁。参照 `suggestion.rs` 的 `Set(...)` + `..Default::default()` 模式。 - [ ] **Step 4: 创建 insight_service.rs** `crates/erp-ai/src/service/insight_service.rs`: 方法: - `create_insight(db, tenant_id, patient_id, insight_type, ...) -> AppResult` — 写入洞察 - `list_insights(db, tenant_id, filters) -> AppResult<(Vec, u64)>` — 分页查询 - `dismiss_insight(db, tenant_id, insight_id) -> AppResult<()>` — 标记已处理 - `cleanup_expired(db) -> AppResult` — 清理过期洞察 - [ ] **Step 5: 注册 service 模块** 在 `crates/erp-ai/src/service/mod.rs` 中添加: ```rust pub mod risk_service; pub mod insight_service; ``` - [ ] **Step 6: 编译验证** Run: `cargo check -p erp-ai` Expected: 编译通过 - [ ] **Step 7: 提交** ```bash git add crates/erp-ai/src/copilot/ crates/erp-ai/src/service/risk_service.rs crates/erp-ai/src/service/insight_service.rs crates/erp-ai/src/service/mod.rs git commit -m "feat(ai): Copilot 评分引擎 + 风险/洞察 service" ``` ### Task 6: Copilot Handler + 路由注册 **Files:** - Create: `crates/erp-ai/src/handler/insight_handler.rs` - Create: `crates/erp-ai/src/handler/risk_handler.rs` - Create: `crates/erp-ai/src/handler/rule_handler.rs` - Create: `crates/erp-ai/src/dto/copilot.rs` - Modify: `crates/erp-ai/src/handler/mod.rs` - Modify: `crates/erp-ai/src/dto/mod.rs` - Modify: `crates/erp-ai/src/state.rs` - Modify: `crates/erp-ai/src/module.rs` - Modify: `crates/erp-ai/src/error.rs` - [ ] **Step 1: 创建 DTO** `crates/erp-ai/src/dto/copilot.rs`: ```rust use serde::{Deserialize, Serialize}; #[derive(Debug, Deserialize)] pub struct ListInsightsQuery { pub patient_id: Option, pub insight_type: Option, pub severity: Option, pub page: Option, pub page_size: Option, } #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum InsightType { RiskScore, Anomaly, FollowUpHint, ConsultHint, } #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum RiskLevel { Low, Medium, High, Critical, } ``` 在 `dto/mod.rs` 中添加 `pub mod copilot;` - [ ] **Step 2: 扩展 State + 构造** 在 `crates/erp-ai/src/state.rs` 中添加: ```rust pub struct AiState { // ... existing fields ... pub risk_service: Arc, pub insight_service: Arc, } ``` 在 `crates/erp-ai/src/module.rs` 的 `on_startup()` 或 state 构造处(参照现有 service 初始化模式),初始化: ```rust risk_service: Arc::new(RiskService), insight_service: Arc::new(InsightService), ``` 在 `crates/erp-ai/src/error.rs` 中添加规则引擎错误变体: ```rust #[error("规则表达式格式错误: {0}")] InvalidRuleExpression(String), ``` - [ ] **Step 3: 创建 insight_handler.rs** `crates/erp-ai/src/handler/insight_handler.rs`: 参照 `suggestion_handler.rs` 模式,实现: - `list_insights` — GET /copilot/insights,权限 copilot.insights.list - `get_insight` — GET /copilot/insights/{id},权限 copilot.insights.list - `dismiss_insight` — POST /copilot/insights/{id}/dismiss,权限 copilot.insights.manage - [ ] **Step 4: 创建 risk_handler.rs** `crates/erp-ai/src/handler/risk_handler.rs`: - `get_patient_risk` — GET /copilot/patients/{id}/risk,权限 copilot.risk.view - [ ] **Step 5: 注册 handler 模块** 在 `handler/mod.rs` 中添加: ```rust pub mod insight_handler; pub mod risk_handler; pub mod rule_handler; ``` - [ ] **Step 5b: 创建 rule_handler.rs** `crates/erp-ai/src/handler/rule_handler.rs`: - `list_rules` — GET /copilot/rules,权限 copilot.rules.list - `create_rule` — POST /copilot/rules,权限 copilot.rules.manage - `update_rule` — PUT /copilot/rules/{id},权限 copilot.rules.manage - [ ] **Step 6: 注册路由 + 权限码** 在 `module.rs` 的 `protected_routes()` 中添加 Copilot 路由,在 `permissions()` 中添加权限码: ``` copilot.insights.list copilot.insights.manage copilot.risk.view copilot.rules.list copilot.rules.manage ``` - [ ] **Step 7: 编译验证** Run: `cargo check -p erp-ai` Expected: 编译通过 - [ ] **Step 8: 提交** ```bash git add crates/erp-ai/src/ git commit -m "feat(ai): Copilot handler + 路由 + 权限码 + DTO" ``` ### Task 7: 预置规则种子数据 **Files:** - Create: `crates/erp-server/migration/src/m20260512_000142_seed_copilot_rules.rs` - Modify: `crates/erp-server/migration/src/lib.rs` - [ ] **Step 1: 创建种子数据迁移** 插入 15 条内置规则,覆盖 spec §3.1 的 5 大类: 体征异常(4 条): - 收缩压连续3次>140(+2) - 舒张压连续3次>90(+1) - 体重周增幅>2kg(+2) - 心率>100(+1) 化验异常(4 条): - eGFR<60(+3) - 血钾>5.5(+4,critical) - 肌酐环比>20%(+3) - 血磷>1.5(+2) 依从性(2 条): - 随访失约>2次(+1) - 药物依从性<80%(+2) 透析质量(3 条): - Kt/V<1.2(+2) - 透析间期体重增长>5%(+3) - 透析前收缩压>180(+3) 综合(2 条): - eGFR<60 且 血钾>5.5(+5,critical) - 收缩压>160 且 肌酐环比>20%(+4) 所有规则 `tenant_id` 使用 `uuid::Uuid::nil()`(系统级规则,适用于所有机构)。 **JSONLogic 示例(供 INSERT 使用):** ```sql -- 收缩压连续3次>140 INSERT INTO copilot_rules (id, tenant_id, name, category, condition_expr, score, severity, suggestion, enabled, sort_order) VALUES ('019d...', '00000000-0000-0000-0000-000000000000', '血压持续偏高', 'vital_signs', '{"and":[{"=": [{"var":"vital_signs.systolic.count_gte_140"}, 3]}]}'::jsonb, 2, 'warning', '建议增加血压监测频率并评估降压方案', true, 1); -- eGFR < 60 INSERT INTO copilot_rules (...) VALUES ('019d...', '00000000-...', 'eGFR下降', 'lab', '{"<": [{"var":"lab_reports.egfr.latest"}, 60]}'::jsonb, 3, 'warning', 'eGFR<60提示肾功能受损,建议调整透析方案', true, 5); -- 血钾 > 5.5(危急值) INSERT INTO copilot_rules (...) VALUES ('019d...', '00000000-...', '高钾血症风险', 'lab', '{">": [{"var":"lab_reports.potassium.latest"}, 5.5]}'::jsonb, 4, 'critical', '立即通知主治医生,评估紧急透析需求', true, 6); ``` **注意:** `vital_signs.systolic.count_gte_140` 等聚合路径需要后端在组装患者数据时预计算。Phase 0 先实现简单字段路径(如 `lab_reports.egfr.latest`),聚合路径在 Phase 1 中补充。 - [ ] **Step 2: 注册迁移** - [ ] **Step 3: 编译验证** Run: `cargo check -p erp-server` Expected: 编译通过 - [ ] **Step 4: 提交** ```bash git add crates/erp-server/migration/src/m20260512_000142_seed_copilot_rules.rs crates/erp-server/migration/src/lib.rs git commit -m "feat(db): 15 条 Copilot 内置规则种子数据" ``` ### Task 8: Phase 0 集成验证 - [ ] **Step 1: 全 workspace 编译检查** Run: `cargo check --workspace` Expected: 0 errors - [ ] **Step 2: 全 workspace 测试** Run: `cargo test --workspace` Expected: 所有测试通过(含规则引擎新测试) - [ ] **Step 3: 启动后端服务** Run: `cd crates/erp-server && cargo run` Expected: 服务启动,迁移自动执行(4 张新表 + 种子数据) - [ ] **Step 4: API 烟雾测试** Run: `curl http://localhost:3000/api/v1/copilot/insights -H "Authorization: Bearer "` Expected: 返回空洞察列表(200 OK) Run: `curl http://localhost:3000/api/v1/copilot/rules -H "Authorization: Bearer "` Expected: 返回 15 条预置规则 - [ ] **Step 5: 提交(如有修复)** --- ## Chunk 2: Phase 1 — 医护端风险画像 > **目标:** 医护打开患者档案时,能看到 Copilot 风险徽章和洞察卡片。事件驱动的异步评分正常工作。 > **验收:** 录入新体征数据后风险评分自动更新 + 医护端显示风险徽章 + LLM 补充分析正常返回(失败时降级) > **依赖:** Chunk 1 全部完成 ### Task 9: 事件消费者(copilot_consumer) **Files:** - Create: `crates/erp-ai/src/event/mod.rs` - Create: `crates/erp-ai/src/event/copilot_consumer.rs` - Modify: `crates/erp-ai/src/lib.rs`(添加 `pub mod event;`) - Modify: `crates/erp-ai/src/module.rs`(on_startup 中启动消费者) - [x] **Step 1: 创建 event 模块入口** `crates/erp-ai/src/event/mod.rs`: ```rust pub mod copilot_consumer; ``` - [x] **Step 2: 编写消费者失败的测试** `crates/erp-ai/src/event/copilot_consumer.rs` 底部: ```rust #[cfg(test)] mod tests { use super::*; #[test] fn test_event_prefixes_include_health_events() { let prefixes = copilot_event_prefixes(); assert!(prefixes.contains(&"daily_monitoring.".to_string())); assert!(prefixes.contains(&"lab_report.".to_string())); assert!(prefixes.contains(&"follow_up.".to_string())); assert!(prefixes.contains(&"patient.".to_string())); } #[test] fn test_should_trigger_risk_refresh_for_vital_signs() { assert!(should_trigger_risk_refresh("daily_monitoring.created")); assert!(should_trigger_risk_refresh("lab_report.reviewed")); assert!(!should_trigger_risk_refresh("patient.updated")); } } ``` - [x] **Step 3: 运行测试确认失败** Run: `cargo test -p erp-ai -- copilot_consumer::tests` Expected: 编译失败 - [x] **Step 4: 实现事件消费者** `crates/erp-ai/src/event/copilot_consumer.rs`: 参照 `crates/erp-health/src/event/ai.rs` 的 `spawn()` + `subscribe_filtered()` 模式: ```rust use erp_core::events::DomainEvent; /// Copilot 关注的事件前缀 pub fn copilot_event_prefixes() -> Vec { vec![ "daily_monitoring.".to_string(), "lab_report.".to_string(), "follow_up.".to_string(), "patient.".to_string(), ] } /// 判断事件是否应触发风险评分刷新 pub fn should_trigger_risk_refresh(event_type: &str) -> bool { matches!( event_type, "daily_monitoring.created" | "lab_report.reviewed" | "follow_up.completed" | "follow_up.overdue" | "patient.created" ) } /// 启动 Copilot 事件消费者 pub fn spawn( db: &sea_orm::DatabaseConnection, event_bus: &erp_core::events::EventBus, ) -> Vec { let mut handles = Vec::new(); for prefix in copilot_event_prefixes() { let (mut rx, handle) = event_bus.subscribe_filtered(prefix); handles.push(handle); let db = db.clone(); tokio::spawn(async move { loop { match rx.recv().await { Some(event) => { if should_trigger_risk_refresh(&event.event_type) { process_event(&db, &event).await; } } None => break, } } }); } handles } async fn process_event(db: &sea_orm::DatabaseConnection, event: &DomainEvent) { // 幂等检查 if erp_core::events::is_event_processed(db, event.id, "copilot_consumer").await.unwrap_or(false) { return; } let tenant_id = event.tenant_id; let patient_id = match event.payload.get("patient_id").and_then(|v| v.as_str()) { Some(id) => match uuid::Uuid::parse_str(id) { Ok(uid) => uid, Err(_) => return, }, None => return, }; // 异步刷新风险评分(纯规则模式) let _ = crate::service::risk_service::RiskService::compute_risk(db, tenant_id, patient_id).await; // 异常检测:如果产生了告警级规则匹配,写入洞察 // TODO: Phase 2 中增强 let _ = erp_core::events::mark_event_processed(db, event.id, "copilot_consumer").await; } ``` - [x] **Step 5: 在 module.rs on_startup 中启动消费者** ```rust // 在 on_startup 方法中添加 let copilot_handles = crate::event::copilot_consumer::spawn(&ctx.db, &ctx.event_bus); std::mem::forget(copilot_handles); ``` - [x] **Step 6: 在 lib.rs 注册 event 模块** 添加 `pub mod event;` - [x] **Step 7: 运行测试** Run: `cargo test -p erp-ai -- copilot_consumer::tests` Expected: 2 tests PASS - [x] **Step 8: 编译验证** Run: `cargo check -p erp-ai` Expected: 编译通过 - [x] **Step 9: 提交** ```bash git add crates/erp-ai/src/event/ crates/erp-ai/src/lib.rs crates/erp-ai/src/module.rs git commit -m "feat(ai): Copilot 事件消费者(订阅 health 事件)" ``` ### Task 10: LLM 补充分析集成 **Files:** - Modify: `crates/erp-ai/src/copilot/scoring.rs` - [x] **Step 1: 编写 LLM 补充分析失败的测试** 在 `scoring.rs` 底部添加测试: ```rust #[cfg(test)] mod tests { use super::*; #[test] fn test_calculate_risk_low() { let matched = vec![]; let result = calculate_risk(matched); assert_eq!(result.score, 0); assert_eq!(result.level, "low"); } #[test] fn test_calculate_risk_high() { let matched = vec![ (uuid::Uuid::new_v4(), "eGFR下降".into(), 3, "warning".into(), Some("建议调整".into())), (uuid::Uuid::new_v4(), "血压偏高".into(), 2, "warning".into(), None), (uuid::Uuid::new_v4(), "失约".into(), 1, "info".into(), None), ]; let result = calculate_risk(matched); assert_eq!(result.score, 6); assert_eq!(result.level, "high"); assert_eq!(result.matched_rules.len(), 3); } #[test] fn test_calculate_risk_clamp_at_10() { let matched = vec![ (uuid::Uuid::new_v4(), "危急".into(), 5, "critical".into(), None), (uuid::Uuid::new_v4(), "严重".into(), 4, "critical".into(), None), (uuid::Uuid::new_v4(), "异常".into(), 3, "warning".into(), None), ]; let result = calculate_risk(matched); assert_eq!(result.score, 10); assert_eq!(result.level, "critical"); } } ``` - [x] **Step 2: 运行测试** Run: `cargo test -p erp-ai -- copilot::scoring::tests` Expected: 3 tests PASS - [x] **Step 3: 添加 LLM 补充分析函数** 在 `scoring.rs` 中添加(不阻塞,失败返回 None): ```rust /// LLM 补充分析:基于规则评分结果和患者数据,生成自然语言的补充洞察 /// 失败时返回 None(降级为纯规则模式) pub async fn llm_supplement( provider_registry: &crate::provider::ProviderRegistry, risk_score: &RiskScore, patient_data: &serde_json::Value, ) -> Option { let prompt = format!( "基于以下患者风险评分和匹配规则,是否存在规则未覆盖的风险因素?\ 风险评分:{}/10,等级:{}\n\ 匹配规则:{}\n\ 患者近期数据摘要:{}\n\ 请给出简洁的补充分析(100字以内),如无补充请回复\"无补充\"。", risk_score.score, risk_score.level, risk_score.matched_rules.iter() .map(|r| format!("- {}(+{}分)", r.name, r.score)) .collect::>() .join("\n"), serde_json::to_string(&serde_json::json!({ "latest_bp": patient_data.get("vital_signs"), "latest_lab": patient_data.get("lab_reports"), })).unwrap_or_default(), ); provider_registry.generate_text(&prompt).await.ok() } ``` - [x] **Step 4: 修改 risk_service 使用 LLM 补充** 在 `risk_service::compute_risk` 中,规则评分完成后异步调用 `llm_supplement()`: - 成功:将结果写入 `copilot_risk_snapshots.llm_summary` - 失败:`llm_summary` 为 None(静默降级) - [x] **Step 5: 编译验证** Run: `cargo check -p erp-ai` Expected: 编译通过 - [x] **Step 6: 提交** ```bash git add crates/erp-ai/src/copilot/scoring.rs crates/erp-ai/src/service/risk_service.rs git commit -m "feat(ai): LLM 补充风险分析 + 降级策略" ``` ### Task 11: 每日风险快照批量刷新 **Files:** - Modify: `crates/erp-ai/src/module.rs`(添加定时任务) - Modify: `crates/erp-ai/src/service/risk_service.rs` - [x] **Step 1: 在 on_startup 中启动定时任务** ```rust // 每日凌晨 2:00 刷新所有在管患者风险快照 let db = ctx.db.clone(); tokio::spawn(async move { let mut interval = tokio::time::interval(std::time::Duration::from_secs(86400)); loop { interval.tick().await; // 获取所有租户的在管患者列表 // 对每个患者调用 RiskService::compute_risk // 记录刷新结果数量 } }); ``` - [x] **Step 2: 实现 refresh_all_patients** 在 `risk_service.rs` 中: - 查询所有 `tenant_id` 下 `deleted_at IS NULL` 的患者 - 逐个调用 `compute_risk` - 返回刷新数量 - [x] **Step 3: 编译验证 + 提交** ```bash git add crates/erp-ai/src/module.rs crates/erp-ai/src/service/risk_service.rs git commit -m "feat(ai): 每日风险快照批量刷新定时任务" ``` ### Task 12: 前端 Copilot API 层 **Files:** - Create: `apps/web/src/api/copilot.ts` - [x] **Step 1: 创建 Copilot API 模块** 参照 `apps/web/src/api/health/articles.ts` 的模式: ```typescript import request from '@/utils/request'; export interface CopilotInsight { id: string; patient_id: string; insight_type: 'risk_score' | 'anomaly' | 'follow_up_hint' | 'consult_hint'; source: 'rule' | 'llm' | 'hybrid'; severity: 'info' | 'warning' | 'critical'; title: string; content: Record; rule_matches?: MatchedRule[]; llm_supplement?: string; created_at: string; } export interface RiskScore { score: number; level: 'low' | 'medium' | 'high' | 'critical'; matched_rules: MatchedRule[]; } export interface MatchedRule { rule_id: string; name: string; score: number; severity: string; suggestion?: string; } export function getPatientRisk(patientId: string) { return request.get<{ data: RiskScore }>(`/copilot/patients/${patientId}/risk`); } export function listInsights(params: { patient_id?: string; insight_type?: string; severity?: string }) { return request.get<{ data: CopilotInsight[]; total: number }>('/copilot/insights', { params }); } export function dismissInsight(id: string) { return request.post(`/copilot/insights/${id}/dismiss`); } export function getFollowupHint(patientId: string) { return request.get<{ data: unknown }>(`/copilot/patients/${patientId}/followup-hint`); } export function getConsultHint(patientId: string) { return request.get<{ data: unknown }>(`/copilot/patients/${patientId}/consult-hint`); } ``` - [x] **Step 2: 提交** ```bash git add apps/web/src/api/copilot.ts git commit -m "feat(web): Copilot API 调用层" ``` ### Task 13: 前端 CopilotBadge + CopilotCard **Files:** - Create: `apps/web/src/components/Copilot/CopilotBadge.tsx` - Create: `apps/web/src/components/Copilot/CopilotCard.tsx` - Create: `apps/web/src/components/Copilot/hooks/useCopilotRisk.ts` - Create: `apps/web/src/components/Copilot/hooks/useCopilotInsights.ts` - [x] **Step 1: 创建 useCopilotRisk hook** ```typescript import { useQuery } from '@tanstack/react-query'; import { getPatientRisk } from '@/api/copilot'; export function useCopilotRisk(patientId: string | undefined) { return useQuery({ queryKey: ['copilot', 'risk', patientId], queryFn: () => getPatientRisk(patientId!), enabled: !!patientId, staleTime: 5 * 60 * 1000, // 5 分钟缓存 }); } ``` - [x] **Step 2: 创建 useCopilotInsights hook** ```typescript import { useQuery } from '@tanstack/react-query'; import { listInsights } from '@/api/copilot'; export function useCopilotInsights(patientId: string | undefined) { return useQuery({ queryKey: ['copilot', 'insights', patientId], queryFn: () => listInsights({ patient_id: patientId, severity: 'warning,critical' }), enabled: !!patientId, staleTime: 5 * 60 * 1000, }); } ``` - [x] **Step 3: 创建 CopilotBadge** ```tsx import { Tag } from 'antd'; import type { RiskScore } from '@/api/copilot'; const levelConfig: Record = { low: { color: 'green', label: '低风险' }, medium: { color: 'orange', label: '中风险' }, high: { color: 'red', label: '高风险' }, critical: { color: '#cf1322', label: '危急' }, }; interface Props { risk: RiskScore | undefined; loading?: boolean; } export default function CopilotBadge({ risk, loading }: Props) { if (loading) return 评估中...; if (!risk) return null; const config = levelConfig[risk.level] ?? levelConfig.low; return {config.label} {risk.score}/10; } ``` - [x] **Step 4: 创建 CopilotCard** 可展开的洞察卡片,显示: - 风险评分 + 规则匹配详情 - LLM 补充分析文本 - 操作按钮:[查看详细报告] [创建随访计划] [忽略] 使用 Ant Design 的 `Collapse.Panel` 或 `Card` 组件。 - [x] **Step 5: 编译验证** Run: `cd apps/web && pnpm build` Expected: 编译通过 - [x] **Step 6: 提交** ```bash git add apps/web/src/components/Copilot/ git commit -m "feat(web): CopilotBadge + CopilotCard 组件" ``` ### Task 14: 嵌入 CopilotBadge 到患者详情页 **Files:** - Modify: `apps/web/src/pages/health/PatientDetail.tsx`(或对应的现有患者详情页) - [ ] **Step 1: 导入组件** 在患者详情页中: ```tsx import CopilotBadge from '@/components/Copilot/CopilotBadge'; import { useCopilotRisk } from '@/components/Copilot/hooks/useCopilotRisk'; ``` - [ ] **Step 2: 在患者姓名旁添加徽章** ```tsx const { data: riskData } = useCopilotRisk(patientId); // 在患者姓名区域 {patient.name} ``` - [ ] **Step 3: 功能验证** 启动前端 + 后端,打开患者详情页,确认: - 风险徽章正常显示 - 有风险评分数据 - Copilot API 调用正常 - [ ] **Step 4: 提交** ```bash git add apps/web/src/pages/health/ git commit -m "feat(web): 患者详情页嵌入 Copilot 风险徽章" ``` ### Task 15: 权限码 + 菜单注册 **Files:** - 修改数据库种子数据或管理后台配置,添加 Copilot 权限码到相应角色 - [ ] **Step 1: 确认权限码已注册** Task 6 中已在 `module.rs` 的 `permissions()` 中注册了 5 个权限码。验证: - 管理员角色拥有所有 Copilot 权限 - 医生/护士角色拥有 `copilot.insights.list` + `copilot.risk.view` - [ ] **Step 2: 功能验证** - 以管理员登录,访问 Copilot API,确认 200 - 以护士登录,确认可查看洞察和风险画像 - 确认无权限用户访问返回 403 - [ ] **Step 3: 提交(如有修改)** ### Task 16: Phase 1 集成验证 - [ ] **Step 1: cargo test --workspace** Run: `cargo test --workspace` Expected: 所有测试通过 - [ ] **Step 2: pnpm build** Run: `cd apps/web && pnpm build` Expected: 构建通过 - [ ] **Step 3: 启动后端 + 前端,完整流程验证** 1. 启动后端 `cd crates/erp-server && cargo run` 2. 启动前端 `cd apps/web && pnpm dev` 3. 以护士角色登录 4. 录入一条新体征数据(收缩压 150) 5. 打开该患者详情页 6. 验证:风险徽章显示"中风险"或以上 7. 展开 CopilotCard,确认规则匹配和 LLM 补充分析 - [ ] **Step 4: 提交(如有修复)** --- ## Chunk 3: Phase 2 — 异常检测 + 告警推送 > **目标:** 健康数据入库时自动检测异常,推送告警给医护。危急值秒级生成告警,仪表盘显示分级告警列表。 > **验收:** Critical value(如 K+ >6.0)入库后秒级生成告警 + 医护仪表盘显示分级告警列表 + 告警可标记处理状态 > **依赖:** Chunk 1(规则引擎 + insight service)+ Chunk 2(事件消费者已订阅 health 事件) ### Task 17: 异常检测规则扩展(趋势类/复合类规则) **Files:** - Create: `crates/erp-server/migration/src/m20260512_000143_seed_copilot_alert_rules.rs` - Modify: `crates/erp-server/migration/src/lib.rs` - [ ] **Step 1: 创建告警类规则种子迁移** 在 Task 7 的 15 条基础规则之上,新增 8 条趋势类/复合类规则,覆盖 spec §3.2 的告警分级场景: 趋势类规则(4 条): - 收缩压快速上升(prev1=130 → latest=150,环比增幅>15%)(+3,warning) - 肌酐连续上升(prev1 < prev2 < latest,三值递增)(+3,warning) - 体重连续上升(prev1 < prev2 < latest,三值递增)(+2,info) - 血压趋势整体上升(prev2 < prev1 < latest)(+2,info) 复合类规则(4 条): - eGFR<45 且 血钾>5.0(+5,critical)— 比基础规则更严格的双重条件 - 透析间期体重增长>5% 且 收缩压>160(+4,critical) - 随访失约>2次 且 药物依从性<70%(+3,warning) - Kt/V<1.0 且 透析前收缩压>180(+5,critical) ```sql -- 收缩压快速上升(趋势类) INSERT INTO copilot_rules (id, tenant_id, name, category, condition_expr, score, severity, suggestion, enabled, sort_order) VALUES ('019d...', '00000000-...', '收缩压快速上升', 'vital_signs', '{"and":[{"=": [{"var":"vital_signs.systolic.trend_rising"}, true]},{">": [{"var":"vital_signs.systolic.change_pct"}, 15]}]}'::jsonb, 3, 'warning', '血压短时间内快速上升,需排除急性因素,建议加测并通知主治医生', true, 20); -- Kt/V<1.0 且 透析前收缩压>180(复合类危急) INSERT INTO copilot_rules (...) VALUES ('019d...', '00000000-...', '透析质量危急', 'composite', '{"and":[{"<": [{"var":"dialysis.ktv.latest"}, 1.0]},{">": [{"var":"vital_signs.systolic.latest"}, 180]}]}'::jsonb, 5, 'critical', '透析充分性严重不足且血压极高,需紧急评估透析方案', true, 27); ``` 所有规则 `tenant_id` 使用 `uuid::Uuid::nil()`(系统级规则)。 - [ ] **Step 2: 注册迁移** 在 `migration/src/lib.rs` 中添加 `mod m20260512_000143_seed_copilot_alert_rules;` 并在 `migrations()` vec 中注册。 - [ ] **Step 3: 编译验证** Run: `cargo check -p erp-server` Expected: 编译通过 - [ ] **Step 4: 提交** ```bash git add crates/erp-server/migration/src/m20260512_000143_seed_copilot_alert_rules.rs crates/erp-server/migration/src/lib.rs git commit -m "feat(db): 8 条 Copilot 趋势/复合类告警规则种子数据" ``` ### Task 18: 告警洞察生成逻辑 **Files:** - Modify: `crates/erp-ai/src/event/copilot_consumer.rs` - Modify: `crates/erp-ai/src/service/insight_service.rs` - Modify: `crates/erp-ai/src/copilot/engine.rs` - [ ] **Step 1: 编写告警洞察生成失败的测试** 在 `crates/erp-ai/src/copilot/engine.rs` 底部添加测试: ```rust #[cfg(test)] mod tests { use super::*; use serde_json::json; #[test] fn test_generate_anomaly_insight_critical() { let matched = vec![ (uuid::Uuid::new_v4(), "高钾血症风险".into(), 4, "critical".into(), Some("立即通知主治医生".into())), ]; let insights = generate_anomaly_insights("patient-123", &matched); assert_eq!(insights.len(), 1); assert_eq!(insights[0]["severity"], "critical"); assert_eq!(insights[0]["insight_type"], "anomaly"); } #[test] fn test_generate_anomaly_insight_filters_info() { // info 级别规则不生成告警洞察(仅在档案内展示) let matched = vec![ (uuid::Uuid::new_v4(), "体重轻微波动".into(), 1, "info".into(), None), ]; let insights = generate_anomaly_insights("patient-123", &matched); assert!(insights.is_empty()); } #[test] fn test_generate_anomaly_insight_warning_and_critical() { let matched = vec![ (uuid::Uuid::new_v4(), "eGFR下降".into(), 3, "warning".into(), Some("建议调整".into())), (uuid::Uuid::new_v4(), "透析质量危急".into(), 5, "critical".into(), Some("紧急评估".into())), ]; let insights = generate_anomaly_insights("patient-123", &matched); // 应生成 2 条洞察(warning + critical 都会生成告警) assert_eq!(insights.len(), 2); } } ``` - [ ] **Step 2: 运行测试确认失败** Run: `cargo test -p erp-ai -- copilot::engine::tests` Expected: 编译失败(函数不存在) - [ ] **Step 3: 在 engine.rs 中实现告警洞察生成** ```rust /// 根据规则匹配结果生成异常洞察 /// 仅 warning 和 critical 级别生成告警洞察,info 级别仅在档案内展示 pub fn generate_anomaly_insights( patient_id: &str, matched: &[(uuid::Uuid, String, i16, String, Option)], ) -> Vec { matched.iter() .filter(|(_, _, _, severity, _)| severity == "warning" || severity == "critical") .map(|(rule_id, name, score, severity, suggestion)| { serde_json::json!({ "patient_id": patient_id, "insight_type": "anomaly", "source": "rule", "severity": severity, "title": name, "content": { "rule_id": rule_id.to_string(), "score": score, "suggestion": suggestion, }, }) }) .collect() } ``` - [ ] **Step 4: 修改事件消费者,在风险评分后生成告警洞察** 在 `copilot_consumer.rs` 的 `process_event` 函数中,`compute_risk` 成功后增加: ```rust // 异常检测:如果产生了告警级规则匹配,写入洞察 if let Ok(risk) = crate::service::risk_service::RiskService::compute_risk(db, tenant_id, patient_id).await { let matched_with_severity: Vec<_> = risk.matched_rules.into_iter() .map(|r| (r.rule_id, r.name, r.score, r.severity, r.suggestion)) .collect(); let anomaly_insights = crate::copilot::engine::generate_anomaly_insights( &patient_id.to_string(), &matched_with_severity, ); for insight_data in anomaly_insights { let severity = insight_data["severity"].as_str().unwrap_or("warning").to_string(); let title = insight_data["title"].as_str().unwrap_or("异常告警").to_string(); let _ = crate::service::insight_service::InsightService::create_insight( db, tenant_id, patient_id, "anomaly", "rule", Some(&severity), &title, &insight_data, None, // rule_matches(已在 content 中) None, // llm_supplement ).await; } } ``` - [ ] **Step 5: 在 insight_service 中扩展 create_insight 签名** 确保 `insight_service::create_insight` 支持传入 `severity`、`source`、`rule_matches` 等字段,设置 `expires_at` 为创建时间 + 7 天。 - [ ] **Step 6: 运行测试** Run: `cargo test -p erp-ai -- copilot::engine::tests` Expected: 3 tests PASS - [ ] **Step 7: 编译验证** Run: `cargo check -p erp-ai` Expected: 编译通过 - [ ] **Step 8: 提交** ```bash git add crates/erp-ai/src/copilot/engine.rs crates/erp-ai/src/event/copilot_consumer.rs crates/erp-ai/src/service/insight_service.rs git commit -m "feat(ai): 告警洞察生成逻辑 + 事件消费者增强" ``` ### Task 19: 前端 CopilotAlert 组件 **Files:** - Create: `apps/web/src/components/Copilot/CopilotAlert.tsx` - Modify: `apps/web/src/api/copilot.ts`(添加告警查询 API) - Modify: `apps/web/src/components/Copilot/hooks/useCopilotInsights.ts`(添加告警专用 hook) - [ ] **Step 1: 扩展 Copilot API 层** 在 `apps/web/src/api/copilot.ts` 中添加: ```typescript export function listAlerts(params?: { severity?: string }) { return request.get<{ data: CopilotInsight[]; total: number }>('/copilot/insights', { params: { insight_type: 'anomaly', ...params }, }); } export function dismissAlert(id: string) { return request.post(`/copilot/insights/${id}/dismiss`); } ``` - [ ] **Step 2: 创建告警专用 hook** 在 `hooks/useCopilotInsights.ts` 中添加: ```typescript export function useCopilotAlerts() { return useQuery({ queryKey: ['copilot', 'alerts'], queryFn: () => listAlerts(), refetchInterval: 30 * 1000, // 30 秒轮询刷新 staleTime: 10 * 1000, }); } ``` - [ ] **Step 3: 创建 CopilotAlert 组件** `apps/web/src/components/Copilot/CopilotAlert.tsx`: ```tsx import { Alert, Badge, List, Button, Space, Typography } from 'antd'; import { BellOutlined, CheckOutlined, WarningOutlined } from '@ant-design/icons'; import type { CopilotInsight } from '@/api/copilot'; const severityConfig: Record = { critical: { type: 'error', label: '危急' }, warning: { type: 'warning', label: '警告' }, info: { type: 'info', label: '提示' }, }; interface Props { alerts: CopilotInsight[]; loading?: boolean; onDismiss: (id: string) => void; } export default function CopilotAlert({ alerts, loading, onDismiss }: Props) { if (!alerts.length && !loading) return null; const criticalCount = alerts.filter(a => a.severity === 'critical').length; return (
{criticalCount > 0 && ( } message={`${criticalCount} 条危急告警`} banner style={{ marginBottom: 16 }} /> )} { const config = severityConfig[item.severity] ?? severityConfig.info; return ( } onClick={() => onDismiss(item.id)}> 已知悉 , ]} > {item.title}} description={item.content?.suggestion as string} /> ); }} />
); } ``` - [ ] **Step 4: 浏览器通知(Critical 级别)** 在 `CopilotAlert` 组件中添加 `useEffect`,当检测到新的 critical 告警时触发浏览器通知: ```typescript useEffect(() => { const criticalAlerts = alerts.filter(a => a.severity === 'critical'); if (criticalAlerts.length > 0 && 'Notification' in window) { Notification.requestPermission().then((perm) => { if (perm === 'granted') { criticalAlerts.forEach((alert) => { new window.Notification('HMS Copilot 危急告警', { body: alert.title, tag: alert.id, // 防止重复通知 }); }); } }); } }, [alerts]); ``` - [ ] **Step 5: 编译验证** Run: `cd apps/web && pnpm build` Expected: 编译通过 - [ ] **Step 6: 提交** ```bash git add apps/web/src/components/Copilot/CopilotAlert.tsx apps/web/src/api/copilot.ts apps/web/src/components/Copilot/hooks/useCopilotInsights.ts git commit -m "feat(web): CopilotAlert 告警组件 + 浏览器通知" ``` ### Task 20: 告警处理工作流 **Files:** - Modify: `crates/erp-ai/src/handler/insight_handler.rs`(添加 dismiss / escalate 端点) - Modify: `crates/erp-ai/src/service/insight_service.rs` - Modify: `crates/erp-ai/src/dto/copilot.rs` - [ ] **Step 1: 扩展 DTO** 在 `dto/copilot.rs` 中添加: ```rust #[derive(Debug, Deserialize)] pub struct DismissInsightRequest { pub action: Option, // "dismiss" | "escalate" pub note: Option, } ``` - [ ] **Step 2: 扩展 insight_service** 在 `insight_service.rs` 中添加方法: - `dismiss_insight(db, tenant_id, insight_id, note) -> AppResult<()>` — 设置 `is_dismissed = true`,`updated_at = now()` - `escalate_insight(db, tenant_id, insight_id, note) -> AppResult` — 创建一条新的 `follow_up_hint` 类型洞察,链接到原始告警,severity 升级为 critical ```rust /// 升级告警:将告警转为随访任务建议 pub async fn escalate_insight( db: &DatabaseConnection, tenant_id: Uuid, insight_id: Uuid, note: Option, ) -> AppResult { let original = Self::get_insight(db, tenant_id, insight_id).await?; let escalated_title = format!("[升级] {}", original.title); let escalated_content = serde_json::json!({ "original_insight_id": insight_id.to_string(), "escalation_note": note, "original_severity": original.severity, }); Self::create_insight( db, tenant_id, original.patient_id, "follow_up_hint", "rule", Some("critical"), &escalated_title, &escalated_content, None, None, ).await } ``` - [ ] **Step 3: 扩展 insight_handler** 在 `insight_handler.rs` 中添加端点: - `escalate_insight` — POST /copilot/insights/{id}/escalate,权限 copilot.insights.manage - 接收 `DismissInsightRequest` body - 调用 `insight_service::escalate_insight` 修改现有 `dismiss_insight` 端点,支持 `note` 字段。 - [ ] **Step 4: 注册新路由** 在 `module.rs` 的 `protected_routes()` 中添加: ```rust .route("/copilot/insights/{insight_id}/escalate", post(insight_handler::escalate_insight)) ``` - [ ] **Step 5: 编译验证** Run: `cargo check -p erp-ai` Expected: 编译通过 - [ ] **Step 6: 提交** ```bash git add crates/erp-ai/src/handler/insight_handler.rs crates/erp-ai/src/service/insight_service.rs crates/erp-ai/src/dto/copilot.rs crates/erp-ai/src/module.rs git commit -m "feat(ai): 告警处理工作流(已知悉 + 升级)" ``` ### Task 21: Phase 2 集成验证 - [ ] **Step 1: 全 workspace 编译检查** Run: `cargo check --workspace` Expected: 0 errors - [ ] **Step 2: 全 workspace 测试** Run: `cargo test --workspace` Expected: 所有测试通过(含告警洞察新测试) - [ ] **Step 3: 启动后端 + 前端,端到端验证** 1. 启动后端 `cd crates/erp-server && cargo run` 2. 启动前端 `cd apps/web && pnpm dev` 3. 以护士角色登录 4. 为测试患者录入一条体征数据:收缩压 180 5. 等待 5 秒(事件消费 + 规则评估) 6. 刷新医护仪表盘页面 7. 验证:CopilotAlert 组件显示告警 8. 点击"已知悉",确认告警消失 9. 录入一条危急化验数据:血钾 6.5 10. 验证:浏览器弹出通知 11. 点击"升级",确认生成随访建议洞察 - [ ] **Step 4: 前端生产构建** Run: `cd apps/web && pnpm build` Expected: 构建通过 - [ ] **Step 5: 提交(如有修复)** --- ## Chunk 4: Phase 3 — 随访推荐 + 咨询辅助 > **目标:** Copilot 在医护创建随访计划或进入咨询对话时提供智能建议,建议可一键采纳插入表单/回复框。 > **验收:** 随访创建时 Copilot 面板显示个性化建议 + 咨询对话时侧边栏显示患者背景和追问建议 + 建议可一键插入 > **依赖:** Chunk 2(风险评分数据可用) ### Task 22: 随访推荐逻辑 **Files:** - Create: `crates/erp-ai/src/service/followup_hint_service.rs` - Modify: `crates/erp-ai/src/service/mod.rs` - Create: `crates/erp-ai/src/handler/followup_hint_handler.rs` - Modify: `crates/erp-ai/src/handler/mod.rs` - Modify: `crates/erp-ai/src/module.rs` - Modify: `crates/erp-ai/src/dto/copilot.rs` - [ ] **Step 1: 编写随访推荐失败的测试** 在 `crates/erp-ai/src/service/followup_hint_service.rs` 底部: ```rust #[cfg(test)] mod tests { use super::*; #[test] fn test_generate_followup_frequency_low_risk() { let hint = generate_followup_hint("low", &serde_json::json!({"diagnosis": "CKD 3期"})); assert_eq!(hint.frequency, "每4周1次"); assert!(!hint.monitoring_indicators.is_empty()); } #[test] fn test_generate_followup_frequency_critical() { let hint = generate_followup_hint("critical", &serde_json::json!({"diagnosis": "CKD 5期"})); assert_eq!(hint.frequency, "每周1次"); } #[test] fn test_monitoring_indicators_ckd() { let hint = generate_followup_hint("medium", &serde_json::json!({"diagnosis": "CKD 4期"})); assert!(hint.monitoring_indicators.iter().any(|i| i.contains("肾功能"))); assert!(hint.monitoring_indicators.iter().any(|i| i.contains("电解质"))); } #[test] fn test_key_questions_include_risk_specific() { let hint = generate_followup_hint("high", &serde_json::json!({ "diagnosis": "CKD 4期", "matched_rules": [{"name": "血压持续偏高"}] })); assert!(hint.key_questions.iter().any(|q| q.contains("血压"))); } } ``` - [ ] **Step 2: 运行测试确认失败** Run: `cargo test -p erp-ai -- followup_hint_service::tests` Expected: 编译失败 - [ ] **Step 3: 实现随访推荐 service** `crates/erp-ai/src/service/followup_hint_service.rs`: ```rust use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct FollowUpHint { pub frequency: String, pub frequency_reason: String, pub monitoring_indicators: Vec, pub key_questions: Vec, pub source: String, // "rule" | "llm" | "hybrid" } /// 基于风险等级 + 疾病模板生成随访建议 pub fn generate_followup_hint( risk_level: &str, patient_context: &serde_json::Value, ) -> FollowUpHint { let diagnosis = patient_context["diagnosis"].as_str().unwrap_or("未知"); let matched_rules = patient_context["matched_rules"].as_array(); // Step 1: 风险等级 → 基础频率 let (frequency, freq_reason) = match risk_level { "low" => ("每4周1次".to_string(), "风险等级低,常规随访频率".to_string()), "medium" => ("每2周1次".to_string(), "风险等级中等,适当加密随访".to_string()), "high" => ("每周1次".to_string(), "风险等级高,密切随访".to_string()), "critical" => ("每周1次".to_string(), "风险等级危急,需密切监测并考虑调整治疗方案".to_string()), _ => ("每4周1次".to_string(), "默认频率".to_string()), }; // Step 2: 疾病模板 → 关注指标 let mut indicators = match diagnosis { d if d.contains("CKD") => vec![ "肾功能(肌酐、eGFR、BUN)".to_string(), "电解质(钾、钠、钙、磷)".to_string(), "甲状旁腺激素(PTH)".to_string(), "血常规(血红蛋白)".to_string(), ], _ => vec!["血压".to_string(), "体重".to_string(), "心率".to_string()], }; // Step 3: 风险因素叠加 → 额外指标 if let Some(rules) = matched_rules { for rule in rules { if let Some(name) = rule["name"].as_str() { if name.contains("血压") && !indicators.iter().any(|i| i.contains("血压")) { indicators.push("24小时动态血压监测".to_string()); } if name.contains("钾") && !indicators.iter().any(|i| i.contains("电解质")) { indicators.push("电解质(紧急复查)".to_string()); } if name.contains("透析") { indicators.push("透析充分性(Kt/V)".to_string()); } } } } // Step 4: 生成问诊要点 let key_questions = generate_key_questions(risk_level, diagnosis, matched_rules); FollowUpHint { frequency, frequency_reason: freq_reason, monitoring_indicators: indicators, key_questions, source: "rule".to_string(), } } fn generate_key_questions( risk_level: &str, diagnosis: &str, matched_rules: Option<&Vec>, ) -> Vec { let mut questions = vec![ "近期是否有恶心、食欲下降、尿量变化?".to_string(), "睡眠质量如何?是否有夜间呼吸困难?".to_string(), ]; if diagnosis.contains("CKD") { questions.push("是否有皮肤瘙痒、骨痛等症状?".to_string()); } if risk_level == "high" || risk_level == "critical" { questions.push("是否有胸闷、心悸等心血管症状?".to_string()); } if let Some(rules) = matched_rules { for rule in rules { if let Some(name) = rule["name"].as_str() { if name.contains("血压") { questions.push("近期是否有头晕、头痛、视物模糊?".to_string()); } if name.contains("体重") { questions.push("是否有下肢或面部浮肿?".to_string()); } } } } questions } ``` - [ ] **Step 4: 创建 handler** `crates/erp-ai/src/handler/followup_hint_handler.rs`: 参照 `risk_handler.rs` 模式,实现: - `get_followup_hint` — GET /copilot/patients/{id}/followup-hint,权限 copilot.risk.view - 获取患者最新风险快照(`risk_service::get_latest_risk`) - 调用 `followup_hint_service::generate_followup_hint` - 如 AI Provider 可用,异步调用 LLM 补充个性化问诊要点 - 返回 `ApiResponse::ok(hint)` - [ ] **Step 5: 注册路由 + 模块** 在 `service/mod.rs` 中添加: ```rust pub mod followup_hint_service; ``` 在 `handler/mod.rs` 中添加: ```rust pub mod followup_hint_handler; ``` 在 `module.rs` 的 `protected_routes()` 中添加: ```rust .route("/copilot/patients/{patient_id}/followup-hint", get(followup_hint_handler::get_followup_hint)) ``` - [ ] **Step 6: 运行测试** Run: `cargo test -p erp-ai -- followup_hint_service::tests` Expected: 4 tests PASS - [ ] **Step 7: 编译验证** Run: `cargo check -p erp-ai` Expected: 编译通过 - [ ] **Step 8: 提交** ```bash git add crates/erp-ai/src/service/followup_hint_service.rs crates/erp-ai/src/service/mod.rs crates/erp-ai/src/handler/followup_hint_handler.rs crates/erp-ai/src/handler/mod.rs crates/erp-ai/src/module.rs crates/erp-ai/src/dto/copilot.rs git commit -m "feat(ai): 随访推荐逻辑(风险等级 + 疾病模板)" ``` ### Task 23: 咨询辅助逻辑 **Files:** - Create: `crates/erp-ai/src/service/consult_hint_service.rs` - Create: `crates/erp-ai/src/handler/consult_hint_handler.rs` - Modify: `crates/erp-ai/src/service/mod.rs` - Modify: `crates/erp-ai/src/handler/mod.rs` - Modify: `crates/erp-ai/src/module.rs` - [ ] **Step 1: 编写咨询辅助失败的测试** 在 `crates/erp-ai/src/service/consult_hint_service.rs` 底部: ```rust #[cfg(test)] mod tests { use super::*; #[test] fn test_generate_patient_background_summary() { let context = serde_json::json!({ "patient": { "name": "张三", "age": 62, "diagnosis": "CKD 4期" }, "recent_data": { "last_bp": "155/95", "next_dialysis": "2026-05-13" }, "risk_summary": { "score": 7, "level": "high" }, }); let summary = generate_patient_background(&context); assert!(summary.contains("张三")); assert!(summary.contains("CKD 4期")); assert!(summary.contains("155/95")); } #[test] fn test_generate_suggested_questions_for_symptom() { let questions = generate_suggested_questions( &serde_json::json!({"risk_summary": {"level": "high"}}), Some("最近感觉头晕,有点恶心"), ); assert!(!questions.is_empty()); assert!(questions.iter().any(|q| q.contains("头晕"))); } #[test] fn test_generate_allergy_alerts() { let context = serde_json::json!({ "patient": { "allergies": ["青霉素", "碘造影剂"] }, }); let alerts = generate_allergy_alerts(&context); assert_eq!(alerts.len(), 2); assert!(alerts[0].contains("青霉素")); } #[test] fn test_generate_suggested_questions_no_message() { let questions = generate_suggested_questions( &serde_json::json!({"risk_summary": {"level": "medium"}}), None, ); // 无消息时应提供通用追问建议 assert!(!questions.is_empty()); } } ``` - [ ] **Step 2: 运行测试确认失败** Run: `cargo test -p erp-ai -- consult_hint_service::tests` Expected: 编译失败 - [ ] **Step 3: 实现咨询辅助 service** `crates/erp-ai/src/service/consult_hint_service.rs`: ```rust use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ConsultHint { pub patient_background: String, pub suggested_questions: Vec, pub allergy_alerts: Vec, pub precautions: Vec, pub source: String, } /// 生成患者背景摘要 pub fn generate_patient_background(context: &serde_json::Value) -> String { let patient = &context["patient"]; let recent = &context["recent_data"]; let risk = &context["risk_summary"]; let name = patient["name"].as_str().unwrap_or("未知"); let age = patient["age"].as_i64().unwrap_or(0); let diagnosis = patient["diagnosis"].as_str().unwrap_or("未知"); let last_bp = recent["last_bp"].as_str().unwrap_or("未记录"); let next_dialysis = recent["next_dialysis"].as_str().unwrap_or("未安排"); let risk_score = risk["score"].as_i64().unwrap_or(0); let risk_level = risk["level"].as_str().unwrap_or("未知"); format!( "{},{}岁,诊断:{}\n最近血压:{} | 下次透析:{}\n风险评分:{}/10({})", name, age, diagnosis, last_bp, next_dialysis, risk_score, risk_level ) } /// 基于患者消息内容 + 风险等级生成追问建议 pub fn generate_suggested_questions( context: &serde_json::Value, patient_message: Option<&str>, ) -> Vec { let risk_level = context["risk_summary"]["level"].as_str().unwrap_or("low"); let mut questions = Vec::new(); // 基于消息内容的关键词匹配 if let Some(msg) = patient_message { if msg.contains("头晕") || msg.contains("头痛") { questions.push("头晕是持续性还是间歇性?".to_string()); questions.push("头晕时是否有视物模糊或耳鸣?".to_string()); } if msg.contains("恶心") || msg.contains("呕吐") { questions.push("恶心是否与进食有关?".to_string()); questions.push("最近尿量是否有变化?".to_string()); } if msg.contains("浮肿") || msg.contains("水肿") { questions.push("浮肿是双侧还是单侧?".to_string()); questions.push("早晨和晚上浮肿程度是否有差异?".to_string()); } if msg.contains("胸") || msg.contains("心悸") { questions.push("胸闷发生在什么情况下(活动/休息)?".to_string()); questions.push("是否有放射到左臂或下颌的疼痛?".to_string()); } } // 风险等级补充通用追问 if risk_level == "high" || risk_level == "critical" { if questions.is_empty() { questions.push("近期是否有任何不适?".to_string()); } questions.push("是否按时服用了所有药物?".to_string()); } // 默认追问(无消息时) if questions.is_empty() { questions = vec![ "近期身体状况如何?".to_string(), "是否有新的不适或症状变化?".to_string(), "透析后恢复情况怎么样?".to_string(), ]; } questions } /// 提取过敏警示 pub fn generate_allergy_alerts(context: &serde_json::Value) -> Vec { context["patient"]["allergies"] .as_array() .map(|arr| { arr.iter() .filter_map(|v| v.as_str().map(|s| format!("⚠ 过敏:{}", s))) .collect() }) .unwrap_or_default() } /// 生成完整的咨询辅助建议 pub fn generate_consult_hint( context: &serde_json::Value, patient_message: Option<&str>, ) -> ConsultHint { let patient = &context["patient"]; let medications = patient["medications"] .as_array() .map(|arr| { arr.iter() .filter_map(|v| v.as_str()) .collect::>() }) .unwrap_or_default(); let precautions = if !medications.is_empty() { vec![format!("当前用药:{},注意药物相互作用", medications.join("、"))] } else { vec![] }; ConsultHint { patient_background: generate_patient_background(context), suggested_questions: generate_suggested_questions(context, patient_message), allergy_alerts: generate_allergy_alerts(context), precautions, source: "rule".to_string(), } } ``` - [ ] **Step 4: 创建 handler** `crates/erp-ai/src/handler/consult_hint_handler.rs`: ```rust // 参照 risk_handler.rs 模式 // GET /copilot/patients/{id}/consult-hint // 权限: copilot.risk.view // 逻辑:获取患者风险快照 + 组装上下文 → 调用 consult_hint_service → 返回建议 ``` - [ ] **Step 5: 注册路由 + 模块** 在 `service/mod.rs` 中添加 `pub mod consult_hint_service;` 在 `handler/mod.rs` 中添加 `pub mod consult_hint_handler;` 在 `module.rs` 的 `protected_routes()` 中添加: ```rust .route("/copilot/patients/{patient_id}/consult-hint", get(consult_hint_handler::get_consult_hint)) ``` - [ ] **Step 6: 运行测试** Run: `cargo test -p erp-ai -- consult_hint_service::tests` Expected: 4 tests PASS - [ ] **Step 7: 编译验证** Run: `cargo check -p erp-ai` Expected: 编译通过 - [ ] **Step 8: 提交** ```bash git add crates/erp-ai/src/service/consult_hint_service.rs crates/erp-ai/src/service/mod.rs crates/erp-ai/src/handler/consult_hint_handler.rs crates/erp-ai/src/handler/mod.rs crates/erp-ai/src/module.rs git commit -m "feat(ai): 咨询辅助逻辑(患者背景 + 追问建议 + 过敏警示)" ``` ### Task 24: 前端 CopilotPanel 组件 **Files:** - Create: `apps/web/src/components/Copilot/CopilotPanel.tsx` - Modify: `apps/web/src/api/copilot.ts`(添加 followup/consult hint 类型) - [ ] **Step 1: 扩展 Copilot API 层** 在 `apps/web/src/api/copilot.ts` 中添加: ```typescript export interface FollowUpHint { frequency: string; frequency_reason: string; monitoring_indicators: string[]; key_questions: string[]; source: string; } export interface ConsultHint { patient_background: string; suggested_questions: string[]; allergy_alerts: string[]; precautions: string[]; source: string; } ``` - [ ] **Step 2: 创建 CopilotPanel 组件** `apps/web/src/components/Copilot/CopilotPanel.tsx`: ```tsx import { Card, Typography, List, Tag, Button, Space, Divider, Alert, Spin } from 'antd'; import { RobotOutlined, ThunderboltOutlined } from '@ant-design/icons'; import { useState } from 'react'; import type { FollowUpHint, ConsultHint } from '@/api/copilot'; const { Title, Text, Paragraph } = Typography; type PanelMode = 'followup' | 'consult'; interface FollowUpProps { mode: 'followup'; hint: FollowUpHint | undefined; loading?: boolean; onAdopt: (field: string, value: string) => void; } interface ConsultProps { mode: 'consult'; hint: ConsultHint | undefined; loading?: boolean; onInsertQuestion: (question: string) => void; } type Props = FollowUpProps | ConsultProps; export default function CopilotPanel(props: Props) { const { mode, loading } = props; if (loading) { return ( Copilot 建议} style={{ width: 360 }}> ); } if (mode === 'followup') { const { hint, onAdopt } = props; if (!hint) return null; return ( Copilot 随访建议} style={{ width: 360 }} size="small" >
推荐频率: {hint.frequency}
{hint.frequency_reason}
关注指标: ( • {item} )} />
建议问诊要点: ( • {item} )} />
); } // mode === 'consult' const { hint, onInsertQuestion } = props as ConsultProps; if (!hint) return null; return ( Copilot 咨询辅助} style={{ width: 360 }} size="small" > {hint.allergy_alerts.map((alert, i) => ( ))}
患者背景: {hint.patient_background}
建议追问: {hint.suggested_questions.map((q, i) => (
• {q}
))}
{hint.precautions.length > 0 && ( <>
注意事项: {hint.precautions.map((p, i) => ( • {p} ))}
)}
); } ``` - [ ] **Step 3: 编译验证** Run: `cd apps/web && pnpm build` Expected: 编译通过 - [ ] **Step 4: 提交** ```bash git add apps/web/src/components/Copilot/CopilotPanel.tsx apps/web/src/api/copilot.ts git commit -m "feat(web): CopilotPanel 侧边栏组件(随访推荐 + 咨询辅助)" ``` ### Task 25: 一键采纳/插入 **Files:** - Modify: `apps/web/src/pages/health/FollowUpTaskList.tsx`(或对应的随访创建页面) - Modify: `apps/web/src/pages/health/ConsultationDetail.tsx`(咨询详情页嵌入 CopilotPanel) - Create: `apps/web/src/components/Copilot/hooks/useFollowupHint.ts` - Create: `apps/web/src/components/Copilot/hooks/useConsultHint.ts` - [ ] **Step 1: 创建 hooks** `apps/web/src/components/Copilot/hooks/useFollowupHint.ts`: ```typescript import { useQuery } from '@tanstack/react-query'; import { getFollowupHint } from '@/api/copilot'; export function useFollowupHint(patientId: string | undefined) { return useQuery({ queryKey: ['copilot', 'followup-hint', patientId], queryFn: () => getFollowupHint(patientId!), enabled: !!patientId, staleTime: 5 * 60 * 1000, }); } ``` `apps/web/src/components/Copilot/hooks/useConsultHint.ts`: ```typescript import { useQuery } from '@tanstack/react-query'; import { getConsultHint } from '@/api/copilot'; export function useConsultHint(patientId: string | undefined) { return useQuery({ queryKey: ['copilot', 'consult-hint', patientId], queryFn: () => getConsultHint(patientId!), enabled: !!patientId, staleTime: 5 * 60 * 1000, }); } ``` - [ ] **Step 2: 嵌入随访页面** 在随访创建/编辑页面(如 `FollowUpTaskList.tsx` 中的创建弹窗)添加: ```tsx import CopilotPanel from '@/components/Copilot/CopilotPanel'; import { useFollowupHint } from '@/components/Copilot/hooks/useFollowupHint'; // 在随访表单右侧 const { data: hintData, isLoading: hintLoading } = useFollowupHint(patientId); // 布局:左侧表单 + 右侧 CopilotPanel {/* 现有随访表单 */} { // 将 Copilot 建议填入表单字段 form.setFieldValue(field, value); message.success(`已采纳 Copilot 建议:${field}`); }} /> ``` - [ ] **Step 3: 嵌入咨询详情页** 在 `ConsultationDetail.tsx` 中添加: ```tsx import CopilotPanel from '@/components/Copilot/CopilotPanel'; import { useConsultHint } from '@/components/Copilot/hooks/useConsultHint'; // 在对话区域右侧 const { data: consultHintData, isLoading: consultHintLoading } = useConsultHint(patientId); // 布局:左侧对话区域 + 右侧 CopilotPanel {/* 现有咨询对话区域 */} { // 将追问建议插入到回复输入框 setReplyContent(prev => prev ? `${prev}\n${question}` : question); message.success('已插入追问建议'); }} /> ``` - [ ] **Step 4: 编译验证** Run: `cd apps/web && pnpm build` Expected: 编译通过 - [ ] **Step 5: 提交** ```bash git add apps/web/src/components/Copilot/hooks/ apps/web/src/pages/health/FollowUpTaskList.tsx apps/web/src/pages/health/ConsultationDetail.tsx git commit -m "feat(web): 随访/咨询页面嵌入 CopilotPanel + 一键采纳/插入" ``` ### Task 26: Phase 3 集成验证 - [ ] **Step 1: 全 workspace 编译检查** Run: `cargo check --workspace` Expected: 0 errors - [ ] **Step 2: 全 workspace 测试** Run: `cargo test --workspace` Expected: 所有测试通过(含随访推荐 + 咨询辅助新测试) - [ ] **Step 3: 前端生产构建** Run: `cd apps/web && pnpm build` Expected: 构建通过 - [ ] **Step 4: 启动后端 + 前端,端到端验证** 随访推荐验证: 1. 启动后端 `cd crates/erp-server && cargo run` 2. 启动前端 `cd apps/web && pnpm dev` 3. 以护士角色登录 4. 打开某个高风险患者的随访创建页面 5. 验证:右侧 CopilotPanel 显示随访建议 6. 点击"采纳"频率建议,确认表单自动填入 7. 点击"全部采纳"指标,确认关注指标列表填入 咨询辅助验证: 1. 打开一个有对话记录的咨询详情页 2. 验证:右侧 CopilotPanel 显示患者背景、过敏警示、追问建议 3. 点击某个追问建议的"插入"按钮 4. 验证:回复输入框中追加了该问题文本 5. 如有过敏记录,确认过敏警示显示为黄色警告条 - [ ] **Step 5: API 烟雾测试** Run: `curl http://localhost:3000/api/v1/copilot/patients//followup-hint -H "Authorization: Bearer "` Expected: 返回随访推荐建议 JSON Run: `curl http://localhost:3000/api/v1/copilot/patients//consult-hint -H "Authorization: Bearer "` Expected: 返回咨询辅助建议 JSON - [ ] **Step 6: 提交(如有修复)** --- ## Chunk 5: Phase 4 — 患者端 Copilot(AI 客服/管家) > **目标:** 患者小程序内可与小H对话,获得合规审查后的回复;诊断性/处方性提问被自动修正为引导到院。 > **验收:** 患者发送消息 → 意图识别 → 合规审查 → 获得安全回复;对话记录完整可审计。 > **依赖:** Phase 0-3 完成(风险画像数据、erp-ai Provider 可用)。 ### Task 27: 意图识别引擎 **Files:** - Modify: `crates/erp-ai/Cargo.toml`(添加 `aho-corasick` 依赖) - Create: `crates/erp-ai/src/copilot/intent.rs` - [ ] **Step 0: 添加 aho-corasick 依赖** 在 `crates/erp-ai/Cargo.toml` 的 `[dependencies]` 中添加: ```toml aho-corasick = "1" ``` - [ ] **Step 1: 写意图分类数据结构和分类函数签名** ```rust // crates/erp-ai/src/copilot/intent.rs use serde::{Deserialize, Serialize}; /// 患者消息意图类型(按优先级排序) #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum IntentType { Emergency, // 紧急情况:胸痛、喘不上气、出血不止 HealthQuery, // 健康咨询:指标含义、症状原因 ServiceQuery, // 服务咨询:预约、流程、收费 EmotionalCare, // 情感关怀:不想透析、好累、谢谢 CasualChat, // 闲聊:天气、你好 } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct IntentResult { pub intent: IntentType, pub confidence: f32, pub should_skip_compliance: bool, // 服务咨询可跳过语义审查 } /// 意图识别 trait(支持规则优先 + LLM 降级) #[async_trait::async_trait] pub trait IntentClassifier: Send + Sync { async fn classify(&self, message: &str, context: &ChatContext) -> IntentResult; } /// 对话上下文(精简版,用于意图分类) #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ChatContext { pub recent_intents: Vec, // 最近 3 轮意图,连续同类可跳过 pub patient_name: String, } ``` - [ ] **Step 2: 写意图识别单元测试** ```rust #[cfg(test)] mod tests { use super::*; fn test_context() -> ChatContext { ChatContext { recent_intents: vec![], patient_name: "张三".into(), } } #[test] fn test_emergency_keywords_detected() { // 紧急关键词:胸痛、喘不上气、出血不止、呼吸困难 let cases = vec![ ("我胸痛", IntentType::Emergency), ("喘不上气了", IntentType::Emergency), ("出血不止怎么办", IntentType::Emergency), ]; for (msg, expected) in cases { let result = classify_by_keywords(msg); assert_eq!(result, Some(expected), "failed for: {}", msg); } } #[test] fn test_service_query_detected() { let cases = vec![ "怎么预约", "透析时间是几点", "收费多少", "在哪里抽血", ]; for msg in cases { let result = classify_by_keywords(msg); assert_eq!(result, Some(IntentType::ServiceQuery), "failed for: {}", msg); } } #[test] fn test_health_query_detected() { let cases = vec![ "这个指标什么意思", "血压高了怎么办", "肌酐高不高", ]; for msg in cases { let result = classify_by_keywords(msg); assert_eq!(result, Some(IntentType::HealthQuery), "failed for: {}", msg); } } #[test] fn test_casual_chat_falls_through() { let cases = vec!["你好", "今天天气怎么样"]; for msg in cases { let result = classify_by_keywords(msg); assert!(result.is_none(), "should not match keywords: {}", msg); } } } ``` - [ ] **Step 3: 运行测试确认失败** Run: `cargo test -p erp-ai --lib copilot::intent` Expected: 编译失败(`classify_by_keywords` 未定义) - [ ] **Step 4: 实现基于关键词的意图分类器(规则层)** ```rust use aho_corasick::AhoCorasick; /// 紧急关键词列表 const EMERGENCY_KEYWORDS: &[&str] = &[ "胸痛", "喘不上气", "出血不止", "呼吸困难", "窒息", "意识不清", "晕倒", "心脏骤停", "严重过敏", ]; /// 服务咨询关键词列表 const SERVICE_KEYWORDS: &[&str] = &[ "预约", "挂号", "透析时间", "收费", "费用", "在哪里", "怎么走", "几点", "流程", "排队", ]; /// 健康咨询关键词列表 const HEALTH_KEYWORDS: &[&str] = &[ "指标", "什么意思", "高不高", "正常吗", "血压", "肌酐", "血红蛋白", "钾", "钙", "磷", ]; /// 情感关怀关键词列表 const EMOTIONAL_KEYWORDS: &[&str] = &[ "不想透析", "好累", "撑不下去", "烦躁", "焦虑", "害怕", "抑郁", "谢谢小H", "谢谢你", ]; /// 基于关键词的快速分类(零 LLM 调用,< 1ms) pub fn classify_by_keywords(message: &str) -> Option { // 按优先级顺序检查:紧急 > 健康 > 服务 > 情感 if matches_keywords(message, EMERGENCY_KEYWORDS) { return Some(IntentType::Emergency); } if matches_keywords(message, HEALTH_KEYWORDS) { return Some(IntentType::HealthQuery); } if matches_keywords(message, SERVICE_KEYWORDS) { return Some(IntentType::ServiceQuery); } if matches_keywords(message, EMOTIONAL_KEYWORDS) { return Some(IntentType::EmotionalCare); } None } fn matches_keywords(text: &str, keywords: &[&str]) -> bool { let ac = AhoCorasick::builder() .ascii_case_insensitive(false) .build(keywords) .expect("invalid keywords"); ac.is_match(text) } ``` - [ ] **Step 5: 实现 LLM 意图分类器(降级方案)** 关键词未匹配时调用 LLM 做快速分类: ```rust use crate::provider::{AiProvider, dto::{GenerateRequest, GenerateResponse}}; pub struct LlmIntentClassifier<'a> { pub provider: &'a dyn AiProvider, } #[async_trait::async_trait] impl<'a> IntentClassifier for LlmIntentClassifier<'a> { async fn classify(&self, message: &str, context: &ChatContext) -> IntentResult { // 1. 先尝试关键词匹配 if let Some(intent) = classify_by_keywords(message) { let confidence = match intent { IntentType::Emergency => 0.95, _ => 0.85, }; return IntentResult { intent, confidence, should_skip_compliance: intent == IntentType::ServiceQuery, }; } // 2. 连续同类消息复用上一轮意图 if let Some(last) = context.recent_intents.last() { return IntentResult { intent: last.clone(), confidence: 0.6, should_skip_compliance: *last == IntentType::ServiceQuery, }; } // 3. LLM 快速分类(低 token) let req = GenerateRequest { system_prompt: Some("将患者消息分为一类,只输出字母。A=紧急 B=健康 C=服务 D=情感 E=闲聊".into()), user_prompt: message.into(), model: None, temperature: Some(0.1), max_tokens: Some(10), }; match self.provider.generate(req).await { Ok(resp) => { let intent = parse_intent_letter(&resp.content); IntentResult { intent, confidence: 0.7, should_skip_compliance: intent == IntentType::ServiceQuery, } } Err(_) => IntentResult { // LLM 不可用时降级为健康咨询(最保守策略) intent: IntentType::HealthQuery, confidence: 0.3, should_skip_compliance: false, }, } } } fn parse_intent_letter(response: &str) -> IntentType { let trimmed = response.trim().to_uppercase(); match trimmed.chars().next() { Some('A') => IntentType::Emergency, Some('B') => IntentType::HealthQuery, Some('C') => IntentType::ServiceQuery, Some('D') => IntentType::EmotionalCare, Some('E') => IntentType::CasualChat, _ => IntentType::HealthQuery, // 默认保守 } } ``` - [ ] **Step 6: 运行测试确认通过** Run: `cargo test -p erp-ai --lib copilot::intent` Expected: PASS - [ ] **Step 7: 提交** ```bash git add crates/erp-ai/src/copilot/intent.rs crates/erp-ai/src/copilot/mod.rs git commit -m "feat(ai): Copilot 意图识别引擎 — 关键词+LLM 双层分类" ``` > **注意:** 在 `copilot/mod.rs` 中添加 `pub mod intent;` ### Task 28: 合规审查引擎 **Files:** - Create: `crates/erp-ai/src/copilot/compliance.rs` - [ ] **Step 1: 写合规审查数据结构** ```rust // crates/erp-ai/src/copilot/compliance.rs use serde::{Deserialize, Serialize}; use std::time::Instant; /// 合规审查结果 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ComplianceResult { pub is_compliant: bool, pub layer1_result: Layer1Result, pub layer2_result: Option, pub violations: Vec, pub fix_strategy: Option, pub final_response: String, pub total_latency_ms: u64, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Layer1Result { pub passed: bool, pub matched_keywords: Vec, pub latency_ms: u64, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct KeywordMatch { pub rule_id: String, pub category: String, // diagnosis/prescription/efficacy/assessment/commitment/misleading pub severity: Severity, pub matched_text: String, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Layer2Result { pub passed: bool, pub violation_type: Option, pub latency_ms: u64, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum Severity { Critical, High, Medium } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum FixStrategy { TemplateReplace, // 关键词违规 → 模板替换 LlmRewrite, // 语义违规 → LLM 重写 Fallback, // 兜底降级 } ``` - [ ] **Step 2: 写合规审查测试** ```rust #[cfg(test)] mod tests { use super::*; #[test] fn test_layer1_detects_diagnosis() { let engine = ComplianceEngine::new(); let cases = vec![ ("确诊为高血压", true), ("诊断为糖尿病", true), ("你得了肾病", true), ("血压偏高需要注意饮食", false), ]; for (text, should_fail) in cases { let result = engine.check_keywords(text); assert_eq!(result.passed, !should_fail, "failed for: {}", text); } } #[test] fn test_layer1_detects_prescription() { let engine = ComplianceEngine::new(); let cases = vec![ ("建议你吃硝苯地平", true), ("开点降压药", true), ("调整药量到2片", true), ("记得按时服药", false), ]; for (text, should_fail) in cases { let result = engine.check_keywords(text); assert_eq!(result.passed, !should_fail, "failed for: {}", text); } } #[test] fn test_layer1_detects_efficacy_commitment() { let engine = ComplianceEngine::new(); let cases = vec![ ("吃了会好", true), ("可以治愈", true), ("完全不用担心", true), ("坚持治疗会有帮助", false), ]; for (text, should_fail) in cases { let result = engine.check_keywords(text); assert_eq!(result.passed, !should_fail, "failed for: {}", text); } } #[test] fn test_fix_strategy_template_replace() { let engine = ComplianceEngine::new(); let violations = vec![KeywordMatch { rule_id: "no_diagnosis".into(), category: "diagnosis".into(), severity: Severity::Critical, matched_text: "确诊为".into(), }]; let strategy = engine.determine_fix_strategy(&violations); assert_eq!(strategy, FixStrategy::TemplateReplace); } #[test] fn test_template_replacement() { let engine = ComplianceEngine::new(); let input = "你得了高血压,建议你吃降压药"; let result = engine.apply_template_replace(input, "diagnosis"); assert!(!result.contains("确诊")); assert!(!result.contains("建议你吃")); } } ``` - [ ] **Step 3: 运行测试确认失败** Run: `cargo test -p erp-ai --lib copilot::compliance` Expected: 编译失败 - [ ] **Step 4: 实现合规审查引擎** ```rust use aho_corasick::AhoCorasick; use std::collections::HashMap; /// 合规规则定义(MVP:代码内嵌静态词表) struct ComplianceRule { rule_id: &'static str, category: &'static str, severity: Severity, keywords: &'static [&'static str], replacement_template: &'static str, } /// 内置合规规则 const BUILT_IN_RULES: &[ComplianceRule] = &[ ComplianceRule { rule_id: "no_diagnosis", category: "diagnosis", severity: Severity::Critical, keywords: &["确诊为", "诊断为", "你得了", "诊断结果是", "可以确诊"], replacement_template: "这个情况建议让医生当面评估一下", }, ComplianceRule { rule_id: "no_prescription", category: "prescription", severity: Severity::Critical, keywords: &["建议你吃", "开点", "处方", "调整药量", "服用XX"], replacement_template: "用药调整需要医生评估,建议到院咨询", }, ComplianceRule { rule_id: "no_efficacy", category: "efficacy", severity: Severity::High, keywords: &["吃了会好", "可以治愈", "保证能好", "肯定能好"], replacement_template: "治疗效果因人而异,建议与医生沟通具体方案", }, ComplianceRule { rule_id: "no_assessment", category: "assessment", severity: Severity::High, keywords: &["我判断", "我认定", "我的诊断"], replacement_template: "这个需要医生来评估,建议您下次来院时跟医生聊聊", }, ComplianceRule { rule_id: "no_commitment", category: "commitment", severity: Severity::Medium, keywords: &["肯定没问题", "绝对不会出问题"], replacement_template: "每个人的情况不同,建议跟医生详细沟通", }, ComplianceRule { rule_id: "no_misleading", category: "misleading", severity: Severity::Medium, keywords: &["完全不用担心", "绝对没事", "小事一桩"], replacement_template: "您的关注很合理,建议下次来院时跟医生确认一下", }, ]; /// 兜底安全回复 const FALLBACK_RESPONSE: &str = "感谢您的提问,这个问题建议您下次来的时候直接跟医生聊聊。要不要我帮您预约?"; pub struct ComplianceEngine { /// 每条规则预编译的 Aho-Corasick 自动机 matchers: Vec<(&'static ComplianceRule, AhoCorasick)>, } impl ComplianceEngine { pub fn new() -> Self { let matchers = BUILT_IN_RULES .iter() .map(|rule| { let ac = AhoCorasick::builder() .ascii_case_insensitive(false) .build(rule.keywords) .expect("invalid compliance keywords"); (rule, ac) }) .collect(); Self { matchers } } /// Layer 1: 关键词过滤(< 5ms) pub fn check_keywords(&self, text: &str) -> Layer1Result { let start = Instant::now(); let mut matched = Vec::new(); for (rule, ac) in &self.matchers { for mat in ac.find_iter(text) { matched.push(KeywordMatch { rule_id: rule.rule_id.to_string(), category: rule.category.to_string(), severity: rule.severity, matched_text: text[mat.start()..mat.end()].to_string(), }); } } Layer1Result { passed: matched.is_empty(), matched_keywords: matched, latency_ms: start.elapsed().as_millis() as u64, } } /// 确定修正策略 pub fn determine_fix_strategy(&self, violations: &[KeywordMatch]) -> FixStrategy { if violations.is_empty() { return FixStrategy::Fallback; } let max_severity = violations.iter().map(|v| v.severity).max_by(|a, b| { let order = |s: Severity| match s { Severity::Critical => 2, Severity::High => 1, Severity::Medium => 0, }; order(*a).cmp(&order(*b)) }); match max_severity { Some(Severity::Critical) | Some(Severity::High) => FixStrategy::TemplateReplace, Some(Severity::Medium) => FixStrategy::LlmRewrite, None => FixStrategy::Fallback, } } /// 模板替换修正 pub fn apply_template_replace(&self, _original: &str, category: &str) -> String { // 找到对应规则的安全模板 for rule in BUILT_IN_RULES { if rule.category == category { return rule.replacement_template.to_string(); } } FALLBACK_RESPONSE.to_string() } /// 兜底回复 pub fn fallback_response() -> &'static str { FALLBACK_RESPONSE } } ``` - [ ] **Step 5: 实现 LLM 语义审查(Layer 2)** 在 `compliance.rs` 中添加: ```rust use crate::provider::AiProvider; impl ComplianceEngine { /// Layer 2: 语义审查(< 200ms) pub async fn check_semantic( &self, text: &str, provider: &dyn AiProvider, ) -> Layer2Result { let start = Instant::now(); let req = crate::provider::dto::GenerateRequest { system_prompt: Some("以下AI回复是否存在医疗合规问题?A=无问题 B=含诊断 C=含处方 D=含疗效承诺 E=其他违规。只输出字母。".into()), user_prompt: format!("回复内容:{}", text), model: None, temperature: Some(0.1), max_tokens: Some(10), }; match provider.generate(req).await { Ok(resp) => { let letter = resp.content.trim().to_uppercase(); let passed = letter.starts_with('A'); let violation_type = if passed { None } else { Some(letter.chars().next().map(|c| c.to_string()).unwrap_or_default()) }; Layer2Result { passed, violation_type, latency_ms: start.elapsed().as_millis() as u64, } } Err(_) => Layer2Result { // LLM 不可用时,保守放过(已过 Layer 1) passed: true, violation_type: None, latency_ms: start.elapsed().as_millis() as u64, }, } } /// LLM 重写修正 pub async fn llm_rewrite( &self, original: &str, provider: &dyn AiProvider, ) -> String { let req = crate::provider::dto::GenerateRequest { system_prompt: Some("将AI回复改写为合规版本,移除诊断/处方语言,改为引导到院,保持关怀语气。只输出改写后的文本。".into()), user_prompt: format!("原文:{}", original), model: None, temperature: Some(0.3), max_tokens: Some(200), }; match provider.generate(req).await { Ok(resp) => { // 重写后再过 Layer 1 let check = self.check_keywords(&resp.content); if check.passed { resp.content } else { FALLBACK_RESPONSE.to_string() } } Err(_) => FALLBACK_RESPONSE.to_string(), } } } ``` - [ ] **Step 6: 运行测试确认通过** Run: `cargo test -p erp-ai --lib copilot::compliance` Expected: PASS - [ ] **Step 7: 提交** ```bash git add crates/erp-ai/src/copilot/compliance.rs crates/erp-ai/src/copilot/mod.rs git commit -m "feat(ai): Copilot 合规审查引擎 — 双层审查+三级修正" ``` > **注意:** 在 `copilot/mod.rs` 中添加 `pub mod compliance;` ### Task 29: 对话上下文组装 **Files:** - Create: `crates/erp-ai/src/copilot/context.rs` - [ ] **Step 1: 写上下文结构定义** ```rust // crates/erp-ai/src/copilot/context.rs use serde::{Deserialize, Serialize}; /// 完整的患者对话上下文(后端自动组装,前端不可篡改) #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PatientChatContext { pub patient: PatientSummary, pub recent_data: RecentHealthData, pub risk_summary: RiskSummary, pub conversation_summary: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PatientSummary { pub name: String, pub age: u32, pub diagnosis: String, // 通俗描述,如"慢性肾病" pub dialysis_schedule: String, // "每周二、四、六 下午" pub allergies: Vec, // 过敏史(安全提示用) pub medications: Vec, // 药物列表(仅名称,不含剂量) } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RecentHealthData { pub last_bp: Option, // "135/85" pub last_weight: Option, // "68.5kg" pub last_dialysis: Option, // "2026-05-09" pub next_dialysis: Option, // "2026-05-13" pub next_checkup: Option, // "2026-05-15" } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RiskSummary { pub score: i32, // 0-10 pub level: String, // 低/中/中高/高 pub top_risks: Vec, // 最多 3 个 } /// 上下文组装器 pub struct ContextAssembler; impl ContextAssembler { /// 将上下文格式化为 LLM prompt 前缀 pub fn to_prompt_prefix(ctx: &PatientChatContext) -> String { let mut parts = vec![ format!("你是小H,一位温暖的肾脏健康管家。患者{},{}岁,诊断:{}。", ctx.patient.name, ctx.patient.age, ctx.patient.diagnosis), format!("透析安排:{}。", ctx.patient.dialysis_schedule), ]; if !ctx.patient.allergies.is_empty() { parts.push(format!("⚠️过敏史:{}。", ctx.patient.allergies.join("、"))); } if let Some(bp) = &ctx.recent_data.last_bp { parts.push(format!("最近血压:{}。", bp)); } if let Some(w) = &ctx.recent_data.last_weight { parts.push(format!("最近体重:{}。", w)); } if !ctx.risk_summary.top_risks.is_empty() { parts.push(format!("当前关注点:{}。", ctx.risk_summary.top_risks.join("、"))); } if let Some(summary) = &ctx.conversation_summary { parts.push(format!("近期对话摘要:{}", summary)); } parts.join("\n") } } ``` - [ ] **Step 2: 写上下文组装测试** ```rust #[cfg(test)] mod tests { use super::*; fn sample_context() -> PatientChatContext { PatientChatContext { patient: PatientSummary { name: "张三".into(), age: 62, diagnosis: "慢性肾病".into(), dialysis_schedule: "每周二、四、六 下午".into(), allergies: vec!["青霉素".into()], medications: vec!["硝苯地平".into(), "碳酸氢钠".into()], }, recent_data: RecentHealthData { last_bp: Some("135/85".into()), last_weight: Some("68.5kg".into()), last_dialysis: Some("2026-05-09".into()), next_dialysis: Some("2026-05-13".into()), next_checkup: None, }, risk_summary: RiskSummary { score: 7, level: "中高".into(), top_risks: vec!["eGFR快速下降".into(), "血压趋势上升".into()], }, conversation_summary: None, } } #[test] fn test_prompt_contains_patient_info() { let ctx = sample_context(); let prefix = ContextAssembler::to_prompt_prefix(&ctx); assert!(prefix.contains("张三")); assert!(prefix.contains("62")); assert!(prefix.contains("慢性肾病")); assert!(prefix.contains("每周二、四、六")); } #[test] fn test_prompt_contains_allergy_warning() { let ctx = sample_context(); let prefix = ContextAssembler::to_prompt_prefix(&ctx); assert!(prefix.contains("⚠️")); assert!(prefix.contains("青霉素")); } #[test] fn test_prompt_contains_health_data() { let ctx = sample_context(); let prefix = ContextAssembler::to_prompt_prefix(&ctx); assert!(prefix.contains("135/85")); assert!(prefix.contains("68.5kg")); } #[test] fn test_prompt_omits_empty_fields() { let mut ctx = sample_context(); ctx.recent_data.last_bp = None; ctx.patient.allergies = vec![]; let prefix = ContextAssembler::to_prompt_prefix(&ctx); assert!(!prefix.contains("最近血压")); assert!(!prefix.contains("过敏史")); } } ``` - [ ] **Step 3: 运行测试确认通过** Run: `cargo test -p erp-ai --lib copilot::context` Expected: PASS - [ ] **Step 4: 提交** ```bash git add crates/erp-ai/src/copilot/context.rs crates/erp-ai/src/copilot/mod.rs git commit -m "feat(ai): Copilot 对话上下文组装器" ``` > **注意:** 在 `copilot/mod.rs` 中添加 `pub mod context;` ### Task 30: 对话服务 + 合规服务 **Files:** - Create: `crates/erp-ai/src/service/chat_service.rs` - Create: `crates/erp-ai/src/service/compliance_service.rs` - [ ] **Step 1: 写合规服务** ```rust // crates/erp-ai/src/service/compliance_service.rs use crate::copilot::compliance::*; use crate::provider::AiProvider; pub struct ComplianceService; impl ComplianceService { /// 对 AI 回复执行完整双层审查 + 修正 pub async fn review_and_fix( engine: &ComplianceEngine, ai_response: &str, provider: Option<&dyn AiProvider>, skip_layer2: bool, ) -> ComplianceResult { let start = std::time::Instant::now(); // Layer 1: 关键词过滤 let layer1 = engine.check_keywords(ai_response); if !layer1.passed { let strategy = engine.determine_fix_strategy(&layer1.matched_keywords); let final_response = match strategy { FixStrategy::TemplateReplace => { let category = &layer1.matched_keywords[0].category; engine.apply_template_replace(ai_response, category) } FixStrategy::LlmRewrite => { if let Some(p) = provider { engine.llm_rewrite(ai_response, p).await } else { ComplianceEngine::fallback_response().to_string() } } FixStrategy::Fallback => ComplianceEngine::fallback_response().to_string(), }; return ComplianceResult { is_compliant: false, layer1_result: layer1, layer2_result: None, violations: layer1.matched_keywords, fix_strategy: Some(strategy), final_response, total_latency_ms: start.elapsed().as_millis() as u64, }; } // Layer 1 通过 → Layer 2 语义审查 let layer2 = if skip_layer2 { Layer2Result { passed: true, violation_type: None, latency_ms: 0 } } else if let Some(p) = provider { engine.check_semantic(ai_response, p).await } else { Layer2Result { passed: true, violation_type: None, latency_ms: 0 } }; let final_response = if layer2.passed { ai_response.to_string() } else { // 语义违规 → LLM 重写 if let Some(p) = provider { engine.llm_rewrite(ai_response, p).await } else { ComplianceEngine::fallback_response().to_string() } }; ComplianceResult { is_compliant: layer2.passed, layer1_result: layer1, layer2_result: Some(layer2), violations: vec![], fix_strategy: if !layer2.passed { Some(FixStrategy::LlmRewrite) } else { None }, final_response, total_latency_ms: start.elapsed().as_millis() as u64, } } } ``` - [ ] **Step 2: 写对话服务** ```rust // crates/erp-ai/src/service/chat_service.rs use crate::copilot::compliance::ComplianceEngine; use crate::copilot::context::{ContextAssembler, PatientChatContext}; use crate::copilot::intent::{IntentClassifier, IntentResult, IntentType}; use crate::entity::copilot_chat_logs; use crate::provider::AiProvider; use crate::provider::dto::GenerateRequest; use crate::service::compliance_service::ComplianceService; use sea_orm::{DatabaseConnection, EntityTrait, Set, ActiveModelTrait}; use serde::{Deserialize, Serialize}; use uuid::Uuid; #[derive(Debug, Serialize, Deserialize)] pub struct ChatRequest { pub message: String, pub session_id: Option, } #[derive(Debug, Serialize, Deserialize)] pub struct ChatResponse { pub reply: String, pub session_id: Uuid, pub intent: String, pub is_compliant: bool, pub latency_ms: u64, } pub struct ChatService; impl ChatService { /// 处理患者消息:意图识别 → AI 生成 → 合规审查 → 回复 pub async fn handle_message( db: &DatabaseConnection, provider: &dyn AiProvider, tenant_id: Uuid, patient_id: Uuid, user_id: Uuid, request: ChatRequest, context: PatientChatContext, ) -> Result { let session_id = request.session_id.unwrap_or_else(Uuid::now_v7); let engine = ComplianceEngine::new(); // 1. 意图识别 let intent_result = Self::classify_intent(provider, &request.message).await; // 2. 紧急意图特殊处理 if intent_result.intent == IntentType::Emergency { let reply = format!( "⚠️ 您描述的情况需要紧急处理,请立即就医或拨打120。\n\n\ 同时已通知您的透析中心医护人员。" ); Self::save_log(db, tenant_id, patient_id, user_id, session_id, &request.message, &intent_result, None, "", &reply).await?; return Ok(ChatResponse { reply, session_id, intent: "emergency".into(), is_compliant: true, latency_ms: 0, }); } // 3. AI 生成回复 let prompt_prefix = ContextAssembler::to_prompt_prefix(&context); let req = GenerateRequest { system_prompt: Some(format!( "{}\n\n规则:你是肾脏健康管家小H,不是医生。不诊断、不开处方、不承诺疗效。对患者保持温暖关怀,涉及健康问题自然引导到院评估。", prompt_prefix )), user_prompt: format!("患者说:{}\n小H回复:", request.message), model: None, temperature: Some(0.7), max_tokens: Some(300), }; let ai_raw = provider.generate(req).await .map_err(|e| format!("AI 生成失败: {}", e))?; let ai_raw_text = ai_raw.content; // 4. 合规审查 + 修正 let compliance = ComplianceService::review_and_fix( &engine, &ai_raw_text, Some(provider), intent_result.should_skip_compliance, ).await; // 5. 持久化对话记录 Self::save_log(db, tenant_id, patient_id, user_id, session_id, &request.message, &intent_result, Some(&compliance), &ai_raw_text, &compliance.final_response).await?; Ok(ChatResponse { reply: compliance.final_response, session_id, intent: serde_json::to_string(&intent_result.intent) .unwrap_or_default() .trim_matches('"') .to_string(), is_compliant: compliance.is_compliant, latency_ms: compliance.total_latency_ms, }) } async fn classify_intent( provider: &dyn AiProvider, message: &str, ) -> IntentResult { use crate::copilot::intent::*; let context = crate::copilot::intent::ChatContext { recent_intents: vec![], patient_name: String::new(), }; let classifier = crate::copilot::intent::LlmIntentClassifier { provider }; classifier.classify(message, &context).await } async fn save_log( db: &DatabaseConnection, tenant_id: Uuid, patient_id: Uuid, user_id: Uuid, session_id: Uuid, user_message: &str, intent: &IntentResult, compliance: Option<&crate::copilot::compliance::ComplianceResult>, ai_raw_response: &str, final_response: &str, ) -> Result<(), String> { let log = copilot_chat_logs::ActiveModel { id: Set(Uuid::now_v7()), tenant_id: Set(tenant_id), patient_id: Set(patient_id), session_id: Set(session_id), user_message: Set(user_message.to_string()), intent_classification: Set(Some( serde_json::to_string(&intent.intent).unwrap() )), ai_raw_response: Set(Some(ai_raw_response.to_string())), layer1_result: Set(compliance.map(|c| { serde_json::to_value(&c.layer1_result).unwrap_or_default() })), layer2_result: Set(compliance.and_then(|c| { c.layer2_result.as_ref().map(|l2| { serde_json::to_value(l2).unwrap_or_default() }) })), violations_found: Set(compliance.map(|c| { serde_json::to_value(&c.violations).unwrap_or_default() })), fix_strategy: Set(compliance.and_then(|c| { c.fix_strategy.map(|s| serde_json::to_string(&s).unwrap()) })), final_response: Set(final_response.to_string()), created_at: Set(chrono::Utc::now()), updated_at: Set(chrono::Utc::now()), created_by: Set(Some(user_id)), updated_by: Set(Some(user_id)), deleted_at: Set(None), version_lock: Set(1), }; log.insert(db).await.map_err(|e| e.to_string())?; Ok(()) } } ``` - [ ] **Step 3: 在 service/mod.rs 注册新模块** 在 `crates/erp-ai/src/service/mod.rs` 添加: ```rust pub mod chat_service; pub mod compliance_service; ``` - [ ] **Step 4: cargo check 确认编译** Run: `cargo check -p erp-ai` Expected: 编译通过(可能有未使用警告,可忽略) - [ ] **Step 5: 提交** ```bash git add crates/erp-ai/src/service/chat_service.rs crates/erp-ai/src/service/compliance_service.rs crates/erp-ai/src/service/mod.rs git commit -m "feat(ai): Copilot 对话服务+合规服务 — 意图→生成→审查→修正流水线" ``` ### Task 31: 患者对话 API(chat_handler) **Files:** - Create: `crates/erp-ai/src/handler/chat_handler.rs` - Modify: `crates/erp-ai/src/handler/mod.rs` - Modify: `crates/erp-ai/src/module.rs` - [ ] **Step 1: 写 chat_handler** ```rust // crates/erp-ai/src/handler/chat_handler.rs use crate::entity::copilot_chat_logs; use crate::service::chat_service::{ChatRequest, ChatService}; use crate::state::AiState; use axum::{ extract::{Extension, Query, State}, Json, }; use erp_core::response::ApiResponse; use erp_core::tenant::TenantContext; use sea_orm::{QueryFilter, QueryOrder, PaginatorTrait}; use serde::Deserialize; use uuid::Uuid; #[derive(Deserialize)] pub struct HistoryQuery { pub session_id: Option, pub page: Option, pub page_size: Option, } /// POST /api/v1/copilot/chat /// 患者发送消息,返回合规审查后的回复 pub async fn send_message( State(state): State, Extension(ctx): Extension, Json(req): Json, ) -> Result>, erp_core::error::AppError> { erp_core::rbac::require_permission(&ctx, "copilot.chat.patient")?; let patient_id = resolve_patient_id(&state.db, &ctx.user_id).await?; let tenant_id = ctx.tenant_id; // MVP 阶段使用空上下文(后续从 erp-health 事件数据组装) let context = crate::copilot::context::PatientChatContext { patient: crate::copilot::context::PatientSummary { name: "患者".into(), age: 0, diagnosis: String::new(), dialysis_schedule: String::new(), allergies: vec![], medications: vec![], }, recent_data: crate::copilot::context::RecentHealthData { last_bp: None, last_weight: None, last_dialysis: None, next_dialysis: None, next_checkup: None, }, risk_summary: crate::copilot::context::RiskSummary { score: 0, level: "未知".into(), top_risks: vec![], }, conversation_summary: None, }; let provider = state.ai_provider.as_ref() .ok_or_else(|| erp_core::error::AppError::Internal("AI 服务不可用".into()))?; let response = ChatService::handle_message( &state.db, provider.as_ref(), tenant_id, patient_id, ctx.user_id, req, context, ).await.map_err(|e| erp_core::error::AppError::Internal(e))?; Ok(Json(ApiResponse::ok(response))) } /// GET /api/v1/copilot/chat/history /// 获取对话历史(分页) pub async fn get_history( State(state): State, Extension(ctx): Extension, Query(query): Query, ) -> Result>, erp_core::error::AppError> { erp_core::rbac::require_permission(&ctx, "copilot.chat.patient")?; let patient_id = resolve_patient_id(&state.db, &ctx.user_id).await?; let tenant_id = ctx.tenant_id; let page = query.page.unwrap_or(1); let page_size = query.page_size.unwrap_or(20).min(50); use crate::entity::copilot_chat_logs::Column; let paginator = copilot_chat_logs::Entity::find() .filter(Column::TenantId.eq(tenant_id)) .filter(Column::PatientId.eq(patient_id)) .filter(Column::DeletedAt.is_null()) .order_by_desc(Column::CreatedAt) .paginate(&state.db, page_size as u64); let total = paginator.num_items().await.map_err(|e| erp_core::error::AppError::Internal(e.to_string()))?; let items = paginator.fetch_page(page - 1).await.map_err(|e| erp_core::error::AppError::Internal(e.to_string()))?; Ok(Json(ApiResponse::ok(serde_json::json!({ "items": items, "total": total, "page": page, "page_size": page_size, })))) } /// GET /api/v1/copilot/chat/daily-greeting /// 获取今日个性化问候(Task 37 增强为含任务进度) pub async fn get_daily_greeting( State(state): State, Extension(ctx): Extension, ) -> Result>, erp_core::error::AppError> { erp_core::rbac::require_permission(&ctx, "copilot.chat.patient")?; // MVP: 固定问候模板(Task 37 增强为 AI + 任务联动) let greeting = serde_json::json!({ "message": "早上好!今天感觉怎么样?记得按时测量血压哦", "tips": ["记得今天测量血压", "注意控制饮水量"], "next_dialysis": null, }); Ok(Json(ApiResponse::ok(greeting))) } async fn resolve_patient_id( db: &sea_orm::DatabaseConnection, user_id: &Uuid, ) -> Result { // 通过 user_id 关联 patients 表获取 patient_id // MVP: 直接返回 user_id(后续实现完整关联) Ok(*user_id) } ``` - [ ] **Step 2: 在 handler/mod.rs 注册** ```rust pub mod chat_handler; ``` - [ ] **Step 3: 在 module.rs 注册路由和权限** 在 `crates/erp-ai/src/module.rs` 的 `routes()` 方法中添加: ```rust // 患者端 Copilot 路由 copilot_routes.push( Router::new() .route("/copilot/chat", post(chat_handler::send_message)) .route("/copilot/chat/history", get(chat_handler::get_history)) .route("/copilot/chat/daily-greeting", get(chat_handler::get_daily_greeting)) ); ``` 在 `permissions()` 方法中添加: ```rust ("copilot.chat.patient".into(), "患者端对话".into()), ``` - [ ] **Step 4: cargo check** Run: `cargo check -p erp-ai` Expected: 编译通过 - [ ] **Step 5: 提交** ```bash git add crates/erp-ai/src/handler/chat_handler.rs crates/erp-ai/src/handler/mod.rs crates/erp-ai/src/module.rs git commit -m "feat(ai): Copilot 患者对话 API — 发送/历史/每日问候" ``` ### Task 32: 每日问候生成服务 **Files:** - Create: `crates/erp-ai/src/service/greeting_service.rs` - [ ] **Step 1: 写问候生成服务** ```rust // crates/erp-ai/src/service/greeting_service.rs use crate::copilot::context::{PatientChatContext, RecentHealthData, RiskSummary}; use crate::provider::AiProvider; use crate::provider::dto::GenerateRequest; use serde::{Deserialize, Serialize}; #[derive(Debug, Serialize, Deserialize)] pub struct DailyGreeting { pub message: String, pub tips: Vec, pub mood: String, // warm/encouraging/caring/cheerful pub next_dialysis: Option, } pub struct GreetingService; impl GreetingService { /// 基于患者上下文生成个性化每日问候 pub async fn generate( provider: &dyn AiProvider, context: &PatientChatContext, ) -> Result { let special_reminders = Self::check_special_reminders(context); let req = GenerateRequest { system_prompt: Some("生成简短早晨问候(30字以内),语气温暖。只输出问候文本,不加引号。".into()), user_prompt: format!( "称呼{}。风险评分{}/10,{}。{}", context.patient.name, context.risk_summary.score, context.risk_summary.level, special_reminders, ), model: None, temperature: Some(0.8), max_tokens: Some(60), }; let message = match provider.generate(req).await { Ok(resp) => resp.content.trim().to_string(), Err(_) => Self::fallback_greeting(context), }; Ok(DailyGreeting { message, tips: Self::generate_tips(context), mood: Self::determine_mood(&context.risk_summary), next_dialysis: context.recent_data.next_dialysis.clone(), }) } fn check_special_reminders(ctx: &PatientChatContext) -> String { let mut reminders = Vec::new(); if let Some(next) = &ctx.recent_data.next_dialysis { reminders.push(format!("下次透析日期:{}。", next)); } if ctx.risk_summary.score >= 7 { reminders.push("风险偏高,需要额外关注。".into()); } reminders.join("") } fn generate_tips(ctx: &PatientChatContext) -> Vec { let mut tips = Vec::new(); tips.push("记得测量血压".into()); if ctx.risk_summary.score >= 5 { tips.push("注意控制饮水量".into()); } if ctx.recent_data.next_dialysis.is_some() { tips.push("透析日记得按时到院".into()); } tips } fn determine_mood(risk: &RiskSummary) -> String { if risk.score >= 7 { "caring".into() } else if risk.score >= 4 { "encouraging".into() } else { "cheerful".into() } } fn fallback_greeting(ctx: &PatientChatContext) -> String { format!("早上好{}!新的一天,记得关注自己的健康哦。", ctx.patient.name) } } ``` - [ ] **Step 2: 注册模块** 在 `crates/erp-ai/src/service/mod.rs` 添加: ```rust pub mod greeting_service; ``` - [ ] **Step 3: cargo check** Run: `cargo check -p erp-ai` Expected: 编译通过 - [ ] **Step 4: 提交** ```bash git add crates/erp-ai/src/service/greeting_service.rs crates/erp-ai/src/service/mod.rs git commit -m "feat(ai): Copilot 每日问候生成 — 基于风险画像个性化" ``` ### Task 33: 小程序对话页面 **Files:** - Create: `apps/miniprogram/src/pages/copilot/index.tsx` - Create: `apps/miniprogram/src/pages/copilot/components/ChatBubble.tsx` - Create: `apps/miniprogram/src/pages/copilot/components/QuickActions.tsx` - Create: `apps/miniprogram/src/pages/copilot/components/InputBar.tsx` - Modify: `apps/miniprogram/src/services/copilot.ts` - [ ] **Step 1: 写 Copilot API 服务层** ```typescript // apps/miniprogram/src/services/copilot.ts import { request } from './request'; const BASE = '/api/v1/copilot'; export interface ChatRequest { message: string; session_id?: string; } export interface ChatResponse { reply: string; session_id: string; intent: string; is_compliant: boolean; latency_ms: number; } export interface DailyGreeting { message: string; tips: string[]; mood: string; next_dialysis: string | null; } export interface ChatHistoryItem { id: string; user_message: string; final_response: string; intent_classification: string | null; created_at: string; } export async function sendMessage(data: ChatRequest): Promise { return request.post(`${BASE}/chat`, data); } export async function getChatHistory(params: { session_id?: string; page?: number; page_size?: number; }): Promise<{ items: ChatHistoryItem[]; total: number }> { return request.get(`${BASE}/chat/history`, params); } export async function getDailyGreeting(): Promise { return request.get(`${BASE}/chat/daily-greeting`); } ``` - [ ] **Step 2: 写 ChatBubble 组件** ```tsx // apps/miniprogram/src/pages/copilot/components/ChatBubble.tsx import { View, Text } from '@tarojs/components'; import './ChatBubble.scss'; interface Props { content: string; isUser: boolean; timestamp?: string; } export default function ChatBubble({ content, isUser, timestamp }: Props) { return ( {!isUser && 🤖} {content} {timestamp && {timestamp}} {isUser && 😊} ); } ``` - [ ] **Step 3: 写 QuickActions 组件** ```tsx // apps/miniprogram/src/pages/copilot/components/QuickActions.tsx import { View, Text } from '@tarojs/components'; import './QuickActions.scss'; const QUICK_ACTIONS = [ { label: '我的指标', message: '帮我看看最近的指标' }, { label: '下次透析', message: '下次透析是什么时候' }, { label: '饮食建议', message: '今天有什么饮食建议' }, { label: '预约', message: '怎么预约' }, ]; interface Props { onAction: (message: string) => void; } export default function QuickActions({ onAction }: Props) { return ( {QUICK_ACTIONS.map((action) => ( onAction(action.message)} > {action.label} ))} ); } ``` - [ ] **Step 4: 写 InputBar 组件** ```tsx // apps/miniprogram/src/pages/copilot/components/InputBar.tsx import { View, Input } from '@tarojs/components'; import { useState } from 'react'; import './InputBar.scss'; interface Props { onSend: (message: string) => void; disabled?: boolean; } export default function InputBar({ onSend, disabled }: Props) { const [value, setValue] = useState(''); const handleSend = () => { const msg = value.trim(); if (!msg || disabled) return; onSend(msg); setValue(''); }; return ( setValue(e.detail.value)} placeholder='问问小H...' confirmType='send' onConfirm={handleSend} disabled={disabled} /> 发送 ); } ``` - [ ] **Step 5: 写对话主页** ```tsx // apps/miniprogram/src/pages/copilot/index.tsx import { View, ScrollView } from '@tarojs/components'; import { useState, useCallback, useRef } from 'react'; import Taro from '@tarojs/taro'; import ChatBubble from './components/ChatBubble'; import QuickActions from './components/QuickActions'; import InputBar from './components/InputBar'; import { sendMessage } from '../../services/copilot'; import './index.scss'; interface Message { id: string; content: string; isUser: boolean; timestamp: string; } export default function CopilotPage() { const [messages, setMessages] = useState([]); const [loading, setLoading] = useState(false); const [sessionId, setSessionId] = useState(); const scrollViewRef = useRef(''); const handleSend = useCallback(async (text: string) => { const userMsg: Message = { id: Date.now().toString(), content: text, isUser: true, timestamp: new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }), }; setMessages((prev) => [...prev, userMsg]); setLoading(true); try { const res = await sendMessage({ message: text, session_id: sessionId }); setSessionId(res.session_id); const aiMsg: Message = { id: res.session_id, content: res.reply, isUser: false, timestamp: new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }), }; setMessages((prev) => [...prev, aiMsg]); } catch { Taro.showToast({ title: '发送失败,请重试', icon: 'none' }); } finally { setLoading(false); } scrollViewRef.current = `scroll-${Date.now()}`; }, [sessionId]); return ( 小H 健康管家 {loading ? '正在思考...' : '在线'} {/* 欢迎语 */} {messages.length === 0 && ( 🤖 你好!我是小H,你的肾脏健康管家。有什么可以帮你的吗? )} {messages.map((msg) => ( ))} {loading && ( )} {messages.length === 0 && ( )} ); } ``` - [ ] **Step 6: 注册页面路由** 在 `apps/miniprogram/src/app.config.ts` 的 `pages` 数组中添加: ```typescript 'pages/copilot/index', ``` - [ ] **Step 7: 前端构建验证** Run: `cd apps/miniprogram && pnpm build` Expected: 构建通过 - [ ] **Step 8: 提交** ```bash git add apps/miniprogram/src/pages/copilot/ apps/miniprogram/src/services/copilot.ts apps/miniprogram/src/app.config.ts git commit -m "feat(mp): Copilot 患者对话页面 — 聊天UI+快捷入口+输入栏" ``` ### Task 34: Phase 4 集成验证 - [ ] **Step 1: cargo check 全 workspace** Run: `cargo check` Expected: 编译通过 - [ ] **Step 2: cargo test 全 workspace** Run: `cargo test --workspace` Expected: 全部通过 - [ ] **Step 3: 启动后端 + 前端,端到端验证** 1. 启动后端 `cd crates/erp-server && cargo run` 2. 启动小程序 `cd apps/miniprogram && pnpm dev:weapp` 3. 在微信开发者工具中打开小程序 4. 以患者角色登录 5. 导航到 Copilot 对话页面 6. 发送"你好" → 验证收到 AI 回复 7. 发送"我胸痛" → 验证收到紧急就医引导 8. 发送"怎么预约" → 验证收到服务类回复 9. 发送"这个指标什么意思" → 验证收到健康类回复 - [ ] **Step 4: API 烟雾测试** Run: `curl -X POST http://localhost:3000/api/v1/copilot/chat -H "Authorization: Bearer " -H "Content-Type: application/json" -d '{"message":"你好"}'` Expected: 返回合规审查后的回复 JSON Run: `curl http://localhost:3000/api/v1/copilot/chat/daily-greeting -H "Authorization: Bearer "` Expected: 返回个性化问候 JSON - [ ] **Step 5: 提交(如有修复)** --- ## Chunk 6: Phase 5 — 日活引擎(小程序游戏化) > **目标:** 积分体系 + AI 问候驱动患者日常互动,实现每日任务打卡、积分兑换、连续打卡加成、AI 问候与任务联动。 > **验收:** 患者每日完成健康任务获得积分;积分可兑换服务特权;连续打卡有加成奖励;AI 问候与当日任务关联。 > **依赖:** Phase 4 完成(AI 问候 API、对话服务)。 ### Task 35: 每日任务系统(后端) **Files:** - Create: `crates/erp-ai/src/copilot/tasks.rs` - Create: `crates/erp-ai/src/service/task_service.rs` - Create: `crates/erp-ai/src/entity/copilot_daily_tasks.rs` - Create: `crates/erp-server/migration/src/m20260512_000142_create_copilot_daily_tasks.rs` - Modify: `crates/erp-server/migration/src/lib.rs` - [ ] **Step 1: 创建数据库迁移** 文件 `m20260512_000142_create_copilot_daily_tasks.rs`: ```rust 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(CopilotDailyTasks::Table) .col(ColumnDef::new(CopilotDailyTasks::Id).uuid().not_null().primary_key()) .col(ColumnDef::new(CopilotDailyTasks::TenantId).uuid().not_null()) .col(ColumnDef::new(CopilotDailyTasks::PatientId).uuid().not_null()) .col(ColumnDef::new(CopilotDailyTasks::TaskDate).date().not_null()) .col(ColumnDef::new(CopilotDailyTasks::TaskType).string_len(50).not_null()) .col(ColumnDef::new(CopilotDailyTasks::Title).string_len(200).not_null()) .col(ColumnDef::new(CopilotDailyTasks::Points).small_integer().not_null().default(10)) .col(ColumnDef::new(CopilotDailyTasks::IsCompleted).boolean().not_null().default(false)) .col(ColumnDef::new(CopilotDailyTasks::CompletedAt).timestamp_with_time_zone().null()) .col(ColumnDef::new(CopilotDailyTasks::CreatedAt).timestamp_with_time_zone().not_null().default(Expr::current_timestamp())) .col(ColumnDef::new(CopilotDailyTasks::UpdatedAt).timestamp_with_time_zone().not_null().default(Expr::current_timestamp())) .col(ColumnDef::new(CopilotDailyTasks::CreatedBy).uuid().null()) .col(ColumnDef::new(CopilotDailyTasks::UpdatedBy).uuid().null()) .col(ColumnDef::new(CopilotDailyTasks::DeletedAt).timestamp_with_time_zone().null()) .col(ColumnDef::new(CopilotDailyTasks::VersionLock).integer().not_null().default(1)) .to_owned(), ) .await?; manager .create_index( Index::create() .name("idx_copilot_daily_tasks_patient_date") .table(CopilotDailyTasks::Table) .col(CopilotDailyTasks::PatientId) .col(CopilotDailyTasks::TaskDate) .to_owned(), ) .await?; manager .create_index( Index::create() .name("idx_copilot_daily_tasks_unique") .table(CopilotDailyTasks::Table) .col(CopilotDailyTasks::PatientId) .col(CopilotDailyTasks::TaskDate) .col(CopilotDailyTasks::TaskType) .unique() .to_owned(), ) .await } async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { manager.drop_table(Table::drop().table(CopilotDailyTasks::Table).to_owned()).await } } #[derive(DeriveIden)] enum CopilotDailyTasks { Table, Id, TenantId, PatientId, TaskDate, TaskType, Title, Points, IsCompleted, CompletedAt, CreatedAt, UpdatedAt, CreatedBy, UpdatedBy, DeletedAt, VersionLock, } ``` - [ ] **Step 2: 注册迁移** 在 `migration/src/lib.rs` 中按序号插入迁移,更新后续编号。 - [ ] **Step 3: 运行迁移测试** Run: `cargo check -p erp-server` Expected: 编译通过 - [ ] **Step 4: 提交迁移** ```bash git add crates/erp-server/migration/src/ git commit -m "feat(db): copilot_daily_tasks 表迁移 — 每日任务系统" ``` - [ ] **Step 5: 写 copilot_daily_tasks Entity** 在 `crates/erp-ai/src/entity/copilot_daily_tasks.rs` 创建 Entity,参照现有 Entity 模式(如 `copilot_insights.rs`,在 Chunk 1 Task 3 中创建)。字段对应迁移 DDL:`id`, `tenant_id`, `patient_id`, `task_date`, `task_type`, `title`, `points`, `is_completed`, `completed_at`, 标准审计字段。 > 按照现有 Entity 模式使用 `#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]`,字段名使用 `task_date: NaiveDate`, `task_type: String`, `points: i16`, `is_completed: bool`。 - [ ] **Step 6: 在 entity/mod.rs 注册** 在 `crates/erp-ai/src/entity/mod.rs` 添加: ```rust pub mod copilot_daily_tasks; ``` - [ ] **Step 7: 写任务类型定义** ```rust // crates/erp-ai/src/copilot/tasks.rs use serde::{Deserialize, Serialize}; /// 每日任务类型 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum TaskType { CheckIn, // 每日打卡 BloodPressure, // 录入血压 WeightRecord, // 记录体重 ReadArticle, // 阅读健康文章 ChatWithH, // 与小H对话 DataUpload, // 上传化验单 } impl TaskType { pub fn title(&self) -> &str { match self { TaskType::CheckIn => "每日打卡", TaskType::BloodPressure => "记录血压", TaskType::WeightRecord => "记录体重", TaskType::ReadArticle => "阅读健康文章", TaskType::ChatWithH => "与小H聊天", TaskType::DataUpload => "上传化验单", } } pub fn points(&self) -> i16 { match self { TaskType::CheckIn => 10, TaskType::BloodPressure => 20, TaskType::WeightRecord => 15, TaskType::ReadArticle => 10, TaskType::ChatWithH => 15, TaskType::DataUpload => 25, } } /// 每日默认生成的任务列表 pub fn daily_tasks() -> Vec { vec![ TaskType::CheckIn, TaskType::BloodPressure, TaskType::WeightRecord, TaskType::ChatWithH, ] } } ``` - [ ] **Step 8: 写任务服务** ```rust // crates/erp-ai/src/service/task_service.rs use crate::copilot::tasks::TaskType; use crate::entity::copilot_daily_tasks; use chrono::{Local, NaiveDate}; use sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, Set}; use uuid::Uuid; pub struct TaskService; impl TaskService { /// 获取患者今日任务列表(如未生成则自动创建) pub async fn get_or_create_today_tasks( db: &DatabaseConnection, tenant_id: Uuid, patient_id: Uuid, ) -> Result, String> { let today = Local::now().date_naive(); // 查询今日已有任务 let existing = copilot_daily_tasks::Entity::find() .filter(copilot_daily_tasks::Column::TenantId.eq(tenant_id)) .filter(copilot_daily_tasks::Column::PatientId.eq(patient_id)) .filter(copilot_daily_tasks::Column::TaskDate.eq(today)) .filter(copilot_daily_tasks::Column::DeletedAt.is_null()) .all(db) .await .map_err(|e| e.to_string())?; if !existing.is_empty() { return Ok(existing); } // 首次查询:自动生成今日任务 let mut tasks = Vec::new(); for task_type in TaskType::daily_tasks() { let model = copilot_daily_tasks::ActiveModel { id: Set(Uuid::now_v7()), tenant_id: Set(tenant_id), patient_id: Set(patient_id), task_date: Set(today), task_type: Set(serde_json::to_string(&task_type).unwrap()), title: Set(task_type.title().to_string()), points: Set(task_type.points()), is_completed: Set(false), completed_at: Set(None), created_at: Set(chrono::Utc::now()), updated_at: Set(chrono::Utc::now()), created_by: Set(None), updated_by: Set(None), deleted_at: Set(None), version_lock: Set(1), }; let inserted = model.insert(db).await.map_err(|e| e.to_string())?; tasks.push(inserted); } Ok(tasks) } /// 完成任务(幂等) pub async fn complete_task( db: &DatabaseConnection, tenant_id: Uuid, task_id: Uuid, patient_id: Uuid, ) -> Result { let task = copilot_daily_tasks::Entity::find_by_id(task_id) .one(db) .await .map_err(|e| e.to_string())? .ok_or("任务不存在")?; if task.patient_id != patient_id || task.tenant_id != tenant_id { return Err("无权操作此任务".into()); } if task.is_completed { return Ok(task); // 幂等:已完成直接返回 } let mut active: copilot_daily_tasks::ActiveModel = task.into(); active.is_completed = Set(true); active.completed_at = Set(Some(chrono::Utc::now())); active.updated_at = Set(chrono::Utc::now()); active.version = Set(active.version.unwrap() + 1); active.update(db).await.map_err(|e| e.to_string()) } /// 获取今日完成进度 pub async fn get_today_progress( db: &DatabaseConnection, tenant_id: Uuid, patient_id: Uuid, ) -> Result { let tasks = Self::get_or_create_today_tasks(db, tenant_id, patient_id).await?; let total = tasks.len() as i32; let completed = tasks.iter().filter(|t| t.is_completed).count() as i32; let earned_points: i32 = tasks.iter() .filter(|t| t.is_completed) .map(|t| t.points as i32) .sum(); Ok(TaskProgress { total, completed, earned_points, tasks, }) } /// 计算连续打卡天数(单次查询优化,避免 N+1) pub async fn get_streak( db: &DatabaseConnection, tenant_id: Uuid, patient_id: Uuid, ) -> Result { let checkin_type = serde_json::to_string(&TaskType::CheckIn).unwrap(); let today = Local::now().date_naive(); // 单次查询获取最近 90 天的打卡记录,在 Rust 中计算连续天数 let tasks = copilot_daily_tasks::Entity::find() .filter(copilot_daily_tasks::Column::TenantId.eq(tenant_id)) .filter(copilot_daily_tasks::Column::PatientId.eq(patient_id)) .filter(copilot_daily_tasks::Column::TaskType.eq(&checkin_type)) .filter(copilot_daily_tasks::Column::IsCompleted.eq(true)) .filter(copilot_daily_tasks::Column::DeletedAt.is_null()) .filter(copilot_daily_tasks::Column::TaskDate.gte(today - chrono::Duration::days(90))) .all(db) .await .map_err(|e| e.to_string())?; let completed_dates: std::collections::HashSet = tasks.iter() .map(|t| t.task_date) .collect(); let mut streak = 0u32; for days_back in 0..90 { let check_date = today - chrono::Duration::days(days_back); if completed_dates.contains(&check_date) { streak += 1; } else { break; } } Ok(streak) } } #[derive(Debug, serde::Serialize)] pub struct TaskProgress { pub total: i32, pub completed: i32, pub earned_points: i32, pub tasks: Vec, } ``` - [ ] **Step 9: 写任务服务测试** ```rust #[cfg(test)] mod tests { use super::*; use crate::copilot::tasks::TaskType; #[test] fn test_daily_tasks_includes_checkin() { let tasks = TaskType::daily_tasks(); assert!(tasks.contains(&TaskType::CheckIn)); assert!(tasks.len() >= 3); } #[test] fn test_points_positive() { for task in TaskType::daily_tasks() { assert!(task.points() > 0, "{:?} points should be positive", task); } } #[test] fn test_streak_bonus_calculation() { // 连续打卡加成规则: // 3 天 → 1.5x, 7 天 → 2x, 30 天 → 3x assert_eq!(streak_multiplier(0), 1.0); assert_eq!(streak_multiplier(2), 1.0); assert_eq!(streak_multiplier(3), 1.5); assert_eq!(streak_multiplier(7), 2.0); assert_eq!(streak_multiplier(30), 3.0); } fn streak_multiplier(days: u32) -> f32 { if days >= 30 { 3.0 } else if days >= 7 { 2.0 } else if days >= 3 { 1.5 } else { 1.0 } } } ``` - [ ] **Step 10: 注册模块** 在 `crates/erp-ai/src/copilot/mod.rs` 添加: ```rust pub mod tasks; ``` 在 `crates/erp-ai/src/service/mod.rs` 添加: ```rust pub mod task_service; ``` - [ ] **Step 11: 运行测试** Run: `cargo test -p erp-ai --lib copilot::tasks` Expected: PASS - [ ] **Step 12: 提交** ```bash git add crates/erp-ai/src/copilot/tasks.rs crates/erp-ai/src/service/task_service.rs crates/erp-ai/src/copilot/mod.rs crates/erp-ai/src/service/mod.rs crates/erp-ai/src/entity/copilot_daily_tasks.rs git commit -m "feat(ai): Copilot 每日任务系统 — 任务生成/完成/连续打卡" ``` ### Task 36: 任务 API + 积分联动 **Files:** - Create: `crates/erp-ai/src/handler/task_handler.rs` - Modify: `crates/erp-ai/src/handler/mod.rs` - Modify: `crates/erp-ai/src/module.rs` - [ ] **Step 1: 写任务 API handler** ```rust // crates/erp-ai/src/handler/task_handler.rs use crate::service::task_service::TaskService; use crate::state::AiState; use axum::{ extract::{Extension, Path, State}, Json, }; use erp_core::response::ApiResponse; use erp_core::tenant::TenantContext; use uuid::Uuid; /// GET /api/v1/copilot/tasks/today pub async fn get_today_tasks( State(state): State, Extension(ctx): Extension, ) -> Result>, erp_core::error::AppError> { erp_core::rbac::require_permission(&ctx, "copilot.chat.patient")?; let patient_id = resolve_patient_id(&state.db, &ctx.user_id).await?; let tenant_id = ctx.tenant_id; let progress = TaskService::get_today_progress(&state.db, tenant_id, patient_id) .await.map_err(|e| erp_core::error::AppError::Internal(e))?; let streak = TaskService::get_streak(&state.db, tenant_id, patient_id) .await.map_err(|e| erp_core::error::AppError::Internal(e))?; Ok(Json(ApiResponse::ok(serde_json::json!({ "total": progress.total, "completed": progress.completed, "earned_points": progress.earned_points, "streak_days": streak, "tasks": progress.tasks, })))) } /// POST /api/v1/copilot/tasks/{id}/complete pub async fn complete_task( State(state): State, Extension(ctx): Extension, Path(task_id): Path, ) -> Result>, erp_core::error::AppError> { erp_core::rbac::require_permission(&ctx, "copilot.chat.patient")?; let patient_id = resolve_patient_id(&state.db, &ctx.user_id).await?; let tenant_id = ctx.tenant_id; let task = TaskService::complete_task(&state.db, tenant_id, task_id, patient_id) .await.map_err(|e| erp_core::error::AppError::Internal(e))?; // TODO: 积分入账 — 调用现有 points 模块 // MVP: 仅记录任务完成状态 Ok(Json(ApiResponse::ok(serde_json::json!({ "task_id": task.id, "points_earned": task.points, "completed": task.is_completed, })))) } async fn resolve_patient_id( db: &sea_orm::DatabaseConnection, user_id: &Uuid, ) -> Result { Ok(*user_id) } ``` - [ ] **Step 2: 注册路由** 在 `crates/erp-ai/src/module.rs` 的 `routes()` 中添加: ```rust // 患者端任务路由 copilot_routes.push( Router::new() .route("/copilot/tasks/today", get(task_handler::get_today_tasks)) .route("/copilot/tasks/{task_id}/complete", post(task_handler::complete_task)) ); ``` - [ ] **Step 3: cargo check** Run: `cargo check -p erp-ai` Expected: 编译通过 - [ ] **Step 4: 提交** ```bash git add crates/erp-ai/src/handler/task_handler.rs crates/erp-ai/src/handler/mod.rs crates/erp-ai/src/module.rs git commit -m "feat(ai): Copilot 任务 API — 今日任务/完成任务" ``` ### Task 37: AI 问候与任务联动 **Files:** - Modify: `crates/erp-ai/src/handler/chat_handler.rs` (daily-greeting 端点) - [ ] **Step 1: 增强 daily-greeting 端点,嵌入任务信息** 替换 `chat_handler.rs` 中的 `get_daily_greeting` 函数: ```rust /// GET /api/v1/copilot/chat/daily-greeting /// 获取今日个性化问候(含任务进度) pub async fn get_daily_greeting( State(state): State, Extension(ctx): Extension, ) -> Result>, erp_core::error::AppError> { erp_core::rbac::require_permission(&ctx, "copilot.chat.patient")?; let patient_id = resolve_patient_id(&state.db, &ctx.user_id).await?; let tenant_id = ctx.tenant_id; // 获取今日任务进度 let progress = crate::service::task_service::TaskService::get_today_progress(&state.db, tenant_id, patient_id) .await.map_err(|e| erp_core::error::AppError::Internal(e))?; let streak = crate::service::task_service::TaskService::get_streak(&state.db, tenant_id, patient_id) .await.map_err(|e| erp_core::error::AppError::Internal(e))?; // 获取 AI 问候(如有 Provider) let greeting = if let Some(provider) = state.ai_provider.as_ref() { let chat_ctx = crate::copilot::context::PatientChatContext { patient: crate::copilot::context::PatientSummary { name: "患者".into(), age: 0, diagnosis: String::new(), dialysis_schedule: String::new(), allergies: vec![], medications: vec![], }, recent_data: crate::copilot::context::RecentHealthData { last_bp: None, last_weight: None, last_dialysis: None, next_dialysis: None, next_checkup: None, }, risk_summary: crate::copilot::context::RiskSummary { score: 0, level: "未知".into(), top_risks: vec![], }, conversation_summary: None, }; crate::service::greeting_service::GreetingService::generate(provider.as_ref(), &chat_ctx) .await.unwrap_or_else(|_| crate::service::greeting_service::DailyGreeting { message: "早上好!新的一天,一起关注健康吧!".into(), tips: vec!["记得测量血压".into()], mood: "cheerful".into(), next_dialysis: None, }) } else { crate::service::greeting_service::DailyGreeting { message: "早上好!新的一天,一起关注健康吧!".into(), tips: vec!["记得测量血压".into()], mood: "cheerful".into(), next_dialysis: None, } }; // 未完成任务列表 let pending: Vec = progress.tasks.iter() .filter(|t| !t.is_completed) .map(|t| serde_json::json!({ "id": t.id, "title": t.title, "points": t.points, "type": t.task_type })) .collect(); Ok(Json(ApiResponse::ok(serde_json::json!({ "greeting": greeting, "tasks": { "total": progress.total, "completed": progress.completed, "earned_points": progress.earned_points, "pending": pending }, "streak_days": streak, })))) } ``` - [ ] **Step 2: cargo check** Run: `cargo check -p erp-ai` Expected: 编译通过 - [ ] **Step 3: 提交** ```bash git add crates/erp-ai/src/handler/chat_handler.rs git commit -m "feat(ai): AI 问候与任务联动 — 问候嵌入任务进度" ``` ### Task 38: 小程序首页改版 — 任务入口 + AI 问候卡片 **Files:** - Modify: `apps/miniprogram/src/pages/index/index.tsx` - Create: `apps/miniprogram/src/components/CopilotGreetingCard/index.tsx` - Create: `apps/miniprogram/src/components/TaskProgressCard/index.tsx` - [ ] **Step 1: 写 CopilotGreetingCard 组件** ```tsx // apps/miniprogram/src/components/CopilotGreetingCard/index.tsx import { View, Text } from '@tarojs/components'; import { useEffect, useState } from 'react'; import Taro from '@tarojs/taro'; import { getDailyGreeting } from '../../services/copilot'; import './index.scss'; interface TaskItem { id: string; title: string; points: number; type: string; } interface GreetingData { greeting: { message: string; tips: string[]; mood: string; }; tasks: { total: number; completed: number; earned_points: number; pending: TaskItem[]; }; streak_days: number; } export default function CopilotGreetingCard() { const [data, setData] = useState(null); useEffect(() => { getDailyGreeting().then(setData).catch(() => {}); }, []); if (!data) return null; return ( 🤖 {data.greeting.message} {data.tasks.pending.length > 0 && ( 今日待完成: {data.tasks.pending.slice(0, 3).map((task) => ( Taro.navigateTo({ url: '/pages/copilot/index' })} > {task.title} +{task.points} ))} )} {data.streak_days > 0 && ( 🔥 连续 {data.streak_days} 天 )} Taro.navigateTo({ url: '/pages/copilot/index' })} > 和小H聊聊 → ); } ``` - [ ] **Step 2: 写 TaskProgressCard 组件** ```tsx // apps/miniprogram/src/components/TaskProgressCard/index.tsx import { View, Text } from '@tarojs/components'; import './index.scss'; interface Props { total: number; completed: number; earnedPoints: number; streakDays: number; } export default function TaskProgressCard({ total, completed, earnedPoints, streakDays }: Props) { const progress = total > 0 ? Math.round((completed / total) * 100) : 0; return ( 今日任务 {completed}/{total} 已获 {earnedPoints} 积分 {streakDays > 0 && ( 🔥 {streakDays}天 )} ); } ``` - [ ] **Step 3: 在首页嵌入组件** 在 `apps/miniprogram/src/pages/index/index.tsx` 的合适位置添加: ```tsx import CopilotGreetingCard from '../../components/CopilotGreetingCard'; import TaskProgressCard from '../../components/TaskProgressCard'; // 在首页 render 中,轮播图下方添加: ``` - [ ] **Step 4: 前端构建验证** Run: `cd apps/miniprogram && pnpm build` Expected: 构建通过 - [ ] **Step 5: 提交** ```bash git add apps/miniprogram/src/components/CopilotGreetingCard/ apps/miniprogram/src/components/TaskProgressCard/ apps/miniprogram/src/pages/index/index.tsx git commit -m "feat(mp): 首页嵌入 AI 问候卡片+任务进度 — 日活引擎入口" ``` ### Task 39: 积分经济扩展 — 分层兑换 **Files:** - Modify: `crates/erp-health/src/service/points_service.rs`(扩展兑换类型) - Modify: `apps/web/src/pages/health/PointsRuleList.tsx`(管理后台配置) - Modify: `apps/miniprogram/src/pages/shop/`(小程序商城页面) - [ ] **Step 1: 在现有积分模块中增加"服务特权"兑换类型** 在 `crates/erp-health/src/service/points_service.rs` 中确认现有积分规则,添加服务特权兑换分类: ```rust // 扩展 PointsRedeemType pub enum RedeemCategory { ServicePrivilege, // 服务特权:优先预约、指定医生、延长透析时间等(零成本) PhysicalGoods, // 实物商品:营养品、护理用品等(有成本) } ``` > 注意:现有 erp-health 积分模块已有基础 CRUD。此 Task 是扩展兑换类型,不是重写。 > 实现时先检查现有 `points_service.rs` 的实际结构,在其基础上扩展。 - [ ] **Step 2: 在小程序商城中增加"服务特权"分类 Tab** 在 `apps/miniprogram/src/pages/shop/` 页面添加 Tab 切换: - "服务特权" — 零积分/低积分兑换(优先预约 50 积分、指定时段 100 积分) - "实物商品" — 高积分兑换(营养品 500 积分、护理用品 300 积分) - [ ] **Step 3: 前端构建验证** Run: `cd apps/miniprogram && pnpm build` Expected: 构建通过 - [ ] **Step 4: 提交** ```bash git add crates/erp-health/src/service/points_service.rs apps/miniprogram/src/pages/shop/ apps/web/src/pages/health/PointsRuleList.tsx git commit -m "feat(health): 积分分层兑换 — 服务特权+实物商品" ``` ### Task 40: Phase 5 集成验证 - [ ] **Step 1: cargo check 全 workspace** Run: `cargo check` Expected: 编译通过 - [ ] **Step 2: cargo test 全 workspace** Run: `cargo test --workspace` Expected: 全部通过 - [ ] **Step 3: 启动后端 + 小程序,端到端验证** 1. 启动后端 `cd crates/erp-server && cargo run` 2. 启动小程序 `cd apps/miniprogram && pnpm dev:weapp` 3. 以患者角色登录 4. **首页验证:** - AI 问候卡片显示 - 任务进度条显示 - 连续打卡天数(首次为 0) 5. **任务验证:** - 点击任务入口 → 对话页面 - API `/copilot/tasks/today` 返回 4 个任务 - 完成打卡任务 → 积分增加 6. **对话验证:** - 与小H对话后,"与小H聊天"任务自动完成 7. **积分验证:** - 打开积分商城 → 服务特权 Tab 和实物商品 Tab 均可见 - 低积分可兑换服务特权 - [ ] **Step 4: API 烟雾测试** Run: `curl http://localhost:3000/api/v1/copilot/tasks/today -H "Authorization: Bearer "` Expected: 返回今日任务列表 JSON Run: `curl -X POST http://localhost:3000/api/v1/copilot/tasks//complete -H "Authorization: Bearer "` Expected: 返回完成结果 JSON Run: `curl http://localhost:3000/api/v1/copilot/chat/daily-greeting -H "Authorization: Bearer "` Expected: 返回问候 + 任务进度 JSON - [ ] **Step 5: 提交(如有修复)** - [ ] **Step 6: 最终提交** ```bash git add crates/erp-ai/src/ apps/miniprogram/src/pages/copilot/ apps/miniprogram/src/pages/index/ apps/miniprogram/src/components/CopilotGreetingCard/ apps/miniprogram/src/components/TaskProgressCard/ apps/miniprogram/src/services/copilot.ts apps/miniprogram/src/app.config.ts git commit -m "feat(ai): Copilot Phase 4-5 完成 — 患者端对话+日活引擎" ``` --- ## 计划总览 | Chunk | Phase | Tasks | 核心产出 | |-------|-------|-------|---------| | 1 | Phase 0 基础设施 | 1-8 | 4 张表 + 规则引擎 + 评分服务 + API + 种子数据 | | 2 | Phase 1 风险画像 | 9-16 | 事件消费 + LLM 补充 + 每日刷新 + 前端徽章/卡片 + 权限 | | 3 | Phase 2 异常检测 | 17-21 | 告警规则扩展 + 异常洞察 + CopilotAlert + 告警工作流 | | 4 | Phase 3 随访/咨询 | 22-26 | 随访推荐 + 咨询辅助 + CopilotPanel + 一键采纳 | | 5 | Phase 4 患者端 Copilot | 27-34 | 意图识别 + 合规审查 + 上下文 + 对话 API + 小程序对话 UI + 问候 | | 6 | Phase 5 日活引擎 | 35-40 | 每日任务 + 积分联动 + 连续打卡 + AI 问候联动 + 首页改版 + 积分分层兑换 | **总计:40 Tasks,6 Phases,~30 天工作量**