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>
48 KiB
48 KiB
IIT Manager Agent 服务层实现指南
版本: V2.9
更新日期: 2026-02-05
关联文档: IIT Manager Agent V2.6 综合开发计划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 工具定义
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 完整实现
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 识别正则库
// 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 完整实现
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
// 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 Dictionary(CSV/JSON)
- 使用 LLM 进行语义映射
- 提供管理后台确认界面
2.6.2 完整实现
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
// 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 完整实现
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 扩展实现
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 完整实现
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 完整实现
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 完整实现
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