feat(iit): harden QC pipeline consistency and release artifacts

Implement IIT quality workflow hardening across eQuery deduplication, guard metadata validation, timeline/readability improvements, and chat evidence fallbacks, then synchronize release and development documentation for deployment handoff.

Includes migration/scripts for open eQuery dedupe guards, orchestration/status semantics, report/tool readability fixes, and updated module status plus deployment checklist.

Made-with: Cursor
This commit is contained in:
2026-03-08 21:54:35 +08:00
parent ac724266c1
commit a666649fd4
57 changed files with 28637 additions and 316 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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