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:
@@ -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}` });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
191
backend/src/modules/admin/iit-projects/iitQcCockpitController.ts
Normal file
191
backend/src/modules/admin/iit-projects/iitQcCockpitController.ts
Normal 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 };
|
||||
245
backend/src/modules/admin/iit-projects/iitQcCockpitRoutes.ts
Normal file
245
backend/src/modules/admin/iit-projects/iitQcCockpitRoutes.ts
Normal 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(默认)或 xml(LLM 友好格式)',
|
||||
},
|
||||
},
|
||||
},
|
||||
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));
|
||||
}
|
||||
527
backend/src/modules/admin/iit-projects/iitQcCockpitService.ts
Normal file
527
backend/src/modules/admin/iit-projects/iitQcCockpitService.ts
Normal 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 };
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user