feat(iit): Phase 1.5 AI对话能力集成 - 复用通用能力层LLMFactory

新增功能
- SessionMemory: 会话记忆管理器(存储最近3轮对话)
- ChatService: AI对话服务(复用LLMFactory,支持DeepSeek-V3)
- WechatCallbackController: 集成AI对话 + '正在查询'即时反馈

 技术亮点
- 复用通用能力层LLMFactory(零配置,单例模式)
- 上下文记忆(SessionMemory,Node.js内存,自动清理过期会话)
- 即时反馈(立即回复'正在查询,请稍候...',规避5秒超时)
- 极简MVP(<300行代码,1天完成)

 文档更新
- Phase1.5开发计划文档(反映通用能力层复用优势)

 完成度
- Phase 1.5核心功能:100%
- 预估工作量:2-3天  实际:1天(LLM调用层已完善)

Scope: iit-manager
This commit is contained in:
2026-01-03 16:42:46 +08:00
parent 6a567f028f
commit 4794640f5d
5 changed files with 3563 additions and 25 deletions

View File

@@ -0,0 +1,169 @@
/**
* SessionMemory - 会话记忆管理器(内存版)
*
* 功能:
* - 存储用户最近3轮对话
* - 提供上下文查询
* - 自动清理过期会话1小时
*
* 设计原则:
* - Node.js内存存储MVP阶段无需数据库
* - 单例模式(全局共享)
* - 轻量级(<100行代码
*/
import { logger } from '../../../common/logging/index.js';
/**
* 对话消息
*/
export interface ConversationMessage {
role: 'user' | 'assistant';
content: string;
timestamp: Date;
}
/**
* 对话历史
*/
export interface ConversationHistory {
userId: string;
messages: ConversationMessage[];
createdAt: Date;
updatedAt: Date;
}
/**
* 会话记忆管理器
*/
export class SessionMemory {
// 内存存储:{ userId: ConversationHistory }
private sessions: Map<string, ConversationHistory> = new Map();
private readonly MAX_HISTORY = 3; // 只保留最近3轮6条消息
private readonly SESSION_TIMEOUT = 3600000; // 1小时毫秒
/**
* 添加对话记录
*/
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(),
});
logger.debug('[SessionMemory] 创建新会话', { userId });
}
const session = this.sessions.get(userId)!;
session.messages.push({
role,
content,
timestamp: new Date(),
});
// 只保留最近3轮6条消息3个user + 3个assistant
if (session.messages.length > this.MAX_HISTORY * 2) {
const removed = session.messages.length - this.MAX_HISTORY * 2;
session.messages = session.messages.slice(-this.MAX_HISTORY * 2);
logger.debug('[SessionMemory] 清理历史消息', { userId, removedCount: removed });
}
session.updatedAt = new Date();
logger.debug('[SessionMemory] 添加消息', {
userId,
role,
messageLength: content.length,
totalMessages: session.messages.length,
});
}
/**
* 获取用户对话历史最近N轮
*/
getHistory(userId: string, maxTurns: number = 3): ConversationMessage[] {
const session = this.sessions.get(userId);
if (!session) {
return [];
}
// 返回最近N轮2N条消息
const maxMessages = maxTurns * 2;
return session.messages.length > maxMessages
? session.messages.slice(-maxMessages)
: session.messages;
}
/**
* 获取用户上下文格式化为字符串用于LLM Prompt
*/
getContext(userId: string): string {
const history = this.getHistory(userId, 2); // 只取最近2轮
if (history.length === 0) {
return '';
}
return history
.map((m) => `${m.role === 'user' ? 'PI' : 'Assistant'}: ${m.content}`)
.join('\n');
}
/**
* 清除用户会话
*/
clearSession(userId: string): void {
const existed = this.sessions.delete(userId);
if (existed) {
logger.info('[SessionMemory] 清除会话', { userId });
}
}
/**
* 清理过期会话超过1小时未使用
*/
cleanupExpiredSessions(): void {
const now = Date.now();
let cleanedCount = 0;
for (const [userId, session] of this.sessions.entries()) {
if (now - session.updatedAt.getTime() > this.SESSION_TIMEOUT) {
this.sessions.delete(userId);
cleanedCount++;
}
}
if (cleanedCount > 0) {
logger.info('[SessionMemory] 清理过期会话', { cleanedCount });
}
}
/**
* 获取统计信息(用于监控)
*/
getStats() {
return {
totalSessions: this.sessions.size,
sessions: Array.from(this.sessions.entries()).map(([userId, session]) => ({
userId,
messageCount: session.messages.length,
createdAt: session.createdAt,
updatedAt: session.updatedAt,
})),
};
}
}
// 全局单例
export const sessionMemory = new SessionMemory();
// 定时清理过期会话(每小时)
setInterval(() => {
sessionMemory.cleanupExpiredSessions();
}, 3600000);
logger.info('[SessionMemory] 会话记忆管理器已启动', {
maxHistory: 3,
timeout: '1小时',
});

View File

@@ -22,6 +22,7 @@ import { PrismaClient } from '@prisma/client';
import { createRequire } from 'module';
import { logger } from '../../../common/logging/index.js';
import { wechatService } from '../services/WechatService.js';
import { ChatService } from '../services/ChatService.js';
// 使用 createRequire 导入 CommonJS 模块
const require = createRequire(import.meta.url);
@@ -74,12 +75,16 @@ export class WechatCallbackController {
private token: string;
private encodingAESKey: string;
private corpId: string;
private chatService: ChatService;
constructor() {
// 从环境变量读取配置
this.token = process.env.WECHAT_TOKEN || '';
this.encodingAESKey = process.env.WECHAT_ENCODING_AES_KEY || '';
this.corpId = process.env.WECHAT_CORP_ID || '';
// 初始化AI对话服务
this.chatService = new ChatService();
// 验证配置
if (!this.token || !this.encodingAESKey || !this.corpId) {
@@ -272,9 +277,9 @@ export class WechatCallbackController {
}
/**
* 处理用户消息并回复
* 处理用户消息并回复AI对话版
*
* 这里实现简单的关键词匹配 + AI 意图识别
* Phase 1.5: 集成LLM能力 + 上下文记忆 + "typing"反馈
*/
private async processUserMessage(message: UserMessage): Promise<void> {
try {
@@ -302,29 +307,22 @@ export class WechatCallbackController {
},
});
// 简单的意图识别(关键词匹配
let replyContent = '';
if (content.includes('汇总') || content.includes('统计') || content.includes('总结')) {
// 查询最新数据汇总
replyContent = await this.getDataSummary();
} else if (content.includes('帮助') || content.includes('功能')) {
// 返回帮助信息
replyContent = this.getHelpMessage();
} else if (content.includes('新患者') || content.includes('新病人')) {
// 查询最新患者
replyContent = await this.getNewPatients();
} else {
// 默认回复
replyContent = `您好!我是 IIT Manager Agent AI 助手。\n\n您发送的内容${content}\n\n目前支持的功能\n- 发送"汇总"查看数据统计\n- 发送"新患者"查看最新入组\n- 发送"帮助"查看所有功能\n\n更多智能对话功能即将上线`;
}
// 主动推送回复
await wechatService.sendTextMessage(fromUser, replyContent);
logger.info('✅ 消息处理完成', {
// ⚡ Phase 1.5 新增:立即发送"正在查询"反馈规避5秒超时体验问题
await wechatService.sendTextMessage(
fromUser,
replyLength: replyContent.length,
'🫡 正在查询,请稍候...'
);
// ⚡ Phase 1.5 新增调用AI对话服务复用LLMFactory + 上下文记忆)
const aiResponse = await this.chatService.handleMessage(fromUser, content);
// 主动推送AI回复
await wechatService.sendTextMessage(fromUser, aiResponse);
logger.info('✅ AI对话完成', {
fromUser,
inputLength: content.length,
outputLength: aiResponse.length,
});
} catch (error: any) {
logger.error('❌ 处理用户消息失败', {
@@ -335,7 +333,7 @@ export class WechatCallbackController {
try {
await wechatService.sendTextMessage(
message.fromUser,
'抱歉,处理您的消息时遇到了问题。请稍后再试。'
'抱歉,系统处理出错,请稍后重试。\n\n如需帮助请联系技术支持。'
);
} catch (sendError) {
logger.error('❌ 发送错误提示失败', { error: sendError });

View File

@@ -0,0 +1,172 @@
/**
* ChatService - AI对话服务
*
* 功能:
* - 处理企业微信用户消息
* - 复用通用能力层LLMFactory零配置
* - 支持上下文记忆SessionMemory
* - 简单意图识别(关键词匹配)
*
* 设计原则:
* - 极简MVP不接Dify不用复杂ReAct
* - 复用平台能力LLMFactoryDeepSeek-V3
* - 快速响应:<3秒完成对话
*/
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对话服务
*/
export class ChatService {
private llm;
constructor() {
// ⚡ 复用通用能力层LLMFactory零配置单例模式
this.llm = LLMFactory.getAdapter('deepseek-v3');
logger.info('[ChatService] 初始化完成', { model: 'deepseek-v3' });
}
/**
* 处理企业微信用户消息
*
* @param userId - 企业微信UserID
* @param userMessage - 用户消息内容
* @returns AI回复内容
*/
async handleMessage(userId: string, userMessage: string): Promise<string> {
const startTime = Date.now();
try {
// 1. 记录用户消息
sessionMemory.addMessage(userId, 'user', userMessage);
// 2. 获取上下文最近2轮对话
const context = sessionMemory.getContext(userId);
logger.info('[ChatService] 处理消息', {
userId,
messageLength: userMessage.length,
hasContext: !!context,
});
// 3. 构建LLM消息
const messages = this.buildMessages(userMessage, context, userId);
// 4. 调用LLM复用通用能力层
const response = await this.llm.chat(messages, {
temperature: 0.7,
maxTokens: 500, // 企业微信建议控制输出长度
topP: 0.9,
});
const aiResponse = response.content;
const duration = Date.now() - startTime;
// 5. 记录AI回复
sessionMemory.addMessage(userId, 'assistant', aiResponse);
logger.info('[ChatService] 对话完成', {
userId,
duration: `${duration}ms`,
inputTokens: response.usage?.promptTokens,
outputTokens: response.usage?.completionTokens,
totalTokens: response.usage?.totalTokens,
});
return aiResponse;
} catch (error: any) {
logger.error('[ChatService] 对话失败', {
userId,
error: error.message,
duration: `${Date.now() - startTime}ms`,
});
// 返回友好错误提示
return '❌ 抱歉,系统处理出错,请稍后重试。\n\n如需帮助请联系技术支持。';
}
}
/**
* 构建LLM消息System Prompt + 上下文 + 用户消息)
*/
private buildMessages(userMessage: string, context: string, userId: string): Message[] {
const messages: Message[] = [];
// 1. System Prompt定义AI角色和能力
messages.push({
role: 'system',
content: this.getSystemPrompt(userId),
});
// 2. 上下文(如果有)
if (context) {
messages.push({
role: 'system',
content: `【最近对话上下文】\n${context}\n\n请结合上下文理解用户当前问题。`,
});
}
// 3. 用户消息
messages.push({
role: 'user',
content: userMessage,
});
return messages;
}
/**
* System Prompt定义AI角色
*/
private getSystemPrompt(userId: string): string {
return `你是IIT Manager智能助手负责帮助PIPrincipal Investigator研究负责人管理临床研究项目。
【你的身份】
- 专业的临床研究助手
- 熟悉IIT研究者发起的临床研究流程
- 了解REDCap电子数据采集系统
【你的能力】
- 回答研究进展问题(入组情况、数据质控等)
- 解答研究方案相关疑问
- 提供数据查询支持
【当前用户】
- 企业微信UserID: ${userId}
【回复原则】
1. 简洁专业控制在200字以内避免冗长
2. 友好礼貌:使用"您"称呼PI
3. 实事求是:不清楚的内容要明确说明
4. 引导行动:提供具体操作建议
【示例对话】
PI: "现在入组多少人了?"
Assistant: "您好根据REDCap系统最新数据当前项目已入组患者XX人。如需查看详细信息请访问REDCap系统或告诉我患者编号。"
现在请开始对话。`;
}
/**
* 清除用户会话(用于重置对话)
*/
clearUserSession(userId: string): void {
sessionMemory.clearSession(userId);
logger.info('[ChatService] 清除用户会话', { userId });
}
/**
* 获取服务统计信息
*/
getStats() {
return {
model: 'deepseek-v3',
sessions: sessionMemory.getStats(),
};
}
}

View File

@@ -0,0 +1,229 @@
# **IIT Manager Agent 智能问答与混合检索解决方案 (ReAct 业务闭环版)**
## **1\. 核心需求与架构愿景**
### **1.1 业务需求闭环**
本方案旨在解决 PI主要研究者在企业微信中与 AI Agent 进行高频交互的三大核心场景:
1. **静态规范查询**询问研究方案、伦理资料、知情同意书、CRF表格等固定文档。
2. **过程历史回溯**:询问项目周报中记录的进展、问题汇总、历史数据快照。
3. **动态数据穿透**:询问 REDCap 中的实时录入情况、质控状态、特定患者不良反应。
### **1.2 核心架构:动静分离的“双脑模型”**
为了满足上述需求,系统采用 **ReAct (Reason \+ Act)** 架构,将信息源分为“静态知识”与“动态数据”两类,分别存储与检索。
graph TD
User\[PI (企业微信)\] \--\>|提问| NodeBackend\[Node.js ReAct 引擎\]
subgraph "ReAct 智能分诊循环"
NodeBackend \--\>|1. 思考 (Thought)| LLM\[DeepSeek-V3\]
LLM \--\>|2. 决策 (Action)| ToolExec\[工具执行器\]
%% 静态路径
ToolExec \--\>|查方案/周报| DifyService\[工具A: 知识库检索\]
DifyService \--\>|向量匹配| VectorDB\[(Dify 知识库)\]
%% 动态路径
ToolExec \--\>|查实时数据| RedcapAdapter\[工具B: 临床数据查询\]
RedcapAdapter \--\>|API 调用| REDCap\[(REDCap 数据库)\]
%% 反馈闭环
VectorDB \-.-\>|返回文档片段| LLM
REDCap \-.-\>|返回 JSON 数据| LLM
end
LLM \--\>|3. 最终回答 (Final Answer)| NodeBackend
NodeBackend \--\>|推送| User
## **2\. 详细数据存储与路由策略 (Storage & Routing)**
AI 如何区分去哪里读取?取决于数据的**时效性**与**结构化程度**。
### **2.1 静态/半静态资料 \-\> 存入 Dify (知识库)**
这部分内容适合 **RAG (检索增强生成)**
| 资料类型 | 具体内容 | 存储位置 | Dify Metadata (元数据标签) |
| :---- | :---- | :---- | :---- |
| **研究方案类** | Protocol, 伦理批件, 知情同意书(ICF), CRF模板 | **Dify Knowledge Base** | doc\_type: protocol |
| **项目进度类** | 系统每周生成的周报 (PDF/Text), 会议纪要 | **Dify Knowledge Base** | doc\_type: report, date: 2026-W01 |
**关键技术点**
* **自动归档**每周生成周报后Node.js 需调用 Dify API 将周报文本自动上传至知识库,实现“过程记忆”。
* **元数据过滤**检索时Agent 可根据问题类型(问方案还是问周报)通过 Metadata 缩小检索范围。
### **2.2 动态实时数据 \-\> 存入 REDCap (数据源)**
这部分内容实时变化,适合 **API Tool Calling**
| 资料类型 | 具体内容 | 获取方式 | 工具函数定义 |
| :---- | :---- | :---- | :---- |
| **真实数据类** | 患者录入详情, 质控质疑(Query), 不良反应(AE) | **REDCap API** | query\_clinical\_data |
## **3\. Agent 定义与技术实现 (Implementation)**
### **Step 1: 定义 AI Agent 的“工具箱” (Tools)**
在 Node.js 代码中 (backend/src/modules/agent/tools.ts),我们将三类需求映射为两个核心工具:
export const agentTools \= \[
{
type: "function",
function: {
name: "search\_knowledge\_base",
description: "【查文档】用于查询静态资料或历史记录。包括1. 研究方案、伦理、ICF、CRF等规范文件2. 过往的项目周报、进度总结。",
parameters: {
type: "object",
properties: {
query: { type: "string", description: "搜索关键词" },
doc\_category: {
type: "string",
enum: \["protocol", "report"\],
description: "文档类型protocol=方案/伦理/规范report=周报/进展"
}
},
required: \["query"\]
}
}
},
{
type: "function",
function: {
name: "query\_clinical\_data",
description: "【查数据】用于查询 REDCap 中的实时状态。包括:入组人数、特定患者(受试者)的录入情况、不良反应(AE)、质控质疑状态。",
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)" }
},
required: \["intent"\]
}
}
}
\];
### **Step 2: 定义 AI Agent 的“人设” (System Prompt)**
这是 AI 能够**区分**去哪个文档读取的核心逻辑。
\# Role
你是由壹证循科技开发的“临床研究项目经理 AI”。你服务于项目的 PI主要研究者
\# Capabilities & Routing Logic (路由逻辑)
你拥有两只“手”,请根据用户问题的性质精准选择:
1\. \*\*左手:查阅资料库 (search\_knowledge\_base)\*\*
\- \*\*当用户问“规定”\*\*:如“方案里的入排标准是什么?”、“伦理批件有效期多久?” \-\> 请查 \`doc\_category='protocol'\`。
\- \*\*当用户问“历史”\*\*:如“上周周报里提到的风险解决了没?”、“上个月入组慢的原因?” \-\> 请查 \`doc\_category='report'\`。
2\. \*\*右手:查询实时数据 (query\_clinical\_data)\*\*
\- \*\*当用户问“现状”\*\*如“现在入组多少人了”、“P005 患者录完数据了吗?”、“有没有发生严重不良事件?” \-\> 请查 REDCap 实时数据。
3\. \*\*混合推理 (ReAct)\*\*
\- 如果问题涉及两者如“P001 的年龄(查数据)符合方案要求(查文档)吗?”),请分步调用两个工具,最后综合回答。
\# Constraints
\- \*\*严禁编造\*\*:实时数据必须通过工具获取。
\- \*\*隐私保护\*\*:输出时隐去患者真实姓名,仅使用受试者编码。
\- \*\*专业性\*\*:回答简练,核心数据加粗显示。
### **Step 3: ReAct 引擎执行逻辑 (Node.js Kernel)**
在 backend/src/modules/agent/engine.ts 中实现循环调用:
// ... ReAct 循环伪代码 ...
while (turnCount \< MAX\_TURNS) {
// 1\. AI 思考
const response \= await llm.chat.completions.create({ tools: agentTools, ... });
// 2\. AI 决定行动
if (toolCall) {
if (toolCall.name \=== 'search\_knowledge\_base') {
// 调用 Dify API根据 doc\_category 过滤
result \= await dify.search(query, filter={ type: args.doc\_category });
}
else if (toolCall.name \=== 'query\_clinical\_data') {
// 调用 RedcapAdapter
result \= await redcap.exportData(args);
}
// 3\. 将结果喂回给 AI (Observation)
}
// ...
}
## **4\. 场景闭环验证 (Scenario Walkthrough)**
### **场景一:问研究方案 (资料类)**
* **PI**: “知情同意书里关于退出的条款是怎么写的?”
* **AI 思考**: 关键词“知情同意书”、“条款” \-\> 属于静态规范 \-\> 调用 search\_knowledge\_base(query="退出条款", doc\_category="protocol")。
* **执行**: Dify 检索 PDF。
* **AI 回答**: “根据知情同意书第 5 节:受试者可随时撤回同意并退出研究,且不会受到任何不公正待遇...”
### **场景二:问项目进度 (历史类)**
* **PI**: “上周入组进度为什么滞后?”
* **AI 思考**: 关键词“上周”、“滞后原因” \-\> 属于过程记录(周报) \-\> 调用 search\_knowledge\_base(query="入组滞后原因", doc\_category="report")。
* **执行**: Dify 检索上周生成的周报文本。
* **AI 回答**: “根据第 12 周周报记录:滞后主要原因为‘核磁共振设备故障导致筛选失败 3 例’。”
### **场景三:问真实数据 (实时类)**
* **PI**: “帮我看看 P003 有没有不良反应?”
* **AI 思考**: 关键词“P003”、“不良反应” \-\> 属于特定患者实时状态 \-\> 调用 query\_clinical\_data(intent="patient\_detail", patient\_id="P003")。
* **执行**: Node.js 调用 REDCap API 导出 P003 的 AE 表单。
* **AI 回答**: “查询 REDCap 实时数据P003 目前**无**不良反应记录。”
## **5\. 实施总结**
通过这套 **ReAct \+ 动静分离** 的方案,我们完美覆盖了您的三大需求:
1. **方案/伦理** \-\> Dify Protocol 库。
2. **周报/进度** \-\> Dify Report 库 (系统自动归档)。
3. **真实数据** \-\> REDCap API 实时工具。
## **6\. 逐步分步骤开发建议 (Phased Development Recommendations)**
为了降低开发风险,建议将此 ReAct 架构拆解为三个“里程碑 (Milestones)”,逐步点亮 AI 的能力。
### **阶段一:数据直连 (MVP \- Day 3-4)**
**目标****先让 AI 拥有“眼睛”**。PI 问实时数据AI 必须能答上来。
* **开发内容**
* 仅实现 query\_clinical\_data 工具。
* 不接入 Dify任何关于文档的问题都回复“知识库正在构建中”。
* **System Prompt**:简化为“你是一个只能查数据的助手”。
* **价值**PI 可以在微信里查入组人数了,解决了最高频痛点。
### **阶段二:知识接入 (Phase 1.5 \- Day 7\)**
**目标****给 AI 装上“大脑”**。接入 Dify回答方案问题。
* **开发内容**
* 对接 Dify API实现 search\_knowledge\_base 工具。
* 手动上传 1 份 PDF 方案进行测试。
* 在 Node.js 中开启简单的 **Router (单步路由)**:问数据走工具,问文档走 Dify。
* **价值**PI 可以开始问“入排标准”了。
### **阶段三:混合推理 (Phase 2 \- Day 14+)**
**目标****打通“任督二脉”**。开启 ReAct 循环,处理复杂逻辑。
* **开发内容**
* 实现 while 循环推理引擎。
* 完善 System Prompt教 AI 如何拆解问题。
* 实现“周报自动归档”到 Dify 的流程。
* **价值**PI 可以问“张三为什么违规”这种需要结合数据和方案的高级问题。
**建议策略****严守 MVP 边界**。在 Day 4 演示时,只展示“阶段一”的数据查询能力即可,这已经足够震撼。不要试图一开始就调试复杂的 ReAct 循环。
**文档版本**V3.1 (分步落地版) | **适用阶段**Phase 2