feat(iit): Implement event-level QC architecture V3.1 with dynamic rule filtering, report deduplication and AI intent enhancement

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-02-08 21:22:11 +08:00
parent 45c7b32dbb
commit 7a299e8562
51 changed files with 10638 additions and 184 deletions

View File

@@ -34,21 +34,36 @@ export interface QCRule {
severity: 'error' | 'warning' | 'info';
category: 'inclusion' | 'exclusion' | 'lab_values' | 'logic_check';
metadata?: Record<string, any>;
// V3.1: 事件级质控支持
/** 适用的事件列表,空数组或不设置表示适用所有事件 */
applicableEvents?: string[];
/** 适用的表单列表,空数组或不设置表示适用所有表单 */
applicableForms?: string[];
}
/**
* 单条规则执行结果
*
* V2.1 优化:支持 LLM 友好的"自包含"格式
*/
export interface RuleResult {
ruleId: string;
ruleName: string;
field: string | string[];
passed: boolean;
message: string;
message: string; // 基础消息
llmMessage?: string; // V2.1: LLM 友好的自包含消息
severity: 'error' | 'warning' | 'info';
category: string;
actualValue?: any;
expectedCondition?: string;
actualValue?: any; // 实际值
expectedValue?: string; // V2.1: 期望值/标准(人类可读)
expectedCondition?: string; // JSON Logic 描述
evidence?: { // V2.1: 结构化证据
value: any;
threshold?: string;
unit?: string;
};
}
/**
@@ -218,6 +233,8 @@ export class HardRuleEngine {
/**
* 执行单条规则
*
* V2.1 优化:生成自包含的 LLM 友好消息
*/
private executeRule(rule: QCRule, data: Record<string, any>): RuleResult {
try {
@@ -227,16 +244,35 @@ export class HardRuleEngine {
// 执行 JSON Logic
const passed = jsonLogic.apply(rule.logic, data) as boolean;
// 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 as any)?.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,
expectedCondition: this.describeLogic(rule.logic)
expectedValue,
expectedCondition,
evidence,
};
} catch (error: any) {
@@ -251,12 +287,68 @@ export class HardRuleEngine {
field: rule.field,
passed: false,
message: `规则执行出错: ${error.message}`,
llmMessage: `规则执行出错: ${error.message}`,
severity: 'error',
category: rule.category
};
}
}
/**
* V2.1: 从 JSON Logic 中提取期望值
*/
private extractExpectedValue(logic: Record<string, any>): string {
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: any) => 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})
*/
private buildLlmMessage(rule: QCRule, actualValue: any, expectedValue: string): string {
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})`;
}
}
/**
* 获取字段值(支持映射)
*/

View File

@@ -0,0 +1,755 @@
/**
* SkillRunner - 规则调度器
*
* 功能:
* - 根据触发类型加载和执行 Skills
* - 协调 HardRuleEngine 和 SoftRuleEngine
* - 实现漏斗式执行策略Blocking → Hard → Soft
* - 聚合质控结果
*
* 设计原则:
* - 可插拔:通过 Skill 配置动态加载规则
* - 成本控制:阻断性检查优先,失败则跳过 AI 检查
* - 统一入口:所有触发类型使用相同的执行逻辑
*/
import { PrismaClient } from '@prisma/client';
import { logger } from '../../../common/logging/index.js';
import { HardRuleEngine, createHardRuleEngine, QCResult, QCRule } from './HardRuleEngine.js';
import { SoftRuleEngine, createSoftRuleEngine, SoftRuleCheck, SoftRuleEngineResult } from './SoftRuleEngine.js';
import { RedcapAdapter } from '../adapters/RedcapAdapter.js';
import jsonLogic from 'json-logic-js';
const prisma = new PrismaClient();
// ============================================================
// 类型定义
// ============================================================
/**
* 触发类型
*/
export type TriggerType = 'webhook' | 'cron' | 'manual';
/**
* 规则类型
*/
export type RuleType = 'HARD_RULE' | 'LLM_CHECK' | 'HYBRID';
/**
* Skill 执行结果
*/
export interface SkillResult {
skillId: string;
skillName: string;
skillType: string;
ruleType: RuleType;
status: 'PASS' | 'FAIL' | 'WARNING' | 'UNCERTAIN';
issues: SkillIssue[];
executionTimeMs: number;
}
/**
* 问题项
*/
export interface SkillIssue {
ruleId: string;
ruleName: string;
field?: string | string[];
message: string;
llmMessage?: string; // V2.1: LLM 友好的自包含消息
severity: 'critical' | 'warning' | 'info';
actualValue?: any;
expectedValue?: string; // V2.1: 期望值(人类可读)
evidence?: Record<string, any>; // V2.1: 结构化证据
confidence?: number;
}
/**
* SkillRunner 执行结果
*
* V3.1: 支持事件级质控,每个 record+event 作为独立单元
*/
export interface SkillRunResult {
projectId: string;
recordId: string;
// V3.1: 事件级质控支持
eventName?: string; // REDCap 事件唯一标识
eventLabel?: string; // 事件显示名称(如"筛选期"
forms?: string[]; // 该事件包含的表单列表
triggerType: TriggerType;
timestamp: string;
overallStatus: 'PASS' | 'FAIL' | 'WARNING' | 'UNCERTAIN';
summary: {
totalSkills: number;
passed: number;
failed: number;
warnings: number;
uncertain: number;
blockedByLevel1: boolean;
};
skillResults: SkillResult[];
allIssues: SkillIssue[];
criticalIssues: SkillIssue[];
warningIssues: SkillIssue[];
executionTimeMs: number;
}
/**
* SkillRunner 选项
*
* V3.1: 支持事件级过滤
*/
export interface SkillRunnerOptions {
recordId?: string;
eventName?: string; // V3.1: 指定事件
formName?: string;
skipSoftRules?: boolean; // 跳过 LLM 检查(用于快速检查)
}
// ============================================================
// SkillRunner 实现
// ============================================================
export class SkillRunner {
private projectId: string;
private redcapAdapter?: RedcapAdapter;
constructor(projectId: string) {
this.projectId = projectId;
}
/**
* 初始化 REDCap 适配器
*/
private async initRedcapAdapter(): Promise<RedcapAdapter> {
if (this.redcapAdapter) {
return this.redcapAdapter;
}
const project = await prisma.iitProject.findUnique({
where: { id: this.projectId },
select: { redcapUrl: true, redcapApiToken: true },
});
if (!project) {
throw new Error(`项目不存在: ${this.projectId}`);
}
this.redcapAdapter = new RedcapAdapter(project.redcapUrl, project.redcapApiToken);
return this.redcapAdapter;
}
/**
* 按触发类型执行 Skills
*
* @param triggerType 触发类型
* @param options 执行选项
* @returns 执行结果
*/
async runByTrigger(
triggerType: TriggerType,
options?: SkillRunnerOptions
): Promise<SkillRunResult[]> {
const startTime = Date.now();
logger.info('[SkillRunner] Starting execution', {
projectId: this.projectId,
triggerType,
options,
});
// 1. 加载启用的 Skills
const skills = await this.loadSkills(triggerType, options?.formName);
if (skills.length === 0) {
logger.warn('[SkillRunner] No active skills found', {
projectId: this.projectId,
triggerType,
});
return [];
}
// 2. 按优先级排序priority 越小越优先blocking 级别最优先)
skills.sort((a, b) => {
if (a.level === 'blocking' && b.level !== 'blocking') return -1;
if (a.level !== 'blocking' && b.level === 'blocking') return 1;
return a.priority - b.priority;
});
// 3. 获取要处理的记录V3.1: 事件级数据)
const records = await this.getRecordsToProcess(options);
// 4. 对每条记录+事件执行所有 Skills
const results: SkillRunResult[] = [];
for (const record of records) {
const result = await this.executeSkillsForRecord(
record.recordId,
record.eventName,
record.eventLabel,
record.forms,
record.data,
skills,
triggerType,
options
);
results.push(result);
// 保存质控日志
await this.saveQcLog(result);
}
const totalTime = Date.now() - startTime;
logger.info('[SkillRunner] Execution completed', {
projectId: this.projectId,
triggerType,
recordEventCount: records.length,
totalTimeMs: totalTime,
});
return results;
}
/**
* 加载 Skills
*/
private async loadSkills(
triggerType: TriggerType,
formName?: string
): Promise<Array<{
id: string;
skillType: string;
name: string;
ruleType: string;
level: string;
priority: number;
config: any;
requiredTags: string[];
}>> {
const where: any = {
projectId: this.projectId,
isActive: true,
};
// 根据触发类型过滤
if (triggerType === 'webhook') {
where.triggerType = 'webhook';
} else if (triggerType === 'cron') {
where.triggerType = { in: ['cron', 'webhook'] }; // Cron 也执行 webhook 规则
}
// manual 执行所有规则
const skills = await prisma.iitSkill.findMany({
where,
select: {
id: true,
skillType: true,
name: true,
ruleType: true,
level: true,
priority: true,
config: true,
requiredTags: true,
},
});
// 如果指定了 formName过滤相关的 Skills
if (formName) {
return skills.filter(skill => {
const config = skill.config as any;
// 检查规则中是否有与该表单相关的规则
if (config?.rules) {
return config.rules.some((rule: any) =>
!rule.formName || rule.formName === formName
);
}
return true; // 没有 formName 限制的规则默认包含
});
}
return skills;
}
/**
* 获取要处理的记录(事件级别)
*
* V3.1: 返回事件级数据,每个 record+event 作为独立单元
* 不再合并事件数据,确保每个访视独立质控
*/
private async getRecordsToProcess(
options?: SkillRunnerOptions
): Promise<Array<{
recordId: string;
eventName: string;
eventLabel: string;
forms: string[];
data: Record<string, any>;
}>> {
const adapter = await this.initRedcapAdapter();
// V3.1: 使用 getAllRecordsByEvent 获取事件级数据
const eventRecords = await adapter.getAllRecordsByEvent({
recordId: options?.recordId,
eventName: options?.eventName,
});
return eventRecords.map(r => ({
recordId: r.recordId,
eventName: r.eventName,
eventLabel: r.eventLabel,
forms: r.forms,
data: r.data,
}));
}
/**
* 对单条记录+事件执行所有 Skills
*
* V3.1: 支持事件级质控,根据规则配置过滤适用的规则
*/
private async executeSkillsForRecord(
recordId: string,
eventName: string,
eventLabel: string,
forms: string[],
data: Record<string, any>,
skills: Array<{
id: string;
skillType: string;
name: string;
ruleType: string;
level: string;
priority: number;
config: any;
requiredTags: string[];
}>,
triggerType: TriggerType,
options?: SkillRunnerOptions
): Promise<SkillRunResult> {
const startTime = Date.now();
const skillResults: SkillResult[] = [];
const allIssues: SkillIssue[] = [];
const criticalIssues: SkillIssue[] = [];
const warningIssues: SkillIssue[] = [];
let blockedByLevel1 = false;
// 漏斗式执行
for (const skill of skills) {
const ruleType = skill.ruleType as RuleType;
// 如果已被阻断且当前不是 blocking 级别,跳过 LLM 检查
if (blockedByLevel1 && ruleType === 'LLM_CHECK') {
logger.debug('[SkillRunner] Skipping LLM check due to blocking failure', {
skillId: skill.id,
recordId,
eventName,
});
continue;
}
// 如果选项要求跳过软规则
if (options?.skipSoftRules && ruleType === 'LLM_CHECK') {
continue;
}
// V3.1: 执行 Skill传入事件和表单信息用于规则过滤
const result = await this.executeSkill(skill, recordId, eventName, forms, data);
skillResults.push(result);
// 收集问题
for (const issue of result.issues) {
allIssues.push(issue);
if (issue.severity === 'critical') {
criticalIssues.push(issue);
} else if (issue.severity === 'warning') {
warningIssues.push(issue);
}
}
// 检查是否触发阻断
if (skill.level === 'blocking' && result.status === 'FAIL') {
blockedByLevel1 = true;
logger.info('[SkillRunner] Blocking check failed, skipping AI checks', {
skillId: skill.id,
recordId,
eventName,
});
}
}
// 计算整体状态
let overallStatus: 'PASS' | 'FAIL' | 'WARNING' | 'UNCERTAIN' = 'PASS';
if (criticalIssues.length > 0) {
overallStatus = 'FAIL';
} else if (skillResults.some(r => r.status === 'UNCERTAIN')) {
overallStatus = 'UNCERTAIN';
} else if (warningIssues.length > 0) {
overallStatus = 'WARNING';
}
const executionTimeMs = Date.now() - startTime;
return {
projectId: this.projectId,
recordId,
// V3.1: 包含事件信息
eventName,
eventLabel,
forms,
triggerType,
timestamp: new Date().toISOString(),
overallStatus,
summary: {
totalSkills: skillResults.length,
passed: skillResults.filter(r => r.status === 'PASS').length,
failed: skillResults.filter(r => r.status === 'FAIL').length,
warnings: skillResults.filter(r => r.status === 'WARNING').length,
uncertain: skillResults.filter(r => r.status === 'UNCERTAIN').length,
blockedByLevel1,
},
skillResults,
allIssues,
criticalIssues,
warningIssues,
executionTimeMs,
};
}
/**
* 执行单个 Skill
*
* V3.1: 支持事件级质控,根据规则配置过滤适用的规则
*/
private async executeSkill(
skill: {
id: string;
skillType: string;
name: string;
ruleType: string;
config: any;
requiredTags: string[];
},
recordId: string,
eventName: string,
forms: string[],
data: Record<string, any>
): Promise<SkillResult> {
const startTime = Date.now();
const ruleType = skill.ruleType as RuleType;
const config = skill.config as any;
const issues: SkillIssue[] = [];
let status: 'PASS' | 'FAIL' | 'WARNING' | 'UNCERTAIN' = 'PASS';
try {
if (ruleType === 'HARD_RULE') {
// 使用 HardRuleEngine
const engine = await createHardRuleEngine(this.projectId);
// 临时注入规则(如果 config 中有)
if (config?.rules) {
// V3.1: 过滤适用于当前事件/表单的规则
const allRules = config.rules as QCRule[];
const applicableRules = this.filterApplicableRules(allRules, eventName, forms);
if (applicableRules.length > 0) {
const result = this.executeHardRulesDirectly(applicableRules, recordId, data);
issues.push(...result.issues);
status = result.status;
}
}
} else if (ruleType === 'LLM_CHECK') {
// 使用 SoftRuleEngine
const engine = createSoftRuleEngine(this.projectId, {
model: config?.model || 'deepseek-v3',
});
// V3.1: 过滤适用于当前事件/表单的检查
const rawChecks = config?.checks || [];
const applicableChecks = this.filterApplicableRules(rawChecks, eventName, forms);
const checks: SoftRuleCheck[] = applicableChecks.map((check: any) => ({
id: check.id,
name: check.name || check.desc,
description: check.desc,
promptTemplate: check.promptTemplate || check.prompt,
requiredTags: check.requiredTags || skill.requiredTags || [],
category: check.category || 'medical_logic',
severity: check.severity || 'warning',
applicableEvents: check.applicableEvents,
applicableForms: check.applicableForms,
}));
if (checks.length > 0) {
const result = await engine.execute(recordId, data, checks);
for (const checkResult of result.results) {
if (checkResult.status !== 'PASS') {
issues.push({
ruleId: checkResult.checkId,
ruleName: checkResult.checkName,
message: checkResult.reason,
severity: checkResult.severity,
evidence: checkResult.evidence,
confidence: checkResult.confidence,
});
}
}
if (result.overallStatus === 'FAIL') {
status = 'FAIL';
} else if (result.overallStatus === 'UNCERTAIN') {
status = 'UNCERTAIN';
}
}
} else if (ruleType === 'HYBRID') {
// 混合模式:先执行硬规则,再执行软规则
// TODO: 实现混合逻辑
logger.warn('[SkillRunner] Hybrid rules not yet implemented', {
skillId: skill.id,
});
}
} catch (error: any) {
logger.error('[SkillRunner] Skill execution error', {
skillId: skill.id,
error: error.message,
});
status = 'UNCERTAIN';
issues.push({
ruleId: 'EXECUTION_ERROR',
ruleName: '执行错误',
message: `Skill 执行出错: ${error.message}`,
severity: 'warning',
});
}
const executionTimeMs = Date.now() - startTime;
return {
skillId: skill.id,
skillName: skill.name,
skillType: skill.skillType,
ruleType,
status,
issues,
executionTimeMs,
};
}
/**
* 直接执行硬规则(不通过 HardRuleEngine 初始化)
*
* V2.1 优化:添加 expectedValue, llmMessage, evidence 字段
*/
private executeHardRulesDirectly(
rules: QCRule[],
recordId: string,
data: Record<string, any>
): { status: 'PASS' | 'FAIL' | 'WARNING'; issues: SkillIssue[] } {
const issues: SkillIssue[] = [];
let hasFail = false;
let hasWarning = false;
for (const rule of rules) {
try {
const passed = jsonLogic.apply(rule.logic, data);
if (!passed) {
const severity = rule.severity === 'error' ? 'critical' : 'warning';
const actualValue = this.getFieldValue(rule.field, data);
// V2.1: 提取期望值
const expectedValue = this.extractExpectedValue(rule.logic);
// V2.1: 构建自包含的 LLM 友好消息
const llmMessage = this.buildLlmMessage(rule, actualValue, expectedValue);
issues.push({
ruleId: rule.id,
ruleName: rule.name,
field: rule.field,
message: rule.message,
llmMessage, // V2.1: 自包含消息
severity,
actualValue,
expectedValue, // V2.1: 期望值
evidence: { // V2.1: 结构化证据
value: actualValue,
threshold: expectedValue,
unit: (rule.metadata as any)?.unit,
},
});
if (severity === 'critical') {
hasFail = true;
} else {
hasWarning = true;
}
}
} catch (error: any) {
logger.warn('[SkillRunner] Rule execution error', {
ruleId: rule.id,
error: error.message,
});
}
}
let status: 'PASS' | 'FAIL' | 'WARNING' = 'PASS';
if (hasFail) {
status = 'FAIL';
} else if (hasWarning) {
status = 'WARNING';
}
return { status, issues };
}
/**
* V2.1: 从 JSON Logic 中提取期望值
*/
private extractExpectedValue(logic: Record<string, any>): string {
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 逻辑,尝试提取范围
if (Array.isArray(args)) {
const values = args.map((a: any) => this.extractExpectedValue(a)).filter(Boolean);
if (values.length === 2) {
return `${values[0]}-${values[1]}`;
}
return values.join(', ');
}
return '';
case '!!':
return '非空/必填';
default:
return '';
}
}
/**
* V2.1: 构建 LLM 友好的自包含消息
*/
private buildLlmMessage(rule: QCRule, actualValue: any, expectedValue: string): string {
const displayValue = actualValue !== undefined && actualValue !== null && actualValue !== ''
? `**${actualValue}**`
: '**空**';
if (expectedValue) {
return `**${rule.name}**: 当前值 ${displayValue} (标准: ${expectedValue})`;
}
return `**${rule.name}**: 当前值 ${displayValue}`;
}
/**
* 获取字段值
*/
private getFieldValue(field: string | string[], data: Record<string, any>): any {
if (Array.isArray(field)) {
return field.map(f => data[f]);
}
return data[field];
}
/**
* V3.1: 过滤适用于当前事件/表单的规则
*
* 规则配置可以包含:
* - applicableEvents: 适用的事件列表(空数组或不设置表示适用所有事件)
* - applicableForms: 适用的表单列表(空数组或不设置表示适用所有表单)
*
* @param rules 所有规则
* @param eventName 当前事件名称
* @param forms 当前事件包含的表单列表
* @returns 适用于当前事件/表单的规则
*/
private filterApplicableRules<T extends { applicableEvents?: string[]; applicableForms?: string[] }>(
rules: T[],
eventName: string,
forms: string[]
): T[] {
return rules.filter(rule => {
// 检查事件是否适用
const eventMatch = !rule.applicableEvents ||
rule.applicableEvents.length === 0 ||
rule.applicableEvents.includes(eventName);
if (!eventMatch) {
return false;
}
// 检查表单是否适用
const formMatch = !rule.applicableForms ||
rule.applicableForms.length === 0 ||
rule.applicableForms.some(f => forms.includes(f));
return formMatch;
});
}
/**
* 保存质控日志
*/
private async saveQcLog(result: SkillRunResult): Promise<void> {
try {
// 将结果保存到 iit_qc_logs 表
// V3.1: 包含事件信息
const issuesWithSummary = {
items: result.allIssues,
summary: result.summary,
// V3.1: 事件级质控元数据
eventLabel: result.eventLabel,
forms: result.forms,
};
await prisma.iitQcLog.create({
data: {
projectId: result.projectId,
recordId: result.recordId,
eventId: result.eventName, // V3.1: 保存事件标识
qcType: 'event', // V3.1: 事件级质控
formName: result.forms?.join(',') || null, // 该事件包含的表单
status: result.overallStatus,
issues: JSON.parse(JSON.stringify(issuesWithSummary)), // 转换为 JSON 兼容格式
ruleVersion: 'v3.1', // V3.1: 事件级质控版本
rulesEvaluated: result.summary.totalSkills || 0,
rulesPassed: result.summary.passed || 0,
rulesFailed: result.summary.failed || 0,
rulesSkipped: 0,
triggeredBy: result.triggerType,
createdAt: new Date(result.timestamp),
},
});
} catch (error: any) {
logger.error('[SkillRunner] Failed to save QC log', {
recordId: result.recordId,
error: error.message,
});
}
}
}
// ============================================================
// 工厂函数
// ============================================================
/**
* 创建 SkillRunner 实例
*
* @param projectId 项目ID
* @returns SkillRunner 实例
*/
export function createSkillRunner(projectId: string): SkillRunner {
return new SkillRunner(projectId);
}

View File

@@ -0,0 +1,487 @@
/**
* SoftRuleEngine - 软规则质控引擎 (LLM 推理)
*
* 功能:
* - 调用 LLM 进行复杂的医学逻辑判断
* - 支持入排标准、AE 事件检测、方案偏离等场景
* - 返回带证据链的结构化结果
*
* 设计原则:
* - 智能推理:利用 LLM 处理模糊规则和复杂逻辑
* - 证据链:每个判断都附带推理过程和证据
* - 三态输出PASS / FAIL / UNCERTAIN需人工确认
*/
import { PrismaClient } from '@prisma/client';
import { LLMFactory } from '../../../common/llm/adapters/LLMFactory.js';
import { ModelType } from '../../../common/llm/adapters/types.js';
import { logger } from '../../../common/logging/index.js';
import { buildClinicalSlice } from '../services/PromptBuilder.js';
const prisma = new PrismaClient();
// ============================================================
// 类型定义
// ============================================================
/**
* 软规则检查项定义
*/
export interface SoftRuleCheck {
id: string;
name: string;
description?: string;
promptTemplate: string; // Prompt 模板,支持 {{variable}} 占位符
requiredTags: string[]; // 需要加载的数据标签
category: 'inclusion' | 'exclusion' | 'ae_detection' | 'protocol_deviation' | 'medical_logic';
severity: 'critical' | 'warning';
// V3.1: 事件级质控支持
/** 适用的事件列表,空数组或不设置表示适用所有事件 */
applicableEvents?: string[];
/** 适用的表单列表,空数组或不设置表示适用所有表单 */
applicableForms?: string[];
}
/**
* 软规则执行结果
*/
export interface SoftRuleResult {
checkId: string;
checkName: string;
status: 'PASS' | 'FAIL' | 'UNCERTAIN';
reason: string; // LLM 给出的判断理由
evidence: Record<string, any>; // 支持判断的证据数据
confidence: number; // 置信度 0-1
severity: 'critical' | 'warning';
category: string;
rawResponse?: string; // 原始 LLM 响应(用于调试)
}
/**
* 软规则引擎配置
*/
export interface SoftRuleEngineConfig {
model?: ModelType;
maxConcurrency?: number;
timeoutMs?: number;
}
/**
* 软规则引擎执行结果
*/
export interface SoftRuleEngineResult {
recordId: string;
projectId: string;
timestamp: string;
overallStatus: 'PASS' | 'FAIL' | 'UNCERTAIN';
summary: {
totalChecks: number;
passed: number;
failed: number;
uncertain: number;
};
results: SoftRuleResult[];
failedChecks: SoftRuleResult[];
uncertainChecks: SoftRuleResult[];
}
// ============================================================
// SoftRuleEngine 实现
// ============================================================
export class SoftRuleEngine {
private projectId: string;
private model: ModelType;
private timeoutMs: number;
constructor(projectId: string, config?: SoftRuleEngineConfig) {
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: string,
data: Record<string, any>,
checks: SoftRuleCheck[]
): Promise<SoftRuleEngineResult> {
const startTime = Date.now();
const results: SoftRuleResult[] = [];
const failedChecks: SoftRuleResult[] = [];
const uncertainChecks: SoftRuleResult[] = [];
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: any) {
logger.error('[SoftRuleEngine] Check execution failed', {
checkId: check.id,
error: error.message,
});
// 发生错误时标记为 UNCERTAIN
const errorResult: SoftRuleResult = {
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' | 'FAIL' | 'UNCERTAIN' = '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,
};
}
/**
* 执行单个检查
*/
private async executeCheck(
recordId: string,
data: Record<string, any>,
check: SoftRuleCheck
): Promise<SoftRuleResult> {
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
*/
private buildCheckPrompt(
recordId: string,
data: Record<string, any>,
check: SoftRuleCheck
): string {
// 使用 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
*/
private getSystemPrompt(): string {
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 响应
*/
private parseResponse(
rawResponse: string,
check: SoftRuleCheck
): {
status: 'PASS' | 'FAIL' | 'UNCERTAIN';
reason: string;
evidence: Record<string, any>;
confidence: number;
} {
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 as 'PASS' | 'FAIL' | 'UNCERTAIN',
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: Array<{ recordId: string; data: Record<string, any> }>,
checks: SoftRuleCheck[]
): Promise<SoftRuleEngineResult[]> {
const results: SoftRuleEngineResult[] = [];
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: string,
config?: SoftRuleEngineConfig
): SoftRuleEngine {
return new SoftRuleEngine(projectId, config);
}
// ============================================================
// 预置检查模板
// ============================================================
/**
* 入排标准检查模板
*/
export const INCLUSION_EXCLUSION_CHECKS: SoftRuleCheck[] = [
{
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: SoftRuleCheck[] = [
{
id: 'AE-001',
name: 'Lab 异常与 AE 一致性',
description: '检查实验室检查异常值是否已在 AE 表中报告',
promptTemplate: '请对比实验室检查数据和不良事件记录判断是否存在未报告的实验室异常Grade 3 及以上)。',
requiredTags: ['#lab', '#ae'],
category: 'ae_detection',
severity: 'critical',
},
];
/**
* 方案偏离检测模板
*/
export const PROTOCOL_DEVIATION_CHECKS: SoftRuleCheck[] = [
{
id: 'PD-001',
name: '访视超窗检测',
description: '检查访视是否在方案规定的时间窗口内',
promptTemplate: '请根据访视日期数据,判断各访视之间的时间间隔是否符合方案规定的访视窗口。',
requiredTags: ['#visits'],
category: 'protocol_deviation',
severity: 'warning',
},
];

View File

@@ -3,4 +3,6 @@
*/
export * from './HardRuleEngine.js';
export * from './SoftRuleEngine.js';
export * from './SopEngine.js';
export * from './SkillRunner.js';