feat(iit): Implement real-time quality control system

Summary:

- Add 4 new database tables: iit_field_metadata, iit_qc_logs, iit_record_summary, iit_qc_project_stats

- Implement pg-boss debounce mechanism in WebhookController

- Refactor QC Worker for dual output: QC logs + record summary

- Enhance HardRuleEngine to support form-based rule filtering

- Create QcService for QC data queries

- Optimize ChatService with new intents: query_enrollment, query_qc_status

- Add admin batch operations: one-click full QC + one-click full summary

- Create IIT Admin management module: project config, QC rules, user mapping

Status: Code complete, pending end-to-end testing
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-02-07 21:56:11 +08:00
parent 0c590854b5
commit 5db4a7064c
74 changed files with 13383 additions and 2129 deletions

View File

@@ -0,0 +1,385 @@
/**
* 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();
// ============================================================
// 类型定义
// ============================================================
/**
* 质控规则定义
*/
export interface QCRule {
id: string;
name: string;
field: string | string[]; // 单字段或多字段
logic: Record<string, any>; // JSON Logic 表达式
message: string;
severity: 'error' | 'warning' | 'info';
category: 'inclusion' | 'exclusion' | 'lab_values' | 'logic_check';
metadata?: Record<string, any>;
}
/**
* 单条规则执行结果
*/
export interface RuleResult {
ruleId: string;
ruleName: string;
field: string | string[];
passed: boolean;
message: string;
severity: 'error' | 'warning' | 'info';
category: string;
actualValue?: any;
expectedCondition?: string;
}
/**
* 质控执行结果
*/
export interface QCResult {
recordId: string;
projectId: string;
timestamp: string;
overallStatus: 'PASS' | 'FAIL' | 'WARNING';
summary: {
totalRules: number;
passed: number;
failed: number;
warnings: number;
};
results: RuleResult[];
errors: RuleResult[];
warnings: RuleResult[];
}
// ============================================================
// HardRuleEngine 实现
// ============================================================
export class HardRuleEngine {
private projectId: string;
private rules: QCRule[] = [];
private fieldMappings: Map<string, string> = new Map();
constructor(projectId: string) {
this.projectId = projectId;
}
/**
* 初始化引擎(加载规则和字段映射)
*
* @param formName 可选,按表单名过滤规则(用于单表实时质控)
*/
async initialize(formName?: string): Promise<void> {
// 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 as any;
let allRules = config.rules || [];
// ⭐ 如果指定了 formName则只加载该表单相关的规则
// 规则通过 formName 或 field 字段来判断所属表单
if (formName) {
allRules = allRules.filter((rule: any) => {
// 优先使用规则中的 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: string, data: Record<string, any>): QCResult {
const startTime = Date.now();
const results: RuleResult[] = [];
const errors: RuleResult[] = [];
const warnings: RuleResult[] = [];
// 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' | 'FAIL' | 'WARNING' = '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: Array<{ recordId: string; data: Record<string, any> }>): QCResult[] {
return records.map(r => this.execute(r.recordId, r.data));
}
/**
* 执行单条规则
*/
private executeRule(rule: QCRule, data: Record<string, any>): RuleResult {
try {
// 获取字段值
const fieldValue = this.getFieldValue(rule.field, data);
// 执行 JSON Logic
const passed = jsonLogic.apply(rule.logic, data) as boolean;
return {
ruleId: rule.id,
ruleName: rule.name,
field: rule.field,
passed,
message: passed ? '通过' : rule.message,
severity: rule.severity,
category: rule.category,
actualValue: fieldValue,
expectedCondition: this.describeLogic(rule.logic)
};
} catch (error: any) {
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}`,
severity: 'error',
category: rule.category
};
}
}
/**
* 获取字段值(支持映射)
*/
private getFieldValue(field: string | string[], data: Record<string, any>): any {
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;
}
/**
* 数据标准化(应用字段映射,转换类型)
*/
private normalizeData(data: Record<string, any>): Record<string, any> {
const normalized: Record<string, any> = { ...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 表达式(用于报告)
*/
private describeLogic(logic: Record<string, any>): string {
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: any) => this.describeLogic(a)).join(' 且 ');
case 'or':
return args.map((a: any) => this.describeLogic(a)).join(' 或 ');
case '!!':
return '非空';
default:
return JSON.stringify(logic);
}
}
/**
* 获取规则列表
*/
getRules(): QCRule[] {
return this.rules;
}
/**
* 获取规则统计
*/
getRuleStats(): {
total: number;
byCategory: Record<string, number>;
bySeverity: Record<string, number>;
} {
const byCategory: Record<string, number> = {};
const bySeverity: Record<string, number> = {};
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: string,
formName?: string
): Promise<HardRuleEngine> {
const engine = new HardRuleEngine(projectId);
await engine.initialize(formName);
return engine;
}

View File

@@ -0,0 +1,472 @@
/**
* SopEngine - SOP 执行引擎(简化版)
*
* 功能:
* - 状态机驱动的 SOP 执行
* - 步骤自动执行
* - 工具调用集成
* - 结果汇总生成
*
* 状态流转:
* PENDING → RUNNING → COMPLETED
* ↘ FAILED
* ↘ SUSPENDED (需人工确认)
*
* MVP 简化:
* - 不实现 SUSPENDED人工确认机制
* - 不实现持久化到数据库
* - 专注于质控 SOP 闭环
*/
import { logger } from '../../../common/logging/index.js';
import { ToolsService, ToolResult, createToolsService } from '../services/ToolsService.js';
// ============================================================
// 类型定义
// ============================================================
/**
* SOP 任务状态
*/
export type SopStatus = 'PENDING' | 'RUNNING' | 'COMPLETED' | 'FAILED' | 'SUSPENDED';
/**
* SOP 步骤定义
*/
export interface SopStep {
id: string;
name: string;
description: string;
tool: string; // 工具名称
params: Record<string, any> | ((context: SopContext) => Record<string, any>); // 参数(静态或动态)
onSuccess?: (result: ToolResult, context: SopContext) => void; // 成功回调
onError?: (error: string, context: SopContext) => void; // 失败回调
continueOnError?: boolean; // 失败时是否继续
}
/**
* SOP 定义
*/
export interface SopDefinition {
id: string;
name: string;
description: string;
steps: SopStep[];
generateSummary: (context: SopContext) => string; // 生成汇总
}
/**
* SOP 执行上下文
*/
export interface SopContext {
sopId: string;
projectId: string;
userId: string;
sessionId?: string;
variables: Record<string, any>; // 执行过程中的变量
stepResults: Map<string, ToolResult>; // 步骤执行结果
currentStepIndex: number;
status: SopStatus;
startTime: Date;
endTime?: Date;
error?: string;
}
/**
* SOP 执行结果
*/
export interface SopResult {
sopId: string;
sopName: string;
status: SopStatus;
summary: string;
stepCount: number;
successCount: number;
failedCount: number;
duration: number; // 毫秒
stepResults: Array<{
stepId: string;
stepName: string;
success: boolean;
error?: string;
}>;
}
// ============================================================
// 预定义 SOP质控检查
// ============================================================
/**
* 单条记录质控 SOP
*/
export const QC_SINGLE_RECORD_SOP: SopDefinition = {
id: 'qc_single_record',
name: '单条记录质控',
description: '对指定患者记录执行完整质控检查',
steps: [
{
id: 'step_1',
name: '获取项目信息',
description: '获取当前研究项目基本信息',
tool: 'get_project_info',
params: {},
onSuccess: (result, context) => {
if (result.data) {
context.variables.projectName = result.data.name;
}
}
},
{
id: 'step_2',
name: '读取患者数据',
description: '从 REDCap 读取患者临床数据',
tool: 'read_clinical_data',
params: (context) => ({
record_id: context.variables.recordId
}),
onSuccess: (result, context) => {
if (result.data && result.data.length > 0) {
context.variables.patientData = result.data[0];
context.variables.hasData = true;
} else {
context.variables.hasData = false;
}
},
continueOnError: false
},
{
id: 'step_3',
name: '执行质控检查',
description: '验证患者数据是否符合纳入/排除标准和变量范围',
tool: 'run_quality_check',
params: (context) => ({
record_id: context.variables.recordId
}),
onSuccess: (result, context) => {
if (result.data) {
context.variables.qcResult = result.data;
}
},
continueOnError: false
}
],
generateSummary: (context) => {
const qcResult = context.variables.qcResult;
if (!qcResult) {
return `❌ 质控检查失败:无法获取记录 ${context.variables.recordId} 的数据`;
}
const { overallStatus, summary, errors, warnings } = qcResult;
let statusEmoji = '✅';
if (overallStatus === 'FAIL') statusEmoji = '❌';
else if (overallStatus === 'WARNING') statusEmoji = '⚠️';
let summaryText = `${statusEmoji} **记录 ${context.variables.recordId} 质控结果:${overallStatus}**\n\n`;
summaryText += `📊 统计:通过 ${summary.passed}/${summary.totalRules} 条规则\n`;
if (errors && errors.length > 0) {
summaryText += `\n❌ **错误(${errors.length}项):**\n`;
for (const e of errors) {
summaryText += `- ${e.rule}${e.message}(当前值:${e.actualValue ?? '空'}\n`;
}
}
if (warnings && warnings.length > 0) {
summaryText += `\n⚠ **警告(${warnings.length}项):**\n`;
for (const w of warnings) {
summaryText += `- ${w.rule}${w.message}(当前值:${w.actualValue ?? '空'}\n`;
}
}
if (overallStatus === 'PASS') {
summaryText += `\n🎉 该记录完全符合入组标准!`;
}
return summaryText;
}
};
/**
* 批量质控 SOP
*/
export const QC_BATCH_SOP: SopDefinition = {
id: 'qc_batch',
name: '批量质控检查',
description: '对所有患者记录执行质控检查并生成汇总报告',
steps: [
{
id: 'step_1',
name: '获取项目信息',
description: '获取当前研究项目基本信息',
tool: 'get_project_info',
params: {},
onSuccess: (result, context) => {
if (result.data) {
context.variables.projectName = result.data.name;
}
}
},
{
id: 'step_2',
name: '执行批量质控',
description: '对所有记录执行质控检查',
tool: 'batch_quality_check',
params: {},
onSuccess: (result, context) => {
if (result.data) {
context.variables.batchResult = result.data;
}
},
continueOnError: false
}
],
generateSummary: (context) => {
const batchResult = context.variables.batchResult;
if (!batchResult) {
return `❌ 批量质控失败:无法获取数据`;
}
if (batchResult.message) {
return `📭 ${batchResult.message}`;
}
const { totalRecords, summary, problemRecords } = batchResult;
let summaryText = `📊 **批量质控报告**\n\n`;
summaryText += `**项目:** ${context.variables.projectName || '未知'}\n`;
summaryText += `**记录总数:** ${totalRecords}\n\n`;
summaryText += `**质控结果:**\n`;
summaryText += `- ✅ 通过:${summary.pass} (${summary.passRate})\n`;
summaryText += `- ❌ 失败:${summary.fail}\n`;
summaryText += `- ⚠️ 警告:${summary.warning}\n`;
if (problemRecords && problemRecords.length > 0) {
summaryText += `\n**问题记录(前${problemRecords.length}条):**\n`;
for (const r of problemRecords) {
summaryText += `\n📌 **记录 ${r.recordId}** [${r.status}]\n`;
for (const issue of r.issues || []) {
summaryText += ` - ${issue.rule}${issue.message}\n`;
}
}
}
return summaryText;
}
};
// ============================================================
// SopEngine 实现
// ============================================================
export class SopEngine {
private toolsService: ToolsService;
private sops: Map<string, SopDefinition> = new Map();
constructor(toolsService: ToolsService) {
this.toolsService = toolsService;
// 注册预定义 SOP
this.registerSop(QC_SINGLE_RECORD_SOP);
this.registerSop(QC_BATCH_SOP);
}
/**
* 注册 SOP
*/
registerSop(sop: SopDefinition): void {
this.sops.set(sop.id, sop);
logger.debug('[SopEngine] SOP registered', { sopId: sop.id });
}
/**
* 获取 SOP
*/
getSop(sopId: string): SopDefinition | undefined {
return this.sops.get(sopId);
}
/**
* 执行 SOP
*/
async execute(
sopId: string,
userId: string,
variables: Record<string, any> = {},
sessionId?: string
): Promise<SopResult> {
const startTime = Date.now();
// 1. 获取 SOP 定义
const sop = this.sops.get(sopId);
if (!sop) {
return {
sopId,
sopName: 'Unknown',
status: 'FAILED',
summary: `SOP "${sopId}" 不存在`,
stepCount: 0,
successCount: 0,
failedCount: 0,
duration: Date.now() - startTime,
stepResults: []
};
}
logger.info('[SopEngine] Executing SOP', {
sopId,
sopName: sop.name,
userId,
variables
});
// 2. 创建执行上下文
const context: SopContext = {
sopId,
projectId: (this.toolsService as any).projectId,
userId,
sessionId,
variables: { ...variables },
stepResults: new Map(),
currentStepIndex: 0,
status: 'RUNNING',
startTime: new Date()
};
// 3. 逐步执行
const stepResults: SopResult['stepResults'] = [];
let successCount = 0;
let failedCount = 0;
for (let i = 0; i < sop.steps.length; i++) {
const step = sop.steps[i];
context.currentStepIndex = i;
logger.debug('[SopEngine] Executing step', {
sopId,
stepId: step.id,
stepName: step.name
});
// 3.1 计算参数
const params = typeof step.params === 'function'
? step.params(context)
: step.params;
// 3.2 执行工具
const result = await this.toolsService.execute(
step.tool,
params,
userId,
sessionId
);
// 3.3 保存结果
context.stepResults.set(step.id, result);
if (result.success) {
successCount++;
stepResults.push({
stepId: step.id,
stepName: step.name,
success: true
});
// 执行成功回调
if (step.onSuccess) {
step.onSuccess(result, context);
}
} else {
failedCount++;
stepResults.push({
stepId: step.id,
stepName: step.name,
success: false,
error: result.error
});
// 执行失败回调
if (step.onError) {
step.onError(result.error || 'Unknown error', context);
}
// 检查是否继续
if (!step.continueOnError) {
context.status = 'FAILED';
context.error = result.error;
break;
}
}
}
// 4. 设置最终状态
if (context.status !== 'FAILED') {
context.status = 'COMPLETED';
}
context.endTime = new Date();
// 5. 生成汇总
let summary: string;
try {
summary = sop.generateSummary(context);
} catch (e: any) {
summary = `SOP 执行${context.status === 'COMPLETED' ? '完成' : '失败'}`;
}
const duration = Date.now() - startTime;
logger.info('[SopEngine] SOP execution completed', {
sopId,
status: context.status,
stepCount: sop.steps.length,
successCount,
failedCount,
duration: `${duration}ms`
});
return {
sopId,
sopName: sop.name,
status: context.status,
summary,
stepCount: sop.steps.length,
successCount,
failedCount,
duration,
stepResults
};
}
/**
* 执行质控 SOP便捷方法
*/
async runQualityCheck(
recordId: string,
userId: string,
sessionId?: string
): Promise<SopResult> {
return this.execute('qc_single_record', userId, { recordId }, sessionId);
}
/**
* 执行批量质控 SOP便捷方法
*/
async runBatchQualityCheck(
userId: string,
sessionId?: string
): Promise<SopResult> {
return this.execute('qc_batch', userId, {}, sessionId);
}
}
// ============================================================
// 工厂函数
// ============================================================
/**
* 创建并初始化 SopEngine
*/
export async function createSopEngine(projectId: string): Promise<SopEngine> {
const toolsService = await createToolsService(projectId);
return new SopEngine(toolsService);
}

View File

@@ -0,0 +1,6 @@
/**
* IIT Manager Agent 引擎层导出
*/
export * from './HardRuleEngine.js';
export * from './SopEngine.js';