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