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:
2026-03-01 15:27:05 +08:00
parent c3f7d54fdf
commit 0b29fe88b5
61 changed files with 6877 additions and 524 deletions

View 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 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();