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:
@@ -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})`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取字段值(支持映射)
|
||||
*/
|
||||
|
||||
755
backend/src/modules/iit-manager/engines/SkillRunner.ts
Normal file
755
backend/src/modules/iit-manager/engines/SkillRunner.ts
Normal 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);
|
||||
}
|
||||
487
backend/src/modules/iit-manager/engines/SoftRuleEngine.ts
Normal file
487
backend/src/modules/iit-manager/engines/SoftRuleEngine.ts
Normal 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',
|
||||
},
|
||||
];
|
||||
@@ -3,4 +3,6 @@
|
||||
*/
|
||||
|
||||
export * from './HardRuleEngine.js';
|
||||
export * from './SoftRuleEngine.js';
|
||||
export * from './SopEngine.js';
|
||||
export * from './SkillRunner.js';
|
||||
|
||||
Reference in New Issue
Block a user