/** * IIT 质控全链路诊断脚本 * * 从 REDCap 原始数据 → 质控规则执行 → 数据库存储 → LLM 报告生成, * 端到端可视化验证整个 QC 管线。 * * 用法:npx tsx scripts/test-qc-pipeline.ts */ import { PrismaClient } from '@prisma/client'; import { RedcapAdapter } from '../src/modules/iit-manager/adapters/RedcapAdapter.js'; import { createSkillRunner } from '../src/modules/iit-manager/engines/SkillRunner.js'; import { QcReportService } from '../src/modules/iit-manager/services/QcReportService.js'; const prisma = new PrismaClient(); const PROJECT_ID = 'test0102-pd-study'; // ─── 工具函数 ──────────────────────────────────────────────── const SEP = '═'.repeat(80); const SEP2 = '─'.repeat(80); function section(title: string) { console.log(`\n${SEP}`); console.log(` ${title}`); console.log(SEP); } function sub(title: string) { console.log(`\n${SEP2}`); console.log(` ${title}`); console.log(SEP2); } function table(rows: Record[], maxRows = 5) { if (rows.length === 0) { console.log(' (空)'); return; } const display = rows.slice(0, maxRows); console.table(display); if (rows.length > maxRows) { console.log(` ... 共 ${rows.length} 行,仅展示前 ${maxRows} 行`); } } function jsonPreview(obj: any, maxLen = 2000) { const str = JSON.stringify(obj, null, 2); if (str.length <= maxLen) { console.log(str); } else { console.log(str.substring(0, maxLen)); console.log(`\n ... (截断,总长 ${str.length} 字符)`); } } // ─── Part 1:REDCap 原始数据 ───────────────────────────────── async function part1_redcapRawData() { section('Part 1:REDCap 原始数据'); const project = await prisma.iitProject.findUnique({ where: { id: PROJECT_ID }, select: { id: true, name: true, redcapUrl: true, redcapApiToken: true }, }); if (!project) { console.log('❌ 项目不存在:', PROJECT_ID); return; } console.log(` 项目: ${project.name} (${project.id})`); console.log(` REDCap: ${project.redcapUrl}`); const adapter = new RedcapAdapter(project.redcapUrl, project.redcapApiToken); // 先检测 REDCap 是否可达 let redcapAvailable = true; try { await adapter.exportRecords({ fields: ['record_id'] }); } catch { redcapAvailable = false; console.log('\n ⚠️ REDCap 服务不可达,跳过 REDCap 数据拉取,仅验证数据库数据。'); console.log(' 请确保 REDCap (localhost:8080) 已启动后再次运行。'); return; } // 1a. 事件映射 sub('1a. 表单-事件映射 (Form-Event Mapping)'); try { const formEventMapping = await adapter.getFormEventMapping(); table(formEventMapping, 20); } catch (e: any) { console.log(` ⚠️ 获取失败: ${e.message}(可能不是纵向研究项目)`); } // 1b. 所有 instruments sub('1b. REDCap 表单列表 (Instruments)'); try { const instruments = await adapter.exportInstruments(); table(instruments, 20); } catch (e: any) { console.log(` ⚠️ 获取失败: ${e.message}`); } // 1c. 原始记录 sub('1c. REDCap 原始记录 (Raw Records - 前 5 行)'); let rawRecords: any[] = []; try { rawRecords = await adapter.exportRecords({}); console.log(` 总行数: ${rawRecords.length}`); for (let i = 0; i < Math.min(5, rawRecords.length); i++) { console.log(`\n --- Record ${i + 1} ---`); jsonPreview(rawRecords[i], 1500); } } catch (e: any) { console.log(` ⚠️ 获取失败: ${e.message}`); } // 1d. 事件级数据 sub('1d. 事件级数据 (getAllRecordsByEvent)'); let eventRecords: Array<{ recordId: string; eventName: string; eventLabel: string; forms: string[]; data: Record }> = []; try { eventRecords = await adapter.getAllRecordsByEvent({}); console.log(` 总 record+event 组合数: ${eventRecords.length}`); const uniqueRecords = new Set(eventRecords.map(r => r.recordId)); const uniqueEvents = new Set(eventRecords.map(r => r.eventName)); console.log(` 唯一 record 数: ${uniqueRecords.size}`); console.log(` 唯一 event 数: ${uniqueEvents.size}`); console.log(` 事件列表: ${[...uniqueEvents].join(', ')}`); } catch (e: any) { console.log(` ⚠️ 获取失败: ${e.message}(可能不是纵向研究设计)`); // 回退:把 rawRecords 当作单事件处理 if (rawRecords.length > 0) { console.log(' 📌 回退:使用 exportRecords 数据作为平铺记录'); const uniqueIds = new Set(rawRecords.map(r => r.record_id)); eventRecords = rawRecords.map(r => ({ recordId: r.record_id, eventName: r.redcap_event_name || 'default', eventLabel: r.redcap_event_name || 'default', forms: [], data: r, })); console.log(` 唯一 record 数: ${uniqueIds.size}, 总行数: ${eventRecords.length}`); } } if (eventRecords.length > 0) { // 1e. Record-Event 分布 const recordGroups = new Map(); for (const r of eventRecords) { if (!recordGroups.has(r.recordId)) recordGroups.set(r.recordId, []); recordGroups.get(r.recordId)!.push(r); } const recordEventSummary: Array<{ recordId: string; events: string; dataFieldCount: number }> = []; for (const [recordId, events] of recordGroups) { recordEventSummary.push({ recordId, events: events.map(e => e.eventLabel || e.eventName).join(' | '), dataFieldCount: events.reduce((sum, e) => sum + Object.keys(e.data).length, 0), }); } sub('1e. Record-Event 分布'); table(recordEventSummary, 20); // 1f. 纳入/排除关键字段可用性 sub('1f. 纳入/排除关键字段可用性检查'); const keyFields = ['age', 'birth_date', 'menstrual_cycle', 'vas_score', 'informed_consent', 'secondary_dysmenorrhea', 'pregnancy_lactation', 'severe_disease', 'irregular_menstruation']; const fieldAvailability: Array<{ recordId: string; event: string; [key: string]: any }> = []; for (const r of eventRecords.slice(0, 30)) { const row: any = { recordId: r.recordId, event: r.eventLabel || r.eventName }; for (const f of keyFields) { const val = r.data[f]; row[f] = val === undefined || val === null || val === '' ? '❌' : `✅ ${val}`; } fieldAvailability.push(row); } table(fieldAvailability, 30); } return { eventRecords, rawRecords }; } // ─── Part 2:执行质控 + 数据库存储验证 ───────────────────── async function part2_qcExecutionAndStorage() { section('Part 2:质控执行与数据库存储验证'); // 2a. 查看当前 QC 规则 sub('2a. 当前加载的 QC 规则'); const skill = await prisma.iitSkill.findFirst({ where: { projectId: PROJECT_ID, skillType: 'qc_process', isActive: true }, select: { id: true, name: true, config: true }, }); if (!skill) { console.log('❌ 未找到 QC 规则'); return; } const config = skill.config as any; const rules = config?.rules || []; console.log(` 规则总数: ${rules.length}`); const ruleOverview = rules.map((r: any) => ({ id: r.id, name: r.name, category: r.category, severity: r.severity, field: Array.isArray(r.field) ? r.field.join(',') : r.field, hasNullTolerance: JSON.stringify(r.logic).includes('"=="') && JSON.stringify(r.logic).includes('null') ? '✅' : '❌', })); table(ruleOverview, 40); // 2b. 执行质控前 - 记录当前 qc_logs 行数 sub('2b. 执行质控前 - 数据库状态'); const logCountBefore = await prisma.iitQcLog.count({ where: { projectId: PROJECT_ID } }); console.log(` qc_logs 现有行数: ${logCountBefore}`); const statsBefore = await prisma.iitQcProjectStats.findUnique({ where: { projectId: PROJECT_ID } }); if (statsBefore) { console.log(` project_stats: total=${statsBefore.totalRecords}, pass=${statsBefore.passedRecords}, fail=${statsBefore.failedRecords}, warn=${statsBefore.warningRecords}`); } // 2c. 清理旧版日志(event_id 为 NULL 的遗留数据) sub('2c-0. 清理旧版质控日志 (event_id IS NULL)'); const deletedLegacy = await prisma.iitQcLog.deleteMany({ where: { projectId: PROJECT_ID, eventId: null } }); console.log(` 删除旧版日志: ${deletedLegacy.count} 条`); // 2c. 执行全量质控 sub('2c. 执行全量质控 (SkillRunner.runByTrigger)'); const runner = createSkillRunner(PROJECT_ID); const startTime = Date.now(); const results = await runner.runByTrigger('manual'); const duration = Date.now() - startTime; console.log(` 耗时: ${duration}ms`); console.log(` 结果总数 (record+event 组合): ${results.length}`); // 状态分布 const statusDist: Record = {}; for (const r of results) { statusDist[r.overallStatus] = (statusDist[r.overallStatus] || 0) + 1; } console.log(` 事件级状态分布: ${JSON.stringify(statusDist)}`); // record 级别聚合 const statusPriority: Record = { 'FAIL': 3, 'WARNING': 2, 'UNCERTAIN': 1, 'PASS': 0 }; const recordWorst = new Map(); for (const r of results) { const existing = recordWorst.get(r.recordId); const curP = statusPriority[r.overallStatus] ?? 0; const exP = existing ? (statusPriority[existing] ?? 0) : -1; if (curP > exP) recordWorst.set(r.recordId, r.overallStatus); } const recordStatusDist: Record = {}; for (const s of recordWorst.values()) { recordStatusDist[s] = (recordStatusDist[s] || 0) + 1; } console.log(` Record 级别状态分布: ${JSON.stringify(recordStatusDist)}`); const totalRec = recordWorst.size; const passRec = recordStatusDist['PASS'] || 0; console.log(` 通过率 (record级): ${totalRec > 0 ? ((passRec / totalRec) * 100).toFixed(1) : 0}%`); // V3.2: 用批量结果更新 record_summary(覆盖旧状态) for (const [recordId, worstStatus] of recordWorst) { await prisma.iitRecordSummary.upsert({ where: { projectId_recordId: { projectId: PROJECT_ID, recordId } }, create: { projectId: PROJECT_ID, recordId, lastUpdatedAt: new Date(), latestQcStatus: worstStatus, latestQcAt: new Date(), formStatus: {}, updateCount: 1 }, update: { latestQcStatus: worstStatus, latestQcAt: new Date() } }); } // 展示每个 result 的详情 sub('2d. 各 record+event 质控详情'); const resultSummary = results.map(r => ({ recordId: r.recordId, event: r.eventLabel || r.eventName || '-', status: r.overallStatus, issueCount: r.allIssues.length, criticalCount: r.criticalIssues.length, warningCount: r.warningIssues.length, issues: r.allIssues.slice(0, 3).map(i => `[${i.ruleId}] ${i.ruleName}`).join('; ') || '(无)', })); table(resultSummary, 50); // 2e. 验证数据库写入 sub('2e. 执行质控后 - 数据库状态'); const logCountAfter = await prisma.iitQcLog.count({ where: { projectId: PROJECT_ID } }); console.log(` qc_logs 行数: ${logCountBefore} → ${logCountAfter} (新增 ${logCountAfter - logCountBefore})`); // 2f. 验证 DISTINCT ON 只取最新 sub('2f. 验证去重逻辑 - DISTINCT ON (record_id, event_id) 只取最新'); const latestLogs = await prisma.$queryRawUnsafe(` SELECT DISTINCT ON (record_id, COALESCE(event_id, '')) record_id, event_id, status, created_at, (SELECT COUNT(*) FROM iit_schema.qc_logs t2 WHERE t2.project_id = t1.project_id AND t2.record_id = t1.record_id AND COALESCE(t2.event_id, '') = COALESCE(t1.event_id, '') ) as total_versions FROM iit_schema.qc_logs t1 WHERE project_id = '${PROJECT_ID}' ORDER BY record_id, COALESCE(event_id, ''), created_at DESC `); console.log(` 去重后的 record+event 组合数: ${latestLogs.length}`); const dedup = latestLogs.map((l: any) => ({ record_id: l.record_id, event_id: l.event_id || '-', status: l.status, total_versions: Number(l.total_versions), created_at: l.created_at, })); table(dedup, 30); const hasMultipleVersions = dedup.some(d => d.total_versions > 1); console.log(` 是否存在多版本: ${hasMultipleVersions ? '✅ 是(去重逻辑生效)' : '仅单版本'}`); // 2g. 查看一条 qc_log 的完整 issues 内容 sub('2g. qc_log issues 字段样本(取第一条有 issues 的)'); const sampleLog = await prisma.iitQcLog.findFirst({ where: { projectId: PROJECT_ID, status: { not: 'PASS' } }, orderBy: { createdAt: 'desc' }, select: { recordId: true, eventId: true, status: true, issues: true, createdAt: true }, }); if (sampleLog) { console.log(` recordId: ${sampleLog.recordId}, eventId: ${sampleLog.eventId}, status: ${sampleLog.status}`); console.log(` issues 内容:`); jsonPreview(sampleLog.issues, 3000); } else { console.log(' ✅ 所有日志都是 PASS(没有 issues)'); } // 2h. record_summary 表验证 sub('2h. record_summary 表内容'); const summaries = await prisma.iitRecordSummary.findMany({ where: { projectId: PROJECT_ID }, select: { recordId: true, latestQcStatus: true, latestQcAt: true, completionRate: true, totalForms: true, completedForms: true }, orderBy: { recordId: 'asc' }, }); table(summaries.map(s => ({ recordId: s.recordId, qcStatus: s.latestQcStatus || '-', qcAt: s.latestQcAt ? s.latestQcAt.toISOString().replace('T', ' ').substring(0, 19) : '-', completionRate: s.completionRate != null ? `${s.completionRate}%` : '-', })), 20); return results; } // ─── Part 3:LLM 报告 ─────────────────────────────────────── async function part3_llmReport() { section('Part 3:最终呈现给 LLM 的质控报告'); // 3a. 强制生成新报告 sub('3a. 调用 QcReportService.getReport (forceRefresh=true)'); const report = await QcReportService.getReport(PROJECT_ID, { forceRefresh: true }); console.log(` reportType: ${report.reportType}`); console.log(` generatedAt: ${report.generatedAt}`); // 3b. Summary 统计 sub('3b. 报告 Summary'); console.log(JSON.stringify(report.summary, null, 2)); // 3c. Critical Issues sub('3c. 严重问题 (Critical Issues)'); console.log(` 总数: ${report.criticalIssues.length}`); table(report.criticalIssues.map(i => ({ recordId: i.recordId, ruleId: i.ruleId, ruleName: i.ruleName, severity: i.severity, actualValue: i.actualValue ?? '空', expectedValue: i.expectedValue ?? '-', })), 30); // 3d. Warning Issues sub('3d. 警告问题 (Warning Issues)'); console.log(` 总数: ${report.warningIssues.length}`); table(report.warningIssues.map(i => ({ recordId: i.recordId, ruleId: i.ruleId, ruleName: i.ruleName, actualValue: i.actualValue ?? '空', })), 20); // 3e. Top Issues sub('3e. Top Issues'); table(report.topIssues, 10); // 3f. LLM XML 报告全文 sub('3f. LLM XML 报告全文 (llmFriendlyXml)'); console.log(report.llmFriendlyXml); // 3g. 验证数据库 qc_reports 表 sub('3g. qc_reports 缓存表验证'); const cachedReports = await prisma.iitQcReport.findMany({ where: { projectId: PROJECT_ID }, select: { id: true, reportType: true, generatedAt: true, expiresAt: true }, orderBy: { generatedAt: 'desc' }, take: 5, }); table(cachedReports.map(r => ({ id: r.id.substring(0, 8) + '...', reportType: r.reportType, generatedAt: r.generatedAt.toISOString().replace('T', ' ').substring(0, 19), expiresAt: r.expiresAt?.toISOString().replace('T', ' ').substring(0, 19) || '-', })), 10); } // ─── 回退:仅查看数据库现有 QC 数据 ───────────────────────── async function part2_dbOnly() { section('Part 2 (回退):数据库现有 QC 数据查看'); const logCount = await prisma.iitQcLog.count({ where: { projectId: PROJECT_ID } }); console.log(` qc_logs 总行数: ${logCount}`); if (logCount === 0) { console.log(' ⚠️ 没有质控日志,请先执行一键全量质控'); return; } sub('数据库 qc_logs 去重后最新状态'); const latestLogs = await prisma.$queryRawUnsafe(` SELECT DISTINCT ON (record_id, COALESCE(event_id, '')) record_id, event_id, status, issues, created_at FROM iit_schema.qc_logs WHERE project_id = '${PROJECT_ID}' ORDER BY record_id, COALESCE(event_id, ''), created_at DESC `); const dedup = latestLogs.map((l: any) => ({ record_id: l.record_id, event_id: l.event_id || '-', status: l.status, created_at: l.created_at, })); table(dedup, 30); sub('record_summary 表'); const summaries = await prisma.iitRecordSummary.findMany({ where: { projectId: PROJECT_ID }, select: { recordId: true, latestQcStatus: true, latestQcAt: true, completionRate: true }, orderBy: { recordId: 'asc' }, }); table(summaries.map(s => ({ recordId: s.recordId, qcStatus: s.latestQcStatus || '-', completionRate: s.completionRate != null ? `${s.completionRate}%` : '-', })), 20); sub('project_stats 表'); const stats = await prisma.iitQcProjectStats.findUnique({ where: { projectId: PROJECT_ID } }); if (stats) { console.log(JSON.stringify({ totalRecords: stats.totalRecords, passedRecords: stats.passedRecords, failedRecords: stats.failedRecords, warningRecords: stats.warningRecords, }, null, 2)); } sub('qc_log issues 字段样本'); const sampleLog = await prisma.iitQcLog.findFirst({ where: { projectId: PROJECT_ID, status: { not: 'PASS' } }, orderBy: { createdAt: 'desc' }, select: { recordId: true, eventId: true, status: true, issues: true }, }); if (sampleLog) { console.log(` recordId: ${sampleLog.recordId}, eventId: ${sampleLog.eventId}, status: ${sampleLog.status}`); jsonPreview(sampleLog.issues, 3000); } else { console.log(' ✅ 所有日志都是 PASS'); } } async function part3_dbOnly() { section('Part 3 (回退):从数据库缓存读取报告'); const cached = await prisma.iitQcReport.findFirst({ where: { projectId: PROJECT_ID }, orderBy: { generatedAt: 'desc' }, }); if (!cached) { console.log(' ⚠️ 数据库中没有缓存报告'); return; } console.log(` reportType: ${cached.reportType}`); console.log(` generatedAt: ${cached.generatedAt}`); sub('Summary'); console.log(JSON.stringify(cached.summary, null, 2)); sub('LLM XML 报告 (llmReport)'); console.log(cached.llmReport || '(空)'); } // ─── 主流程 ────────────────────────────────────────────────── async function main() { console.log('╔════════════════════════════════════════════════════════════════╗'); console.log('║ IIT 质控全链路诊断脚本 (QC Pipeline Deep Test) ║'); console.log('║ 项目: ' + PROJECT_ID.padEnd(47) + '║'); console.log('╚════════════════════════════════════════════════════════════════╝'); try { // Part 1: REDCap 原始数据(可能因 REDCap 不可达而跳过) try { await part1_redcapRawData(); } catch (e: any) { console.log(`\n ⚠️ Part 1 出错: ${e.message}(继续执行后续部分)`); } // Part 2: 质控执行 + 数据库验证 try { await part2_qcExecutionAndStorage(); } catch (e: any) { console.log(`\n ⚠️ Part 2 出错: ${e.message}`); console.log(' 尝试仅验证数据库现有数据...\n'); await part2_dbOnly(); } // Part 3: LLM 报告 try { await part3_llmReport(); } catch (e: any) { console.log(`\n ⚠️ Part 3 出错: ${e.message}`); console.log(' 尝试从数据库缓存读取报告...\n'); await part3_dbOnly(); } section('✅ 全链路诊断完成'); console.log(' 请检查以上输出确认数据正确性。'); } catch (error: any) { console.error('\n❌ 诊断过程出错:', error.message); console.error(error.stack); } finally { await prisma.$disconnect(); } } main();