feat(iit): harden QC pipeline consistency and release artifacts
Implement IIT quality workflow hardening across eQuery deduplication, guard metadata validation, timeline/readability improvements, and chat evidence fallbacks, then synchronize release and development documentation for deployment handoff. Includes migration/scripts for open eQuery dedupe guards, orchestration/status semantics, report/tool readability fixes, and updated module status plus deployment checklist. Made-with: Cursor
This commit is contained in:
261
backend/scripts/e2e_iit_full_flow.ts
Normal file
261
backend/scripts/e2e_iit_full_flow.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { RedcapAdapter } from '../src/modules/iit-manager/adapters/RedcapAdapter.js';
|
||||
import { QcExecutor } from '../src/modules/iit-manager/engines/QcExecutor.js';
|
||||
import { dailyQcOrchestrator } from '../src/modules/iit-manager/services/DailyQcOrchestrator.js';
|
||||
import { QcReportService } from '../src/modules/iit-manager/services/QcReportService.js';
|
||||
import { createToolsService } from '../src/modules/iit-manager/services/ToolsService.js';
|
||||
import { iitQcCockpitService } from '../src/modules/admin/iit-projects/iitQcCockpitService.js';
|
||||
import { getChatOrchestrator } from '../src/modules/iit-manager/services/ChatOrchestrator.js';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
type StageResult = {
|
||||
name: string;
|
||||
ok: boolean;
|
||||
checks: Array<{ name: string; ok: boolean; value?: unknown }>;
|
||||
detail?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
function check(name: string, condition: boolean, value?: unknown) {
|
||||
return { name, ok: condition, value };
|
||||
}
|
||||
|
||||
function assertStage(stage: StageResult) {
|
||||
const failed = stage.checks.filter((c) => !c.ok);
|
||||
if (failed.length > 0) {
|
||||
const reason = failed.map((f) => f.name).join(', ');
|
||||
throw new Error(`[${stage.name}] 断言失败: ${reason}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const projectId = process.argv[2];
|
||||
const withChat = process.argv.includes('--with-chat') || process.env.E2E_WITH_CHAT === '1';
|
||||
const strictGuards = process.argv.includes('--strict-guards') || process.env.E2E_REQUIRE_GUARD_TYPES === '1';
|
||||
if (!projectId) {
|
||||
throw new Error('Usage: npx tsx scripts/e2e_iit_full_flow.ts <projectId> [--with-chat] [--strict-guards]');
|
||||
}
|
||||
|
||||
const summary: {
|
||||
projectId: string;
|
||||
withChat: boolean;
|
||||
strictGuards: boolean;
|
||||
startedAt: string;
|
||||
stages: StageResult[];
|
||||
endedAt?: string;
|
||||
} = {
|
||||
projectId,
|
||||
withChat,
|
||||
strictGuards,
|
||||
startedAt: new Date().toISOString(),
|
||||
stages: [],
|
||||
};
|
||||
|
||||
// Stage 1: REDCap 结构 + 数据同步能力
|
||||
{
|
||||
const project = await prisma.iitProject.findUnique({
|
||||
where: { id: projectId },
|
||||
select: { redcapUrl: true, redcapApiToken: true },
|
||||
});
|
||||
if (!project?.redcapUrl || !project?.redcapApiToken) {
|
||||
throw new Error('项目未配置 REDCap 连接信息');
|
||||
}
|
||||
const adapter = new RedcapAdapter(project.redcapUrl, project.redcapApiToken);
|
||||
const [metadata, events, formEventMapping, recordsByEvent] = await Promise.all([
|
||||
adapter.exportMetadata(),
|
||||
adapter.getEvents(),
|
||||
adapter.getFormEventMapping(),
|
||||
adapter.getAllRecordsByEvent(),
|
||||
]);
|
||||
|
||||
const uniqueRecords = new Set(recordsByEvent.map((r) => r.recordId));
|
||||
const stage: StageResult = {
|
||||
name: 'Stage1_REDCap',
|
||||
ok: true,
|
||||
checks: [
|
||||
check('metadata_non_empty', metadata.length > 0, metadata.length),
|
||||
check('records_by_event_non_empty', recordsByEvent.length > 0, recordsByEvent.length),
|
||||
check('unique_records_non_empty', uniqueRecords.size > 0, uniqueRecords.size),
|
||||
check('events_loaded_or_non_longitudinal', events.length > 0 || formEventMapping.length === 0, {
|
||||
events: events.length,
|
||||
formEventMapping: formEventMapping.length,
|
||||
}),
|
||||
],
|
||||
detail: {
|
||||
metadataCount: metadata.length,
|
||||
eventCount: events.length,
|
||||
formEventMappingCount: formEventMapping.length,
|
||||
recordEventRows: recordsByEvent.length,
|
||||
uniqueRecordCount: uniqueRecords.size,
|
||||
},
|
||||
};
|
||||
assertStage(stage);
|
||||
summary.stages.push(stage);
|
||||
}
|
||||
|
||||
// Stage 2: 规则配置加载与覆盖
|
||||
{
|
||||
const skill = await prisma.iitSkill.findFirst({
|
||||
where: { projectId, skillType: 'qc_process', isActive: true },
|
||||
select: { config: true },
|
||||
});
|
||||
const rules = ((skill?.config as any)?.rules || []) as Array<{
|
||||
id: string;
|
||||
name?: string;
|
||||
category?: string;
|
||||
field?: string | string[];
|
||||
metadata?: Record<string, unknown>;
|
||||
}>;
|
||||
const multiFieldRules = rules.filter((r) => Array.isArray(r.field)).length;
|
||||
const categorySet = new Set(rules.map((r) => String(r.category || '')).filter(Boolean));
|
||||
const guardCandidates = rules.filter((r) =>
|
||||
/访视日期.*知情同意|早于知情同意|SF-?MPQ.*CMSS.*不一致|评估日期.*访视日期.*不一致|所有纳入标准.*检查|纳入标准.*满足|入组状态.*排除标准.*冲突/i.test(String(r.name || '')),
|
||||
);
|
||||
const guardConfigured = guardCandidates.filter((r) => String((r.metadata as any)?.guardType || '').trim().length > 0);
|
||||
|
||||
const stage: StageResult = {
|
||||
name: 'Stage2_Rules',
|
||||
ok: true,
|
||||
checks: [
|
||||
check('active_qc_rules_exists', rules.length > 0, rules.length),
|
||||
check('multi_field_rules_exists', multiFieldRules > 0, multiFieldRules),
|
||||
check('has_D1_or_legacy_inclusion_exclusion', categorySet.has('D1') || categorySet.has('inclusion') || categorySet.has('exclusion'), Array.from(categorySet)),
|
||||
check(
|
||||
strictGuards ? 'guardtype_coverage_required' : 'guardtype_coverage_info',
|
||||
strictGuards ? guardConfigured.length === guardCandidates.length : true,
|
||||
{ configured: guardConfigured.length, candidates: guardCandidates.length },
|
||||
),
|
||||
],
|
||||
detail: {
|
||||
ruleCount: rules.length,
|
||||
multiFieldRuleCount: multiFieldRules,
|
||||
categories: Array.from(categorySet).sort(),
|
||||
guardTypeCoverage: {
|
||||
strictMode: strictGuards,
|
||||
configured: guardConfigured.length,
|
||||
candidates: guardCandidates.length,
|
||||
},
|
||||
},
|
||||
};
|
||||
assertStage(stage);
|
||||
summary.stages.push(stage);
|
||||
}
|
||||
|
||||
// Stage 3: 质控执行 + 报告编排
|
||||
{
|
||||
const executor = new QcExecutor(projectId);
|
||||
const batch = await executor.executeBatch({ triggeredBy: 'manual', skipSoftRules: true });
|
||||
const orchestrate = await dailyQcOrchestrator.orchestrate(projectId, { skipPush: true });
|
||||
|
||||
const [fieldCountRows, eventCountRows, summaryCountRows, projectStats] = await Promise.all([
|
||||
prisma.$queryRaw<Array<{ cnt: bigint }>>`
|
||||
SELECT COUNT(*)::bigint AS cnt FROM iit_schema.qc_field_status WHERE project_id = ${projectId}
|
||||
`,
|
||||
prisma.$queryRaw<Array<{ cnt: bigint }>>`
|
||||
SELECT COUNT(*)::bigint AS cnt FROM iit_schema.qc_event_status WHERE project_id = ${projectId}
|
||||
`,
|
||||
prisma.$queryRaw<Array<{ cnt: bigint }>>`
|
||||
SELECT COUNT(*)::bigint AS cnt FROM iit_schema.record_summary WHERE project_id = ${projectId}
|
||||
`,
|
||||
prisma.iitQcProjectStats.findUnique({ where: { projectId } }),
|
||||
]);
|
||||
|
||||
const fieldStatusCount = Number(fieldCountRows[0]?.cnt || 0n);
|
||||
const eventStatusCount = Number(eventCountRows[0]?.cnt || 0n);
|
||||
const recordSummaryCount = Number(summaryCountRows[0]?.cnt || 0n);
|
||||
|
||||
const stage: StageResult = {
|
||||
name: 'Stage3_Execution',
|
||||
ok: true,
|
||||
checks: [
|
||||
check('batch_has_records', batch.totalRecords > 0, batch.totalRecords),
|
||||
check('field_status_written', fieldStatusCount > 0, fieldStatusCount),
|
||||
check('event_status_written', eventStatusCount > 0, eventStatusCount),
|
||||
check('record_summary_written', recordSummaryCount > 0, recordSummaryCount),
|
||||
check('project_stats_exists', !!projectStats, projectStats ? true : false),
|
||||
],
|
||||
detail: {
|
||||
batch,
|
||||
orchestrate,
|
||||
db: {
|
||||
fieldStatusCount,
|
||||
eventStatusCount,
|
||||
recordSummaryCount,
|
||||
projectStats: projectStats
|
||||
? {
|
||||
totalRecords: projectStats.totalRecords,
|
||||
passedRecords: projectStats.passedRecords,
|
||||
failedRecords: projectStats.failedRecords,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
assertStage(stage);
|
||||
summary.stages.push(stage);
|
||||
}
|
||||
|
||||
// Stage 4: 多消费者一致性(驾驶舱 / 报告 / 工具 / 可选Chat)
|
||||
{
|
||||
const [report, cockpitData, equeryLog, tools] = await Promise.all([
|
||||
QcReportService.getReport(projectId),
|
||||
iitQcCockpitService.getCockpitData(projectId),
|
||||
iitQcCockpitService.getEqueryLogReport(projectId),
|
||||
createToolsService(projectId),
|
||||
]);
|
||||
|
||||
const toolSummary = await tools.execute('read_report', { section: 'summary' }, 'e2e-script');
|
||||
const toolPassRate = Number((toolSummary as any)?.data?.passRate ?? NaN);
|
||||
const reportPassRate = Number(report.summary.passRate);
|
||||
const cockpitPassRate = Number(cockpitData.stats.passRate);
|
||||
const passRateConsistent =
|
||||
Number.isFinite(toolPassRate) &&
|
||||
Math.abs(reportPassRate - cockpitPassRate) < 0.0001 &&
|
||||
Math.abs(reportPassRate - toolPassRate) < 0.0001;
|
||||
|
||||
let chatResult: string | null = null;
|
||||
if (withChat) {
|
||||
const orchestrator = await getChatOrchestrator(projectId);
|
||||
chatResult = await orchestrator.handleMessage('e2e-user', '当前项目总体通过率是多少?');
|
||||
}
|
||||
|
||||
const stage: StageResult = {
|
||||
name: 'Stage4_Consumption',
|
||||
ok: true,
|
||||
checks: [
|
||||
check('report_summary_exists', report.summary.totalRecords >= 0, report.summary.totalRecords),
|
||||
check('cockpit_stats_exists', cockpitData.stats.totalRecords >= 0, cockpitData.stats.totalRecords),
|
||||
check('equery_log_summary_exists', equeryLog.summary.total >= 0, equeryLog.summary.total),
|
||||
check('pass_rate_consistent_report_cockpit_tool', passRateConsistent, {
|
||||
reportPassRate,
|
||||
cockpitPassRate,
|
||||
toolPassRate,
|
||||
}),
|
||||
check('chat_has_conclusion_or_skipped', !withChat || !!chatResult, chatResult || 'skipped'),
|
||||
],
|
||||
detail: {
|
||||
reportPassRate,
|
||||
cockpitPassRate,
|
||||
toolPassRate,
|
||||
chatResult: withChat ? chatResult : 'skipped',
|
||||
},
|
||||
};
|
||||
assertStage(stage);
|
||||
summary.stages.push(stage);
|
||||
}
|
||||
|
||||
summary.endedAt = new Date().toISOString();
|
||||
console.log(JSON.stringify(summary, null, 2));
|
||||
}
|
||||
|
||||
main()
|
||||
.then(async () => {
|
||||
await prisma.$disconnect();
|
||||
process.exit(0);
|
||||
})
|
||||
.catch(async (e) => {
|
||||
console.error(e);
|
||||
await prisma.$disconnect();
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user