feat(iit): Implement real-time quality control system

Summary:

- Add 4 new database tables: iit_field_metadata, iit_qc_logs, iit_record_summary, iit_qc_project_stats

- Implement pg-boss debounce mechanism in WebhookController

- Refactor QC Worker for dual output: QC logs + record summary

- Enhance HardRuleEngine to support form-based rule filtering

- Create QcService for QC data queries

- Optimize ChatService with new intents: query_enrollment, query_qc_status

- Add admin batch operations: one-click full QC + one-click full summary

- Create IIT Admin management module: project config, QC rules, user mapping

Status: Code complete, pending end-to-end testing
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-02-07 21:56:11 +08:00
parent 0c590854b5
commit 5db4a7064c
74 changed files with 13383 additions and 2129 deletions

View File

@@ -4,10 +4,11 @@
> **更新日期:** 2026-02-05
> **关联文档:** [IIT Manager Agent V2.6 综合开发计划](./IIT%20Manager%20Agent%20V2.6%20综合开发计划.md)
>
> **V2.9 更新**
> **V2.9.1 更新**
> - 扩展 `iit_skills` 表支持 Cron Skill主动提醒
> - 扩展 `iit_conversation_history` 表增加反馈字段
> - 更新 `iit_project_memory` 内容结构(用户画像)
> - **新增 `iit_pii_audit_log` 表**PII 脱敏审计日志(合规必需)
---
@@ -17,6 +18,7 @@
|------|------|-------|--------|
| `iit_skills` | Skill 配置存储 | 1 | P0 |
| `iit_field_mapping` | 字段名映射字典 | 1 | P0 |
| `iit_pii_audit_log` | PII 脱敏审计日志 | 1.5 | P0 |
| `iit_task_run` | SOP 任务执行记录 | 2 | P0 |
| `iit_pending_actions` | 待处理的违规记录 | 2 | P0 |
| `iit_conversation_history` | 对话历史(流水账) | 2 | P1 |
@@ -159,6 +161,64 @@ INSERT INTO iit_field_mapping (project_id, alias_name, actual_name) VALUES
---
## 2.5 Phase 1.5隐私安全表P0 合规必需)
### 2.5.1 iit_pii_audit_log - PII 脱敏审计日志
> **重要**:临床数据包含大量患者隐私信息(姓名、身份证、手机号),在调用第三方 LLM 之前**必须脱敏**。
> 此表用于存储脱敏记录,便于事后合规审计。
```prisma
model IitPiiAuditLog {
id String @id @default(uuid())
projectId String
userId String // 操作者
sessionId String // 会话 ID关联 conversation_history
// 脱敏内容(加密存储)
originalHash String // 原始内容的 SHA256 哈希(不存明文)
maskedPayload String @db.Text // 脱敏后发送给 LLM 的内容
maskingMap String @db.Text // 加密存储的映射表 { "[PATIENT_1]": "张三", ... }
// 元数据
piiCount Int // 检测到的 PII 数量
piiTypes String[] // 检测到的 PII 类型 ['name', 'id_card', 'phone']
llmProvider String // 'qwen' | 'deepseek' | 'openai'
createdAt DateTime @default(now())
@@index([projectId, userId])
@@index([sessionId])
@@index([createdAt])
@@map("iit_pii_audit_log")
@@schema("iit_schema")
}
```
**PII 类型说明**
| PII 类型 | 正则模式 | 脱敏示例 |
|----------|----------|----------|
| `name` | 中文姓名2-4字 | 张三 → [PATIENT_1] |
| `id_card` | 身份证号18位 | 420101... → [ID_CARD_1] |
| `phone` | 手机号11位 | 13800138000 → [PHONE_1] |
| `mrn` | 病历号 | MRN123456 → [MRN_1] |
**脱敏流程**
```
用户输入: "张三身份证420101199001011234今天血压偏高"
↓ AnonymizerService.mask()
LLM 收到: "[PATIENT_1](身份证[ID_CARD_1])今天血压偏高"
↓ 同时写入 iit_pii_audit_log
↓ LLM 处理
LLM 返回: "[PATIENT_1] 的血压需要关注..."
↓ AnonymizerService.unmask()
用户看到: "张三 的血压需要关注..."
```
---
## 3. Phase 2SOP 执行与记忆表
### 3.1 iit_task_run - SOP 任务执行记录

View File

@@ -4,10 +4,12 @@
> **更新日期:** 2026-02-05
> **关联文档:** [IIT Manager Agent V2.6 综合开发计划](./IIT%20Manager%20Agent%20V2.6%20综合开发计划.md)
>
> **V2.9 更新**
> **V2.9.1 更新**
> - 新增 `ProfilerService` 用户画像服务
> - `ChatService` 增加反馈循环功能
> - `SchedulerService` 支持 Cron Skill 触发
> - **新增 `AnonymizerService`**PII 脱敏中间件P0 合规必需)
> - **新增 `AutoMapperService`**REDCap Schema 自动对齐工具
---
@@ -16,6 +18,8 @@
| 服务 | 职责 | Phase |
|------|------|-------|
| `ToolsService` | 统一工具管理(字段映射 + 执行) | 1 |
| `AnonymizerService` | **PII 脱敏中间件P0 合规必需)** | 1.5 |
| `AutoMapperService` | **REDCap Schema 自动对齐** | 1 |
| `ChatService` | 消息路由(双脑入口)+ 反馈收集 | 2 |
| `IntentService` | 意图识别(混合路由) | 5 |
| `MemoryService` | 记忆管理V2.8 架构) | 2-3 |
@@ -315,6 +319,460 @@ export class ToolsService {
---
## 2.5 AnonymizerService - PII 脱敏中间件P0 合规必需)
> **文件路径**: `backend/src/modules/iit-manager/services/AnonymizerService.ts`
>
> **⚠️ 重要**:临床数据包含大量患者隐私信息,在调用第三方 LLM 之前**必须脱敏**
### 2.5.1 核心职责
- 识别文本中的 PII个人身份信息
- 发送 LLM 前脱敏Masking
- 接收 LLM 回复后还原Unmasking
- 记录脱敏审计日志
### 2.5.2 PII 识别正则库
```typescript
// PII 类型定义
const PII_PATTERNS = {
// 中文姓名2-4字排除常见非姓名词
name: /(?<![a-zA-Z\u4e00-\u9fa5])[\u4e00-\u9fa5]{2,4}(?![a-zA-Z\u4e00-\u9fa5])/g,
// 身份证号18位
id_card: /\d{6}(19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]/g,
// 手机号11位1开头
phone: /1[3-9]\d{9}/g,
// 病历号(字母+数字组合)
mrn: /(?:MRN|HN|病案号)[:\s]?([A-Z0-9]{6,12})/gi
};
// 非姓名词排除列表(提高准确率)
const NAME_EXCLUSIONS = [
'患者', '医生', '护士', '主任', '教授', '方案', '访视',
'入组', '排除', '标准', '剂量', '疗程', '周期', '疗效'
];
```
### 2.5.3 完整实现
```typescript
import { prisma } from '../../common/prisma';
import * as crypto from 'crypto';
interface MaskingResult {
maskedText: string;
maskingMap: Record<string, string>; // { "[PATIENT_1]": "张三" }
piiCount: number;
piiTypes: string[];
}
interface UnmaskingContext {
maskingMap: Record<string, string>;
}
export class AnonymizerService {
private encryptionKey: string;
constructor() {
this.encryptionKey = process.env.PII_ENCRYPTION_KEY || 'default-key-change-me';
}
/**
* 脱敏:发送 LLM 前调用
*/
async mask(
text: string,
context: { projectId: string; userId: string; sessionId: string }
): Promise<MaskingResult> {
const maskingMap: Record<string, string> = {};
const piiTypes: string[] = [];
let maskedText = text;
let counter = { name: 0, id_card: 0, phone: 0, mrn: 0 };
// 按优先级处理(先处理身份证,再处理姓名,避免误识别)
// 1. 身份证号
maskedText = maskedText.replace(PII_PATTERNS.id_card, (match) => {
counter.id_card++;
const placeholder = `[ID_CARD_${counter.id_card}]`;
maskingMap[placeholder] = match;
if (!piiTypes.includes('id_card')) piiTypes.push('id_card');
return placeholder;
});
// 2. 手机号
maskedText = maskedText.replace(PII_PATTERNS.phone, (match) => {
counter.phone++;
const placeholder = `[PHONE_${counter.phone}]`;
maskingMap[placeholder] = match;
if (!piiTypes.includes('phone')) piiTypes.push('phone');
return placeholder;
});
// 3. 病历号
maskedText = maskedText.replace(PII_PATTERNS.mrn, (match, mrn) => {
counter.mrn++;
const placeholder = `[MRN_${counter.mrn}]`;
maskingMap[placeholder] = mrn;
if (!piiTypes.includes('mrn')) piiTypes.push('mrn');
return placeholder.padEnd(match.length);
});
// 4. 中文姓名(需要更精细的判断)
maskedText = maskedText.replace(PII_PATTERNS.name, (match) => {
// 排除非姓名词
if (NAME_EXCLUSIONS.includes(match)) return match;
// 排除已被其他规则处理的部分
if (Object.values(maskingMap).includes(match)) return match;
counter.name++;
const placeholder = `[PATIENT_${counter.name}]`;
maskingMap[placeholder] = match;
if (!piiTypes.includes('name')) piiTypes.push('name');
return placeholder;
});
const piiCount = Object.keys(maskingMap).length;
// 记录审计日志
if (piiCount > 0) {
await this.saveAuditLog({
projectId: context.projectId,
userId: context.userId,
sessionId: context.sessionId,
originalHash: this.hashText(text),
maskedPayload: maskedText,
maskingMap: this.encrypt(JSON.stringify(maskingMap)),
piiCount,
piiTypes
});
}
return { maskedText, maskingMap, piiCount, piiTypes };
}
/**
* 还原:接收 LLM 回复后调用
*/
unmask(text: string, context: UnmaskingContext): string {
let result = text;
// 将占位符替换回原始值
for (const [placeholder, original] of Object.entries(context.maskingMap)) {
result = result.replace(new RegExp(this.escapeRegex(placeholder), 'g'), original);
}
return result;
}
// ===== 辅助方法 =====
private hashText(text: string): string {
return crypto.createHash('sha256').update(text).digest('hex');
}
private encrypt(text: string): string {
const cipher = crypto.createCipheriv(
'aes-256-gcm',
crypto.scryptSync(this.encryptionKey, 'salt', 32),
crypto.randomBytes(16)
);
return cipher.update(text, 'utf8', 'hex') + cipher.final('hex');
}
private escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
private async saveAuditLog(data: {
projectId: string;
userId: string;
sessionId: string;
originalHash: string;
maskedPayload: string;
maskingMap: string;
piiCount: number;
piiTypes: string[];
}): Promise<void> {
await prisma.iitPiiAuditLog.create({
data: {
...data,
llmProvider: process.env.LLM_PROVIDER || 'qwen'
}
});
}
}
```
### 2.5.4 集成到 ChatService
```typescript
// ChatService.ts 中的使用
export class ChatService {
private anonymizer: AnonymizerService;
async handleMessage(userId: string, message: string): Promise<string> {
const projectId = await this.getUserProject(userId);
const sessionId = this.sessionMemory.getSessionId(userId);
// ⚠️ 调用 LLM 前脱敏
const { maskedText, maskingMap, piiCount } = await this.anonymizer.mask(
message,
{ projectId, userId, sessionId }
);
if (piiCount > 0) {
console.log(`[Anonymizer] 检测到 ${piiCount} 个 PII已脱敏`);
}
// 使用脱敏后的文本调用 LLM
const llmResponse = await this.llm.chat(maskedText, ...);
// ⚠️ 收到 LLM 回复后还原
const unmaskedResponse = this.anonymizer.unmask(llmResponse, { maskingMap });
return unmaskedResponse;
}
}
```
---
## 2.6 AutoMapperService - REDCap Schema 自动对齐
> **文件路径**: `backend/src/modules/iit-manager/services/AutoMapperService.ts`
>
> **目的**:大幅减少 `iit_field_mapping` 表的人工配置工作量
### 2.6.1 核心职责
- 解析 REDCap Data DictionaryCSV/JSON
- 使用 LLM 进行语义映射
- 提供管理后台确认界面
### 2.6.2 完整实现
```typescript
import { parse } from 'papaparse';
import { LLMFactory } from '../../common/llm/adapters/LLMFactory';
import { prisma } from '../../common/prisma';
interface FieldDefinition {
variableName: string;
fieldLabel: string;
fieldType: string;
choices?: string;
}
interface MappingSuggestion {
redcapField: string;
redcapLabel: string;
suggestedAlias: string[];
confidence: number;
status: 'pending' | 'confirmed' | 'rejected';
}
export class AutoMapperService {
private llm = LLMFactory.create('qwen');
// 系统标准字段列表
private readonly STANDARD_FIELDS = [
{ name: 'age', aliases: ['年龄', 'age', '岁数'] },
{ name: 'gender', aliases: ['性别', 'sex', 'gender', '男女'] },
{ name: 'ecog', aliases: ['ECOG', 'PS评分', '体力状态'] },
{ name: 'visit_date', aliases: ['访视日期', '就诊日期', 'visit date'] },
{ name: 'height', aliases: ['身高', 'height', 'ht'] },
{ name: 'weight', aliases: ['体重', 'weight', 'wt'] },
{ name: 'bmi', aliases: ['BMI', '体质指数'] },
{ name: 'consent_date', aliases: ['知情同意日期', 'ICF日期', 'consent date'] },
{ name: 'enrollment_date', aliases: ['入组日期', 'enrollment date', '入选日期'] }
];
/**
* 解析 REDCap Data Dictionary
*/
async parseDataDictionary(fileContent: string, format: 'csv' | 'json'): Promise<FieldDefinition[]> {
if (format === 'csv') {
const result = parse(fileContent, { header: true });
return result.data.map((row: any) => ({
variableName: row['Variable / Field Name'] || row['variable_name'],
fieldLabel: row['Field Label'] || row['field_label'],
fieldType: row['Field Type'] || row['field_type'],
choices: row['Choices, Calculations, OR Slider Labels'] || row['choices']
}));
} else {
return JSON.parse(fileContent);
}
}
/**
* 使用 LLM 生成映射建议
*/
async generateMappingSuggestions(
projectId: string,
fields: FieldDefinition[]
): Promise<MappingSuggestion[]> {
const prompt = `你是一个临床研究数据专家。请将以下 REDCap 字段与系统标准字段进行语义匹配。
## 系统标准字段
${this.STANDARD_FIELDS.map(f => `- ${f.name}: ${f.aliases.join(', ')}`).join('\n')}
## REDCap 字段列表
${fields.slice(0, 50).map(f => `- ${f.variableName}: ${f.fieldLabel}`).join('\n')}
请返回 JSON 格式的映射建议:
{
"mappings": [
{
"redcapField": "nl_age",
"suggestedAlias": ["age", "年龄"],
"confidence": 0.95
}
]
}
注意:
1. 只返回有把握的映射confidence >= 0.7
2. 如果不确定,不要强行映射
3. 一个 REDCap 字段可以有多个别名`;
const response = await this.llm.chat([
{ role: 'user', content: prompt }
]);
try {
const jsonMatch = response.content.match(/\{[\s\S]*\}/);
const result = JSON.parse(jsonMatch?.[0] || '{"mappings":[]}');
return result.mappings.map((m: any) => ({
redcapField: m.redcapField,
redcapLabel: fields.find(f => f.variableName === m.redcapField)?.fieldLabel || '',
suggestedAlias: m.suggestedAlias,
confidence: m.confidence,
status: 'pending' as const
}));
} catch (e) {
console.error('[AutoMapper] LLM 返回解析失败', e);
return [];
}
}
/**
* 批量确认映射
*/
async confirmMappings(
projectId: string,
confirmations: Array<{
redcapField: string;
aliases: string[];
confirmed: boolean;
}>
): Promise<{ created: number; skipped: number }> {
let created = 0;
let skipped = 0;
for (const conf of confirmations) {
if (!conf.confirmed) {
skipped++;
continue;
}
for (const alias of conf.aliases) {
try {
await prisma.iitFieldMapping.upsert({
where: {
projectId_aliasName: { projectId, aliasName: alias }
},
create: {
projectId,
aliasName: alias,
actualName: conf.redcapField
},
update: {
actualName: conf.redcapField
}
});
created++;
} catch (e) {
console.error(`[AutoMapper] 创建映射失败: ${alias} -> ${conf.redcapField}`, e);
}
}
}
return { created, skipped };
}
/**
* 一键导入流程
*/
async autoImport(
projectId: string,
fileContent: string,
format: 'csv' | 'json'
): Promise<{
suggestions: MappingSuggestion[];
message: string;
}> {
// 1. 解析 Data Dictionary
const fields = await this.parseDataDictionary(fileContent, format);
console.log(`[AutoMapper] 解析到 ${fields.length} 个字段`);
// 2. 生成 LLM 建议
const suggestions = await this.generateMappingSuggestions(projectId, fields);
console.log(`[AutoMapper] 生成 ${suggestions.length} 个映射建议`);
return {
suggestions,
message: `已解析 ${fields.length} 个 REDCap 字段,生成 ${suggestions.length} 个映射建议,请在管理后台确认。`
};
}
}
```
### 2.6.3 管理后台 API
```typescript
// routes/autoMapperRoutes.ts
router.post('/auto-mapper/import', async (req, res) => {
const { projectId, fileContent, format } = req.body;
const result = await autoMapperService.autoImport(projectId, fileContent, format);
res.json({
success: true,
suggestions: result.suggestions,
message: result.message
});
});
router.post('/auto-mapper/confirm', async (req, res) => {
const { projectId, confirmations } = req.body;
const result = await autoMapperService.confirmMappings(projectId, confirmations);
res.json({
success: true,
created: result.created,
skipped: result.skipped,
message: `已创建 ${result.created} 个映射,跳过 ${result.skipped}`
});
});
```
### 2.6.4 效率对比
| 配置方式 | 100 个字段耗时 | 准确率 |
|----------|---------------|--------|
| 手动逐条配置 | 2-4 小时 | 100%(人工保证) |
| LLM 猜测 + 人工确认 | 15-30 分钟 | 95%LLM猜测→ 100%(人工确认) |
---
## 3. IntentService - 意图识别
> **文件路径**: `backend/src/modules/iit-manager/services/IntentService.ts`

View File

@@ -4,7 +4,9 @@
> **更新日期:** 2026-02-05
> **关联文档:** [IIT Manager Agent V2.6 综合开发计划](./IIT%20Manager%20Agent%20V2.6%20综合开发计划.md)
>
> **V2.9 更新**
> **V2.9.1 更新**
> - **新增 Phase 1.5**隐私安全与自动化工具P0 合规必需)
> - Phase 1 新增 AutoMapperService 任务
> - Phase 3 新增反馈循环任务
> - Phase 4 新增 ProfilerService 和 Cron Skill 任务
> - Phase 5 新增多意图处理任务
@@ -14,15 +16,15 @@
## 1. 开发阶段总览
```
Phase 1 Phase 2 Phase 3 Phase 4 Phase 5 Phase 6
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
基础工具层 │ ──▶ │ SOP 引擎 │ ──▶ReAct 引擎 │ ──▶ 调度系统 │ ──▶ 智能路由 │ ──▶ 视觉能力
│ │ │ + 记忆L2 + 记忆L1 + 记忆L3 (延后)
└─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘
▼ ▼
ToolsService SopEngine ReActEngine SchedulerService IntentService VisionService
FieldMapping HotMemory FlowMemory WeeklyReports MixedRouting (Postponed)
HardRuleEngine SoftRuleEngine AgentTrace ReportService StreamingFB
Phase 1 Phase 1.5 Phase 2 Phase 3 Phase 4 Phase 5 Phase 6
┌──────────┐ ┌──────────┐ ──────────┐ ──────────┐ ──────────┐ ──────────┐ ──────────┐
│基础工具层│ ──▶│ 隐私安全 │ ──▶│ SOP 引擎 │ ──▶ReAct 引擎│ ──▶ 调度系统 │ ──▶ 智能路由 │ ──▶ 视觉能力 │
P0必需 + 记忆L2 │ + 记忆L1 │ + 记忆L3 │ (延后) │
└──────────┘ └──────────┘ ──────────┘ ──────────┘ ──────────┘ ──────────┘ ──────────┘
ToolsService Anonymizer SopEngine ReActEngine Scheduler IntentService VisionService
FieldMapping AutoMapper HotMemory FlowMemory WeeklyReports MixedRouting (Postponed)
HardRule PII Audit SoftRule AgentTrace ReportService StreamingFB
```
---
@@ -46,17 +48,72 @@ Phase 1 Phase 2 Phase 3 Phase 4
| P1-05 | 实现 `search_protocol` 工具 | 高 | 待开始 | P1-03 |
| P1-06 | 实现 `HardRuleEngine` | 高 | 待开始 | - |
| P1-07 | 集成字段映射到 ToolsService | 中 | 待开始 | P1-02, P1-03 |
| P1-08 | 单元测试覆盖 | 中 | 待开始 | P1-01~P1-07 |
| P1-08 | **[V2.9.1]** 实现 `AutoMapperService` | 中 | 待开始 | P1-02 |
| P1-09 | **[V2.9.1]** 实现 REDCap Data Dictionary 解析器 | 中 | 待开始 | P1-08 |
| P1-10 | **[V2.9.1]** 实现 LLM 语义映射 Job | 中 | 待开始 | P1-08 |
| P1-11 | **[V2.9.1]** 实现管理后台映射确认 UI | 低 | 待开始 | P1-10 |
| P1-12 | 单元测试覆盖 | 中 | 待开始 | P1-01~P1-11 |
### 2.3 验收标准
- [ ] 工具可通过名称调用
- [ ] 字段映射正确生效LLM 用 "年龄" → 实际调用 "dem_age"
- [ ] 硬规则拦截生效
- [ ] **[V2.9.1]** AutoMapper 可解析 REDCap Data Dictionary
- [ ] **[V2.9.1]** LLM 可生成字段映射建议
- [ ] 测试覆盖率 > 80%
---
## 2.5 Phase 1.5: 隐私安全与自动化工具P0 合规必需)
> **⚠️ 重要**:此阶段必须在 Phase 2 调用 LLM 之前完成!
> 临床数据包含患者隐私信息,未脱敏直接发送给 LLM 将违反数据保护法规。
### 2.5.1 目标
- 实现 PII 脱敏中间件
- 建立脱敏审计日志
- 确保合规性
### 2.5.2 任务清单
| 任务ID | 任务名称 | 优先级 | 状态 | 前置依赖 |
|--------|----------|--------|------|----------|
| P1.5-01 | 创建 `iit_pii_audit_log` 表 | **P0** | 待开始 | - |
| P1.5-02 | 实现 PII 识别正则库 | **P0** | 待开始 | - |
| P1.5-03 | 实现 `AnonymizerService.mask()` | **P0** | 待开始 | P1.5-01, P1.5-02 |
| P1.5-04 | 实现 `AnonymizerService.unmask()` | **P0** | 待开始 | P1.5-03 |
| P1.5-05 | 实现脱敏映射加密存储 | 高 | 待开始 | P1.5-03 |
| P1.5-06 | 集成到 ChatService 调用链 | **P0** | 待开始 | P1.5-04 |
| P1.5-07 | 单元测试:各类 PII 识别 | 高 | 待开始 | P1.5-02 |
| P1.5-08 | 端到端测试:脱敏还原完整流程 | 高 | 待开始 | P1.5-01~P1.5-06 |
### 2.5.3 验收标准
- [ ] 身份证号正确识别并脱敏18位
- [ ] 手机号正确识别并脱敏11位
- [ ] 中文姓名正确识别并脱敏2-4字
- [ ] 病历号正确识别并脱敏
- [ ] LLM 收到的 Payload 不包含任何 PII
- [ ] LLM 回复正确还原占位符
- [ ] 审计日志正确记录(加密存储)
### 2.5.4 PII 脱敏流程
```
用户输入: "张三身份证420101199001011234今天血压偏高"
↓ AnonymizerService.mask()
LLM 收到: "[PATIENT_1](身份证[ID_CARD_1])今天血压偏高"
↓ 同时写入 iit_pii_audit_log加密存储映射表
↓ LLM 处理
LLM 返回: "[PATIENT_1] 的血压需要关注..."
↓ AnonymizerService.unmask()
用户看到: "张三 的血压需要关注..."
```
---
## 3. Phase 2: SOP 引擎 + 热记忆
### 3.1 目标
@@ -260,6 +317,8 @@ gantt
| **[V2.9]** 用户多意图混乱 | 任务遗漏 | ReAct Prompt 多意图拆分 | ✅ |
| **[V2.9]** 回复不符用户偏好 | 体验差 | 反馈循环 + 用户画像 | ✅ |
| **[V2.9]** 主动提醒打扰用户 | 用户投诉 | 最佳通知时间 + 个性化 | ✅ |
| **[V2.9.1]** 患者隐私泄露给 LLM | **法律风险** | PII 脱敏中间件 + 审计日志 | ✅ |
| **[V2.9.1]** 字段映射配置繁琐 | 效率低 | AutoMapper LLM 语义匹配 | ✅ |
---

View File

@@ -0,0 +1,883 @@
# IIT Manager Agent 实时质控系统开发计划
> **文档版本:** v1.1
> **创建日期:** 2026-02-07
> **最后更新:** 2026-02-07
> **参考文档:**
> - `docs/02-通用能力层/Postgres-Only异步任务处理指南.md`
> - `docs/03-业务模块/IIT Manager Agent/05-测试文档/IIT Manager Agent V2.9 补充:业务规则与数据治理细则.md`
---
## 📋 概述
### 背景
当前 IIT Manager Agent 的质控功能存在以下问题:
1. **无持久化**:每次查询需要实时调用 REDCap API 并执行质控规则
2. **规则管理繁琐**:需要手动配置所有质控规则
3. **缺乏审计信息**:不知道数据谁录入、何时录入
4. **AI响应慢**:每次都要实时查询和计算
### 目标
建立一套完整的实时质控系统:
1. **字段自动同步**:从 REDCap 元数据自动同步字段并生成默认规则
2. **实时质控存储**:每次数据录入后自动执行质控并存储结果
3. **录入汇总跟踪**:同步记录入组时间、录入人、表单完成状态
4. **分级告警**:严重问题立即推送,非紧急问题汇总推送
5. **AI快速响应**AI 直接查询质控结果表,无需实时计算
---
## 🎯 核心设计原则
### 双模式质控策略Skill 分层配置)
针对 "录了3张表" vs "录了10张表" 的不同质控需求,采用 **Skill 分层配置策略**,无需配置复杂的前置条件。
#### 策略 A单表实时质控 (Real-time / Form-based)
| 属性 | 说明 |
|------|------|
| **触发** | Webhook (DET) |
| **上下文** | `instrument` 参数限制了范围 |
| **逻辑** | 系统自动只加载 `formName` 匹配的规则,无需人工配置 preconditions |
| **场景** | 刚录完"人口学",系统只检查"年龄"**绝不会报"实验室检查缺失"的错** |
```typescript
// 实时质控:只加载当前表单的规则
async executeFormQc(projectId: string, recordId: string, instrument: string) {
// ✅ 根据 instrument 自动过滤规则
const rules = await getRulesByForm(projectId, instrument);
// 只质控这张表单涉及的字段
return executeRules(rules, record);
}
```
#### 策略 B全案定时质控 (Daily Batch / Holistic)
| 属性 | 说明 |
|------|------|
| **触发** | Cron Job (每日凌晨) |
| **上下文** | 全量数据 |
| **逻辑** | 加载跨表规则Cross-Form Logic |
| **依赖处理** | **逻辑守卫 (Logic Guard)**:规则逻辑内部判断,未录入则静默跳过 |
```typescript
// ✅ 正确:逻辑守卫模式
// 不要在 JSON 里配 dependencies: ['lab_test']
// 而是直接在规则逻辑里写判断
const crossFormRule = {
id: 'age_lab_consistency',
name: '年龄与实验室结果一致性检查',
logic: {
"if": [
// 逻辑守卫:如果 lab_test 表单未完成,直接跳过(返回 true = 通过)
{ "!": { "var": "lab_test_complete" } },
true, // 未录入 = 跳过,不报错
// 否则执行实际检查
{ "and": [
{ ">=": [{ "var": "age" }, 18] },
{ "<=": [{ "var": "creatinine" }, 1.5] }
]}
]
}
};
```
### 质控日志记录策略:仅新增,不覆盖
**原则**:每次质控都 **新增记录**,而不是覆盖。
**原因**
1. **审计轨迹 (Audit Trail)**
- 调出周一的 Log发现当时 `age` 字段是空的(规则没触发)
- 周五补录了 `16`(触发了规则)
- **这能证明系统是清白的**
2. **趋势分析**
- P001 患者在入组初期有 10 个错误
- 经过 3 次修改后,错误降为 0
- **这展示了数据质量提升的过程**
```typescript
// ✅ 正确:每次质控都新增记录
await prisma.iitQcLog.create({
data: {
projectId,
recordId,
formName: instrument,
status,
issues,
ruleVersion,
triggeredBy,
createdAt: new Date(), // 每次都是新记录
}
});
// ❌ 错误:覆盖模式会丢失历史
await prisma.iitQcLog.upsert({ ... }); // 不要用 upsert
```
---
## 🏗️ 架构设计
### 整体架构
```
┌──────────────────────────────────────────────────────────────────────────┐
│ CRC 在 REDCap 录入数据 │
└─────────────────────────────────────┬────────────────────────────────────┘
▼ DET Webhook (含 instrument 参数)
┌──────────────────────────────────────────────────────────────────────────┐
│ 同步层 (WebhookController) │
│ - 接收 HTTP 请求 │
│ - 校验签名 │
│ - 防抖检查 (pg-boss singletonKey) │
│ - 推入队列(携带 instrument 参数) │
│ - 立即返回 200 OK (< 10ms) │
└─────────────────────────────────────┬────────────────────────────────────┘
▼ pg-boss 队列
┌──────────────────────────────────────────────────────────────────────────┐
│ 异步层 (QcWorker) - 一次处理,两个产出 │
│ │
│ ┌──────────────────────────────────────────────────────────────────────┐│
│ │ 1. 拉取记录数据 (RedcapAdapter) ││
│ └──────────────────────────────────────────────────────────────────────┘│
│ │ │
│ ┌─────────────┴─────────────┐ │
│ ▼ ▼ │
│ ┌─────────────────────┐ ┌─────────────────────┐ │
│ │ 2a. 更新录入汇总表 │ │ 2b. 执行单表质控 │ │
│ │ iit_record_summary │ │ (只评估当前表单规则) │ │
│ │ - 入组时间 │ │ 根据 instrument 过滤 │ │
│ │ - 录入人 │ └─────────────────────┘ │
│ │ - 表单完成状态 │ │ │
│ └─────────────────────┘ ▼ │
│ │ ┌─────────────────────┐ │
│ │ │ 2c. 新增质控日志 │ │
│ │ │ iit_qc_logs │ │
│ │ │ (仅新增,不覆盖) │ │
│ │ └─────────────────────┘ │
│ │ │ │
│ └─────────────┬─────────────┘ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────┐│
│ │ 3. 主动干预 ││
│ │ - 🔴 RED: 立即推送企业微信 ││
│ │ - 🟡 YELLOW: 存入每日摘要队列 ││
│ │ - 🟢 GREEN: 仅记录日志 ││
│ └─────────────────────────────────────────────────────────────┘│
└──────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────┴─────────────────────────┐
▼ ▼
┌───────────────────────────────┐ ┌───────────────────────────────┐
│ 每日定时任务 (Cron Job) │ │ AI Agent 查询层 │
│ - 全案定时质控(跨表规则) │ │ - 查询 iit_qc_logs毫秒级
│ - 逻辑守卫模式处理依赖 │ │ - 查询 iit_record_summary │
│ - 生成每日摘要报告 │ │ - 查询 iit_qc_project_stats │
└───────────────────────────────┘ └───────────────────────────────┘
```
---
## 📦 数据库设计
### 新增表
#### 1. iit_qc_logs质控日志表
质控结果存储,**仅新增,不覆盖**,保留完整审计轨迹。
```prisma
model IitQcLog {
id String @id @default(uuid())
projectId String @map("project_id")
recordId String @map("record_id")
eventId String? @map("event_id")
// 质控类型
qcType String @map("qc_type") // 'form' | 'holistic'
formName String? @map("form_name") // 单表质控时记录表单名
// 核心结果
status String // 'PASS' | 'FAIL' | 'WARNING'
// 字段级详情 (JSONB)
// 格式: [{ field: "age", rule: "range_check", level: "RED", message: "..." }, ...]
issues Json @default("[]")
// 规则统计
rulesEvaluated Int @default(0) @map("rules_evaluated") // 实际评估的规则数
rulesSkipped Int @default(0) @map("rules_skipped") // 逻辑守卫跳过的规则数
rulesPassed Int @default(0) @map("rules_passed")
rulesFailed Int @default(0) @map("rules_failed")
// 规则版本(用于历史追溯)
ruleVersion String @map("rule_version")
// 入排标准检查(全案质控时填充)
inclusionPassed Boolean? @map("inclusion_passed")
exclusionPassed Boolean? @map("exclusion_passed")
// 审计信息
triggeredBy String @map("triggered_by") // 'webhook' | 'manual' | 'batch'
createdAt DateTime @default(now()) @map("created_at")
// 索引 - 支持历史查询和趋势分析
@@index([projectId, recordId, createdAt]) // 查询某记录的质控历史
@@index([projectId, status, createdAt]) // 查询某状态的质控记录
@@index([projectId, qcType, createdAt]) // 按质控类型查询
@@map("iit_qc_logs")
@@schema("iit_schema")
}
```
**设计说明**
- **无唯一约束**:同一 `projectId + recordId` 可以有多条记录
- **`qcType`**:区分单表实时质控 (`form`) 和全案定时质控 (`holistic`)
- **`rulesSkipped`**:逻辑守卫跳过的规则数,用于分析数据完整度
#### 2. iit_record_summary录入汇总表
**一次处理,两个产出**:与质控同时更新,记录入组和录入进度。
```prisma
model IitRecordSummary {
id String @id @default(uuid())
projectId String @map("project_id")
recordId String @map("record_id")
// 入组信息
enrolledAt DateTime? @map("enrolled_at") // 首次录入时间 = 入组时间
enrolledBy String? @map("enrolled_by") // 入组录入人REDCap username
// 最新录入信息
lastUpdatedAt DateTime @map("last_updated_at")
lastUpdatedBy String? @map("last_updated_by")
lastFormName String? @map("last_form_name") // 最后更新的表单
// 表单完成状态 (JSONB)
// 格式: { "demographics": 2, "baseline": 1, "visit1": 0 }
// 0=未开始, 1=进行中, 2=完成
formStatus Json @default("{}") @map("form_status")
// 数据完整度
totalForms Int @default(0) @map("total_forms")
completedForms Int @default(0) @map("completed_forms")
completionRate Float @default(0) @map("completion_rate") // 0-100%
// 最新质控状态(冗余存储,查询更快)
latestQcStatus String? @map("latest_qc_status") // 'PASS' | 'FAIL' | 'WARNING'
latestQcAt DateTime? @map("latest_qc_at")
// 更新次数(用于趋势分析)
updateCount Int @default(0) @map("update_count")
// 时间戳
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// 唯一约束 - 每个记录只有一条汇总
@@unique([projectId, recordId])
@@index([projectId, enrolledAt])
@@index([projectId, latestQcStatus])
@@index([projectId, completionRate])
@@map("iit_record_summary")
@@schema("iit_schema")
}
```
**设计说明**
- **使用 upsert**:与 `iit_qc_logs` 不同,汇总表使用覆盖模式
- **`enrolledAt`**:首次录入时自动设置,后续不更新(入组时间锁定)
- **`formStatus`**JSONB 存储各表单完成状态,便于前端展示进度条
#### 3. iit_qc_project_stats项目级汇总表
用于 Dashboard 快速展示,避免每次 COUNT。
```prisma
model IitQcProjectStats {
id String @id @default(uuid())
projectId String @unique @map("project_id")
// 汇总统计
totalRecords Int @default(0) @map("total_records")
passedRecords Int @default(0) @map("passed_records")
failedRecords Int @default(0) @map("failed_records")
warningRecords Int @default(0) @map("warning_records")
// 入排标准统计
inclusionMet Int @default(0) @map("inclusion_met")
exclusionMet Int @default(0) @map("exclusion_met")
// 录入进度统计
avgCompletionRate Float @default(0) @map("avg_completion_rate")
// 更新时间
updatedAt DateTime @updatedAt @map("updated_at")
@@map("iit_qc_project_stats")
@@schema("iit_schema")
}
```
#### 4. iit_field_mapping字段映射表- 增强
```prisma
model IitFieldMapping {
id String @id @default(uuid())
projectId String @map("project_id")
// REDCap 字段信息
fieldName String @map("field_name")
fieldLabel String @map("field_label")
fieldType String @map("field_type")
formName String @map("form_name")
// 验证规则(从 REDCap 元数据提取)
validation String? @map("validation")
validationMin String? @map("validation_min")
validationMax String? @map("validation_max")
choices String? @map("choices")
required Boolean @default(false)
// 别名LLM 友好名称)
alias String?
// 规则来源标记
ruleSource String? @map("rule_source") // 'auto' | 'manual'
// 时间戳
syncedAt DateTime @default(now()) @map("synced_at")
// 唯一约束
@@unique([projectId, fieldName])
@@index([projectId])
@@index([projectId, formName]) // 按表单查询字段
@@map("iit_field_mapping")
@@schema("iit_schema")
}
```
---
## 🔧 核心模块设计
### 1. 字段同步服务 (FieldSyncService)
```typescript
// backend/src/modules/iit-manager/services/FieldSyncService.ts
export class FieldSyncService {
/**
* 从 REDCap 同步字段元数据
*
* 流程:
* 1. 调用 RedcapAdapter.exportMetadata()
* 2. 解析元数据,提取字段信息(含 formName
* 3. 更新 iit_field_mapping 表
* 4. 自动生成默认质控规则(标记 ruleSource = 'auto'
*/
async syncFields(projectId: string): Promise<SyncResult> {
const adapter = await getRedcapAdapter(projectId);
const metadata = await adapter.exportMetadata();
for (const field of metadata) {
await prisma.iitFieldMapping.upsert({
where: { projectId_fieldName: { projectId, fieldName: field.field_name } },
create: {
projectId,
fieldName: field.field_name,
fieldLabel: field.field_label,
fieldType: field.field_type,
formName: field.form_name, // ⭐ 关键:用于单表质控规则匹配
validation: field.text_validation_type_or_show_slider_number,
validationMin: field.text_validation_min,
validationMax: field.text_validation_max,
choices: field.select_choices_or_calculations,
required: field.required_field === 'y',
},
update: { /* ... */ },
});
}
}
/**
* 自动生成默认质控规则
*
* 根据 REDCap 元数据自动生成:
* - 范围规则from validation_min/max
* - 必填规则from required
* - 枚举规则from choices
*
* 规则携带 formName用于单表质控时过滤
*/
async generateDefaultRules(projectId: string): Promise<GeneratedRule[]> {
const fields = await prisma.iitFieldMapping.findMany({ where: { projectId } });
const rules: GeneratedRule[] = [];
for (const field of fields) {
// 范围规则
if (field.validationMin || field.validationMax) {
rules.push({
id: `${field.fieldName}_range`,
name: `${field.fieldLabel}范围检查`,
formName: field.formName, // ⭐ 规则绑定到表单
source: 'auto',
logic: { /* JSON Logic */ },
});
}
}
return rules;
}
}
```
### 2. 质控 Worker (QcWorker)
**关键改动**:根据 `instrument` 参数过滤规则,一次处理更新两个表。
```typescript
// backend/src/modules/iit-manager/workers/qcWorker.ts
import { jobQueue } from '@/common/jobs';
import { logger } from '@/common/logging';
interface QcJob {
projectId: string;
recordId: string;
eventId?: string;
instrument?: string; // ⭐ Webhook 携带的表单名
username?: string; // ⭐ REDCap 录入人
triggeredBy: 'webhook' | 'manual' | 'batch';
}
export function registerQcWorker() {
logger.info('[QcWorker] Registering worker');
jobQueue.process<QcJob>('iit_quality_check', async (job) => {
const { projectId, recordId, eventId, instrument, username, triggeredBy } = job.data;
logger.info('[QcWorker] Processing', { jobId: job.id, recordId, instrument });
try {
// 1. 从 REDCap 获取记录数据
const adapter = await getRedcapAdapter(projectId);
const record = await adapter.getRecordById(recordId);
// ========== 产出1更新录入汇总表 ==========
await updateRecordSummary({
projectId,
recordId,
username,
formName: instrument,
record,
});
// ========== 产出2执行单表质控 ==========
// ⭐ 策略 A根据 instrument 过滤规则
const rules = instrument
? await getRulesByForm(projectId, instrument) // 只加载当前表单的规则
: await getAllRules(projectId); // batch 任务加载全部规则
const ruleEngine = createHardRuleEngine(rules);
const qcResult = ruleEngine.execute(recordId, record);
// 3. 新增质控日志(仅新增,不覆盖)
await prisma.iitQcLog.create({
data: {
projectId,
recordId,
eventId,
qcType: instrument ? 'form' : 'holistic',
formName: instrument,
status: qcResult.overallStatus,
issues: qcResult.results.filter(r => r.status !== 'PASS'),
rulesEvaluated: qcResult.summary.totalRules,
rulesSkipped: qcResult.summary.skipped || 0,
rulesPassed: qcResult.summary.passed,
rulesFailed: qcResult.summary.failed,
ruleVersion: await getRuleVersion(projectId),
triggeredBy,
}
});
// 4. 更新汇总统计
await updateProjectStats(projectId);
// 5. 主动干预
await handleAlerts(projectId, recordId, qcResult);
logger.info('[QcWorker] ✅ Job completed', { jobId: job.id });
return { success: true, recordId, status: qcResult.overallStatus };
} catch (error: any) {
logger.error('[QcWorker] ❌ Job failed', {
jobId: job.id,
error: error.message
});
throw error;
}
});
logger.info('[QcWorker] ✅ Worker registered: iit_quality_check');
}
/**
* 更新录入汇总表
*/
async function updateRecordSummary(params: {
projectId: string;
recordId: string;
username?: string;
formName?: string;
record: Record<string, any>;
}) {
const { projectId, recordId, username, formName, record } = params;
// 获取现有汇总
const existing = await prisma.iitRecordSummary.findUnique({
where: { projectId_recordId: { projectId, recordId } }
});
// 计算表单完成状态
const formStatus = calculateFormStatus(record);
const totalForms = Object.keys(formStatus).length;
const completedForms = Object.values(formStatus).filter(s => s === 2).length;
await prisma.iitRecordSummary.upsert({
where: { projectId_recordId: { projectId, recordId } },
create: {
projectId,
recordId,
enrolledAt: new Date(), // 首次录入 = 入组时间
enrolledBy: username,
lastUpdatedAt: new Date(),
lastUpdatedBy: username,
lastFormName: formName,
formStatus,
totalForms,
completedForms,
completionRate: (completedForms / totalForms) * 100,
updateCount: 1,
},
update: {
// 入组时间不更新
lastUpdatedAt: new Date(),
lastUpdatedBy: username,
lastFormName: formName,
formStatus,
completedForms,
completionRate: (completedForms / totalForms) * 100,
updateCount: { increment: 1 },
},
});
}
```
### 3. 每日定时质控服务 (DailyQcService)
**策略 B全案定时质控使用逻辑守卫处理依赖。**
```typescript
// backend/src/modules/iit-manager/services/DailyQcService.ts
export class DailyQcService {
/**
* 每日凌晨执行全案质控
*
* 特点:
* - 加载所有规则,包括跨表规则
* - 使用逻辑守卫处理依赖表单未完成的情况
*/
async executeHolisticQc(projectId: string) {
const records = await redcapAdapter.exportRecords();
for (const record of records) {
// 推入队列,分批处理
await jobQueue.push('iit_quality_check', {
projectId,
recordId: record.record_id,
triggeredBy: 'batch',
// 不传 instrument加载全部规则
}, {
singletonKey: `batch-qc-${projectId}-${record.record_id}`,
});
}
}
}
// 跨表规则示例:使用逻辑守卫
const crossFormRules = [
{
id: 'age_lab_consistency',
name: '年龄与实验室结果一致性检查',
formName: null, // 跨表规则,不绑定单一表单
logic: {
"if": [
// 逻辑守卫:检查依赖表单是否完成
{ "!": { "var": "lab_test_complete" } },
{ "__skip": true }, // 返回特殊标记,表示跳过
// 实际检查逻辑
{ "and": [
{ ">=": [{ "var": "age" }, 18] },
{ "<=": [{ "var": "creatinine" }, 1.5] }
]}
]
}
}
];
```
### 4. 告警服务 (AlertService)
```typescript
// backend/src/modules/iit-manager/services/AlertService.ts
export class AlertService {
/**
* 处理告警分级
*/
async handleAlerts(projectId: string, recordId: string, qcResult: QCResult) {
const redIssues = qcResult.results.filter(r => r.severity === 'error');
const yellowIssues = qcResult.results.filter(r => r.severity === 'warning');
// 🔴 RED: 立即推送
if (redIssues.length > 0) {
await this.sendImmediateAlert(projectId, recordId, redIssues);
}
// 🟡 YELLOW: 存入每日摘要队列
if (yellowIssues.length > 0) {
await this.addToDailyDigest(projectId, recordId, yellowIssues);
}
// 🟢 GREEN: 只记录日志,不推送
}
/**
* 立即推送企业微信告警
*/
private async sendImmediateAlert(
projectId: string,
recordId: string,
issues: QCRuleResult[]
) {
const message = this.formatAlertMessage(recordId, issues);
const piUserId = await this.getProjectPiUserId(projectId);
await wechatService.sendTextMessage(piUserId, message);
}
/**
* 每日摘要定时发送17:00
*/
async sendDailyDigest(projectId: string) {
// 由定时任务触发
}
}
---
## 📋
### Phase 1: 字段同步与自动规则 2
| | | | |
|------|------|--------|------|
| 1.1 | `iit_field_mapping` `formName` | P0 | |
| 1.2 | `FieldSyncService.syncFields()` | P0 | |
| 1.3 | `FieldSyncService.generateDefaultRules()` formName | P0 | |
| 1.4 | "同步字段" | P1 | |
| 1.5 | `source` `formName` | P1 | |
### Phase 2: 实时质控与录入汇总 3
| | | | |
|------|------|--------|------|
| 2.1 | `iit_qc_logs` | P0 | |
| 2.2 | `iit_record_summary` | P0 | |
| 2.3 | `iit_qc_project_stats` | P0 | |
| 2.4 | `WebhookController` `instrument` `username` | P0 | |
| 2.5 | `QcWorker` | P0 | |
| 2.6 | `updateRecordSummary()` | P0 | |
| 2.7 | `getRulesByForm()` | P0 | |
| 2.8 | `updateProjectStats()` | P1 | |
| 2.9 | JSONB | P1 | |
### Phase 3: AI Agent 2
| | | | |
|------|------|--------|------|
| 3.1 | `queryQcLogs()` | P0 | |
| 3.2 | `getRecordSummary()` | P0 | |
| 3.3 | `getProjectQcStats()` | P0 | |
| 3.4 | `ChatService` | P0 | |
| 3.5 | "哪些记录有问题" | P1 | |
| 3.6 | "记录10质控历史" | P1 | |
| 3.7 | "最近入组的患者" | P1 | |
### Phase 4: 每日定时质控 2
| | | | |
|------|------|--------|------|
| 4.1 | `DailyQcService.executeHolisticQc()` | P0 | |
| 4.2 | | P0 | |
| 4.3 | 2:00 | P0 | |
| 4.4 | | P1 | |
### Phase 5: 主动干预 2
| | | | |
|------|------|--------|------|
| 5.1 | `AlertService.handleAlerts()` | P0 | |
| 5.2 | | P0 | |
| 5.3 | | P1 | |
| 5.4 | 17:00 | P1 | |
| 5.5 | | P2 | |
### Phase 6: 批量回溯 1
| | | | |
|------|------|--------|------|
| 6.1 | `BatchQcJob` | P1 | |
| 6.2 | 50 | P1 | |
| 6.3 | "重新质控" | P2 | |
---
## 🛡
### 1.
**** pg-boss
```typescript
// ✅ 质控日志:仅新增,不需要 upsert
// 幂等性由 pg-boss singletonKey 保证
await prisma.iitQcLog.create({
data: { projectId, recordId, ... }
});
```
**录入汇总表(覆盖模式)**:使用 upsert 确保重试安全。
```typescript
// ✅ 录入汇总:使用 upsert
await prisma.iitRecordSummary.upsert({
where: { projectId_recordId: { projectId, recordId } },
create: { enrolledAt: new Date(), ... },
update: { lastUpdatedAt: new Date(), ... },
});
```
### 2. pg-boss 防抖
```typescript
// WebhookController.ts
await jobQueue.push('iit_quality_check',
{ projectId, recordId, triggeredBy: 'webhook' },
{
singletonKey: `qc-${projectId}-${recordId}`,
singletonSeconds: 300, // 5分钟防抖
}
);
```
### 3. Payload 精简
```typescript
// ✅ 只存 ID不存大数据
await jobQueue.push('iit_quality_check', {
projectId: 'xxx',
recordId: '10',
triggeredBy: 'webhook',
});
// ❌ 禁止存大数据
await jobQueue.push('iit_quality_check', {
recordData: { ... }, // 禁止!
});
```
### 4. 任务过期时间
```typescript
// 质控任务 15 分钟过期
await jobQueue.push('iit_quality_check', data, {
expireInSeconds: 15 * 60,
});
// 批量任务 1 小时过期
await jobQueue.push('iit_batch_qc', data, {
expireInSeconds: 60 * 60,
});
```
---
## 📊 性能预期
| 指标 | 当前 | 优化后 | 改善 |
|------|------|--------|------|
| Webhook 响应时间 | 2-5秒 | < 50ms | ✅ -99% |
| AI 查询响应时间 | 3-5秒调REDCap | < 200ms查本地表 | ✅ -95% |
| 批量质控 1000条 | 不支持 | ~5分钟分片 | ✅ 新增 |
| 并发 Webhook | 可能重复执行 | 防抖去重 | ✅ 修复 |
---
## ✅ 检查清单
### 数据库
- [ ] `iit_field_mapping` 表已创建(含 `formName` 字段)
- [ ] `iit_qc_logs` 表已创建(支持仅新增模式)
- [ ] `iit_record_summary` 表已创建
- [ ] `iit_qc_project_stats` 表已创建
- [ ] JSONB 索引已添加
### 双模式质控
- [ ] 单表质控:根据 `instrument` 过滤规则
- [ ] 全案质控:逻辑守卫模式处理依赖
- [ ] 质控规则绑定 `formName`
### 异步处理
- [ ] `QcWorker` 已注册
- [ ] 队列名称使用下划线(`iit_quality_check`
- [ ] Worker 在 `jobQueue.start()` 之后注册
- [ ] 防抖配置已添加
### 安全规范
- [ ] 质控日志:仅新增,不覆盖
- [ ] 录入汇总:使用 `upsert`
- [ ] Payload只存 ID
- [ ] 错误处理:直接 `throw error`
### 功能完整性
- [ ] 字段同步功能可用
- [ ] 实时质控存储正常
- [ ] 录入汇总更新正常
- [ ] AI 查询质控表正常
- [ ] AI 查询录入汇总正常
- [ ] 告警推送正常
- [ ] 每日定时质控正常
### 趋势分析
- [ ] 可查询记录的质控历史
- [ ] 可分析数据质量提升过程
- [ ] 审计轨迹完整
---
**维护者**: IIT Manager Agent 开发团队
**最后更新**: 2026-02-07
**文档状态**: ✅ 已完成v1.1 更新双模式质控策略)

View File

@@ -1,10 +1,10 @@
# IIT Manager Agent V2.6 综合开发计划
> **版本:** V2.9(极简架构 + SOP状态机 + 双脑路由 + 三层记忆 + 主动性增强)
> **版本:** V2.9.1(极简架构 + SOP状态机 + 双脑路由 + 三层记忆 + 主动性增强 + 隐私合规
> **日期:** 2026-02-05
> **团队规模:** 2人
> **预估周期:** 6周
> **核心目标:** 实现数据质控 Agent 的完整闭环 + 智能化交互 + 长期记忆 + 主动提醒 + 个性化
> **核心目标:** 实现数据质控 Agent 的完整闭环 + 智能化交互 + 长期记忆 + 主动提醒 + 个性化 + **隐私合规**
---
@@ -63,8 +63,32 @@
| **V2.4 SOP状态机** | 粗粒度 SOP 节点 + 节点内 ReAct | ✅ 认可 |
| **V2.8 记忆系统** | 三层记忆(流水账 + 热记忆 + 历史书) | ✅ 认可 |
| **V2.9 主动性增强** | Cron Skill + 用户画像 + 反馈循环 | ✅ 认可 |
| **V2.9.1 隐私合规** | PII 脱敏中间件 + REDCap Schema 自动对齐 | ✅ 认可 |
### 1.0 V2.9 核心增强(新
### 1.0 V2.9.1 隐私合规增强P0 必需
> **⚠️ 重要**:临床数据包含大量患者隐私信息,在调用第三方 LLM 之前**必须脱敏**
```
┌─────────────────────────────────────────────────────────────────────┐
│ V2.9.1 隐私合规能力 │
├─────────────────────────────────┬───────────────────────────────────┤
│ 🔒 AnonymizerService │ 🔧 AutoMapperService │
│ PII 脱敏中间件 │ REDCap Schema 自动对齐 │
│ - 身份证号脱敏 │ - Data Dictionary 解析 │
│ - 手机号脱敏 │ - LLM 语义映射 │
│ - 中文姓名脱敏 │ - 人工确认 UI │
│ - 审计日志加密存储 │ - 效率提升 8-16x │
└─────────────────────────────────┴───────────────────────────────────┘
```
| 能力 | 实现方式 | 价值 |
|------|----------|------|
| **PII 脱敏** | `AnonymizerService.mask/unmask()` + 正则库 | 合规必需,防止隐私泄露 |
| **审计日志** | `iit_pii_audit_log` 表 + 加密存储 | 事后合规审计 |
| **Schema 自动对齐** | `AutoMapperService` + LLM 语义匹配 | 字段配置效率提升 8-16x |
### 1.1 V2.9 核心增强
> **目标**:让 Agent 从"被动应答"进化为"主动协作",同时根据用户反馈持续优化
@@ -325,3 +349,4 @@ backend/src/modules/iit-manager/
| V2.6.1 | 2026-02-05 | 整合团队风险审查建议;拆分为多个专项文档 |
| V2.6.2 | 2026-02-05 | 简化表结构:删除 `iit_user_preferences``iit_patient_notes`(合并到 `project_memory` |
| V2.9 | 2026-02-05 | 主动性增强Cron Skill、用户画像、反馈循环、多意图处理 |
| V2.9.1 | 2026-02-05 | 隐私合规PII 脱敏中间件、REDCap Schema 自动对齐工具 |