# IIT Manager Agent - Phase 1.5 AI对话能力开发计划 > **版本**: v2.0(极简版 + 上下文记忆) > **创建日期**: 2026-01-03 > **完成日期**: 2026-01-03 > **状态**: ✅ **已完成** > **实际工作量**: ~1天(极简版) > **核心价值**: PI可在企业微信中自然对话查询REDCap真实数据 > **核心成就**: ✅ REDCap数据集成 + ✅ 上下文记忆 + ✅ 解决LLM幻觉 --- ## 🚀 极简版快速启动(1天上线)⚡ **通用能力层加速!** ### 🎉 重大发现:通用能力层已完善! **平台现状**(2026-01-03调研结果): - ✅ **LLMFactory 完全就绪**:5种模型(DeepSeek/Qwen/GPT-5/Claude),单例模式,零配置 - ✅ **ChatContainer 完全就绪**:Ant Design X组件,已在Tool C验证(~968行) - ✅ **环境变量已配置**:`DEEPSEEK_API_KEY`、`QWEN_API_KEY`等 - ✅ **成熟实践**:ASL、DC模块已大量使用,稳定可靠 **核心优势**: ```typescript // 后端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 1(4-6小时): 实现基础对话 + 上下文记忆 + "typing"反馈 - 复用LLMFactory(0开发) - 创建ChatService.ts(2小时) - 创建SessionMemory.ts(2小时) - 修改WechatCallbackController(2小时) ❌ 暂不实现: Dify知识库、周报生成、复杂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:创建SessionMemory(30分钟) **文件位置**:`backend/src/modules/iit-manager/agents/SessionMemory.ts` ```typescript /** * 会话记忆管理器(内存版) * 存储用户最近3轮对话,用于上下文理解 */ export class SessionMemory { // 内存存储:{ userId: ConversationHistory } private sessions: Map = 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:创建ChatService(2小时)⚡ 复用LLMFactory **文件位置**:`backend/src/modules/iit-manager/services/ChatService.ts` ```typescript 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 { 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:简化ToolExecutor(1.5小时) **文件位置**:`backend/src/modules/iit-manager/agents/SimpleToolExecutor.ts` ```typescript 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 { 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 { 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 { 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 { 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:简化AnswerGenerator(1小时) **文件位置**:`backend/src/modules/iit-manager/agents/SimpleAnswerGenerator.ts` ```typescript 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:集成到WechatCallbackController(1小时) **修改文件**:`backend/src/modules/iit-manager/controllers/WechatCallbackController.ts` **在handleCallback方法中添加**: ```typescript 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 { // ... 现有的验证、解密逻辑 ... // ✅ 立即返回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提取**: ```typescript // 在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中使用**: ```typescript // 如果用户说"他有不良反应吗?",自动填充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小时) **测试场景**: ```typescript // 场景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 1:Dify环境配置与知识库创建(8小时) #### 任务1.1:验证Dify本地环境(1小时) **检查项**: ```bash # 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` **代码实现**: ```typescript 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 { 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`): ```bash # 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` ```typescript 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 Schema)(2小时) **文件位置**:`backend/src/modules/iit-manager/agents/tools.ts` ```typescript /** * 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 Router)(3小时) **文件位置**:`backend/src/modules/iit-manager/agents/IntentRouter.ts` ```typescript 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 { 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 Executor)(3小时) **文件位置**:`backend/src/modules/iit-manager/agents/ToolExecutor.ts` ```typescript 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 { 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 { // 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 { // 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:增强WechatCallbackController(3小时) **修改文件**:`backend/src/modules/iit-manager/controllers/WechatCallbackController.ts` **在现有的`handleCallback`方法中增加AI对话逻辑**: ```typescript // 在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 { // ... 现有的验证、解密逻辑 ... // ✅ 立即返回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 Generator)(2小时) **文件位置**:`backend/src/modules/iit-manager/agents/AnswerGenerator.ts` ```typescript 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 { 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 { 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小时) **测试场景**: ```typescript // 测试场景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` ```typescript 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 { 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` ```typescript 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.2:Phase 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 环境变量配置 ```bash # 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 数据库迁移 ```bash # 添加周报表 npx prisma db push npx prisma generate ``` ### 5.3 重启服务 ```bash 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: ❌ "请提供患者编号"(失忆了) ``` **解决**: ```typescript // 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秒 - 用户发完消息后,手机没反应 - 用户以为系统挂了 **解决**: ```typescript // 立即发送临时反馈 await wechatService.sendTextMessage(userId, '🫡 正在查询,请稍候...'); // 再慢慢处理 const answer = await processQuery(userMessage); await wechatService.sendMarkdownMessage(userId, answer); ``` **效果**: - ✅ 用户立即看到反馈(<1秒) - ✅ 知道AI在工作 - ✅ 不会焦虑 --- #### 改进3:极简优先 ✅ **问题**: - 原计划5天开发太长 - Dify、周报等功能非必需 - 用户最想要:能对话 **解决**: - ✅ 只做REDCap查询(复用现有adapter) - ✅ 不接Dify(Phase 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分钟) ```bash cd AIclinicalresearch/backend/src/modules/iit-manager mkdir -p agents touch agents/SessionMemory.ts ``` 复制Day 1任务1.1的代码到 `SessionMemory.ts` #### Step 2:运行单元测试(可选) ```bash 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 4:Day 1结束时测试 在企业微信中发送: ``` "现在入组多少人?" ``` 预期: 1. 立即收到"🫡 正在查询,请稍候..." 2. 3秒内收到"📊 项目统计..." #### Step 5:Day 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天),验证价值 ✅ 收集用户反馈 ✅ 再决定是否做完整版 --- ## ✅ 九、总结 ### 核心成就(极简版) 1. ✅ **2天上线**:最快实现AI对话能力 2. ✅ **上下文记忆**:支持多轮对话(3轮) 3. ✅ **正在输入反馈**:避免用户焦虑 4. ✅ **代词解析**:"他"能自动识别患者 5. ✅ **零成本**:只查REDCap,不用额外服务 ### 技术亮点 - 🧠 **SessionMemory**:内存存储,无需Redis - 🎯 **单步路由**:不用复杂ReAct循环 - 🔧 **复用现有**:RedcapAdapter + WechatService - ⚡ **性能保证**:<3秒端到端延迟 - 📊 **审计完整**:记录所有对话 ### 用户价值 **Before(Day 3)**: - ✅ PI可以接收企业微信通知 - ❌ PI无法主动查询数据 - ❌ 需要登录REDCap查看 **After(Phase 1.5极简版)**: - ✅ PI可以在企业微信中直接问"入组多少人" - ✅ PI可以问"P001有不良反应吗" - ✅ AI记得上一轮对话,支持代词 - ✅ 回复快速(<3秒),有反馈 --- ## 🎉 Phase 1.5 开发完成总结 (2026-01-03) ### **实际完成情况** - ✅ **Day 1完成**: SessionMemory + ChatService + REDCap集成 - ✅ **测试通过**: 企业微信对话 + 真实数据查询 - ✅ **核心突破**: 解决LLM幻觉问题 ### **关键成果** 1. ✅ AI基于REDCap真实数据回答,不编造 2. ✅ 从数据库读取项目配置(test0102) 3. ✅ 意图识别 + 数据查询 + LLM集成 4. ✅ 上下文记忆(最近3轮对话) 5. ✅ 即时反馈("正在查询") ### **测试验证** - **项目**: test0102 (REDCap PID: 16, 10条记录) - **场景**: 查询ID 7患者信息 - **结果**: ✅ 完全匹配真实数据,无编造 ### **详细记录** 参见:[Phase 1.5开发完成记录](../06-开发记录/Phase1.5-AI对话集成REDCap完成记录.md) --- **维护者**:IIT Manager开发团队 **最后更新**:2026-01-03 **文档状态**:✅ Phase 1.5已完成