Files
HaHafeng 5db4a7064c 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>
2026-02-07 21:56:11 +08:00

1582 lines
48 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# IIT Manager Agent 服务层实现指南
> **版本:** V2.9
> **更新日期:** 2026-02-05
> **关联文档:** [IIT Manager Agent V2.6 综合开发计划](./IIT%20Manager%20Agent%20V2.6%20综合开发计划.md)
>
> **V2.9.1 更新**
> - 新增 `ProfilerService` 用户画像服务
> - `ChatService` 增加反馈循环功能
> - `SchedulerService` 支持 Cron Skill 触发
> - **新增 `AnonymizerService`**PII 脱敏中间件P0 合规必需)
> - **新增 `AutoMapperService`**REDCap Schema 自动对齐工具
---
## 1. 服务层总览
| 服务 | 职责 | Phase |
|------|------|-------|
| `ToolsService` | 统一工具管理(字段映射 + 执行) | 1 |
| `AnonymizerService` | **PII 脱敏中间件P0 合规必需)** | 1.5 |
| `AutoMapperService` | **REDCap Schema 自动对齐** | 1 |
| `ChatService` | 消息路由(双脑入口)+ 反馈收集 | 2 |
| `IntentService` | 意图识别(混合路由) | 5 |
| `MemoryService` | 记忆管理V2.8 架构) | 2-3 |
| `SchedulerService` | 定时任务调度 + Cron Skill | 4 |
| `ReportService` | 报告生成 | 4 |
| `ProfilerService` | 用户画像管理V2.9 | 4 |
| `VisionService` | 视觉识别(延后) | 6 |
```
┌─────────────────────────────────────────────────────────────────────┐
│ 路由层 (Router) │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────┐ │
│ │ ChatService │ │VisionService │ │ API Routes │ │Scheduler │ │
│ │ (文本路由) │ │ (图片识别) │ │ (REST API) │ │ (定时) │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ └──────────┘ │
└─────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ 工具层 (ToolsService) │
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
│ │read_clinical_data│ │write_clinical_data│ │search_protocol │ │
│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
```
---
## 2. ToolsService - 统一工具管理
> **文件路径**: `backend/src/modules/iit-manager/services/ToolsService.ts`
### 2.1 核心职责
- 定义可供 LLM 调用的工具
- 实现字段名映射(第一层防御)
- 实现空结果兜底(第三层防御)
- 区分只读/读写工具权限
### 2.2 工具定义
```typescript
export const TOOL_DEFINITIONS = [
// ===== 只读工具ReAct 可用)=====
{
name: "read_clinical_data",
description: "读取 REDCap 临床数据。可指定患者ID和字段列表",
parameters: {
type: "object",
properties: {
record_id: { type: "string", description: "患者ID如 P001" },
fields: {
type: "array",
items: { type: "string" },
description: "要读取的字段名列表,如 ['age', 'gender', 'ecog']"
}
},
required: ["record_id", "fields"]
}
},
{
name: "search_protocol",
description: "检索研究方案文档。返回与问题相关的方案内容",
parameters: {
type: "object",
properties: {
query: { type: "string", description: "搜索关键词或问题" }
},
required: ["query"]
}
},
{
name: "get_project_stats",
description: "获取项目统计数据,如入组人数、完成率等",
parameters: {
type: "object",
properties: {
project_id: { type: "string" },
stat_type: {
type: "string",
enum: ["enrollment", "completion", "ae_summary", "query_status"]
}
},
required: ["project_id", "stat_type"]
}
},
{
name: "check_visit_window",
description: "检查访视是否在方案允许的时间窗口内",
parameters: {
type: "object",
properties: {
record_id: { type: "string" },
visit_id: { type: "string" },
actual_date: { type: "string", format: "date" }
},
required: ["record_id", "visit_id", "actual_date"]
}
},
// ===== 读写工具(仅 SOP 可用)=====
{
name: "write_clinical_data",
description: "写入 REDCap 临床数据(需人工确认)",
parameters: {
type: "object",
properties: {
record_id: { type: "string" },
form_name: { type: "string" },
data: { type: "object" }
},
required: ["record_id", "form_name", "data"]
}
},
{
name: "manage_issue",
description: "创建或更新质疑Query",
parameters: {
type: "object",
properties: {
action: { type: "string", enum: ["create", "close", "update"] },
record_id: { type: "string" },
field: { type: "string" },
message: { type: "string" }
},
required: ["action", "record_id", "field", "message"]
}
}
];
```
### 2.3 完整实现
```typescript
import { prisma } from '../../common/prisma';
import { RedcapAdapter } from '../adapters/RedcapAdapter';
import { DifyClient } from '../../common/rag/DifyClient';
export class ToolsService {
private redcap: RedcapAdapter;
private dify: DifyClient;
constructor(redcap: RedcapAdapter, dify: DifyClient) {
this.redcap = redcap;
this.dify = dify;
}
/**
* 获取工具定义(供 LLM 使用)
*/
getToolDefinitions() {
return TOOL_DEFINITIONS;
}
/**
* 统一执行入口
*/
async executeTool(
toolName: string,
args: Record<string, any>,
context?: { projectId: string }
): Promise<any> {
const projectId = context?.projectId || args.project_id;
switch (toolName) {
case 'read_clinical_data':
return this.readClinicalData(projectId, args.record_id, args.fields);
case 'search_protocol':
return this.searchProtocol(projectId, args.query);
case 'get_project_stats':
return this.getProjectStats(args.project_id, args.stat_type);
case 'check_visit_window':
return this.checkVisitWindow(args);
case 'write_clinical_data':
return this.writeClinicalData(args);
case 'manage_issue':
return this.manageIssue(args);
default:
throw new Error(`未知工具: ${toolName}`);
}
}
// ===== 字段映射(第一层防御)=====
private async mapFields(projectId: string, fields: string[]): Promise<string[]> {
const mappings = await prisma.iitFieldMapping.findMany({
where: { projectId }
});
const mappingDict = Object.fromEntries(
mappings.map(m => [m.aliasName.toLowerCase(), m.actualName])
);
return fields.map(f => {
const mapped = mappingDict[f.toLowerCase()];
if (mapped) {
console.log(`[ToolsService] 字段映射: ${f} -> ${mapped}`);
}
return mapped || f;
});
}
// ===== 工具实现 =====
private async readClinicalData(
projectId: string,
recordId: string,
fields: string[]
): Promise<any> {
// 字段映射
const mappedFields = await this.mapFields(projectId, fields);
try {
const data = await this.redcap.getRecord(recordId, mappedFields);
// ⚠️ 空结果兜底(第三层防御)
if (!data || Object.keys(data).length === 0) {
return {
success: false,
message: `未找到患者 ${recordId} 的相关数据。请检查患者ID是否正确。`,
hint: '可用 get_project_stats 查看项目中的患者列表'
};
}
return { success: true, data };
} catch (error) {
return {
success: false,
message: `读取数据失败: ${error.message}`,
hint: '请检查字段名是否正确'
};
}
}
private async searchProtocol(projectId: string, query: string): Promise<any> {
const results = await this.dify.retrieve(projectId, query);
if (!results || results.length === 0) {
return {
success: false,
message: '未找到相关的方案内容',
hint: '可以尝试更具体的关键词'
};
}
return {
success: true,
results: results.slice(0, 3).map(r => ({
content: r.content,
source: r.metadata?.source,
relevance: r.score
}))
};
}
private async getProjectStats(projectId: string, statType: string): Promise<any> {
// 根据 statType 查询不同统计数据
switch (statType) {
case 'enrollment':
return this.getEnrollmentStats(projectId);
case 'completion':
return this.getCompletionStats(projectId);
case 'ae_summary':
return this.getAESummary(projectId);
case 'query_status':
return this.getQueryStatus(projectId);
default:
return { error: `未知的统计类型: ${statType}` };
}
}
private async checkVisitWindow(args: {
record_id: string;
visit_id: string;
actual_date: string;
}): Promise<any> {
const baseline = await this.getBaselineDate(args.record_id);
const window = await this.getVisitWindow(args.visit_id);
const actualDays = this.daysBetween(baseline, args.actual_date);
const inWindow = actualDays >= window.minDays && actualDays <= window.maxDays;
return {
inWindow,
expectedRange: `Day ${window.minDays} - Day ${window.maxDays}`,
actualDay: actualDays,
deviation: inWindow ? 0 : Math.min(
Math.abs(actualDays - window.minDays),
Math.abs(actualDays - window.maxDays)
)
};
}
// ... 其他工具实现省略
}
```
---
## 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`
### 3.1 核心职责
- **混合路由**:正则快速通道 + LLM 后备
- 识别用户意图类型QC_TASK / QA_QUERY / UNCLEAR
- 提取实体信息record_id, visit 等)
- 生成追问句(当信息不全时)
### 3.2 意图类型
| 意图类型 | 描述 | 路由目标 |
|----------|------|----------|
| `QC_TASK` | 质控任务 | SopEngine |
| `QA_QUERY` | 模糊查询 | ReActEngine |
| `PROTOCOL_QA` | 方案问题 | DifyClient |
| `REPORT` | 报表请求 | ReportService |
| `HELP` | 帮助请求 | 静态回复 |
| `UNCLEAR` | 信息不全 | 追问 |
### 3.3 完整实现
```typescript
import { LLMFactory } from '../../common/llm/adapters/LLMFactory';
export interface IntentResult {
type: 'QC_TASK' | 'QA_QUERY' | 'PROTOCOL_QA' | 'REPORT' | 'HELP' | 'UNCLEAR';
confidence: number;
entities: {
record_id?: string;
visit?: string;
date_range?: string;
};
source: 'fast_path' | 'llm' | 'fallback';
missing_info?: string;
clarification_question?: string;
}
export class IntentService {
private llm = LLMFactory.create('qwen');
/**
* 主入口:混合路由
*/
async detect(message: string, history: Message[]): Promise<IntentResult> {
// ⚠️ 第一层:正则快速通道(< 10ms
const fastResult = this.fastPathDetect(message);
if (fastResult) {
console.log('[IntentService] 命中快速通道');
return fastResult;
}
// 第二层LLM 智能识别(复杂长句)
return this.llmDetect(message, history);
}
/**
* 带降级的检测LLM 不可用时回退)
*/
async detectWithFallback(message: string, history: Message[]): Promise<IntentResult> {
try {
return await this.detect(message, history);
} catch (error) {
console.warn('[IntentService] LLM 不可用,回退到关键词匹配');
return this.keywordFallback(message);
}
}
// ===== 快速通道:正则匹配 =====
private fastPathDetect(message: string): IntentResult | null {
// 质控任务
const qcMatch = message.match(/^(质控|检查|校验|QC)\s*(ID|患者|病人)?[=:]?\s*([A-Z]?\d+)/i);
if (qcMatch) {
return {
type: 'QC_TASK',
confidence: 0.9,
entities: { record_id: qcMatch[3] },
source: 'fast_path'
};
}
// 报表/报告
if (/^(报表|报告|周报|日报|统计)/.test(message)) {
return { type: 'REPORT', confidence: 0.9, entities: {}, source: 'fast_path' };
}
// 帮助
if (/^(帮助|help|怎么用|使用说明)$/i.test(message)) {
return { type: 'HELP', confidence: 1.0, entities: {}, source: 'fast_path' };
}
// 不匹配,走 LLM
return null;
}
// ===== LLM 智能识别 =====
private async llmDetect(message: string, history: Message[]): Promise<IntentResult> {
const prompt = `你是一个临床研究助手的"分诊台"。请分析用户输入,返回 JSON。
用户输入: "${message}"
分类标准:
1. QC_TASK: 明确的质控、检查、录入指令(如"检查P001的入排标准"
2. QA_QUERY: 模糊的查询、分析、统计问题(如"查下那个发烧的病人是谁"
3. PROTOCOL_QA: 关于研究方案的问题(如"访视窗口是多少天"
4. UNCLEAR: 指代不清,缺少关键信息(如"他怎么样了?"
返回格式:
{
"type": "QC_TASK" | "QA_QUERY" | "PROTOCOL_QA" | "UNCLEAR",
"confidence": 0.0-1.0,
"entities": { "record_id": "...", "visit": "..." },
"missing_info": "如果 UNCLEAR说明缺什么信息",
"clarification_question": "如果 UNCLEAR生成追问句"
}`;
const response = await this.llm.chat([
{ role: 'system', content: prompt },
...history.slice(-3),
{ role: 'user', content: message }
]);
try {
const jsonMatch = response.content.match(/\{[\s\S]*\}/);
const result = JSON.parse(jsonMatch?.[0] || '{}');
result.source = 'llm';
return result;
} catch (e) {
// 解析失败,回退
return this.keywordFallback(message);
}
}
// ===== 降级策略 =====
private keywordFallback(message: string): IntentResult {
if (/质控|检查|校验|QC|入排/.test(message)) {
return { type: 'QC_TASK', confidence: 0.6, entities: {}, source: 'fallback' };
}
if (/方案|访视|窗口|知情同意/.test(message)) {
return { type: 'PROTOCOL_QA', confidence: 0.6, entities: {}, source: 'fallback' };
}
return { type: 'QA_QUERY', confidence: 0.5, entities: {}, source: 'fallback' };
}
}
```
---
## 4. ChatService - 消息路由
> **文件路径**: `backend/src/modules/iit-manager/services/ChatService.ts`(扩展现有)
### 4.1 核心职责
- 接收企业微信消息
- 路由到正确的引擎SOP / ReAct
- 集成记忆系统
- 实现追问机制
- **V2.9 新增**:收集用户反馈(👍/👎)
### 4.2 扩展实现
```typescript
export class ChatService {
private intentService: IntentService;
private sopEngine: SopEngine;
private reactEngine: ReActEngine;
private memoryService: MemoryService;
private sessionMemory: SessionMemory;
private wechatService: WechatService;
private profilerService: ProfilerService; // V2.9 新增
async handleMessage(userId: string, message: string): Promise<string> {
const projectId = await this.getUserProject(userId);
// ⚠️ 立即发送"正在思考..."状态(解决延迟感)
await this.wechatService.sendTypingStatus(userId);
// 获取会话历史
const history = this.sessionMemory.getHistory(userId);
// 意图识别
const intent = await this.intentService.detectWithFallback(message, history);
// 保存用户消息到长期记忆
await this.memoryService.saveConversation({
projectId, userId, role: 'user', content: message, intent: intent.type
});
// ===== 追问机制 =====
if (intent.type === 'UNCLEAR') {
const clarification = intent.clarification_question
|| `请问您能具体说明一下吗?`;
this.sessionMemory.addMessage(userId, 'assistant', clarification);
return clarification;
}
// ===== 获取记忆上下文V2.8 =====
const memoryContext = await this.memoryService.getContextForPrompt(projectId, intent);
// ===== 路由到引擎 =====
let response: string;
if (intent.type === 'QC_TASK') {
// 走 SOP 引擎
const skill = await this.getSkillConfig(projectId, 'qc_process');
const result = await this.sopEngine.run(skill.config, {
recordId: intent.entities.record_id,
projectId
});
response = this.formatSopResult(result);
} else if (intent.type === 'QA_QUERY') {
// 走 ReAct 引擎
const result = await this.reactEngine.run(message, {
history,
memoryContext,
projectId
});
// ⚠️ 保存 Trace仅供 Admin 调试,不发给用户)
await this.reactEngine.saveTrace(projectId, userId, message, result);
response = result.content;
} else if (intent.type === 'PROTOCOL_QA') {
// 走知识库
const results = await this.difyClient.retrieve(projectId, message);
response = this.formatProtocolResults(results);
} else if (intent.type === 'HELP') {
response = this.getHelpMessage();
} else {
response = '抱歉,我不太理解您的问题。您可以说"帮助"查看使用说明。';
}
// 保存助手回复到长期记忆(返回对话 ID 用于反馈关联)
const conversationId = await this.memoryService.saveConversation({
projectId, userId, role: 'assistant', content: response
});
// 更新会话记忆
this.sessionMemory.addMessage(userId, 'assistant', response);
return response;
}
// ===== V2.9 新增:反馈循环 =====
/**
* 处理用户反馈(👍/👎)
*/
async handleFeedback(
conversationId: string,
feedback: 'thumbs_up' | 'thumbs_down',
reason?: string
): Promise<void> {
// 保存反馈到数据库
await prisma.iitConversationHistory.update({
where: { id: conversationId },
data: { feedback, feedbackReason: reason }
});
// 如果是负反馈,更新用户画像
if (feedback === 'thumbs_down' && reason) {
const conversation = await prisma.iitConversationHistory.findUnique({
where: { id: conversationId }
});
if (conversation) {
await this.profilerService.updateFromFeedback(
conversation.projectId,
conversation.userId,
reason
);
}
}
}
}
```
---
## 5. SchedulerService - 定时任务调度
> **文件路径**: `backend/src/modules/iit-manager/services/SchedulerService.ts`
### 5.1 核心职责
- 基于 pg-boss 实现定时任务
- 调度周报生成
- 调度记忆卷叠V2.8
- **V2.9 新增**Cron Skill 主动触发(访视提醒等)
### 5.2 完整实现
```typescript
import PgBoss from 'pg-boss';
export class SchedulerService {
private boss: PgBoss;
private reportService: ReportService;
private memoryService: MemoryService;
private wechatService: WechatService;
async init() {
this.boss = new PgBoss(process.env.DATABASE_URL!);
await this.boss.start();
// 注册任务处理器
await this.boss.work('weekly-report', this.handleWeeklyReport.bind(this));
await this.boss.work('memory-rollup', this.handleMemoryRollup.bind(this));
await this.boss.work('sop-continue', this.handleSopContinue.bind(this));
await this.boss.work('cron-skill', this.handleCronSkill.bind(this)); // V2.9 新增
await this.boss.work('profile-refresh', this.handleProfileRefresh.bind(this)); // V2.9 新增
// 配置定时任务
// 每周一早上 9 点生成周报
await this.boss.schedule('weekly-report', '0 9 * * 1', { projectId: 'all' });
// 每天凌晨 2 点执行记忆卷叠
await this.boss.schedule('memory-rollup', '0 2 * * *', { projectId: 'all' });
// 每天凌晨 3 点刷新用户画像V2.9
await this.boss.schedule('profile-refresh', '0 3 * * *', { projectId: 'all' });
// V2.9 新增:注册所有 Cron Skill
await this.registerCronSkills();
}
/**
* V2.9 新增:注册数据库中的 Cron Skill
*/
private async registerCronSkills(): Promise<void> {
const cronSkills = await prisma.iitSkill.findMany({
where: {
triggerType: 'cron',
isActive: true
}
});
for (const skill of cronSkills) {
if (skill.cronSchedule) {
await this.boss.schedule(
`cron-skill`,
skill.cronSchedule,
{ skillId: skill.id, projectId: skill.projectId }
);
console.log(`[Scheduler] 注册 Cron Skill: ${skill.name} (${skill.cronSchedule})`);
}
}
}
private async handleWeeklyReport(job: Job) {
const { projectId } = job.data;
// 生成周报
const report = await this.reportService.generateWeeklyReport(projectId);
// 发送到管理员群
await this.wechatService.sendToAdmins(report);
// 保存到历史书V2.8
await this.memoryService.saveWeeklyReport(projectId, report);
}
private async handleMemoryRollup(job: Job) {
const { projectId } = job.data;
// V2.8 记忆卷叠:将一周的对话总结为周报
await this.memoryService.rollupWeeklyMemory(projectId);
// 更新热记忆
await this.memoryService.refreshHotMemory(projectId);
}
private async handleSopContinue(job: Job) {
const { taskRunId } = job.data;
// 恢复执行 SOP 任务
await this.sopEngine.continueFromCheckpoint(taskRunId);
}
/**
* V2.9 新增:处理 Cron Skill 触发
*/
private async handleCronSkill(job: Job) {
const { skillId, projectId } = job.data;
const skill = await prisma.iitSkill.findUnique({
where: { id: skillId }
});
if (!skill || !skill.isActive) {
console.log(`[Scheduler] Skill ${skillId} 已禁用或不存在,跳过`);
return;
}
console.log(`[Scheduler] 执行 Cron Skill: ${skill.name}`);
// 获取项目的用户画像,确定通知对象
const profiles = await this.profilerService.getAllProfiles(projectId);
// 运行 SOP 流程
const result = await this.sopEngine.run(skill.config, {
projectId,
triggerType: 'cron',
profiles
});
// 根据结果发送通知
if (result.status === 'COMPLETED' && result.output) {
for (const profile of profiles) {
// 检查用户的最佳通知时间(简化版:假设 Cron 已按时间配置)
await this.wechatService.sendToUser(
profile.userId,
this.formatNotification(result.output, profile)
);
}
}
}
/**
* V2.9 新增:刷新用户画像
*/
private async handleProfileRefresh(job: Job) {
const { projectId } = job.data;
await this.profilerService.refreshProfiles(projectId);
console.log(`[Scheduler] 用户画像刷新完成: ${projectId}`);
}
/**
* 根据用户画像格式化通知内容
*/
private formatNotification(content: string, profile: UserProfile): string {
// 根据用户偏好调整通知格式
if (profile.preference?.includes('简洁')) {
// 简洁模式:只保留关键信息
const lines = content.split('\n').filter(l => l.trim());
return lines.slice(0, 3).join('\n') + (lines.length > 3 ? '\n...' : '');
}
return content;
}
}
```
---
## 6. ReportService - 报告生成
> **文件路径**: `backend/src/modules/iit-manager/services/ReportService.ts`
### 6.1 完整实现
```typescript
export class ReportService {
private redcap: RedcapAdapter;
private memoryService: MemoryService;
async generateWeeklyReport(projectId: string): Promise<string> {
const stats = await this.getProjectStats(projectId);
const weekRange = this.getWeekRange();
// 获取本周的关键对话
const keyConversations = await this.memoryService.getWeeklyKeyConversations(projectId);
const report = `
📊 **${stats.projectName} 周报**
📅 ${weekRange}
**入组进度**
- 本周新入组:${stats.newEnrollments}
- 累计入组:${stats.totalEnrollments} / ${stats.targetEnrollments}
- 完成率:${stats.completionRate}%
**数据质量**
- 待处理质疑:${stats.pendingQueries}
- 本周关闭质疑:${stats.closedQueries}
- 方案偏离:${stats.protocolDeviations}
**AE/SAE**
- 本周新增 AE${stats.newAEs}
- 本周新增 SAE${stats.newSAEs}
**本周关键决策**
${keyConversations.decisions.map(d => `- ${d.date}: ${d.content}`).join('\n')}
**下周重点**
${stats.upcomingVisits.map(v => `- ${v.patientId}: ${v.visitName} (${v.dueDate})`).join('\n')}
`;
return report.trim();
}
private getWeekRange(): string {
const now = new Date();
const weekStart = new Date(now);
weekStart.setDate(now.getDate() - now.getDay() + 1);
const weekEnd = new Date(weekStart);
weekEnd.setDate(weekStart.getDate() + 6);
return `${this.formatDate(weekStart)} ~ ${this.formatDate(weekEnd)}`;
}
private formatDate(date: Date): string {
return date.toISOString().split('T')[0];
}
}
```
---
## 7. ProfilerService - 用户画像管理V2.9
> **文件路径**: `backend/src/modules/iit-manager/services/ProfilerService.ts`
### 7.1 核心职责
- 管理用户画像(偏好、关注点、最佳通知时间)
- 根据反馈自动调整偏好
- 为主动消息提供个性化参数
### 7.2 完整实现
```typescript
import { prisma } from '../../common/prisma';
import { LLMFactory } from '../../common/llm/adapters/LLMFactory';
interface UserProfile {
role: string; // PI | CRC | CRA | PM
preference: string; // 简洁汇报 | 详细步骤
focusAreas: string[]; // AE | 入组进度 | 访视安排
bestNotifyTime: string; // HH:mm 格式
restrictions: string[]; // 禁令列表
feedbackStats: {
thumbsUp: number;
thumbsDown: number;
};
}
export class ProfilerService {
private llm = LLMFactory.create('qwen');
/**
* 获取用户画像(从 project_memory 中解析)
*/
async getUserProfile(projectId: string, userId: string): Promise<UserProfile | null> {
const memory = await prisma.iitProjectMemory.findUnique({
where: { projectId }
});
if (!memory) return null;
// 从 Markdown 中解析用户画像
return this.parseProfileFromMarkdown(memory.content, userId);
}
/**
* 根据反馈更新用户偏好
*/
async updateFromFeedback(
projectId: string,
userId: string,
feedbackReason: string
): Promise<void> {
const memory = await prisma.iitProjectMemory.findUnique({
where: { projectId }
});
if (!memory) return;
// 根据反馈原因推断偏好调整
const adjustment = this.inferAdjustment(feedbackReason);
// 使用 LLM 更新 Markdown 中的用户画像
const updatedContent = await this.updateProfileInMarkdown(
memory.content,
userId,
adjustment
);
await prisma.iitProjectMemory.update({
where: { projectId },
data: {
content: updatedContent,
lastUpdatedBy: 'profiler_job'
}
});
}
/**
* 周期性刷新用户画像(每日凌晨运行)
*/
async refreshProfiles(projectId: string): Promise<void> {
// 获取最近一周的对话
const weekAgo = new Date();
weekAgo.setDate(weekAgo.getDate() - 7);
const conversations = await prisma.iitConversationHistory.findMany({
where: {
projectId,
createdAt: { gte: weekAgo }
},
orderBy: { createdAt: 'desc' }
});
// 按用户分组
const userConversations = this.groupByUser(conversations);
// 对每个用户更新画像
for (const [userId, convs] of Object.entries(userConversations)) {
await this.updateProfileFromConversations(projectId, userId, convs);
}
}
// ===== 私有方法 =====
private inferAdjustment(reason: string): { key: string; action: string } {
switch (reason) {
case 'too_long':
return { key: 'preference', action: '更简洁的回复' };
case 'inaccurate':
return { key: 'restriction', action: '注意数据准确性' };
case 'unclear':
return { key: 'preference', action: '更详细的解释' };
default:
return { key: 'feedback', action: `用户不满意: ${reason}` };
}
}
private async updateProfileInMarkdown(
content: string,
userId: string,
adjustment: { key: string; action: string }
): Promise<string> {
const prompt = `请更新以下 Markdown 中 ${userId} 的用户画像部分,增加一条偏好记录:${adjustment.action}
原内容:
${content}
请只返回更新后的完整 Markdown不要添加任何解释。`;
const response = await this.llm.chat([
{ role: 'user', content: prompt }
]);
return response.content;
}
private parseProfileFromMarkdown(content: string, userId: string): UserProfile | null {
// 使用正则从 Markdown 中提取用户画像
const userSection = content.match(
new RegExp(`## .*\\(user_id: ${userId}\\)[\\s\\S]*?(?=##|$)`)
);
if (!userSection) return null;
const section = userSection[0];
return {
role: this.extractField(section, '角色') || 'CRC',
preference: this.extractField(section, '偏好') || '默认',
focusAreas: this.extractList(section, '关注点'),
bestNotifyTime: this.extractField(section, '最佳通知时间') || '09:00',
restrictions: this.extractList(section, '禁令'),
feedbackStats: this.extractFeedbackStats(section)
};
}
private extractField(text: string, field: string): string | null {
const match = text.match(new RegExp(`\\*\\*${field}\\*\\*:\\s*(.+)`));
return match ? match[1].trim() : null;
}
private extractList(text: string, field: string): string[] {
const match = text.match(new RegExp(`\\*\\*${field}\\*\\*:\\s*(.+)`));
if (!match) return [];
return match[1].split(/[,、,]/).map(s => s.trim());
}
private extractFeedbackStats(text: string): { thumbsUp: number; thumbsDown: number } {
const match = text.match(/👍\s*(\d+)\s*\/\s*👎\s*(\d+)/);
return {
thumbsUp: match ? parseInt(match[1]) : 0,
thumbsDown: match ? parseInt(match[2]) : 0
};
}
private groupByUser(conversations: any[]): Record<string, any[]> {
return conversations.reduce((acc, conv) => {
if (!acc[conv.userId]) acc[conv.userId] = [];
acc[conv.userId].push(conv);
return acc;
}, {} as Record<string, any[]>);
}
private async updateProfileFromConversations(
projectId: string,
userId: string,
conversations: any[]
): Promise<void> {
// 分析对话模式,推断用户偏好
const analysis = await this.analyzeConversationPatterns(conversations);
if (analysis.hasSignificantChanges) {
const memory = await prisma.iitProjectMemory.findUnique({
where: { projectId }
});
if (memory) {
const updatedContent = await this.updateProfileInMarkdown(
memory.content,
userId,
{ key: 'behavior', action: analysis.summary }
);
await prisma.iitProjectMemory.update({
where: { projectId },
data: {
content: updatedContent,
lastUpdatedBy: 'profiler_job'
}
});
}
}
}
private async analyzeConversationPatterns(conversations: any[]): Promise<{
hasSignificantChanges: boolean;
summary: string;
}> {
if (conversations.length < 5) {
return { hasSignificantChanges: false, summary: '' };
}
// 分析反馈统计
const thumbsUp = conversations.filter(c => c.feedback === 'thumbs_up').length;
const thumbsDown = conversations.filter(c => c.feedback === 'thumbs_down').length;
if (thumbsDown > thumbsUp && thumbsDown >= 3) {
return {
hasSignificantChanges: true,
summary: `近期负反馈较多(👎${thumbsDown}/👍${thumbsUp}),需调整回复策略`
};
}
return { hasSignificantChanges: false, summary: '' };
}
}
```
---
## 8. 服务依赖关系
```
┌──────────────────┐
│ ChatService │
└────────┬─────────┘
┌─────────────────────────┼─────────────────────────┐
│ │ │ │ │
▼ ▼ ▼ ▼ ▼
┌────────┐ ┌──────────┐ ┌──────────┐ ┌────────┐ ┌───────────┐
│Intent │ │Memory │ │Session │ │Profiler│ │Scheduler │
│Service │ │Service │ │Memory │ │Service │ │Service │
└────────┘ └──────────┘ └──────────┘ └────────┘ └───────────┘
│ │ │ │
▼ │ │ │
┌─────────────────────────────────────────────────────────┐
│ Engines │
│ ┌─────────────┐ ┌──────────────┐ ┌─────────────────┐ │
│ │ SopEngine │ │ ReActEngine │ │ SoftRuleEngine │ │
│ └─────────────┘ └──────────────┘ └─────────────────┘ │
└─────────────────────────┬───────────────────────────────┘
┌──────────────┐
│ToolsService │
└──────────────┘
┌────────────────┴────────────────┐
▼ ▼
┌──────────────┐ ┌──────────────┐
│RedcapAdapter │ │ DifyClient │
└──────────────┘ └──────────────┘
```
**V2.9 新增服务流**
```
SchedulerService ──[cron-skill]──> SopEngine ──> ProfilerService ──> WechatService
ChatService ──[feedback]──> MemoryService ──────────────┘
```
---
**文档维护人**AI Agent
**最后更新**2026-02-05