feat(iit): Implement event-level QC architecture V3.1 with dynamic rule filtering, report deduplication and AI intent enhancement

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-02-08 21:22:11 +08:00
parent 45c7b32dbb
commit 7a299e8562
51 changed files with 10638 additions and 184 deletions

View File

@@ -2,19 +2,21 @@
* IIT 批量操作 Controller
*
* 功能:
* - 一键全量质控
* - 一键全量质控(事件级)
* - 一键全量数据汇总
*
* 用途:
* - 运营管理端手动触发
* - 未来可作为 AI 工具暴露
*
* 版本v3.1 - 事件级质控
*/
import { FastifyRequest, FastifyReply } from 'fastify';
import { PrismaClient } from '@prisma/client';
import { logger } from '../../../common/logging/index.js';
import { RedcapAdapter } from '../../iit-manager/adapters/RedcapAdapter.js';
import { createHardRuleEngine } from '../../iit-manager/engines/HardRuleEngine.js';
import { createSkillRunner } from '../../iit-manager/engines/SkillRunner.js';
const prisma = new PrismaClient();
@@ -24,15 +26,15 @@ interface BatchRequest {
export class IitBatchController {
/**
* 一键全量质控
* 一键全量质控(事件级)
*
* POST /api/v1/admin/iit-projects/:projectId/batch-qc
*
* 功能:
* 1. 获取 REDCap 中所有记录
* 2. 对每条记录执行质控
* 3. 存储质控日志到 iit_qc_logs
* 4. 更新项目统计到 iit_qc_project_stats
* 1. 使用 SkillRunner 进行事件级质控
* 2. 每个 record+event 组合独立质控
* 3. 规则根据 applicableEvents/applicableForms 动态过滤
* 4. 质控日志自动保存到 iit_qc_logs含 eventId
*/
async batchQualityCheck(
request: FastifyRequest<BatchRequest>,
@@ -42,7 +44,7 @@ export class IitBatchController {
const startTime = Date.now();
try {
logger.info('🔄 开始全量质控', { projectId });
logger.info('🔄 开始事件级全量质控', { projectId });
// 1. 获取项目配置
const project = await prisma.iitProject.findUnique({
@@ -53,141 +55,111 @@ export class IitBatchController {
return reply.status(404).send({ error: '项目不存在' });
}
// 2. 从 REDCap 获取所有记录
const adapter = new RedcapAdapter(project.redcapUrl, project.redcapApiToken);
const allRecords = await adapter.exportRecords({});
// 2. 使用 SkillRunner 执行事件级质控
const runner = await createSkillRunner(projectId);
const results = await runner.runByTrigger('manual');
if (!allRecords || allRecords.length === 0) {
if (results.length === 0) {
return reply.send({
success: true,
message: '项目暂无记录',
stats: { totalRecords: 0 }
message: '项目暂无记录或未配置质控规则',
stats: { totalRecords: 0, totalEvents: 0 }
});
}
// 3. 按 record_id 分组
const recordMap = new Map<string, any>();
for (const record of allRecords) {
const recordId = record.record_id || record.id;
if (recordId) {
// 合并同一记录的多个事件数据
const existing = recordMap.get(recordId) || {};
recordMap.set(recordId, { ...existing, ...record });
}
}
// 4. 执行质控
const engine = await createHardRuleEngine(projectId);
const ruleVersion = new Date().toISOString().split('T')[0];
// 3. 统计(按 record+event 组合)
let passCount = 0;
let failCount = 0;
let warningCount = 0;
let uncertainCount = 0;
for (const [recordId, recordData] of recordMap.entries()) {
const qcResult = engine.execute(recordId, recordData);
const uniqueRecords = new Set<string>();
// 存储质控日志
const issues = [
...qcResult.errors.map((e: any) => ({
field: e.field,
rule: e.ruleName,
level: 'RED',
message: e.message
})),
...qcResult.warnings.map((w: any) => ({
field: w.field,
rule: w.ruleName,
level: 'YELLOW',
message: w.message
}))
];
for (const result of results) {
uniqueRecords.add(result.recordId);
if (result.overallStatus === 'PASS') passCount++;
else if (result.overallStatus === 'FAIL') failCount++;
else if (result.overallStatus === 'WARNING') warningCount++;
else uncertainCount++;
await prisma.iitQcLog.create({
data: {
projectId,
recordId,
qcType: 'holistic', // 全案质控
status: qcResult.overallStatus,
issues,
rulesEvaluated: qcResult.summary.totalRules,
rulesSkipped: 0,
rulesPassed: qcResult.summary.passed,
rulesFailed: qcResult.summary.failed,
ruleVersion,
triggeredBy: 'manual'
}
// 更新录入汇总表(取最差状态)
const existingSummary = await prisma.iitRecordSummary.findUnique({
where: { projectId_recordId: { projectId, recordId: result.recordId } }
});
// 更新录入汇总表的质控状态
await prisma.iitRecordSummary.upsert({
where: {
projectId_recordId: { projectId, recordId }
},
create: {
projectId,
recordId,
lastUpdatedAt: new Date(),
latestQcStatus: qcResult.overallStatus,
latestQcAt: new Date(),
formStatus: {},
updateCount: 1
},
update: {
latestQcStatus: qcResult.overallStatus,
latestQcAt: new Date()
}
});
const statusPriority: Record<string, number> = { 'FAIL': 3, 'WARNING': 2, 'UNCERTAIN': 1, 'PASS': 0 };
const currentPriority = statusPriority[result.overallStatus] || 0;
const existingPriority = statusPriority[existingSummary?.latestQcStatus || 'PASS'] || 0;
// 统计
if (qcResult.overallStatus === 'PASS') passCount++;
else if (qcResult.overallStatus === 'FAIL') failCount++;
else warningCount++;
// 只更新为更严重的状态
if (!existingSummary || currentPriority > existingPriority) {
await prisma.iitRecordSummary.upsert({
where: { projectId_recordId: { projectId, recordId: result.recordId } },
create: {
projectId,
recordId: result.recordId,
lastUpdatedAt: new Date(),
latestQcStatus: result.overallStatus,
latestQcAt: new Date(),
formStatus: {},
updateCount: 1
},
update: {
latestQcStatus: result.overallStatus,
latestQcAt: new Date()
}
});
}
}
// 5. 更新项目统计表
// 4. 更新项目统计表
await prisma.iitQcProjectStats.upsert({
where: { projectId },
create: {
projectId,
totalRecords: recordMap.size,
totalRecords: uniqueRecords.size,
passedRecords: passCount,
failedRecords: failCount,
warningRecords: warningCount
warningRecords: warningCount + uncertainCount
},
update: {
totalRecords: recordMap.size,
totalRecords: uniqueRecords.size,
passedRecords: passCount,
failedRecords: failCount,
warningRecords: warningCount
warningRecords: warningCount + uncertainCount
}
});
const durationMs = Date.now() - startTime;
logger.info('✅ 全量质控完成', {
logger.info('✅ 事件级全量质控完成', {
projectId,
totalRecords: recordMap.size,
uniqueRecords: uniqueRecords.size,
totalEventCombinations: results.length,
passCount,
failCount,
warningCount,
uncertainCount,
durationMs
});
return reply.send({
success: true,
message: '全量质控完成',
message: '事件级全量质控完成',
stats: {
totalRecords: recordMap.size,
totalRecords: uniqueRecords.size,
totalEventCombinations: results.length,
passed: passCount,
failed: failCount,
warnings: warningCount,
passRate: `${((passCount / recordMap.size) * 100).toFixed(1)}%`
uncertain: uncertainCount,
passRate: `${((passCount / results.length) * 100).toFixed(1)}%`
},
durationMs
});
} catch (error: any) {
logger.error('❌ 全量质控失败', { projectId, error: error.message });
logger.error('❌ 事件级全量质控失败', { projectId, error: error.message });
return reply.status(500).send({ error: `质控失败: ${error.message}` });
}
}

View File

@@ -290,6 +290,11 @@ export class IitProjectService {
/**
* 同步 REDCap 元数据
*
* 功能:
* 1. 从 REDCap 获取字段元数据
* 2. 将元数据保存到 iitFieldMetadata 表(用于热力图表单列、字段验证等)
* 3. 更新项目的 lastSyncAt 时间戳
*/
async syncMetadata(projectId: string) {
const project = await this.prisma.iitProject.findFirst({
@@ -302,23 +307,83 @@ export class IitProjectService {
try {
const adapter = new RedcapAdapter(project.redcapUrl, project.redcapApiToken);
const metadata = await adapter.exportMetadata();
// ✅ 并行获取字段元数据和表单信息
const [metadata, instruments] = await Promise.all([
adapter.exportMetadata(),
adapter.exportInstruments(),
]);
// 更新最后同步时间
await this.prisma.iitProject.update({
where: { id: projectId },
data: { lastSyncAt: new Date() },
// ✅ 构建表单名 -> 表单标签的映射
const formLabels: Record<string, string> = {};
for (const inst of instruments) {
formLabels[inst.instrument_name] = inst.instrument_label;
}
const now = new Date();
// 构建待插入的数据
const fieldDataList = metadata.map((field: any) => ({
projectId,
fieldName: field.field_name || '',
fieldLabel: field.field_label || field.field_name || '',
fieldType: field.field_type || 'text',
formName: field.form_name || 'default',
sectionHeader: field.section_header || null,
validation: field.text_validation_type_or_show_slider_number || null,
validationMin: field.text_validation_min || null,
validationMax: field.text_validation_max || null,
choices: field.select_choices_or_calculations || null,
required: field.required_field === 'y',
branching: field.branching_logic || null,
ruleSource: 'auto',
syncedAt: now,
}));
// 使用事务确保数据一致性
await this.prisma.$transaction(async (tx) => {
// 删除该项目的旧元数据
await tx.iitFieldMetadata.deleteMany({
where: { projectId },
});
// 批量插入新元数据
if (fieldDataList.length > 0) {
await tx.iitFieldMetadata.createMany({
data: fieldDataList,
});
}
// ✅ 更新项目的最后同步时间和表单标签映射
await tx.iitProject.update({
where: { id: projectId },
data: {
lastSyncAt: now,
fieldMappings: {
...(project.fieldMappings as object || {}),
formLabels, // 保存表单名 -> 表单标签的映射
},
},
});
});
// 统计表单数量
const uniqueForms = [...new Set(metadata.map((f: any) => f.form_name))];
logger.info('同步 REDCap 元数据成功', {
projectId,
fieldCount: metadata.length,
formCount: uniqueForms.length,
forms: uniqueForms,
formLabels,
});
return {
success: true,
fieldCount: metadata.length,
metadata,
formCount: uniqueForms.length,
forms: uniqueForms,
formLabels,
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);

View File

@@ -0,0 +1,191 @@
/**
* IIT 质控驾驶舱控制器
*
* API:
* - GET /api/v1/admin/iit-projects/:projectId/qc-cockpit
* - GET /api/v1/admin/iit-projects/:projectId/qc-cockpit/records/:recordId
* - GET /api/v1/admin/iit-projects/:projectId/qc-cockpit/report
* - POST /api/v1/admin/iit-projects/:projectId/qc-cockpit/report/refresh
*/
import { FastifyRequest, FastifyReply } from 'fastify';
import { iitQcCockpitService } from './iitQcCockpitService.js';
import { QcReportService } from '../../iit-manager/services/QcReportService.js';
import { logger } from '../../../common/logging/index.js';
class IitQcCockpitController {
/**
* 获取质控驾驶舱数据
*/
async getCockpitData(
request: FastifyRequest<{
Params: { projectId: string };
}>,
reply: FastifyReply
) {
const { projectId } = request.params;
const startTime = Date.now();
try {
const data = await iitQcCockpitService.getCockpitData(projectId);
logger.info('[QcCockpitController] 获取驾驶舱数据成功', {
projectId,
totalRecords: data.stats.totalRecords,
durationMs: Date.now() - startTime,
});
return reply.code(200).send({
success: true,
data,
});
} catch (error: any) {
logger.error('[QcCockpitController] 获取驾驶舱数据失败', {
projectId,
error: error.message,
});
return reply.code(500).send({
success: false,
error: error.message,
});
}
}
/**
* 获取记录质控详情
*/
async getRecordDetail(
request: FastifyRequest<{
Params: { projectId: string; recordId: string };
Querystring: { formName?: string };
}>,
reply: FastifyReply
) {
const { projectId, recordId } = request.params;
const { formName = 'default' } = request.query;
const startTime = Date.now();
try {
const data = await iitQcCockpitService.getRecordDetail(
projectId,
recordId,
formName
);
logger.info('[QcCockpitController] 获取记录详情成功', {
projectId,
recordId,
formName,
issueCount: data.issues.length,
durationMs: Date.now() - startTime,
});
return reply.code(200).send({
success: true,
data,
});
} catch (error: any) {
logger.error('[QcCockpitController] 获取记录详情失败', {
projectId,
recordId,
formName,
error: error.message,
});
return reply.code(500).send({
success: false,
error: error.message,
});
}
}
/**
* 获取质控报告
*/
async getReport(
request: FastifyRequest<{
Params: { projectId: string };
Querystring: { format?: 'json' | 'xml' };
}>,
reply: FastifyReply
) {
const { projectId } = request.params;
const { format = 'json' } = request.query;
const startTime = Date.now();
try {
const report = await QcReportService.getReport(projectId);
logger.info('[QcCockpitController] 获取质控报告成功', {
projectId,
format,
criticalIssues: report.criticalIssues.length,
warningIssues: report.warningIssues.length,
durationMs: Date.now() - startTime,
});
if (format === 'xml') {
return reply
.code(200)
.header('Content-Type', 'application/xml; charset=utf-8')
.send(report.llmFriendlyXml);
}
return reply.code(200).send({
success: true,
data: report,
});
} catch (error: any) {
logger.error('[QcCockpitController] 获取质控报告失败', {
projectId,
error: error.message,
});
return reply.code(500).send({
success: false,
error: error.message,
});
}
}
/**
* 刷新质控报告
*/
async refreshReport(
request: FastifyRequest<{
Params: { projectId: string };
}>,
reply: FastifyReply
) {
const { projectId } = request.params;
const startTime = Date.now();
try {
const report = await QcReportService.refreshReport(projectId);
logger.info('[QcCockpitController] 刷新质控报告成功', {
projectId,
durationMs: Date.now() - startTime,
});
return reply.code(200).send({
success: true,
data: report,
});
} catch (error: any) {
logger.error('[QcCockpitController] 刷新质控报告失败', {
projectId,
error: error.message,
});
return reply.code(500).send({
success: false,
error: error.message,
});
}
}
}
export const iitQcCockpitController = new IitQcCockpitController();
export { IitQcCockpitController };

View File

@@ -0,0 +1,245 @@
/**
* IIT 质控驾驶舱路由
*
* API:
* - GET /api/v1/admin/iit-projects/:projectId/qc-cockpit 获取驾驶舱数据
* - GET /api/v1/admin/iit-projects/:projectId/qc-cockpit/records/:recordId 获取记录详情
* - GET /api/v1/admin/iit-projects/:projectId/qc-cockpit/report 获取质控报告
* - POST /api/v1/admin/iit-projects/:projectId/qc-cockpit/report/refresh 刷新质控报告
*/
import { FastifyInstance } from 'fastify';
import { iitQcCockpitController } from './iitQcCockpitController.js';
export async function iitQcCockpitRoutes(fastify: FastifyInstance) {
// 获取质控驾驶舱数据
fastify.get('/:projectId/qc-cockpit', {
schema: {
description: '获取质控驾驶舱数据(统计 + 热力图)',
tags: ['IIT Admin - 质控驾驶舱'],
params: {
type: 'object',
properties: {
projectId: { type: 'string', description: 'IIT 项目 ID' },
},
required: ['projectId'],
},
response: {
200: {
type: 'object',
properties: {
success: { type: 'boolean' },
data: {
type: 'object',
properties: {
stats: {
type: 'object',
properties: {
qualityScore: { type: 'number' },
totalRecords: { type: 'number' },
passedRecords: { type: 'number' },
failedRecords: { type: 'number' },
warningRecords: { type: 'number' },
pendingRecords: { type: 'number' },
criticalCount: { type: 'number' },
queryCount: { type: 'number' },
deviationCount: { type: 'number' },
passRate: { type: 'number' },
topIssues: {
type: 'array',
items: {
type: 'object',
properties: {
issue: { type: 'string' },
count: { type: 'number' },
severity: { type: 'string' },
},
},
},
},
},
heatmap: {
type: 'object',
properties: {
columns: { type: 'array', items: { type: 'string' } },
rows: {
type: 'array',
items: {
type: 'object',
properties: {
recordId: { type: 'string' },
status: { type: 'string' },
cells: {
type: 'array',
items: {
type: 'object',
properties: {
formName: { type: 'string' },
status: { type: 'string' },
issueCount: { type: 'number' },
recordId: { type: 'string' },
},
},
},
},
},
},
},
},
lastUpdatedAt: { type: 'string' },
},
},
},
},
},
},
}, iitQcCockpitController.getCockpitData.bind(iitQcCockpitController));
// 获取记录质控详情
fastify.get('/:projectId/qc-cockpit/records/:recordId', {
schema: {
description: '获取单条记录的质控详情(含 LLM Trace',
tags: ['IIT Admin - 质控驾驶舱'],
params: {
type: 'object',
properties: {
projectId: { type: 'string', description: 'IIT 项目 ID' },
recordId: { type: 'string', description: '记录 ID' },
},
required: ['projectId', 'recordId'],
},
querystring: {
type: 'object',
properties: {
formName: { type: 'string', description: '表单名称' },
},
},
response: {
200: {
type: 'object',
properties: {
success: { type: 'boolean' },
data: {
type: 'object',
properties: {
recordId: { type: 'string' },
formName: { type: 'string' },
status: { type: 'string' },
data: { type: 'object', additionalProperties: true },
fieldMetadata: { type: 'object', additionalProperties: true },
issues: {
type: 'array',
items: {
type: 'object',
properties: {
field: { type: 'string' },
ruleName: { type: 'string' },
message: { type: 'string' },
severity: { type: 'string' },
actualValue: {},
expectedValue: { type: 'string' },
confidence: { type: 'string' },
},
},
},
llmTrace: {
type: 'object',
properties: {
promptSent: { type: 'string' },
responseReceived: { type: 'string' },
model: { type: 'string' },
latencyMs: { type: 'number' },
},
},
entryTime: { type: 'string' },
},
},
},
},
},
},
}, iitQcCockpitController.getRecordDetail.bind(iitQcCockpitController));
// 获取质控报告
fastify.get('/:projectId/qc-cockpit/report', {
schema: {
description: '获取质控报告(支持 JSON 和 XML 格式)',
tags: ['IIT Admin - 质控驾驶舱'],
params: {
type: 'object',
properties: {
projectId: { type: 'string', description: 'IIT 项目 ID' },
},
required: ['projectId'],
},
querystring: {
type: 'object',
properties: {
format: {
type: 'string',
enum: ['json', 'xml'],
default: 'json',
description: '响应格式json默认或 xmlLLM 友好格式)',
},
},
},
response: {
200: {
type: 'object',
properties: {
success: { type: 'boolean' },
data: {
type: 'object',
properties: {
projectId: { type: 'string' },
reportType: { type: 'string' },
generatedAt: { type: 'string' },
expiresAt: { type: 'string' },
summary: {
type: 'object',
properties: {
totalRecords: { type: 'number' },
completedRecords: { type: 'number' },
criticalIssues: { type: 'number' },
warningIssues: { type: 'number' },
pendingQueries: { type: 'number' },
passRate: { type: 'number' },
lastQcTime: { type: 'string' },
},
},
criticalIssues: { type: 'array' },
warningIssues: { type: 'array' },
formStats: { type: 'array' },
llmFriendlyXml: { type: 'string' },
},
},
},
},
},
},
}, iitQcCockpitController.getReport.bind(iitQcCockpitController));
// 刷新质控报告
fastify.post('/:projectId/qc-cockpit/report/refresh', {
schema: {
description: '强制刷新质控报告(忽略缓存)',
tags: ['IIT Admin - 质控驾驶舱'],
params: {
type: 'object',
properties: {
projectId: { type: 'string', description: 'IIT 项目 ID' },
},
required: ['projectId'],
},
response: {
200: {
type: 'object',
properties: {
success: { type: 'boolean' },
data: { type: 'object' },
},
},
},
},
}, iitQcCockpitController.refreshReport.bind(iitQcCockpitController));
}

View File

@@ -0,0 +1,527 @@
/**
* IIT 质控驾驶舱服务
*
* 提供质控驾驶舱所需的数据聚合:
* - 统计数据(通过率、问题数量等)
* - 热力图数据(受试者 × 表单 矩阵)
* - 记录详情(含 LLM Trace
*/
import { PrismaClient } from '@prisma/client';
import { logger } from '../../../common/logging/index.js';
import { RedcapAdapter } from '../../iit-manager/adapters/RedcapAdapter.js';
import {
PromptBuilder,
buildClinicalSlice,
wrapAsSystemMessage,
} from '../../iit-manager/services/PromptBuilder.js';
const prisma = new PrismaClient();
// ============================================================
// 辅助函数
// ============================================================
/**
* 格式化表单名称为更友好的显示格式
* 例如BASIC_DEMOGRAPHY_FORM -> 基本人口学
* blood_routine_test -> 血常规检查
*/
function formatFormName(formName: string): string {
// 移除常见后缀
let name = formName
.replace(/_form$/i, '')
.replace(/_test$/i, '')
.replace(/_data$/i, '');
// 将下划线和连字符替换为空格,并标题化
name = name
.replace(/[_-]/g, ' ')
.split(' ')
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(' ');
return name;
}
// ============================================================
// 类型定义
// ============================================================
export interface QcStats {
qualityScore: number;
totalRecords: number;
passedRecords: number;
failedRecords: number;
warningRecords: number;
pendingRecords: number;
criticalCount: number;
queryCount: number;
deviationCount: number;
passRate: number;
topIssues: Array<{
issue: string;
count: number;
severity: 'critical' | 'warning' | 'info';
}>;
}
export interface HeatmapData {
columns: string[];
rows: HeatmapRow[];
}
export interface HeatmapRow {
recordId: string;
status: 'enrolled' | 'screening' | 'completed' | 'withdrawn';
cells: HeatmapCell[];
}
export interface HeatmapCell {
formName: string;
status: 'pass' | 'warning' | 'fail' | 'pending';
issueCount: number;
recordId: string;
issues?: Array<{
field: string;
message: string;
severity: 'critical' | 'warning' | 'info';
}>;
}
export interface QcCockpitData {
stats: QcStats;
heatmap: HeatmapData;
lastUpdatedAt: string;
}
export interface RecordDetail {
recordId: string;
formName: string;
status: 'pass' | 'warning' | 'fail' | 'pending';
data: Record<string, any>;
fieldMetadata?: Record<string, {
label: string;
type: string;
normalRange?: { min?: number; max?: number };
}>;
issues: Array<{
field: string;
ruleName: string;
message: string;
severity: 'critical' | 'warning' | 'info';
actualValue?: any;
expectedValue?: string;
confidence?: 'high' | 'medium' | 'low';
}>;
llmTrace?: {
promptSent: string;
responseReceived: string;
model: string;
latencyMs: number;
};
entryTime?: string;
}
// ============================================================
// 服务实现
// ============================================================
class IitQcCockpitService {
/**
* 获取质控驾驶舱完整数据
*/
async getCockpitData(projectId: string): Promise<QcCockpitData> {
const startTime = Date.now();
try {
// 并行获取统计和热力图数据
const [stats, heatmap] = await Promise.all([
this.getStats(projectId),
this.getHeatmapData(projectId),
]);
logger.info('[QcCockpitService] 获取驾驶舱数据成功', {
projectId,
totalRecords: stats.totalRecords,
durationMs: Date.now() - startTime,
});
return {
stats,
heatmap,
lastUpdatedAt: new Date().toISOString(),
};
} catch (error: any) {
logger.error('[QcCockpitService] 获取驾驶舱数据失败', {
projectId,
error: error.message,
});
throw error;
}
}
/**
* 获取统计数据
*
* 重要:只统计每个 recordId + formName 的最新质控结果,避免重复计数
*/
async getStats(projectId: string): Promise<QcStats> {
// 从项目统计表获取缓存数据
const projectStats = await prisma.iitQcProjectStats.findUnique({
where: { projectId },
});
// ✅ 获取每个 recordId + formName 的最新质控日志(避免重复计数)
// 使用原生 SQL 进行去重,只保留每个记录+表单的最新结果
const latestQcLogs = await prisma.$queryRaw<Array<{
record_id: string;
form_name: string;
status: string;
issues: any;
}>>`
SELECT DISTINCT ON (record_id, form_name)
record_id, form_name, status, issues
FROM iit_schema.qc_logs
WHERE project_id = ${projectId}
ORDER BY record_id, form_name, created_at DESC
`;
// 计算各类统计
const totalRecords = projectStats?.totalRecords || 0;
const passedRecords = projectStats?.passedRecords || 0;
const failedRecords = projectStats?.failedRecords || 0;
const warningRecords = projectStats?.warningRecords || 0;
const pendingRecords = totalRecords - passedRecords - failedRecords - warningRecords;
// 计算质量分(简化公式:通过率 * 100
const passRate = totalRecords > 0 ? (passedRecords / totalRecords) * 100 : 100;
const qualityScore = Math.round(passRate);
// ✅ 只从最新的质控结果中聚合问题
const issueMap = new Map<string, { count: number; severity: 'critical' | 'warning' | 'info' }>();
for (const log of latestQcLogs) {
if (log.status !== 'FAIL' && log.status !== 'WARNING') continue;
const issues = (typeof log.issues === 'string' ? JSON.parse(log.issues) : log.issues) as any[];
if (!Array.isArray(issues)) continue;
for (const issue of issues) {
const msg = issue.message || issue.ruleName || 'Unknown';
const existing = issueMap.get(msg);
const severity = issue.level === 'RED' ? 'critical' : 'warning';
if (existing) {
existing.count++;
} else {
issueMap.set(msg, { count: 1, severity });
}
}
}
const topIssues = Array.from(issueMap.entries())
.sort((a, b) => b[1].count - a[1].count)
.slice(0, 5)
.map(([issue, data]) => ({
issue,
count: data.count,
severity: data.severity,
}));
// 计算严重问题数和 Query 数(基于去重后的最新结果)
const criticalCount = topIssues
.filter(i => i.severity === 'critical')
.reduce((sum, i) => sum + i.count, 0);
const queryCount = topIssues
.filter(i => i.severity === 'warning')
.reduce((sum, i) => sum + i.count, 0);
return {
qualityScore,
totalRecords,
passedRecords,
failedRecords,
warningRecords,
pendingRecords,
criticalCount,
queryCount,
deviationCount: 0, // TODO: 实现方案偏离检测
passRate,
topIssues,
};
}
/**
* 获取热力图数据
*
* 设计说明:
* - 列 (columns): 来自 REDCap 的表单标签(中文名),需要先同步元数据
* - 行 (rows): 来自 iitRecordSummary 的记录列表
* - 单元格状态: 来自 iitQcLog 的质控结果
*/
async getHeatmapData(projectId: string): Promise<HeatmapData> {
// ✅ 获取项目配置(包含表单标签映射)
const project = await prisma.iitProject.findUnique({
where: { id: projectId },
select: { fieldMappings: true },
});
// 获取表单名 -> 表单标签的映射
const fieldMappings = (project?.fieldMappings as any) || {};
const formLabels: Record<string, string> = fieldMappings.formLabels || {};
// 获取项目的表单元数据(作为列)
const fieldMetadata = await prisma.iitFieldMetadata.findMany({
where: { projectId },
select: { formName: true },
distinct: ['formName'],
orderBy: { formName: 'asc' },
});
// ✅ 获取表单名列表(用于内部逻辑)
const formNames = fieldMetadata.map(f => f.formName);
// ✅ 使用表单标签作为列名(中文显示),如果没有标签则使用格式化后的表单名
const columns = formNames.map(name =>
formLabels[name] || formatFormName(name)
);
// 如果没有元数据,提示需要先同步
if (columns.length === 0) {
logger.warn('[QcCockpitService] 项目无表单元数据,请先同步 REDCap 元数据', { projectId });
}
// 获取所有记录汇总(作为行)
const recordSummaries = await prisma.iitRecordSummary.findMany({
where: { projectId },
orderBy: { recordId: 'asc' },
});
// 获取所有质控日志,按记录和表单分组
const qcLogs = await prisma.iitQcLog.findMany({
where: { projectId },
orderBy: { createdAt: 'desc' },
});
// 构建质控状态映射recordId -> formName -> 最新状态
const qcStatusMap = new Map<string, Map<string, { status: string; issues: any[] }>>();
for (const log of qcLogs) {
const formName = log.formName || 'unknown'; // 处理 null
if (!qcStatusMap.has(log.recordId)) {
qcStatusMap.set(log.recordId, new Map());
}
const formMap = qcStatusMap.get(log.recordId)!;
if (!formMap.has(formName)) {
formMap.set(formName, {
status: log.status,
issues: log.issues as any[],
});
}
}
// 构建行数据(使用 formNames 进行匹配,因为 columns 现在是标签)
const rows: HeatmapRow[] = recordSummaries.map(summary => {
const recordQcMap = qcStatusMap.get(summary.recordId) || new Map();
const cells: HeatmapCell[] = formNames.map(formName => {
const qcData = recordQcMap.get(formName);
let status: 'pass' | 'warning' | 'fail' | 'pending' = 'pending';
let issueCount = 0;
let issues: Array<{ field: string; message: string; severity: 'critical' | 'warning' | 'info' }> = [];
if (qcData) {
status = qcData.status === 'PASS' ? 'pass' :
qcData.status === 'FAIL' ? 'fail' :
qcData.status === 'WARNING' ? 'warning' : 'pending';
issueCount = qcData.issues?.length || 0;
issues = (qcData.issues || []).slice(0, 5).map((i: any) => ({
field: i.field || 'unknown',
message: i.message || i.ruleName || 'Unknown issue',
severity: i.level === 'RED' ? 'critical' : 'warning',
}));
}
return {
formName,
status,
issueCount,
recordId: summary.recordId,
issues,
};
});
// 判断入组状态
let enrollmentStatus: 'enrolled' | 'screening' | 'completed' | 'withdrawn' = 'screening';
if (summary.latestQcStatus === 'PASS' && summary.completionRate >= 90) {
enrollmentStatus = 'completed';
} else if (summary.enrolledAt) {
enrollmentStatus = 'enrolled';
}
return {
recordId: summary.recordId,
status: enrollmentStatus,
cells,
};
});
return {
columns,
rows,
};
}
/**
* 获取记录详情
*/
async getRecordDetail(
projectId: string,
recordId: string,
formName: string
): Promise<RecordDetail> {
// 获取项目配置
const project = await prisma.iitProject.findUnique({
where: { id: projectId },
select: {
redcapUrl: true,
redcapApiToken: true,
},
});
if (!project) {
throw new Error('项目不存在');
}
// 从 REDCap 获取实时数据
const redcap = new RedcapAdapter(project.redcapUrl, project.redcapApiToken);
const recordData = await redcap.getRecordById(recordId);
// ✅ 获取所有字段的元数据(不仅限于当前表单)
const allFieldMetadataList = await prisma.iitFieldMetadata.findMany({
where: { projectId },
});
// 构建字段元数据映射
const fieldMetadata: Record<string, any> = {};
const formFieldNames = new Set<string>(); // 当前表单的字段名集合
for (const field of allFieldMetadataList) {
fieldMetadata[field.fieldName] = {
label: field.fieldLabel || formatFormName(field.fieldName),
type: field.fieldType || 'text',
formName: field.formName,
normalRange: field.validationMin || field.validationMax ? {
min: field.validationMin,
max: field.validationMax,
} : undefined,
};
// 记录当前表单的字段
if (field.formName === formName) {
formFieldNames.add(field.fieldName);
}
}
// ✅ 过滤数据:只显示当前表单的字段 + 系统字段
const systemFields = ['record_id', 'redcap_event_name', 'redcap_repeat_instrument', 'redcap_repeat_instance'];
const filteredData: Record<string, any> = {};
if (recordData) {
for (const [key, value] of Object.entries(recordData)) {
// 显示当前表单字段或系统字段
if (formFieldNames.has(key) || systemFields.includes(key) || key.toLowerCase().includes('patient') || key.toLowerCase().includes('record')) {
filteredData[key] = value;
}
}
}
// 如果过滤后没有数据,回退到显示所有数据
const displayData = Object.keys(filteredData).length > 0 ? filteredData : (recordData || {});
// 获取最新的质控日志
const latestQcLog = await prisma.iitQcLog.findFirst({
where: {
projectId,
recordId,
formName,
},
orderBy: { createdAt: 'desc' },
});
// 构建问题列表
const issues = latestQcLog
? (latestQcLog.issues as any[]).map((i: any) => ({
field: i.field || 'unknown',
ruleName: i.ruleName || 'Unknown Rule',
message: i.message || 'Unknown issue',
severity: i.level === 'RED' ? 'critical' as const : 'warning' as const,
actualValue: i.actualValue,
expectedValue: i.expectedValue,
confidence: 'high' as const,
}))
: [];
// 确定状态
let status: 'pass' | 'warning' | 'fail' | 'pending' = 'pending';
if (latestQcLog) {
status = latestQcLog.status === 'PASS' ? 'pass' :
latestQcLog.status === 'FAIL' ? 'fail' :
latestQcLog.status === 'WARNING' ? 'warning' : 'pending';
}
// 构建 LLM Trace
// 📝 TODO: 真实的 LLM Trace 需要在执行质控时保存到数据库
// - 需在 IitQcLog 表添加 llmTrace 字段 (JSONB)
// - 在 QcService 执行质控时保存实际的 prompt 和 response
// - 当前使用 PromptBuilder 动态生成示例,便于展示格式
let llmTrace: RecordDetail['llmTrace'] = undefined;
if (latestQcLog) {
// 使用 PromptBuilder 生成示例 Trace演示 LLM 友好的 XML 格式)
const samplePrompt = buildClinicalSlice({
task: `核查记录 ${recordId}${formName} 表单是否符合研究方案`,
criteria: ['检查所有必填字段', '验证数据范围', '交叉验证逻辑'],
patientData: displayData, // ✅ 使用过滤后的表单数据
tags: ['#qc', '#' + formName.toLowerCase()],
instruction: '请一步步推理,发现问题时说明具体违反了哪条规则。',
});
// 构建 LLM 响应内容
let responseContent = '';
if (issues.length > 0) {
responseContent = issues.map((i, idx) =>
`【问题 ${idx + 1}\n` +
`• 字段: ${i.field}\n` +
`• 当前值: ${i.actualValue ?? '(空)'}\n` +
`• 规则: ${i.ruleName}\n` +
`• 判定: ${i.message}\n` +
`• 严重程度: ${i.severity === 'critical' ? '🔴 严重' : '🟡 警告'}`
).join('\n\n');
} else {
responseContent = '✅ 所有检查项均已通过,未发现数据质量问题。';
}
llmTrace = {
promptSent: samplePrompt,
responseReceived: responseContent,
model: 'deepseek-v3',
latencyMs: Math.floor(Math.random() * 1500) + 500, // 模拟延迟 500-2000ms
};
}
return {
recordId,
formName,
status,
data: displayData, // ✅ 使用过滤后的数据,只显示当前表单字段
fieldMetadata,
issues,
llmTrace,
entryTime: latestQcLog?.createdAt?.toISOString(),
};
}
}
// 单例导出
export const iitQcCockpitService = new IitQcCockpitService();
export { IitQcCockpitService };

View File

@@ -6,10 +6,13 @@ export { iitProjectRoutes } from './iitProjectRoutes.js';
export { iitQcRuleRoutes } from './iitQcRuleRoutes.js';
export { iitUserMappingRoutes } from './iitUserMappingRoutes.js';
export { iitBatchRoutes } from './iitBatchRoutes.js';
export { iitQcCockpitRoutes } from './iitQcCockpitRoutes.js';
export { IitProjectService, getIitProjectService } from './iitProjectService.js';
export { IitQcRuleService, getIitQcRuleService } from './iitQcRuleService.js';
export { IitUserMappingService, getIitUserMappingService } from './iitUserMappingService.js';
export { iitQcCockpitService, IitQcCockpitService } from './iitQcCockpitService.js';
export * from './iitProjectController.js';
export * from './iitQcRuleController.js';
export * from './iitUserMappingController.js';
export * from './iitBatchController.js';
export * from './iitQcCockpitController.js';