Files
AIclinicalresearch/docs/03-业务模块/IIT Manager Agent/04-开发计划/Phase1.5-AI对话能力开发计划.md
HaHafeng dfc472810b feat(iit-manager): Integrate Dify knowledge base for hybrid retrieval
Completed features:
- Created Dify dataset (Dify_test0102) with 2 processed documents
- Linked test0102 project with Dify dataset ID
- Extended intent detection to recognize query_protocol intent
- Implemented queryDifyKnowledge method (semantic search Top 5)
- Integrated hybrid retrieval (REDCap data + Dify documents)
- Fixed AI hallucination bugs (intent detection + API field path)
- Developed debugging scripts
- Completed end-to-end testing (5 scenarios passed)
- Generated comprehensive documentation (600+ lines)
- Updated development plans and module status

Technical highlights:
- Single project single knowledge base architecture
- Smart routing based on user intent
- Prevent AI hallucination by injecting real data/documents
- Session memory for multi-turn conversations
- Reused LLMFactory for DeepSeek-V3 integration

Bug fixes:
- Fixed intent detection missing keywords
- Fixed Dify API response field path error

Testing: All scenarios verified in WeChat production environment

Status: Fully tested and deployed
2026-01-04 15:44:11 +08:00

84 KiB
Raw Blame History

IIT Manager Agent - Phase 1.5 AI对话能力开发计划

版本: v3.0(极简版 + 上下文记忆 + Dify知识库
创建日期: 2026-01-03
最新更新: 2026-01-04
状态: 已完成含Dify集成
实际工作量: ~2天极简版 + Dify知识库
核心价值: PI可在企业微信中自然对话查询REDCap真实数据 + 研究方案文档
核心成就: REDCap数据集成 + 上下文记忆 + 解决LLM幻觉 + Dify知识库混合检索


🚀 极简版快速启动1天上线 通用能力层加速!

🎉 重大发现:通用能力层已完善!

平台现状2026-01-03调研结果

  • LLMFactory 完全就绪5种模型DeepSeek/Qwen/GPT-5/Claude单例模式零配置
  • ChatContainer 完全就绪Ant Design X组件已在Tool C验证~968行
  • 环境变量已配置DEEPSEEK_API_KEYQWEN_API_KEY
  • 成熟实践ASL、DC模块已大量使用稳定可靠

核心优势

// 后端LLM调用3行代码
import { LLMFactory } from '@/common/llm/adapters/LLMFactory.js';
const llm = LLMFactory.getAdapter('deepseek-v3');
const response = await llm.chat(messages, { temperature: 0.7 });

极简版功能范围

✅ Day 14-6小时: 实现基础对话 + 上下文记忆 + "typing"反馈
   - 复用LLMFactory0开发
   - 创建ChatService.ts2小时
   - 创建SessionMemory.ts2小时
   - 修改WechatCallbackController2小时
✅ Day 24-6小时: Dify知识库集成 + 混合检索2026-01-04完成
   - 关联项目与Dify知识库1小时
   - 集成Dify检索到ChatService2小时
   - 修复意图识别与数据注入bug2小时
   - 端到端测试与文档记录1小时
❌ 暂不实现: 周报生成、复杂Tool Calling

极简版架构(复用通用能力层)

PI提问 → Node.js → LLMFactory(deepseek-v3) → 生成回答 → 企业微信推送
                      ↓                ↑
                 SessionMemory    RedcapAdapter可选

关键决策

  • 🚀 复用LLMFactory(已有,零开发,推荐deepseek-v3
  • 只查REDCap数据已有RedcapAdapter复用即可
  • 不接Dify(减少依赖,加快开发)
  • 上下文记忆Node.js内存存最近3轮
  • 正在输入反馈(立即回"正在查询..."
  • 单步路由不用ReAct循环

预估工作量大幅降低2-3天 → 1天因为LLM调用层已完善


🎯 一、核心目标与价值

1.1 为什么需要AI对话能力

当前状态Day 3已完成

✅ REDCap录入数据 → Node.js捕获 → 企业微信推送通知

目标状态Phase 1.5

✅ PI在企业微信中提问 → AI理解意图 → 查询数据/文档 → 智能回答

核心价值

  • 🚀 主动查询PI不用等通知随时问"现在入组多少人?"
  • 📊 数据穿透实时查询REDCap数据患者详情、质控状态
  • 📚 知识检索查询研究方案、CRF表格、入排标准
  • 💡 智能理解:自然语言提问,无需记忆命令

📊 二、技术架构基于本地Dify

2.1 整体架构图

┌─────────────────────────────────────────────────┐
│              PI (企业微信)                       │
│  "P001患者符合入排标准吗"                      │
└───────────┬─────────────────────────────────────┘
            │
            ↓
┌─────────────────────────────────────────────────┐
│      Node.js 后端 (已有 WechatCallbackController) │
│  ┌──────────────────────────────────────────┐   │
│  │ 1. 接收消息 (handleCallback)              │   │
│  │ 2. 意图识别 (Intent Router)               │   │
│  │ 3. 工具调用 (Tool Executor)               │   │
│  │ 4. 企业微信推送 (WechatService)           │   │
│  └──────────────────────────────────────────┘   │
└───────────┬─────────────────────────────────────┘
            │
       ┌────┴────────┬───────────────┐
       ↓             ↓               ↓
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│  Dify        │ │  REDCap      │ │  PostgreSQL  │
│  (本地Docker)│ │  API         │ │  数据库      │
│              │ │              │ │              │
│ - 研究方案   │ │ - 患者数据   │ │ - 项目配置   │
│ - CRF表格    │ │ - 质控状态   │ │ - 审计日志   │
│ - 周报归档   │ │ - 不良反应   │ │ - 用户映射   │
└──────────────┘ └──────────────┘ └──────────────┘
  <50ms           <100ms           <20ms
  本地调用         本地调用         本地调用

2.2 关键技术决策

决策点 方案 原因
AI推理引擎 DeepSeek-V3 (API) 性价比高支持Function Calling
知识库 Dify本地Docker 已部署,无额外成本,延迟低
向量数据库 Dify内置Weaviate 免维护,开箱即用
路由策略 单步意图识别 MVP阶段简化不用ReAct循环
数据查询 RedcapAdapter 已有,直接复用

二、极简版开发计划2天

🎯 Day 1基础对话能力6小时

核心目标

让AI能回答用户问题只查REDCap数据

任务1.1创建SessionMemory30分钟

文件位置backend/src/modules/iit-manager/agents/SessionMemory.ts

/**
 * 会话记忆管理器(内存版)
 * 存储用户最近3轮对话用于上下文理解
 */
export class SessionMemory {
  // 内存存储:{ userId: ConversationHistory }
  private sessions: Map<string, ConversationHistory> = new Map();
  private readonly MAX_HISTORY = 3; // 只保留最近3轮

  /**
   * 添加对话记录
   */
  addMessage(userId: string, role: 'user' | 'assistant', content: string): void {
    if (!this.sessions.has(userId)) {
      this.sessions.set(userId, {
        userId,
        messages: [],
        createdAt: new Date(),
        updatedAt: new Date()
      });
    }

    const session = this.sessions.get(userId)!;
    session.messages.push({
      role,
      content,
      timestamp: new Date()
    });

    // 只保留最近3轮6条消息
    if (session.messages.length > this.MAX_HISTORY * 2) {
      session.messages = session.messages.slice(-this.MAX_HISTORY * 2);
    }

    session.updatedAt = new Date();
  }

  /**
   * 获取用户对话历史
   */
  getHistory(userId: string): ConversationMessage[] {
    const session = this.sessions.get(userId);
    return session?.messages || [];
  }

  /**
   * 获取用户上下文(最近一轮)
   */
  getContext(userId: string): string {
    const history = this.getHistory(userId);
    if (history.length === 0) return '';

    // 只取最近一轮对话
    const recentMessages = history.slice(-2);
    return recentMessages
      .map(m => `${m.role}: ${m.content}`)
      .join('\n');
  }

  /**
   * 清除用户会话
   */
  clearSession(userId: string): void {
    this.sessions.delete(userId);
  }

  /**
   * 清理过期会话超过1小时未使用
   */
  cleanupExpiredSessions(): void {
    const now = Date.now();
    const ONE_HOUR = 3600000;

    for (const [userId, session] of this.sessions.entries()) {
      if (now - session.updatedAt.getTime() > ONE_HOUR) {
        this.sessions.delete(userId);
      }
    }
  }
}

interface ConversationHistory {
  userId: string;
  messages: ConversationMessage[];
  createdAt: Date;
  updatedAt: Date;
}

interface ConversationMessage {
  role: 'user' | 'assistant';
  content: string;
  timestamp: Date;
}

// 全局单例
export const sessionMemory = new SessionMemory();

// 定时清理过期会话(每小时)
setInterval(() => {
  sessionMemory.cleanupExpiredSessions();
}, 3600000);

验收标准

  • 可存储对话历史
  • 可获取上下文
  • 自动清理过期会话

任务1.2创建ChatService2小时 复用LLMFactory

文件位置backend/src/modules/iit-manager/services/ChatService.ts

import { LLMFactory } from '../../../common/llm/adapters/LLMFactory.js';
import { Message } from '../../../common/llm/adapters/types.js';
import { logger } from '../../../common/logging/index.js';
import { sessionMemory } from '../agents/SessionMemory.js';

/**
 * AI对话服务复用通用能力层LLMFactory
 * 处理企业微信用户消息,支持上下文记忆
 */
export class ChatService {
  private llm;

  constructor() {
    // ⚡ 复用通用能力层LLMFactory零配置
    this.llm = LLMFactory.getAdapter('deepseek-v3');
  }

  /**
   * 识别用户意图(带上下文)
   */
  async route(
    userMessage: string,
    userId: string,
    projectId: string
  ): Promise<IntentRouteResult> {
    try {
      // 1. 获取上下文
      const context = sessionMemory.getContext(userId);

      logger.info('[SimpleIntentRouter] Routing with context', {
        message: userMessage.substring(0, 50),
        hasContext: !!context,
        userId
      });

      // 2. 构建Prompt包含上下文
      const systemPrompt = this.buildSystemPrompt();
      const userPrompt = context 
        ? `【上下文】\n${context}\n\n【当前问题】\n${userMessage}`
        : userMessage;

      // 3. 调用LLM
      const response = await this.llm.chat.completions.create({
        model: 'deepseek-chat',
        messages: [
          { role: 'system', content: systemPrompt },
          { role: 'user', content: userPrompt }
        ],
        tools: [this.getDataQueryTool()],
        tool_choice: 'auto',
        temperature: 0.1,
        max_tokens: 500
      });

      const message = response.choices[0].message;

      // 4. 如果LLM决定调用工具
      if (message.tool_calls && message.tool_calls.length > 0) {
        const toolCall = message.tool_calls[0];
        const toolArgs = JSON.parse(toolCall.function.arguments);

        // ✅ 上下文解析如果args中有代词尝试从上下文中解析
        if (context && this.hasPronouns(userMessage)) {
          toolArgs.patient_id = this.extractPatientIdFromContext(context, toolArgs);
        }

        return {
          needsToolCall: true,
          toolName: 'query_clinical_data',
          toolArgs,
          rawResponse: message
        };
      }

      // 5. 直接回答
      return {
        needsToolCall: false,
        directAnswer: message.content || '抱歉,我没有理解您的问题',
        rawResponse: message
      };
    } catch (error: any) {
      logger.error('[SimpleIntentRouter] Routing failed', {
        error: error.message
      });

      return {
        needsToolCall: false,
        directAnswer: '抱歉,我遇到了一些问题,请稍后再试',
        error: error.message
      };
    }
  }

  /**
   * 构建System Prompt
   */
  private buildSystemPrompt(): string {
    return `# 角色
你是临床研究项目助手帮助PI查询项目数据。

# 能力
你可以查询REDCap数据库包括
1. 项目统计(入组人数、数据完整率)
2. 患者详情(录入情况、基本信息)
3. 质控状态(数据问题)

# 上下文理解
- 如果用户说"他"、"这个患者"等代词,请根据【上下文】中提到的患者编号
- 如果上下文中没有患者编号,请要求用户提供

# 约束
- 严禁编造数据
- 只能查询REDCap数据不能查询文档
- 回答要简洁专业`;
  }

  /**
   * 定义数据查询工具
   */
  private getDataQueryTool(): any {
    return {
      type: "function",
      function: {
        name: "query_clinical_data",
        description: `查询REDCap临床数据。
适用场景:
- 问项目统计:现在入组多少人?数据质量如何?
- 问患者详情P001患者录完了吗有不良反应吗
- 问质控状态:有哪些质控问题?`,
        parameters: {
          type: "object",
          properties: {
            intent: {
              type: "string",
              enum: ["project_stats", "patient_detail", "qc_status"],
              description: `查询意图:
- project_stats: 项目统计
- patient_detail: 患者详情
- qc_status: 质控状态`
            },
            patient_id: {
              type: "string",
              description: "患者编号如P001当intent=patient_detail时必填"
            }
          },
          required: ["intent"]
        }
      }
    };
  }

  /**
   * 检查消息中是否有代词
   */
  private hasPronouns(message: string): boolean {
    const pronouns = ['他', '她', '这个患者', '该患者', '这位', '那位'];
    return pronouns.some(p => message.includes(p));
  }

  /**
   * 从上下文中提取患者ID
   */
  private extractPatientIdFromContext(context: string, toolArgs: any): string {
    // 简单正则提取患者编号
    const match = context.match(/P\d{3,}/);
    return match ? match[0] : toolArgs.patient_id;
  }
}

export interface IntentRouteResult {
  needsToolCall: boolean;
  toolName?: string;
  toolArgs?: any;
  directAnswer?: string;
  error?: string;
  rawResponse?: any;
}

验收标准

  • 可识别查询意图
  • 支持上下文理解(代词解析)
  • 错误处理完善

任务1.3简化ToolExecutor1.5小时)

文件位置backend/src/modules/iit-manager/agents/SimpleToolExecutor.ts

import { RedcapAdapter } from '../adapters/RedcapAdapter.js';
import { logger } from '../../../common/logging/index.js';
import { prisma } from '../../../config/database.js';

/**
 * 简化版工具执行器
 * 只执行REDCap数据查询
 */
export class SimpleToolExecutor {
  /**
   * 执行查询临床数据
   */
  async execute(
    toolArgs: {
      intent: 'project_stats' | 'patient_detail' | 'qc_status';
      patient_id?: string;
    },
    context: {
      projectId: string;
      userId: string;
    }
  ): Promise<ToolExecutionResult> {
    try {
      logger.info('[SimpleToolExecutor] Executing query', {
        intent: toolArgs.intent,
        patientId: toolArgs.patient_id,
        projectId: context.projectId
      });

      // 1. 获取项目配置
      const project = await prisma.iitProject.findUnique({
        where: { id: context.projectId },
        select: {
          name: true,
          redcapApiUrl: true,
          redcapApiToken: true
        }
      });

      if (!project) {
        return {
          success: false,
          data: null,
          error: '项目不存在'
        };
      }

      // 2. 初始化RedcapAdapter
      const redcap = new RedcapAdapter(
        project.redcapApiUrl,
        project.redcapApiToken
      );

      // 3. 根据intent执行查询
      switch (toolArgs.intent) {
        case 'project_stats':
          return await this.getProjectStats(redcap, project.name);

        case 'patient_detail':
          if (!toolArgs.patient_id) {
            return {
              success: false,
              data: null,
              error: '请提供患者编号P001'
            };
          }
          return await this.getPatientDetail(redcap, toolArgs.patient_id);

        case 'qc_status':
          return await this.getQCStatus(context.projectId);

        default:
          return {
            success: false,
            data: null,
            error: '未知的查询类型'
          };
      }
    } catch (error: any) {
      logger.error('[SimpleToolExecutor] Execution failed', {
        error: error.message
      });

      return {
        success: false,
        data: null,
        error: error.message
      };
    }
  }

  /**
   * 获取项目统计
   */
  private async getProjectStats(
    redcap: RedcapAdapter,
    projectName: string
  ): Promise<ToolExecutionResult> {
    const records = await redcap.exportRecords();

    return {
      success: true,
      data: {
        type: 'project_stats',
        projectName,
        stats: {
          totalRecords: records.length,
          enrolled: records.length,
          completed: records.filter((r: any) => r.complete === '2').length,
          dataQuality: this.calculateDataQuality(records)
        }
      }
    };
  }

  /**
   * 获取患者详情
   */
  private async getPatientDetail(
    redcap: RedcapAdapter,
    patientId: string
  ): Promise<ToolExecutionResult> {
    const records = await redcap.exportRecords([patientId]);

    if (records.length === 0) {
      return {
        success: false,
        data: null,
        error: `未找到患者 ${patientId}`
      };
    }

    const record = records[0];

    return {
      success: true,
      data: {
        type: 'patient_detail',
        patientId,
        details: {
          age: record.age,
          gender: record.gender,
          bmi: record.bmi,
          complete: record.complete === '2' ? '已完成' : '进行中',
          lastUpdate: new Date().toISOString()
        }
      }
    };
  }

  /**
   * 获取质控状态
   */
  private async getQCStatus(projectId: string): Promise<ToolExecutionResult> {
    const logs = await prisma.iitAuditLog.findMany({
      where: {
        projectId,
        actionType: 'quality_issue'
      },
      orderBy: { createdAt: 'desc' },
      take: 10
    });

    return {
      success: true,
      data: {
        type: 'qc_status',
        issueCount: logs.length,
        recentIssues: logs.map(log => ({
          recordId: log.entityId,
          issue: log.details,
          createdAt: log.createdAt
        }))
      }
    };
  }

  /**
   * 计算数据质量(简单算法)
   */
  private calculateDataQuality(records: any[]): string {
    if (records.length === 0) return '0%';

    const completedCount = records.filter((r: any) => r.complete === '2').length;
    const quality = (completedCount / records.length) * 100;

    return `${quality.toFixed(1)}%`;
  }
}

export interface ToolExecutionResult {
  success: boolean;
  data: any;
  error?: string;
}

验收标准

  • 可查询项目统计
  • 可查询患者详情
  • 可查询质控状态

任务1.4简化AnswerGenerator1小时

文件位置backend/src/modules/iit-manager/agents/SimpleAnswerGenerator.ts

import { ToolExecutionResult } from './SimpleToolExecutor.js';
import { logger } from '../../../common/logging/index.js';

/**
 * 简化版答案生成器
 * 使用模板生成回答不调用LLM节省成本
 */
export class SimpleAnswerGenerator {
  /**
   * 生成回答
   */
  generate(
    userQuestion: string,
    toolResult: ToolExecutionResult
  ): string {
    try {
      logger.info('[SimpleAnswerGenerator] Generating answer', {
        success: toolResult.success,
        dataType: toolResult.data?.type
      });

      // 如果工具执行失败
      if (!toolResult.success) {
        return this.generateErrorMessage(toolResult.error);
      }

      // 根据数据类型生成回答
      const dataType = toolResult.data.type;

      if (dataType === 'project_stats') {
        return this.generateProjectStatsAnswer(toolResult.data);
      } else if (dataType === 'patient_detail') {
        return this.generatePatientDetailAnswer(toolResult.data);
      } else if (dataType === 'qc_status') {
        return this.generateQCStatusAnswer(toolResult.data);
      }

      return '抱歉,我无法生成回答';
    } catch (error: any) {
      logger.error('[SimpleAnswerGenerator] Generation failed', {
        error: error.message
      });

      return '抱歉,回答生成失败';
    }
  }

  /**
   * 生成项目统计回答
   */
  private generateProjectStatsAnswer(data: any): string {
    const stats = data.stats;

    return `📊 **${data.projectName}项目统计**

✅ **入组人数**${stats.enrolled}✅ **完成病例**${stats.completed}✅ **数据质量**${stats.dataQuality}

💡 更新时间:${new Date().toLocaleString('zh-CN')}`;
  }

  /**
   * 生成患者详情回答
   */
  private generatePatientDetailAnswer(data: any): string {
    const details = data.details;

    return `👤 **患者 ${data.patientId} 详情**

📋 **基本信息**
- 年龄:${details.age || '未录入'}- 性别:${details.gender || '未录入'}
- BMI${details.bmi || '未录入'}

📊 **录入状态**
- ${details.complete}

💡 最后更新:${new Date().toLocaleString('zh-CN')}`;
  }

  /**
   * 生成质控状态回答
   */
  private generateQCStatusAnswer(data: any): string {
    const issues = data.recentIssues.slice(0, 5);
    let answer = `🔍 **质控状态**\n\n`;
    answer += `⚠️ **质控问题数**${data.issueCount}\n\n`;

    if (issues.length > 0) {
      answer += `📋 **最近问题**\n`;
      issues.forEach((issue: any, index: number) => {
        answer += `${index + 1}. 记录${issue.recordId}\n`;
      });
    } else {
      answer += `✅ 暂无质控问题`;
    }

    return answer;
  }

  /**
   * 生成错误提示
   */
  private generateErrorMessage(error?: string): string {
    return `❌ 查询失败

原因:${error || '未知错误'}

💡 您可以:
1. 稍后重试
2. 换个问法
3. 联系管理员`;
  }
}

验收标准

  • 回答格式友好
  • 支持Markdown
  • 错误提示清晰

任务1.5集成到WechatCallbackController1小时

修改文件backend/src/modules/iit-manager/controllers/WechatCallbackController.ts

在handleCallback方法中添加

import { sessionMemory } from '../agents/SessionMemory.js';
import { SimpleIntentRouter } from '../agents/SimpleIntentRouter.js';
import { SimpleToolExecutor } from '../agents/SimpleToolExecutor.js';
import { SimpleAnswerGenerator } from '../agents/SimpleAnswerGenerator.js';

class WechatCallbackController {
  private intentRouter: SimpleIntentRouter;
  private toolExecutor: SimpleToolExecutor;
  private answerGenerator: SimpleAnswerGenerator;

  constructor() {
    // ... 现有代码 ...
    this.intentRouter = new SimpleIntentRouter();
    this.toolExecutor = new SimpleToolExecutor();
    this.answerGenerator = new SimpleAnswerGenerator();
  }

  async handleCallback(request: FastifyRequest, reply: FastifyReply): Promise<void> {
    // ... 现有的验证、解密逻辑 ...

    // ✅ 立即返回success
    reply.send('success');

    // ✅ 异步处理新增AI对话
    setImmediate(async () => {
      try {
        const userMessage = decryptedData.Content;
        const userId = decryptedData.FromUserName;

        logger.info('📥 收到用户消息', {
          userId,
          message: userMessage.substring(0, 50)
        });

        // ✅ 立即发送"正在查询..."反馈
        await wechatService.sendTextMessage(
          userId,
          '🫡 正在查询,请稍候...'
        );

        // 1. 保存用户消息到会话记忆
        sessionMemory.addMessage(userId, 'user', userMessage);

        // 2. 获取用户的项目信息
        const userMapping = await prisma.iitUserMapping.findFirst({
          where: { wechatUserId: userId }
        });

        if (!userMapping) {
          await wechatService.sendTextMessage(
            userId,
            '⚠️ 您还未绑定项目,请联系管理员配置'
          );
          return;
        }

        // 3. 意图识别(带上下文)
        const routeResult = await this.intentRouter.route(
          userMessage,
          userId,
          userMapping.projectId
        );

        // 4. 如果直接回答
        if (!routeResult.needsToolCall) {
          const answer = routeResult.directAnswer!;
          await wechatService.sendTextMessage(userId, answer);
          sessionMemory.addMessage(userId, 'assistant', answer);
          return;
        }

        // 5. 执行工具
        const toolResult = await this.toolExecutor.execute(
          routeResult.toolArgs,
          {
            projectId: userMapping.projectId,
            userId
          }
        );

        // 6. 生成回答
        const answer = this.answerGenerator.generate(userMessage, toolResult);

        // 7. 发送回复
        await wechatService.sendMarkdownMessage(userId, answer);

        // 8. 保存AI回答到会话记忆
        sessionMemory.addMessage(userId, 'assistant', answer);

        // 9. 记录审计日志
        await prisma.iitAuditLog.create({
          data: {
            projectId: userMapping.projectId,
            actionType: 'wechat_user_query',
            operator: userId,
            entityId: userId,
            details: {
              question: userMessage,
              answer: answer.substring(0, 200),
              toolUsed: 'query_clinical_data',
              hasContext: !!sessionMemory.getContext(userId)
            }
          }
        });

        logger.info('✅ 回答发送成功', { userId });
      } catch (error: any) {
        logger.error('❌ 处理用户消息失败', {
          error: error.message
        });

        await wechatService.sendTextMessage(
          userId,
          '抱歉,我遇到了一些问题,请稍后再试'
        );
      }
    });
  }
}

验收标准

  • 可接收用户消息
  • 立即发送"正在查询..."
  • 正确识别意图
  • 正确执行工具
  • 正确发送回复
  • 上下文记忆生效

🎯 Day 2上下文优化 + 测试4小时

任务2.1上下文记忆优化1小时

增强SessionMemory支持患者ID提取

// 在SessionMemory中添加
/**
 * 从历史记录中提取最近提到的患者ID
 */
getLastPatientId(userId: string): string | null {
  const history = this.getHistory(userId);
  
  // 从最近的对话中倒序查找患者ID
  for (let i = history.length - 1; i >= 0; i--) {
    const message = history[i];
    const match = message.content.match(/P\d{3,}/);
    if (match) {
      return match[0];
    }
  }
  
  return null;
}

在SimpleIntentRouter中使用

// 如果用户说"他有不良反应吗?"自动填充patient_id
if (context && this.hasPronouns(userMessage) && !toolArgs.patient_id) {
  const lastPatientId = sessionMemory.getLastPatientId(userId);
  if (lastPatientId) {
    toolArgs.patient_id = lastPatientId;
    logger.info('[SimpleIntentRouter] 自动填充患者ID', {
      patientId: lastPatientId
    });
  }
}

任务2.2完整测试3小时

测试场景

// 场景1无上下文查询
{
  input: "现在入组多少人?",
  expectedIntent: "project_stats",
  expectedOutput: "📊 项目统计\n✅ 入组人数XX例"
}

// 场景2有上下文的多轮对话关键
{
  conversation: [
    {
      input: "帮我查一下P001的情况",
      expectedIntent: "patient_detail",
      expectedPatientId: "P001"
    },
    {
      input: "他有不良反应吗?", // ← 代词"他"
      expectedIntent: "patient_detail",
      expectedPatientId: "P001", // ← 自动填充
      expectedOutput: "应该包含P001"
    }
  ]
}

// 场景3正在输入反馈
{
  input: "现在入组多少人?",
  expectedFirstReply: "🫡 正在查询,请稍候...",
  expectedSecondReply: "📊 项目统计..."
}

// 场景4质控查询
{
  input: "有哪些质控问题?",
  expectedIntent: "qc_status",
  expectedOutput: "🔍 质控状态"
}

// 场景5闲聊
{
  input: "你好",
  expectedOutput: "您好!我是临床研究助手"
}

测试步骤

  1. 在企业微信中发送测试消息
  2. 验证是否收到"正在查询..."
  3. 验证最终回复内容
  4. 检查审计日志中的上下文标记
  5. 测试多轮对话的上下文理解

验收标准

  • 5个测试场景全部通过
  • "正在输入"反馈生效
  • 上下文记忆生效(代词解析)
  • 回复时间<3秒

📊 极简版成功标准

功能 验收标准 优先级
基础对话 可查询REDCap数据 🔴 P0
上下文记忆 支持最近3轮对话 🔴 P0
代词解析 "他"能自动识别患者 🔴 P0
正在输入反馈 立即回"正在查询..." 🔴 P0
回复延迟 <3秒 🔴 P0
意图识别准确率 >80% 🔴 P0

🎉 极简版vs完整版对比

功能 极简版 (2天) 完整版 (5天)
REDCap查询
上下文记忆 (内存3轮) (内存3轮)
正在输入反馈
Dify知识库
周报自动归档
文档查询

🗓️ 三、完整版开发计划5天可选

Day 1Dify环境配置与知识库创建8小时

任务1.1验证Dify本地环境1小时

检查项

# 1. 检查Dify容器状态
cd AIclinicalresearch/docker
docker-compose ps | grep dify

# 2. 访问Dify管理后台
# http://localhost/dify (或实际端口)

# 3. 获取API密钥
# Dify后台 → 设置 → API Keys → 创建

验收标准

  • Dify容器运行正常
  • 可访问管理后台
  • 获得API Key

任务1.2创建IIT Manager知识库2小时

操作步骤

  1. 创建知识库Dify后台操作

    名称IIT Manager - test0102项目
    类型:通用知识库
    Embedding模型text-embedding-3-small (OpenAI)
    分块策略智能分块500字符/块重叠50字符
    
  2. 上传测试文档

    • 上传1份CRF表格PDF/Word
    • 上传1份入排标准文档Markdown/Text
    • 上传1份研究方案摘要PDF
  3. 测试检索效果

    测试问题1"入组标准有哪些?"
    测试问题2"CRF表格中有哪些字段"
    测试问题3"研究终点是什么?"
    

验收标准

  • 知识库创建成功
  • 3份文档上传成功
  • 检索测试准确率>80%

产出

  • Dify知识库ID
  • API调用示例代码

任务1.3实现Dify API适配器3小时

文件位置backend/src/modules/iit-manager/adapters/DifyAdapter.ts

代码实现

import axios from 'axios';
import { logger } from '../../../common/logging/index.js';

/**
 * Dify API适配器
 * 用于与本地Dify Docker实例交互
 */
export class DifyAdapter {
  private baseUrl: string;
  private apiKey: string;
  private knowledgeBaseId: string;

  constructor(projectId: string) {
    // 从环境变量或数据库读取配置
    this.baseUrl = process.env.DIFY_API_URL || 'http://localhost/v1';
    this.apiKey = process.env.DIFY_API_KEY || '';
    this.knowledgeBaseId = this.getKnowledgeBaseId(projectId);
  }

  /**
   * 搜索知识库
   * @param query 查询问题
   * @param options 搜索选项
   */
  async searchKnowledge(
    query: string,
    options?: {
      doc_type?: 'protocol' | 'crf' | 'report';
      top_k?: number;
    }
  ): Promise<DifySearchResult> {
    try {
      logger.info('[DifyAdapter] Searching knowledge base', {
        query,
        options,
        knowledgeBaseId: this.knowledgeBaseId
      });

      const response = await axios.post(
        `${this.baseUrl}/datasets/${this.knowledgeBaseId}/retrieve`,
        {
          query: query,
          retrieval_model: {
            search_method: 'semantic_search',
            top_k: options?.top_k || 3,
            score_threshold: 0.5
          },
          // 如果指定了doc_type通过metadata过滤
          ...(options?.doc_type && {
            retrieval_model: {
              filter: {
                doc_type: options.doc_type
              }
            }
          })
        },
        {
          headers: {
            'Authorization': `Bearer ${this.apiKey}`,
            'Content-Type': 'application/json'
          },
          timeout: 10000 // 10秒超时
        }
      );

      logger.info('[DifyAdapter] Search completed', {
        recordCount: response.data.records?.length || 0
      });

      return {
        success: true,
        records: response.data.records || [],
        query: query
      };
    } catch (error: any) {
      logger.error('[DifyAdapter] Search failed', {
        error: error.message,
        query
      });

      return {
        success: false,
        records: [],
        query,
        error: error.message
      };
    }
  }

  /**
   * 上传文档到知识库
   * @param content 文档内容
   * @param metadata 元数据
   */
  async uploadDocument(
    content: string,
    metadata: {
      name: string;
      doc_type: 'protocol' | 'crf' | 'report';
      date?: string;
    }
  ): Promise<{ success: boolean; documentId?: string }> {
    try {
      logger.info('[DifyAdapter] Uploading document', {
        name: metadata.name,
        type: metadata.doc_type
      });

      const response = await axios.post(
        `${this.baseUrl}/datasets/${this.knowledgeBaseId}/document/create_by_text`,
        {
          name: metadata.name,
          text: content,
          indexing_technique: 'high_quality',
          process_rule: {
            mode: 'automatic',
            rules: {
              pre_processing_rules: [
                { id: 'remove_extra_spaces', enabled: true },
                { id: 'remove_urls_emails', enabled: false }
              ],
              segmentation: {
                separator: '\n',
                max_tokens: 500
              }
            }
          },
          doc_form: 'text_model',
          doc_language: 'Chinese',
          // 保存元数据
          metadata: {
            doc_type: metadata.doc_type,
            date: metadata.date || new Date().toISOString(),
            upload_time: new Date().toISOString()
          }
        },
        {
          headers: {
            'Authorization': `Bearer ${this.apiKey}`,
            'Content-Type': 'application/json'
          }
        }
      );

      logger.info('[DifyAdapter] Document uploaded', {
        documentId: response.data.document.id
      });

      return {
        success: true,
        documentId: response.data.document.id
      };
    } catch (error: any) {
      logger.error('[DifyAdapter] Upload failed', {
        error: error.message,
        name: metadata.name
      });

      return {
        success: false
      };
    }
  }

  /**
   * 获取项目对应的知识库ID
   * @param projectId 项目ID
   */
  private getKnowledgeBaseId(projectId: string): string {
    // TODO: 从数据库读取项目配置
    // 临时方案:从环境变量读取
    return process.env.DIFY_KNOWLEDGE_BASE_ID || '';
  }
}

/**
 * Dify搜索结果
 */
export interface DifySearchResult {
  success: boolean;
  records: Array<{
    content: string;
    score: number;
    metadata?: {
      doc_type?: string;
      date?: string;
    };
  }>;
  query: string;
  error?: string;
}

环境变量配置.env

# Dify配置
DIFY_API_URL=http://localhost/v1
DIFY_API_KEY=app-xxxxxxxxxxxxxxxxxxxxxx
DIFY_KNOWLEDGE_BASE_ID=kb-xxxxxxxxxxxxxxxxxxxxxx

验收标准

  • DifyAdapter类实现完整
  • 可成功调用搜索API
  • 可成功上传文档
  • 错误处理完善

任务1.4编写单元测试2小时

文件位置backend/src/modules/iit-manager/adapters/__tests__/DifyAdapter.test.ts

import { DifyAdapter } from '../DifyAdapter';

describe('DifyAdapter', () => {
  let difyAdapter: DifyAdapter;

  beforeAll(() => {
    difyAdapter = new DifyAdapter('test-project-id');
  });

  describe('searchKnowledge', () => {
    it('应该成功搜索知识库', async () => {
      const result = await difyAdapter.searchKnowledge('入组标准有哪些?');
      
      expect(result.success).toBe(true);
      expect(result.records.length).toBeGreaterThan(0);
      expect(result.records[0]).toHaveProperty('content');
      expect(result.records[0]).toHaveProperty('score');
    });

    it('应该支持按文档类型过滤', async () => {
      const result = await difyAdapter.searchKnowledge(
        '入组标准',
        { doc_type: 'protocol' }
      );
      
      expect(result.success).toBe(true);
      expect(result.records.length).toBeGreaterThan(0);
    });

    it('应该处理搜索失败情况', async () => {
      // Mock错误场景
      const result = await difyAdapter.searchKnowledge('');
      
      expect(result.success).toBe(false);
      expect(result.error).toBeDefined();
    });
  });

  describe('uploadDocument', () => {
    it('应该成功上传文档', async () => {
      const result = await difyAdapter.uploadDocument(
        '这是一份测试文档',
        {
          name: '测试文档',
          doc_type: 'protocol'
        }
      );
      
      expect(result.success).toBe(true);
      expect(result.documentId).toBeDefined();
    });
  });
});

验收标准

  • 单元测试覆盖率>80%
  • 所有测试用例通过

Day 2意图识别与路由逻辑8小时

任务2.1设计工具定义Tool Schema2小时

文件位置backend/src/modules/iit-manager/agents/tools.ts

/**
 * IIT Manager Agent工具定义
 */
export const iitAgentTools = [
  // 工具1查询实时数据
  {
    type: "function",
    function: {
      name: "query_clinical_data",
      description: `【查REDCap实时数据】用于查询临床研究的实时数据状态。
适用场景:
- 问项目进度:现在入组多少人了?数据完整率如何?
- 问患者详情P001患者录完数据了吗有没有不良反应
- 问质控状态:有哪些质控问题?数据质量怎么样?`,
      parameters: {
        type: "object",
        properties: {
          intent: {
            type: "string",
            enum: ["project_stats", "patient_detail", "qc_status"],
            description: `查询意图:
- project_stats: 项目宏观统计(入组人数、数据完整率等)
- patient_detail: 特定患者详情(录入情况、不良反应等)
- qc_status: 质控状态(质疑列表、数据问题等)`
          },
          patient_id: {
            type: "string",
            description: "患者/受试者编号(如 P001、P002当intent=patient_detail时必填"
          },
          date_range: {
            type: "string",
            enum: ["today", "this_week", "this_month", "all"],
            description: "时间范围默认为all"
          }
        },
        required: ["intent"]
      }
    }
  },
  
  // 工具2搜索知识库
  {
    type: "function",
    function: {
      name: "search_knowledge_base",
      description: `【查研究文档】用于搜索研究方案、规范文件、历史记录等静态资料。
适用场景:
- 问研究规范:入排标准是什么?研究终点怎么定义?
- 问CRF表格某个字段的定义是什么填写规范是
- 问历史记录:上周的周报里提到了什么问题?`,
      parameters: {
        type: "object",
        properties: {
          query: {
            type: "string",
            description: "搜索关键词或问题"
          },
          doc_category: {
            type: "string",
            enum: ["protocol", "crf", "report"],
            description: `文档类别:
- protocol: 研究方案、伦理批件、知情同意书、入排标准
- crf: CRF表格定义、填写说明、数据字典
- report: 项目周报、进度总结、历史记录`
          }
        },
        required: ["query"]
      }
    }
  }
];

任务2.2实现意图路由器Intent Router3小时

文件位置backend/src/modules/iit-manager/agents/IntentRouter.ts

import OpenAI from 'openai';
import { logger } from '../../../common/logging/index.js';
import { iitAgentTools } from './tools.js';

/**
 * 意图路由器
 * 使用LLM的Function Calling能力识别用户意图
 */
export class IntentRouter {
  private llm: OpenAI;
  private systemPrompt: string;

  constructor() {
    this.llm = new OpenAI({
      apiKey: process.env.OPENAI_API_KEY,
      baseURL: process.env.OPENAI_BASE_URL || 'https://api.deepseek.com'
    });

    this.systemPrompt = this.buildSystemPrompt();
  }

  /**
   * 识别用户意图并返回工具调用
   */
  async route(userMessage: string, context?: {
    projectId: string;
    userId: string;
  }): Promise<IntentRouteResult> {
    try {
      logger.info('[IntentRouter] Routing user message', {
        message: userMessage.substring(0, 100),
        projectId: context?.projectId
      });

      const response = await this.llm.chat.completions.create({
        model: 'deepseek-chat',
        messages: [
          { role: 'system', content: this.systemPrompt },
          { role: 'user', content: userMessage }
        ],
        tools: iitAgentTools,
        tool_choice: 'auto',
        temperature: 0.1, // 低温度,保证稳定性
        max_tokens: 500
      });

      const message = response.choices[0].message;

      // 如果LLM决定调用工具
      if (message.tool_calls && message.tool_calls.length > 0) {
        const toolCall = message.tool_calls[0];
        const toolName = toolCall.function.name;
        const toolArgs = JSON.parse(toolCall.function.arguments);

        logger.info('[IntentRouter] Tool selected', {
          toolName,
          toolArgs
        });

        return {
          needsToolCall: true,
          toolName: toolName as 'query_clinical_data' | 'search_knowledge_base',
          toolArgs,
          rawResponse: message
        };
      }

      // 如果LLM直接回答不需要工具
      logger.info('[IntentRouter] Direct answer', {
        answer: message.content?.substring(0, 100)
      });

      return {
        needsToolCall: false,
        directAnswer: message.content || '抱歉,我没有理解您的问题',
        rawResponse: message
      };
    } catch (error: any) {
      logger.error('[IntentRouter] Routing failed', {
        error: error.message,
        message: userMessage
      });

      return {
        needsToolCall: false,
        directAnswer: '抱歉,我遇到了一些问题,请稍后再试',
        error: error.message
      };
    }
  }

  /**
   * 构建System Prompt
   */
  private buildSystemPrompt(): string {
    return `# 角色
你是由壹证循科技开发的"临床研究项目助手"服务于IIT研究者发起试验项目的PI主要研究者
# 能力
你拥有两个工具,请根据用户问题精准选择:

1. **query_clinical_data**(查实时数据)
   - 当用户问"现状"时使用
   - 例如:"现在入组多少人?"、"P001患者录完了吗"、"有没有不良反应?"
   - 这些问题需要查询REDCap数据库的实时数据

2. **search_knowledge_base**(查研究文档)
   - 当用户问"规定"或"历史"时使用
   - 例如:"入排标准是什么?"、"上周的问题解决了吗?"、"CRF里某字段怎么填"
   - 这些问题需要查阅研究方案、周报等文档

# 路由原则
- 如果问题明确需要工具,必须调用工具,不要猜测或编造答案
- 如果问题模糊优先选择query_clinical_data实时数据更重要
- 如果是闲聊或打招呼,可以直接回答,不调用工具

# 约束
- 严禁编造数据
- 回答要简洁专业
- 隐去患者真实姓名,只使用编号`;
  }
}

/**
 * 意图路由结果
 */
export interface IntentRouteResult {
  needsToolCall: boolean;
  toolName?: 'query_clinical_data' | 'search_knowledge_base';
  toolArgs?: any;
  directAnswer?: string;
  error?: string;
  rawResponse?: any;
}

验收标准

  • IntentRouter类实现完整
  • 可正确识别查数据意图
  • 可正确识别查文档意图
  • 可处理闲聊场景

任务2.3实现工具执行器Tool Executor3小时

文件位置backend/src/modules/iit-manager/agents/ToolExecutor.ts

import { DifyAdapter } from '../adapters/DifyAdapter.js';
import { RedcapAdapter } from '../adapters/RedcapAdapter.js';
import { logger } from '../../../common/logging/index.js';
import { prisma } from '../../../config/database.js';

/**
 * 工具执行器
 * 根据意图路由结果执行对应的工具
 */
export class ToolExecutor {
  /**
   * 执行工具
   */
  async execute(
    toolName: 'query_clinical_data' | 'search_knowledge_base',
    toolArgs: any,
    context: {
      projectId: string;
      userId: string;
    }
  ): Promise<ToolExecutionResult> {
    try {
      logger.info('[ToolExecutor] Executing tool', {
        toolName,
        toolArgs,
        projectId: context.projectId
      });

      if (toolName === 'query_clinical_data') {
        return await this.executeQueryClinicalData(toolArgs, context);
      } else if (toolName === 'search_knowledge_base') {
        return await this.executeSearchKnowledge(toolArgs, context);
      }

      return {
        success: false,
        data: null,
        error: `Unknown tool: ${toolName}`
      };
    } catch (error: any) {
      logger.error('[ToolExecutor] Execution failed', {
        error: error.message,
        toolName
      });

      return {
        success: false,
        data: null,
        error: error.message
      };
    }
  }

  /**
   * 执行:查询临床数据
   */
  private async executeQueryClinicalData(
    args: {
      intent: 'project_stats' | 'patient_detail' | 'qc_status';
      patient_id?: string;
      date_range?: string;
    },
    context: { projectId: string; userId: string }
  ): Promise<ToolExecutionResult> {
    // 1. 获取项目配置
    const project = await prisma.iitProject.findUnique({
      where: { id: context.projectId },
      select: {
        redcapApiUrl: true,
        redcapApiToken: true,
        redcapProjectId: true
      }
    });

    if (!project) {
      return {
        success: false,
        data: null,
        error: '项目不存在'
      };
    }

    // 2. 初始化RedcapAdapter
    const redcap = new RedcapAdapter(
      project.redcapApiUrl,
      project.redcapApiToken
    );

    // 3. 根据intent执行不同查询
    switch (args.intent) {
      case 'project_stats': {
        // 查询项目统计
        const records = await redcap.exportRecords();
        
        return {
          success: true,
          data: {
            type: 'project_stats',
            totalRecords: records.length,
            stats: {
              enrolled: records.length,
              completed: records.filter((r: any) => r.complete === '2').length,
              dataQuality: '87.5%' // TODO: 实际计算
            }
          }
        };
      }

      case 'patient_detail': {
        // 查询特定患者
        if (!args.patient_id) {
          return {
            success: false,
            data: null,
            error: '缺少患者ID'
          };
        }

        const records = await redcap.exportRecords([args.patient_id]);
        
        if (records.length === 0) {
          return {
            success: false,
            data: null,
            error: `未找到患者 ${args.patient_id}`
          };
        }

        return {
          success: true,
          data: {
            type: 'patient_detail',
            patientId: args.patient_id,
            details: records[0]
          }
        };
      }

      case 'qc_status': {
        // 查询质控状态
        const logs = await prisma.iitAuditLog.findMany({
          where: {
            projectId: context.projectId,
            actionType: 'quality_issue'
          },
          orderBy: { createdAt: 'desc' },
          take: 10
        });

        return {
          success: true,
          data: {
            type: 'qc_status',
            issueCount: logs.length,
            recentIssues: logs.map(log => ({
              recordId: log.entityId,
              issue: log.details,
              createdAt: log.createdAt
            }))
          }
        };
      }

      default:
        return {
          success: false,
          data: null,
          error: `Unknown intent: ${args.intent}`
        };
    }
  }

  /**
   * 执行:搜索知识库
   */
  private async executeSearchKnowledge(
    args: {
      query: string;
      doc_category?: 'protocol' | 'crf' | 'report';
    },
    context: { projectId: string; userId: string }
  ): Promise<ToolExecutionResult> {
    // 1. 初始化DifyAdapter
    const dify = new DifyAdapter(context.projectId);

    // 2. 搜索知识库
    const result = await dify.searchKnowledge(args.query, {
      doc_type: args.doc_category,
      top_k: 3
    });

    if (!result.success) {
      return {
        success: false,
        data: null,
        error: result.error || '知识库搜索失败'
      };
    }

    // 3. 格式化结果
    return {
      success: true,
      data: {
        type: 'knowledge_search',
        query: args.query,
        category: args.doc_category,
        results: result.records.map(record => ({
          content: record.content,
          score: record.score,
          metadata: record.metadata
        }))
      }
    };
  }
}

/**
 * 工具执行结果
 */
export interface ToolExecutionResult {
  success: boolean;
  data: any;
  error?: string;
}

验收标准

  • ToolExecutor类实现完整
  • query_clinical_data工具可执行
  • search_knowledge_base工具可执行
  • 错误处理完善

Day 3集成企业微信对话8小时

任务3.1增强WechatCallbackController3小时

修改文件backend/src/modules/iit-manager/controllers/WechatCallbackController.ts

在现有的handleCallback方法中增加AI对话逻辑

// 在WechatCallbackController类中添加
import { IntentRouter } from '../agents/IntentRouter.js';
import { ToolExecutor } from '../agents/ToolExecutor.js';
import { AnswerGenerator } from '../agents/AnswerGenerator.js';

class WechatCallbackController {
  private intentRouter: IntentRouter;
  private toolExecutor: ToolExecutor;
  private answerGenerator: AnswerGenerator;

  constructor() {
    // ... 现有代码 ...
    this.intentRouter = new IntentRouter();
    this.toolExecutor = new ToolExecutor();
    this.answerGenerator = new AnswerGenerator();
  }

  /**
   * 处理企业微信回调消息(已有方法,增强)
   */
  async handleCallback(request: FastifyRequest, reply: FastifyReply): Promise<void> {
    // ... 现有的验证、解密逻辑 ...

    // ✅ 立即返回success避免5秒超时
    reply.send('success');

    // ✅ 异步处理用户消息(新增)
    setImmediate(async () => {
      try {
        const userMessage = decryptedData.Content;
        const userId = decryptedData.FromUserName;

        logger.info('📥 收到用户消息', {
          userId,
          message: userMessage.substring(0, 50)
        });

        // 1. 获取用户的项目信息
        const userMapping = await prisma.iitUserMapping.findFirst({
          where: { wechatUserId: userId }
        });

        if (!userMapping) {
          await wechatService.sendTextMessage(
            userId,
            '⚠️ 您还未绑定项目,请联系管理员配置'
          );
          return;
        }

        // 2. 意图识别
        const routeResult = await this.intentRouter.route(userMessage, {
          projectId: userMapping.projectId,
          userId
        });

        // 3. 如果直接回答(不需要工具)
        if (!routeResult.needsToolCall) {
          await wechatService.sendTextMessage(userId, routeResult.directAnswer!);
          return;
        }

        // 4. 执行工具
        const toolResult = await this.toolExecutor.execute(
          routeResult.toolName!,
          routeResult.toolArgs,
          {
            projectId: userMapping.projectId,
            userId
          }
        );

        // 5. 生成回答
        const answer = await this.answerGenerator.generate(
          userMessage,
          toolResult,
          routeResult.toolName!
        );

        // 6. 发送回复
        await wechatService.sendMarkdownMessage(userId, answer);

        // 7. 记录审计日志
        await prisma.iitAuditLog.create({
          data: {
            projectId: userMapping.projectId,
            actionType: 'wechat_user_query',
            operator: userId,
            entityId: userId,
            details: {
              question: userMessage,
              answer: answer.substring(0, 200),
              toolUsed: routeResult.toolName
            }
          }
        });

        logger.info('✅ 回答发送成功', {
          userId,
          toolUsed: routeResult.toolName
        });
      } catch (error: any) {
        logger.error('❌ 处理用户消息失败', {
          error: error.message
        });

        // 发送错误提示
        await wechatService.sendTextMessage(
          userId,
          '抱歉,我遇到了一些问题,请稍后再试或联系管理员'
        );
      }
    });
  }
}

验收标准

  • 可接收用户消息
  • 可调用意图路由
  • 可执行工具
  • 可生成回答
  • 可发送回复

任务3.2实现答案生成器Answer Generator2小时

文件位置backend/src/modules/iit-manager/agents/AnswerGenerator.ts

import OpenAI from 'openai';
import { logger } from '../../../common/logging/index.js';
import { ToolExecutionResult } from './ToolExecutor.js';

/**
 * 答案生成器
 * 将工具执行结果转换为用户友好的回答
 */
export class AnswerGenerator {
  private llm: OpenAI;

  constructor() {
    this.llm = new OpenAI({
      apiKey: process.env.OPENAI_API_KEY,
      baseURL: process.env.OPENAI_BASE_URL || 'https://api.deepseek.com'
    });
  }

  /**
   * 生成回答
   */
  async generate(
    userQuestion: string,
    toolResult: ToolExecutionResult,
    toolName: string
  ): Promise<string> {
    try {
      logger.info('[AnswerGenerator] Generating answer', {
        question: userQuestion.substring(0, 50),
        toolName,
        success: toolResult.success
      });

      // 如果工具执行失败
      if (!toolResult.success) {
        return this.generateErrorMessage(toolResult.error);
      }

      // 根据不同工具类型,使用不同的回答模板
      if (toolName === 'query_clinical_data') {
        return this.generateDataAnswer(userQuestion, toolResult.data);
      } else if (toolName === 'search_knowledge_base') {
        return await this.generateKnowledgeAnswer(userQuestion, toolResult.data);
      }

      return '抱歉,我无法生成回答';
    } catch (error: any) {
      logger.error('[AnswerGenerator] Generation failed', {
        error: error.message
      });

      return '抱歉,回答生成失败,请稍后再试';
    }
  }

  /**
   * 生成数据查询的回答使用模板不调用LLM
   */
  private generateDataAnswer(question: string, data: any): string {
    const type = data.type;

    if (type === 'project_stats') {
      return `📊 **项目统计数据**

✅ **入组人数**${data.stats.enrolled}✅ **完成病例**${data.stats.completed}✅ **数据质量**${data.stats.dataQuality}

💡 数据更新时间:${new Date().toLocaleString('zh-CN')}`;
    }

    if (type === 'patient_detail') {
      const details = data.details;
      return `👤 **患者 ${data.patientId} 详情**

📋 **基本信息**
- 年龄:${details.age || '未录入'}- 性别:${details.gender || '未录入'}
- BMI${details.bmi || '未录入'}

📊 **录入状态**
- 数据完整度:${details.complete === '2' ? '✅ 已完成' : '⏳ 进行中'}

💡 最后更新:${new Date().toLocaleString('zh-CN')}`;
    }

    if (type === 'qc_status') {
      const issues = data.recentIssues.slice(0, 5);
      let answer = `🔍 **质控状态**\n\n`;
      answer += `⚠️ **质控问题数**${data.issueCount}\n\n`;
      
      if (issues.length > 0) {
        answer += `📋 **最近问题**\n`;
        issues.forEach((issue: any, index: number) => {
          answer += `${index + 1}. 记录${issue.recordId}${JSON.stringify(issue.issue).substring(0, 50)}\n`;
        });
      } else {
        answer += `✅ 暂无质控问题`;
      }

      return answer;
    }

    return JSON.stringify(data, null, 2);
  }

  /**
   * 生成知识检索的回答调用LLM综合
   */
  private async generateKnowledgeAnswer(question: string, data: any): Promise<string> {
    const results = data.results;

    if (results.length === 0) {
      return `📚 **知识库搜索**

❌ 未找到相关内容

💡 建议:
1. 尝试换个关键词
2. 查看研究方案原文
3. 联系项目协调员`;
    }

    // 将搜索结果拼接为上下文
    const context = results
      .map((r: any, index: number) => `[文档${index + 1}] ${r.content}`)
      .join('\n\n');

    // 调用LLM综合回答
    const response = await this.llm.chat.completions.create({
      model: 'deepseek-chat',
      messages: [
        {
          role: 'system',
          content: `你是临床研究助手。根据检索到的文档内容,回答用户问题。
要求:
1. 回答要准确、简洁
2. 引用文档时标注[文档X]
3. 如果文档中没有明确答案,诚实说明
4. 使用Markdown格式`
        },
        {
          role: 'user',
          content: `用户问题:${question}\n\n检索到的文档内容\n${context}`
        }
      ],
      temperature: 0.3,
      max_tokens: 800
    });

    const answer = response.choices[0].message.content || '无法生成回答';

    return `📚 **知识库查询结果**\n\n${answer}`;
  }

  /**
   * 生成错误提示
   */
  private generateErrorMessage(error?: string): string {
    return `❌ 查询失败

原因:${error || '未知错误'}

💡 您可以:
1. 稍后重试
2. 换个问法
3. 联系管理员`;
  }
}

验收标准

  • 可生成数据查询回答
  • 可生成知识检索回答
  • 回答格式友好Markdown
  • 错误提示清晰

任务3.3端到端测试3小时

测试场景

// 测试场景1查询项目统计
{
  input: "现在入组多少人了?",
  expectedTool: "query_clinical_data",
  expectedIntent: "project_stats",
  expectedOutput: "📊 项目统计数据\n✅ 入组人数XX例"
}

// 测试场景2查询特定患者
{
  input: "P001患者录完数据了吗",
  expectedTool: "query_clinical_data",
  expectedIntent: "patient_detail",
  expectedOutput: "👤 患者 P001 详情"
}

// 测试场景3查询研究方案
{
  input: "入排标准是什么?",
  expectedTool: "search_knowledge_base",
  expectedCategory: "protocol",
  expectedOutput: "📚 知识库查询结果"
}

// 测试场景4查询CRF表格
{
  input: "BMI这个字段怎么填",
  expectedTool: "search_knowledge_base",
  expectedCategory: "crf",
  expectedOutput: "📚 知识库查询结果"
}

// 测试场景5闲聊
{
  input: "你好",
  expectedTool: null,
  expectedOutput: "您好!我是临床研究助手"
}

测试步骤

  1. 在企业微信中发送测试消息
  2. 观察后端日志
  3. 验证回复内容
  4. 检查审计日志

验收标准

  • 5个测试场景全部通过
  • 回复时间<3秒
  • 回复内容准确
  • 审计日志完整

Day 4周报自动归档6小时

任务4.1实现周报生成器3小时

文件位置backend/src/modules/iit-manager/services/WeeklyReportGenerator.ts

import { prisma } from '../../../config/database.js';
import { RedcapAdapter } from '../adapters/RedcapAdapter.js';
import { DifyAdapter } from '../adapters/DifyAdapter.js';
import { logger } from '../../../common/logging/index.js';
import { getISOWeek, startOfWeek, endOfWeek } from 'date-fns';

/**
 * 周报生成器
 * 自动生成项目周报并上传到Dify知识库
 */
export class WeeklyReportGenerator {
  /**
   * 生成并上传周报
   */
  async generateAndUpload(projectId: string): Promise<{
    success: boolean;
    reportId?: string;
    error?: string;
  }> {
    try {
      const weekNumber = getISOWeek(new Date());
      const year = new Date().getFullYear();

      logger.info('[WeeklyReportGenerator] Starting generation', {
        projectId,
        year,
        weekNumber
      });

      // 1. 检查是否已生成
      const existing = await prisma.iitWeeklyReport.findFirst({
        where: {
          projectId,
          year,
          weekNumber
        }
      });

      if (existing) {
        logger.warn('[WeeklyReportGenerator] Report already exists', {
          reportId: existing.id
        });
        return {
          success: false,
          error: '本周周报已生成'
        };
      }

      // 2. 收集数据
      const reportData = await this.collectWeeklyData(projectId, year, weekNumber);

      // 3. 生成Markdown内容
      const content = this.generateMarkdownContent(reportData, year, weekNumber);

      // 4. 保存到数据库
      const report = await prisma.iitWeeklyReport.create({
        data: {
          projectId,
          year,
          weekNumber,
          content,
          stats: reportData.stats,
          createdAt: new Date()
        }
      });

      // 5. 上传到Dify知识库
      const dify = new DifyAdapter(projectId);
      const uploadResult = await dify.uploadDocument(content, {
        name: `周报-${year}年第${weekNumber}周`,
        doc_type: 'report',
        date: `${year}-W${weekNumber.toString().padStart(2, '0')}`
      });

      if (!uploadResult.success) {
        logger.error('[WeeklyReportGenerator] Dify upload failed');
        // 数据库已保存Dify上传失败不影响
      }

      logger.info('[WeeklyReportGenerator] Generation completed', {
        reportId: report.id,
        difyUploaded: uploadResult.success
      });

      return {
        success: true,
        reportId: report.id
      };
    } catch (error: any) {
      logger.error('[WeeklyReportGenerator] Generation failed', {
        error: error.message,
        projectId
      });

      return {
        success: false,
        error: error.message
      };
    }
  }

  /**
   * 收集本周数据
   */
  private async collectWeeklyData(
    projectId: string,
    year: number,
    weekNumber: number
  ): Promise<any> {
    const weekStart = startOfWeek(new Date(), { weekStartsOn: 1 });
    const weekEnd = endOfWeek(new Date(), { weekStartsOn: 1 });

    // 1. 获取项目配置
    const project = await prisma.iitProject.findUnique({
      where: { id: projectId },
      select: {
        name: true,
        redcapApiUrl: true,
        redcapApiToken: true
      }
    });

    // 2. 从REDCap获取统计
    const redcap = new RedcapAdapter(
      project!.redcapApiUrl,
      project!.redcapApiToken
    );
    const allRecords = await redcap.exportRecords();

    // 3. 从审计日志获取本周活动
    const weeklyLogs = await prisma.iitAuditLog.findMany({
      where: {
        projectId,
        createdAt: {
          gte: weekStart,
          lte: weekEnd
        }
      },
      orderBy: { createdAt: 'desc' }
    });

    // 4. 统计数据
    const stats = {
      totalRecords: allRecords.length,
      newRecordsThisWeek: weeklyLogs.filter(
        log => log.actionType === 'redcap_data_received'
      ).length,
      qualityIssues: weeklyLogs.filter(
        log => log.actionType === 'quality_issue'
      ).length,
      wechatNotifications: weeklyLogs.filter(
        log => log.actionType === 'wechat_notification_sent'
      ).length
    };

    return {
      projectName: project!.name,
      year,
      weekNumber,
      weekStart: weekStart.toISOString(),
      weekEnd: weekEnd.toISOString(),
      stats,
      recentActivities: weeklyLogs.slice(0, 20).map(log => ({
        actionType: log.actionType,
        entityId: log.entityId,
        createdAt: log.createdAt,
        details: log.details
      }))
    };
  }

  /**
   * 生成Markdown内容
   */
  private generateMarkdownContent(data: any, year: number, weekNumber: number): string {
    return `# ${data.projectName} - ${year}年第${weekNumber}周周报

## 📊 统计数据

- **总记录数**${data.stats.totalRecords}- **本周新增**${data.stats.newRecordsThisWeek}- **质控问题**${data.stats.qualityIssues}- **企业微信通知**${data.stats.wechatNotifications}
## 📅 时间范围

- **开始时间**${new Date(data.weekStart).toLocaleString('zh-CN')}
- **结束时间**${new Date(data.weekEnd).toLocaleString('zh-CN')}

## 📋 本周主要活动

${data.recentActivities
  .map((activity: any, index: number) => {
    return `${index + 1}. **${activity.actionType}** - 记录${activity.entityId} (${new Date(activity.createdAt).toLocaleString('zh-CN')})`;
  })
  .join('\n')}

## 💡 重点关注

${data.stats.qualityIssues > 0 
  ? `⚠️ 本周发现${data.stats.qualityIssues}个质控问题,请及时处理` 
  : '✅ 本周无质控问题'}

---
*自动生成时间:${new Date().toLocaleString('zh-CN')}*`;
  }
}

// 数据库表定义需要添加到Prisma Schema
/*
model IitWeeklyReport {
  id        String   @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
  projectId String   @db.Uuid
  year      Int
  weekNumber Int
  content   String   @db.Text
  stats     Json
  createdAt DateTime @default(now())

  project   IitProject @relation(fields: [projectId], references: [id])

  @@unique([projectId, year, weekNumber])
  @@map("weekly_reports")
  @@schema("iit_schema")
}
*/

验收标准

  • 可生成周报Markdown
  • 可保存到数据库
  • 可上传到Dify
  • 防止重复生成

任务4.2配置定时任务2小时

文件位置backend/src/modules/iit-manager/index.ts

import cron from 'node-cron';
import { WeeklyReportGenerator } from './services/WeeklyReportGenerator.js';

/**
 * 初始化IIT Manager模块
 */
export async function initIitManager() {
  // ... 现有的Worker注册代码 ...

  // ✅ 新增:注册周报定时任务
  registerWeeklyReportCron();

  logger.info('✅ IIT Manager initialized');
}

/**
 * 注册周报定时任务
 * 每周一 00:00 自动生成上周周报
 */
function registerWeeklyReportCron() {
  const generator = new WeeklyReportGenerator();

  // 每周一凌晨0点执行
  cron.schedule('0 0 * * 1', async () => {
    logger.info('⏰ 开始生成周报');

    try {
      // 获取所有活跃项目
      const projects = await prisma.iitProject.findMany({
        where: { status: 'active' }
      });

      for (const project of projects) {
        await generator.generateAndUpload(project.id);
      }

      logger.info('✅ 周报生成完成', {
        projectCount: projects.length
      });
    } catch (error: any) {
      logger.error('❌ 周报生成失败', {
        error: error.message
      });
    }
  }, {
    timezone: 'Asia/Shanghai'
  });

  logger.info('✅ 周报定时任务已注册(每周一 00:00');
}

验收标准

  • 定时任务注册成功
  • 可手动触发测试
  • 日志记录完整

Day 5文档编写与测试6小时

任务5.1用户使用手册2小时

文件位置AIclinicalresearch/docs/03-业务模块/IIT Manager Agent/05-使用手册/企业微信对话指南.md

内容大纲

  1. 功能介绍
  2. 支持的查询类型
  3. 常用问法示例
  4. 注意事项
  5. 常见问题FAQ

任务5.2Phase 1.5开发记录2小时

文件位置AIclinicalresearch/docs/03-业务模块/IIT Manager Agent/06-开发记录/Phase1.5-AI对话能力开发完成记录.md

内容大纲

  1. 开发目标与成果
  2. 技术实现细节
  3. 测试验证结果
  4. 已知限制与改进计划

任务5.3完整测试2小时

测试矩阵

场景 输入 预期工具 预期输出 状态
项目统计 "现在入组多少人?" query_clinical_data 包含入组人数
患者详情 "P001录完了吗" query_clinical_data 包含患者状态
质控状态 "有哪些质控问题?" query_clinical_data 问题列表
研究方案 "入排标准是什么?" search_knowledge_base 方案内容
CRF查询 "BMI怎么填" search_knowledge_base CRF说明
周报查询 "上周进展如何?" search_knowledge_base 周报内容
闲聊 "你好" 友好回复

📊 四、成功标准与验收

4.1 功能完整性

功能 验收标准 优先级
Dify集成 可成功调用本地Dify API 🔴 P0
意图识别 准确率>80% 🔴 P0
数据查询 可查询REDCap实时数据 🔴 P0
知识检索 可检索研究方案文档 🔴 P0
企业微信回复 回复时间<3秒 🔴 P0
周报自动归档 每周一自动生成 🟠 P1
审计日志 所有对话有日志 🟠 P1

4.2 性能指标

指标 目标 说明
回复延迟 <3秒 用户问 → 收到回复
Dify查询延迟 <500ms 本地部署,应该很快
REDCap查询延迟 <1秒 已有adapter已验证
意图识别准确率 >80% 通过测试矩阵验证
知识检索准确率 >70% 依赖文档质量

4.3 代码质量

  • TypeScript类型完整
  • 单元测试覆盖率>70%
  • 集成测试通过
  • 错误处理完善
  • 日志记录完整
  • 代码符合规范

🚀 五、部署与上线

5.1 环境变量配置

# Dify配置
DIFY_API_URL=http://localhost/v1
DIFY_API_KEY=app-xxxxxxxxxxxxxxxxxxxxxx
DIFY_KNOWLEDGE_BASE_ID=kb-xxxxxxxxxxxxxxxxxxxxxx

# LLM配置DeepSeek
OPENAI_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxx
OPENAI_BASE_URL=https://api.deepseek.com

# 企业微信配置(已有)
WECHAT_CORP_ID=ww01cb7b72ea2db83c
WECHAT_CORP_SECRET=xxx
WECHAT_AGENT_ID=1000002

5.2 数据库迁移

# 添加周报表
npx prisma db push
npx prisma generate

5.3 重启服务

cd AIclinicalresearch/backend
npm run dev

📚 六、技术债务与改进计划

6.1 当前限制Phase 1.5

限制 影响 计划改进时间
单项目支持 只能服务一个项目 Phase 2
无多轮对话 每次问答独立 Phase 2
无上下文记忆 不记得之前的对话 Phase 2
硬编码知识库ID 不支持多项目 Phase 2
简单意图识别 不支持复杂推理 Phase 3

6.2 Phase 2 改进计划

  1. 多项目支持

    • 每个项目独立知识库
    • 用户权限管理
    • 项目切换功能
  2. 多轮对话

    • 会话状态管理
    • 上下文记忆Redis
    • 澄清式提问
  3. 混合推理ReAct

    • 支持复杂查询
    • 多工具组合
    • 自主推理循环

七、总结

7.1 Phase 1.5核心价值

实现目标

  • PI可在企业微信中自然语言提问
  • AI可理解意图并查询数据/文档
  • 回答准确、及时、友好
  • 周报自动归档,可随时查询

技术亮点

  • 🎯 基于本地Dify无API成本
  • 🧠 单步意图识别,简单高效
  • 🔧 复用现有RedcapAdapter
  • 📊 周报自动生成与归档
  • 🚀 端到端延迟<3秒

开发效率

  • 📅 预估工作量5天
  • 📝 新增代码:~2000行
  • 🧪 测试覆盖:>70%
  • 📚 文档完整:用户手册+开发记录

下一步Phase 2 - 多项目支持与高级对话能力


🎯 八、极简版核心价值总结

8.1 为什么极简版最重要?

用户反馈

"最重要的是先让AI能对话其他都先放一边"

现实情况

  • MVP闭环已打通Day 3完成
  • 企业微信推送已验证100%成功率)
  • RedcapAdapter已可用直接复用
  • ⚠️ 缺少PI无法主动查询数据

极简版价值

  • 🚀 2天上线最快实现AI对话
  • 💰 零成本只查REDCap不用Dify
  • 🧠 有记忆支持多轮对话3轮
  • 有反馈"正在查询..."避免用户焦虑
  • 🎯 核心够用满足80%的查询需求

8.2 三大核心改进(基于用户建议)

改进1上下文记忆

问题

PI: "帮我查一下P001的入组情况"
AI: "P001已入组"
PI: "他有不良反应吗?"
AI: ❌ "请提供患者编号"(失忆了)

解决

// SessionMemory存储最近3轮对话
sessionMemory.addMessage(userId, 'user', '帮我查P001');
sessionMemory.addMessage(userId, 'assistant', 'P001已入组');

// 下次查询时自动填充患者ID
const lastPatientId = sessionMemory.getLastPatientId(userId); // P001

效果

PI: "他有不良反应吗?"
AI: ✅ "查询P001无不良反应记录"记得是P001

改进2正在输入反馈

问题

  • AI处理需要5-8秒
  • 用户发完消息后,手机没反应
  • 用户以为系统挂了

解决

// 立即发送临时反馈
await wechatService.sendTextMessage(userId, '🫡 正在查询,请稍候...');

// 再慢慢处理
const answer = await processQuery(userMessage);
await wechatService.sendMarkdownMessage(userId, answer);

效果

  • 用户立即看到反馈(<1秒
  • 知道AI在工作
  • 不会焦虑

改进3极简优先

问题

  • 原计划5天开发太长
  • Dify、周报等功能非必需
  • 用户最想要:能对话

解决

  • 只做REDCap查询复用现有adapter
  • 不接DifyPhase 2再做
  • 不做周报Phase 2再做
  • 2天上线

效果

  • 快速验证价值
  • 快速收集反馈
  • 快速迭代

8.3 前端架构演进路线

Phase 1.5(当前):
├── 企业微信原生对话(省事)
├── 无自定义UI
└── 上下文存在Node.js内存

       ↓ (用户反馈 + 需求增长)

Phase 3未来:
├── 自研H5/小程序Taro 4.x
├── Ant Design X管理上下文
├── 丰富的UI组件输入提示、历史记录、知识卡片
└── 更好的用户体验

为什么分两阶段?

  • Phase 1.5验证核心价值AI能回答问题
  • Phase 3优化用户体验更美观、更智能
  • 避免过度设计(先有再好)

8.4 立即行动指南

Step 1创建第一个文件5分钟

cd AIclinicalresearch/backend/src/modules/iit-manager
mkdir -p agents
touch agents/SessionMemory.ts

复制Day 1任务1.1的代码到 SessionMemory.ts

Step 2运行单元测试可选

npm test agents/SessionMemory.test.ts

Step 3继续Day 1其他任务

按照文档中的顺序:

  1. SessionMemory (30分钟)
  2. SimpleIntentRouter (2小时)
  3. SimpleToolExecutor (1.5小时)
  4. SimpleAnswerGenerator (1小时)
  5. 集成到WechatCallbackController (1小时)

Step 4Day 1结束时测试

在企业微信中发送:

"现在入组多少人?"

预期:

  1. 立即收到"🫡 正在查询,请稍候..."
  2. 3秒内收到"📊 项目统计..."

Step 5Day 2测试多轮对话

PI: "帮我查一下P001的情况"
AI: "👤 患者 P001 详情..."

PI: "他有不良反应吗?" ← 测试上下文
AI: "查询P001无不良反应记录" ← 应该自动识别

8.5 成功标准(极简版)

检查项 标准 验证方式
基础对话 能回答"入组多少人" 企微测试
患者查询 能回答"P001情况" 企微测试
上下文记忆 "他有不良反应吗"能识别P001 企微测试
正在输入反馈 <1秒收到"正在查询..." 企微测试
最终回复 <3秒收到完整答案 后端日志
审计日志 记录上下文标记 数据库检查

8.6 与完整版的关系

极简版2天

  • 🎯 目标最快验证AI对话价值
  • 📦 范围REDCap查询 + 上下文记忆
  • 💰 成本:无额外成本(复用现有)
  • 🚀 速度2天上线

完整版5天

  • 🎯 目标全面的AI助手能力
  • 📦 范围:+ Dify知识库 + 周报归档 + 文档查询
  • 💰 成本需配置Dify已有Docker
  • 🚀 速度5天上线

建议 先做极简版2天验证价值
收集用户反馈
再决定是否做完整版

实际执行 极简版已完成2026-01-03
Dify知识库已集成2026-01-04
混合检索已实现REDCap实时数据 + Dify文档知识库


🎓 八、Dify知识库集成2026-01-04完成

8.7 集成背景

完成时间: 2026-01-04
开发工作量: 4-6小时
集成目标: 在REDCap实时数据查询基础上增加研究方案文档查询能力

核心价值

  • 📚 文档查询: 查询研究方案、CRF表格、伦理文件
  • 🔀 混合检索: 同时支持结构化数据REDCap和非结构化文档Dify
  • 🎯 智能路由: 根据用户问题自动选择数据源

8.8 技术方案

方案选择

维度 采用方案
知识库架构 单项目单知识库1个IIT项目 → 1个Dify Dataset
文档上传 Dify Web界面手动上传MVP阶段
项目关联 用户绑定默认项目(存储在iit_schema.projects.dify_dataset_id

核心实现

1. 扩展意图识别

ChatService.detectIntent()中新增query_protocol意图:

// 识别文档查询研究方案、伦理、知情同意、CRF等
if (/(研究方案|伦理|知情同意|CRF|病例报告表|纳入|入选|排除|标准|入组标准|治疗方案|试验设计|研究目的|研究流程|观察指标|诊断标准|疾病标准)/.test(message)) {
  return { intent: 'query_protocol' };
}

2. 新增Dify查询方法

private async queryDifyKnowledge(query: string): Promise<string> {
  // 1. 获取项目的difyDatasetId
  const project = await prisma.iitProject.findFirst({
    where: { status: 'active' },
    select: { name: true, difyDatasetId: true }
  });

  // 2. 调用Dify API检索
  const retrievalResult = await difyClient.retrieveKnowledge(
    project.difyDatasetId,
    query,
    { retrieval_model: { search_method: 'semantic_search', top_k: 5 } }
  );

  // 3. 格式化检索结果
  // 修复bug使用正确的字段路径 record.segment.document.name 和 record.segment.content
  // ...
}

3. 更新对话流程

async handleMessage(userId: string, userMessage: string): Promise<string> {
  const { intent, params } = this.detectIntent(userMessage);

  // REDCap查询
  let toolResult: any = null;
  if (intent === 'query_record') {
    toolResult = await this.queryRedcapRecord(params.recordId);
  }

  // Dify知识库查询
  let difyKnowledge: string = '';
  if (intent === 'query_protocol') {
    difyKnowledge = await this.queryDifyKnowledge(userMessage);
  }

  // 构建LLM消息同时注入REDCap数据和Dify知识
  const messages = this.buildMessagesWithData(
    userMessage, context, toolResult, difyKnowledge, userId
  );

  // 调用LLM生成回答
  const response = await this.llm.chat(messages);
  // ...
}

8.9 问题排查与修复

问题1: AI不查询Dify自己编造答案

现象: 用户问"纳入标准是什么?"AI编造了答案Dify控制台无查询记录

根因1: 意图识别关键词不全

  • 缺少: "入选"、"诊断标准"、"疾病标准"
  • 解决: 扩充关键词列表

根因2: Dify API返回字段路径错误

  • 错误: record.document_namerecord.content → 返回undefined
  • 正确: record.segment.document.namerecord.segment.content
  • 解决: 修正字段访问路径

调试过程:

  1. 创建debug-dify-injection.ts追踪数据注入流程
  2. 创建inspect-dify-response.ts查看Dify API实际返回结构
  3. 发现并修复字段路径错误

8.10 测试验证

测试场景 问题 数据源 结果
文档查询 "这个研究的排除标准是什么?" Dify 成功
CRF查询 "CRF表格中有哪些观察指标" Dify 成功
患者查询 "ID 7的患者情况" REDCap 成功
统计查询 "目前入组了多少人?" REDCap 成功
混合查询 "这个研究的主要研究目的是什么?" Dify 成功

8.11 集成成果

技术架构:

用户提问 → 意图识别 → ┬→ [query_protocol] → Dify API → 文档片段
                       ├→ [query_record]   → REDCap API → 患者数据
                       └→ [count_records]  → REDCap API → 统计数据
                              ↓
                    构建LLM PromptSystem + Data + Context
                              ↓
                         DeepSeek-V3
                              ↓
                           AI回答

核心能力:

  1. 混合检索: 同时支持结构化数据和非结构化文档
  2. 智能路由: 根据意图自动选择数据源
  3. 防止幻觉: 所有回答基于真实数据/文档
  4. 来源标注: 清晰标注数据来自REDCap或Dify

详细记录: 参见 Dify知识库集成开发记录


九、总结

核心成就(极简版 + Dify集成

  1. 2天上线最快实现AI对话能力含Dify集成
  2. 上下文记忆支持多轮对话3轮
  3. 正在输入反馈:避免用户焦虑
  4. 代词解析"他"能自动识别患者
  5. 混合检索同时支持REDCap实时数据 + Dify文档知识库
  6. 防止幻觉:所有回答基于真实数据,绝不编造

技术亮点

  • 🧠 SessionMemory内存存储无需Redis
  • 🎯 单步路由不用复杂ReAct循环
  • 🔧 复用现有RedcapAdapter + WechatService
  • 性能保证<3秒端到端延迟
  • 📊 审计完整:记录所有对话

用户价值

BeforeDay 3

  • PI可以接收企业微信通知
  • PI无法主动查询数据
  • 需要登录REDCap查看

AfterPhase 1.5 + Dify集成

  • PI可以在企业微信中直接问"入组多少人"REDCap
  • PI可以问"P001有不良反应吗"REDCap
  • PI可以问"研究的纳入排除标准是什么"Dify
  • PI可以问"CRF表格中有哪些观察指标"Dify
  • AI记得上一轮对话支持代词
  • 回复快速(<6秒有反馈
  • AI基于真实数据/文档回答,不编造

🎉 Phase 1.5 开发完成总结 (2026-01-03 & 2026-01-04)

实际完成情况

  • Day 1完成 (2026-01-03): SessionMemory + ChatService + REDCap集成
  • Day 2完成 (2026-01-04): Dify知识库集成 + 混合检索
  • 测试通过: 企业微信对话 + 真实数据查询 + 文档查询
  • 核心突破: 解决LLM幻觉问题 + 混合检索架构

关键成果

  1. AI基于REDCap真实数据回答不编造
  2. AI基于Dify知识库文档回答研究方案问题
  3. 混合检索:同时支持结构化数据和非结构化文档
  4. 从数据库读取项目配置test0102
  5. 意图识别 + 智能路由 + 数据查询 + LLM集成
  6. 上下文记忆最近3轮对话
  7. 即时反馈("正在查询"

测试验证

  • 项目: test0102
    • REDCap PID: 16, 11条记录
    • Dify Dataset ID: b49595b2-bf71-4e47-9988-4aa2816d3c6f
    • 文档: 研究方案、CRF表格2个文件已处理
  • 场景1: 查询ID 7患者信息REDCap 完全匹配真实数据
  • 场景2: 查询研究排除标准Dify 基于文档准确回答
  • 场景3: 查询CRF观察指标Dify 基于文档准确回答
  • 场景4: 统计入组人数REDCap 准确统计11人
  • 结果: 所有测试通过,无编造

详细记录


维护者IIT Manager开发团队
最后更新2026-01-03
文档状态 Phase 1.5已完成