354 lines
12 KiB
JavaScript
354 lines
12 KiB
JavaScript
/**
|
||
* SoftRuleEngine - 软规则质控引擎 (LLM 推理)
|
||
*
|
||
* 功能:
|
||
* - 调用 LLM 进行复杂的医学逻辑判断
|
||
* - 支持入排标准、AE 事件检测、方案偏离等场景
|
||
* - 返回带证据链的结构化结果
|
||
*
|
||
* 设计原则:
|
||
* - 智能推理:利用 LLM 处理模糊规则和复杂逻辑
|
||
* - 证据链:每个判断都附带推理过程和证据
|
||
* - 三态输出:PASS / FAIL / UNCERTAIN(需人工确认)
|
||
*/
|
||
import { PrismaClient } from '@prisma/client';
|
||
import { LLMFactory } from '../../../common/llm/adapters/LLMFactory.js';
|
||
import { logger } from '../../../common/logging/index.js';
|
||
import { buildClinicalSlice } from '../services/PromptBuilder.js';
|
||
const prisma = new PrismaClient();
|
||
// ============================================================
|
||
// SoftRuleEngine 实现
|
||
// ============================================================
|
||
export class SoftRuleEngine {
|
||
constructor(projectId, config) {
|
||
this.projectId = projectId;
|
||
this.model = config?.model || 'deepseek-v3';
|
||
this.timeoutMs = config?.timeoutMs || 30000;
|
||
}
|
||
/**
|
||
* 执行软规则检查
|
||
*
|
||
* @param recordId 记录ID
|
||
* @param data 记录数据
|
||
* @param checks 要执行的检查列表
|
||
* @returns 检查结果
|
||
*/
|
||
async execute(recordId, data, checks) {
|
||
const startTime = Date.now();
|
||
const results = [];
|
||
const failedChecks = [];
|
||
const uncertainChecks = [];
|
||
logger.info('[SoftRuleEngine] Starting execution', {
|
||
projectId: this.projectId,
|
||
recordId,
|
||
checkCount: checks.length,
|
||
model: this.model,
|
||
});
|
||
// 逐个执行检查(可以改为并发,但需注意 Token 限制)
|
||
for (const check of checks) {
|
||
try {
|
||
const result = await this.executeCheck(recordId, data, check);
|
||
results.push(result);
|
||
if (result.status === 'FAIL') {
|
||
failedChecks.push(result);
|
||
}
|
||
else if (result.status === 'UNCERTAIN') {
|
||
uncertainChecks.push(result);
|
||
}
|
||
}
|
||
catch (error) {
|
||
logger.error('[SoftRuleEngine] Check execution failed', {
|
||
checkId: check.id,
|
||
error: error.message,
|
||
});
|
||
// 发生错误时标记为 UNCERTAIN
|
||
const errorResult = {
|
||
checkId: check.id,
|
||
checkName: check.name,
|
||
status: 'UNCERTAIN',
|
||
reason: `执行出错: ${error.message}`,
|
||
evidence: {},
|
||
confidence: 0,
|
||
severity: check.severity,
|
||
category: check.category,
|
||
};
|
||
results.push(errorResult);
|
||
uncertainChecks.push(errorResult);
|
||
}
|
||
}
|
||
// 计算整体状态
|
||
let overallStatus = 'PASS';
|
||
if (failedChecks.length > 0) {
|
||
overallStatus = 'FAIL';
|
||
}
|
||
else if (uncertainChecks.length > 0) {
|
||
overallStatus = 'UNCERTAIN';
|
||
}
|
||
const duration = Date.now() - startTime;
|
||
logger.info('[SoftRuleEngine] Execution completed', {
|
||
recordId,
|
||
overallStatus,
|
||
totalChecks: checks.length,
|
||
failed: failedChecks.length,
|
||
uncertain: uncertainChecks.length,
|
||
duration: `${duration}ms`,
|
||
});
|
||
return {
|
||
recordId,
|
||
projectId: this.projectId,
|
||
timestamp: new Date().toISOString(),
|
||
overallStatus,
|
||
summary: {
|
||
totalChecks: checks.length,
|
||
passed: results.filter(r => r.status === 'PASS').length,
|
||
failed: failedChecks.length,
|
||
uncertain: uncertainChecks.length,
|
||
},
|
||
results,
|
||
failedChecks,
|
||
uncertainChecks,
|
||
};
|
||
}
|
||
/**
|
||
* 执行单个检查
|
||
*/
|
||
async executeCheck(recordId, data, check) {
|
||
const startTime = Date.now();
|
||
// 1. 构建 Prompt
|
||
const prompt = this.buildCheckPrompt(recordId, data, check);
|
||
// 2. 调用 LLM
|
||
const llmAdapter = LLMFactory.getAdapter(this.model);
|
||
const response = await llmAdapter.chat([
|
||
{
|
||
role: 'system',
|
||
content: this.getSystemPrompt(),
|
||
},
|
||
{
|
||
role: 'user',
|
||
content: prompt,
|
||
},
|
||
]);
|
||
const rawResponse = response.content;
|
||
// 3. 解析响应
|
||
const parsed = this.parseResponse(rawResponse, check);
|
||
const duration = Date.now() - startTime;
|
||
logger.debug('[SoftRuleEngine] Check executed', {
|
||
checkId: check.id,
|
||
status: parsed.status,
|
||
confidence: parsed.confidence,
|
||
duration: `${duration}ms`,
|
||
});
|
||
return {
|
||
checkId: check.id,
|
||
checkName: check.name,
|
||
status: parsed.status,
|
||
reason: parsed.reason,
|
||
evidence: parsed.evidence,
|
||
confidence: parsed.confidence,
|
||
severity: check.severity,
|
||
category: check.category,
|
||
rawResponse,
|
||
};
|
||
}
|
||
/**
|
||
* 构建检查 Prompt
|
||
*/
|
||
buildCheckPrompt(recordId, data, check) {
|
||
// 使用 PromptBuilder 生成临床数据切片
|
||
const clinicalSlice = buildClinicalSlice({
|
||
task: check.name,
|
||
criteria: [check.description || check.name],
|
||
patientData: data,
|
||
tags: check.requiredTags,
|
||
instruction: '请根据以下数据进行判断。',
|
||
});
|
||
// 替换 Prompt 模板中的变量
|
||
let userPrompt = check.promptTemplate;
|
||
// 替换 {{variable}} 格式的占位符
|
||
userPrompt = userPrompt.replace(/\{\{(\w+)\}\}/g, (_, key) => {
|
||
return data[key] !== undefined ? String(data[key]) : `[${key}未提供]`;
|
||
});
|
||
// 替换 {{#tag}} 格式的数据标签
|
||
userPrompt = userPrompt.replace(/\{\{#(\w+)\}\}/g, (_, tag) => {
|
||
// 根据标签筛选相关字段
|
||
return JSON.stringify(data, null, 2);
|
||
});
|
||
return `${clinicalSlice}\n\n---\n\n## 检查任务\n\n${userPrompt}`;
|
||
}
|
||
/**
|
||
* 获取系统 Prompt
|
||
*/
|
||
getSystemPrompt() {
|
||
return `你是一个专业的临床研究数据监查员 (CRA),负责核查受试者数据的质量和合规性。
|
||
|
||
## 你的职责
|
||
1. 仔细分析提供的临床数据
|
||
2. 根据检查任务进行判断
|
||
3. 给出清晰的判断结果和理由
|
||
|
||
## 输出格式要求
|
||
请严格按照以下 JSON 格式输出:
|
||
|
||
\`\`\`json
|
||
{
|
||
"status": "PASS" | "FAIL" | "UNCERTAIN",
|
||
"reason": "判断理由的详细说明",
|
||
"evidence": {
|
||
"key_field_1": "相关数据值",
|
||
"key_field_2": "相关数据值"
|
||
},
|
||
"confidence": 0.95
|
||
}
|
||
\`\`\`
|
||
|
||
## 状态说明
|
||
- **PASS**: 检查通过,数据符合要求
|
||
- **FAIL**: 检查失败,发现问题
|
||
- **UNCERTAIN**: 数据不足或存在歧义,需要人工确认
|
||
|
||
## 置信度说明
|
||
- 0.9-1.0: 非常确定
|
||
- 0.7-0.9: 比较确定
|
||
- 0.5-0.7: 有一定把握
|
||
- <0.5: 不太确定,建议人工复核
|
||
|
||
请只输出 JSON,不要有其他内容。`;
|
||
}
|
||
/**
|
||
* 解析 LLM 响应
|
||
*/
|
||
parseResponse(rawResponse, check) {
|
||
try {
|
||
// 尝试提取 JSON
|
||
const jsonMatch = rawResponse.match(/```json\s*([\s\S]*?)\s*```/);
|
||
const jsonStr = jsonMatch ? jsonMatch[1] : rawResponse;
|
||
const parsed = JSON.parse(jsonStr.trim());
|
||
// 验证状态值
|
||
const validStatuses = ['PASS', 'FAIL', 'UNCERTAIN'];
|
||
const status = validStatuses.includes(parsed.status?.toUpperCase())
|
||
? parsed.status.toUpperCase()
|
||
: 'UNCERTAIN';
|
||
return {
|
||
status: status,
|
||
reason: parsed.reason || '未提供理由',
|
||
evidence: parsed.evidence || {},
|
||
confidence: typeof parsed.confidence === 'number'
|
||
? Math.min(1, Math.max(0, parsed.confidence))
|
||
: 0.5,
|
||
};
|
||
}
|
||
catch (error) {
|
||
logger.warn('[SoftRuleEngine] Failed to parse LLM response', {
|
||
checkId: check.id,
|
||
rawResponse: rawResponse.substring(0, 500),
|
||
});
|
||
// 解析失败时尝试简单匹配
|
||
const lowerResponse = rawResponse.toLowerCase();
|
||
if (lowerResponse.includes('pass') || lowerResponse.includes('通过')) {
|
||
return {
|
||
status: 'PASS',
|
||
reason: rawResponse,
|
||
evidence: {},
|
||
confidence: 0.6,
|
||
};
|
||
}
|
||
else if (lowerResponse.includes('fail') || lowerResponse.includes('失败') || lowerResponse.includes('不符合')) {
|
||
return {
|
||
status: 'FAIL',
|
||
reason: rawResponse,
|
||
evidence: {},
|
||
confidence: 0.6,
|
||
};
|
||
}
|
||
return {
|
||
status: 'UNCERTAIN',
|
||
reason: `无法解析响应: ${rawResponse.substring(0, 200)}`,
|
||
evidence: {},
|
||
confidence: 0.3,
|
||
};
|
||
}
|
||
}
|
||
/**
|
||
* 批量执行检查
|
||
*
|
||
* @param records 记录列表
|
||
* @param checks 检查列表
|
||
* @returns 所有记录的检查结果
|
||
*/
|
||
async executeBatch(records, checks) {
|
||
const results = [];
|
||
for (const record of records) {
|
||
const result = await this.execute(record.recordId, record.data, checks);
|
||
results.push(result);
|
||
}
|
||
return results;
|
||
}
|
||
}
|
||
// ============================================================
|
||
// 工厂函数
|
||
// ============================================================
|
||
/**
|
||
* 创建 SoftRuleEngine 实例
|
||
*
|
||
* @param projectId 项目ID
|
||
* @param config 可选配置
|
||
* @returns SoftRuleEngine 实例
|
||
*/
|
||
export function createSoftRuleEngine(projectId, config) {
|
||
return new SoftRuleEngine(projectId, config);
|
||
}
|
||
// ============================================================
|
||
// 预置检查模板
|
||
// ============================================================
|
||
/**
|
||
* 入排标准检查模板
|
||
*/
|
||
export const INCLUSION_EXCLUSION_CHECKS = [
|
||
{
|
||
id: 'IE-001',
|
||
name: '年龄入组标准',
|
||
description: '检查受试者年龄是否符合入组标准',
|
||
promptTemplate: '请根据受试者数据,判断其年龄是否在研究方案规定的入组范围内。如果年龄字段缺失,请标记为 UNCERTAIN。',
|
||
requiredTags: ['#demographics'],
|
||
category: 'inclusion',
|
||
severity: 'critical',
|
||
},
|
||
{
|
||
id: 'IE-002',
|
||
name: '确诊时间入组标准',
|
||
description: '检查受试者确诊时间是否符合入组标准(通常要求确诊在一定时间内)',
|
||
promptTemplate: '请根据受试者的确诊日期和入组日期,判断确诊时间是否符合研究方案要求。',
|
||
requiredTags: ['#demographics', '#medical_history'],
|
||
category: 'inclusion',
|
||
severity: 'critical',
|
||
},
|
||
];
|
||
/**
|
||
* AE 事件检测模板
|
||
*/
|
||
export const AE_DETECTION_CHECKS = [
|
||
{
|
||
id: 'AE-001',
|
||
name: 'Lab 异常与 AE 一致性',
|
||
description: '检查实验室检查异常值是否已在 AE 表中报告',
|
||
promptTemplate: '请对比实验室检查数据和不良事件记录,判断是否存在未报告的实验室异常(Grade 3 及以上)。',
|
||
requiredTags: ['#lab', '#ae'],
|
||
category: 'ae_detection',
|
||
severity: 'critical',
|
||
},
|
||
];
|
||
/**
|
||
* 方案偏离检测模板
|
||
*/
|
||
export const PROTOCOL_DEVIATION_CHECKS = [
|
||
{
|
||
id: 'PD-001',
|
||
name: '访视超窗检测',
|
||
description: '检查访视是否在方案规定的时间窗口内',
|
||
promptTemplate: '请根据访视日期数据,判断各访视之间的时间间隔是否符合方案规定的访视窗口。',
|
||
requiredTags: ['#visits'],
|
||
category: 'protocol_deviation',
|
||
severity: 'warning',
|
||
},
|
||
];
|