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:
354
backend/src/modules/iit-manager/engines/HardRuleEngine.js
Normal file
354
backend/src/modules/iit-manager/engines/HardRuleEngine.js
Normal file
@@ -0,0 +1,354 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
Reference in New Issue
Block a user