# 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, context?: { projectId: string } ): Promise { 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 { 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 { // 字段映射 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 { 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 { // 根据 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 { 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: /(?; // { "[PATIENT_1]": "张三" } piiCount: number; piiTypes: string[]; } interface UnmaskingContext { maskingMap: Record; } 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 { const maskingMap: Record = {}; 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 { 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 { 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 完整实现 ```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 { 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 { 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 { // ⚠️ 第一层:正则快速通道(< 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 { 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 { 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 { 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 { // 保存反馈到数据库 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 { 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 { 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 { 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 { 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 { // 获取最近一周的对话 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 { 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 { return conversations.reduce((acc, conv) => { if (!acc[conv.userId]) acc[conv.userId] = []; acc[conv.userId].push(conv); return acc; }, {} as Record); } private async updateProfileFromConversations( projectId: string, userId: string, conversations: any[] ): Promise { // 分析对话模式,推断用户偏好 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