feat(ssa): Complete T-test end-to-end testing with 9 bug fixes - Phase 1 core 85% complete. R service: missing value auto-filter. Backend: error handling, variable matching, dynamic filename. Frontend: module activation, session isolation, error propagation. Full flow verified.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
353
backend/src/modules/iit-manager/engines/SoftRuleEngine.js
Normal file
353
backend/src/modules/iit-manager/engines/SoftRuleEngine.js
Normal file
@@ -0,0 +1,353 @@
|
||||
/**
|
||||
* 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',
|
||||
},
|
||||
];
|
||||
Reference in New Issue
Block a user