Files
AIclinicalresearch/backend/src/modules/iit-manager/engines/SoftRuleEngine.js

354 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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',
},
];