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:
@@ -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) {
|
||||
// 审计日志失败不应影响主流程
|
||||
|
||||
Reference in New Issue
Block a user