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:
2026-03-08 21:54:35 +08:00
parent ac724266c1
commit a666649fd4
57 changed files with 28637 additions and 316 deletions

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