Files
AIclinicalresearch/backend/scripts/e2e_iit_full_flow.ts
HaHafeng a666649fd4 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
2026-03-08 21:54:35 +08:00

262 lines
9.8 KiB
TypeScript
Raw Permalink 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.
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);
});