- feat: ChatService集成DeepSeek-V3实现AI对话(390行) - feat: SessionMemory实现上下文记忆(最近3轮对话,170行) - feat: 意图识别支持REDCap数据查询(关键词匹配) - feat: REDCap数据注入LLM(queryRedcapRecord, countRedcapRecords, getProjectInfo) - feat: 解决LLM幻觉问题(基于真实数据回答,明确system prompt) - feat: 即时反馈(正在查询...提示) - test: REDCap查询测试通过(test0102项目,10条记录,ID 7患者详情) - docs: 创建Phase1.5开发完成记录(313行) - docs: 更新Phase1.5开发计划(标记完成) - docs: 更新MVP开发任务清单(Phase 1.5完成) - docs: 更新模块当前状态(60%完成度) - docs: 更新系统总体设计文档(v2.6) - chore: 删除测试脚本(test-redcap-query-for-ai.ts, check-env-config.ts) - chore: 移除REDCap测试环境变量(REDCAP_TEST_*) 技术亮点: - AI基于REDCap真实数据对话,不编造信息 - 从数据库读取项目配置,不使用环境变量 - 企业微信端测试通过,用户体验良好 测试通过: - 查询项目记录总数(10条) - 查询特定患者详情(ID 7) - 项目信息查询 - 上下文记忆(3轮对话) - 即时反馈提示 影响范围:IIT Manager Agent模块
2993 lines
78 KiB
Markdown
2993 lines
78 KiB
Markdown
# 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<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:创建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<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:简化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<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:简化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<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提取**:
|
||
|
||
```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<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`):
|
||
```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<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 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<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:增强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<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 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<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小时)
|
||
|
||
**测试场景**:
|
||
|
||
```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<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`
|
||
|
||
```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已完成
|
||
|