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

@@ -2,19 +2,21 @@
* IIT 批量操作 Controller
*
* 功能:
* - 一键全量质控
* - 一键全量质控(事件级)
* - 一键全量数据汇总
*
* 用途:
* - 运营管理端手动触发
* - 未来可作为 AI 工具暴露
*
* 版本v3.1 - 事件级质控
*/
import { FastifyRequest, FastifyReply } from 'fastify';
import { PrismaClient } from '@prisma/client';
import { logger } from '../../../common/logging/index.js';
import { RedcapAdapter } from '../../iit-manager/adapters/RedcapAdapter.js';
import { createHardRuleEngine } from '../../iit-manager/engines/HardRuleEngine.js';
import { createSkillRunner } from '../../iit-manager/engines/SkillRunner.js';
const prisma = new PrismaClient();
@@ -24,15 +26,15 @@ interface BatchRequest {
export class IitBatchController {
/**
* 一键全量质控
* 一键全量质控(事件级)
*
* POST /api/v1/admin/iit-projects/:projectId/batch-qc
*
* 功能:
* 1. 获取 REDCap 中所有记录
* 2. 对每条记录执行质控
* 3. 存储质控日志到 iit_qc_logs
* 4. 更新项目统计到 iit_qc_project_stats
* 1. 使用 SkillRunner 进行事件级质控
* 2. 每个 record+event 组合独立质控
* 3. 规则根据 applicableEvents/applicableForms 动态过滤
* 4. 质控日志自动保存到 iit_qc_logs含 eventId
*/
async batchQualityCheck(
request: FastifyRequest<BatchRequest>,
@@ -42,7 +44,7 @@ export class IitBatchController {
const startTime = Date.now();
try {
logger.info('🔄 开始全量质控', { projectId });
logger.info('🔄 开始事件级全量质控', { projectId });
// 1. 获取项目配置
const project = await prisma.iitProject.findUnique({
@@ -53,141 +55,111 @@ export class IitBatchController {
return reply.status(404).send({ error: '项目不存在' });
}
// 2. 从 REDCap 获取所有记录
const adapter = new RedcapAdapter(project.redcapUrl, project.redcapApiToken);
const allRecords = await adapter.exportRecords({});
// 2. 使用 SkillRunner 执行事件级质控
const runner = await createSkillRunner(projectId);
const results = await runner.runByTrigger('manual');
if (!allRecords || allRecords.length === 0) {
if (results.length === 0) {
return reply.send({
success: true,
message: '项目暂无记录',
stats: { totalRecords: 0 }
message: '项目暂无记录或未配置质控规则',
stats: { totalRecords: 0, totalEvents: 0 }
});
}
// 3. 按 record_id 分组
const recordMap = new Map<string, any>();
for (const record of allRecords) {
const recordId = record.record_id || record.id;
if (recordId) {
// 合并同一记录的多个事件数据
const existing = recordMap.get(recordId) || {};
recordMap.set(recordId, { ...existing, ...record });
}
}
// 4. 执行质控
const engine = await createHardRuleEngine(projectId);
const ruleVersion = new Date().toISOString().split('T')[0];
// 3. 统计(按 record+event 组合)
let passCount = 0;
let failCount = 0;
let warningCount = 0;
let uncertainCount = 0;
for (const [recordId, recordData] of recordMap.entries()) {
const qcResult = engine.execute(recordId, recordData);
const uniqueRecords = new Set<string>();
// 存储质控日志
const issues = [
...qcResult.errors.map((e: any) => ({
field: e.field,
rule: e.ruleName,
level: 'RED',
message: e.message
})),
...qcResult.warnings.map((w: any) => ({
field: w.field,
rule: w.ruleName,
level: 'YELLOW',
message: w.message
}))
];
for (const result of results) {
uniqueRecords.add(result.recordId);
if (result.overallStatus === 'PASS') passCount++;
else if (result.overallStatus === 'FAIL') failCount++;
else if (result.overallStatus === 'WARNING') warningCount++;
else uncertainCount++;
await prisma.iitQcLog.create({
data: {
projectId,
recordId,
qcType: 'holistic', // 全案质控
status: qcResult.overallStatus,
issues,
rulesEvaluated: qcResult.summary.totalRules,
rulesSkipped: 0,
rulesPassed: qcResult.summary.passed,
rulesFailed: qcResult.summary.failed,
ruleVersion,
triggeredBy: 'manual'
}
// 更新录入汇总表(取最差状态)
const existingSummary = await prisma.iitRecordSummary.findUnique({
where: { projectId_recordId: { projectId, recordId: result.recordId } }
});
// 更新录入汇总表的质控状态
await prisma.iitRecordSummary.upsert({
where: {
projectId_recordId: { projectId, recordId }
},
create: {
projectId,
recordId,
lastUpdatedAt: new Date(),
latestQcStatus: qcResult.overallStatus,
latestQcAt: new Date(),
formStatus: {},
updateCount: 1
},
update: {
latestQcStatus: qcResult.overallStatus,
latestQcAt: new Date()
}
});
const statusPriority: Record<string, number> = { 'FAIL': 3, 'WARNING': 2, 'UNCERTAIN': 1, 'PASS': 0 };
const currentPriority = statusPriority[result.overallStatus] || 0;
const existingPriority = statusPriority[existingSummary?.latestQcStatus || 'PASS'] || 0;
// 统计
if (qcResult.overallStatus === 'PASS') passCount++;
else if (qcResult.overallStatus === 'FAIL') failCount++;
else warningCount++;
// 只更新为更严重的状态
if (!existingSummary || currentPriority > existingPriority) {
await prisma.iitRecordSummary.upsert({
where: { projectId_recordId: { projectId, recordId: result.recordId } },
create: {
projectId,
recordId: result.recordId,
lastUpdatedAt: new Date(),
latestQcStatus: result.overallStatus,
latestQcAt: new Date(),
formStatus: {},
updateCount: 1
},
update: {
latestQcStatus: result.overallStatus,
latestQcAt: new Date()
}
});
}
}
// 5. 更新项目统计表
// 4. 更新项目统计表
await prisma.iitQcProjectStats.upsert({
where: { projectId },
create: {
projectId,
totalRecords: recordMap.size,
totalRecords: uniqueRecords.size,
passedRecords: passCount,
failedRecords: failCount,
warningRecords: warningCount
warningRecords: warningCount + uncertainCount
},
update: {
totalRecords: recordMap.size,
totalRecords: uniqueRecords.size,
passedRecords: passCount,
failedRecords: failCount,
warningRecords: warningCount
warningRecords: warningCount + uncertainCount
}
});
const durationMs = Date.now() - startTime;
logger.info('✅ 全量质控完成', {
logger.info('✅ 事件级全量质控完成', {
projectId,
totalRecords: recordMap.size,
uniqueRecords: uniqueRecords.size,
totalEventCombinations: results.length,
passCount,
failCount,
warningCount,
uncertainCount,
durationMs
});
return reply.send({
success: true,
message: '全量质控完成',
message: '事件级全量质控完成',
stats: {
totalRecords: recordMap.size,
totalRecords: uniqueRecords.size,
totalEventCombinations: results.length,
passed: passCount,
failed: failCount,
warnings: warningCount,
passRate: `${((passCount / recordMap.size) * 100).toFixed(1)}%`
uncertain: uncertainCount,
passRate: `${((passCount / results.length) * 100).toFixed(1)}%`
},
durationMs
});
} catch (error: any) {
logger.error('❌ 全量质控失败', { projectId, error: error.message });
logger.error('❌ 事件级全量质控失败', { projectId, error: error.message });
return reply.status(500).send({ error: `质控失败: ${error.message}` });
}
}