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,97 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
const projectId = process.argv[2];
if (!projectId) {
throw new Error('Usage: npx tsx scripts/analyze_missing_equery_context.ts <projectId>');
}
const reasonStats = await prisma.$queryRawUnsafe<Array<{ reason: string; cnt: bigint }>>(`
WITH missing AS (
SELECT
e.id,
e.record_id,
e.category,
e.field_name,
e.event_id,
e.form_name,
e.created_at
FROM iit_schema.equery e
WHERE e.project_id = $1
AND (COALESCE(e.event_id, '') = '' OR COALESCE(e.form_name, '') = '')
),
flags AS (
SELECT
m.*,
EXISTS (
SELECT 1
FROM iit_schema.qc_field_status fs
WHERE fs.project_id = $1
AND fs.record_id = m.record_id
) AS has_record_match,
EXISTS (
SELECT 1
FROM iit_schema.qc_field_status fs
WHERE fs.project_id = $1
AND fs.record_id = m.record_id
AND COALESCE(fs.rule_name, '') = COALESCE(m.category, '')
) AS has_rule_match,
EXISTS (
SELECT 1
FROM iit_schema.qc_field_status fs
WHERE fs.project_id = $1
AND fs.record_id = m.record_id
AND COALESCE(fs.rule_name, '') = COALESCE(m.category, '')
AND COALESCE(fs.field_name, '') = COALESCE(m.field_name, '')
) AS has_rule_field_match
FROM missing m
)
SELECT
CASE
WHEN has_rule_field_match THEN 'A_RULE_FIELD_MATCH_BUT_EVENT_FORM_EMPTY'
WHEN has_rule_match THEN 'B_RULE_MATCH_FIELD_MISMATCH'
WHEN has_record_match THEN 'C_RECORD_MATCH_ONLY'
ELSE 'D_NO_RECORD_MATCH'
END AS reason,
COUNT(*)::bigint AS cnt
FROM flags
GROUP BY 1
ORDER BY 2 DESC
`, projectId);
const sample = await prisma.$queryRawUnsafe<Array<{
id: string;
record_id: string;
category: string | null;
field_name: string | null;
event_id: string | null;
form_name: string | null;
created_at: Date;
}>>(`
SELECT
id, record_id, category, field_name, event_id, form_name, created_at
FROM iit_schema.equery
WHERE project_id = $1
AND (COALESCE(event_id, '') = '' OR COALESCE(form_name, '') = '')
ORDER BY created_at DESC
LIMIT 20
`, projectId);
console.log(JSON.stringify({
projectId,
reasonStats: reasonStats.map((r) => ({ reason: r.reason, cnt: Number(r.cnt) })),
sample,
}, null, 2));
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

View File

@@ -0,0 +1,111 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
const projectId = process.argv[2];
if (!projectId) {
throw new Error('Usage: npx tsx scripts/backfill_equery_context.ts <projectId>');
}
const beforeRows = await prisma.$queryRawUnsafe<Array<{ total: bigint; missing: bigint }>>(`
SELECT
COUNT(*)::bigint AS total,
COUNT(*) FILTER (
WHERE COALESCE(event_id, '') = '' OR COALESCE(form_name, '') = ''
)::bigint AS missing
FROM iit_schema.equery
WHERE project_id = $1
`, projectId);
// Phase 1: 严格匹配record + rule + field
const strictUpdated = await prisma.$executeRawUnsafe(`
WITH matched AS (
SELECT
e.id,
fs.event_id,
fs.form_name,
ROW_NUMBER() OVER (
PARTITION BY e.id
ORDER BY fs.last_qc_at DESC NULLS LAST, fs.updated_at DESC NULLS LAST
) AS rn
FROM iit_schema.equery e
JOIN iit_schema.qc_field_status fs
ON fs.project_id = e.project_id
AND fs.record_id = e.record_id
AND COALESCE(fs.rule_name, '') = COALESCE(e.category, '')
AND COALESCE(fs.field_name, '') = COALESCE(e.field_name, '')
WHERE e.project_id = $1
AND (COALESCE(e.event_id, '') = '' OR COALESCE(e.form_name, '') = '')
)
UPDATE iit_schema.equery e
SET
event_id = COALESCE(NULLIF(e.event_id, ''), matched.event_id),
form_name = COALESCE(NULLIF(e.form_name, ''), matched.form_name),
updated_at = NOW()
FROM matched
WHERE e.id = matched.id
AND matched.rn = 1
`, projectId);
// Phase 2: 容错匹配record + rule用于历史“拆字段”eQuery
const relaxedUpdated = await prisma.$executeRawUnsafe(`
WITH matched AS (
SELECT
e.id,
fs.event_id,
fs.form_name,
ROW_NUMBER() OVER (
PARTITION BY e.id
ORDER BY fs.last_qc_at DESC NULLS LAST, fs.updated_at DESC NULLS LAST
) AS rn
FROM iit_schema.equery e
JOIN iit_schema.qc_field_status fs
ON fs.project_id = e.project_id
AND fs.record_id = e.record_id
AND COALESCE(fs.rule_name, '') = COALESCE(e.category, '')
WHERE e.project_id = $1
AND (COALESCE(e.event_id, '') = '' OR COALESCE(e.form_name, '') = '')
)
UPDATE iit_schema.equery e
SET
event_id = COALESCE(NULLIF(e.event_id, ''), matched.event_id),
form_name = COALESCE(NULLIF(e.form_name, ''), matched.form_name),
updated_at = NOW()
FROM matched
WHERE e.id = matched.id
AND matched.rn = 1
`, projectId);
const afterRows = await prisma.$queryRawUnsafe<Array<{ total: bigint; missing: bigint }>>(`
SELECT
COUNT(*)::bigint AS total,
COUNT(*) FILTER (
WHERE COALESCE(event_id, '') = '' OR COALESCE(form_name, '') = ''
)::bigint AS missing
FROM iit_schema.equery
WHERE project_id = $1
`, projectId);
const before = beforeRows[0];
const after = afterRows[0];
console.log(JSON.stringify({
projectId,
total: Number(before.total),
missingBefore: Number(before.missing),
strictUpdatedRows: strictUpdated,
relaxedUpdatedRows: relaxedUpdated,
updatedRows: Number(strictUpdated) + Number(relaxedUpdated),
missingAfter: Number(after.missing),
}, null, 2));
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

View File

@@ -0,0 +1,145 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
function parseArgs() {
const args = process.argv.slice(2);
const apply = args.includes('--apply');
const projectId = args.find((a) => !a.startsWith('--'));
if (!projectId) {
throw new Error('Usage: npx tsx scripts/dedupe_open_equeries.ts <projectId> [--apply]');
}
return { projectId, apply };
}
async function main() {
const { projectId, apply } = parseArgs();
const summaryRows = await prisma.$queryRawUnsafe<Array<{
open_total: bigint;
duplicate_groups: bigint;
duplicate_rows: bigint;
}>>(
`
WITH grouped AS (
SELECT
record_id,
COALESCE(event_id, '') AS event_id_norm,
COALESCE(category, '') AS category_norm,
COUNT(*)::bigint AS cnt
FROM iit_schema.equery
WHERE project_id = $1
AND status IN ('pending', 'responded', 'reviewing', 'reopened')
GROUP BY 1,2,3
)
SELECT
(
SELECT COUNT(*)::bigint
FROM iit_schema.equery
WHERE project_id = $1
AND status IN ('pending', 'responded', 'reviewing', 'reopened')
) AS open_total,
COUNT(*) FILTER (WHERE cnt > 1)::bigint AS duplicate_groups,
COALESCE(SUM(cnt - 1) FILTER (WHERE cnt > 1), 0)::bigint AS duplicate_rows
FROM grouped
`,
projectId,
);
const sample = await prisma.$queryRawUnsafe<Array<{
record_id: string;
event_id_norm: string;
category_norm: string;
cnt: bigint;
}>>(
`
SELECT
record_id,
COALESCE(event_id, '') AS event_id_norm,
COALESCE(category, '') AS category_norm,
COUNT(*)::bigint AS cnt
FROM iit_schema.equery
WHERE project_id = $1
AND status IN ('pending', 'responded', 'reviewing', 'reopened')
GROUP BY 1,2,3
HAVING COUNT(*) > 1
ORDER BY cnt DESC, record_id ASC
LIMIT 20
`,
projectId,
);
let updatedRows = 0;
if (apply) {
const updated = await prisma.$executeRawUnsafe(
`
WITH ranked AS (
SELECT
id,
ROW_NUMBER() OVER (
PARTITION BY
project_id,
record_id,
COALESCE(event_id, ''),
COALESCE(category, '')
ORDER BY
CASE status
WHEN 'reviewing' THEN 4
WHEN 'responded' THEN 3
WHEN 'reopened' THEN 2
WHEN 'pending' THEN 1
ELSE 0
END DESC,
updated_at DESC NULLS LAST,
created_at DESC NULLS LAST,
id DESC
) AS rn
FROM iit_schema.equery
WHERE project_id = $1
AND status IN ('pending', 'responded', 'reviewing', 'reopened')
)
UPDATE iit_schema.equery e
SET
status = 'auto_closed',
closed_at = COALESCE(e.closed_at, NOW()),
closed_by = COALESCE(e.closed_by, 'system_dedupe_script'),
resolution = COALESCE(
NULLIF(e.resolution, ''),
'自动去重收敛:同一受试者/事件/规则已存在未关闭 eQuery'
),
updated_at = NOW()
FROM ranked r
WHERE e.id = r.id
AND r.rn > 1
`,
projectId,
);
updatedRows = Number(updated);
}
const result = {
projectId,
mode: apply ? 'apply' : 'dry-run',
openTotal: Number(summaryRows[0]?.open_total || 0),
duplicateGroups: Number(summaryRows[0]?.duplicate_groups || 0),
duplicateRows: Number(summaryRows[0]?.duplicate_rows || 0),
updatedRows,
sampleTopGroups: sample.map((x) => ({
recordId: x.record_id,
eventId: x.event_id_norm || '(empty)',
category: x.category_norm || '(empty)',
count: Number(x.cnt),
})),
};
console.log(JSON.stringify(result, null, 2));
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

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

View File

@@ -0,0 +1,126 @@
import json
import sys
from typing import List, Dict
import requests
BASE = "http://localhost:3001"
PROJECT_ID = "1d80f270-6a02-4b58-9db3-6af176e91f3c"
USER_ID = "diag-user-p1-regression"
def login() -> str:
resp = requests.post(
f"{BASE}/api/v1/auth/login/password",
json={"phone": "13800000001", "password": "123456"},
timeout=10,
)
resp.raise_for_status()
return resp.json()["data"]["tokens"]["accessToken"]
def ask(question: str) -> Dict:
resp = requests.post(
f"{BASE}/api/v1/iit/chat",
json={"message": question, "projectId": PROJECT_ID, "userId": USER_ID},
timeout=80,
)
resp.raise_for_status()
return resp.json()
def must_contain(text: str, keywords: List[str]) -> bool:
return all(k in text for k in keywords)
def must_not_contain(text: str, keywords: List[str]) -> bool:
return all(k not in text for k in keywords)
def main() -> int:
# Ensure backend is alive and creds are valid before running chat checks.
_ = login()
cases = [
{
"name": "知情统计",
"q": "目前已经有几个患者签署知情了?",
"must": ["结论", "证据", "签署", "12"],
},
{
"name": "纳排合规",
"q": "3号患者的纳入排除标准都符合要求吗",
"must": ["结论", "证据", "3号", "规则"],
},
{
"name": "项目总览",
"q": "最新质控报告怎么样?",
"must": ["结论", "通过率", "严重问题"],
},
{
"name": "患者明细",
"q": "查询一下患者ID为2的患者数据",
"must": ["结论", "证据", "2"],
},
{
"name": "访视进度",
"q": "4号患者到第几次访视了",
"must": ["结论", "证据", "4号", "访视"],
},
{
"name": "eQuery状态",
"q": "目前eQuery总体状态如何",
"must": ["结论", "待处理", "证据"],
},
{
"name": "通过率口径",
"q": "现在通过率是多少,怎么算出来的?",
"must": ["结论", "证据", "通过率", "计算方法"],
},
{
"name": "D6风险",
"q": "现在方案偏离风险大吗?",
"must": ["结论", "D6"],
"must_not": ["156条严重问题", "284条警告问题"],
},
{
"name": "D1维度风险",
"q": "D1数据一致性风险现在怎么样",
"must": ["结论", "D1", "证据"],
"must_not": ["D6问题总数"],
},
{
"name": "D2维度风险",
"q": "D2数据完整性现在风险大吗",
"must": ["结论", "D2", "证据"],
"must_not": ["D1问题总数", "D6问题总数"],
},
]
failed = 0
print("IIT Chat Regression Start\n")
for idx, c in enumerate(cases, 1):
try:
out = ask(c["q"])
reply = out.get("reply", "")
ok = must_contain(reply, c["must"])
if ok and c.get("must_not"):
ok = must_not_contain(reply, c["must_not"])
status = "PASS" if ok else "FAIL"
print(f"[{idx}] {status} {c['name']}")
print(f"Q: {c['q']}")
print(f"A: {reply[:220].replace(chr(10), ' | ')}")
print("")
if not ok:
failed += 1
except Exception as exc:
failed += 1
print(f"[{idx}] FAIL {c['name']}: {exc}\n")
print(f"Done. total={len(cases)} failed={failed}")
return 1 if failed else 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,114 @@
/**
* 引擎级 guard smoke test与项目配置无关
*
* 注意:
* - 本脚本只验证 HardRuleEngine 的通用 guard 机制是否生效
* - 不代表任何具体项目规则配置
* - 项目级回归请使用 regression_hardrule_guards_by_project.ts
*/
import assert from 'node:assert';
import { HardRuleEngine, type QCRule } from '../src/modules/iit-manager/engines/HardRuleEngine.js';
function runCase(name: string, rules: QCRule[], data: Record<string, any>, expectedStatus: 'PASS' | 'FAIL' | 'WARNING') {
const engine = new HardRuleEngine('regression-project');
const result = engine.executeWithRules('R1', data, rules);
assert.strictEqual(result.overallStatus, expectedStatus, `${name} 期望 ${expectedStatus},实际 ${result.overallStatus}`);
console.log(`${name}: ${result.overallStatus}`);
}
async function main() {
const rules: QCRule[] = [
{
id: 'r1',
name: 'date consistency check A',
field: ['visiting_date', 'date_of_signature'],
logic: { '<': [{ var: 'visiting_date' }, { var: 'date_of_signature' }] },
message: 'date ordering failed',
severity: 'error',
category: 'D1',
metadata: { guardType: 'date_not_before_or_equal' },
},
{
id: 'r2',
name: 'date consistency check B',
field: ['date_of_assessment_sf_mpq_scale', 'visiting_date'],
logic: { '!=': [{ var: 'date_of_assessment_sf_mpq_scale' }, { var: 'visiting_date' }] },
message: 'date mismatch',
severity: 'warning',
category: 'D3',
metadata: { guardType: 'skip_if_any_missing' },
},
{
id: 'r3',
name: 'all inclusion criteria check',
field: ['inclusion_criteria1', 'inclusion_criteria2', 'inclusion_criteria3', 'inclusion_criteria4', 'inclusion_criteria5'],
logic: { '==': [1, 0] }, // 故意构造为失败,验证 guard 能兜底
message: 'inclusion not all met',
severity: 'warning',
category: 'D1',
metadata: { guardType: 'pass_if_all_ones' },
},
{
id: 'r4',
name: 'enrollment exclusion conflict check',
field: ['enrollment_status', 'exclusion_criteria1', 'exclusion_criteria2'],
logic: { '==': [1, 0] }, // 故意构造为失败,验证 guard 能兜底
message: 'enrollment conflict',
severity: 'error',
category: 'D1',
metadata: { guardType: 'pass_if_exclusion_all_zero' },
},
];
runCase(
'同日访视不应误判早于',
[rules[0]],
{ visiting_date: '2024-03-27', date_of_signature: '2024-03-27' },
'PASS',
);
runCase(
'评估日期缺失不应判不一致',
[rules[1]],
{ date_of_assessment_sf_mpq_scale: '', visiting_date: '2024-03-27' },
'PASS',
);
runCase(
'纳入标准全1应通过',
[rules[2]],
{ inclusion_criteria1: 1, inclusion_criteria2: 1, inclusion_criteria3: 1, inclusion_criteria4: 1, inclusion_criteria5: 1 },
'PASS',
);
runCase(
'排除标准全0应通过',
[rules[3]],
{ enrollment_status: 1, exclusion_criteria1: 0, exclusion_criteria2: 0 },
'PASS',
);
runCase(
'无guard普通规则失败应保持失败',
[
{
id: 'r5',
name: 'plain range check',
field: 'age',
logic: { '>=': [{ var: 'age' }, 18] },
message: 'age too small',
severity: 'error',
category: 'D1',
},
],
{ age: 10 },
'FAIL',
);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

View File

@@ -0,0 +1,119 @@
import assert from 'node:assert';
import { PrismaClient } from '@prisma/client';
import { HardRuleEngine, type QCRule } from '../src/modules/iit-manager/engines/HardRuleEngine.js';
const prisma = new PrismaClient();
type Status = 'PASS' | 'FAIL' | 'WARNING';
function fieldsOf(rule: QCRule): string[] {
return Array.isArray(rule.field) ? rule.field : [rule.field];
}
function findRule(rules: QCRule[], patterns: RegExp[]): QCRule | null {
return rules.find((r) => patterns.some((p) => p.test(r.name || ''))) || null;
}
function findRuleByGuardType(rules: QCRule[], guardType: string): QCRule | null {
return rules.find((r) => String((r.metadata as any)?.guardType || '') === guardType) || null;
}
function runCase(name: string, rule: QCRule, data: Record<string, any>, expectedStatus: Status) {
const engine = new HardRuleEngine('regression-project');
const result = engine.executeWithRules('R1', data, [rule]);
assert.strictEqual(result.overallStatus, expectedStatus, `${name} 期望 ${expectedStatus},实际 ${result.overallStatus}`);
console.log(`${name}: ${result.overallStatus}`);
}
async function main() {
const projectId = process.argv[2];
if (!projectId) {
throw new Error('Usage: npx tsx scripts/regression_hardrule_guards_by_project.ts <projectId>');
}
const skill = await prisma.iitSkill.findFirst({
where: { projectId, skillType: 'qc_process', isActive: true },
select: { config: true },
});
const rules = (((skill?.config as any)?.rules || []) as QCRule[]);
if (rules.length === 0) {
throw new Error(`项目 ${projectId} 未找到可用 qc_process 规则`);
}
const dateRule = findRuleByGuardType(rules, 'date_not_before_or_equal');
const assessRule = findRuleByGuardType(rules, 'skip_if_any_missing');
const inclusionRule = findRuleByGuardType(rules, 'pass_if_all_ones');
const exclusionRule = findRuleByGuardType(rules, 'pass_if_exclusion_all_zero');
const skipped: string[] = [];
if (dateRule) {
const f = fieldsOf(dateRule);
if (f.length >= 2) {
runCase('同日访视不应误判早于', dateRule, { [f[0]]: '2024-03-27', [f[1]]: '2024-03-27' }, 'PASS');
} else {
skipped.push('dateRule(字段数不足2)');
}
} else {
const legacy = findRule(rules, [/访视日期.*知情同意/i, /早于知情同意/i]);
skipped.push(legacy ? 'dateRule(规则存在但未配置 guardType=date_not_before_or_equal)' : 'dateRule(未匹配)');
}
if (assessRule) {
const f = fieldsOf(assessRule);
if (f.length >= 2) {
runCase('评估日期缺失不应判不一致', assessRule, { [f[0]]: '', [f[1]]: '2024-03-27' }, 'PASS');
} else {
skipped.push('assessRule(字段数不足2)');
}
} else {
const legacy = findRule(rules, [/SF-?MPQ.*CMSS.*不一致/i, /评估日期.*访视日期.*不一致/i]);
skipped.push(legacy ? 'assessRule(规则存在但未配置 guardType=skip_if_any_missing)' : 'assessRule(未匹配)');
}
if (inclusionRule) {
const f = fieldsOf(inclusionRule);
if (f.length >= 2) {
const payload: Record<string, any> = {};
for (const field of f) payload[field] = 1;
runCase('纳入标准全1应通过', inclusionRule, payload, 'PASS');
} else {
skipped.push('inclusionRule(字段数不足2)');
}
} else {
const legacy = findRule(rules, [/所有纳入标准.*检查/i, /纳入标准.*满足/i]);
skipped.push(legacy ? 'inclusionRule(规则存在但未配置 guardType=pass_if_all_ones)' : 'inclusionRule(未匹配)');
}
if (exclusionRule) {
const f = fieldsOf(exclusionRule);
if (f.length >= 2) {
const payload: Record<string, any> = {};
payload[f[0]] = 1;
for (const field of f.slice(1)) payload[field] = 0;
runCase('排除标准全0应通过', exclusionRule, payload, 'PASS');
} else {
skipped.push('exclusionRule(字段数不足2)');
}
} else {
const legacy = findRule(rules, [/入组状态.*排除标准.*冲突/i]);
skipped.push(legacy ? 'exclusionRule(规则存在但未配置 guardType=pass_if_exclusion_all_zero)' : 'exclusionRule(未匹配)');
}
console.log(JSON.stringify({
projectId,
totalRules: rules.length,
skipped,
}, null, 2));
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

View File

@@ -0,0 +1,39 @@
import { QcExecutor } from '../src/modules/iit-manager/engines/QcExecutor.js';
import { dailyQcOrchestrator } from '../src/modules/iit-manager/services/DailyQcOrchestrator.js';
async function main() {
const projectId = process.argv[2];
const skipPush = process.argv.includes('--skip-push');
if (!projectId) {
throw new Error('Usage: npx tsx scripts/run_iit_qc_once.ts <projectId> [--skip-push]');
}
const executor = new QcExecutor(projectId);
const batch = await executor.executeBatch({
triggeredBy: 'manual',
skipSoftRules: true,
});
const orchestrate = await dailyQcOrchestrator.orchestrate(projectId, { skipPush });
console.log(JSON.stringify({
projectId,
batch: {
totalRecords: batch.totalRecords,
totalEvents: batch.totalEvents,
passed: batch.passed,
failed: batch.failed,
warnings: batch.warnings,
fieldStatusWrites: batch.fieldStatusWrites,
executionTimeMs: batch.executionTimeMs,
},
orchestrate,
skipPush,
}, null, 2));
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

View File

@@ -0,0 +1,73 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
type RuleLike = {
id: string;
name: string;
metadata?: Record<string, any>;
};
function inferGuardTypeByName(name: string): string | null {
if (/访视日期.*知情同意|早于知情同意/i.test(name)) return 'date_not_before_or_equal';
if (/SF-?MPQ.*CMSS.*不一致|评估日期.*访视日期.*不一致/i.test(name)) return 'skip_if_any_missing';
if (/所有纳入标准.*检查|纳入标准.*满足/i.test(name)) return 'pass_if_all_ones';
if (/入组状态.*排除标准.*冲突/i.test(name)) return 'pass_if_exclusion_all_zero';
return null;
}
async function main() {
const projectId = process.argv[2];
const apply = process.argv.includes('--apply');
if (!projectId) {
throw new Error('Usage: npx tsx scripts/suggest_guard_types_for_project.ts <projectId> [--apply]');
}
const skill = await prisma.iitSkill.findFirst({
where: { projectId, skillType: 'qc_process', isActive: true },
select: { id: true, config: true },
});
if (!skill) throw new Error(`项目 ${projectId} 未找到启用的 qc_process skill`);
const config = (skill.config as any) || {};
const rules: RuleLike[] = Array.isArray(config.rules) ? config.rules : [];
let candidates = 0;
const updates: Array<{ id: string; name: string; guardType: string }> = [];
for (const r of rules) {
const current = String((r.metadata as any)?.guardType || '').trim();
if (current) continue;
const inferred = inferGuardTypeByName(r.name || '');
if (!inferred) continue;
candidates++;
updates.push({ id: r.id, name: r.name, guardType: inferred });
r.metadata = { ...(r.metadata || {}), guardType: inferred };
}
if (apply && updates.length > 0) {
await prisma.iitSkill.update({
where: { id: skill.id },
data: { config: config as any, updatedAt: new Date() },
});
}
console.log(JSON.stringify({
projectId,
apply,
totalRules: rules.length,
candidates,
updated: apply ? updates.length : 0,
updates,
}, null, 2));
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

View File

@@ -0,0 +1,92 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
type RuleLike = {
id: string;
name: string;
metadata?: Record<string, unknown>;
};
function expectedGuardType(name: string): string | null {
if (/访视日期.*知情同意|早于知情同意/i.test(name)) return 'date_not_before_or_equal';
if (/SF-?MPQ.*CMSS.*不一致|评估日期.*访视日期.*不一致/i.test(name)) return 'skip_if_any_missing';
if (/所有纳入标准.*检查|纳入标准.*满足/i.test(name)) return 'pass_if_all_ones';
if (/入组状态.*排除标准.*冲突/i.test(name)) return 'pass_if_exclusion_all_zero';
return null;
}
async function main() {
const strict = process.argv.includes('--strict');
const projects = await prisma.iitProject.findMany({
where: { status: 'active', deletedAt: null },
select: { id: true, name: true },
});
const report: Array<{
projectId: string;
projectName: string;
totalRules: number;
checkedRules: number;
missingCount: number;
mismatchCount: number;
}> = [];
let totalMissing = 0;
let totalMismatch = 0;
for (const p of projects) {
const skill = await prisma.iitSkill.findFirst({
where: { projectId: p.id, skillType: 'qc_process', isActive: true },
select: { config: true },
});
const rules: RuleLike[] = Array.isArray((skill?.config as any)?.rules) ? (skill!.config as any).rules : [];
let checkedRules = 0;
let missingCount = 0;
let mismatchCount = 0;
for (const r of rules) {
const expected = expectedGuardType(r.name || '');
if (!expected) continue;
checkedRules++;
const actual = String((r.metadata as any)?.guardType || '').trim();
if (!actual) missingCount++;
else if (actual !== expected) mismatchCount++;
}
totalMissing += missingCount;
totalMismatch += mismatchCount;
report.push({
projectId: p.id,
projectName: p.name,
totalRules: rules.length,
checkedRules,
missingCount,
mismatchCount,
});
}
const summary = {
strict,
projectCount: report.length,
totalMissing,
totalMismatch,
projects: report,
};
console.log(JSON.stringify(summary, null, 2));
if (strict && (totalMissing > 0 || totalMismatch > 0)) {
process.exit(2);
}
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

View File

@@ -0,0 +1,74 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
type RuleLike = {
id: string;
name: string;
metadata?: Record<string, unknown>;
};
function expectedGuardType(name: string): string | null {
if (/访视日期.*知情同意|早于知情同意/i.test(name)) return 'date_not_before_or_equal';
if (/SF-?MPQ.*CMSS.*不一致|评估日期.*访视日期.*不一致/i.test(name)) return 'skip_if_any_missing';
if (/所有纳入标准.*检查|纳入标准.*满足/i.test(name)) return 'pass_if_all_ones';
if (/入组状态.*排除标准.*冲突/i.test(name)) return 'pass_if_exclusion_all_zero';
return null;
}
async function main() {
const projectId = process.argv[2];
const strict = process.argv.includes('--strict');
if (!projectId) {
throw new Error('Usage: npx tsx scripts/validate_guard_types_for_project.ts <projectId> [--strict]');
}
const skill = await prisma.iitSkill.findFirst({
where: { projectId, skillType: 'qc_process', isActive: true },
select: { config: true },
});
const rules: RuleLike[] = Array.isArray((skill?.config as any)?.rules) ? (skill!.config as any).rules : [];
const missing: Array<{ id: string; name: string; expectedGuardType: string }> = [];
const mismatch: Array<{ id: string; name: string; expectedGuardType: string; actualGuardType: string }> = [];
for (const r of rules) {
const expected = expectedGuardType(r.name || '');
if (!expected) continue;
const actual = String((r.metadata as any)?.guardType || '').trim();
if (!actual) {
missing.push({ id: r.id, name: r.name, expectedGuardType: expected });
continue;
}
if (actual !== expected) {
mismatch.push({ id: r.id, name: r.name, expectedGuardType: expected, actualGuardType: actual });
}
}
const result = {
projectId,
strict,
totalRules: rules.length,
checkedRules: missing.length + mismatch.length + (rules.filter((r) => expectedGuardType(r.name || '') && String((r.metadata as any)?.guardType || '').trim() === expectedGuardType(r.name || '')).length),
missingCount: missing.length,
mismatchCount: mismatch.length,
missing,
mismatch,
};
console.log(JSON.stringify(result, null, 2));
if (strict && (missing.length > 0 || mismatch.length > 0)) {
process.exit(2);
}
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});