Summary: - Add 4 new database tables: iit_field_metadata, iit_qc_logs, iit_record_summary, iit_qc_project_stats - Implement pg-boss debounce mechanism in WebhookController - Refactor QC Worker for dual output: QC logs + record summary - Enhance HardRuleEngine to support form-based rule filtering - Create QcService for QC data queries - Optimize ChatService with new intents: query_enrollment, query_qc_status - Add admin batch operations: one-click full QC + one-click full summary - Create IIT Admin management module: project config, QC rules, user mapping Status: Code complete, pending end-to-end testing Co-authored-by: Cursor <cursoragent@cursor.com>
1582 lines
48 KiB
Markdown
1582 lines
48 KiB
Markdown
# IIT Manager Agent 服务层实现指南
|
||
|
||
> **版本:** V2.9
|
||
> **更新日期:** 2026-02-05
|
||
> **关联文档:** [IIT Manager Agent V2.6 综合开发计划](./IIT%20Manager%20Agent%20V2.6%20综合开发计划.md)
|
||
>
|
||
> **V2.9.1 更新**:
|
||
> - 新增 `ProfilerService` 用户画像服务
|
||
> - `ChatService` 增加反馈循环功能
|
||
> - `SchedulerService` 支持 Cron Skill 触发
|
||
> - **新增 `AnonymizerService`**:PII 脱敏中间件(P0 合规必需)
|
||
> - **新增 `AutoMapperService`**:REDCap Schema 自动对齐工具
|
||
|
||
---
|
||
|
||
## 1. 服务层总览
|
||
|
||
| 服务 | 职责 | Phase |
|
||
|------|------|-------|
|
||
| `ToolsService` | 统一工具管理(字段映射 + 执行) | 1 |
|
||
| `AnonymizerService` | **PII 脱敏中间件(P0 合规必需)** | 1.5 |
|
||
| `AutoMapperService` | **REDCap Schema 自动对齐** | 1 |
|
||
| `ChatService` | 消息路由(双脑入口)+ 反馈收集 | 2 |
|
||
| `IntentService` | 意图识别(混合路由) | 5 |
|
||
| `MemoryService` | 记忆管理(V2.8 架构) | 2-3 |
|
||
| `SchedulerService` | 定时任务调度 + Cron Skill | 4 |
|
||
| `ReportService` | 报告生成 | 4 |
|
||
| `ProfilerService` | 用户画像管理(V2.9) | 4 |
|
||
| `VisionService` | 视觉识别(延后) | 6 |
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────────────┐
|
||
│ 路由层 (Router) │
|
||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────┐ │
|
||
│ │ ChatService │ │VisionService │ │ API Routes │ │Scheduler │ │
|
||
│ │ (文本路由) │ │ (图片识别) │ │ (REST API) │ │ (定时) │ │
|
||
│ └──────────────┘ └──────────────┘ └──────────────┘ └──────────┘ │
|
||
└─────────────────────────────────────────────────────────────────────┘
|
||
│
|
||
▼
|
||
┌─────────────────────────────────────────────────────────────────────┐
|
||
│ 工具层 (ToolsService) │
|
||
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
|
||
│ │read_clinical_data│ │write_clinical_data│ │search_protocol │ │
|
||
│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │
|
||
└─────────────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
---
|
||
|
||
## 2. ToolsService - 统一工具管理
|
||
|
||
> **文件路径**: `backend/src/modules/iit-manager/services/ToolsService.ts`
|
||
|
||
### 2.1 核心职责
|
||
|
||
- 定义可供 LLM 调用的工具
|
||
- 实现字段名映射(第一层防御)
|
||
- 实现空结果兜底(第三层防御)
|
||
- 区分只读/读写工具权限
|
||
|
||
### 2.2 工具定义
|
||
|
||
```typescript
|
||
export const TOOL_DEFINITIONS = [
|
||
// ===== 只读工具(ReAct 可用)=====
|
||
{
|
||
name: "read_clinical_data",
|
||
description: "读取 REDCap 临床数据。可指定患者ID和字段列表",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
record_id: { type: "string", description: "患者ID,如 P001" },
|
||
fields: {
|
||
type: "array",
|
||
items: { type: "string" },
|
||
description: "要读取的字段名列表,如 ['age', 'gender', 'ecog']"
|
||
}
|
||
},
|
||
required: ["record_id", "fields"]
|
||
}
|
||
},
|
||
{
|
||
name: "search_protocol",
|
||
description: "检索研究方案文档。返回与问题相关的方案内容",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
query: { type: "string", description: "搜索关键词或问题" }
|
||
},
|
||
required: ["query"]
|
||
}
|
||
},
|
||
{
|
||
name: "get_project_stats",
|
||
description: "获取项目统计数据,如入组人数、完成率等",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
project_id: { type: "string" },
|
||
stat_type: {
|
||
type: "string",
|
||
enum: ["enrollment", "completion", "ae_summary", "query_status"]
|
||
}
|
||
},
|
||
required: ["project_id", "stat_type"]
|
||
}
|
||
},
|
||
{
|
||
name: "check_visit_window",
|
||
description: "检查访视是否在方案允许的时间窗口内",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
record_id: { type: "string" },
|
||
visit_id: { type: "string" },
|
||
actual_date: { type: "string", format: "date" }
|
||
},
|
||
required: ["record_id", "visit_id", "actual_date"]
|
||
}
|
||
},
|
||
|
||
// ===== 读写工具(仅 SOP 可用)=====
|
||
{
|
||
name: "write_clinical_data",
|
||
description: "写入 REDCap 临床数据(需人工确认)",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
record_id: { type: "string" },
|
||
form_name: { type: "string" },
|
||
data: { type: "object" }
|
||
},
|
||
required: ["record_id", "form_name", "data"]
|
||
}
|
||
},
|
||
{
|
||
name: "manage_issue",
|
||
description: "创建或更新质疑(Query)",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
action: { type: "string", enum: ["create", "close", "update"] },
|
||
record_id: { type: "string" },
|
||
field: { type: "string" },
|
||
message: { type: "string" }
|
||
},
|
||
required: ["action", "record_id", "field", "message"]
|
||
}
|
||
}
|
||
];
|
||
```
|
||
|
||
### 2.3 完整实现
|
||
|
||
```typescript
|
||
import { prisma } from '../../common/prisma';
|
||
import { RedcapAdapter } from '../adapters/RedcapAdapter';
|
||
import { DifyClient } from '../../common/rag/DifyClient';
|
||
|
||
export class ToolsService {
|
||
private redcap: RedcapAdapter;
|
||
private dify: DifyClient;
|
||
|
||
constructor(redcap: RedcapAdapter, dify: DifyClient) {
|
||
this.redcap = redcap;
|
||
this.dify = dify;
|
||
}
|
||
|
||
/**
|
||
* 获取工具定义(供 LLM 使用)
|
||
*/
|
||
getToolDefinitions() {
|
||
return TOOL_DEFINITIONS;
|
||
}
|
||
|
||
/**
|
||
* 统一执行入口
|
||
*/
|
||
async executeTool(
|
||
toolName: string,
|
||
args: Record<string, any>,
|
||
context?: { projectId: string }
|
||
): Promise<any> {
|
||
const projectId = context?.projectId || args.project_id;
|
||
|
||
switch (toolName) {
|
||
case 'read_clinical_data':
|
||
return this.readClinicalData(projectId, args.record_id, args.fields);
|
||
case 'search_protocol':
|
||
return this.searchProtocol(projectId, args.query);
|
||
case 'get_project_stats':
|
||
return this.getProjectStats(args.project_id, args.stat_type);
|
||
case 'check_visit_window':
|
||
return this.checkVisitWindow(args);
|
||
case 'write_clinical_data':
|
||
return this.writeClinicalData(args);
|
||
case 'manage_issue':
|
||
return this.manageIssue(args);
|
||
default:
|
||
throw new Error(`未知工具: ${toolName}`);
|
||
}
|
||
}
|
||
|
||
// ===== 字段映射(第一层防御)=====
|
||
|
||
private async mapFields(projectId: string, fields: string[]): Promise<string[]> {
|
||
const mappings = await prisma.iitFieldMapping.findMany({
|
||
where: { projectId }
|
||
});
|
||
|
||
const mappingDict = Object.fromEntries(
|
||
mappings.map(m => [m.aliasName.toLowerCase(), m.actualName])
|
||
);
|
||
|
||
return fields.map(f => {
|
||
const mapped = mappingDict[f.toLowerCase()];
|
||
if (mapped) {
|
||
console.log(`[ToolsService] 字段映射: ${f} -> ${mapped}`);
|
||
}
|
||
return mapped || f;
|
||
});
|
||
}
|
||
|
||
// ===== 工具实现 =====
|
||
|
||
private async readClinicalData(
|
||
projectId: string,
|
||
recordId: string,
|
||
fields: string[]
|
||
): Promise<any> {
|
||
// 字段映射
|
||
const mappedFields = await this.mapFields(projectId, fields);
|
||
|
||
try {
|
||
const data = await this.redcap.getRecord(recordId, mappedFields);
|
||
|
||
// ⚠️ 空结果兜底(第三层防御)
|
||
if (!data || Object.keys(data).length === 0) {
|
||
return {
|
||
success: false,
|
||
message: `未找到患者 ${recordId} 的相关数据。请检查患者ID是否正确。`,
|
||
hint: '可用 get_project_stats 查看项目中的患者列表'
|
||
};
|
||
}
|
||
|
||
return { success: true, data };
|
||
} catch (error) {
|
||
return {
|
||
success: false,
|
||
message: `读取数据失败: ${error.message}`,
|
||
hint: '请检查字段名是否正确'
|
||
};
|
||
}
|
||
}
|
||
|
||
private async searchProtocol(projectId: string, query: string): Promise<any> {
|
||
const results = await this.dify.retrieve(projectId, query);
|
||
|
||
if (!results || results.length === 0) {
|
||
return {
|
||
success: false,
|
||
message: '未找到相关的方案内容',
|
||
hint: '可以尝试更具体的关键词'
|
||
};
|
||
}
|
||
|
||
return {
|
||
success: true,
|
||
results: results.slice(0, 3).map(r => ({
|
||
content: r.content,
|
||
source: r.metadata?.source,
|
||
relevance: r.score
|
||
}))
|
||
};
|
||
}
|
||
|
||
private async getProjectStats(projectId: string, statType: string): Promise<any> {
|
||
// 根据 statType 查询不同统计数据
|
||
switch (statType) {
|
||
case 'enrollment':
|
||
return this.getEnrollmentStats(projectId);
|
||
case 'completion':
|
||
return this.getCompletionStats(projectId);
|
||
case 'ae_summary':
|
||
return this.getAESummary(projectId);
|
||
case 'query_status':
|
||
return this.getQueryStatus(projectId);
|
||
default:
|
||
return { error: `未知的统计类型: ${statType}` };
|
||
}
|
||
}
|
||
|
||
private async checkVisitWindow(args: {
|
||
record_id: string;
|
||
visit_id: string;
|
||
actual_date: string;
|
||
}): Promise<any> {
|
||
const baseline = await this.getBaselineDate(args.record_id);
|
||
const window = await this.getVisitWindow(args.visit_id);
|
||
const actualDays = this.daysBetween(baseline, args.actual_date);
|
||
|
||
const inWindow = actualDays >= window.minDays && actualDays <= window.maxDays;
|
||
|
||
return {
|
||
inWindow,
|
||
expectedRange: `Day ${window.minDays} - Day ${window.maxDays}`,
|
||
actualDay: actualDays,
|
||
deviation: inWindow ? 0 : Math.min(
|
||
Math.abs(actualDays - window.minDays),
|
||
Math.abs(actualDays - window.maxDays)
|
||
)
|
||
};
|
||
}
|
||
|
||
// ... 其他工具实现省略
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 2.5 AnonymizerService - PII 脱敏中间件(P0 合规必需)
|
||
|
||
> **文件路径**: `backend/src/modules/iit-manager/services/AnonymizerService.ts`
|
||
>
|
||
> **⚠️ 重要**:临床数据包含大量患者隐私信息,在调用第三方 LLM 之前**必须脱敏**!
|
||
|
||
### 2.5.1 核心职责
|
||
|
||
- 识别文本中的 PII(个人身份信息)
|
||
- 发送 LLM 前脱敏(Masking)
|
||
- 接收 LLM 回复后还原(Unmasking)
|
||
- 记录脱敏审计日志
|
||
|
||
### 2.5.2 PII 识别正则库
|
||
|
||
```typescript
|
||
// PII 类型定义
|
||
const PII_PATTERNS = {
|
||
// 中文姓名(2-4字,排除常见非姓名词)
|
||
name: /(?<![a-zA-Z\u4e00-\u9fa5])[\u4e00-\u9fa5]{2,4}(?![a-zA-Z\u4e00-\u9fa5])/g,
|
||
|
||
// 身份证号(18位)
|
||
id_card: /\d{6}(19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]/g,
|
||
|
||
// 手机号(11位,1开头)
|
||
phone: /1[3-9]\d{9}/g,
|
||
|
||
// 病历号(字母+数字组合)
|
||
mrn: /(?:MRN|HN|病案号)[::\s]?([A-Z0-9]{6,12})/gi
|
||
};
|
||
|
||
// 非姓名词排除列表(提高准确率)
|
||
const NAME_EXCLUSIONS = [
|
||
'患者', '医生', '护士', '主任', '教授', '方案', '访视',
|
||
'入组', '排除', '标准', '剂量', '疗程', '周期', '疗效'
|
||
];
|
||
```
|
||
|
||
### 2.5.3 完整实现
|
||
|
||
```typescript
|
||
import { prisma } from '../../common/prisma';
|
||
import * as crypto from 'crypto';
|
||
|
||
interface MaskingResult {
|
||
maskedText: string;
|
||
maskingMap: Record<string, string>; // { "[PATIENT_1]": "张三" }
|
||
piiCount: number;
|
||
piiTypes: string[];
|
||
}
|
||
|
||
interface UnmaskingContext {
|
||
maskingMap: Record<string, string>;
|
||
}
|
||
|
||
export class AnonymizerService {
|
||
private encryptionKey: string;
|
||
|
||
constructor() {
|
||
this.encryptionKey = process.env.PII_ENCRYPTION_KEY || 'default-key-change-me';
|
||
}
|
||
|
||
/**
|
||
* 脱敏:发送 LLM 前调用
|
||
*/
|
||
async mask(
|
||
text: string,
|
||
context: { projectId: string; userId: string; sessionId: string }
|
||
): Promise<MaskingResult> {
|
||
const maskingMap: Record<string, string> = {};
|
||
const piiTypes: string[] = [];
|
||
let maskedText = text;
|
||
let counter = { name: 0, id_card: 0, phone: 0, mrn: 0 };
|
||
|
||
// 按优先级处理(先处理身份证,再处理姓名,避免误识别)
|
||
|
||
// 1. 身份证号
|
||
maskedText = maskedText.replace(PII_PATTERNS.id_card, (match) => {
|
||
counter.id_card++;
|
||
const placeholder = `[ID_CARD_${counter.id_card}]`;
|
||
maskingMap[placeholder] = match;
|
||
if (!piiTypes.includes('id_card')) piiTypes.push('id_card');
|
||
return placeholder;
|
||
});
|
||
|
||
// 2. 手机号
|
||
maskedText = maskedText.replace(PII_PATTERNS.phone, (match) => {
|
||
counter.phone++;
|
||
const placeholder = `[PHONE_${counter.phone}]`;
|
||
maskingMap[placeholder] = match;
|
||
if (!piiTypes.includes('phone')) piiTypes.push('phone');
|
||
return placeholder;
|
||
});
|
||
|
||
// 3. 病历号
|
||
maskedText = maskedText.replace(PII_PATTERNS.mrn, (match, mrn) => {
|
||
counter.mrn++;
|
||
const placeholder = `[MRN_${counter.mrn}]`;
|
||
maskingMap[placeholder] = mrn;
|
||
if (!piiTypes.includes('mrn')) piiTypes.push('mrn');
|
||
return placeholder.padEnd(match.length);
|
||
});
|
||
|
||
// 4. 中文姓名(需要更精细的判断)
|
||
maskedText = maskedText.replace(PII_PATTERNS.name, (match) => {
|
||
// 排除非姓名词
|
||
if (NAME_EXCLUSIONS.includes(match)) return match;
|
||
// 排除已被其他规则处理的部分
|
||
if (Object.values(maskingMap).includes(match)) return match;
|
||
|
||
counter.name++;
|
||
const placeholder = `[PATIENT_${counter.name}]`;
|
||
maskingMap[placeholder] = match;
|
||
if (!piiTypes.includes('name')) piiTypes.push('name');
|
||
return placeholder;
|
||
});
|
||
|
||
const piiCount = Object.keys(maskingMap).length;
|
||
|
||
// 记录审计日志
|
||
if (piiCount > 0) {
|
||
await this.saveAuditLog({
|
||
projectId: context.projectId,
|
||
userId: context.userId,
|
||
sessionId: context.sessionId,
|
||
originalHash: this.hashText(text),
|
||
maskedPayload: maskedText,
|
||
maskingMap: this.encrypt(JSON.stringify(maskingMap)),
|
||
piiCount,
|
||
piiTypes
|
||
});
|
||
}
|
||
|
||
return { maskedText, maskingMap, piiCount, piiTypes };
|
||
}
|
||
|
||
/**
|
||
* 还原:接收 LLM 回复后调用
|
||
*/
|
||
unmask(text: string, context: UnmaskingContext): string {
|
||
let result = text;
|
||
|
||
// 将占位符替换回原始值
|
||
for (const [placeholder, original] of Object.entries(context.maskingMap)) {
|
||
result = result.replace(new RegExp(this.escapeRegex(placeholder), 'g'), original);
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
// ===== 辅助方法 =====
|
||
|
||
private hashText(text: string): string {
|
||
return crypto.createHash('sha256').update(text).digest('hex');
|
||
}
|
||
|
||
private encrypt(text: string): string {
|
||
const cipher = crypto.createCipheriv(
|
||
'aes-256-gcm',
|
||
crypto.scryptSync(this.encryptionKey, 'salt', 32),
|
||
crypto.randomBytes(16)
|
||
);
|
||
return cipher.update(text, 'utf8', 'hex') + cipher.final('hex');
|
||
}
|
||
|
||
private escapeRegex(str: string): string {
|
||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||
}
|
||
|
||
private async saveAuditLog(data: {
|
||
projectId: string;
|
||
userId: string;
|
||
sessionId: string;
|
||
originalHash: string;
|
||
maskedPayload: string;
|
||
maskingMap: string;
|
||
piiCount: number;
|
||
piiTypes: string[];
|
||
}): Promise<void> {
|
||
await prisma.iitPiiAuditLog.create({
|
||
data: {
|
||
...data,
|
||
llmProvider: process.env.LLM_PROVIDER || 'qwen'
|
||
}
|
||
});
|
||
}
|
||
}
|
||
```
|
||
|
||
### 2.5.4 集成到 ChatService
|
||
|
||
```typescript
|
||
// ChatService.ts 中的使用
|
||
export class ChatService {
|
||
private anonymizer: AnonymizerService;
|
||
|
||
async handleMessage(userId: string, message: string): Promise<string> {
|
||
const projectId = await this.getUserProject(userId);
|
||
const sessionId = this.sessionMemory.getSessionId(userId);
|
||
|
||
// ⚠️ 调用 LLM 前脱敏
|
||
const { maskedText, maskingMap, piiCount } = await this.anonymizer.mask(
|
||
message,
|
||
{ projectId, userId, sessionId }
|
||
);
|
||
|
||
if (piiCount > 0) {
|
||
console.log(`[Anonymizer] 检测到 ${piiCount} 个 PII,已脱敏`);
|
||
}
|
||
|
||
// 使用脱敏后的文本调用 LLM
|
||
const llmResponse = await this.llm.chat(maskedText, ...);
|
||
|
||
// ⚠️ 收到 LLM 回复后还原
|
||
const unmaskedResponse = this.anonymizer.unmask(llmResponse, { maskingMap });
|
||
|
||
return unmaskedResponse;
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 2.6 AutoMapperService - REDCap Schema 自动对齐
|
||
|
||
> **文件路径**: `backend/src/modules/iit-manager/services/AutoMapperService.ts`
|
||
>
|
||
> **目的**:大幅减少 `iit_field_mapping` 表的人工配置工作量
|
||
|
||
### 2.6.1 核心职责
|
||
|
||
- 解析 REDCap Data Dictionary(CSV/JSON)
|
||
- 使用 LLM 进行语义映射
|
||
- 提供管理后台确认界面
|
||
|
||
### 2.6.2 完整实现
|
||
|
||
```typescript
|
||
import { parse } from 'papaparse';
|
||
import { LLMFactory } from '../../common/llm/adapters/LLMFactory';
|
||
import { prisma } from '../../common/prisma';
|
||
|
||
interface FieldDefinition {
|
||
variableName: string;
|
||
fieldLabel: string;
|
||
fieldType: string;
|
||
choices?: string;
|
||
}
|
||
|
||
interface MappingSuggestion {
|
||
redcapField: string;
|
||
redcapLabel: string;
|
||
suggestedAlias: string[];
|
||
confidence: number;
|
||
status: 'pending' | 'confirmed' | 'rejected';
|
||
}
|
||
|
||
export class AutoMapperService {
|
||
private llm = LLMFactory.create('qwen');
|
||
|
||
// 系统标准字段列表
|
||
private readonly STANDARD_FIELDS = [
|
||
{ name: 'age', aliases: ['年龄', 'age', '岁数'] },
|
||
{ name: 'gender', aliases: ['性别', 'sex', 'gender', '男女'] },
|
||
{ name: 'ecog', aliases: ['ECOG', 'PS评分', '体力状态'] },
|
||
{ name: 'visit_date', aliases: ['访视日期', '就诊日期', 'visit date'] },
|
||
{ name: 'height', aliases: ['身高', 'height', 'ht'] },
|
||
{ name: 'weight', aliases: ['体重', 'weight', 'wt'] },
|
||
{ name: 'bmi', aliases: ['BMI', '体质指数'] },
|
||
{ name: 'consent_date', aliases: ['知情同意日期', 'ICF日期', 'consent date'] },
|
||
{ name: 'enrollment_date', aliases: ['入组日期', 'enrollment date', '入选日期'] }
|
||
];
|
||
|
||
/**
|
||
* 解析 REDCap Data Dictionary
|
||
*/
|
||
async parseDataDictionary(fileContent: string, format: 'csv' | 'json'): Promise<FieldDefinition[]> {
|
||
if (format === 'csv') {
|
||
const result = parse(fileContent, { header: true });
|
||
return result.data.map((row: any) => ({
|
||
variableName: row['Variable / Field Name'] || row['variable_name'],
|
||
fieldLabel: row['Field Label'] || row['field_label'],
|
||
fieldType: row['Field Type'] || row['field_type'],
|
||
choices: row['Choices, Calculations, OR Slider Labels'] || row['choices']
|
||
}));
|
||
} else {
|
||
return JSON.parse(fileContent);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 使用 LLM 生成映射建议
|
||
*/
|
||
async generateMappingSuggestions(
|
||
projectId: string,
|
||
fields: FieldDefinition[]
|
||
): Promise<MappingSuggestion[]> {
|
||
const prompt = `你是一个临床研究数据专家。请将以下 REDCap 字段与系统标准字段进行语义匹配。
|
||
|
||
## 系统标准字段
|
||
${this.STANDARD_FIELDS.map(f => `- ${f.name}: ${f.aliases.join(', ')}`).join('\n')}
|
||
|
||
## REDCap 字段列表
|
||
${fields.slice(0, 50).map(f => `- ${f.variableName}: ${f.fieldLabel}`).join('\n')}
|
||
|
||
请返回 JSON 格式的映射建议:
|
||
{
|
||
"mappings": [
|
||
{
|
||
"redcapField": "nl_age",
|
||
"suggestedAlias": ["age", "年龄"],
|
||
"confidence": 0.95
|
||
}
|
||
]
|
||
}
|
||
|
||
注意:
|
||
1. 只返回有把握的映射(confidence >= 0.7)
|
||
2. 如果不确定,不要强行映射
|
||
3. 一个 REDCap 字段可以有多个别名`;
|
||
|
||
const response = await this.llm.chat([
|
||
{ role: 'user', content: prompt }
|
||
]);
|
||
|
||
try {
|
||
const jsonMatch = response.content.match(/\{[\s\S]*\}/);
|
||
const result = JSON.parse(jsonMatch?.[0] || '{"mappings":[]}');
|
||
|
||
return result.mappings.map((m: any) => ({
|
||
redcapField: m.redcapField,
|
||
redcapLabel: fields.find(f => f.variableName === m.redcapField)?.fieldLabel || '',
|
||
suggestedAlias: m.suggestedAlias,
|
||
confidence: m.confidence,
|
||
status: 'pending' as const
|
||
}));
|
||
} catch (e) {
|
||
console.error('[AutoMapper] LLM 返回解析失败', e);
|
||
return [];
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 批量确认映射
|
||
*/
|
||
async confirmMappings(
|
||
projectId: string,
|
||
confirmations: Array<{
|
||
redcapField: string;
|
||
aliases: string[];
|
||
confirmed: boolean;
|
||
}>
|
||
): Promise<{ created: number; skipped: number }> {
|
||
let created = 0;
|
||
let skipped = 0;
|
||
|
||
for (const conf of confirmations) {
|
||
if (!conf.confirmed) {
|
||
skipped++;
|
||
continue;
|
||
}
|
||
|
||
for (const alias of conf.aliases) {
|
||
try {
|
||
await prisma.iitFieldMapping.upsert({
|
||
where: {
|
||
projectId_aliasName: { projectId, aliasName: alias }
|
||
},
|
||
create: {
|
||
projectId,
|
||
aliasName: alias,
|
||
actualName: conf.redcapField
|
||
},
|
||
update: {
|
||
actualName: conf.redcapField
|
||
}
|
||
});
|
||
created++;
|
||
} catch (e) {
|
||
console.error(`[AutoMapper] 创建映射失败: ${alias} -> ${conf.redcapField}`, e);
|
||
}
|
||
}
|
||
}
|
||
|
||
return { created, skipped };
|
||
}
|
||
|
||
/**
|
||
* 一键导入流程
|
||
*/
|
||
async autoImport(
|
||
projectId: string,
|
||
fileContent: string,
|
||
format: 'csv' | 'json'
|
||
): Promise<{
|
||
suggestions: MappingSuggestion[];
|
||
message: string;
|
||
}> {
|
||
// 1. 解析 Data Dictionary
|
||
const fields = await this.parseDataDictionary(fileContent, format);
|
||
console.log(`[AutoMapper] 解析到 ${fields.length} 个字段`);
|
||
|
||
// 2. 生成 LLM 建议
|
||
const suggestions = await this.generateMappingSuggestions(projectId, fields);
|
||
console.log(`[AutoMapper] 生成 ${suggestions.length} 个映射建议`);
|
||
|
||
return {
|
||
suggestions,
|
||
message: `已解析 ${fields.length} 个 REDCap 字段,生成 ${suggestions.length} 个映射建议,请在管理后台确认。`
|
||
};
|
||
}
|
||
}
|
||
```
|
||
|
||
### 2.6.3 管理后台 API
|
||
|
||
```typescript
|
||
// routes/autoMapperRoutes.ts
|
||
|
||
router.post('/auto-mapper/import', async (req, res) => {
|
||
const { projectId, fileContent, format } = req.body;
|
||
|
||
const result = await autoMapperService.autoImport(projectId, fileContent, format);
|
||
|
||
res.json({
|
||
success: true,
|
||
suggestions: result.suggestions,
|
||
message: result.message
|
||
});
|
||
});
|
||
|
||
router.post('/auto-mapper/confirm', async (req, res) => {
|
||
const { projectId, confirmations } = req.body;
|
||
|
||
const result = await autoMapperService.confirmMappings(projectId, confirmations);
|
||
|
||
res.json({
|
||
success: true,
|
||
created: result.created,
|
||
skipped: result.skipped,
|
||
message: `已创建 ${result.created} 个映射,跳过 ${result.skipped} 个`
|
||
});
|
||
});
|
||
```
|
||
|
||
### 2.6.4 效率对比
|
||
|
||
| 配置方式 | 100 个字段耗时 | 准确率 |
|
||
|----------|---------------|--------|
|
||
| 手动逐条配置 | 2-4 小时 | 100%(人工保证) |
|
||
| LLM 猜测 + 人工确认 | 15-30 分钟 | 95%(LLM猜测)→ 100%(人工确认) |
|
||
|
||
---
|
||
|
||
## 3. IntentService - 意图识别
|
||
|
||
> **文件路径**: `backend/src/modules/iit-manager/services/IntentService.ts`
|
||
|
||
### 3.1 核心职责
|
||
|
||
- **混合路由**:正则快速通道 + LLM 后备
|
||
- 识别用户意图类型(QC_TASK / QA_QUERY / UNCLEAR)
|
||
- 提取实体信息(record_id, visit 等)
|
||
- 生成追问句(当信息不全时)
|
||
|
||
### 3.2 意图类型
|
||
|
||
| 意图类型 | 描述 | 路由目标 |
|
||
|----------|------|----------|
|
||
| `QC_TASK` | 质控任务 | SopEngine |
|
||
| `QA_QUERY` | 模糊查询 | ReActEngine |
|
||
| `PROTOCOL_QA` | 方案问题 | DifyClient |
|
||
| `REPORT` | 报表请求 | ReportService |
|
||
| `HELP` | 帮助请求 | 静态回复 |
|
||
| `UNCLEAR` | 信息不全 | 追问 |
|
||
|
||
### 3.3 完整实现
|
||
|
||
```typescript
|
||
import { LLMFactory } from '../../common/llm/adapters/LLMFactory';
|
||
|
||
export interface IntentResult {
|
||
type: 'QC_TASK' | 'QA_QUERY' | 'PROTOCOL_QA' | 'REPORT' | 'HELP' | 'UNCLEAR';
|
||
confidence: number;
|
||
entities: {
|
||
record_id?: string;
|
||
visit?: string;
|
||
date_range?: string;
|
||
};
|
||
source: 'fast_path' | 'llm' | 'fallback';
|
||
missing_info?: string;
|
||
clarification_question?: string;
|
||
}
|
||
|
||
export class IntentService {
|
||
private llm = LLMFactory.create('qwen');
|
||
|
||
/**
|
||
* 主入口:混合路由
|
||
*/
|
||
async detect(message: string, history: Message[]): Promise<IntentResult> {
|
||
// ⚠️ 第一层:正则快速通道(< 10ms)
|
||
const fastResult = this.fastPathDetect(message);
|
||
if (fastResult) {
|
||
console.log('[IntentService] 命中快速通道');
|
||
return fastResult;
|
||
}
|
||
|
||
// 第二层:LLM 智能识别(复杂长句)
|
||
return this.llmDetect(message, history);
|
||
}
|
||
|
||
/**
|
||
* 带降级的检测(LLM 不可用时回退)
|
||
*/
|
||
async detectWithFallback(message: string, history: Message[]): Promise<IntentResult> {
|
||
try {
|
||
return await this.detect(message, history);
|
||
} catch (error) {
|
||
console.warn('[IntentService] LLM 不可用,回退到关键词匹配');
|
||
return this.keywordFallback(message);
|
||
}
|
||
}
|
||
|
||
// ===== 快速通道:正则匹配 =====
|
||
|
||
private fastPathDetect(message: string): IntentResult | null {
|
||
// 质控任务
|
||
const qcMatch = message.match(/^(质控|检查|校验|QC)\s*(ID|患者|病人)?[=:]?\s*([A-Z]?\d+)/i);
|
||
if (qcMatch) {
|
||
return {
|
||
type: 'QC_TASK',
|
||
confidence: 0.9,
|
||
entities: { record_id: qcMatch[3] },
|
||
source: 'fast_path'
|
||
};
|
||
}
|
||
|
||
// 报表/报告
|
||
if (/^(报表|报告|周报|日报|统计)/.test(message)) {
|
||
return { type: 'REPORT', confidence: 0.9, entities: {}, source: 'fast_path' };
|
||
}
|
||
|
||
// 帮助
|
||
if (/^(帮助|help|怎么用|使用说明)$/i.test(message)) {
|
||
return { type: 'HELP', confidence: 1.0, entities: {}, source: 'fast_path' };
|
||
}
|
||
|
||
// 不匹配,走 LLM
|
||
return null;
|
||
}
|
||
|
||
// ===== LLM 智能识别 =====
|
||
|
||
private async llmDetect(message: string, history: Message[]): Promise<IntentResult> {
|
||
const prompt = `你是一个临床研究助手的"分诊台"。请分析用户输入,返回 JSON。
|
||
|
||
用户输入: "${message}"
|
||
|
||
分类标准:
|
||
1. QC_TASK: 明确的质控、检查、录入指令(如"检查P001的入排标准")
|
||
2. QA_QUERY: 模糊的查询、分析、统计问题(如"查下那个发烧的病人是谁")
|
||
3. PROTOCOL_QA: 关于研究方案的问题(如"访视窗口是多少天")
|
||
4. UNCLEAR: 指代不清,缺少关键信息(如"他怎么样了?")
|
||
|
||
返回格式:
|
||
{
|
||
"type": "QC_TASK" | "QA_QUERY" | "PROTOCOL_QA" | "UNCLEAR",
|
||
"confidence": 0.0-1.0,
|
||
"entities": { "record_id": "...", "visit": "..." },
|
||
"missing_info": "如果 UNCLEAR,说明缺什么信息",
|
||
"clarification_question": "如果 UNCLEAR,生成追问句"
|
||
}`;
|
||
|
||
const response = await this.llm.chat([
|
||
{ role: 'system', content: prompt },
|
||
...history.slice(-3),
|
||
{ role: 'user', content: message }
|
||
]);
|
||
|
||
try {
|
||
const jsonMatch = response.content.match(/\{[\s\S]*\}/);
|
||
const result = JSON.parse(jsonMatch?.[0] || '{}');
|
||
result.source = 'llm';
|
||
return result;
|
||
} catch (e) {
|
||
// 解析失败,回退
|
||
return this.keywordFallback(message);
|
||
}
|
||
}
|
||
|
||
// ===== 降级策略 =====
|
||
|
||
private keywordFallback(message: string): IntentResult {
|
||
if (/质控|检查|校验|QC|入排/.test(message)) {
|
||
return { type: 'QC_TASK', confidence: 0.6, entities: {}, source: 'fallback' };
|
||
}
|
||
if (/方案|访视|窗口|知情同意/.test(message)) {
|
||
return { type: 'PROTOCOL_QA', confidence: 0.6, entities: {}, source: 'fallback' };
|
||
}
|
||
return { type: 'QA_QUERY', confidence: 0.5, entities: {}, source: 'fallback' };
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 4. ChatService - 消息路由
|
||
|
||
> **文件路径**: `backend/src/modules/iit-manager/services/ChatService.ts`(扩展现有)
|
||
|
||
### 4.1 核心职责
|
||
|
||
- 接收企业微信消息
|
||
- 路由到正确的引擎(SOP / ReAct)
|
||
- 集成记忆系统
|
||
- 实现追问机制
|
||
- **V2.9 新增**:收集用户反馈(👍/👎)
|
||
|
||
### 4.2 扩展实现
|
||
|
||
```typescript
|
||
export class ChatService {
|
||
private intentService: IntentService;
|
||
private sopEngine: SopEngine;
|
||
private reactEngine: ReActEngine;
|
||
private memoryService: MemoryService;
|
||
private sessionMemory: SessionMemory;
|
||
private wechatService: WechatService;
|
||
private profilerService: ProfilerService; // V2.9 新增
|
||
|
||
async handleMessage(userId: string, message: string): Promise<string> {
|
||
const projectId = await this.getUserProject(userId);
|
||
|
||
// ⚠️ 立即发送"正在思考..."状态(解决延迟感)
|
||
await this.wechatService.sendTypingStatus(userId);
|
||
|
||
// 获取会话历史
|
||
const history = this.sessionMemory.getHistory(userId);
|
||
|
||
// 意图识别
|
||
const intent = await this.intentService.detectWithFallback(message, history);
|
||
|
||
// 保存用户消息到长期记忆
|
||
await this.memoryService.saveConversation({
|
||
projectId, userId, role: 'user', content: message, intent: intent.type
|
||
});
|
||
|
||
// ===== 追问机制 =====
|
||
if (intent.type === 'UNCLEAR') {
|
||
const clarification = intent.clarification_question
|
||
|| `请问您能具体说明一下吗?`;
|
||
|
||
this.sessionMemory.addMessage(userId, 'assistant', clarification);
|
||
return clarification;
|
||
}
|
||
|
||
// ===== 获取记忆上下文(V2.8) =====
|
||
const memoryContext = await this.memoryService.getContextForPrompt(projectId, intent);
|
||
|
||
// ===== 路由到引擎 =====
|
||
let response: string;
|
||
|
||
if (intent.type === 'QC_TASK') {
|
||
// 走 SOP 引擎
|
||
const skill = await this.getSkillConfig(projectId, 'qc_process');
|
||
const result = await this.sopEngine.run(skill.config, {
|
||
recordId: intent.entities.record_id,
|
||
projectId
|
||
});
|
||
response = this.formatSopResult(result);
|
||
|
||
} else if (intent.type === 'QA_QUERY') {
|
||
// 走 ReAct 引擎
|
||
const result = await this.reactEngine.run(message, {
|
||
history,
|
||
memoryContext,
|
||
projectId
|
||
});
|
||
|
||
// ⚠️ 保存 Trace(仅供 Admin 调试,不发给用户)
|
||
await this.reactEngine.saveTrace(projectId, userId, message, result);
|
||
response = result.content;
|
||
|
||
} else if (intent.type === 'PROTOCOL_QA') {
|
||
// 走知识库
|
||
const results = await this.difyClient.retrieve(projectId, message);
|
||
response = this.formatProtocolResults(results);
|
||
|
||
} else if (intent.type === 'HELP') {
|
||
response = this.getHelpMessage();
|
||
|
||
} else {
|
||
response = '抱歉,我不太理解您的问题。您可以说"帮助"查看使用说明。';
|
||
}
|
||
|
||
// 保存助手回复到长期记忆(返回对话 ID 用于反馈关联)
|
||
const conversationId = await this.memoryService.saveConversation({
|
||
projectId, userId, role: 'assistant', content: response
|
||
});
|
||
|
||
// 更新会话记忆
|
||
this.sessionMemory.addMessage(userId, 'assistant', response);
|
||
|
||
return response;
|
||
}
|
||
|
||
// ===== V2.9 新增:反馈循环 =====
|
||
|
||
/**
|
||
* 处理用户反馈(👍/👎)
|
||
*/
|
||
async handleFeedback(
|
||
conversationId: string,
|
||
feedback: 'thumbs_up' | 'thumbs_down',
|
||
reason?: string
|
||
): Promise<void> {
|
||
// 保存反馈到数据库
|
||
await prisma.iitConversationHistory.update({
|
||
where: { id: conversationId },
|
||
data: { feedback, feedbackReason: reason }
|
||
});
|
||
|
||
// 如果是负反馈,更新用户画像
|
||
if (feedback === 'thumbs_down' && reason) {
|
||
const conversation = await prisma.iitConversationHistory.findUnique({
|
||
where: { id: conversationId }
|
||
});
|
||
|
||
if (conversation) {
|
||
await this.profilerService.updateFromFeedback(
|
||
conversation.projectId,
|
||
conversation.userId,
|
||
reason
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 5. SchedulerService - 定时任务调度
|
||
|
||
> **文件路径**: `backend/src/modules/iit-manager/services/SchedulerService.ts`
|
||
|
||
### 5.1 核心职责
|
||
|
||
- 基于 pg-boss 实现定时任务
|
||
- 调度周报生成
|
||
- 调度记忆卷叠(V2.8)
|
||
- **V2.9 新增**:Cron Skill 主动触发(访视提醒等)
|
||
|
||
### 5.2 完整实现
|
||
|
||
```typescript
|
||
import PgBoss from 'pg-boss';
|
||
|
||
export class SchedulerService {
|
||
private boss: PgBoss;
|
||
private reportService: ReportService;
|
||
private memoryService: MemoryService;
|
||
private wechatService: WechatService;
|
||
|
||
async init() {
|
||
this.boss = new PgBoss(process.env.DATABASE_URL!);
|
||
await this.boss.start();
|
||
|
||
// 注册任务处理器
|
||
await this.boss.work('weekly-report', this.handleWeeklyReport.bind(this));
|
||
await this.boss.work('memory-rollup', this.handleMemoryRollup.bind(this));
|
||
await this.boss.work('sop-continue', this.handleSopContinue.bind(this));
|
||
await this.boss.work('cron-skill', this.handleCronSkill.bind(this)); // V2.9 新增
|
||
await this.boss.work('profile-refresh', this.handleProfileRefresh.bind(this)); // V2.9 新增
|
||
|
||
// 配置定时任务
|
||
// 每周一早上 9 点生成周报
|
||
await this.boss.schedule('weekly-report', '0 9 * * 1', { projectId: 'all' });
|
||
|
||
// 每天凌晨 2 点执行记忆卷叠
|
||
await this.boss.schedule('memory-rollup', '0 2 * * *', { projectId: 'all' });
|
||
|
||
// 每天凌晨 3 点刷新用户画像(V2.9)
|
||
await this.boss.schedule('profile-refresh', '0 3 * * *', { projectId: 'all' });
|
||
|
||
// V2.9 新增:注册所有 Cron Skill
|
||
await this.registerCronSkills();
|
||
}
|
||
|
||
/**
|
||
* V2.9 新增:注册数据库中的 Cron Skill
|
||
*/
|
||
private async registerCronSkills(): Promise<void> {
|
||
const cronSkills = await prisma.iitSkill.findMany({
|
||
where: {
|
||
triggerType: 'cron',
|
||
isActive: true
|
||
}
|
||
});
|
||
|
||
for (const skill of cronSkills) {
|
||
if (skill.cronSchedule) {
|
||
await this.boss.schedule(
|
||
`cron-skill`,
|
||
skill.cronSchedule,
|
||
{ skillId: skill.id, projectId: skill.projectId }
|
||
);
|
||
console.log(`[Scheduler] 注册 Cron Skill: ${skill.name} (${skill.cronSchedule})`);
|
||
}
|
||
}
|
||
}
|
||
|
||
private async handleWeeklyReport(job: Job) {
|
||
const { projectId } = job.data;
|
||
|
||
// 生成周报
|
||
const report = await this.reportService.generateWeeklyReport(projectId);
|
||
|
||
// 发送到管理员群
|
||
await this.wechatService.sendToAdmins(report);
|
||
|
||
// 保存到历史书(V2.8)
|
||
await this.memoryService.saveWeeklyReport(projectId, report);
|
||
}
|
||
|
||
private async handleMemoryRollup(job: Job) {
|
||
const { projectId } = job.data;
|
||
|
||
// V2.8 记忆卷叠:将一周的对话总结为周报
|
||
await this.memoryService.rollupWeeklyMemory(projectId);
|
||
|
||
// 更新热记忆
|
||
await this.memoryService.refreshHotMemory(projectId);
|
||
}
|
||
|
||
private async handleSopContinue(job: Job) {
|
||
const { taskRunId } = job.data;
|
||
|
||
// 恢复执行 SOP 任务
|
||
await this.sopEngine.continueFromCheckpoint(taskRunId);
|
||
}
|
||
|
||
/**
|
||
* V2.9 新增:处理 Cron Skill 触发
|
||
*/
|
||
private async handleCronSkill(job: Job) {
|
||
const { skillId, projectId } = job.data;
|
||
|
||
const skill = await prisma.iitSkill.findUnique({
|
||
where: { id: skillId }
|
||
});
|
||
|
||
if (!skill || !skill.isActive) {
|
||
console.log(`[Scheduler] Skill ${skillId} 已禁用或不存在,跳过`);
|
||
return;
|
||
}
|
||
|
||
console.log(`[Scheduler] 执行 Cron Skill: ${skill.name}`);
|
||
|
||
// 获取项目的用户画像,确定通知对象
|
||
const profiles = await this.profilerService.getAllProfiles(projectId);
|
||
|
||
// 运行 SOP 流程
|
||
const result = await this.sopEngine.run(skill.config, {
|
||
projectId,
|
||
triggerType: 'cron',
|
||
profiles
|
||
});
|
||
|
||
// 根据结果发送通知
|
||
if (result.status === 'COMPLETED' && result.output) {
|
||
for (const profile of profiles) {
|
||
// 检查用户的最佳通知时间(简化版:假设 Cron 已按时间配置)
|
||
await this.wechatService.sendToUser(
|
||
profile.userId,
|
||
this.formatNotification(result.output, profile)
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* V2.9 新增:刷新用户画像
|
||
*/
|
||
private async handleProfileRefresh(job: Job) {
|
||
const { projectId } = job.data;
|
||
|
||
await this.profilerService.refreshProfiles(projectId);
|
||
console.log(`[Scheduler] 用户画像刷新完成: ${projectId}`);
|
||
}
|
||
|
||
/**
|
||
* 根据用户画像格式化通知内容
|
||
*/
|
||
private formatNotification(content: string, profile: UserProfile): string {
|
||
// 根据用户偏好调整通知格式
|
||
if (profile.preference?.includes('简洁')) {
|
||
// 简洁模式:只保留关键信息
|
||
const lines = content.split('\n').filter(l => l.trim());
|
||
return lines.slice(0, 3).join('\n') + (lines.length > 3 ? '\n...' : '');
|
||
}
|
||
return content;
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 6. ReportService - 报告生成
|
||
|
||
> **文件路径**: `backend/src/modules/iit-manager/services/ReportService.ts`
|
||
|
||
### 6.1 完整实现
|
||
|
||
```typescript
|
||
export class ReportService {
|
||
private redcap: RedcapAdapter;
|
||
private memoryService: MemoryService;
|
||
|
||
async generateWeeklyReport(projectId: string): Promise<string> {
|
||
const stats = await this.getProjectStats(projectId);
|
||
const weekRange = this.getWeekRange();
|
||
|
||
// 获取本周的关键对话
|
||
const keyConversations = await this.memoryService.getWeeklyKeyConversations(projectId);
|
||
|
||
const report = `
|
||
📊 **${stats.projectName} 周报**
|
||
📅 ${weekRange}
|
||
|
||
**入组进度**
|
||
- 本周新入组:${stats.newEnrollments} 例
|
||
- 累计入组:${stats.totalEnrollments} / ${stats.targetEnrollments} 例
|
||
- 完成率:${stats.completionRate}%
|
||
|
||
**数据质量**
|
||
- 待处理质疑:${stats.pendingQueries} 条
|
||
- 本周关闭质疑:${stats.closedQueries} 条
|
||
- 方案偏离:${stats.protocolDeviations} 例
|
||
|
||
**AE/SAE**
|
||
- 本周新增 AE:${stats.newAEs} 例
|
||
- 本周新增 SAE:${stats.newSAEs} 例
|
||
|
||
**本周关键决策**
|
||
${keyConversations.decisions.map(d => `- ${d.date}: ${d.content}`).join('\n')}
|
||
|
||
**下周重点**
|
||
${stats.upcomingVisits.map(v => `- ${v.patientId}: ${v.visitName} (${v.dueDate})`).join('\n')}
|
||
`;
|
||
|
||
return report.trim();
|
||
}
|
||
|
||
private getWeekRange(): string {
|
||
const now = new Date();
|
||
const weekStart = new Date(now);
|
||
weekStart.setDate(now.getDate() - now.getDay() + 1);
|
||
const weekEnd = new Date(weekStart);
|
||
weekEnd.setDate(weekStart.getDate() + 6);
|
||
|
||
return `${this.formatDate(weekStart)} ~ ${this.formatDate(weekEnd)}`;
|
||
}
|
||
|
||
private formatDate(date: Date): string {
|
||
return date.toISOString().split('T')[0];
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 7. ProfilerService - 用户画像管理(V2.9)
|
||
|
||
> **文件路径**: `backend/src/modules/iit-manager/services/ProfilerService.ts`
|
||
|
||
### 7.1 核心职责
|
||
|
||
- 管理用户画像(偏好、关注点、最佳通知时间)
|
||
- 根据反馈自动调整偏好
|
||
- 为主动消息提供个性化参数
|
||
|
||
### 7.2 完整实现
|
||
|
||
```typescript
|
||
import { prisma } from '../../common/prisma';
|
||
import { LLMFactory } from '../../common/llm/adapters/LLMFactory';
|
||
|
||
interface UserProfile {
|
||
role: string; // PI | CRC | CRA | PM
|
||
preference: string; // 简洁汇报 | 详细步骤
|
||
focusAreas: string[]; // AE | 入组进度 | 访视安排
|
||
bestNotifyTime: string; // HH:mm 格式
|
||
restrictions: string[]; // 禁令列表
|
||
feedbackStats: {
|
||
thumbsUp: number;
|
||
thumbsDown: number;
|
||
};
|
||
}
|
||
|
||
export class ProfilerService {
|
||
private llm = LLMFactory.create('qwen');
|
||
|
||
/**
|
||
* 获取用户画像(从 project_memory 中解析)
|
||
*/
|
||
async getUserProfile(projectId: string, userId: string): Promise<UserProfile | null> {
|
||
const memory = await prisma.iitProjectMemory.findUnique({
|
||
where: { projectId }
|
||
});
|
||
|
||
if (!memory) return null;
|
||
|
||
// 从 Markdown 中解析用户画像
|
||
return this.parseProfileFromMarkdown(memory.content, userId);
|
||
}
|
||
|
||
/**
|
||
* 根据反馈更新用户偏好
|
||
*/
|
||
async updateFromFeedback(
|
||
projectId: string,
|
||
userId: string,
|
||
feedbackReason: string
|
||
): Promise<void> {
|
||
const memory = await prisma.iitProjectMemory.findUnique({
|
||
where: { projectId }
|
||
});
|
||
|
||
if (!memory) return;
|
||
|
||
// 根据反馈原因推断偏好调整
|
||
const adjustment = this.inferAdjustment(feedbackReason);
|
||
|
||
// 使用 LLM 更新 Markdown 中的用户画像
|
||
const updatedContent = await this.updateProfileInMarkdown(
|
||
memory.content,
|
||
userId,
|
||
adjustment
|
||
);
|
||
|
||
await prisma.iitProjectMemory.update({
|
||
where: { projectId },
|
||
data: {
|
||
content: updatedContent,
|
||
lastUpdatedBy: 'profiler_job'
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 周期性刷新用户画像(每日凌晨运行)
|
||
*/
|
||
async refreshProfiles(projectId: string): Promise<void> {
|
||
// 获取最近一周的对话
|
||
const weekAgo = new Date();
|
||
weekAgo.setDate(weekAgo.getDate() - 7);
|
||
|
||
const conversations = await prisma.iitConversationHistory.findMany({
|
||
where: {
|
||
projectId,
|
||
createdAt: { gte: weekAgo }
|
||
},
|
||
orderBy: { createdAt: 'desc' }
|
||
});
|
||
|
||
// 按用户分组
|
||
const userConversations = this.groupByUser(conversations);
|
||
|
||
// 对每个用户更新画像
|
||
for (const [userId, convs] of Object.entries(userConversations)) {
|
||
await this.updateProfileFromConversations(projectId, userId, convs);
|
||
}
|
||
}
|
||
|
||
// ===== 私有方法 =====
|
||
|
||
private inferAdjustment(reason: string): { key: string; action: string } {
|
||
switch (reason) {
|
||
case 'too_long':
|
||
return { key: 'preference', action: '更简洁的回复' };
|
||
case 'inaccurate':
|
||
return { key: 'restriction', action: '注意数据准确性' };
|
||
case 'unclear':
|
||
return { key: 'preference', action: '更详细的解释' };
|
||
default:
|
||
return { key: 'feedback', action: `用户不满意: ${reason}` };
|
||
}
|
||
}
|
||
|
||
private async updateProfileInMarkdown(
|
||
content: string,
|
||
userId: string,
|
||
adjustment: { key: string; action: string }
|
||
): Promise<string> {
|
||
const prompt = `请更新以下 Markdown 中 ${userId} 的用户画像部分,增加一条偏好记录:${adjustment.action}
|
||
|
||
原内容:
|
||
${content}
|
||
|
||
请只返回更新后的完整 Markdown,不要添加任何解释。`;
|
||
|
||
const response = await this.llm.chat([
|
||
{ role: 'user', content: prompt }
|
||
]);
|
||
|
||
return response.content;
|
||
}
|
||
|
||
private parseProfileFromMarkdown(content: string, userId: string): UserProfile | null {
|
||
// 使用正则从 Markdown 中提取用户画像
|
||
const userSection = content.match(
|
||
new RegExp(`## .*\\(user_id: ${userId}\\)[\\s\\S]*?(?=##|$)`)
|
||
);
|
||
|
||
if (!userSection) return null;
|
||
|
||
const section = userSection[0];
|
||
|
||
return {
|
||
role: this.extractField(section, '角色') || 'CRC',
|
||
preference: this.extractField(section, '偏好') || '默认',
|
||
focusAreas: this.extractList(section, '关注点'),
|
||
bestNotifyTime: this.extractField(section, '最佳通知时间') || '09:00',
|
||
restrictions: this.extractList(section, '禁令'),
|
||
feedbackStats: this.extractFeedbackStats(section)
|
||
};
|
||
}
|
||
|
||
private extractField(text: string, field: string): string | null {
|
||
const match = text.match(new RegExp(`\\*\\*${field}\\*\\*:\\s*(.+)`));
|
||
return match ? match[1].trim() : null;
|
||
}
|
||
|
||
private extractList(text: string, field: string): string[] {
|
||
const match = text.match(new RegExp(`\\*\\*${field}\\*\\*:\\s*(.+)`));
|
||
if (!match) return [];
|
||
return match[1].split(/[,、,]/).map(s => s.trim());
|
||
}
|
||
|
||
private extractFeedbackStats(text: string): { thumbsUp: number; thumbsDown: number } {
|
||
const match = text.match(/👍\s*(\d+)\s*\/\s*👎\s*(\d+)/);
|
||
return {
|
||
thumbsUp: match ? parseInt(match[1]) : 0,
|
||
thumbsDown: match ? parseInt(match[2]) : 0
|
||
};
|
||
}
|
||
|
||
private groupByUser(conversations: any[]): Record<string, any[]> {
|
||
return conversations.reduce((acc, conv) => {
|
||
if (!acc[conv.userId]) acc[conv.userId] = [];
|
||
acc[conv.userId].push(conv);
|
||
return acc;
|
||
}, {} as Record<string, any[]>);
|
||
}
|
||
|
||
private async updateProfileFromConversations(
|
||
projectId: string,
|
||
userId: string,
|
||
conversations: any[]
|
||
): Promise<void> {
|
||
// 分析对话模式,推断用户偏好
|
||
const analysis = await this.analyzeConversationPatterns(conversations);
|
||
|
||
if (analysis.hasSignificantChanges) {
|
||
const memory = await prisma.iitProjectMemory.findUnique({
|
||
where: { projectId }
|
||
});
|
||
|
||
if (memory) {
|
||
const updatedContent = await this.updateProfileInMarkdown(
|
||
memory.content,
|
||
userId,
|
||
{ key: 'behavior', action: analysis.summary }
|
||
);
|
||
|
||
await prisma.iitProjectMemory.update({
|
||
where: { projectId },
|
||
data: {
|
||
content: updatedContent,
|
||
lastUpdatedBy: 'profiler_job'
|
||
}
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
private async analyzeConversationPatterns(conversations: any[]): Promise<{
|
||
hasSignificantChanges: boolean;
|
||
summary: string;
|
||
}> {
|
||
if (conversations.length < 5) {
|
||
return { hasSignificantChanges: false, summary: '' };
|
||
}
|
||
|
||
// 分析反馈统计
|
||
const thumbsUp = conversations.filter(c => c.feedback === 'thumbs_up').length;
|
||
const thumbsDown = conversations.filter(c => c.feedback === 'thumbs_down').length;
|
||
|
||
if (thumbsDown > thumbsUp && thumbsDown >= 3) {
|
||
return {
|
||
hasSignificantChanges: true,
|
||
summary: `近期负反馈较多(👎${thumbsDown}/👍${thumbsUp}),需调整回复策略`
|
||
};
|
||
}
|
||
|
||
return { hasSignificantChanges: false, summary: '' };
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 8. 服务依赖关系
|
||
|
||
```
|
||
┌──────────────────┐
|
||
│ ChatService │
|
||
└────────┬─────────┘
|
||
│
|
||
┌─────────────────────────┼─────────────────────────┐
|
||
│ │ │ │ │
|
||
▼ ▼ ▼ ▼ ▼
|
||
┌────────┐ ┌──────────┐ ┌──────────┐ ┌────────┐ ┌───────────┐
|
||
│Intent │ │Memory │ │Session │ │Profiler│ │Scheduler │
|
||
│Service │ │Service │ │Memory │ │Service │ │Service │
|
||
└────────┘ └──────────┘ └──────────┘ └────────┘ └───────────┘
|
||
│ │ │ │
|
||
▼ │ │ │
|
||
┌─────────────────────────────────────────────────────────┐
|
||
│ Engines │
|
||
│ ┌─────────────┐ ┌──────────────┐ ┌─────────────────┐ │
|
||
│ │ SopEngine │ │ ReActEngine │ │ SoftRuleEngine │ │
|
||
│ └─────────────┘ └──────────────┘ └─────────────────┘ │
|
||
└─────────────────────────┬───────────────────────────────┘
|
||
│
|
||
▼
|
||
┌──────────────┐
|
||
│ToolsService │
|
||
└──────────────┘
|
||
│
|
||
┌────────────────┴────────────────┐
|
||
▼ ▼
|
||
┌──────────────┐ ┌──────────────┐
|
||
│RedcapAdapter │ │ DifyClient │
|
||
└──────────────┘ └──────────────┘
|
||
```
|
||
|
||
**V2.9 新增服务流**:
|
||
```
|
||
SchedulerService ──[cron-skill]──> SopEngine ──> ProfilerService ──> WechatService
|
||
↑
|
||
ChatService ──[feedback]──> MemoryService ──────────────┘
|
||
```
|
||
|
||
---
|
||
|
||
**文档维护人**:AI Agent
|
||
**最后更新**:2026-02-05
|