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