Files
AIclinicalresearch/backend/scripts/test-qc-pipeline.ts
HaHafeng 0b29fe88b5 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
2026-03-01 15:27:05 +08:00

554 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 1REDCap 原始数据 ─────────────────────────────────
async function part1_redcapRawData() {
section('Part 1REDCap 原始数据');
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 3LLM 报告 ───────────────────────────────────────
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();