diff --git a/docs/superpowers/plans/2026-05-11-copilot-gene-plan.md b/docs/superpowers/plans/2026-05-11-copilot-gene-plan.md new file mode 100644 index 0000000..ffaee50 --- /dev/null +++ b/docs/superpowers/plans/2026-05-11-copilot-gene-plan.md @@ -0,0 +1,5186 @@ +# 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` + +- [ ] **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, +} +``` + +- [ ] **Step 2: 注册迁移** + +在 `migration/src/lib.rs` 中: +- 顶部添加 `mod m20260512_000138_create_copilot_rules;` +- `migrations()` vec 中添加 `Box::new(m20260512_000138_create_copilot_rules::Migration)` + +- [ ] **Step 3: 编译验证** + +Run: `cargo check -p erp-server` +Expected: 编译通过 + +- [ ] **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` + +- [ ] **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) + +- [ ] **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) + +- [ ] **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) + +- [ ] **Step 4: 注册 3 个迁移** + +在 `migration/src/lib.rs` 中注册 m139、m140、m141。 + +- [ ] **Step 5: 编译验证** + +Run: `cargo check -p erp-server` +Expected: 编译通过 + +- [ ] **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` + +- [ ] **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 {} +``` + +- [ ] **Step 2: 创建其余 3 个 entity** + +同样模式,字段对应 spec §6.2 DDL。 + +- [ ] **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; +``` + +- [ ] **Step 4: 编译验证** + +Run: `cargo check -p erp-ai` +Expected: 编译通过 + +- [ ] **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` + +- [ ] **Step 1: 创建 copilot 模块入口(仅 rules,其他 Task 5 再加)** + +`crates/erp-ai/src/copilot/mod.rs`: +```rust +pub mod rules; +// scoring 和 engine 将在 Task 5 中添加 +``` + +- [ ] **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)); + } +} +``` + +- [ ] **Step 3: 运行测试确认失败** + +Run: `cargo test -p erp-ai -- copilot::rules::tests` +Expected: 编译失败(函数不存在) + +- [ ] **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() +} +``` + +- [ ] **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 中启动消费者) + +- [ ] **Step 1: 创建 event 模块入口** + +`crates/erp-ai/src/event/mod.rs`: +```rust +pub mod copilot_consumer; +``` + +- [ ] **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")); + } +} +``` + +- [ ] **Step 3: 运行测试确认失败** + +Run: `cargo test -p erp-ai -- copilot_consumer::tests` +Expected: 编译失败 + +- [ ] **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; +} +``` + +- [ ] **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); +``` + +- [ ] **Step 6: 在 lib.rs 注册 event 模块** + +添加 `pub mod event;` + +- [ ] **Step 7: 运行测试** + +Run: `cargo test -p erp-ai -- copilot_consumer::tests` +Expected: 2 tests PASS + +- [ ] **Step 8: 编译验证** + +Run: `cargo check -p erp-ai` +Expected: 编译通过 + +- [ ] **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` + +- [ ] **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"); + } +} +``` + +- [ ] **Step 2: 运行测试** + +Run: `cargo test -p erp-ai -- copilot::scoring::tests` +Expected: 3 tests PASS + +- [ ] **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() +} +``` + +- [ ] **Step 4: 修改 risk_service 使用 LLM 补充** + +在 `risk_service::compute_risk` 中,规则评分完成后异步调用 `llm_supplement()`: +- 成功:将结果写入 `copilot_risk_snapshots.llm_summary` +- 失败:`llm_summary` 为 None(静默降级) + +- [ ] **Step 5: 编译验证** + +Run: `cargo check -p erp-ai` +Expected: 编译通过 + +- [ ] **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` + +- [ ] **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 + // 记录刷新结果数量 + } +}); +``` + +- [ ] **Step 2: 实现 refresh_all_patients** + +在 `risk_service.rs` 中: +- 查询所有 `tenant_id` 下 `deleted_at IS NULL` 的患者 +- 逐个调用 `compute_risk` +- 返回刷新数量 + +- [ ] **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` + +- [ ] **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`); +} +``` + +- [ ] **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` + +- [ ] **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 分钟缓存 + }); +} +``` + +- [ ] **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, + }); +} +``` + +- [ ] **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; +} +``` + +- [ ] **Step 4: 创建 CopilotCard** + +可展开的洞察卡片,显示: +- 风险评分 + 规则匹配详情 +- LLM 补充分析文本 +- 操作按钮:[查看详细报告] [创建随访计划] [忽略] + +使用 Ant Design 的 `Collapse.Panel` 或 `Card` 组件。 + +- [ ] **Step 5: 编译验证** + +Run: `cd apps/web && pnpm build` +Expected: 编译通过 + +- [ ] **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 天工作量**