Files
AIclinicalresearch/docs/03-业务模块/IIT Manager Agent/04-开发计划/03-服务层实现指南.md
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

48 KiB
Raw Blame History

IIT Manager Agent 服务层实现指南

版本: V2.9
更新日期: 2026-02-05
关联文档: IIT Manager Agent V2.6 综合开发计划

V2.9.1 更新

  • 新增 ProfilerService 用户画像服务
  • ChatService 增加反馈循环功能
  • SchedulerService 支持 Cron Skill 触发
  • 新增 AnonymizerServicePII 脱敏中间件P0 合规必需)
  • 新增 AutoMapperServiceREDCap 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 DictionaryCSV/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