355 lines
12 KiB
JavaScript
355 lines
12 KiB
JavaScript
/**
|
||
* HardRuleEngine - 硬规则质控引擎
|
||
*
|
||
* 功能:
|
||
* - 基于 JSON Logic 执行质控规则
|
||
* - 支持纳入标准、排除标准、变量范围检查
|
||
* - 返回结构化的质控结果
|
||
*
|
||
* 设计原则:
|
||
* - 零容忍:规则判断是确定性的,不依赖 AI 猜测
|
||
* - 可追溯:每条规则执行结果都有详细记录
|
||
* - 高性能:纯逻辑计算,无 LLM 调用
|
||
*/
|
||
import jsonLogic from 'json-logic-js';
|
||
import { PrismaClient } from '@prisma/client';
|
||
import { logger } from '../../../common/logging/index.js';
|
||
const prisma = new PrismaClient();
|
||
// ============================================================
|
||
// HardRuleEngine 实现
|
||
// ============================================================
|
||
export class HardRuleEngine {
|
||
constructor(projectId) {
|
||
this.rules = [];
|
||
this.fieldMappings = new Map();
|
||
this.projectId = projectId;
|
||
}
|
||
/**
|
||
* 初始化引擎(加载规则和字段映射)
|
||
*
|
||
* @param formName 可选,按表单名过滤规则(用于单表实时质控)
|
||
*/
|
||
async initialize(formName) {
|
||
// 1. 加载质控规则
|
||
const skill = await prisma.iitSkill.findFirst({
|
||
where: {
|
||
projectId: this.projectId,
|
||
skillType: 'qc_process',
|
||
isActive: true
|
||
}
|
||
});
|
||
if (!skill) {
|
||
throw new Error(`No active QC rules found for project: ${this.projectId}`);
|
||
}
|
||
const config = skill.config;
|
||
let allRules = config.rules || [];
|
||
// ⭐ 如果指定了 formName,则只加载该表单相关的规则
|
||
// 规则通过 formName 或 field 字段来判断所属表单
|
||
if (formName) {
|
||
allRules = allRules.filter((rule) => {
|
||
// 优先使用规则中的 formName 字段
|
||
if (rule.formName) {
|
||
return rule.formName === formName;
|
||
}
|
||
// 如果规则没有 formName,则默认包含(兼容旧规则)
|
||
// TODO: 后续可以通过 field_metadata 表来判断字段所属表单
|
||
return true;
|
||
});
|
||
}
|
||
this.rules = allRules;
|
||
logger.info('[HardRuleEngine] Rules loaded', {
|
||
projectId: this.projectId,
|
||
ruleCount: this.rules.length
|
||
});
|
||
// 2. 加载字段映射
|
||
const mappings = await prisma.iitFieldMapping.findMany({
|
||
where: { projectId: this.projectId }
|
||
});
|
||
for (const m of mappings) {
|
||
this.fieldMappings.set(m.aliasName, m.actualName);
|
||
}
|
||
logger.info('[HardRuleEngine] Field mappings loaded', {
|
||
mappingCount: this.fieldMappings.size
|
||
});
|
||
}
|
||
/**
|
||
* 执行质控检查
|
||
*
|
||
* @param recordId 记录ID
|
||
* @param data 记录数据(REDCap 格式)
|
||
* @returns 质控结果
|
||
*/
|
||
execute(recordId, data) {
|
||
const startTime = Date.now();
|
||
const results = [];
|
||
const errors = [];
|
||
const warnings = [];
|
||
// 1. 数据预处理:应用字段映射
|
||
const normalizedData = this.normalizeData(data);
|
||
// 2. 执行每条规则
|
||
for (const rule of this.rules) {
|
||
const result = this.executeRule(rule, normalizedData);
|
||
results.push(result);
|
||
if (!result.passed) {
|
||
if (result.severity === 'error') {
|
||
errors.push(result);
|
||
}
|
||
else if (result.severity === 'warning') {
|
||
warnings.push(result);
|
||
}
|
||
}
|
||
}
|
||
// 3. 计算整体状态
|
||
let overallStatus = 'PASS';
|
||
if (errors.length > 0) {
|
||
overallStatus = 'FAIL';
|
||
}
|
||
else if (warnings.length > 0) {
|
||
overallStatus = 'WARNING';
|
||
}
|
||
const duration = Date.now() - startTime;
|
||
logger.info('[HardRuleEngine] QC completed', {
|
||
recordId,
|
||
overallStatus,
|
||
totalRules: this.rules.length,
|
||
errors: errors.length,
|
||
warnings: warnings.length,
|
||
duration: `${duration}ms`
|
||
});
|
||
return {
|
||
recordId,
|
||
projectId: this.projectId,
|
||
timestamp: new Date().toISOString(),
|
||
overallStatus,
|
||
summary: {
|
||
totalRules: this.rules.length,
|
||
passed: results.filter(r => r.passed).length,
|
||
failed: errors.length,
|
||
warnings: warnings.length
|
||
},
|
||
results,
|
||
errors,
|
||
warnings
|
||
};
|
||
}
|
||
/**
|
||
* 批量质控检查
|
||
*
|
||
* @param records 记录数组
|
||
* @returns 质控结果数组
|
||
*/
|
||
executeBatch(records) {
|
||
return records.map(r => this.execute(r.recordId, r.data));
|
||
}
|
||
/**
|
||
* 执行单条规则
|
||
*
|
||
* V2.1 优化:生成自包含的 LLM 友好消息
|
||
*/
|
||
executeRule(rule, data) {
|
||
try {
|
||
// 获取字段值
|
||
const fieldValue = this.getFieldValue(rule.field, data);
|
||
// 执行 JSON Logic
|
||
const passed = jsonLogic.apply(rule.logic, data);
|
||
// V2.1: 解析期望值(从 JSON Logic 中提取)
|
||
const expectedValue = this.extractExpectedValue(rule.logic);
|
||
const expectedCondition = this.describeLogic(rule.logic);
|
||
// V2.1: 构建自包含的 LLM 友好消息
|
||
const llmMessage = passed
|
||
? '通过'
|
||
: this.buildLlmMessage(rule, fieldValue, expectedValue);
|
||
// V2.1: 构建结构化证据
|
||
const evidence = {
|
||
value: fieldValue,
|
||
threshold: expectedValue,
|
||
unit: rule.metadata?.unit,
|
||
};
|
||
return {
|
||
ruleId: rule.id,
|
||
ruleName: rule.name,
|
||
field: rule.field,
|
||
passed,
|
||
message: passed ? '通过' : rule.message,
|
||
llmMessage,
|
||
severity: rule.severity,
|
||
category: rule.category,
|
||
actualValue: fieldValue,
|
||
expectedValue,
|
||
expectedCondition,
|
||
evidence,
|
||
};
|
||
}
|
||
catch (error) {
|
||
logger.error('[HardRuleEngine] Rule execution error', {
|
||
ruleId: rule.id,
|
||
error: error.message
|
||
});
|
||
return {
|
||
ruleId: rule.id,
|
||
ruleName: rule.name,
|
||
field: rule.field,
|
||
passed: false,
|
||
message: `规则执行出错: ${error.message}`,
|
||
llmMessage: `规则执行出错: ${error.message}`,
|
||
severity: 'error',
|
||
category: rule.category
|
||
};
|
||
}
|
||
}
|
||
/**
|
||
* V2.1: 从 JSON Logic 中提取期望值
|
||
*/
|
||
extractExpectedValue(logic) {
|
||
const operator = Object.keys(logic)[0];
|
||
const args = logic[operator];
|
||
switch (operator) {
|
||
case '>=':
|
||
case '<=':
|
||
case '>':
|
||
case '<':
|
||
case '==':
|
||
case '!=':
|
||
return String(args[1]);
|
||
case 'and':
|
||
// 对于 and 逻辑,尝试提取范围
|
||
const values = args.map((a) => this.extractExpectedValue(a)).filter(Boolean);
|
||
if (values.length === 2) {
|
||
return `${values[0]}-${values[1]}`;
|
||
}
|
||
return values.join(', ');
|
||
case '!!':
|
||
return '非空/必填';
|
||
default:
|
||
return '';
|
||
}
|
||
}
|
||
/**
|
||
* V2.1: 构建 LLM 友好的自包含消息
|
||
*
|
||
* 格式:当前 **{actualValue}** (标准: {expectedValue})
|
||
*/
|
||
buildLlmMessage(rule, actualValue, expectedValue) {
|
||
const fieldName = Array.isArray(rule.field) ? rule.field.join(', ') : rule.field;
|
||
const displayValue = actualValue !== undefined && actualValue !== null && actualValue !== ''
|
||
? `**${actualValue}**`
|
||
: '**空**';
|
||
// 根据规则类别生成不同的消息格式
|
||
switch (rule.category) {
|
||
case 'inclusion':
|
||
return `**${rule.name}**: 当前值 ${displayValue} (入排标准: ${expectedValue || rule.message})`;
|
||
case 'exclusion':
|
||
return `**${rule.name}**: 当前值 ${displayValue} 触发排除条件`;
|
||
case 'lab_values':
|
||
return `**${rule.name}**: 当前值 ${displayValue} (正常范围: ${expectedValue})`;
|
||
case 'logic_check':
|
||
return `**${rule.name}**: \`${fieldName}\` = ${displayValue} (要求: ${expectedValue || '非空'})`;
|
||
default:
|
||
return `**${rule.name}**: 当前值 ${displayValue} (标准: ${expectedValue || rule.message})`;
|
||
}
|
||
}
|
||
/**
|
||
* 获取字段值(支持映射)
|
||
*/
|
||
getFieldValue(field, data) {
|
||
if (Array.isArray(field)) {
|
||
return field.map(f => data[f]);
|
||
}
|
||
// 先尝试直接获取
|
||
if (data[field] !== undefined) {
|
||
return data[field];
|
||
}
|
||
// 再尝试通过映射获取
|
||
const mappedField = this.fieldMappings.get(field);
|
||
if (mappedField && data[mappedField] !== undefined) {
|
||
return data[mappedField];
|
||
}
|
||
return undefined;
|
||
}
|
||
/**
|
||
* 数据标准化(应用字段映射,转换类型)
|
||
*/
|
||
normalizeData(data) {
|
||
const normalized = { ...data };
|
||
// 1. 应用字段映射(反向映射:actualName -> aliasName)
|
||
for (const [alias, actual] of this.fieldMappings.entries()) {
|
||
if (data[actual] !== undefined && normalized[alias] === undefined) {
|
||
normalized[alias] = data[actual];
|
||
}
|
||
}
|
||
// 2. 类型转换(字符串数字转数字)
|
||
for (const [key, value] of Object.entries(normalized)) {
|
||
if (typeof value === 'string' && value !== '' && !isNaN(Number(value))) {
|
||
normalized[key] = Number(value);
|
||
}
|
||
}
|
||
return normalized;
|
||
}
|
||
/**
|
||
* 描述 JSON Logic 表达式(用于报告)
|
||
*/
|
||
describeLogic(logic) {
|
||
const operator = Object.keys(logic)[0];
|
||
const args = logic[operator];
|
||
switch (operator) {
|
||
case '>=':
|
||
return `>= ${args[1]}`;
|
||
case '<=':
|
||
return `<= ${args[1]}`;
|
||
case '>':
|
||
return `> ${args[1]}`;
|
||
case '<':
|
||
return `< ${args[1]}`;
|
||
case '==':
|
||
return `= ${args[1]}`;
|
||
case '!=':
|
||
return `≠ ${args[1]}`;
|
||
case 'and':
|
||
return args.map((a) => this.describeLogic(a)).join(' 且 ');
|
||
case 'or':
|
||
return args.map((a) => this.describeLogic(a)).join(' 或 ');
|
||
case '!!':
|
||
return '非空';
|
||
default:
|
||
return JSON.stringify(logic);
|
||
}
|
||
}
|
||
/**
|
||
* 获取规则列表
|
||
*/
|
||
getRules() {
|
||
return this.rules;
|
||
}
|
||
/**
|
||
* 获取规则统计
|
||
*/
|
||
getRuleStats() {
|
||
const byCategory = {};
|
||
const bySeverity = {};
|
||
for (const rule of this.rules) {
|
||
byCategory[rule.category] = (byCategory[rule.category] || 0) + 1;
|
||
bySeverity[rule.severity] = (bySeverity[rule.severity] || 0) + 1;
|
||
}
|
||
return {
|
||
total: this.rules.length,
|
||
byCategory,
|
||
bySeverity
|
||
};
|
||
}
|
||
}
|
||
// ============================================================
|
||
// 工厂函数
|
||
// ============================================================
|
||
/**
|
||
* 创建并初始化 HardRuleEngine
|
||
*
|
||
* @param projectId 项目ID
|
||
* @param formName 可选,按表单名过滤规则(用于单表实时质控)
|
||
* 如果不传,则加载所有规则(用于全案批量质控)
|
||
*/
|
||
export async function createHardRuleEngine(projectId, formName) {
|
||
const engine = new HardRuleEngine(projectId);
|
||
await engine.initialize(formName);
|
||
return engine;
|
||
}
|