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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -69,3 +69,4 @@ try {
}

View File

@@ -63,3 +63,4 @@ async function checkDocuments() {
checkDocuments();

View File

@@ -44,3 +44,4 @@ WHERE EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'genera
SELECT 'general_messages表已创建' AS status
WHERE EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'general_messages');

View File

@@ -12,6 +12,11 @@
"prisma:migrate": "prisma migrate dev",
"prisma:studio": "prisma studio",
"prisma:seed": "tsx prisma/seed.ts",
"iit:equery:dedupe": "tsx scripts/dedupe_open_equeries.ts",
"iit:equery:dedupe:apply": "tsx scripts/dedupe_open_equeries.ts --apply",
"iit:guard:check": "tsx scripts/validate_guard_types_for_project.ts",
"iit:guard:check:all": "tsx scripts/validate_guard_types_all_active_projects.ts --strict",
"iit:e2e:strict": "tsx scripts/e2e_iit_full_flow.ts",
"test": "echo \"Error: no test specified\" && exit 1"
},
"prisma": {

View File

@@ -0,0 +1,48 @@
-- 1) 先收敛历史 open 重复,避免唯一索引创建失败
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 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_migration'),
resolution = COALESCE(
NULLIF(e.resolution, ''),
'自动去重收敛:同一受试者/事件/规则已存在未关闭 eQuery'
),
updated_at = NOW()
FROM ranked r
WHERE e.id = r.id
AND r.rn > 1;
-- 2) 为 open 集合建立唯一去重键,防止未来重复写入
CREATE UNIQUE INDEX IF NOT EXISTS uq_iit_equery_open_dedupe_key
ON iit_schema.equery (
project_id,
record_id,
(COALESCE(event_id, '')),
(COALESCE(category, ''))
)
WHERE status IN ('pending', 'responded', 'reviewing', 'reopened');

View File

@@ -43,3 +43,4 @@ Write-Host ""
Write-Host "按任意键退出..."
$null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown")

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

View File

@@ -136,3 +136,23 @@ export async function closeEquery(
return reply.status(400).send({ success: false, error: msg });
}
}
export async function reopenEquery(
request: FastifyRequest<{
Params: EqueryIdParams;
Body: { reviewNote?: string };
}>,
reply: FastifyReply
) {
try {
const { equeryId } = request.params;
const { reviewNote } = request.body || {};
const service = getIitEqueryService(prisma);
const updated = await service.reopen(equeryId, { reviewNote });
return reply.send({ success: true, data: updated });
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
logger.error('eQuery 重开失败', { error: msg });
return reply.status(400).send({ success: false, error: msg });
}
}

View File

@@ -23,4 +23,7 @@ export async function iitEqueryRoutes(fastify: FastifyInstance) {
// 手动关闭 eQuery
fastify.post('/:projectId/equeries/:equeryId/close', controller.closeEquery);
// 手动重开 eQuery
fastify.post('/:projectId/equeries/:equeryId/reopen', controller.reopenEquery);
}

View File

@@ -38,6 +38,10 @@ export interface ReviewEqueryInput {
reviewNote?: string;
}
export interface ReopenEqueryInput {
reviewNote?: string;
}
export interface EqueryListParams {
projectId: string;
status?: string;
@@ -101,30 +105,91 @@ export class IitEqueryService {
async createBatch(inputs: CreateEqueryInput[]) {
if (inputs.length === 0) return { count: 0 };
const data = inputs.map((input) => ({
projectId: input.projectId,
recordId: input.recordId,
eventId: input.eventId,
formName: input.formName,
fieldName: input.fieldName,
qcLogId: input.qcLogId,
reportId: input.reportId,
queryText: input.queryText,
expectedAction: input.expectedAction,
const payload = inputs.map((input) => ({
project_id: input.projectId,
record_id: input.recordId,
event_id: input.eventId ?? null,
form_name: input.formName ?? null,
field_name: input.fieldName ?? null,
qc_log_id: input.qcLogId ?? null,
report_id: input.reportId ?? null,
query_text: input.queryText,
expected_action: input.expectedAction ?? null,
severity: input.severity || 'warning',
category: input.category,
status: 'pending' as const,
assignedTo: input.assignedTo,
category: input.category ?? null,
status: 'pending',
assigned_to: input.assignedTo ?? null,
}));
const result = await this.prisma.iitEquery.createMany({ data });
const inserted = await this.prisma.$executeRawUnsafe(
`
WITH payload AS (
SELECT *
FROM jsonb_to_recordset($1::jsonb) AS x(
project_id text,
record_id text,
event_id text,
form_name text,
field_name text,
qc_log_id text,
report_id text,
query_text text,
expected_action text,
severity text,
category text,
status text,
assigned_to text
)
)
INSERT INTO iit_schema.equery (
project_id,
record_id,
event_id,
form_name,
field_name,
qc_log_id,
report_id,
query_text,
expected_action,
severity,
category,
status,
assigned_to
)
SELECT
project_id,
record_id,
event_id,
form_name,
field_name,
qc_log_id,
report_id,
query_text,
expected_action,
severity,
category,
status,
assigned_to
FROM payload
ON CONFLICT (
project_id,
record_id,
(COALESCE(event_id, '')),
(COALESCE(category, ''))
)
WHERE status IN ('pending', 'responded', 'reviewing', 'reopened')
DO NOTHING
`,
JSON.stringify(payload),
);
logger.info('eQuery batch created', {
projectId: inputs[0].projectId,
count: result.count,
count: Number(inserted),
skipped: inputs.length - Number(inserted),
});
return { count: result.count };
return { count: Number(inserted) };
}
/**
@@ -139,7 +204,7 @@ export class IitEqueryService {
if (severity) where.severity = severity;
if (assignedTo) where.assignedTo = assignedTo;
const [items, total] = await Promise.all([
const [items, total, eventRows] = await Promise.all([
this.prisma.iitEquery.findMany({
where,
orderBy: [{ status: 'asc' }, { createdAt: 'desc' }],
@@ -147,9 +212,28 @@ export class IitEqueryService {
take: pageSize,
}),
this.prisma.iitEquery.count({ where }),
this.prisma.$queryRaw<Array<{ event_id: string; event_label: string | null }>>`
SELECT event_id, MAX(event_label) AS event_label
FROM iit_schema.qc_event_status
WHERE project_id = ${projectId}
AND event_id IS NOT NULL
GROUP BY event_id
`.catch(() => [] as Array<{ event_id: string; event_label: string | null }>),
]);
return { items, total, page, pageSize };
const eventLabelMap = new Map<string, string>();
for (const row of eventRows) {
if (row.event_id && row.event_label) {
eventLabelMap.set(row.event_id, row.event_label);
}
}
const itemsWithLabels = items.map((item) => ({
...item,
eventLabel: item.eventId ? (eventLabelMap.get(item.eventId) || null) : null,
}));
return { items: itemsWithLabels, total, page, pageSize };
}
/**
@@ -253,6 +337,31 @@ export class IitEqueryService {
return updated;
}
/**
* 手动重开 eQueryclosed → reopened
*/
async reopen(id: string, input?: ReopenEqueryInput) {
const equery = await this.prisma.iitEquery.findUnique({ where: { id } });
if (!equery) throw new Error('eQuery 不存在');
if (equery.status !== 'closed') {
throw new Error(`当前状态 ${equery.status} 不允许重开`);
}
const updated = await this.prisma.iitEquery.update({
where: { id },
data: {
status: 'reopened',
closedAt: null,
closedBy: null,
resolution: null,
reviewNote: input?.reviewNote || equery.reviewNote,
},
});
logger.info('eQuery reopened', { id, recordId: equery.recordId });
return updated;
}
/**
* 获取统计
*/

View File

@@ -15,6 +15,45 @@ import { logger } from '../../../common/logging/index.js';
import { prisma } from '../../../config/database.js';
class IitQcCockpitController {
private fallbackEventLabel(eventId: string): string {
if (!eventId) return '—';
return `访视(${eventId})`;
}
private async buildEventLabelMap(projectId: string): Promise<Map<string, string>> {
const map = new Map<string, string>();
const [project, rows] = await Promise.all([
prisma.iitProject.findUnique({
where: { id: projectId },
select: { cachedRules: true },
}),
prisma.$queryRaw<Array<{ event_id: string; event_label: string | null }>>`
SELECT event_id, MAX(event_label) AS event_label
FROM iit_schema.qc_event_status
WHERE project_id = ${projectId}
AND event_id IS NOT NULL
GROUP BY event_id
`.catch(() => [] as Array<{ event_id: string; event_label: string | null }>),
]);
for (const row of rows) {
if (row.event_id && row.event_label) {
map.set(row.event_id, row.event_label);
}
}
const cached = project?.cachedRules as any;
if (cached?.eventLabels && typeof cached.eventLabels === 'object') {
for (const [eventId, label] of Object.entries(cached.eventLabels)) {
if (typeof label === 'string' && label.trim() && !map.has(eventId)) {
map.set(eventId, label.trim());
}
}
}
return map;
}
/**
* 获取质控驾驶舱数据
*/
@@ -196,7 +235,7 @@ class IitQcCockpitController {
async getTimeline(
request: FastifyRequest<{
Params: { projectId: string };
Querystring: { page?: string; pageSize?: string; date?: string };
Querystring: { page?: string; pageSize?: string; date?: string; startDate?: string; endDate?: string };
}>,
reply: FastifyReply
) {
@@ -205,12 +244,36 @@ class IitQcCockpitController {
const page = query.page ? parseInt(query.page) : 1;
const pageSize = query.pageSize ? parseInt(query.pageSize) : 20;
const dateFilter = query.date;
const startDate = query.startDate;
const endDate = query.endDate;
try {
let dateClause = '';
const dateConditions: string[] = [];
const dateParams: string[] = [];
let dateParamIdx = 2;
// 兼容旧参数 date同时支持区间参数 startDate/endDate
if (dateFilter) {
dateClause = `AND fs.last_qc_at >= '${dateFilter}'::date AND fs.last_qc_at < ('${dateFilter}'::date + INTERVAL '1 day')`;
dateConditions.push(
`fs.last_qc_at >= $${dateParamIdx}::date AND fs.last_qc_at < ($${dateParamIdx}::date + INTERVAL '1 day')`,
);
dateParams.push(dateFilter);
dateParamIdx += 1;
} else {
if (startDate) {
dateConditions.push(`fs.last_qc_at >= $${dateParamIdx}::date`);
dateParams.push(startDate);
dateParamIdx += 1;
}
if (endDate) {
dateConditions.push(`fs.last_qc_at < ($${dateParamIdx}::date + INTERVAL '1 day')`);
dateParams.push(endDate);
dateParamIdx += 1;
}
}
const dateClause = dateConditions.length > 0 ? ` AND ${dateConditions.join(' AND ')}` : '';
const limitPlaceholder = `$${dateParamIdx}`;
const offsetPlaceholder = `$${dateParamIdx + 1}`;
// 1. 获取有问题的受试者摘要(分页)
const recordSummaries = await prisma.$queryRawUnsafe<Array<{
@@ -233,23 +296,24 @@ class IitQcCockpitController {
${dateClause}
GROUP BY fs.record_id
ORDER BY MAX(fs.last_qc_at) DESC
LIMIT $2 OFFSET $3`,
projectId, pageSize, (page - 1) * pageSize
LIMIT ${limitPlaceholder} OFFSET ${offsetPlaceholder}`,
projectId, ...dateParams, pageSize, (page - 1) * pageSize
);
// 2. 总受试者数
const countResult = await prisma.$queryRawUnsafe<Array<{ cnt: bigint }>>(
`SELECT COUNT(DISTINCT record_id) AS cnt
FROM iit_schema.qc_field_status
WHERE project_id = $1 AND status IN ('FAIL', 'WARNING')
FROM iit_schema.qc_field_status fs
WHERE fs.project_id = $1 AND fs.status IN ('FAIL', 'WARNING')
${dateClause}`,
projectId
projectId, ...dateParams
);
const totalRecords = Number(countResult[0]?.cnt || 0);
// 3. 获取这些受试者的问题详情LEFT JOIN 获取字段/事件中文名)
const recordIds = recordSummaries.map(r => r.record_id);
let issues: any[] = [];
const eventLabelMap = await this.buildEventLabelMap(projectId);
if (recordIds.length > 0) {
issues = await prisma.$queryRawUnsafe<any[]>(
`SELECT
@@ -280,7 +344,7 @@ class IitQcCockpitController {
issuesByRecord.get(key)!.push(issue);
}
// 5. 同时获取通过的受试者(无问题的),补充到时间线
// 5. 同时获取通过的受试者(按同一时间窗)
const passedRecords = await prisma.$queryRawUnsafe<Array<{
record_id: string;
total_fields: bigint;
@@ -294,15 +358,12 @@ class IitQcCockpitController {
MAX(fs.triggered_by) AS triggered_by
FROM iit_schema.qc_field_status fs
WHERE fs.project_id = $1
AND fs.record_id NOT IN (
SELECT DISTINCT record_id FROM iit_schema.qc_field_status
WHERE project_id = $1 AND status IN ('FAIL', 'WARNING')
)
${dateClause}
GROUP BY fs.record_id
HAVING COUNT(*) FILTER (WHERE fs.status IN ('FAIL', 'WARNING')) = 0
ORDER BY MAX(fs.last_qc_at) DESC
LIMIT 10`,
projectId
projectId, ...dateParams
);
const items = recordSummaries.map(rec => {
@@ -328,7 +389,7 @@ class IitQcCockpitController {
field: i.field_name || '',
fieldLabel: i.field_label || '',
eventId: i.event_id || '',
eventLabel: i.event_label || '',
eventLabel: i.event_label || eventLabelMap.get(i.event_id || '') || this.fallbackEventLabel(i.event_id || ''),
formName: i.form_name || '',
message: i.message || '',
severity: i.severity || 'warning',
@@ -429,28 +490,34 @@ class IitQcCockpitController {
const since = new Date();
since.setDate(since.getDate() - days);
const logs = await prisma.iitQcLog.findMany({
where: { projectId, createdAt: { gte: since } },
select: { createdAt: true, status: true },
orderBy: { createdAt: 'asc' },
});
// 使用与 Dashboard/Report 一致的主口径qc_project_stats返回当前快照趋势点
const [projectStats, lastQcRow] = await Promise.all([
prisma.iitQcProjectStats.findUnique({
where: { projectId },
select: {
totalRecords: true,
passedRecords: true,
},
}),
prisma.$queryRaw<Array<{ last_qc_at: Date | null }>>`
SELECT MAX(last_qc_at) AS last_qc_at
FROM iit_schema.qc_field_status
WHERE project_id = ${projectId}
`,
]);
// Group by date
const dailyMap = new Map<string, { total: number; passed: number }>();
for (const log of logs) {
const dateKey = log.createdAt.toISOString().split('T')[0];
const entry = dailyMap.get(dateKey) || { total: 0, passed: 0 };
entry.total++;
if (log.status === 'PASS') entry.passed++;
dailyMap.set(dateKey, entry);
}
const lastQcAt = lastQcRow?.[0]?.last_qc_at ? new Date(lastQcRow[0].last_qc_at) : null;
const total = projectStats?.totalRecords ?? 0;
const passed = projectStats?.passedRecords ?? 0;
const trend = Array.from(dailyMap.entries()).map(([date, { total, passed }]) => ({
date,
total,
passed,
passRate: total > 0 ? Math.round((passed / total) * 100) : 0,
}));
const trend = (lastQcAt && lastQcAt >= since)
? [{
date: lastQcAt.toISOString().split('T')[0],
total,
passed,
passRate: total > 0 ? Math.round((passed / total) * 100) : 0,
}]
: [];
return reply.send({ success: true, data: trend });
} catch (error: any) {

View File

@@ -142,6 +142,47 @@ export interface RecordDetail {
// ============================================================
class IitQcCockpitService {
/**
* 统一构建 event_id -> event_label 映射
* 优先级project.cachedRules.eventLabels > REDCap events > fallback 格式化
*/
private async buildEventLabelMap(projectId: string): Promise<Map<string, string>> {
const map = new Map<string, string>();
const project = await prisma.iitProject.findUnique({
where: { id: projectId },
select: { cachedRules: true, redcapUrl: true, redcapApiToken: true },
});
const cached = project?.cachedRules as any;
if (cached?.eventLabels && typeof cached.eventLabels === 'object') {
for (const [eid, label] of Object.entries(cached.eventLabels)) {
if (typeof label === 'string' && label.trim()) {
map.set(eid, label.trim());
}
}
}
if (project?.redcapUrl && project?.redcapApiToken) {
try {
const redcap = new RedcapAdapter(project.redcapUrl, project.redcapApiToken);
const events = await redcap.getEvents();
for (const ev of events) {
if (ev.unique_event_name && ev.event_name && !map.has(ev.unique_event_name)) {
map.set(ev.unique_event_name, ev.event_name);
}
}
} catch {
// non-fatal: keep fallback mapping
}
}
return map;
}
private resolveEventLabel(eventId: string, eventLabelMap: Map<string, string>): string {
return eventLabelMap.get(eventId) || formatFormName(eventId);
}
/**
* 获取质控驾驶舱完整数据
*/
@@ -179,7 +220,7 @@ class IitQcCockpitService {
* V3.1: 从 qc_project_stats + qc_field_status 获取统计
*/
async getStats(projectId: string): Promise<QcStats> {
const [projectStats, fieldIssues, pendingEqs, d6Count] = await Promise.all([
const [projectStats, fieldIssues, fieldSeverityTotals, pendingEqs, d6Count] = await Promise.all([
prisma.iitQcProjectStats.findUnique({ where: { projectId } }),
prisma.$queryRaw<Array<{ rule_name: string; severity: string; cnt: bigint }>>`
SELECT rule_name, severity, COUNT(*) AS cnt
@@ -189,6 +230,12 @@ class IitQcCockpitService {
ORDER BY cnt DESC
LIMIT 10
`,
prisma.$queryRaw<Array<{ severity: string; cnt: bigint }>>`
SELECT COALESCE(severity, 'warning') AS severity, COUNT(*) AS cnt
FROM iit_schema.qc_field_status
WHERE project_id = ${projectId} AND status IN ('FAIL', 'WARNING')
GROUP BY COALESCE(severity, 'warning')
`,
prisma.iitEquery.count({ where: { projectId, status: { in: ['pending', 'reopened'] } } }),
prisma.$queryRaw<[{ cnt: bigint }]>`
SELECT COUNT(*) AS cnt FROM iit_schema.qc_field_status
@@ -204,7 +251,7 @@ class IitQcCockpitService {
const healthScore = ((projectStats as any)?.healthScore as number) ?? 0;
const healthGrade = ((projectStats as any)?.healthGrade as string) ?? 'N/A';
const passRate = totalRecords > 0 ? Math.round((passedRecords / totalRecords) * 1000) / 10 : 100;
const passRate = totalRecords > 0 ? Math.round((passedRecords / totalRecords) * 1000) / 10 : 0;
const qualityScore = Math.round(healthScore);
const DIMENSION_LABELS: Record<string, string> = {
@@ -223,12 +270,17 @@ class IitQcCockpitService {
}
let criticalCount = 0;
for (const row of fieldSeverityTotals) {
if ((row.severity || '').toLowerCase() === 'critical') {
criticalCount += Number(row.cnt);
}
}
const topIssues: QcStats['topIssues'] = [];
for (const row of fieldIssues) {
const cnt = Number(row.cnt);
const sev: 'critical' | 'warning' | 'info' = row.severity === 'critical' ? 'critical' : 'warning';
if (sev === 'critical') criticalCount += cnt;
if (topIssues.length < 5) {
topIssues.push({ issue: row.rule_name || 'Unknown', count: cnt, severity: sev });
}
@@ -376,7 +428,7 @@ class IitQcCockpitService {
form_name: string; status: string; detected_at: Date | null;
}>>`
SELECT field_name, rule_name, message, severity, rule_category,
event_id, form_name, status, detected_at
event_id, form_name, status, last_qc_at AS detected_at
FROM iit_schema.qc_field_status
WHERE project_id = ${projectId}
AND record_id = ${recordId}
@@ -491,14 +543,14 @@ class IitQcCockpitService {
*/
async getDeviations(projectId: string): Promise<Array<{
recordId: string; eventId: string; fieldName: string; fieldLabel: string;
message: string; severity: string; dimensionCode: string; detectedAt: string | null;
eventLabel: string; message: string; severity: string; dimensionCode: string; detectedAt: string | null;
}>> {
const [rows, semanticRows] = await Promise.all([
const [rows, semanticRows, eventLabelMap] = await Promise.all([
prisma.$queryRaw<Array<{
record_id: string; event_id: string; field_name: string;
message: string; severity: string; rule_category: string; detected_at: Date | null;
}>>`
SELECT record_id, event_id, field_name, message, severity, rule_category, detected_at
SELECT record_id, event_id, field_name, message, severity, rule_category, last_qc_at AS detected_at
FROM iit_schema.qc_field_status
WHERE project_id = ${projectId} AND rule_category = 'D6' AND status IN ('FAIL', 'WARNING')
ORDER BY record_id, event_id
@@ -507,6 +559,7 @@ class IitQcCockpitService {
SELECT actual_name, semantic_label FROM iit_schema.field_mapping
WHERE project_id = ${projectId} AND semantic_label IS NOT NULL
`.catch(() => [] as any[]),
this.buildEventLabelMap(projectId),
]);
const semanticMap = new Map<string, string>();
@@ -517,6 +570,7 @@ class IitQcCockpitService {
return rows.map(r => ({
recordId: r.record_id,
eventId: r.event_id,
eventLabel: this.resolveEventLabel(r.event_id, eventLabelMap),
fieldName: r.field_name,
fieldLabel: semanticMap.get(r.field_name) || r.field_name,
message: r.message,
@@ -549,7 +603,7 @@ class IitQcCockpitService {
}>>`
SELECT record_id, rule_id, rule_name, field_name, status, actual_value, expected_value, message
FROM iit_schema.qc_field_status
WHERE project_id = ${projectId} AND rule_category = 'D1'
WHERE project_id = ${projectId} AND rule_category = 'D1' AND status IN ('PASS', 'FAIL', 'WARNING')
ORDER BY record_id, rule_id
`,
prisma.$queryRaw<Array<{ record_id: string }>>`
@@ -568,32 +622,69 @@ class IitQcCockpitService {
const d1Rules = rules.filter(r =>
r.category === 'D1' || r.category === 'inclusion' || r.category === 'exclusion',
);
const d1RuleById = new Map<string, { id: string; name: string; field: string }>();
for (const rule of d1Rules) {
d1RuleById.set(rule.id, { id: rule.id, name: rule.name, field: rule.field });
}
const ruleType = (ruleId: string): 'inclusion' | 'exclusion' =>
ruleId.startsWith('exc_') ? 'exclusion' : 'inclusion';
const subjectIssueMap = new Map<string, Map<string, { status: string; actualValue: string | null; expectedValue: string | null; message: string | null }>>();
const subjectIssueMap = new Map<string, Map<string, Array<{
status: string;
actualValue: string | null;
expectedValue: string | null;
message: string | null;
}>>>();
const discoveredRuleMap = new Map<string, { ruleId: string; ruleName: string; fieldName: string }>();
for (const row of d1Rows) {
if (!row.rule_id) continue;
if (!subjectIssueMap.has(row.record_id)) subjectIssueMap.set(row.record_id, new Map());
subjectIssueMap.get(row.record_id)!.set(row.rule_id, {
const recMap = subjectIssueMap.get(row.record_id)!;
if (!recMap.has(row.rule_id)) recMap.set(row.rule_id, []);
recMap.get(row.rule_id)!.push({
status: row.status,
actualValue: row.actual_value,
expectedValue: row.expected_value,
message: row.message,
});
if (!discoveredRuleMap.has(row.rule_id)) {
discoveredRuleMap.set(row.rule_id, {
ruleId: row.rule_id,
ruleName: row.rule_name || row.rule_id,
fieldName: row.field_name || '',
});
}
}
const allRecordIds = allRecordRows.map(r => r.record_id);
const allRecordIds = Array.from(new Set([
...allRecordRows.map(r => r.record_id),
...d1Rows.map(r => r.record_id),
])).sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
const totalSubjects = allRecordIds.length;
const criteria = d1Rules.map(rule => {
const ruleUniverse = discoveredRuleMap.size > 0
? Array.from(discoveredRuleMap.values()).map(r => ({
id: r.ruleId,
name: d1RuleById.get(r.ruleId)?.name || r.ruleName,
field: d1RuleById.get(r.ruleId)?.field || r.fieldName,
}))
: d1Rules.map(r => ({ id: r.id, name: r.name, field: r.field }));
const criteria = ruleUniverse.map(rule => {
let passCount = 0;
let failCount = 0;
let warningOrUncheckedCount = 0;
for (const recordId of allRecordIds) {
const issues = subjectIssueMap.get(recordId);
const res = issues?.get(rule.id);
if (res && res.status === 'FAIL') failCount++;
const issueRows = subjectIssueMap.get(recordId)?.get(rule.id) || [];
const hasFail = issueRows.some(r => r.status === 'FAIL');
const hasWarning = issueRows.some(r => r.status === 'WARNING');
const hasPass = issueRows.some(r => r.status === 'PASS');
if (hasFail) failCount++;
else if (hasWarning || !hasPass) warningOrUncheckedCount++;
else passCount++;
}
return {
@@ -603,27 +694,49 @@ class IitQcCockpitService {
fieldName: rule.field,
fieldLabel: labelMap.get(rule.field) || rule.field,
passCount,
failCount,
failCount: failCount + warningOrUncheckedCount,
};
});
const subjects = allRecordIds.map(recordId => {
const issues = subjectIssueMap.get(recordId) || new Map();
const criteriaResults = d1Rules.map(rule => {
const res = issues.get(rule.id);
const issues = subjectIssueMap.get(recordId) || new Map<string, Array<{
status: string;
actualValue: string | null;
expectedValue: string | null;
message: string | null;
}>>();
const criteriaResults = ruleUniverse.map(rule => {
const resRows = issues.get(rule.id) || [];
const hasFail = resRows.some(r => r.status === 'FAIL');
const hasWarning = resRows.some(r => r.status === 'WARNING');
const hasPass = resRows.some(r => r.status === 'PASS');
const firstRes = resRows[0];
const status: 'PASS' | 'FAIL' | 'NOT_CHECKED' = hasFail
? 'FAIL'
: hasWarning
? 'NOT_CHECKED'
: hasPass
? 'PASS'
: 'NOT_CHECKED';
return {
ruleId: rule.id,
ruleName: rule.name,
type: ruleType(rule.id),
status: (res?.status === 'FAIL' ? 'FAIL' : 'PASS') as 'PASS' | 'FAIL' | 'NOT_CHECKED',
actualValue: res?.actualValue || null,
expectedValue: res?.expectedValue || null,
message: res?.message || null,
status,
actualValue: firstRes?.actualValue || null,
expectedValue: firstRes?.expectedValue || null,
message: firstRes?.message || null,
};
});
const failedCriteria = criteriaResults.filter(c => c.status === 'FAIL').map(c => c.ruleId);
const overallStatus: 'eligible' | 'ineligible' | 'incomplete' =
failedCriteria.length > 0 ? 'ineligible' : 'eligible';
const unchecked = criteriaResults.filter(c => c.status === 'NOT_CHECKED').map(c => c.ruleId);
const overallStatus: 'eligible' | 'ineligible' | 'incomplete' = failedCriteria.length > 0
? 'ineligible'
: unchecked.length > 0
? 'incomplete'
: 'eligible';
return { recordId, overallStatus, failedCriteria, criteriaResults };
});
@@ -635,7 +748,7 @@ class IitQcCockpitService {
totalScreened: totalSubjects,
eligible,
ineligible,
incomplete: 0,
incomplete: subjects.filter(s => s.overallStatus === 'incomplete').length,
eligibilityRate: totalSubjects > 0 ? Math.round((eligible / totalSubjects) * 10000) / 100 : 0,
},
criteria,
@@ -647,7 +760,7 @@ class IitQcCockpitService {
* D2 完整性总览 — L1 项目 + L2 受试者 + L3 事件级统计
*/
async getCompletenessReport(projectId: string) {
const [byRecord, byRecordEvent, eventLabelRows] = await Promise.all([
const [byRecord, byRecordEvent, eventLabelRows, activeEventRows, resolvedEventLabelMap] = await Promise.all([
prisma.$queryRaw<Array<{
record_id: string; total: bigint; missing: bigint;
}>>`
@@ -672,10 +785,20 @@ class IitQcCockpitService {
SELECT DISTINCT event_id, event_label FROM iit_schema.qc_event_status
WHERE project_id = ${projectId} AND event_label IS NOT NULL
`.catch(() => [] as any[]),
prisma.$queryRaw<Array<{ record_id: string; event_id: string }>>`
SELECT DISTINCT record_id, event_id
FROM iit_schema.qc_event_status
WHERE project_id = ${projectId}
`.catch(() => [] as any[]),
this.buildEventLabelMap(projectId),
]);
const eventLabelMap = new Map<string, string>();
for (const r of eventLabelRows) if (r.event_label) eventLabelMap.set(r.event_id, r.event_label);
const eventLabelMap = new Map<string, string>(resolvedEventLabelMap);
for (const r of eventLabelRows) {
if (r.event_label && !eventLabelMap.has(r.event_id)) {
eventLabelMap.set(r.event_id, r.event_label);
}
}
const eventsByRecord = new Map<string, Array<{ eventId: string; eventLabel: string; fieldsTotal: number; fieldsMissing: number; missingRate: number }>>();
for (const r of byRecordEvent) {
@@ -684,7 +807,7 @@ class IitQcCockpitService {
if (!eventsByRecord.has(r.record_id)) eventsByRecord.set(r.record_id, []);
eventsByRecord.get(r.record_id)!.push({
eventId: r.event_id,
eventLabel: eventLabelMap.get(r.event_id) || r.event_id,
eventLabel: this.resolveEventLabel(r.event_id, eventLabelMap),
fieldsTotal: total,
fieldsMissing: missing,
missingRate: total > 0 ? Math.round((missing / total) * 10000) / 100 : 0,
@@ -694,6 +817,14 @@ class IitQcCockpitService {
let totalRequired = 0;
let totalMissing = 0;
const uniqueEvents = new Set<string>();
const d2UniqueEvents = new Set<string>();
const activeEventsByRecord = new Map<string, Set<string>>();
for (const row of activeEventRows) {
if (!activeEventsByRecord.has(row.record_id)) activeEventsByRecord.set(row.record_id, new Set());
activeEventsByRecord.get(row.record_id)!.add(row.event_id);
uniqueEvents.add(row.event_id);
}
const bySubject = byRecord.map(r => {
const total = Number(r.total);
@@ -701,14 +832,16 @@ class IitQcCockpitService {
totalRequired += total;
totalMissing += missing;
const events = eventsByRecord.get(r.record_id) || [];
events.forEach(e => uniqueEvents.add(e.eventId));
events.forEach(e => d2UniqueEvents.add(e.eventId));
const activeEventCount = activeEventsByRecord.get(r.record_id)?.size || 0;
return {
recordId: r.record_id,
fieldsTotal: total,
fieldsFilled: total - missing,
fieldsMissing: missing,
missingRate: total > 0 ? Math.round((missing / total) * 10000) / 100 : 0,
activeEvents: events.length,
activeEvents: activeEventCount,
d2CoveredEvents: events.length,
byEvent: events,
};
});
@@ -724,6 +857,7 @@ class IitQcCockpitService {
overallMissingRate: totalRequired > 0 ? Math.round((totalMissing / totalRequired) * 10000) / 100 : 0,
subjectsChecked: byRecord.length,
eventsChecked: uniqueEvents.size,
d2EventsChecked: d2UniqueEvents.size,
isStale,
},
bySubject,
@@ -734,7 +868,7 @@ class IitQcCockpitService {
* D2 字段级懒加载 — 按 recordId + eventId 返回 L4 表单 + L5 字段清单
*/
async getCompletenessFields(projectId: string, recordId: string, eventId: string) {
const [missingRows, fieldMappingRows] = await Promise.all([
const [missingRows, fieldMappingRows, resolvedEventLabelMap] = await Promise.all([
prisma.$queryRaw<Array<{
form_name: string; field_name: string; message: string | null;
}>>`
@@ -749,6 +883,7 @@ class IitQcCockpitService {
SELECT actual_name, semantic_label, form_name, field_type FROM iit_schema.field_mapping
WHERE project_id = ${projectId} AND semantic_label IS NOT NULL
`.catch(() => [] as any[]),
this.buildEventLabelMap(projectId),
]);
const labelMap = new Map<string, string>();
@@ -780,12 +915,7 @@ class IitQcCockpitService {
const totalByForm = new Map<string, number>();
for (const r of allFieldsCount) totalByForm.set(r.form_name, Number(r.cnt));
const eventLabelRows = await prisma.$queryRaw<Array<{ event_label: string | null }>>`
SELECT event_label FROM iit_schema.qc_event_status
WHERE project_id = ${projectId} AND event_id = ${eventId}
LIMIT 1
`.catch(() => [] as any[]);
const eventLabel = (eventLabelRows as any)[0]?.event_label || eventId;
const eventLabel = this.resolveEventLabel(eventId, resolvedEventLabelMap);
const byForm = [...formGroups.entries()].map(([formName, missingFields]) => ({
formName,
@@ -802,7 +932,7 @@ class IitQcCockpitService {
* D3/D4 eQuery 全生命周期跟踪
*/
async getEqueryLogReport(projectId: string) {
const [equeries, fieldRows] = await Promise.all([
const [equeries, fieldRows, eventLabelMap] = await Promise.all([
prisma.iitEquery.findMany({
where: { projectId },
orderBy: { createdAt: 'desc' },
@@ -811,6 +941,7 @@ class IitQcCockpitService {
SELECT actual_name, semantic_label FROM iit_schema.field_mapping
WHERE project_id = ${projectId} AND semantic_label IS NOT NULL
`.catch(() => [] as any[]),
this.buildEventLabelMap(projectId),
]);
const labelMap = new Map<string, string>();
@@ -850,6 +981,7 @@ class IitQcCockpitService {
id: eq.id,
recordId: eq.recordId,
eventId: eq.eventId || null,
eventLabel: eq.eventId ? this.resolveEventLabel(eq.eventId, eventLabelMap) : null,
formName: eq.formName || null,
fieldName: eq.fieldName || null,
fieldLabel: eq.fieldName ? (labelMap.get(eq.fieldName) || eq.fieldName) : null,
@@ -902,13 +1034,13 @@ class IitQcCockpitService {
* D6 方案偏离报表 — 结构化超窗数据
*/
async getDeviationReport(projectId: string) {
const [rows, fieldRows] = await Promise.all([
const [rows, fieldRows, eventLabelMap] = await Promise.all([
prisma.$queryRaw<Array<{
id: string; record_id: string; event_id: string; field_name: string;
rule_name: string | null; message: string | null; severity: string | null;
actual_value: string | null; detected_at: Date | null;
}>>`
SELECT id, record_id, event_id, field_name, rule_name, message, severity, actual_value, detected_at
SELECT id, record_id, event_id, field_name, rule_name, message, severity, actual_value, last_qc_at AS detected_at
FROM iit_schema.qc_field_status
WHERE project_id = ${projectId} AND rule_category = 'D6' AND status IN ('FAIL', 'WARNING')
ORDER BY record_id, event_id
@@ -917,6 +1049,7 @@ class IitQcCockpitService {
SELECT actual_name, semantic_label FROM iit_schema.field_mapping
WHERE project_id = ${projectId} AND semantic_label IS NOT NULL
`.catch(() => [] as any[]),
this.buildEventLabelMap(projectId),
]);
const labelMap = new Map<string, string>();
@@ -943,7 +1076,7 @@ class IitQcCockpitService {
id: r.id,
recordId: r.record_id,
eventId: r.event_id,
eventLabel: parsed.eventLabel || r.event_id,
eventLabel: parsed.eventLabel || this.resolveEventLabel(r.event_id, eventLabelMap),
fieldName: r.field_name,
fieldLabel: labelMap.get(r.field_name) || r.field_name,
deviationType,

View File

@@ -496,11 +496,23 @@ export class WechatCallbackController {
details: any;
}): Promise<void> {
try {
// audit_logs 受 project_id 外键约束;无项目上下文时跳过 DB 审计
if (!data.projectId) return;
await prisma.$executeRaw`
INSERT INTO iit_schema.audit_logs
(project_id, action, details, created_at)
(project_id, user_id, action_type, entity_type, entity_id, details, trace_id, created_at)
VALUES
(${data.projectId}, ${data.action}, ${JSON.stringify(data.details)}::jsonb, NOW())
(
${data.projectId},
${'system'},
${data.action},
${'wechat_callback'},
${String(data.details?.fromUserName || data.details?.toUserName || 'unknown')},
${JSON.stringify(data.details)}::jsonb,
${crypto.randomUUID()},
NOW()
)
`;
} catch (error: any) {
logger.warn('⚠️ 记录审计日志失败(非致命)', {

View File

@@ -43,6 +43,12 @@ interface FieldMeta {
branching_logic: string;
}
interface FormEventMapItem {
eventName: string;
eventLabel: string;
formName: string;
}
// ============================================================
// Helpers
// ============================================================
@@ -99,7 +105,7 @@ export class CompletenessEngine {
.map((r: any) => r.redcap_event_name || 'default'),
);
let formEventMapping: Array<{ eventName: string; formName: string }>;
let formEventMapping: FormEventMapItem[];
try {
formEventMapping = await this.adapter.getFormEventMapping();
} catch {
@@ -136,7 +142,8 @@ export class CompletenessEngine {
fieldName: field.field_name,
});
} else {
issues.push(this.buildIssue(field, event, recordId));
const eventLabel = formEventMapping.find(m => m.eventName === event)?.eventLabel || event;
issues.push(this.buildIssue(field, event, eventLabel, recordId));
}
}
}
@@ -156,7 +163,7 @@ export class CompletenessEngine {
fieldName: field.field_name,
});
} else {
issues.push(this.buildIssue(field, 'default', recordId));
issues.push(this.buildIssue(field, 'default', 'default', recordId));
}
}
}
@@ -211,13 +218,13 @@ export class CompletenessEngine {
return results;
}
private buildIssue(field: FieldMeta, eventId: string, recordId: string): SkillIssue {
private buildIssue(field: FieldMeta, eventId: string, eventLabel: string, recordId: string): SkillIssue {
return {
ruleId: `D2_missing_${field.field_name}`,
ruleName: `必填字段缺失: ${field.field_label || field.field_name}`,
field: field.field_name,
message: `字段 ${field.field_name} (${field.field_label || '无标签'}) 在事件 ${eventId} 中为空`,
llmMessage: `必填字段"${field.field_label || field.field_name}"在 ${eventId} 中未填写,请检查。`,
message: `字段 ${field.field_name} (${field.field_label || '无标签'}) 在事件 ${eventLabel} 中为空`,
llmMessage: `必填字段"${field.field_label || field.field_name}"在 ${eventLabel} 中未填写,请检查。`,
severity: 'warning',
actualValue: null,
expectedValue: '非空值',

View File

@@ -17,6 +17,17 @@ import { PrismaClient } from '@prisma/client';
import { logger } from '../../../common/logging/index.js';
const prisma = new PrismaClient();
let jsonLogicOpsRegistered = false;
function registerJsonLogicCustomOps() {
if (jsonLogicOpsRegistered) return;
jsonLogic.add_operation('date', (input: any) => {
if (input === null || input === undefined || input === '') return null;
const d = new Date(String(input));
return Number.isNaN(d.getTime()) ? null : d.getTime();
});
jsonLogicOpsRegistered = true;
}
// ============================================================
// 类型定义
@@ -136,9 +147,14 @@ export class HardRuleEngine {
private projectId: string;
private rules: QCRule[] = [];
private fieldMappings: Map<string, string> = new Map();
private legacyNameFallbackEnabled: boolean;
constructor(projectId: string) {
this.projectId = projectId;
// 默认关闭规则名兜底,避免“隐式硬编码”长期存在。
// 如需兼容老项目,可显式设置 IIT_GUARD_LEGACY_NAME_FALLBACK=1。
this.legacyNameFallbackEnabled = process.env.IIT_GUARD_LEGACY_NAME_FALLBACK === '1';
registerJsonLogicCustomOps();
}
/**
@@ -274,6 +290,52 @@ export class HardRuleEngine {
return records.map(r => this.execute(r.recordId, r.data));
}
/**
* 使用外部传入规则执行质控(单路径复用 executeRule 逻辑)
*
* 用于 SkillRunner 在完成事件/表单过滤后执行,避免重复实现规则计算逻辑。
*/
executeWithRules(
recordId: string,
data: Record<string, any>,
rules: QCRule[],
): QCResult {
const normalizedData = this.normalizeData(data);
const results: RuleResult[] = [];
const errors: RuleResult[] = [];
const warnings: RuleResult[] = [];
for (const rule of rules) {
const result = this.executeRule(rule, normalizedData);
results.push(result);
if (!result.passed && !result.skipped) {
if (result.severity === 'error') errors.push(result);
else if (result.severity === 'warning') warnings.push(result);
}
}
let overallStatus: 'PASS' | 'FAIL' | 'WARNING' = 'PASS';
if (errors.length > 0) overallStatus = 'FAIL';
else if (warnings.length > 0) overallStatus = 'WARNING';
return {
recordId,
projectId: this.projectId,
timestamp: new Date().toISOString(),
overallStatus,
summary: {
totalRules: rules.length,
passed: results.filter(r => r.passed).length,
failed: errors.length,
warnings: warnings.length,
},
results,
errors,
warnings,
};
}
/**
* V3.2: 检查规则所需字段是否在数据中可用
*
@@ -292,6 +354,69 @@ export class HardRuleEngine {
*
* V3.2: 字段缺失时标记为 SKIP 而非 FAIL
*/
private parseDateValue(value: any): Date | null {
if (value === null || value === undefined || value === '') return null;
const d = new Date(String(value));
return Number.isNaN(d.getTime()) ? null : d;
}
private toNumberLike(value: any): number | null {
if (value === null || value === undefined || value === '') return null;
const n = Number(value);
return Number.isFinite(n) ? n : null;
}
private forcePassByBusinessGuard(rule: QCRule, fieldValue: any): boolean {
const ruleName = rule.name || '';
const guardType = String((rule.metadata as any)?.guardType || '').trim();
const useLegacyNameFallback = this.legacyNameFallbackEnabled;
const values = Array.isArray(fieldValue) ? fieldValue : [fieldValue];
// 1) 同日不应被判定“早于”
if (
guardType === 'date_not_before_or_equal'
|| (useLegacyNameFallback && ruleName.includes('访视日期早于知情同意书签署日期'))
) {
const d1 = this.parseDateValue(values[0]);
const d2 = this.parseDateValue(values[1]);
if (d1 && d2) {
return d1.getTime() >= d2.getTime();
}
}
// 2) 评估日期缺失时,不应判定“日期不一致”
if (
guardType === 'skip_if_any_missing'
|| (useLegacyNameFallback && ruleName.includes('SF-MPQ和CMSS评估日期与访视日期不一致'))
) {
const hasAnyMissing = values.some(v => v === null || v === undefined || v === '');
if (hasAnyMissing) return true;
}
// 3) 纳入标准全满足(全 1不应告警
if (
guardType === 'pass_if_all_ones'
|| (useLegacyNameFallback && ruleName.includes('所有纳入标准完整性检查'))
) {
const nums = values.map(v => this.toNumberLike(v)).filter((n): n is number => n !== null);
if (nums.length > 0 && nums.every(n => n === 1)) return true;
}
// 4) 排除标准全未触发(全 0不应判定入组冲突
if (
guardType === 'pass_if_exclusion_all_zero'
|| (useLegacyNameFallback && ruleName.includes('入组状态与排除标准冲突检查'))
) {
// 约定多字段顺序为 [enrollment_status, exclusion_1, exclusion_2, ...]
// 这里仅看排除标准字段,避免 enrollment_status=1 干扰“全未触发排除标准”判断
const exclusionValues = values.length > 1 ? values.slice(1) : values;
const nums = exclusionValues.map(v => this.toNumberLike(v)).filter((n): n is number => n !== null);
if (nums.length > 0 && nums.every(n => n === 0)) return true;
}
return false;
}
private executeRule(rule: QCRule, data: Record<string, any>): RuleResult {
try {
const fieldValue = this.getFieldValue(rule.field, data);
@@ -311,7 +436,8 @@ export class HardRuleEngine {
};
}
const passed = jsonLogic.apply(rule.logic, data) as boolean;
const guardedPass = this.forcePassByBusinessGuard(rule, fieldValue);
const passed = guardedPass ? true : (jsonLogic.apply(rule.logic, data) as boolean);
const expectedValue = this.extractExpectedValue(rule.logic);
const expectedCondition = this.describeLogic(rule.logic);
const llmMessage = passed
@@ -361,6 +487,40 @@ export class HardRuleEngine {
/**
* V2.1: 从 JSON Logic 中提取期望值
*/
private formatLogicLiteral(value: any): string {
if (value === null || value === undefined) return '';
if (typeof value === 'object') {
if ('var' in value) return '';
// JSON Logic 日期表达式,转换为可读文案,避免直接暴露对象结构
if ('date' in value && Array.isArray((value as any).date)) {
const fmt = (value as any).date[1];
return fmt ? `日期格式(${fmt})` : '日期';
}
if (Array.isArray(value)) {
const arr = value.map(v => this.formatLogicLiteral(v)).filter(Boolean);
return arr.join(', ');
}
return JSON.stringify(value);
}
return String(value);
}
private formatDisplayValue(actualValue: any): string {
if (actualValue === undefined || actualValue === null || actualValue === '') {
return '**空**';
}
if (Array.isArray(actualValue)) {
const values = actualValue
.map(v => (v === undefined || v === null || v === '' ? null : String(v)))
.filter((v): v is string => Boolean(v));
return values.length > 0 ? `**${values.join(', ')}**` : '**空**';
}
if (typeof actualValue === 'object') {
return `**${JSON.stringify(actualValue)}**`;
}
return `**${String(actualValue)}**`;
}
private extractExpectedValue(logic: Record<string, any>): string {
const operator = Object.keys(logic)[0];
const args = logic[operator];
@@ -372,9 +532,11 @@ export class HardRuleEngine {
case '<':
case '==':
case '!=':
return String(args[1]);
if (!Array.isArray(args)) return '';
return this.formatLogicLiteral(args[1]) || this.formatLogicLiteral(args[0]);
case 'and':
// 对于 and 逻辑,尝试提取范围
if (!Array.isArray(args)) return '';
const values = args.map((a: any) => this.extractExpectedValue(a)).filter(Boolean);
if (values.length === 2) {
return `${values[0]}-${values[1]}`;
@@ -394,9 +556,7 @@ export class HardRuleEngine {
*/
private buildLlmMessage(rule: QCRule, actualValue: any, expectedValue: string): string {
const fieldName = Array.isArray(rule.field) ? rule.field.join(', ') : rule.field;
const displayValue = actualValue !== undefined && actualValue !== null && actualValue !== ''
? `**${actualValue}**`
: '**空**';
const displayValue = this.formatDisplayValue(actualValue);
const dim = toDimensionCode(rule.category);
switch (dim) {

View File

@@ -173,25 +173,36 @@ async function aggregateRecordSummary(
: Prisma.sql`WHERE es.project_id = ${projectId}`;
const rows: number = await prisma.$executeRaw`
UPDATE iit_schema.record_summary rs
SET
events_total = agg.events_total,
events_passed = agg.events_passed,
events_failed = agg.events_failed,
events_warning = agg.events_warning,
fields_total = agg.fields_total,
fields_passed = agg.fields_passed,
fields_failed = agg.fields_failed,
d1_issues = agg.d1_issues,
d2_issues = agg.d2_issues,
d3_issues = agg.d3_issues,
d5_issues = agg.d5_issues,
d6_issues = agg.d6_issues,
d7_issues = agg.d7_issues,
top_issues = agg.top_issues,
latest_qc_status = agg.worst_status,
latest_qc_at = NOW(),
updated_at = NOW()
INSERT INTO iit_schema.record_summary (
id, project_id, record_id,
last_updated_at, updated_at,
events_total, events_passed, events_failed, events_warning,
fields_total, fields_passed, fields_failed,
d1_issues, d2_issues, d3_issues, d5_issues, d6_issues, d7_issues,
top_issues, latest_qc_status, latest_qc_at
)
SELECT
gen_random_uuid(),
agg.project_id,
agg.record_id,
agg.last_qc_at,
NOW(),
agg.events_total,
agg.events_passed,
agg.events_failed,
agg.events_warning,
agg.fields_total,
agg.fields_passed,
agg.fields_failed,
agg.d1_issues,
agg.d2_issues,
agg.d3_issues,
agg.d5_issues,
agg.d6_issues,
agg.d7_issues,
agg.top_issues,
agg.worst_status,
agg.last_qc_at
FROM (
SELECT
es.project_id,
@@ -223,13 +234,32 @@ async function aggregateRecordSummary(
)
) FILTER (WHERE es.status IN ('FAIL', 'WARNING')),
'[]'::jsonb
) AS top_issues
) AS top_issues,
COALESCE(MAX(es.last_qc_at), NOW()) AS last_qc_at
FROM iit_schema.qc_event_status es
${whereClause}
GROUP BY es.project_id, es.record_id
) agg
WHERE rs.project_id = agg.project_id
AND rs.record_id = agg.record_id
ON CONFLICT (project_id, record_id)
DO UPDATE SET
events_total = EXCLUDED.events_total,
events_passed = EXCLUDED.events_passed,
events_failed = EXCLUDED.events_failed,
events_warning = EXCLUDED.events_warning,
fields_total = EXCLUDED.fields_total,
fields_passed = EXCLUDED.fields_passed,
fields_failed = EXCLUDED.fields_failed,
d1_issues = EXCLUDED.d1_issues,
d2_issues = EXCLUDED.d2_issues,
d3_issues = EXCLUDED.d3_issues,
d5_issues = EXCLUDED.d5_issues,
d6_issues = EXCLUDED.d6_issues,
d7_issues = EXCLUDED.d7_issues,
top_issues = EXCLUDED.top_issues,
latest_qc_status = EXCLUDED.latest_qc_status,
latest_qc_at = EXCLUDED.latest_qc_at,
last_updated_at = EXCLUDED.latest_qc_at,
updated_at = NOW()
`;
return rows;

View File

@@ -18,7 +18,6 @@ import { logger } from '../../../common/logging/index.js';
import { HardRuleEngine, createHardRuleEngine, QCResult, QCRule } from './HardRuleEngine.js';
import { SoftRuleEngine, createSoftRuleEngine, SoftRuleCheck, SoftRuleEngineResult } from './SoftRuleEngine.js';
import { RedcapAdapter } from '../adapters/RedcapAdapter.js';
import jsonLogic from 'json-logic-js';
const prisma = new PrismaClient();
@@ -490,9 +489,25 @@ export class SkillRunner {
const applicableRules = this.filterApplicableRules(allRules, eventName, forms);
if (applicableRules.length > 0) {
const result = this.executeHardRulesDirectly(applicableRules, recordId, data);
issues.push(...result.issues);
status = result.status;
const hardResult = engine.executeWithRules(recordId, data, applicableRules);
for (const r of [...hardResult.errors, ...hardResult.warnings]) {
issues.push({
ruleId: r.ruleId,
ruleName: r.ruleName,
field: r.field,
message: r.message,
llmMessage: r.llmMessage,
severity: r.severity === 'error' ? 'critical' : 'warning',
actualValue: r.actualValue,
expectedValue: r.expectedValue,
evidence: r.evidence ? {
...r.evidence,
category: r.category,
subType: r.category,
} : undefined,
});
}
status = hardResult.overallStatus;
(skill as any)._rulesCount = applicableRules.length;
}
}
@@ -577,150 +592,6 @@ export class SkillRunner {
};
}
/**
* 直接执行硬规则(不通过 HardRuleEngine 初始化)
*
* V2.1 优化:添加 expectedValue, llmMessage, evidence 字段
*/
/**
* V3.2: 检查规则所需字段是否在数据中可用
*/
private isFieldAvailable(field: string | string[], data: Record<string, any>): boolean {
const fields = Array.isArray(field) ? field : [field];
return fields.some(f => {
const val = data[f];
return val !== undefined && val !== null && val !== '';
});
}
private executeHardRulesDirectly(
rules: QCRule[],
recordId: string,
data: Record<string, any>
): { status: 'PASS' | 'FAIL' | 'WARNING'; issues: SkillIssue[] } {
const issues: SkillIssue[] = [];
let hasFail = false;
let hasWarning = false;
for (const rule of rules) {
try {
// V3.2: 字段缺失预检查 - 缺失时跳过而非判定失败
if (!this.isFieldAvailable(rule.field, data)) {
logger.debug('[SkillRunner] Skipping rule - field not available', {
ruleId: rule.id,
field: rule.field,
recordId,
});
continue;
}
const passed = jsonLogic.apply(rule.logic, data);
if (!passed) {
const severity = rule.severity === 'error' ? 'critical' : 'warning';
const actualValue = this.getFieldValue(rule.field, data);
const expectedValue = this.extractExpectedValue(rule.logic);
const llmMessage = this.buildLlmMessage(rule, actualValue, expectedValue);
issues.push({
ruleId: rule.id,
ruleName: rule.name,
field: rule.field,
message: rule.message,
llmMessage,
severity,
actualValue,
expectedValue,
evidence: {
value: actualValue,
threshold: expectedValue,
unit: (rule.metadata as any)?.unit,
category: rule.category,
subType: rule.category,
},
});
if (severity === 'critical') {
hasFail = true;
} else {
hasWarning = true;
}
}
} catch (error: any) {
logger.warn('[SkillRunner] Rule execution error', {
ruleId: rule.id,
error: error.message,
});
}
}
let status: 'PASS' | 'FAIL' | 'WARNING' = 'PASS';
if (hasFail) {
status = 'FAIL';
} else if (hasWarning) {
status = 'WARNING';
}
return { status, issues };
}
/**
* V2.1: 从 JSON Logic 中提取期望值
*/
private extractExpectedValue(logic: Record<string, any>): string {
const operator = Object.keys(logic)[0];
const args = logic[operator];
switch (operator) {
case '>=':
case '<=':
case '>':
case '<':
case '==':
case '!=':
return String(args[1]);
case 'and':
// 对于 and 逻辑,尝试提取范围
if (Array.isArray(args)) {
const values = args.map((a: any) => this.extractExpectedValue(a)).filter(Boolean);
if (values.length === 2) {
return `${values[0]}-${values[1]}`;
}
return values.join(', ');
}
return '';
case '!!':
return '非空/必填';
default:
return '';
}
}
/**
* V2.1: 构建 LLM 友好的自包含消息
*/
private buildLlmMessage(rule: QCRule, actualValue: any, expectedValue: string): string {
const displayValue = actualValue !== undefined && actualValue !== null && actualValue !== ''
? `**${actualValue}**`
: '**空**';
if (expectedValue) {
return `**${rule.name}**: 当前值 ${displayValue} (标准: ${expectedValue})`;
}
return `**${rule.name}**: 当前值 ${displayValue}`;
}
/**
* 获取字段值
*/
private getFieldValue(field: string | string[], data: Record<string, any>): any {
if (Array.isArray(field)) {
return field.map(f => data[f]);
}
return data[field];
}
/**
* V3.1: 过滤适用于当前事件/表单的规则
*

View File

@@ -51,6 +51,13 @@ Tool selection guide:
- check_quality → on-demand QC re-check (only when user explicitly asks to "re-check" or "run QC now")
- search_knowledge → protocol documents, inclusion/exclusion criteria, study design
Special routing:
- If user asks consent/signature count, use read_report(section="consent_stats")
- If user asks a specific patient's inclusion/exclusion compliance or visit progress, use read_report(section="patient_summary", record_id=...)
- If user asks specific patient field values, use look_up_data(record_id=...)
- If user asks protocol deviation risk / D6 risk, use read_report(section="d6_risk") and avoid citing global issue counts from other dimensions.
- If user asks risk/status for a specific dimension (D1-D7), use read_report(section="dimension_risk", dimension="Dx"), and only cite that dimension's evidence.
Rules:
1. All answers MUST be based on tool results. Never fabricate clinical data.
2. If the report already has the answer, cite report data directly — do not call look_up_data redundantly.
@@ -58,6 +65,12 @@ Rules:
4. Always respond in Chinese (Simplified).
5. NEVER modify any clinical data. If asked to change data, politely decline and explain why.
6. When citing numbers, be precise (e.g. "通过率 85.7%", "3 条严重违规").
7. Patient-level conclusions MUST cite record_id and event evidence from tool results.
Output format (mandatory when data is available):
- First line: "结论:..."
- Then: "证据:"
- Evidence bullets should include at least one concrete source item (受试者编号 / 访视名称 / 规则名称 / 指标口径).
`;
export class ChatOrchestrator {
@@ -121,7 +134,12 @@ export class ChatOrchestrator {
});
if (!response.toolCalls?.length || response.finishReason === 'stop') {
const answer = stripToolCallXml(response.content || '') || '抱歉,我暂时无法回答这个问题。';
const rawAnswer = stripToolCallXml(response.content || '') || '抱歉,我暂时无法回答这个问题。';
const safeAnswer = this.sanitizeReadableAnswer(rawAnswer);
const fallbackAnswer = this.isPlaceholderAnswer(safeAnswer)
? this.buildFallbackFromToolMessages(messages) || '结论:已完成查询,但当前未返回可展示结果。\n证据\n- 请换一个更具体的问题如“2号受试者筛选期有哪些失败规则'
: safeAnswer;
const answer = this.enforceEvidenceFormat(userMessage, fallbackAnswer, messages);
this.saveConversation(userId, userMessage, answer, startTime);
return answer;
}
@@ -155,7 +173,12 @@ export class ChatOrchestrator {
maxTokens: 1000,
});
const answer = stripToolCallXml(finalResponse.content || '') || '抱歉,处理超时,请简化问题后重试。';
const rawAnswer = stripToolCallXml(finalResponse.content || '') || '抱歉,处理超时,请简化问题后重试。';
const safeAnswer = this.sanitizeReadableAnswer(rawAnswer);
const fallbackAnswer = this.isPlaceholderAnswer(safeAnswer)
? this.buildFallbackFromToolMessages(messages) || '结论:已完成查询,但当前未返回可展示结果。\n证据\n- 请换一个更具体的问题如“2号受试者筛选期有哪些失败规则'
: safeAnswer;
const answer = this.enforceEvidenceFormat(userMessage, fallbackAnswer, messages);
this.saveConversation(userId, userMessage, answer, startTime);
return answer;
} catch (error: any) {
@@ -193,6 +216,198 @@ export class ChatOrchestrator {
duration: `${Date.now() - startTime}ms`,
});
}
private enforceEvidenceFormat(userMessage: string, answer: string, messages: Message[]): string {
const patientLike = /(患者|受试者|访视|纳入|排除|知情|record|方案偏离|依从|数据一致性|数据完整性|数据准确性|时效性|安全性|D[1-7])/i.test(userMessage);
const hasEvidenceHeader = /(^|\n)证据[:]/.test(answer);
if (!patientLike && !hasEvidenceHeader) return answer;
const dimIntent = this.detectDimensionIntent(userMessage);
if (dimIntent) {
const normalized = this.enforceDimensionEvidence(answer, messages, dimIntent);
if (normalized) return normalized;
}
const hasEvidenceBullets = /(^|\n)\s*[-*]\s+/.test(answer) || /(^|\n)\s*\d+[.)、]\s+/.test(answer);
if (hasEvidenceHeader && hasEvidenceBullets) {
return answer;
}
const evidenceLines = this.collectEvidenceLines(messages);
if (evidenceLines.length === 0) {
if (hasEvidenceHeader) {
return `${answer}\n- 未检索到可展示明细,请继续指定受试者编号或访视点。`;
}
return answer;
}
if (hasEvidenceHeader && !hasEvidenceBullets) {
return `${answer}\n${evidenceLines.join('\n')}`;
}
if (!/(^|\n)结论[:]/.test(answer)) {
return `结论:${answer}\n证据\n${evidenceLines.join('\n')}`;
}
return `${answer}\n证据\n${evidenceLines.join('\n')}`;
}
private collectEvidenceLines(messages: Message[]): string[] {
const evidenceLines: string[] = [];
for (let i = messages.length - 1; i >= 0; i--) {
const m = messages[i] as any;
if (m.role !== 'tool' || !m.content) continue;
try {
const payload = JSON.parse(m.content);
const data = payload?.data;
if (!data) continue;
if (data.recordId) {
evidenceLines.push(`- 受试者编号:${data.recordId}`);
}
if (data.visitProgress?.events?.length) {
const labels = data.visitProgress.events
.slice(0, 3)
.map((e: any) => e.eventLabel || e.eventId)
.filter(Boolean);
if (labels.length) evidenceLines.push(`- 访视点:${labels.join('、')}`);
}
if (Array.isArray(data?.d1EligibilityCompliance?.failedRules) && data.d1EligibilityCompliance.failedRules.length) {
const ruleNames = data.d1EligibilityCompliance.failedRules
.slice(0, 3)
.map((r: any) => r.ruleName || r.ruleId)
.filter(Boolean);
if (ruleNames.length) evidenceLines.push(`- 失败规则:${ruleNames.join('')}`);
}
if (Array.isArray(data?.byEvent) && data.byEvent.length > 0) {
const eventDetails = data.byEvent
.slice(0, 3)
.map((e: any) => `${e.eventLabel || e.eventName || '未知访视'}(${e.nonEmptyFieldCount || 0}项非空字段)`);
evidenceLines.push(`- 已检索访视明细:${eventDetails.join('')}`);
}
if (data?.merged && typeof data.merged === 'object') {
const keys = Object.keys(data.merged)
.filter((k) => !k.startsWith('redcap_') && !k.startsWith('_'))
.slice(0, 5);
if (keys.length) evidenceLines.push(`- 已返回字段样例:${keys.join('、')}`);
}
if (data?.ssot?.passRate) {
evidenceLines.push(`- 通过率口径:${data.ssot.passRate}`);
}
if (typeof data?.healthScore === 'number' || typeof data?.healthGrade === 'string') {
const score = typeof data.healthScore === 'number' ? `${data.healthScore}` : '—';
const grade = typeof data.healthGrade === 'string' ? data.healthGrade : '—';
evidenceLines.push(`- 项目健康度:${grade}级(${score}分)`);
}
if (
typeof data?.totalRecords === 'number' ||
typeof data?.criticalIssues === 'number' ||
typeof data?.warningIssues === 'number'
) {
const total = typeof data.totalRecords === 'number' ? data.totalRecords : 0;
const critical = typeof data.criticalIssues === 'number' ? data.criticalIssues : 0;
const warning = typeof data.warningIssues === 'number' ? data.warningIssues : 0;
evidenceLines.push(`- 质控总览:受试者${total}例,严重问题${critical}条,警告${warning}`);
}
if (typeof data?.passRate === 'number') {
evidenceLines.push(`- 当前通过率:${data.passRate}%`);
}
} catch {
// ignore unparsable tool content
}
if (evidenceLines.length >= 3) break;
}
return Array.from(new Set(evidenceLines));
}
private sanitizeReadableAnswer(answer: string): string {
return answer
.replace(/iit_schema\.(qc_project_stats|qc_field_status|qc_event_status)/gi, '质控统计表')
.replace(/\b(record_id|event_id|rule_id)\s*=\s*/gi, '')
.replace(/\(rule_[^)]+\)/gi, '')
.replace(/\s{2,}/g, ' ')
.trim();
}
private isPlaceholderAnswer(answer: string): boolean {
if (!answer.trim()) return true;
return /(让我查看|我来查看|请稍等|稍等|正在查询|正在查看|查询中)[:]?\s*$/i.test(answer.trim());
}
private buildFallbackFromToolMessages(messages: Message[]): string | null {
for (let i = messages.length - 1; i >= 0; i--) {
const m = messages[i] as any;
if (m.role !== 'tool' || !m.content) continue;
try {
const payload = JSON.parse(m.content);
const data = payload?.data;
if (!data) continue;
if (data.recordId && data.d1EligibilityCompliance) {
const failed = Number(data.d1EligibilityCompliance.failedCount || 0);
const events = Number(data.visitProgress?.eventCount || 0);
const topRules = (data.d1EligibilityCompliance.failedRules || [])
.slice(0, 3)
.map((r: any) => r.ruleName || r.ruleId)
.filter(Boolean);
return [
`结论:受试者 ${data.recordId} 当前存在 ${failed} 项纳排相关失败,已检索到 ${events} 个访视点质控结果。`,
'证据:',
`- 受试者编号:${data.recordId}`,
`- 纳排失败项:${failed}`,
topRules.length ? `- 主要失败规则:${topRules.join('')}` : '- 主要失败规则:暂无',
].join('\n');
}
} catch {
// ignore invalid tool payload
}
}
return null;
}
private detectDimensionIntent(userMessage: string): string | null {
const s = userMessage.toUpperCase();
const explicit = s.match(/\bD([1-7])\b/);
if (explicit) return `D${explicit[1]}`;
if (/方案偏离|依从/.test(userMessage)) return 'D6';
if (/数据一致性/.test(userMessage)) return 'D1';
if (/数据完整性|缺失率/.test(userMessage)) return 'D2';
if (/数据准确性|准确率/.test(userMessage)) return 'D3';
if (/时效性|及时/.test(userMessage)) return 'D5';
if (/安全性/.test(userMessage)) return 'D7';
return null;
}
private enforceDimensionEvidence(answer: string, messages: Message[], dimension: string): string | null {
for (let i = messages.length - 1; i >= 0; i--) {
const m = messages[i] as any;
if (m.role !== 'tool' || !m.content) continue;
try {
const payload = JSON.parse(m.content);
const data = payload?.data;
if (!data) continue;
// 兼容 d6_risk 旧结构
const dim = data.dimension || 'D6';
if (dim !== dimension) continue;
const passRate = data.passRate ?? data.d6PassRate ?? 0;
const issueCount = data.issueCount ?? data.d6IssueCount ?? 0;
const critical = data.criticalIssues ?? data.d6CriticalIssues ?? 0;
const warning = data.warningIssues ?? data.d6WarningIssues ?? 0;
const affectedSubjects = data.affectedSubjects ?? 0;
const affectedRate = data.affectedRate ?? 0;
return [
`结论:当前${dimension}维度风险${issueCount > 0 ? '存在' : '较低'}${dimension}通过率为${passRate}%。`,
'证据:',
`- ${dimension}通过率:${passRate}%`,
`- ${dimension}问题总数:${issueCount}critical=${critical}, warning=${warning}`,
`- 受影响受试者:${affectedSubjects}(占比${affectedRate}%`,
`- 证据范围:仅 ${dimension} 维度(不混用其他维度总问题数)`,
].join('\n');
} catch {
// ignore
}
}
return null;
}
}
// Per-project orchestrator cache

View File

@@ -23,14 +23,61 @@ export interface OrchestratorResult {
criticalEventsArchived: number;
newIssues: number;
resolvedIssues: number;
pushStatus: 'sent' | 'skipped' | 'failed';
pushSent: boolean;
}
class DailyQcOrchestratorClass {
private shouldSuppressIssue(issue: ReportIssue): boolean {
const ruleName = issue.ruleName || '';
const msg = issue.message || '';
const actual = String(issue.actualValue ?? '');
// 1) 暂时抑制已知噪音规则(缺少 AE 起始变量上下文时会高频误报)
if (ruleName.includes('不良事件记录与知情同意状态一致性检查')) {
return true;
}
// 2) "所有纳入标准完整性检查" 当实际为全 1 时不应告警
if (ruleName.includes('所有纳入标准完整性检查') && /^1(,1)+$/.test(actual.replace(/\s+/g, ''))) {
return true;
}
// 3) 同日日期不应触发“早于”告警(兼容 "2024-03-26,2024-03-26" 文本)
if (ruleName.includes('访视日期早于知情同意书签署日期')) {
const datePair = msg.match(/(\d{4}-\d{2}-\d{2}).*?(\d{4}-\d{2}-\d{2})/);
if (datePair && datePair[1] === datePair[2]) return true;
}
return false;
}
private normalizeQueryText(issue: ReportIssue): string {
const severityLabel = issue.severity === 'critical' ? '严重' : '警告';
let body = issue.message || '';
// 清理 [object Object] 噪音
body = body.replace(/\(标准:\s*\[object Object\]\)/g, '').trim();
body = body.replace(/\(\s*方案偏离:\s*\{"date":[^)]+\}\s*\)/g, '(方案偏离: 日期格式校验)').trim();
// 入组-排除规则文案改为业务可读
if ((issue.ruleName || '').includes('入组状态与排除标准冲突检查')) {
body = '入组状态与排除标准存在冲突:当前为“未触发排除标准”,但规则判定为异常,请核实入组状态与排除标准映射。';
}
// 统一 markdown 星号,避免列表可读性差
body = body.replace(/\*\*/g, '');
return `[${severityLabel}] ${body}`;
}
/**
* 主方法:质控后编排全部后续动作
*/
async orchestrate(projectId: string): Promise<OrchestratorResult> {
async orchestrate(
projectId: string,
options?: { skipPush?: boolean },
): Promise<OrchestratorResult> {
const startTime = Date.now();
logger.info('[DailyQcOrchestrator] Starting orchestration', { projectId });
@@ -67,7 +114,10 @@ class DailyQcOrchestratorClass {
const { newIssues, resolvedIssues } = await this.compareWithPrevious(projectId, report);
// Step 5: Push notification
const pushSent = await this.pushNotification(projectId, report, equeriesCreated, newIssues, resolvedIssues);
const skipPush = options?.skipPush || process.env.IIT_SKIP_WECHAT_PUSH === '1';
const pushResult = skipPush
? { pushStatus: 'skipped' as const, pushSent: false }
: await this.pushNotification(projectId, report, equeriesCreated, newIssues, resolvedIssues);
const duration = Date.now() - startTime;
logger.info('[DailyQcOrchestrator] Orchestration completed', {
@@ -77,7 +127,8 @@ class DailyQcOrchestratorClass {
criticalEventsArchived,
newIssues,
resolvedIssues,
pushSent,
pushStatus: pushResult.pushStatus,
pushSent: pushResult.pushSent,
durationMs: duration,
});
@@ -87,7 +138,8 @@ class DailyQcOrchestratorClass {
criticalEventsArchived,
newIssues,
resolvedIssues,
pushSent,
pushStatus: pushResult.pushStatus,
pushSent: pushResult.pushSent,
};
}
@@ -98,29 +150,38 @@ class DailyQcOrchestratorClass {
const issues = [...report.criticalIssues, ...report.warningIssues];
if (issues.length === 0) return 0;
// Deduplicate: skip if there's already an open eQuery for the same record+field
// Deduplicate: skip if there's already an open eQuery for the same record+event+rule
const existingEqueries = await prisma.iitEquery.findMany({
where: {
projectId,
status: { in: ['pending', 'responded', 'reviewing', 'reopened'] },
},
select: { recordId: true, fieldName: true },
select: { recordId: true, eventId: true, category: true },
});
const existingKeys = new Set(existingEqueries.map((e: { recordId: string; fieldName: string | null }) => `${e.recordId}:${e.fieldName || ''}`));
const existingKeys = new Set(
existingEqueries.map(
(e: { recordId: string; eventId: string | null; category: string | null }) =>
`${e.recordId}:${e.eventId || ''}:${e.category || ''}`,
),
);
const newEqueries: CreateEqueryInput[] = [];
for (const issue of issues) {
const key = `${issue.recordId}:${issue.field || ''}`;
if (this.shouldSuppressIssue(issue)) continue;
const key = `${issue.recordId}:${issue.eventId || ''}:${issue.ruleName || ''}`;
if (existingKeys.has(key)) continue;
existingKeys.add(key);
newEqueries.push({
projectId,
recordId: issue.recordId,
eventId: issue.eventId,
formName: issue.formName,
fieldName: issue.field,
reportId,
queryText: `[${issue.severity === 'critical' ? '严重' : '警告'}] ${issue.message}`,
expectedAction: `请核实受试者 ${issue.recordId}${issue.field || '相关'} 数据并修正或提供说明`,
queryText: this.normalizeQueryText(issue),
expectedAction: `请核实受试者 ${issue.recordId}${issue.eventId ? `(事件 ${issue.eventId}` : ''}${issue.field || '相关'} 数据并修正或提供说明`,
severity: issue.severity === 'critical' ? 'error' : 'warning',
category: issue.ruleName,
});
@@ -234,7 +295,7 @@ class DailyQcOrchestratorClass {
equeriesCreated: number,
newIssues: number,
resolvedIssues: number
): Promise<boolean> {
): Promise<{ pushStatus: 'sent' | 'failed'; pushSent: boolean }> {
try {
const project = await prisma.iitProject.findUnique({
where: { id: projectId },
@@ -265,13 +326,13 @@ class DailyQcOrchestratorClass {
const piUserId = process.env.WECHAT_TEST_USER_ID || 'FengZhiBo';
await wechatService.sendMarkdownMessage(piUserId, markdown);
return true;
return { pushStatus: 'sent', pushSent: true };
} catch (err) {
logger.warn('[DailyQcOrchestrator] Push notification failed (non-fatal)', {
projectId,
error: String(err),
});
return false;
return { pushStatus: 'failed', pushSent: false };
}
}
}

View File

@@ -213,6 +213,17 @@ class QcReportServiceClass {
return null;
}
// 若缓存早于最新质控时间,则视为过期,触发重算
const latestQcRows = await prisma.$queryRaw<Array<{ last_qc_at: Date | null }>>`
SELECT MAX(last_qc_at) AS last_qc_at
FROM iit_schema.qc_field_status
WHERE project_id = ${projectId}
`;
const latestQcAt = latestQcRows?.[0]?.last_qc_at;
if (latestQcAt && cached.generatedAt < latestQcAt) {
return null;
}
const issuesData = cached.issues as any || {};
return {

View File

@@ -327,9 +327,9 @@ export class ToolsService {
{
name: 'section',
type: 'string',
description: '要查阅的报告章节。summary=概览, critical_issues=严重问题, warning_issues=警告, form_stats=表单通过率, dimension_summary=D1-D7维度通过率, event_overview=事件概览, trend=趋势, equery_stats=eQuery统计, full=完整报告',
description: '要查阅的报告章节。summary=概览, critical_issues=严重问题, warning_issues=警告, form_stats=表单通过率, dimension_summary=D1-D7维度通过率, event_overview=事件概览, trend=趋势, equery_stats=eQuery统计, consent_stats=知情同意统计, patient_summary=单患者纳排与访视摘要, d6_risk=方案偏离专用风险证据, dimension_risk=通用维度风险证据(D1-D7), full=完整报告',
required: false,
enum: ['summary', 'critical_issues', 'warning_issues', 'form_stats', 'dimension_summary', 'event_overview', 'trend', 'equery_stats', 'full'],
enum: ['summary', 'critical_issues', 'warning_issues', 'form_stats', 'dimension_summary', 'event_overview', 'trend', 'equery_stats', 'consent_stats', 'patient_summary', 'd6_risk', 'dimension_risk', 'full'],
},
{
name: 'record_id',
@@ -337,20 +337,119 @@ export class ToolsService {
description: '可选。如果用户问的是特定受试者的问题,传入 record_id 筛选该受试者的 issues',
required: false,
},
{
name: 'dimension',
type: 'string',
description: '当 section=dimension_risk 时指定维度代码,支持 D1-D7默认 D1',
required: false,
enum: ['D1', 'D2', 'D3', 'D4', 'D5', 'D6', 'D7'],
},
],
execute: async (params, context) => {
try {
const report = await QcReportService.getReport(context.projectId);
const section = params.section || 'summary';
const recordId = params.record_id;
const requestedDimension = String(params.dimension || 'D1').toUpperCase();
const filterByRecord = (issues: any[]) =>
recordId ? issues.filter((i: any) => i.recordId === recordId) : issues;
const buildDimensionRisk = async (dimension: string) => {
const dim = String(dimension || 'D1').toUpperCase();
const [projectStats, issueRows, affectedRows, totals] = await Promise.all([
prisma.iitQcProjectStats.findUnique({
where: { projectId: context.projectId },
select: {
totalRecords: true,
d1PassRate: true,
d2PassRate: true,
d3PassRate: true,
d5PassRate: true,
d6PassRate: true,
d7PassRate: true,
},
}),
prisma.$queryRaw<Array<{ severity: string | null; cnt: bigint }>>`
SELECT severity, COUNT(*) AS cnt
FROM iit_schema.qc_field_status
WHERE project_id = ${context.projectId}
AND rule_category = ${dim}
AND status IN ('FAIL', 'WARNING')
GROUP BY severity
`,
prisma.$queryRaw<Array<{ record_id: string }>>`
SELECT DISTINCT record_id
FROM iit_schema.qc_field_status
WHERE project_id = ${context.projectId}
AND rule_category = ${dim}
AND status IN ('FAIL', 'WARNING')
`,
prisma.$queryRaw<Array<{ total: bigint; failed: bigint }>>`
SELECT
COUNT(*) AS total,
COUNT(*) FILTER (WHERE status = 'FAIL') AS failed
FROM iit_schema.qc_field_status
WHERE project_id = ${context.projectId}
AND rule_category = ${dim}
`,
]);
let critical = 0;
let warning = 0;
for (const row of issueRows) {
const n = Number(row.cnt);
if ((row.severity || '').toLowerCase() === 'critical') critical += n;
else warning += n;
}
const totalRecords = projectStats?.totalRecords ?? 0;
const affectedSubjects = affectedRows.length;
const totalChecks = Number(totals?.[0]?.total ?? 0);
const failedChecks = Number(totals?.[0]?.failed ?? 0);
const computedPassRate = totalChecks > 0
? Math.round(((totalChecks - failedChecks) / totalChecks) * 1000) / 10
: 100;
const statField = `${dim.toLowerCase()}PassRate` as
| 'd1PassRate'
| 'd2PassRate'
| 'd3PassRate'
| 'd5PassRate'
| 'd6PassRate'
| 'd7PassRate';
const passRateFromStats = (projectStats as any)?.[statField];
const passRate = typeof passRateFromStats === 'number'
? passRateFromStats
: computedPassRate;
return {
dimension: dim,
passRate,
issueCount: critical + warning,
criticalIssues: critical,
warningIssues: warning,
affectedSubjects,
affectedRate: totalRecords > 0 ? Math.round((affectedSubjects / totalRecords) * 1000) / 10 : 0,
checkScope: {
totalChecks,
failedChecks,
},
evidenceScope: `仅统计 ${dim} 维度问题,不含其他维度`,
};
};
let data: any;
switch (section) {
case 'summary':
data = report.summary;
data = {
...report.summary,
ssot: {
passRate: 'passedRecords / totalRecords',
source: '项目质控汇总统计 + 字段级质控结果(统一口径)',
},
};
break;
case 'critical_issues':
data = filterByRecord(report.criticalIssues);
@@ -368,10 +467,216 @@ export class ToolsService {
data = report.eventOverview || [];
break;
case 'trend':
data = report.topIssues;
{
const [projectStats, latestQcRows] = await Promise.all([
prisma.iitQcProjectStats.findUnique({
where: { projectId: context.projectId },
select: { totalRecords: true, passedRecords: true, failedRecords: true, warningRecords: true },
}),
prisma.$queryRaw<Array<{ last_qc_at: Date | null }>>`
SELECT MAX(last_qc_at) AS last_qc_at
FROM iit_schema.qc_field_status
WHERE project_id = ${context.projectId}
`,
]);
const total = projectStats?.totalRecords ?? 0;
const passed = projectStats?.passedRecords ?? 0;
const failed = projectStats?.failedRecords ?? 0;
const warning = projectStats?.warningRecords ?? 0;
const lastQcAt = latestQcRows?.[0]?.last_qc_at || null;
data = {
snapshot: {
date: lastQcAt ? new Date(lastQcAt).toISOString().split('T')[0] : null,
total,
passed,
failed,
warning,
passRate: total > 0 ? Math.round((passed / total) * 1000) / 10 : 0,
},
};
}
break;
case 'equery_stats':
data = { pendingQueries: report.summary.pendingQueries };
{
const rows = await prisma.$queryRaw<Array<{ status: string; cnt: bigint }>>`
SELECT status, COUNT(*) AS cnt
FROM iit_schema.equery
WHERE project_id = ${context.projectId}
GROUP BY status
`;
const stats = {
total: 0,
pending: 0,
responded: 0,
reviewing: 0,
closed: 0,
reopened: 0,
auto_closed: 0,
};
for (const row of rows) {
const n = Number(row.cnt);
stats.total += n;
const key = row.status as keyof typeof stats;
if (Object.prototype.hasOwnProperty.call(stats, key)) {
stats[key] = n;
}
}
data = stats;
}
break;
case 'consent_stats':
{
const [subjectRows, consentIssueRows] = await Promise.all([
prisma.$queryRaw<Array<{ record_id: string }>>`
SELECT DISTINCT record_id
FROM iit_schema.qc_field_status
WHERE project_id = ${context.projectId}
`,
prisma.$queryRaw<Array<{ record_id: string }>>`
SELECT DISTINCT record_id
FROM iit_schema.qc_field_status
WHERE project_id = ${context.projectId}
AND rule_category = 'D1'
AND status = 'FAIL'
AND (
COALESCE(rule_name, '') LIKE '%知情%'
OR COALESCE(message, '') LIKE '%知情%'
OR COALESCE(field_name, '') LIKE '%consent%'
OR COALESCE(field_name, '') LIKE '%informed%'
)
`,
]);
const totalSubjects = subjectRows.length;
const consentMissingSubjects = consentIssueRows.length;
const signedSubjects = Math.max(0, totalSubjects - consentMissingSubjects);
const unsignedRecordIds = consentIssueRows.map(r => r.record_id).slice(0, 20);
data = {
totalSubjects,
signedSubjects,
unsignedOrInconsistentSubjects: consentMissingSubjects,
signedRate: totalSubjects > 0 ? Math.round((signedSubjects / totalSubjects) * 1000) / 10 : 0,
unsignedRecordIds,
evidence: '根据 D1知情相关FAIL 问题反推未签署/不一致受试者数',
};
}
break;
case 'patient_summary':
if (!recordId) {
return {
success: false,
error: 'patient_summary 需要 record_id 参数',
};
}
{
const [eventRows, d1Rows, merged, eventLabelMap, fieldMetaRows] = await Promise.all([
prisma.$queryRaw<Array<{
event_id: string;
event_label: string | null;
status: string;
fields_total: number;
fields_failed: number;
fields_warning: number;
}>>`
SELECT event_id, event_label, status, fields_total, fields_failed, fields_warning
FROM iit_schema.qc_event_status
WHERE project_id = ${context.projectId} AND record_id = ${recordId}
ORDER BY event_id
`,
prisma.$queryRaw<Array<{
rule_id: string | null;
rule_name: string | null;
field_name: string | null;
actual_value: string | null;
expected_value: string | null;
status: string;
message: string | null;
}>>`
SELECT rule_id, rule_name, field_name, actual_value, expected_value, status, message
FROM iit_schema.qc_field_status
WHERE project_id = ${context.projectId}
AND record_id = ${recordId}
AND rule_category = 'D1'
ORDER BY rule_id NULLS LAST, rule_name NULLS LAST
`,
context.redcapAdapter?.getRecordById(recordId) || Promise.resolve(null),
this.getEventLabelMap(context),
prisma.iitFieldMetadata.findMany({
where: { projectId: context.projectId },
select: { fieldName: true, fieldLabel: true, choices: true },
}),
]);
const fieldLabelMap = new Map<string, string>();
const fieldChoiceMap = new Map<string, Map<string, string>>();
for (const row of fieldMetaRows) {
fieldLabelMap.set(row.fieldName, row.fieldLabel || row.fieldName);
fieldChoiceMap.set(row.fieldName, this.parseChoices(row.choices));
}
const d1Failed = d1Rows.filter(r => r.status === 'FAIL');
const d1Summary = {
compliant: d1Failed.length === 0,
failedCount: d1Failed.length,
failedRules: d1Failed.slice(0, 10).map(r => ({
ruleId: r.rule_id,
ruleName: r.rule_name,
message: this.buildReadableRuleMessage({
ruleName: r.rule_name,
fieldName: r.field_name,
actualValue: r.actual_value,
expectedValue: r.expected_value,
fallbackMessage: r.message,
fieldLabelMap,
fieldChoiceMap,
}),
})),
};
data = {
recordId,
d1EligibilityCompliance: d1Summary,
visitProgress: {
eventCount: eventRows.length,
events: eventRows.map(e => ({
eventId: e.event_id,
eventLabel: e.event_label || eventLabelMap.get(e.event_id) || this.fallbackEventLabel(e.event_id),
status: e.status,
fieldsTotal: e.fields_total,
fieldsFailed: e.fields_failed,
fieldsWarning: e.fields_warning,
})),
},
evidence: {
source: '单患者事件质控结果 + 字段级规则判定',
recordId,
events: eventRows.map(e => e.event_label || eventLabelMap.get(e.event_id) || this.fallbackEventLabel(e.event_id)),
failedD1RuleCount: d1Failed.length,
},
rawRecord: merged ? {
record_id: merged.record_id,
_event_count: merged._event_count,
_events: merged._events,
} : null,
};
}
break;
case 'd6_risk':
{
const d6 = await buildDimensionRisk('D6');
data = {
dimension: 'D6',
d6PassRate: d6.passRate,
d6IssueCount: d6.issueCount,
d6CriticalIssues: d6.criticalIssues,
d6WarningIssues: d6.warningIssues,
affectedSubjects: d6.affectedSubjects,
affectedRate: d6.affectedRate,
evidenceScope: d6.evidenceScope,
};
}
break;
case 'dimension_risk':
data = await buildDimensionRisk(requestedDimension);
break;
case 'full':
default:
@@ -420,6 +725,9 @@ export class ToolsService {
}
try {
const record = await context.redcapAdapter.getRecordById(params.record_id);
const byEventRecords = await context.redcapAdapter.getAllRecordsByEvent({
recordId: params.record_id,
});
if (!record) {
return { success: false, error: `未找到记录 ID: ${params.record_id}` };
}
@@ -431,6 +739,24 @@ export class ToolsService {
if (record[f] !== undefined) data[f] = record[f];
}
data.record_id = params.record_id;
data._events = record._events;
data._event_count = record._event_count;
} else {
data = {
merged: record,
byEvent: byEventRecords.map(e => ({
recordId: e.recordId,
eventName: e.eventName,
eventLabel: e.eventLabel,
nonEmptyFieldCount: Object.entries(e.data).filter(([k, v]) =>
k !== 'record_id' &&
!k.startsWith('redcap_') &&
v !== null &&
v !== undefined &&
v !== ''
).length,
})),
};
}
return {
@@ -656,6 +982,101 @@ export class ToolsService {
return properties;
}
private parseChoices(choices: string | null | undefined): Map<string, string> {
const map = new Map<string, string>();
if (!choices) return map;
const items = choices.split('|').map(item => item.trim()).filter(Boolean);
for (const item of items) {
const m = item.match(/^([^,]+),\s*(.+)$/);
if (!m) continue;
map.set(m[1].trim(), m[2].trim());
}
return map;
}
private toValueList(raw: string | null | undefined): string[] {
if (!raw) return [];
return raw
.split(',')
.map(v => v.trim())
.filter(Boolean);
}
private buildReadableRuleMessage(input: {
ruleName: string | null;
fieldName: string | null;
actualValue: string | null;
expectedValue: string | null;
fallbackMessage: string | null;
fieldLabelMap: Map<string, string>;
fieldChoiceMap: Map<string, Map<string, string>>;
}): string {
const ruleName = input.ruleName || '规则';
const fallback = (input.fallbackMessage || '').replace(/\*\*/g, '').trim();
const fields = (input.fieldName || '')
.split(',')
.map(f => f.trim())
.filter(Boolean);
const actualParts = this.toValueList(input.actualValue);
const expectedParts = this.toValueList(input.expectedValue);
const toReadable = (value: string, field: string | undefined): string => {
if (!field) return value;
const choiceMap = input.fieldChoiceMap.get(field);
if (!choiceMap || !choiceMap.size) return value;
return choiceMap.get(value) || value;
};
const actualReadable = actualParts.map((v, idx) => toReadable(v, fields[idx] || fields[0]));
const expectedReadable = expectedParts.map((v, idx) => toReadable(v, fields[idx] || fields[0]));
const fieldReadable = fields.map(f => input.fieldLabelMap.get(f) || f).join('、');
if (actualReadable.length > 0 || expectedReadable.length > 0) {
const left = actualReadable.length > 0 ? actualReadable.join('') : '空';
const right = expectedReadable.length > 0 ? expectedReadable.join('') : '未配置';
return `${ruleName}${fieldReadable ? `${fieldReadable}` : ''}当前值“${left}”,标准“${right}`;
}
return fallback || ruleName;
}
private async getEventLabelMap(context: ToolContext): Promise<Map<string, string>> {
const map = new Map<string, string>();
const project = await prisma.iitProject.findUnique({
where: { id: context.projectId },
select: { cachedRules: true },
});
const cached = project?.cachedRules as any;
if (cached?.eventLabels && typeof cached.eventLabels === 'object') {
for (const [eid, label] of Object.entries(cached.eventLabels)) {
if (typeof label === 'string' && label.trim()) {
map.set(eid, label.trim());
}
}
}
if (context.redcapAdapter) {
try {
const events = await context.redcapAdapter.getEvents();
for (const ev of events) {
if (ev.unique_event_name && ev.event_name && !map.has(ev.unique_event_name)) {
map.set(ev.unique_event_name, ev.event_name);
}
}
} catch {
// non-fatal
}
}
return map;
}
private fallbackEventLabel(eventId: string): string {
return eventId
.replace(/_arm_\d+$/i, '')
.replace(/[_-]/g, ' ')
.replace(/\b\w/g, c => c.toUpperCase());
}
}
// ============================================================

View File

@@ -284,11 +284,23 @@ export class WechatService {
details: any;
}): Promise<void> {
try {
// iit_schema.audit_logs 需要 project_id 外键;无项目上下文时跳过 DB 审计,保留应用日志即可
if (!data.projectId) return;
await prisma.$executeRaw`
INSERT INTO iit_schema.audit_logs
(project_id, action, details, created_at)
(project_id, user_id, action_type, entity_type, entity_id, details, trace_id, created_at)
VALUES
(${data.projectId}, ${data.action}, ${JSON.stringify(data.details)}::jsonb, NOW())
(
${data.projectId},
${'system'},
${data.action},
${'wechat'},
${String(data.details?.userId || 'unknown')},
${JSON.stringify(data.details)}::jsonb,
${`wechat-${Date.now()}`},
NOW()
)
`;
} catch (error: any) {
// 审计日志失败不应影响主流程

View File

@@ -75,3 +75,4 @@ const query = process.argv[3] || '阿尔兹海默症的症状';
testDifySearch(datasetId, query);