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:
2900
backend/baseline-e2e-after-guardtype-apply.json
Normal file
2900
backend/baseline-e2e-after-guardtype-apply.json
Normal file
File diff suppressed because it is too large
Load Diff
2892
backend/baseline-e2e-after-unify.json
Normal file
2892
backend/baseline-e2e-after-unify.json
Normal file
File diff suppressed because it is too large
Load Diff
17006
backend/baseline-e2e-before-unify.json
Normal file
17006
backend/baseline-e2e-before-unify.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -69,3 +69,4 @@ try {
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -63,3 +63,4 @@ async function checkDocuments() {
|
||||
checkDocuments();
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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');
|
||||
@@ -43,3 +43,4 @@ Write-Host ""
|
||||
Write-Host "按任意键退出..."
|
||||
$null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown")
|
||||
|
||||
|
||||
|
||||
97
backend/scripts/analyze_missing_equery_context.ts
Normal file
97
backend/scripts/analyze_missing_equery_context.ts
Normal 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();
|
||||
});
|
||||
|
||||
111
backend/scripts/backfill_equery_context.ts
Normal file
111
backend/scripts/backfill_equery_context.ts
Normal 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();
|
||||
});
|
||||
|
||||
145
backend/scripts/dedupe_open_equeries.ts
Normal file
145
backend/scripts/dedupe_open_equeries.ts
Normal 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();
|
||||
});
|
||||
261
backend/scripts/e2e_iit_full_flow.ts
Normal file
261
backend/scripts/e2e_iit_full_flow.ts
Normal 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);
|
||||
});
|
||||
|
||||
126
backend/scripts/iit_chat_regression.py
Normal file
126
backend/scripts/iit_chat_regression.py
Normal 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())
|
||||
|
||||
114
backend/scripts/regression_hardrule_guards.ts
Normal file
114
backend/scripts/regression_hardrule_guards.ts
Normal 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);
|
||||
});
|
||||
|
||||
119
backend/scripts/regression_hardrule_guards_by_project.ts
Normal file
119
backend/scripts/regression_hardrule_guards_by_project.ts
Normal 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();
|
||||
});
|
||||
|
||||
39
backend/scripts/run_iit_qc_once.ts
Normal file
39
backend/scripts/run_iit_qc_once.ts
Normal 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);
|
||||
});
|
||||
|
||||
73
backend/scripts/suggest_guard_types_for_project.ts
Normal file
73
backend/scripts/suggest_guard_types_for_project.ts
Normal 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();
|
||||
});
|
||||
|
||||
92
backend/scripts/validate_guard_types_all_active_projects.ts
Normal file
92
backend/scripts/validate_guard_types_all_active_projects.ts
Normal 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();
|
||||
});
|
||||
|
||||
74
backend/scripts/validate_guard_types_for_project.ts
Normal file
74
backend/scripts/validate_guard_types_for_project.ts
Normal 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();
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动重开 eQuery(closed → 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取统计
|
||||
*/
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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('⚠️ 记录审计日志失败(非致命)', {
|
||||
|
||||
@@ -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: '非空值',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: 过滤适用于当前事件/表单的规则
|
||||
*
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
|
||||
@@ -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) {
|
||||
// 审计日志失败不应影响主流程
|
||||
|
||||
@@ -75,3 +75,4 @@ const query = process.argv[3] || '阿尔兹海默症的症状';
|
||||
testDifySearch(datasetId, query);
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user