feat(iit): QC deep fix + V3.1 architecture plan + project member management
QC System Deep Fix: - HardRuleEngine: add null tolerance + field availability pre-check (skipped status) - SkillRunner: baseline data merge for follow-up events + field availability check - QcReportService: record-level pass rate calculation + accurate LLM XML report - iitBatchController: legacy log cleanup (eventId=null) + upsert RecordSummary - seed-iit-qc-rules: null/empty string tolerance + applicableEvents config V3.1 Architecture Design (docs only, no code changes): - QC engine V3.1 plan: 5-level data structure (CDISC ODM) + D1-D7 dimensions - Three-batch implementation strategy (A: foundation, B: bubbling, C: new engines) - Architecture team review: 4 whitepapers reviewed + feedback doc + 4 critical suggestions - CRA Agent strategy roadmap + CRA 4-tool explanation doc for clinical experts Project Member Management: - Cross-tenant member search and assignment (remove tenant restriction) - IIT project detail page enhancement with tabbed layout (KB + members) - IitProjectContext for business-side project selection - System-KB route access control adjustment for project operators Frontend: - AdminLayout sidebar menu restructure - IitLayout with project context provider - IitMemberManagePage new component - Business-side pages adapt to project context Prisma: - 2 new migrations (user-project RBAC + is_demo flag) - Schema updates for project member management Made-with: Cursor
This commit is contained in:
553
backend/scripts/test-qc-pipeline.ts
Normal file
553
backend/scripts/test-qc-pipeline.ts
Normal file
@@ -0,0 +1,553 @@
|
||||
/**
|
||||
* 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<string, any>[], 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<string, any> }> = [];
|
||||
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<string, typeof eventRecords>();
|
||||
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<string, number> = {};
|
||||
for (const r of results) {
|
||||
statusDist[r.overallStatus] = (statusDist[r.overallStatus] || 0) + 1;
|
||||
}
|
||||
console.log(` 事件级状态分布: ${JSON.stringify(statusDist)}`);
|
||||
|
||||
// record 级别聚合
|
||||
const statusPriority: Record<string, number> = { 'FAIL': 3, 'WARNING': 2, 'UNCERTAIN': 1, 'PASS': 0 };
|
||||
const recordWorst = new Map<string, string>();
|
||||
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<string, number> = {};
|
||||
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<any[]>(`
|
||||
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<any[]>(`
|
||||
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();
|
||||
Reference in New Issue
Block a user