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

@@ -0,0 +1,384 @@
/**
* CRA 智能质控引擎 - 默认规则配置 Seed
*
* 五大规则体系:
* 1. 变量质控 (VARIABLE_QC) - 硬规则
* 2. 入排标准 (INCLUSION_EXCLUSION) - LLM 检查
* 3. 方案偏离 (PROTOCOL_DEVIATION) - 混合规则
* 4. AE 监测 (AE_MONITORING) - LLM 检查
* 5. 伦理合规 (ETHICS_COMPLIANCE) - 硬规则
*/
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
/**
* 默认 Skills 配置
*/
const DEFAULT_SKILLS = [
// ============================================================
// 1. 变量质控 - 硬规则 (Level: normal, 实时触发)
// ============================================================
{
skillType: 'variable_qc',
name: '变量质控',
description: '针对每个变量的数据校验,包括空值检查、数值范围、格式验证',
ruleType: 'HARD_RULE',
level: 'normal',
priority: 100,
triggerType: 'webhook',
requiredTags: ['#demographics', '#lab'],
config: {
engine: 'HardRuleEngine',
rules: [
{
id: 'VQ-001',
name: '年龄范围检查',
field: 'age',
formName: 'demographics',
logic: {
and: [
{ '>=': [{ var: 'age' }, 18] },
{ '<=': [{ var: 'age' }, 100] },
],
},
message: '年龄超出合理范围 (18-100岁)',
severity: 'error',
category: 'lab_values',
},
{
id: 'VQ-002',
name: 'BMI 范围检查',
field: 'bmi',
formName: 'demographics',
logic: {
and: [
{ '>': [{ var: 'bmi' }, 10] },
{ '<': [{ var: 'bmi' }, 60] },
],
},
message: 'BMI 值异常 (正常范围 10-60)',
severity: 'warning',
category: 'lab_values',
},
{
id: 'VQ-003',
name: '体重范围检查',
field: 'weight',
formName: 'demographics',
logic: {
and: [
{ '>': [{ var: 'weight' }, 20] },
{ '<': [{ var: 'weight' }, 300] },
],
},
message: '体重值异常 (正常范围 20-300kg)',
severity: 'warning',
category: 'lab_values',
},
{
id: 'VQ-004',
name: '身高范围检查',
field: 'height',
formName: 'demographics',
logic: {
and: [
{ '>': [{ var: 'height' }, 50] },
{ '<': [{ var: 'height' }, 250] },
],
},
message: '身高值异常 (正常范围 50-250cm)',
severity: 'warning',
category: 'lab_values',
},
],
},
},
// ============================================================
// 2. 入排标准 - LLM 检查 (Level: normal, 定时触发)
// ============================================================
{
skillType: 'inclusion_exclusion',
name: '入排标准核查',
description: '判断受试者是否符合研究方案的入组标准和排除标准',
ruleType: 'LLM_CHECK',
level: 'normal',
priority: 200,
triggerType: 'cron',
cronSchedule: '0 2 * * *', // 每日凌晨 2 点
requiredTags: ['#demographics', '#medical_history', '#lab'],
config: {
engine: 'SoftRuleEngine',
model: 'deepseek-v3',
systemPrompt: '你是一个专业的临床研究监查员,负责核查受试者是否符合入组标准。',
checks: [
{
id: 'IE-001',
name: '年龄入组标准',
desc: '检查受试者年龄是否符合研究方案规定的入组年龄范围',
promptTemplate: `请根据以下受试者数据,判断其年龄是否符合入组标准。
受试者年龄: {{age}}
研究方案通常要求受试者年龄在 18-75 岁之间。请判断此受试者是否符合年龄入组标准。`,
requiredTags: ['#demographics'],
category: 'inclusion',
severity: 'critical',
},
{
id: 'IE-002',
name: '确诊时间入组标准',
desc: '检查受试者确诊时间是否在研究方案规定的时间窗口内',
promptTemplate: `请根据以下受试者数据,判断其确诊时间是否符合入组标准。
确诊日期: {{diagnosis_date}}
入组日期: {{enrollment_date}}
研究方案通常要求确诊时间在入组前一定时间内(如 3 个月、6 个月等)。请判断此受试者是否符合确诊时间入组标准。`,
requiredTags: ['#demographics', '#medical_history'],
category: 'inclusion',
severity: 'critical',
},
],
},
},
// ============================================================
// 3. 方案偏离 - 混合规则 (Level: normal, 定时触发)
// ============================================================
{
skillType: 'protocol_deviation',
name: '方案偏离检测',
description: '检测访视超窗、漏做检查、违反用药规定等方案偏离情况',
ruleType: 'HYBRID',
level: 'normal',
priority: 300,
triggerType: 'cron',
cronSchedule: '0 3 * * *', // 每日凌晨 3 点
requiredTags: ['#visits', '#medications'],
config: {
engine: 'HybridEngine',
hardRules: [
{
id: 'PD-001',
name: '访视间隔检查',
field: ['visit_1_date', 'visit_2_date'],
logic: {
'<=': [
{ '-': [{ var: 'visit_2_date' }, { var: 'visit_1_date' }] },
31, // 最大间隔 28+3 天
],
},
message: '访视超窗:访视 2 与访视 1 间隔超过 31 天',
severity: 'warning',
category: 'logic_check',
},
],
softChecks: [
{
id: 'PD-002',
name: '禁用药物检查',
desc: '检查受试者是否使用了研究方案禁止的药物',
promptTemplate: '请检查受试者的用药记录,判断是否存在使用研究方案禁止药物的情况。',
requiredTags: ['#medications'],
category: 'protocol_deviation',
severity: 'warning',
},
],
},
},
// ============================================================
// 4. AE 监测 - LLM 检查 (Level: normal, 定时触发)
// ============================================================
{
skillType: 'ae_monitoring',
name: 'AE 事件监测',
description: '检测未报告的不良事件,包括实验室异常与 AE 记录的一致性检查',
ruleType: 'LLM_CHECK',
level: 'normal',
priority: 250,
triggerType: 'cron',
cronSchedule: '0 4 * * *', // 每日凌晨 4 点
requiredTags: ['#lab', '#ae'],
config: {
engine: 'SoftRuleEngine',
model: 'deepseek-v3',
systemPrompt: '你是一个专业的药物安全监查员,负责核查不良事件报告的完整性和准确性。',
checks: [
{
id: 'AE-001',
name: 'Lab 异常与 AE 一致性',
desc: '检查实验室检查异常值是否已在 AE 表中报告',
promptTemplate: `请对比以下实验室检查数据和不良事件记录,判断是否存在未报告的实验室异常。
重点关注:
1. Grade 3 及以上的实验室异常是否已记录为 AE
2. 持续异常是否已报告
3. 与基线相比显著变化的指标
请给出判断结果,并列出可能遗漏报告的异常。`,
requiredTags: ['#lab', '#ae'],
category: 'ae_detection',
severity: 'critical',
},
{
id: 'AE-002',
name: 'SAE 报告时效性',
desc: '检查严重不良事件是否在规定时间内报告',
promptTemplate: `请检查以下 SAE 记录,判断报告时效是否符合法规要求。
通常要求:
- 致死/危及生命的 SAE: 24 小时内报告
- 其他 SAE: 15 天内报告
请判断各 SAE 的报告时效是否合规。`,
requiredTags: ['#ae'],
category: 'ae_detection',
severity: 'critical',
},
],
},
},
// ============================================================
// 5. 伦理合规 - 硬规则 (Level: blocking, 实时触发)
// ============================================================
{
skillType: 'ethics_compliance',
name: '伦理合规检查',
description: '检查知情同意书签署时间、隐私保护等伦理合规问题',
ruleType: 'HARD_RULE',
level: 'blocking', // 阻断性检查,失败则跳过后续 AI 检查
priority: 10, // 最高优先级
triggerType: 'webhook',
requiredTags: ['#consent', '#demographics'],
config: {
engine: 'HardRuleEngine',
rules: [
{
id: 'EC-001',
name: '知情同意书签署时间',
field: ['icf_date', 'first_visit_date'],
logic: {
'<=': [{ var: 'icf_date' }, { var: 'first_visit_date' }],
},
message: '伦理违规:知情同意书签署日期晚于首次访视日期',
severity: 'error',
category: 'ethics',
},
{
id: 'EC-002',
name: '知情同意书必填检查',
field: 'icf_date',
logic: {
'!!': [{ var: 'icf_date' }],
},
message: '伦理违规:缺少知情同意书签署日期',
severity: 'error',
category: 'ethics',
},
{
id: 'EC-003',
name: '受试者 ID 必填检查',
field: 'record_id',
logic: {
'!!': [{ var: 'record_id' }],
},
message: '数据完整性:缺少受试者 ID',
severity: 'error',
category: 'ethics',
},
],
},
},
];
/**
* 为项目创建默认 Skills
*
* @param projectId 项目 ID
*/
export async function seedDefaultSkills(projectId: string): Promise<void> {
console.log(`[Seed] Creating default skills for project: ${projectId}`);
for (const skillData of DEFAULT_SKILLS) {
try {
// 使用 upsert 避免重复创建
await prisma.iitSkill.upsert({
where: {
projectId_skillType: {
projectId,
skillType: skillData.skillType,
},
},
update: {
name: skillData.name,
description: skillData.description,
ruleType: skillData.ruleType,
level: skillData.level,
priority: skillData.priority,
triggerType: skillData.triggerType,
cronSchedule: skillData.cronSchedule,
requiredTags: skillData.requiredTags,
config: skillData.config,
isActive: true,
updatedAt: new Date(),
},
create: {
projectId,
skillType: skillData.skillType,
name: skillData.name,
description: skillData.description,
ruleType: skillData.ruleType,
level: skillData.level,
priority: skillData.priority,
triggerType: skillData.triggerType,
cronSchedule: skillData.cronSchedule,
requiredTags: skillData.requiredTags,
config: skillData.config,
isActive: true,
},
});
console.log(`[Seed] Created/Updated skill: ${skillData.name}`);
} catch (error: any) {
console.error(`[Seed] Failed to create skill: ${skillData.name}`, error.message);
}
}
console.log(`[Seed] Completed creating default skills for project: ${projectId}`);
}
/**
* 主函数 - 可以直接运行
*/
async function main() {
// 获取命令行参数
const projectId = process.argv[2];
if (!projectId) {
console.error('Usage: npx ts-node prisma/seeds/cra-qc-skills.seed.ts <projectId>');
console.error('Example: npx ts-node prisma/seeds/cra-qc-skills.seed.ts test0102-pd-study');
process.exit(1);
}
// 检查项目是否存在
const project = await prisma.iitProject.findUnique({
where: { id: projectId },
});
if (!project) {
console.error(`Project not found: ${projectId}`);
process.exit(1);
}
await seedDefaultSkills(projectId);
}
// 如果直接运行此文件
main()
.catch(console.error)
.finally(() => prisma.$disconnect());