- T40 UI 审计计划和结果文档(docs/qa/) - wiki 更新:miniprogram 设计系统合规审计记录 + index 关键数字更新 - 审计 V2 完整报告(docs/audits/v2/) - 讨论记录文档(docs/discussions/) - 设计规格和实施计划(docs/superpowers/) - 角色测试计划和结果(docs/qa/role-test-*) - Docker 生产部署配置
164 KiB
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_patienton (tenant_id, patient_id) -
索引:
idx_copilot_insights_expireson (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_patientUNIQUE 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_sessionon (tenant_id, session_id) -
索引:
idx_copilot_chat_logs_patienton (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 Entity(4 个实体)
Files:
-
Create:
crates/erp-ai/src/entity/copilot_rules.rs -
Create:
crates/erp-ai/src/entity/copilot_insights.rs -
Create:
crates/erp-ai/src/entity/copilot_risk_snapshots.rs -
Create:
crates/erp-ai/src/entity/copilot_chat_logs.rs -
Modify:
crates/erp-ai/src/entity/mod.rs -
Step 1: 创建 copilot_rules entity
参照 crates/erp-ai/src/entity/ai_suggestion.rs 模式:
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.rs 的 Set(...) + ..Default::default() 模式。
- Step 4: 创建 insight_service.rs
crates/erp-ai/src/service/insight_service.rs:
方法:
-
create_insight(db, tenant_id, patient_id, insight_type, ...) -> AppResult<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.rs 的 on_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.rs 的 protected_routes() 中添加 Copilot 路由,在 permissions() 中添加权限码:
copilot.insights.list
copilot.insights.manage
copilot.risk.view
copilot.rules.list
copilot.rules.manage
- Step 7: 编译验证
Run: cargo check -p erp-ai
Expected: 编译通过
- Step 8: 提交
git add crates/erp-ai/src/
git commit -m "feat(ai): Copilot handler + 路由 + 权限码 + DTO"
Task 7: 预置规则种子数据
Files:
-
Create:
crates/erp-server/migration/src/m20260512_000142_seed_copilot_rules.rs -
Modify:
crates/erp-server/migration/src/lib.rs -
Step 1: 创建种子数据迁移
插入 15 条内置规则,覆盖 spec §3.1 的 5 大类:
体征异常(4 条):
- 收缩压连续3次>140(+2)
- 舒张压连续3次>90(+1)
- 体重周增幅>2kg(+2)
- 心率>100(+1)
化验异常(4 条):
- eGFR<60(+3)
- 血钾>5.5(+4,critical)
- 肌酐环比>20%(+3)
- 血磷>1.5(+2)
依从性(2 条):
- 随访失约>2次(+1)
- 药物依从性<80%(+2)
透析质量(3 条):
- Kt/V<1.2(+2)
- 透析间期体重增长>5%(+3)
- 透析前收缩压>180(+3)
综合(2 条):
- eGFR<60 且 血钾>5.5(+5,critical)
- 收缩压>160 且 肌酐环比>20%(+4)
所有规则 tenant_id 使用 uuid::Uuid::nil()(系统级规则,适用于所有机构)。
JSONLogic 示例(供 INSERT 使用):
-- 收缩压连续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.rs(on_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.rs 的 spawn() + 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_id下deleted_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.Panel 或 Card 组件。
- 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.rs 的 permissions() 中注册了 5 个权限码。验证:
-
管理员角色拥有所有 Copilot 权限
-
医生/护士角色拥有
copilot.insights.list+copilot.risk.view -
Step 2: 功能验证
-
以管理员登录,访问 Copilot API,确认 200
-
以护士登录,确认可查看洞察和风险画像
-
确认无权限用户访问返回 403
-
Step 3: 提交(如有修改)
Task 16: Phase 1 集成验证
- Step 1: cargo test --workspace
Run: cargo test --workspace
Expected: 所有测试通过
- Step 2: pnpm build
Run: cd apps/web && pnpm build
Expected: 构建通过
- Step 3: 启动后端 + 前端,完整流程验证
- 启动后端
cd crates/erp-server && cargo run - 启动前端
cd apps/web && pnpm dev - 以护士角色登录
- 录入一条新体征数据(收缩压 150)
- 打开该患者详情页
- 验证:风险徽章显示"中风险"或以上
- 展开 CopilotCard,确认规则匹配和 LLM 补充分析
- Step 4: 提交(如有修复)
Chunk 3: Phase 2 — 异常检测 + 告警推送
目标: 健康数据入库时自动检测异常,推送告警给医护。危急值秒级生成告警,仪表盘显示分级告警列表。 验收: Critical value(如 K+ >6.0)入库后秒级生成告警 + 医护仪表盘显示分级告警列表 + 告警可标记处理状态 依赖: Chunk 1(规则引擎 + insight service)+ Chunk 2(事件消费者已订阅 health 事件)
Task 17: 异常检测规则扩展(趋势类/复合类规则)
Files:
-
Create:
crates/erp-server/migration/src/m20260512_000143_seed_copilot_alert_rules.rs -
Modify:
crates/erp-server/migration/src/lib.rs -
Step 1: 创建告警类规则种子迁移
在 Task 7 的 15 条基础规则之上,新增 8 条趋势类/复合类规则,覆盖 spec §3.2 的告警分级场景:
趋势类规则(4 条):
- 收缩压快速上升(prev1=130 → latest=150,环比增幅>15%)(+3,warning)
- 肌酐连续上升(prev1 < prev2 < latest,三值递增)(+3,warning)
- 体重连续上升(prev1 < prev2 < latest,三值递增)(+2,info)
- 血压趋势整体上升(prev2 < prev1 < latest)(+2,info)
复合类规则(4 条):
- eGFR<45 且 血钾>5.0(+5,critical)— 比基础规则更严格的双重条件
- 透析间期体重增长>5% 且 收缩压>160(+4,critical)
- 随访失约>2次 且 药物依从性<70%(+3,warning)
- Kt/V<1.0 且 透析前收缩压>180(+5,critical)
-- 收缩压快速上升(趋势类)
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.rs 的 process_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 支持传入 severity、source、rule_matches 等字段,设置 expires_at 为创建时间 + 7 天。
- Step 6: 运行测试
Run: cargo test -p erp-ai -- copilot::engine::tests
Expected: 3 tests PASS
- Step 7: 编译验证
Run: cargo check -p erp-ai
Expected: 编译通过
- Step 8: 提交
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 = true,updated_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- 接收
DismissInsightRequestbody - 调用
insight_service::escalate_insight
- 接收
修改现有 dismiss_insight 端点,支持 note 字段。
- Step 4: 注册新路由
在 module.rs 的 protected_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: 启动后端 + 前端,端到端验证
- 启动后端
cd crates/erp-server && cargo run - 启动前端
cd apps/web && pnpm dev - 以护士角色登录
- 为测试患者录入一条体征数据:收缩压 180
- 等待 5 秒(事件消费 + 规则评估)
- 刷新医护仪表盘页面
- 验证:CopilotAlert 组件显示告警
- 点击"已知悉",确认告警消失
- 录入一条危急化验数据:血钾 6.5
- 验证:浏览器弹出通知
- 点击"升级",确认生成随访建议洞察
- 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.rs 的 protected_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.rs 的 protected_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: 启动后端 + 前端,端到端验证
随访推荐验证:
- 启动后端
cd crates/erp-server && cargo run - 启动前端
cd apps/web && pnpm dev - 以护士角色登录
- 打开某个高风险患者的随访创建页面
- 验证:右侧 CopilotPanel 显示随访建议
- 点击"采纳"频率建议,确认表单自动填入
- 点击"全部采纳"指标,确认关注指标列表填入
咨询辅助验证:
- 打开一个有对话记录的咨询详情页
- 验证:右侧 CopilotPanel 显示患者背景、过敏警示、追问建议
- 点击某个追问建议的"插入"按钮
- 验证:回复输入框中追加了该问题文本
- 如有过敏记录,确认过敏警示显示为黄色警告条
- 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 — 患者端 Copilot(AI 客服/管家)
目标: 患者小程序内可与小H对话,获得合规审查后的回复;诊断性/处方性提问被自动修正为引导到院。 验收: 患者发送消息 → 意图识别 → 合规审查 → 获得安全回复;对话记录完整可审计。 依赖: Phase 0-3 完成(风险画像数据、erp-ai Provider 可用)。
Task 27: 意图识别引擎
Files:
-
Modify:
crates/erp-ai/Cargo.toml(添加aho-corasick依赖) -
Create:
crates/erp-ai/src/copilot/intent.rs -
Step 0: 添加 aho-corasick 依赖
在 crates/erp-ai/Cargo.toml 的 [dependencies] 中添加:
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: 患者对话 API(chat_handler)
Files:
-
Create:
crates/erp-ai/src/handler/chat_handler.rs -
Modify:
crates/erp-ai/src/handler/mod.rs -
Modify:
crates/erp-ai/src/module.rs -
Step 1: 写 chat_handler
// 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.rs 的 routes() 方法中添加:
// 患者端 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.ts 的 pages 数组中添加:
'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: 启动后端 + 前端,端到端验证
- 启动后端
cd crates/erp-server && cargo run - 启动小程序
cd apps/miniprogram && pnpm dev:weapp - 在微信开发者工具中打开小程序
- 以患者角色登录
- 导航到 Copilot 对话页面
- 发送"你好" → 验证收到 AI 回复
- 发送"我胸痛" → 验证收到紧急就医引导
- 发送"怎么预约" → 验证收到服务类回复
- 发送"这个指标什么意思" → 验证收到健康类回复
- 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 中创建)。字段对应迁移 DDL:id, tenant_id, patient_id, task_date, task_type, title, points, is_completed, completed_at, 标准审计字段。
按照现有 Entity 模式使用
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)],字段名使用task_date: NaiveDate,task_type: String,points: i16,is_completed: bool。
- Step 6: 在 entity/mod.rs 注册
在 crates/erp-ai/src/entity/mod.rs 添加:
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.rs 的 routes() 中添加:
// 患者端任务路由
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: 启动后端 + 小程序,端到端验证
- 启动后端
cd crates/erp-server && cargo run - 启动小程序
cd apps/miniprogram && pnpm dev:weapp - 以患者角色登录
- 首页验证:
- AI 问候卡片显示
- 任务进度条显示
- 连续打卡天数(首次为 0)
- 任务验证:
- 点击任务入口 → 对话页面
- API
/copilot/tasks/today返回 4 个任务 - 完成打卡任务 → 积分增加
- 对话验证:
- 与小H对话后,"与小H聊天"任务自动完成
- 积分验证:
- 打开积分商城 → 服务特权 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 Tasks,6 Phases,~30 天工作量