Files
hms/docs/superpowers/plans/2026-05-11-copilot-gene-plan.md
iven df1d85bfde docs: T40 UI 审计报告 + wiki 更新 + Docker 配置
- T40 UI 审计计划和结果文档(docs/qa/)
- wiki 更新:miniprogram 设计系统合规审计记录 + index 关键数字更新
- 审计 V2 完整报告(docs/audits/v2/)
- 讨论记录文档(docs/discussions/)
- 设计规格和实施计划(docs/superpowers/)
- 角色测试计划和结果(docs/qa/role-test-*)
- Docker 生产部署配置
2026-05-13 23:29:42 +08:00

164 KiB
Raw Blame History

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 模式:

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: 提交
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: 提交
git add crates/erp-server/migration/src/
git commit -m "feat(db): copilot_insights/risk_snapshots/chat_logs 表迁移"

Task 3: SeaORM Entity4 个实体)

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 模式:

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<String>,
    pub enabled: bool,
    pub sort_order: i32,
    pub created_at: DateTimeUtc,
    pub updated_at: DateTimeUtc,
    pub created_by: Option<Uuid>,
    pub updated_by: Option<Uuid>,
    pub deleted_at: Option<DateTimeUtc>,
    pub version_lock: i32,
}

#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}

impl ActiveModelBehavior for ActiveModel {}
  • Step 2: 创建其余 3 个 entity

同样模式,字段对应 spec §6.2 DDL。

  • Step 3: 注册 entity 模块

crates/erp-ai/src/entity/mod.rs 中添加:

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

pub mod rules;
// scoring 和 engine 将在 Task 5 中添加
  • Step 2: 编写规则引擎失败的测试

crates/erp-ai/src/copilot/rules.rs 底部:

#[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 解释器
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<String>)],
    patient_data: &Value,
) -> Vec<(uuid::Uuid, String, i16, String, Option<String>)> {
    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: 提交
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:

use crate::copilot::rules::evaluate_rules;

/// 风险评分结果
#[derive(Debug, Clone, serde::Serialize)]
pub struct RiskScore {
    pub score: i16,
    pub level: String,
    pub matched_rules: Vec<MatchedRule>,
}

#[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<String>,
}

/// 根据匹配规则计算风险评分
pub fn calculate_risk(
    matched: Vec<(uuid::Uuid, String, i16, String, Option<String>)>,
) -> 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:

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<String>)],
        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<RiskScore> — 加载规则 → 查询患者数据 → 调用 CopilotEngine::assess_patient → UPSERT copilot_risk_snapshots使用 ON CONFLICT tenant_id+patient_id DO UPDATE因为唯一索引保证每个患者只有一条快照
  • get_latest_risk(db, tenant_id, patient_id) -> AppResult<Option<CopilotRiskSnapshotModel>> — 查询最新快照
  • refresh_all_patients(db, tenant_id) -> AppResult<usize> — 批量刷新所有在管患者

实现要点: 所有查询必须带 tenant_id 过滤 + deleted_at IS NULL 条件。更新操作检查 version_lock 乐观锁。参照 suggestion.rsSet(...) + ..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<Uuid> — 写入洞察

  • list_insights(db, tenant_id, filters) -> AppResult<(Vec<Model>, u64)> — 分页查询

  • dismiss_insight(db, tenant_id, insight_id) -> AppResult<()> — 标记已处理

  • cleanup_expired(db) -> AppResult<u64> — 清理过期洞察

  • Step 5: 注册 service 模块

crates/erp-ai/src/service/mod.rs 中添加:

pub mod risk_service;
pub mod insight_service;
  • Step 6: 编译验证

Run: cargo check -p erp-ai Expected: 编译通过

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

use serde::{Deserialize, Serialize};

#[derive(Debug, Deserialize)]
pub struct ListInsightsQuery {
    pub patient_id: Option<uuid::Uuid>,
    pub insight_type: Option<String>,
    pub severity: Option<String>,
    pub page: Option<u64>,
    pub page_size: Option<u64>,
}

#[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 中添加:

pub struct AiState {
    // ... existing fields ...
    pub risk_service: Arc<RiskService>,
    pub insight_service: Arc<InsightService>,
}

crates/erp-ai/src/module.rson_startup() 或 state 构造处(参照现有 service 初始化模式),初始化:

risk_service: Arc::new(RiskService),
insight_service: Arc::new(InsightService),

crates/erp-ai/src/error.rs 中添加规则引擎错误变体:

#[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 中添加:

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.rsprotected_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: 提交
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+4critical
  • 肌酐环比>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+5critical
  • 收缩压>160 且 肌酐环比>20%+4

所有规则 tenant_id 使用 uuid::Uuid::nil()(系统级规则,适用于所有机构)。

JSONLogic 示例(供 INSERT 使用):

-- 收缩压连续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: 提交
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 <token>" Expected: 返回空洞察列表200 OK

Run: curl http://localhost:3000/api/v1/copilot/rules -H "Authorization: Bearer <token>" 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.rson_startup 中启动消费者)

  • Step 1: 创建 event 模块入口

crates/erp-ai/src/event/mod.rs:

pub mod copilot_consumer;
  • Step 2: 编写消费者失败的测试

crates/erp-ai/src/event/copilot_consumer.rs 底部:

#[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.rsspawn() + subscribe_filtered() 模式:

use erp_core::events::DomainEvent;

/// Copilot 关注的事件前缀
pub fn copilot_event_prefixes() -> Vec<String> {
    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<erp_core::events::SubscriptionHandle> {
    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 中启动消费者
// 在 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: 提交
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 底部添加测试:

#[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

/// LLM 补充分析:基于规则评分结果和患者数据,生成自然语言的补充洞察
/// 失败时返回 None降级为纯规则模式
pub async fn llm_supplement(
    provider_registry: &crate::provider::ProviderRegistry,
    risk_score: &RiskScore,
    patient_data: &serde_json::Value,
) -> Option<String> {
    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::<Vec<_>>()
            .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: 提交
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 中启动定时任务

// 每日凌晨 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_iddeleted_at IS NULL 的患者

  • 逐个调用 compute_risk

  • 返回刷新数量

  • Step 3: 编译验证 + 提交

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 的模式:

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<string, unknown>;
  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: 提交
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

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
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
import { Tag } from 'antd';
import type { RiskScore } from '@/api/copilot';

const levelConfig: Record<string, { color: string; label: string }> = {
  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 <Tag>评估中...</Tag>;
  if (!risk) return null;
  const config = levelConfig[risk.level] ?? levelConfig.low;
  return <Tag color={config.color}>{config.label} {risk.score}/10</Tag>;
}
  • Step 4: 创建 CopilotCard

可展开的洞察卡片,显示:

  • 风险评分 + 规则匹配详情
  • LLM 补充分析文本
  • 操作按钮:[查看详细报告] [创建随访计划] [忽略]

使用 Ant Design 的 Collapse.PanelCard 组件。

  • Step 5: 编译验证

Run: cd apps/web && pnpm build Expected: 编译通过

  • Step 6: 提交
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: 导入组件

在患者详情页中:

import CopilotBadge from '@/components/Copilot/CopilotBadge';
import { useCopilotRisk } from '@/components/Copilot/hooks/useCopilotRisk';
  • Step 2: 在患者姓名旁添加徽章
const { data: riskData } = useCopilotRisk(patientId);
// 在患者姓名区域
<span>{patient.name}</span>
<CopilotBadge risk={riskData?.data} />
  • Step 3: 功能验证

启动前端 + 后端,打开患者详情页,确认:

  • 风险徽章正常显示

  • 有风险评分数据

  • Copilot API 调用正常

  • Step 4: 提交

git add apps/web/src/pages/health/
git commit -m "feat(web): 患者详情页嵌入 Copilot 风险徽章"

Task 15: 权限码 + 菜单注册

Files:

  • 修改数据库种子数据或管理后台配置,添加 Copilot 权限码到相应角色

  • Step 1: 确认权限码已注册

Task 6 中已在 module.rspermissions() 中注册了 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%+3warning
  • 肌酐连续上升prev1 < prev2 < latest三值递增+3warning
  • 体重连续上升prev1 < prev2 < latest三值递增+2info
  • 血压趋势整体上升prev2 < prev1 < latest+2info

复合类规则4 条):

  • eGFR<45 且 血钾>5.0+5critical— 比基础规则更严格的双重条件
  • 透析间期体重增长>5% 且 收缩压>160+4critical
  • 随访失约>2次 且 药物依从性<70%+3warning
  • Kt/V<1.0 且 透析前收缩压>180+5critical
-- 收缩压快速上升(趋势类)
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: 提交
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 底部添加测试:

#[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 中实现告警洞察生成
/// 根据规则匹配结果生成异常洞察
/// 仅 warning 和 critical 级别生成告警洞察info 级别仅在档案内展示
pub fn generate_anomaly_insights(
    patient_id: &str,
    matched: &[(uuid::Uuid, String, i16, String, Option<String>)],
) -> Vec<serde_json::Value> {
    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.rsprocess_event 函数中,compute_risk 成功后增加:

// 异常检测:如果产生了告警级规则匹配,写入洞察
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 支持传入 severitysourcerule_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: 提交
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 中添加:

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 中添加:

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

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<string, { type: 'success' | 'info' | 'warning' | 'error'; label: string }> = {
  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 (
    <div>
      {criticalCount > 0 && (
        <Alert
          type="error"
          showIcon
          icon={<WarningOutlined />}
          message={`${criticalCount} 条危急告警`}
          banner
          style={{ marginBottom: 16 }}
        />
      )}
      <List
        loading={loading}
        dataSource={alerts}
        renderItem={(item) => {
          const config = severityConfig[item.severity] ?? severityConfig.info;
          return (
            <List.Item
              actions={[
                <Button key="dismiss" size="small" icon={<CheckOutlined />} onClick={() => onDismiss(item.id)}>
                  已知悉
                </Button>,
              ]}
            >
              <List.Item.Meta
                title={<Space><Badge status={config.type} />{item.title}</Space>}
                description={item.content?.suggestion as string}
              />
            </List.Item>
          );
        }}
      />
    </div>
  );
}
  • Step 4: 浏览器通知Critical 级别)

CopilotAlert 组件中添加 useEffect,当检测到新的 critical 告警时触发浏览器通知:

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: 提交
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 中添加:

#[derive(Debug, Deserialize)]
pub struct DismissInsightRequest {
    pub action: Option<String>, // "dismiss" | "escalate"
    pub note: Option<String>,
}
  • Step 2: 扩展 insight_service

insight_service.rs 中添加方法:

  • dismiss_insight(db, tenant_id, insight_id, note) -> AppResult<()> — 设置 is_dismissed = trueupdated_at = now()
  • escalate_insight(db, tenant_id, insight_id, note) -> AppResult<Uuid> — 创建一条新的 follow_up_hint 类型洞察链接到原始告警severity 升级为 critical
/// 升级告警:将告警转为随访任务建议
pub async fn escalate_insight(
    db: &DatabaseConnection,
    tenant_id: Uuid,
    insight_id: Uuid,
    note: Option<String>,
) -> AppResult<Uuid> {
    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.rsprotected_routes() 中添加:

.route("/copilot/insights/{insight_id}/escalate", post(insight_handler::escalate_insight))
  • Step 5: 编译验证

Run: cargo check -p erp-ai Expected: 编译通过

  • Step 6: 提交
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 底部:

#[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

use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FollowUpHint {
    pub frequency: String,
    pub frequency_reason: String,
    pub monitoring_indicators: Vec<String>,
    pub key_questions: Vec<String>,
    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<serde_json::Value>>,
) -> Vec<String> {
    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 中添加:

pub mod followup_hint_service;

handler/mod.rs 中添加:

pub mod followup_hint_handler;

module.rsprotected_routes() 中添加:

.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: 提交
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 底部:

#[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

use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConsultHint {
    pub patient_background: String,
    pub suggested_questions: Vec<String>,
    pub allergy_alerts: Vec<String>,
    pub precautions: Vec<String>,
    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<String> {
    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<String> {
    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::<Vec<_>>()
        })
        .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

// 参照 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.rsprotected_routes() 中添加:

.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: 提交
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 中添加:

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

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 (
      <Card title={<Space><RobotOutlined /> Copilot 建议</Space>} style={{ width: 360 }}>
        <Spin tip="分析中..." />
      </Card>
    );
  }

  if (mode === 'followup') {
    const { hint, onAdopt } = props;
    if (!hint) return null;

    return (
      <Card
        title={<Space><RobotOutlined /> Copilot 随访建议</Space>}
        style={{ width: 360 }}
        size="small"
      >
        <Space direction="vertical" style={{ width: '100%' }}>
          <div>
            <Text strong>推荐频率:</Text>
            <Tag color="blue">{hint.frequency}</Tag>
            <Button size="small" type="link" onClick={() => onAdopt('frequency', hint.frequency)}>
              采纳
            </Button>
            <br />
            <Text type="secondary" style={{ fontSize: 12 }}>{hint.frequency_reason}</Text>
          </div>
          <Divider style={{ margin: '8px 0' }} />
          <div>
            <Text strong>关注指标:</Text>
            <Button size="small" type="link" onClick={() => onAdopt('indicators', hint.monitoring_indicators.join('、'))}>
              全部采纳
            </Button>
            <List
              size="small"
              dataSource={hint.monitoring_indicators}
              renderItem={(item) => (
                <List.Item style={{ padding: '2px 0', border: 'none' }}>
                  <Text> {item}</Text>
                </List.Item>
              )}
            />
          </div>
          <Divider style={{ margin: '8px 0' }} />
          <div>
            <Text strong>建议问诊要点:</Text>
            <Button size="small" type="link" onClick={() => onAdopt('questions', hint.key_questions.join('\n'))}>
              全部采纳
            </Button>
            <List
              size="small"
              dataSource={hint.key_questions}
              renderItem={(item) => (
                <List.Item style={{ padding: '2px 0', border: 'none' }}>
                  <Text> {item}</Text>
                </List.Item>
              )}
            />
          </div>
        </Space>
      </Card>
    );
  }

  // mode === 'consult'
  const { hint, onInsertQuestion } = props as ConsultProps;
  if (!hint) return null;

  return (
    <Card
      title={<Space><RobotOutlined /> Copilot 咨询辅助</Space>}
      style={{ width: 360 }}
      size="small"
    >
      <Space direction="vertical" style={{ width: '100%' }}>
        {hint.allergy_alerts.map((alert, i) => (
          <Alert key={i} message={alert} type="warning" showIcon banner />
        ))}
        <div>
          <Text strong>患者背景:</Text>
          <Paragraph style={{ fontSize: 12, whiteSpace: 'pre-wrap', marginBottom: 0 }}>
            {hint.patient_background}
          </Paragraph>
        </div>
        <Divider style={{ margin: '8px 0' }} />
        <div>
          <Text strong>建议追问:</Text>
          {hint.suggested_questions.map((q, i) => (
            <div key={i} style={{ margin: '4px 0' }}>
              <Text> {q}</Text>
              <Button
                size="small"
                type="link"
                icon={<ThunderboltOutlined />}
                onClick={() => onInsertQuestion(q)}
              >
                插入
              </Button>
            </div>
          ))}
        </div>
        {hint.precautions.length > 0 && (
          <>
            <Divider style={{ margin: '8px 0' }} />
            <div>
              <Text strong>注意事项:</Text>
              {hint.precautions.map((p, i) => (
                <Text key={i} type="secondary" style={{ display: 'block', fontSize: 12 }}> {p}</Text>
              ))}
            </div>
          </>
        )}
      </Space>
    </Card>
  );
}
  • Step 3: 编译验证

Run: cd apps/web && pnpm build Expected: 编译通过

  • Step 4: 提交
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

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

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 中的创建弹窗)添加:

import CopilotPanel from '@/components/Copilot/CopilotPanel';
import { useFollowupHint } from '@/components/Copilot/hooks/useFollowupHint';

// 在随访表单右侧
const { data: hintData, isLoading: hintLoading } = useFollowupHint(patientId);

// 布局:左侧表单 + 右侧 CopilotPanel
<Row gutter={16}>
  <Col span={16}>
    {/* 现有随访表单 */}
  </Col>
  <Col span={8}>
    <CopilotPanel
      mode="followup"
      hint={hintData?.data}
      loading={hintLoading}
      onAdopt={(field, value) => {
        // 将 Copilot 建议填入表单字段
        form.setFieldValue(field, value);
        message.success(`已采纳 Copilot 建议:${field}`);
      }}
    />
  </Col>
</Row>
  • Step 3: 嵌入咨询详情页

ConsultationDetail.tsx 中添加:

import CopilotPanel from '@/components/Copilot/CopilotPanel';
import { useConsultHint } from '@/components/Copilot/hooks/useConsultHint';

// 在对话区域右侧
const { data: consultHintData, isLoading: consultHintLoading } = useConsultHint(patientId);

// 布局:左侧对话区域 + 右侧 CopilotPanel
<Row gutter={16}>
  <Col span={16}>
    {/* 现有咨询对话区域 */}
  </Col>
  <Col span={8}>
    <CopilotPanel
      mode="consult"
      hint={consultHintData?.data}
      loading={consultHintLoading}
      onInsertQuestion={(question) => {
        // 将追问建议插入到回复输入框
        setReplyContent(prev => prev ? `${prev}\n${question}` : question);
        message.success('已插入追问建议');
      }}
    />
  </Col>
</Row>
  • Step 4: 编译验证

Run: cd apps/web && pnpm build Expected: 编译通过

  • Step 5: 提交
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/<id>/followup-hint -H "Authorization: Bearer <token>" Expected: 返回随访推荐建议 JSON

Run: curl http://localhost:3000/api/v1/copilot/patients/<id>/consult-hint -H "Authorization: Bearer <token>" Expected: 返回咨询辅助建议 JSON

  • Step 6: 提交(如有修复)

Chunk 5: Phase 4 — 患者端 CopilotAI 客服/管家)

目标: 患者小程序内可与小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] 中添加:

aho-corasick = "1"
  • Step 1: 写意图分类数据结构和分类函数签名
// 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<IntentType>, // 最近 3 轮意图,连续同类可跳过
    pub patient_name: String,
}
  • Step 2: 写意图识别单元测试
#[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: 实现基于关键词的意图分类器(规则层)
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<IntentType> {
    // 按优先级顺序检查:紧急 > 健康 > 服务 > 情感
    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 做快速分类:

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: 提交
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: 写合规审查数据结构

// 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<Layer2Result>,
    pub violations: Vec<Violation>,
    pub fix_strategy: Option<FixStrategy>,
    pub final_response: String,
    pub total_latency_ms: u64,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Layer1Result {
    pub passed: bool,
    pub matched_keywords: Vec<KeywordMatch>,
    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<String>,
    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: 写合规审查测试
#[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: 实现合规审查引擎
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 中添加:

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: 提交
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: 写上下文结构定义

// 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<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PatientSummary {
    pub name: String,
    pub age: u32,
    pub diagnosis: String,          // 通俗描述,如"慢性肾病"
    pub dialysis_schedule: String,  // "每周二、四、六 下午"
    pub allergies: Vec<String>,     // 过敏史(安全提示用)
    pub medications: Vec<String>,   // 药物列表(仅名称,不含剂量)
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecentHealthData {
    pub last_bp: Option<String>,           // "135/85"
    pub last_weight: Option<String>,       // "68.5kg"
    pub last_dialysis: Option<String>,     // "2026-05-09"
    pub next_dialysis: Option<String>,     // "2026-05-13"
    pub next_checkup: Option<String>,      // "2026-05-15"
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RiskSummary {
    pub score: i32,                        // 0-10
    pub level: String,                     // 低/中/中高/高
    pub top_risks: Vec<String>,            // 最多 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: 写上下文组装测试
#[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: 提交
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: 写合规服务

// 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: 写对话服务
// 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<Uuid>,
}

#[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<ChatResponse, String> {
        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 添加:

pub mod chat_service;
pub mod compliance_service;
  • Step 4: cargo check 确认编译

Run: cargo check -p erp-ai Expected: 编译通过(可能有未使用警告,可忽略)

  • Step 5: 提交
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: 患者对话 APIchat_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

// 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<Uuid>,
    pub page: Option<u64>,
    pub page_size: Option<u64>,
}

/// POST /api/v1/copilot/chat
/// 患者发送消息,返回合规审查后的回复
pub async fn send_message(
    State(state): State<AiState>,
    Extension(ctx): Extension<TenantContext>,
    Json(req): Json<ChatRequest>,
) -> Result<Json<ApiResponse<crate::service::chat_service::ChatResponse>>, 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<AiState>,
    Extension(ctx): Extension<TenantContext>,
    Query(query): Query<HistoryQuery>,
) -> Result<Json<ApiResponse<serde_json::Value>>, 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<AiState>,
    Extension(ctx): Extension<TenantContext>,
) -> Result<Json<ApiResponse<serde_json::Value>>, 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<Uuid, erp_core::error::AppError> {
    // 通过 user_id 关联 patients 表获取 patient_id
    // MVP: 直接返回 user_id后续实现完整关联
    Ok(*user_id)
}
  • Step 2: 在 handler/mod.rs 注册
pub mod chat_handler;
  • Step 3: 在 module.rs 注册路由和权限

crates/erp-ai/src/module.rsroutes() 方法中添加:

// 患者端 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() 方法中添加:

("copilot.chat.patient".into(), "患者端对话".into()),
  • Step 4: cargo check

Run: cargo check -p erp-ai Expected: 编译通过

  • Step 5: 提交
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: 写问候生成服务

// 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<String>,
    pub mood: String,           // warm/encouraging/caring/cheerful
    pub next_dialysis: Option<String>,
}

pub struct GreetingService;

impl GreetingService {
    /// 基于患者上下文生成个性化每日问候
    pub async fn generate(
        provider: &dyn AiProvider,
        context: &PatientChatContext,
    ) -> Result<DailyGreeting, String> {
        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<String> {
        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 添加:

pub mod greeting_service;
  • Step 3: cargo check

Run: cargo check -p erp-ai Expected: 编译通过

  • Step 4: 提交
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 服务层

// 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<ChatResponse> {
  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<DailyGreeting> {
  return request.get(`${BASE}/chat/daily-greeting`);
}
  • Step 2: 写 ChatBubble 组件
// 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 (
    <View className={`chat-bubble ${isUser ? 'user' : 'ai'}`}>
      {!isUser && <Text className='bubble-avatar'>🤖</Text>}
      <View className='bubble-body'>
        <Text className='bubble-text'>{content}</Text>
        {timestamp && <Text className='bubble-time'>{timestamp}</Text>}
      </View>
      {isUser && <Text className='bubble-avatar'>😊</Text>}
    </View>
  );
}
  • Step 3: 写 QuickActions 组件
// 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 (
    <View className='quick-actions'>
      {QUICK_ACTIONS.map((action) => (
        <View
          key={action.label}
          className='quick-action-btn'
          onClick={() => onAction(action.message)}
        >
          <Text>{action.label}</Text>
        </View>
      ))}
    </View>
  );
}
  • Step 4: 写 InputBar 组件
// 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 (
    <View className='input-bar'>
      <Input
        className='chat-input'
        value={value}
        onInput={(e) => setValue(e.detail.value)}
        placeholder='问问小H...'
        confirmType='send'
        onConfirm={handleSend}
        disabled={disabled}
      />
      <View
        className={`send-btn ${!value.trim() ? 'disabled' : ''}`}
        onClick={handleSend}
      >
        发送
      </View>
    </View>
  );
}
  • Step 5: 写对话主页
// 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<Message[]>([]);
  const [loading, setLoading] = useState(false);
  const [sessionId, setSessionId] = useState<string | undefined>();
  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 (
    <View className='copilot-page'>
      <View className='copilot-header'>
        <View className='header-title'>H 健康管家</View>
        <View className='header-status'>
          {loading ? '正在思考...' : '在线'}
        </View>
      </View>

      <ScrollView
        className='chat-body'
        scrollY
        scrollIntoView={scrollViewRef.current}
        scrollWithAnimation
      >
        {/* 欢迎语 */}
        {messages.length === 0 && (
          <View className='welcome-message'>
            <View className='welcome-avatar'>🤖</View>
            <View className='welcome-text'>
              你好!我是小H,你的肾脏健康管家。有什么可以帮你的吗?
            </View>
          </View>
        )}

        {messages.map((msg) => (
          <ChatBubble
            key={msg.id}
            content={msg.content}
            isUser={msg.isUser}
            timestamp={msg.timestamp}
          />
        ))}

        {loading && (
          <ChatBubble content='思考中...' isUser={false} />
        )}
      </ScrollView>

      {messages.length === 0 && (
        <QuickActions onAction={handleSend} />
      )}

      <InputBar onSend={handleSend} disabled={loading} />
    </View>
  );
}
  • Step 6: 注册页面路由

apps/miniprogram/src/app.config.tspages 数组中添加:

'pages/copilot/index',
  • Step 7: 前端构建验证

Run: cd apps/miniprogram && pnpm build Expected: 构建通过

  • Step 8: 提交
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 <token>" -H "Content-Type: application/json" -d '{"message":"你好"}' Expected: 返回合规审查后的回复 JSON

Run: curl http://localhost:3000/api/v1/copilot/chat/daily-greeting -H "Authorization: Bearer <token>" 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

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: 提交迁移
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 中创建)。字段对应迁移 DDLid, 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 添加:

pub mod copilot_daily_tasks;
  • Step 7: 写任务类型定义
// 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<TaskType> {
        vec![
            TaskType::CheckIn,
            TaskType::BloodPressure,
            TaskType::WeightRecord,
            TaskType::ChatWithH,
        ]
    }
}
  • Step 8: 写任务服务
// 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<Vec<copilot_daily_tasks::Model>, 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<copilot_daily_tasks::Model, String> {
        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<TaskProgress, String> {
        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<u32, String> {
        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<NaiveDate> = 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<copilot_daily_tasks::Model>,
}
  • Step 9: 写任务服务测试
#[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 添加:

pub mod tasks;

crates/erp-ai/src/service/mod.rs 添加:

pub mod task_service;
  • Step 11: 运行测试

Run: cargo test -p erp-ai --lib copilot::tasks Expected: PASS

  • Step 12: 提交
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

// 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<AiState>,
    Extension(ctx): Extension<TenantContext>,
) -> Result<Json<ApiResponse<serde_json::Value>>, 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<AiState>,
    Extension(ctx): Extension<TenantContext>,
    Path(task_id): Path<Uuid>,
) -> Result<Json<ApiResponse<serde_json::Value>>, 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<Uuid, erp_core::error::AppError> {
    Ok(*user_id)
}
  • Step 2: 注册路由

crates/erp-ai/src/module.rsroutes() 中添加:

// 患者端任务路由
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: 提交
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 函数:

/// GET /api/v1/copilot/chat/daily-greeting
/// 获取今日个性化问候(含任务进度)
pub async fn get_daily_greeting(
    State(state): State<AiState>,
    Extension(ctx): Extension<TenantContext>,
) -> Result<Json<ApiResponse<serde_json::Value>>, 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<serde_json::Value> = 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: 提交
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 组件

// 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<GreetingData | null>(null);

  useEffect(() => {
    getDailyGreeting().then(setData).catch(() => {});
  }, []);

  if (!data) return null;

  return (
    <View className='copilot-greeting-card'>
      <View className='greeting-header'>
        <Text className='greeting-avatar'>🤖</Text>
        <View className='greeting-text'>
          <Text className='greeting-message'>{data.greeting.message}</Text>
        </View>
      </View>

      {data.tasks.pending.length > 0 && (
        <View className='greeting-tasks'>
          <Text className='tasks-label'>今日待完成:</Text>
          <View className='tasks-row'>
            {data.tasks.pending.slice(0, 3).map((task) => (
              <View
                key={task.id}
                className='task-chip'
                onClick={() => Taro.navigateTo({ url: '/pages/copilot/index' })}
              >
                <Text>{task.title}</Text>
                <Text className='task-points'>+{task.points}</Text>
              </View>
            ))}
          </View>
        </View>
      )}

      {data.streak_days > 0 && (
        <View className='streak-badge'>
          <Text>🔥 连续 {data.streak_days} </Text>
        </View>
      )}

      <View
        className='chat-entry'
        onClick={() => Taro.navigateTo({ url: '/pages/copilot/index' })}
      >
        <Text>和小H聊聊 </Text>
      </View>
    </View>
  );
}
  • Step 2: 写 TaskProgressCard 组件
// 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 (
    <View className='task-progress-card'>
      <View className='progress-header'>
        <Text className='progress-title'>今日任务</Text>
        <Text className='progress-count'>{completed}/{total}</Text>
      </View>

      <View className='progress-bar'>
        <View className='progress-fill' style={{ width: `${progress}%` }} />
      </View>

      <View className='progress-footer'>
        <Text className='points-text'>已获 {earnedPoints} 积分</Text>
        {streakDays > 0 && (
          <Text className='streak-text'>🔥 {streakDays}</Text>
        )}
      </View>
    </View>
  );
}
  • Step 3: 在首页嵌入组件

apps/miniprogram/src/pages/index/index.tsx 的合适位置添加:

import CopilotGreetingCard from '../../components/CopilotGreetingCard';
import TaskProgressCard from '../../components/TaskProgressCard';

// 在首页 render 中,轮播图下方添加:
<CopilotGreetingCard />
<TaskProgressCard
  total={taskProgress.total}
  completed={taskProgress.completed}
  earnedPoints={taskProgress.earned_points}
  streakDays={taskProgress.streak_days}
/>
  • Step 4: 前端构建验证

Run: cd apps/miniprogram && pnpm build Expected: 构建通过

  • Step 5: 提交
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 中确认现有积分规则,添加服务特权兑换分类:

// 扩展 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: 提交
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 <token>" Expected: 返回今日任务列表 JSON

Run: curl -X POST http://localhost:3000/api/v1/copilot/tasks/<id>/complete -H "Authorization: Bearer <token>" Expected: 返回完成结果 JSON

Run: curl http://localhost:3000/api/v1/copilot/chat/daily-greeting -H "Authorization: Bearer <token>" Expected: 返回问候 + 任务进度 JSON

  • Step 5: 提交(如有修复)

  • Step 6: 最终提交

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 Tasks6 Phases~30 天工作量