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:
2026-02-19 20:57:00 +08:00
parent 8137e3cde2
commit 49b5c37cb1
86 changed files with 21207 additions and 252 deletions

View 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;
}