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:
@@ -109,7 +109,7 @@ import { tenantRoutes, moduleRoutes } from './modules/admin/routes/tenantRoutes.
|
||||
import { userRoutes } from './modules/admin/routes/userRoutes.js';
|
||||
import { statsRoutes, userOverviewRoute } from './modules/admin/routes/statsRoutes.js';
|
||||
import { systemKbRoutes } from './modules/admin/system-kb/index.js';
|
||||
import { iitProjectRoutes, iitQcRuleRoutes, iitUserMappingRoutes, iitBatchRoutes } from './modules/admin/iit-projects/index.js';
|
||||
import { iitProjectRoutes, iitQcRuleRoutes, iitUserMappingRoutes, iitBatchRoutes, iitQcCockpitRoutes } from './modules/admin/iit-projects/index.js';
|
||||
await fastify.register(tenantRoutes, { prefix: '/api/admin/tenants' });
|
||||
await fastify.register(moduleRoutes, { prefix: '/api/admin/modules' });
|
||||
await fastify.register(userRoutes, { prefix: '/api/admin/users' });
|
||||
@@ -120,6 +120,7 @@ await fastify.register(iitProjectRoutes, { prefix: '/api/v1/admin/iit-projects'
|
||||
await fastify.register(iitQcRuleRoutes, { prefix: '/api/v1/admin/iit-projects' });
|
||||
await fastify.register(iitUserMappingRoutes, { prefix: '/api/v1/admin/iit-projects' });
|
||||
await fastify.register(iitBatchRoutes, { prefix: '/api/v1/admin/iit-projects' }); // 一键全量质控/汇总
|
||||
await fastify.register(iitQcCockpitRoutes, { prefix: '/api/v1/admin/iit-projects' }); // 质控驾驶舱
|
||||
logger.info('✅ 运营管理路由已注册: /api/admin/tenants, /api/admin/modules, /api/admin/users, /api/admin/stats, /api/v1/admin/system-kb, /api/v1/admin/iit-projects');
|
||||
|
||||
// ============================================
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -16,6 +16,10 @@ export interface RedcapExportOptions {
|
||||
dateRangeEnd?: Date;
|
||||
/** 事件列表(纵向研究) */
|
||||
events?: string[];
|
||||
/** 返回值格式:'raw'=原始代码, 'label'=显示标签 */
|
||||
rawOrLabel?: 'raw' | 'label';
|
||||
/** 是否导出计算字段(Calculated Fields)的值 */
|
||||
exportCalculatedFields?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -118,6 +122,16 @@ export class RedcapAdapter {
|
||||
});
|
||||
}
|
||||
|
||||
// 返回值格式:raw(原始代码)或 label(显示标签)
|
||||
// 默认使用 raw,保证数据一致性
|
||||
formData.append('rawOrLabel', options.rawOrLabel || 'raw');
|
||||
|
||||
// 导出计算字段(如:从出生日期自动计算的年龄)
|
||||
// 默认启用,确保所有字段数据完整
|
||||
if (options.exportCalculatedFields !== false) {
|
||||
formData.append('exportCalculatedFields', 'true');
|
||||
}
|
||||
|
||||
try {
|
||||
const startTime = Date.now();
|
||||
|
||||
@@ -887,5 +901,462 @@ export class RedcapAdapter {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有记录(按事件分开,不合并)
|
||||
*
|
||||
* 每个 record_id + event 作为独立的数据单元返回
|
||||
* 这是纵向研究的正确处理方式,确保每个访视的数据独立质控
|
||||
*
|
||||
* @param options 可选参数
|
||||
* @returns 事件级记录数组
|
||||
*/
|
||||
async getAllRecordsByEvent(options?: {
|
||||
recordId?: string;
|
||||
eventName?: string;
|
||||
}): Promise<Array<{
|
||||
recordId: string;
|
||||
eventName: string;
|
||||
eventLabel: string;
|
||||
data: Record<string, any>;
|
||||
forms: string[]; // 该事件包含的表单列表
|
||||
}>> {
|
||||
try {
|
||||
// 获取表单-事件映射
|
||||
const formEventMapping = await this.getFormEventMapping();
|
||||
|
||||
// 按事件分组表单
|
||||
const eventForms = new Map<string, Set<string>>();
|
||||
const eventLabels = new Map<string, string>();
|
||||
for (const m of formEventMapping) {
|
||||
if (!eventForms.has(m.eventName)) {
|
||||
eventForms.set(m.eventName, new Set());
|
||||
}
|
||||
eventForms.get(m.eventName)!.add(m.formName);
|
||||
eventLabels.set(m.eventName, m.eventLabel);
|
||||
}
|
||||
|
||||
// 获取原始记录(不合并)
|
||||
const exportOptions: any = {};
|
||||
if (options?.recordId) {
|
||||
exportOptions.records = [options.recordId];
|
||||
}
|
||||
if (options?.eventName) {
|
||||
exportOptions.events = [options.eventName];
|
||||
}
|
||||
|
||||
const rawRecords = await this.exportRecords(exportOptions);
|
||||
|
||||
if (rawRecords.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 转换为事件级数据结构
|
||||
const results: Array<{
|
||||
recordId: string;
|
||||
eventName: string;
|
||||
eventLabel: string;
|
||||
data: Record<string, any>;
|
||||
forms: string[];
|
||||
}> = [];
|
||||
|
||||
for (const record of rawRecords) {
|
||||
const recordId = record.record_id;
|
||||
const eventName = record.redcap_event_name;
|
||||
|
||||
if (!recordId || !eventName) continue;
|
||||
|
||||
// 获取该事件包含的表单列表
|
||||
const forms = eventForms.has(eventName)
|
||||
? [...eventForms.get(eventName)!]
|
||||
: [];
|
||||
|
||||
results.push({
|
||||
recordId,
|
||||
eventName,
|
||||
eventLabel: eventLabels.get(eventName) || eventName,
|
||||
data: record,
|
||||
forms,
|
||||
});
|
||||
}
|
||||
|
||||
// 按 recordId 和事件排序
|
||||
results.sort((a, b) => {
|
||||
const idA = parseInt(a.recordId) || 0;
|
||||
const idB = parseInt(b.recordId) || 0;
|
||||
if (idA !== idB) return idA - idB;
|
||||
return a.eventName.localeCompare(b.eventName);
|
||||
});
|
||||
|
||||
logger.info('REDCap: getAllRecordsByEvent success', {
|
||||
recordCount: results.length,
|
||||
eventCount: eventForms.size,
|
||||
});
|
||||
|
||||
return results;
|
||||
} catch (error: any) {
|
||||
logger.error('REDCap: getAllRecordsByEvent failed', { error: error.message });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 计算字段和表单状态相关方法
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 获取所有计算字段列表
|
||||
*
|
||||
* @returns 计算字段信息数组
|
||||
*/
|
||||
async getCalculatedFields(): Promise<Array<{
|
||||
fieldName: string;
|
||||
fieldLabel: string;
|
||||
formName: string;
|
||||
calculation: string;
|
||||
}>> {
|
||||
const metadata = await this.exportMetadata();
|
||||
|
||||
const calcFields = metadata
|
||||
.filter((field: any) => field.field_type === 'calc')
|
||||
.map((field: any) => ({
|
||||
fieldName: field.field_name,
|
||||
fieldLabel: field.field_label,
|
||||
formName: field.form_name,
|
||||
calculation: field.select_choices_or_calculations || '',
|
||||
}));
|
||||
|
||||
logger.info('REDCap: getCalculatedFields success', {
|
||||
calcFieldCount: calcFields.length,
|
||||
});
|
||||
|
||||
return calcFields;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取记录的表单完成状态
|
||||
*
|
||||
* REDCap 表单状态值:
|
||||
* - 0: Incomplete(红色)
|
||||
* - 1: Unverified(黄色)
|
||||
* - 2: Complete(绿色)
|
||||
*
|
||||
* @param recordId 记录ID(可选,不提供则获取所有记录)
|
||||
* @returns 表单完成状态
|
||||
*/
|
||||
async getFormCompletionStatus(recordId?: string): Promise<Array<{
|
||||
recordId: string;
|
||||
forms: Record<string, {
|
||||
status: number;
|
||||
statusLabel: 'Incomplete' | 'Unverified' | 'Complete';
|
||||
}>;
|
||||
allComplete: boolean;
|
||||
}>> {
|
||||
// 获取表单列表
|
||||
const metadata = await this.exportMetadata();
|
||||
const formNames = [...new Set(metadata.map((f: any) => f.form_name))];
|
||||
|
||||
// 构建 _complete 字段列表
|
||||
const completeFields = formNames.map(form => `${form}_complete`);
|
||||
|
||||
// 获取记录数据
|
||||
const options: any = { fields: ['record_id', ...completeFields] };
|
||||
if (recordId) {
|
||||
options.records = [recordId];
|
||||
}
|
||||
|
||||
const records = await this.exportRecords(options);
|
||||
|
||||
// 按 record_id 分组(处理多事件)
|
||||
const recordGroups = new Map<string, Record<string, any>>();
|
||||
for (const record of records) {
|
||||
const id = record.record_id;
|
||||
if (!recordGroups.has(id)) {
|
||||
recordGroups.set(id, {});
|
||||
}
|
||||
// 合并(取最大值,即最完整的状态)
|
||||
const existing = recordGroups.get(id)!;
|
||||
for (const field of completeFields) {
|
||||
const newValue = parseInt(record[field]) || 0;
|
||||
const existingValue = parseInt(existing[field]) || 0;
|
||||
existing[field] = Math.max(newValue, existingValue);
|
||||
}
|
||||
}
|
||||
|
||||
// 转换为结果格式
|
||||
const statusLabels: Record<number, 'Incomplete' | 'Unverified' | 'Complete'> = {
|
||||
0: 'Incomplete',
|
||||
1: 'Unverified',
|
||||
2: 'Complete',
|
||||
};
|
||||
|
||||
const results: Array<{
|
||||
recordId: string;
|
||||
forms: Record<string, { status: number; statusLabel: 'Incomplete' | 'Unverified' | 'Complete' }>;
|
||||
allComplete: boolean;
|
||||
}> = [];
|
||||
|
||||
for (const [recId, data] of recordGroups) {
|
||||
const forms: Record<string, { status: number; statusLabel: 'Incomplete' | 'Unverified' | 'Complete' }> = {};
|
||||
let allComplete = true;
|
||||
|
||||
for (const formName of formNames) {
|
||||
const status = parseInt(data[`${formName}_complete`]) || 0;
|
||||
forms[formName] = {
|
||||
status,
|
||||
statusLabel: statusLabels[status] || 'Incomplete',
|
||||
};
|
||||
if (status !== 2) {
|
||||
allComplete = false;
|
||||
}
|
||||
}
|
||||
|
||||
results.push({
|
||||
recordId: recId,
|
||||
forms,
|
||||
allComplete,
|
||||
});
|
||||
}
|
||||
|
||||
logger.info('REDCap: getFormCompletionStatus success', {
|
||||
recordCount: results.length,
|
||||
formCount: formNames.length,
|
||||
});
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查指定表单是否完成
|
||||
*
|
||||
* @param recordId 记录ID
|
||||
* @param formName 表单名称
|
||||
* @returns 表单状态
|
||||
*/
|
||||
async isFormComplete(recordId: string, formName: string): Promise<{
|
||||
status: number;
|
||||
statusLabel: 'Incomplete' | 'Unverified' | 'Complete';
|
||||
isComplete: boolean;
|
||||
}> {
|
||||
const completeField = `${formName}_complete`;
|
||||
const records = await this.exportRecords({
|
||||
records: [recordId],
|
||||
fields: ['record_id', completeField],
|
||||
});
|
||||
|
||||
if (records.length === 0) {
|
||||
return { status: 0, statusLabel: 'Incomplete', isComplete: false };
|
||||
}
|
||||
|
||||
// 取所有事件中的最大值
|
||||
let maxStatus = 0;
|
||||
for (const record of records) {
|
||||
const status = parseInt(record[completeField]) || 0;
|
||||
maxStatus = Math.max(maxStatus, status);
|
||||
}
|
||||
|
||||
const statusLabels: Record<number, 'Incomplete' | 'Unverified' | 'Complete'> = {
|
||||
0: 'Incomplete',
|
||||
1: 'Unverified',
|
||||
2: 'Complete',
|
||||
};
|
||||
|
||||
return {
|
||||
status: maxStatus,
|
||||
statusLabel: statusLabels[maxStatus] || 'Incomplete',
|
||||
isComplete: maxStatus === 2,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取表单-事件映射(纵向研究)
|
||||
*
|
||||
* @returns 表单-事件映射列表
|
||||
*/
|
||||
async getFormEventMapping(): Promise<Array<{
|
||||
eventName: string;
|
||||
eventLabel: string;
|
||||
formName: string;
|
||||
}>> {
|
||||
const formData = new FormData();
|
||||
formData.append('token', this.apiToken);
|
||||
formData.append('content', 'formEventMapping');
|
||||
formData.append('format', 'json');
|
||||
|
||||
try {
|
||||
const response = await this.client.post(`${this.baseUrl}/api/`, formData, {
|
||||
headers: formData.getHeaders()
|
||||
});
|
||||
|
||||
if (!Array.isArray(response.data)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 获取事件标签
|
||||
const events = await this.getEvents();
|
||||
const eventLabels = new Map<string, string>();
|
||||
for (const event of events) {
|
||||
eventLabels.set(event.unique_event_name, event.event_name);
|
||||
}
|
||||
|
||||
const mapping = response.data.map((m: any) => ({
|
||||
eventName: m.unique_event_name,
|
||||
eventLabel: eventLabels.get(m.unique_event_name) || m.unique_event_name,
|
||||
formName: m.form,
|
||||
}));
|
||||
|
||||
logger.info('REDCap: getFormEventMapping success', {
|
||||
mappingCount: mapping.length,
|
||||
});
|
||||
|
||||
return mapping;
|
||||
} catch (error: any) {
|
||||
logger.error('REDCap: getFormEventMapping failed', { error: error.message });
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取事件列表(纵向研究)
|
||||
*
|
||||
* @returns 事件列表
|
||||
*/
|
||||
async getEvents(): Promise<Array<{
|
||||
unique_event_name: string;
|
||||
event_name: string;
|
||||
arm_num: number;
|
||||
}>> {
|
||||
const formData = new FormData();
|
||||
formData.append('token', this.apiToken);
|
||||
formData.append('content', 'event');
|
||||
formData.append('format', 'json');
|
||||
|
||||
try {
|
||||
const response = await this.client.post(`${this.baseUrl}/api/`, formData, {
|
||||
headers: formData.getHeaders()
|
||||
});
|
||||
|
||||
if (!Array.isArray(response.data)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
logger.info('REDCap: getEvents success', {
|
||||
eventCount: response.data.length,
|
||||
});
|
||||
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
logger.error('REDCap: getEvents failed', { error: error.message });
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取记录的表单完成状态(按事件维度)
|
||||
*
|
||||
* 返回每个记录在每个事件中每个表单的完成状态
|
||||
* 这是纵向研究的正确统计方式
|
||||
*
|
||||
* @param recordId 记录ID(可选)
|
||||
* @returns 事件维度的表单完成状态
|
||||
*/
|
||||
async getFormCompletionStatusByEvent(recordId?: string): Promise<Array<{
|
||||
recordId: string;
|
||||
eventName: string;
|
||||
eventLabel: string;
|
||||
forms: Record<string, {
|
||||
status: number;
|
||||
statusLabel: 'Incomplete' | 'Unverified' | 'Complete';
|
||||
}>;
|
||||
eventComplete: boolean; // 该事件的所有表单是否都完成
|
||||
}>> {
|
||||
// 获取表单-事件映射
|
||||
const formEventMapping = await this.getFormEventMapping();
|
||||
|
||||
// 按事件分组表单
|
||||
const eventForms = new Map<string, Set<string>>();
|
||||
const eventLabels = new Map<string, string>();
|
||||
for (const m of formEventMapping) {
|
||||
if (!eventForms.has(m.eventName)) {
|
||||
eventForms.set(m.eventName, new Set());
|
||||
eventLabels.set(m.eventName, m.eventLabel);
|
||||
}
|
||||
eventForms.get(m.eventName)!.add(m.formName);
|
||||
}
|
||||
|
||||
// 获取所有表单的 _complete 字段
|
||||
const allForms = [...new Set(formEventMapping.map(m => m.formName))];
|
||||
const completeFields = allForms.map(f => `${f}_complete`);
|
||||
|
||||
// 获取记录数据
|
||||
// 注意:redcap_event_name 是自动返回的元数据字段,不需要在 fields 中请求
|
||||
const options: any = {
|
||||
fields: ['record_id', ...completeFields]
|
||||
};
|
||||
if (recordId) {
|
||||
options.records = [recordId];
|
||||
}
|
||||
|
||||
const records = await this.exportRecords(options);
|
||||
|
||||
const statusLabels: Record<number, 'Incomplete' | 'Unverified' | 'Complete'> = {
|
||||
0: 'Incomplete',
|
||||
1: 'Unverified',
|
||||
2: 'Complete',
|
||||
};
|
||||
|
||||
const results: Array<{
|
||||
recordId: string;
|
||||
eventName: string;
|
||||
eventLabel: string;
|
||||
forms: Record<string, { status: number; statusLabel: 'Incomplete' | 'Unverified' | 'Complete' }>;
|
||||
eventComplete: boolean;
|
||||
}> = [];
|
||||
|
||||
for (const record of records) {
|
||||
const recId = record.record_id;
|
||||
const eventName = record.redcap_event_name;
|
||||
|
||||
if (!eventName || !eventForms.has(eventName)) continue;
|
||||
|
||||
const formsInEvent = eventForms.get(eventName)!;
|
||||
const forms: Record<string, { status: number; statusLabel: 'Incomplete' | 'Unverified' | 'Complete' }> = {};
|
||||
let eventComplete = true;
|
||||
|
||||
for (const formName of formsInEvent) {
|
||||
const status = parseInt(record[`${formName}_complete`]) || 0;
|
||||
forms[formName] = {
|
||||
status,
|
||||
statusLabel: statusLabels[status] || 'Incomplete',
|
||||
};
|
||||
if (status !== 2) {
|
||||
eventComplete = false;
|
||||
}
|
||||
}
|
||||
|
||||
results.push({
|
||||
recordId: recId,
|
||||
eventName,
|
||||
eventLabel: eventLabels.get(eventName) || eventName,
|
||||
forms,
|
||||
eventComplete,
|
||||
});
|
||||
}
|
||||
|
||||
// 按 recordId 和事件顺序排序
|
||||
results.sort((a, b) => {
|
||||
const idA = parseInt(a.recordId) || 0;
|
||||
const idB = parseInt(b.recordId) || 0;
|
||||
if (idA !== idB) return idA - idB;
|
||||
return a.eventName.localeCompare(b.eventName);
|
||||
});
|
||||
|
||||
logger.info('REDCap: getFormCompletionStatusByEvent success', {
|
||||
resultCount: results.length,
|
||||
eventCount: eventForms.size,
|
||||
});
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,21 +34,36 @@ export interface QCRule {
|
||||
severity: 'error' | 'warning' | 'info';
|
||||
category: 'inclusion' | 'exclusion' | 'lab_values' | 'logic_check';
|
||||
metadata?: Record<string, any>;
|
||||
|
||||
// V3.1: 事件级质控支持
|
||||
/** 适用的事件列表,空数组或不设置表示适用所有事件 */
|
||||
applicableEvents?: string[];
|
||||
/** 适用的表单列表,空数组或不设置表示适用所有表单 */
|
||||
applicableForms?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 单条规则执行结果
|
||||
*
|
||||
* V2.1 优化:支持 LLM 友好的"自包含"格式
|
||||
*/
|
||||
export interface RuleResult {
|
||||
ruleId: string;
|
||||
ruleName: string;
|
||||
field: string | string[];
|
||||
passed: boolean;
|
||||
message: string;
|
||||
message: string; // 基础消息
|
||||
llmMessage?: string; // V2.1: LLM 友好的自包含消息
|
||||
severity: 'error' | 'warning' | 'info';
|
||||
category: string;
|
||||
actualValue?: any;
|
||||
expectedCondition?: string;
|
||||
actualValue?: any; // 实际值
|
||||
expectedValue?: string; // V2.1: 期望值/标准(人类可读)
|
||||
expectedCondition?: string; // JSON Logic 描述
|
||||
evidence?: { // V2.1: 结构化证据
|
||||
value: any;
|
||||
threshold?: string;
|
||||
unit?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -218,6 +233,8 @@ export class HardRuleEngine {
|
||||
|
||||
/**
|
||||
* 执行单条规则
|
||||
*
|
||||
* V2.1 优化:生成自包含的 LLM 友好消息
|
||||
*/
|
||||
private executeRule(rule: QCRule, data: Record<string, any>): RuleResult {
|
||||
try {
|
||||
@@ -227,16 +244,35 @@ export class HardRuleEngine {
|
||||
// 执行 JSON Logic
|
||||
const passed = jsonLogic.apply(rule.logic, data) as boolean;
|
||||
|
||||
// V2.1: 解析期望值(从 JSON Logic 中提取)
|
||||
const expectedValue = this.extractExpectedValue(rule.logic);
|
||||
const expectedCondition = this.describeLogic(rule.logic);
|
||||
|
||||
// V2.1: 构建自包含的 LLM 友好消息
|
||||
const llmMessage = passed
|
||||
? '通过'
|
||||
: this.buildLlmMessage(rule, fieldValue, expectedValue);
|
||||
|
||||
// V2.1: 构建结构化证据
|
||||
const evidence = {
|
||||
value: fieldValue,
|
||||
threshold: expectedValue,
|
||||
unit: (rule.metadata as any)?.unit,
|
||||
};
|
||||
|
||||
return {
|
||||
ruleId: rule.id,
|
||||
ruleName: rule.name,
|
||||
field: rule.field,
|
||||
passed,
|
||||
message: passed ? '通过' : rule.message,
|
||||
llmMessage,
|
||||
severity: rule.severity,
|
||||
category: rule.category,
|
||||
actualValue: fieldValue,
|
||||
expectedCondition: this.describeLogic(rule.logic)
|
||||
expectedValue,
|
||||
expectedCondition,
|
||||
evidence,
|
||||
};
|
||||
|
||||
} catch (error: any) {
|
||||
@@ -251,12 +287,68 @@ export class HardRuleEngine {
|
||||
field: rule.field,
|
||||
passed: false,
|
||||
message: `规则执行出错: ${error.message}`,
|
||||
llmMessage: `规则执行出错: ${error.message}`,
|
||||
severity: 'error',
|
||||
category: rule.category
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 逻辑,尝试提取范围
|
||||
const values = args.map((a: any) => this.extractExpectedValue(a)).filter(Boolean);
|
||||
if (values.length === 2) {
|
||||
return `${values[0]}-${values[1]}`;
|
||||
}
|
||||
return values.join(', ');
|
||||
case '!!':
|
||||
return '非空/必填';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* V2.1: 构建 LLM 友好的自包含消息
|
||||
*
|
||||
* 格式:当前 **{actualValue}** (标准: {expectedValue})
|
||||
*/
|
||||
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}**`
|
||||
: '**空**';
|
||||
|
||||
// 根据规则类别生成不同的消息格式
|
||||
switch (rule.category) {
|
||||
case 'inclusion':
|
||||
return `**${rule.name}**: 当前值 ${displayValue} (入排标准: ${expectedValue || rule.message})`;
|
||||
case 'exclusion':
|
||||
return `**${rule.name}**: 当前值 ${displayValue} 触发排除条件`;
|
||||
case 'lab_values':
|
||||
return `**${rule.name}**: 当前值 ${displayValue} (正常范围: ${expectedValue})`;
|
||||
case 'logic_check':
|
||||
return `**${rule.name}**: \`${fieldName}\` = ${displayValue} (要求: ${expectedValue || '非空'})`;
|
||||
default:
|
||||
return `**${rule.name}**: 当前值 ${displayValue} (标准: ${expectedValue || rule.message})`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取字段值(支持映射)
|
||||
*/
|
||||
|
||||
755
backend/src/modules/iit-manager/engines/SkillRunner.ts
Normal file
755
backend/src/modules/iit-manager/engines/SkillRunner.ts
Normal file
@@ -0,0 +1,755 @@
|
||||
/**
|
||||
* SkillRunner - 规则调度器
|
||||
*
|
||||
* 功能:
|
||||
* - 根据触发类型加载和执行 Skills
|
||||
* - 协调 HardRuleEngine 和 SoftRuleEngine
|
||||
* - 实现漏斗式执行策略(Blocking → Hard → Soft)
|
||||
* - 聚合质控结果
|
||||
*
|
||||
* 设计原则:
|
||||
* - 可插拔:通过 Skill 配置动态加载规则
|
||||
* - 成本控制:阻断性检查优先,失败则跳过 AI 检查
|
||||
* - 统一入口:所有触发类型使用相同的执行逻辑
|
||||
*/
|
||||
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
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();
|
||||
|
||||
// ============================================================
|
||||
// 类型定义
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 触发类型
|
||||
*/
|
||||
export type TriggerType = 'webhook' | 'cron' | 'manual';
|
||||
|
||||
/**
|
||||
* 规则类型
|
||||
*/
|
||||
export type RuleType = 'HARD_RULE' | 'LLM_CHECK' | 'HYBRID';
|
||||
|
||||
/**
|
||||
* Skill 执行结果
|
||||
*/
|
||||
export interface SkillResult {
|
||||
skillId: string;
|
||||
skillName: string;
|
||||
skillType: string;
|
||||
ruleType: RuleType;
|
||||
status: 'PASS' | 'FAIL' | 'WARNING' | 'UNCERTAIN';
|
||||
issues: SkillIssue[];
|
||||
executionTimeMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 问题项
|
||||
*/
|
||||
export interface SkillIssue {
|
||||
ruleId: string;
|
||||
ruleName: string;
|
||||
field?: string | string[];
|
||||
message: string;
|
||||
llmMessage?: string; // V2.1: LLM 友好的自包含消息
|
||||
severity: 'critical' | 'warning' | 'info';
|
||||
actualValue?: any;
|
||||
expectedValue?: string; // V2.1: 期望值(人类可读)
|
||||
evidence?: Record<string, any>; // V2.1: 结构化证据
|
||||
confidence?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* SkillRunner 执行结果
|
||||
*
|
||||
* V3.1: 支持事件级质控,每个 record+event 作为独立单元
|
||||
*/
|
||||
export interface SkillRunResult {
|
||||
projectId: string;
|
||||
recordId: string;
|
||||
// V3.1: 事件级质控支持
|
||||
eventName?: string; // REDCap 事件唯一标识
|
||||
eventLabel?: string; // 事件显示名称(如"筛选期")
|
||||
forms?: string[]; // 该事件包含的表单列表
|
||||
triggerType: TriggerType;
|
||||
timestamp: string;
|
||||
overallStatus: 'PASS' | 'FAIL' | 'WARNING' | 'UNCERTAIN';
|
||||
summary: {
|
||||
totalSkills: number;
|
||||
passed: number;
|
||||
failed: number;
|
||||
warnings: number;
|
||||
uncertain: number;
|
||||
blockedByLevel1: boolean;
|
||||
};
|
||||
skillResults: SkillResult[];
|
||||
allIssues: SkillIssue[];
|
||||
criticalIssues: SkillIssue[];
|
||||
warningIssues: SkillIssue[];
|
||||
executionTimeMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* SkillRunner 选项
|
||||
*
|
||||
* V3.1: 支持事件级过滤
|
||||
*/
|
||||
export interface SkillRunnerOptions {
|
||||
recordId?: string;
|
||||
eventName?: string; // V3.1: 指定事件
|
||||
formName?: string;
|
||||
skipSoftRules?: boolean; // 跳过 LLM 检查(用于快速检查)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// SkillRunner 实现
|
||||
// ============================================================
|
||||
|
||||
export class SkillRunner {
|
||||
private projectId: string;
|
||||
private redcapAdapter?: RedcapAdapter;
|
||||
|
||||
constructor(projectId: string) {
|
||||
this.projectId = projectId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化 REDCap 适配器
|
||||
*/
|
||||
private async initRedcapAdapter(): Promise<RedcapAdapter> {
|
||||
if (this.redcapAdapter) {
|
||||
return this.redcapAdapter;
|
||||
}
|
||||
|
||||
const project = await prisma.iitProject.findUnique({
|
||||
where: { id: this.projectId },
|
||||
select: { redcapUrl: true, redcapApiToken: true },
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw new Error(`项目不存在: ${this.projectId}`);
|
||||
}
|
||||
|
||||
this.redcapAdapter = new RedcapAdapter(project.redcapUrl, project.redcapApiToken);
|
||||
return this.redcapAdapter;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按触发类型执行 Skills
|
||||
*
|
||||
* @param triggerType 触发类型
|
||||
* @param options 执行选项
|
||||
* @returns 执行结果
|
||||
*/
|
||||
async runByTrigger(
|
||||
triggerType: TriggerType,
|
||||
options?: SkillRunnerOptions
|
||||
): Promise<SkillRunResult[]> {
|
||||
const startTime = Date.now();
|
||||
|
||||
logger.info('[SkillRunner] Starting execution', {
|
||||
projectId: this.projectId,
|
||||
triggerType,
|
||||
options,
|
||||
});
|
||||
|
||||
// 1. 加载启用的 Skills
|
||||
const skills = await this.loadSkills(triggerType, options?.formName);
|
||||
|
||||
if (skills.length === 0) {
|
||||
logger.warn('[SkillRunner] No active skills found', {
|
||||
projectId: this.projectId,
|
||||
triggerType,
|
||||
});
|
||||
return [];
|
||||
}
|
||||
|
||||
// 2. 按优先级排序(priority 越小越优先,blocking 级别最优先)
|
||||
skills.sort((a, b) => {
|
||||
if (a.level === 'blocking' && b.level !== 'blocking') return -1;
|
||||
if (a.level !== 'blocking' && b.level === 'blocking') return 1;
|
||||
return a.priority - b.priority;
|
||||
});
|
||||
|
||||
// 3. 获取要处理的记录(V3.1: 事件级数据)
|
||||
const records = await this.getRecordsToProcess(options);
|
||||
|
||||
// 4. 对每条记录+事件执行所有 Skills
|
||||
const results: SkillRunResult[] = [];
|
||||
for (const record of records) {
|
||||
const result = await this.executeSkillsForRecord(
|
||||
record.recordId,
|
||||
record.eventName,
|
||||
record.eventLabel,
|
||||
record.forms,
|
||||
record.data,
|
||||
skills,
|
||||
triggerType,
|
||||
options
|
||||
);
|
||||
results.push(result);
|
||||
|
||||
// 保存质控日志
|
||||
await this.saveQcLog(result);
|
||||
}
|
||||
|
||||
const totalTime = Date.now() - startTime;
|
||||
logger.info('[SkillRunner] Execution completed', {
|
||||
projectId: this.projectId,
|
||||
triggerType,
|
||||
recordEventCount: records.length,
|
||||
totalTimeMs: totalTime,
|
||||
});
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载 Skills
|
||||
*/
|
||||
private async loadSkills(
|
||||
triggerType: TriggerType,
|
||||
formName?: string
|
||||
): Promise<Array<{
|
||||
id: string;
|
||||
skillType: string;
|
||||
name: string;
|
||||
ruleType: string;
|
||||
level: string;
|
||||
priority: number;
|
||||
config: any;
|
||||
requiredTags: string[];
|
||||
}>> {
|
||||
const where: any = {
|
||||
projectId: this.projectId,
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
// 根据触发类型过滤
|
||||
if (triggerType === 'webhook') {
|
||||
where.triggerType = 'webhook';
|
||||
} else if (triggerType === 'cron') {
|
||||
where.triggerType = { in: ['cron', 'webhook'] }; // Cron 也执行 webhook 规则
|
||||
}
|
||||
// manual 执行所有规则
|
||||
|
||||
const skills = await prisma.iitSkill.findMany({
|
||||
where,
|
||||
select: {
|
||||
id: true,
|
||||
skillType: true,
|
||||
name: true,
|
||||
ruleType: true,
|
||||
level: true,
|
||||
priority: true,
|
||||
config: true,
|
||||
requiredTags: true,
|
||||
},
|
||||
});
|
||||
|
||||
// 如果指定了 formName,过滤相关的 Skills
|
||||
if (formName) {
|
||||
return skills.filter(skill => {
|
||||
const config = skill.config as any;
|
||||
// 检查规则中是否有与该表单相关的规则
|
||||
if (config?.rules) {
|
||||
return config.rules.some((rule: any) =>
|
||||
!rule.formName || rule.formName === formName
|
||||
);
|
||||
}
|
||||
return true; // 没有 formName 限制的规则默认包含
|
||||
});
|
||||
}
|
||||
|
||||
return skills;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取要处理的记录(事件级别)
|
||||
*
|
||||
* V3.1: 返回事件级数据,每个 record+event 作为独立单元
|
||||
* 不再合并事件数据,确保每个访视独立质控
|
||||
*/
|
||||
private async getRecordsToProcess(
|
||||
options?: SkillRunnerOptions
|
||||
): Promise<Array<{
|
||||
recordId: string;
|
||||
eventName: string;
|
||||
eventLabel: string;
|
||||
forms: string[];
|
||||
data: Record<string, any>;
|
||||
}>> {
|
||||
const adapter = await this.initRedcapAdapter();
|
||||
|
||||
// V3.1: 使用 getAllRecordsByEvent 获取事件级数据
|
||||
const eventRecords = await adapter.getAllRecordsByEvent({
|
||||
recordId: options?.recordId,
|
||||
eventName: options?.eventName,
|
||||
});
|
||||
|
||||
return eventRecords.map(r => ({
|
||||
recordId: r.recordId,
|
||||
eventName: r.eventName,
|
||||
eventLabel: r.eventLabel,
|
||||
forms: r.forms,
|
||||
data: r.data,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 对单条记录+事件执行所有 Skills
|
||||
*
|
||||
* V3.1: 支持事件级质控,根据规则配置过滤适用的规则
|
||||
*/
|
||||
private async executeSkillsForRecord(
|
||||
recordId: string,
|
||||
eventName: string,
|
||||
eventLabel: string,
|
||||
forms: string[],
|
||||
data: Record<string, any>,
|
||||
skills: Array<{
|
||||
id: string;
|
||||
skillType: string;
|
||||
name: string;
|
||||
ruleType: string;
|
||||
level: string;
|
||||
priority: number;
|
||||
config: any;
|
||||
requiredTags: string[];
|
||||
}>,
|
||||
triggerType: TriggerType,
|
||||
options?: SkillRunnerOptions
|
||||
): Promise<SkillRunResult> {
|
||||
const startTime = Date.now();
|
||||
const skillResults: SkillResult[] = [];
|
||||
const allIssues: SkillIssue[] = [];
|
||||
const criticalIssues: SkillIssue[] = [];
|
||||
const warningIssues: SkillIssue[] = [];
|
||||
let blockedByLevel1 = false;
|
||||
|
||||
// 漏斗式执行
|
||||
for (const skill of skills) {
|
||||
const ruleType = skill.ruleType as RuleType;
|
||||
|
||||
// 如果已被阻断且当前不是 blocking 级别,跳过 LLM 检查
|
||||
if (blockedByLevel1 && ruleType === 'LLM_CHECK') {
|
||||
logger.debug('[SkillRunner] Skipping LLM check due to blocking failure', {
|
||||
skillId: skill.id,
|
||||
recordId,
|
||||
eventName,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// 如果选项要求跳过软规则
|
||||
if (options?.skipSoftRules && ruleType === 'LLM_CHECK') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// V3.1: 执行 Skill(传入事件和表单信息用于规则过滤)
|
||||
const result = await this.executeSkill(skill, recordId, eventName, forms, data);
|
||||
skillResults.push(result);
|
||||
|
||||
// 收集问题
|
||||
for (const issue of result.issues) {
|
||||
allIssues.push(issue);
|
||||
if (issue.severity === 'critical') {
|
||||
criticalIssues.push(issue);
|
||||
} else if (issue.severity === 'warning') {
|
||||
warningIssues.push(issue);
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否触发阻断
|
||||
if (skill.level === 'blocking' && result.status === 'FAIL') {
|
||||
blockedByLevel1 = true;
|
||||
logger.info('[SkillRunner] Blocking check failed, skipping AI checks', {
|
||||
skillId: skill.id,
|
||||
recordId,
|
||||
eventName,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 计算整体状态
|
||||
let overallStatus: 'PASS' | 'FAIL' | 'WARNING' | 'UNCERTAIN' = 'PASS';
|
||||
if (criticalIssues.length > 0) {
|
||||
overallStatus = 'FAIL';
|
||||
} else if (skillResults.some(r => r.status === 'UNCERTAIN')) {
|
||||
overallStatus = 'UNCERTAIN';
|
||||
} else if (warningIssues.length > 0) {
|
||||
overallStatus = 'WARNING';
|
||||
}
|
||||
|
||||
const executionTimeMs = Date.now() - startTime;
|
||||
|
||||
return {
|
||||
projectId: this.projectId,
|
||||
recordId,
|
||||
// V3.1: 包含事件信息
|
||||
eventName,
|
||||
eventLabel,
|
||||
forms,
|
||||
triggerType,
|
||||
timestamp: new Date().toISOString(),
|
||||
overallStatus,
|
||||
summary: {
|
||||
totalSkills: skillResults.length,
|
||||
passed: skillResults.filter(r => r.status === 'PASS').length,
|
||||
failed: skillResults.filter(r => r.status === 'FAIL').length,
|
||||
warnings: skillResults.filter(r => r.status === 'WARNING').length,
|
||||
uncertain: skillResults.filter(r => r.status === 'UNCERTAIN').length,
|
||||
blockedByLevel1,
|
||||
},
|
||||
skillResults,
|
||||
allIssues,
|
||||
criticalIssues,
|
||||
warningIssues,
|
||||
executionTimeMs,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行单个 Skill
|
||||
*
|
||||
* V3.1: 支持事件级质控,根据规则配置过滤适用的规则
|
||||
*/
|
||||
private async executeSkill(
|
||||
skill: {
|
||||
id: string;
|
||||
skillType: string;
|
||||
name: string;
|
||||
ruleType: string;
|
||||
config: any;
|
||||
requiredTags: string[];
|
||||
},
|
||||
recordId: string,
|
||||
eventName: string,
|
||||
forms: string[],
|
||||
data: Record<string, any>
|
||||
): Promise<SkillResult> {
|
||||
const startTime = Date.now();
|
||||
const ruleType = skill.ruleType as RuleType;
|
||||
const config = skill.config as any;
|
||||
const issues: SkillIssue[] = [];
|
||||
let status: 'PASS' | 'FAIL' | 'WARNING' | 'UNCERTAIN' = 'PASS';
|
||||
|
||||
try {
|
||||
if (ruleType === 'HARD_RULE') {
|
||||
// 使用 HardRuleEngine
|
||||
const engine = await createHardRuleEngine(this.projectId);
|
||||
|
||||
// 临时注入规则(如果 config 中有)
|
||||
if (config?.rules) {
|
||||
// V3.1: 过滤适用于当前事件/表单的规则
|
||||
const allRules = config.rules as QCRule[];
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
} else if (ruleType === 'LLM_CHECK') {
|
||||
// 使用 SoftRuleEngine
|
||||
const engine = createSoftRuleEngine(this.projectId, {
|
||||
model: config?.model || 'deepseek-v3',
|
||||
});
|
||||
|
||||
// V3.1: 过滤适用于当前事件/表单的检查
|
||||
const rawChecks = config?.checks || [];
|
||||
const applicableChecks = this.filterApplicableRules(rawChecks, eventName, forms);
|
||||
|
||||
const checks: SoftRuleCheck[] = applicableChecks.map((check: any) => ({
|
||||
id: check.id,
|
||||
name: check.name || check.desc,
|
||||
description: check.desc,
|
||||
promptTemplate: check.promptTemplate || check.prompt,
|
||||
requiredTags: check.requiredTags || skill.requiredTags || [],
|
||||
category: check.category || 'medical_logic',
|
||||
severity: check.severity || 'warning',
|
||||
applicableEvents: check.applicableEvents,
|
||||
applicableForms: check.applicableForms,
|
||||
}));
|
||||
|
||||
if (checks.length > 0) {
|
||||
const result = await engine.execute(recordId, data, checks);
|
||||
|
||||
for (const checkResult of result.results) {
|
||||
if (checkResult.status !== 'PASS') {
|
||||
issues.push({
|
||||
ruleId: checkResult.checkId,
|
||||
ruleName: checkResult.checkName,
|
||||
message: checkResult.reason,
|
||||
severity: checkResult.severity,
|
||||
evidence: checkResult.evidence,
|
||||
confidence: checkResult.confidence,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (result.overallStatus === 'FAIL') {
|
||||
status = 'FAIL';
|
||||
} else if (result.overallStatus === 'UNCERTAIN') {
|
||||
status = 'UNCERTAIN';
|
||||
}
|
||||
}
|
||||
|
||||
} else if (ruleType === 'HYBRID') {
|
||||
// 混合模式:先执行硬规则,再执行软规则
|
||||
// TODO: 实现混合逻辑
|
||||
logger.warn('[SkillRunner] Hybrid rules not yet implemented', {
|
||||
skillId: skill.id,
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error('[SkillRunner] Skill execution error', {
|
||||
skillId: skill.id,
|
||||
error: error.message,
|
||||
});
|
||||
status = 'UNCERTAIN';
|
||||
issues.push({
|
||||
ruleId: 'EXECUTION_ERROR',
|
||||
ruleName: '执行错误',
|
||||
message: `Skill 执行出错: ${error.message}`,
|
||||
severity: 'warning',
|
||||
});
|
||||
}
|
||||
|
||||
const executionTimeMs = Date.now() - startTime;
|
||||
|
||||
return {
|
||||
skillId: skill.id,
|
||||
skillName: skill.name,
|
||||
skillType: skill.skillType,
|
||||
ruleType,
|
||||
status,
|
||||
issues,
|
||||
executionTimeMs,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 直接执行硬规则(不通过 HardRuleEngine 初始化)
|
||||
*
|
||||
* V2.1 优化:添加 expectedValue, llmMessage, evidence 字段
|
||||
*/
|
||||
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 {
|
||||
const passed = jsonLogic.apply(rule.logic, data);
|
||||
|
||||
if (!passed) {
|
||||
const severity = rule.severity === 'error' ? 'critical' : 'warning';
|
||||
const actualValue = this.getFieldValue(rule.field, data);
|
||||
|
||||
// V2.1: 提取期望值
|
||||
const expectedValue = this.extractExpectedValue(rule.logic);
|
||||
|
||||
// V2.1: 构建自包含的 LLM 友好消息
|
||||
const llmMessage = this.buildLlmMessage(rule, actualValue, expectedValue);
|
||||
|
||||
issues.push({
|
||||
ruleId: rule.id,
|
||||
ruleName: rule.name,
|
||||
field: rule.field,
|
||||
message: rule.message,
|
||||
llmMessage, // V2.1: 自包含消息
|
||||
severity,
|
||||
actualValue,
|
||||
expectedValue, // V2.1: 期望值
|
||||
evidence: { // V2.1: 结构化证据
|
||||
value: actualValue,
|
||||
threshold: expectedValue,
|
||||
unit: (rule.metadata as any)?.unit,
|
||||
},
|
||||
});
|
||||
|
||||
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: 过滤适用于当前事件/表单的规则
|
||||
*
|
||||
* 规则配置可以包含:
|
||||
* - applicableEvents: 适用的事件列表(空数组或不设置表示适用所有事件)
|
||||
* - applicableForms: 适用的表单列表(空数组或不设置表示适用所有表单)
|
||||
*
|
||||
* @param rules 所有规则
|
||||
* @param eventName 当前事件名称
|
||||
* @param forms 当前事件包含的表单列表
|
||||
* @returns 适用于当前事件/表单的规则
|
||||
*/
|
||||
private filterApplicableRules<T extends { applicableEvents?: string[]; applicableForms?: string[] }>(
|
||||
rules: T[],
|
||||
eventName: string,
|
||||
forms: string[]
|
||||
): T[] {
|
||||
return rules.filter(rule => {
|
||||
// 检查事件是否适用
|
||||
const eventMatch = !rule.applicableEvents ||
|
||||
rule.applicableEvents.length === 0 ||
|
||||
rule.applicableEvents.includes(eventName);
|
||||
|
||||
if (!eventMatch) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查表单是否适用
|
||||
const formMatch = !rule.applicableForms ||
|
||||
rule.applicableForms.length === 0 ||
|
||||
rule.applicableForms.some(f => forms.includes(f));
|
||||
|
||||
return formMatch;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存质控日志
|
||||
*/
|
||||
private async saveQcLog(result: SkillRunResult): Promise<void> {
|
||||
try {
|
||||
// 将结果保存到 iit_qc_logs 表
|
||||
// V3.1: 包含事件信息
|
||||
const issuesWithSummary = {
|
||||
items: result.allIssues,
|
||||
summary: result.summary,
|
||||
// V3.1: 事件级质控元数据
|
||||
eventLabel: result.eventLabel,
|
||||
forms: result.forms,
|
||||
};
|
||||
|
||||
await prisma.iitQcLog.create({
|
||||
data: {
|
||||
projectId: result.projectId,
|
||||
recordId: result.recordId,
|
||||
eventId: result.eventName, // V3.1: 保存事件标识
|
||||
qcType: 'event', // V3.1: 事件级质控
|
||||
formName: result.forms?.join(',') || null, // 该事件包含的表单
|
||||
status: result.overallStatus,
|
||||
issues: JSON.parse(JSON.stringify(issuesWithSummary)), // 转换为 JSON 兼容格式
|
||||
ruleVersion: 'v3.1', // V3.1: 事件级质控版本
|
||||
rulesEvaluated: result.summary.totalSkills || 0,
|
||||
rulesPassed: result.summary.passed || 0,
|
||||
rulesFailed: result.summary.failed || 0,
|
||||
rulesSkipped: 0,
|
||||
triggeredBy: result.triggerType,
|
||||
createdAt: new Date(result.timestamp),
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error('[SkillRunner] Failed to save QC log', {
|
||||
recordId: result.recordId,
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 工厂函数
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 创建 SkillRunner 实例
|
||||
*
|
||||
* @param projectId 项目ID
|
||||
* @returns SkillRunner 实例
|
||||
*/
|
||||
export function createSkillRunner(projectId: string): SkillRunner {
|
||||
return new SkillRunner(projectId);
|
||||
}
|
||||
487
backend/src/modules/iit-manager/engines/SoftRuleEngine.ts
Normal file
487
backend/src/modules/iit-manager/engines/SoftRuleEngine.ts
Normal file
@@ -0,0 +1,487 @@
|
||||
/**
|
||||
* SoftRuleEngine - 软规则质控引擎 (LLM 推理)
|
||||
*
|
||||
* 功能:
|
||||
* - 调用 LLM 进行复杂的医学逻辑判断
|
||||
* - 支持入排标准、AE 事件检测、方案偏离等场景
|
||||
* - 返回带证据链的结构化结果
|
||||
*
|
||||
* 设计原则:
|
||||
* - 智能推理:利用 LLM 处理模糊规则和复杂逻辑
|
||||
* - 证据链:每个判断都附带推理过程和证据
|
||||
* - 三态输出:PASS / FAIL / UNCERTAIN(需人工确认)
|
||||
*/
|
||||
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { LLMFactory } from '../../../common/llm/adapters/LLMFactory.js';
|
||||
import { ModelType } from '../../../common/llm/adapters/types.js';
|
||||
import { logger } from '../../../common/logging/index.js';
|
||||
import { buildClinicalSlice } from '../services/PromptBuilder.js';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// ============================================================
|
||||
// 类型定义
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 软规则检查项定义
|
||||
*/
|
||||
export interface SoftRuleCheck {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
promptTemplate: string; // Prompt 模板,支持 {{variable}} 占位符
|
||||
requiredTags: string[]; // 需要加载的数据标签
|
||||
category: 'inclusion' | 'exclusion' | 'ae_detection' | 'protocol_deviation' | 'medical_logic';
|
||||
severity: 'critical' | 'warning';
|
||||
|
||||
// V3.1: 事件级质控支持
|
||||
/** 适用的事件列表,空数组或不设置表示适用所有事件 */
|
||||
applicableEvents?: string[];
|
||||
/** 适用的表单列表,空数组或不设置表示适用所有表单 */
|
||||
applicableForms?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 软规则执行结果
|
||||
*/
|
||||
export interface SoftRuleResult {
|
||||
checkId: string;
|
||||
checkName: string;
|
||||
status: 'PASS' | 'FAIL' | 'UNCERTAIN';
|
||||
reason: string; // LLM 给出的判断理由
|
||||
evidence: Record<string, any>; // 支持判断的证据数据
|
||||
confidence: number; // 置信度 0-1
|
||||
severity: 'critical' | 'warning';
|
||||
category: string;
|
||||
rawResponse?: string; // 原始 LLM 响应(用于调试)
|
||||
}
|
||||
|
||||
/**
|
||||
* 软规则引擎配置
|
||||
*/
|
||||
export interface SoftRuleEngineConfig {
|
||||
model?: ModelType;
|
||||
maxConcurrency?: number;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 软规则引擎执行结果
|
||||
*/
|
||||
export interface SoftRuleEngineResult {
|
||||
recordId: string;
|
||||
projectId: string;
|
||||
timestamp: string;
|
||||
overallStatus: 'PASS' | 'FAIL' | 'UNCERTAIN';
|
||||
summary: {
|
||||
totalChecks: number;
|
||||
passed: number;
|
||||
failed: number;
|
||||
uncertain: number;
|
||||
};
|
||||
results: SoftRuleResult[];
|
||||
failedChecks: SoftRuleResult[];
|
||||
uncertainChecks: SoftRuleResult[];
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// SoftRuleEngine 实现
|
||||
// ============================================================
|
||||
|
||||
export class SoftRuleEngine {
|
||||
private projectId: string;
|
||||
private model: ModelType;
|
||||
private timeoutMs: number;
|
||||
|
||||
constructor(projectId: string, config?: SoftRuleEngineConfig) {
|
||||
this.projectId = projectId;
|
||||
this.model = config?.model || 'deepseek-v3';
|
||||
this.timeoutMs = config?.timeoutMs || 30000;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行软规则检查
|
||||
*
|
||||
* @param recordId 记录ID
|
||||
* @param data 记录数据
|
||||
* @param checks 要执行的检查列表
|
||||
* @returns 检查结果
|
||||
*/
|
||||
async execute(
|
||||
recordId: string,
|
||||
data: Record<string, any>,
|
||||
checks: SoftRuleCheck[]
|
||||
): Promise<SoftRuleEngineResult> {
|
||||
const startTime = Date.now();
|
||||
const results: SoftRuleResult[] = [];
|
||||
const failedChecks: SoftRuleResult[] = [];
|
||||
const uncertainChecks: SoftRuleResult[] = [];
|
||||
|
||||
logger.info('[SoftRuleEngine] Starting execution', {
|
||||
projectId: this.projectId,
|
||||
recordId,
|
||||
checkCount: checks.length,
|
||||
model: this.model,
|
||||
});
|
||||
|
||||
// 逐个执行检查(可以改为并发,但需注意 Token 限制)
|
||||
for (const check of checks) {
|
||||
try {
|
||||
const result = await this.executeCheck(recordId, data, check);
|
||||
results.push(result);
|
||||
|
||||
if (result.status === 'FAIL') {
|
||||
failedChecks.push(result);
|
||||
} else if (result.status === 'UNCERTAIN') {
|
||||
uncertainChecks.push(result);
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error('[SoftRuleEngine] Check execution failed', {
|
||||
checkId: check.id,
|
||||
error: error.message,
|
||||
});
|
||||
|
||||
// 发生错误时标记为 UNCERTAIN
|
||||
const errorResult: SoftRuleResult = {
|
||||
checkId: check.id,
|
||||
checkName: check.name,
|
||||
status: 'UNCERTAIN',
|
||||
reason: `执行出错: ${error.message}`,
|
||||
evidence: {},
|
||||
confidence: 0,
|
||||
severity: check.severity,
|
||||
category: check.category,
|
||||
};
|
||||
results.push(errorResult);
|
||||
uncertainChecks.push(errorResult);
|
||||
}
|
||||
}
|
||||
|
||||
// 计算整体状态
|
||||
let overallStatus: 'PASS' | 'FAIL' | 'UNCERTAIN' = 'PASS';
|
||||
if (failedChecks.length > 0) {
|
||||
overallStatus = 'FAIL';
|
||||
} else if (uncertainChecks.length > 0) {
|
||||
overallStatus = 'UNCERTAIN';
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
logger.info('[SoftRuleEngine] Execution completed', {
|
||||
recordId,
|
||||
overallStatus,
|
||||
totalChecks: checks.length,
|
||||
failed: failedChecks.length,
|
||||
uncertain: uncertainChecks.length,
|
||||
duration: `${duration}ms`,
|
||||
});
|
||||
|
||||
return {
|
||||
recordId,
|
||||
projectId: this.projectId,
|
||||
timestamp: new Date().toISOString(),
|
||||
overallStatus,
|
||||
summary: {
|
||||
totalChecks: checks.length,
|
||||
passed: results.filter(r => r.status === 'PASS').length,
|
||||
failed: failedChecks.length,
|
||||
uncertain: uncertainChecks.length,
|
||||
},
|
||||
results,
|
||||
failedChecks,
|
||||
uncertainChecks,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行单个检查
|
||||
*/
|
||||
private async executeCheck(
|
||||
recordId: string,
|
||||
data: Record<string, any>,
|
||||
check: SoftRuleCheck
|
||||
): Promise<SoftRuleResult> {
|
||||
const startTime = Date.now();
|
||||
|
||||
// 1. 构建 Prompt
|
||||
const prompt = this.buildCheckPrompt(recordId, data, check);
|
||||
|
||||
// 2. 调用 LLM
|
||||
const llmAdapter = LLMFactory.getAdapter(this.model);
|
||||
const response = await llmAdapter.chat([
|
||||
{
|
||||
role: 'system',
|
||||
content: this.getSystemPrompt(),
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: prompt,
|
||||
},
|
||||
]);
|
||||
|
||||
const rawResponse = response.content;
|
||||
|
||||
// 3. 解析响应
|
||||
const parsed = this.parseResponse(rawResponse, check);
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
logger.debug('[SoftRuleEngine] Check executed', {
|
||||
checkId: check.id,
|
||||
status: parsed.status,
|
||||
confidence: parsed.confidence,
|
||||
duration: `${duration}ms`,
|
||||
});
|
||||
|
||||
return {
|
||||
checkId: check.id,
|
||||
checkName: check.name,
|
||||
status: parsed.status,
|
||||
reason: parsed.reason,
|
||||
evidence: parsed.evidence,
|
||||
confidence: parsed.confidence,
|
||||
severity: check.severity,
|
||||
category: check.category,
|
||||
rawResponse,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建检查 Prompt
|
||||
*/
|
||||
private buildCheckPrompt(
|
||||
recordId: string,
|
||||
data: Record<string, any>,
|
||||
check: SoftRuleCheck
|
||||
): string {
|
||||
// 使用 PromptBuilder 生成临床数据切片
|
||||
const clinicalSlice = buildClinicalSlice({
|
||||
task: check.name,
|
||||
criteria: [check.description || check.name],
|
||||
patientData: data,
|
||||
tags: check.requiredTags,
|
||||
instruction: '请根据以下数据进行判断。',
|
||||
});
|
||||
|
||||
// 替换 Prompt 模板中的变量
|
||||
let userPrompt = check.promptTemplate;
|
||||
|
||||
// 替换 {{variable}} 格式的占位符
|
||||
userPrompt = userPrompt.replace(/\{\{(\w+)\}\}/g, (_, key) => {
|
||||
return data[key] !== undefined ? String(data[key]) : `[${key}未提供]`;
|
||||
});
|
||||
|
||||
// 替换 {{#tag}} 格式的数据标签
|
||||
userPrompt = userPrompt.replace(/\{\{#(\w+)\}\}/g, (_, tag) => {
|
||||
// 根据标签筛选相关字段
|
||||
return JSON.stringify(data, null, 2);
|
||||
});
|
||||
|
||||
return `${clinicalSlice}\n\n---\n\n## 检查任务\n\n${userPrompt}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取系统 Prompt
|
||||
*/
|
||||
private getSystemPrompt(): string {
|
||||
return `你是一个专业的临床研究数据监查员 (CRA),负责核查受试者数据的质量和合规性。
|
||||
|
||||
## 你的职责
|
||||
1. 仔细分析提供的临床数据
|
||||
2. 根据检查任务进行判断
|
||||
3. 给出清晰的判断结果和理由
|
||||
|
||||
## 输出格式要求
|
||||
请严格按照以下 JSON 格式输出:
|
||||
|
||||
\`\`\`json
|
||||
{
|
||||
"status": "PASS" | "FAIL" | "UNCERTAIN",
|
||||
"reason": "判断理由的详细说明",
|
||||
"evidence": {
|
||||
"key_field_1": "相关数据值",
|
||||
"key_field_2": "相关数据值"
|
||||
},
|
||||
"confidence": 0.95
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
## 状态说明
|
||||
- **PASS**: 检查通过,数据符合要求
|
||||
- **FAIL**: 检查失败,发现问题
|
||||
- **UNCERTAIN**: 数据不足或存在歧义,需要人工确认
|
||||
|
||||
## 置信度说明
|
||||
- 0.9-1.0: 非常确定
|
||||
- 0.7-0.9: 比较确定
|
||||
- 0.5-0.7: 有一定把握
|
||||
- <0.5: 不太确定,建议人工复核
|
||||
|
||||
请只输出 JSON,不要有其他内容。`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 LLM 响应
|
||||
*/
|
||||
private parseResponse(
|
||||
rawResponse: string,
|
||||
check: SoftRuleCheck
|
||||
): {
|
||||
status: 'PASS' | 'FAIL' | 'UNCERTAIN';
|
||||
reason: string;
|
||||
evidence: Record<string, any>;
|
||||
confidence: number;
|
||||
} {
|
||||
try {
|
||||
// 尝试提取 JSON
|
||||
const jsonMatch = rawResponse.match(/```json\s*([\s\S]*?)\s*```/);
|
||||
const jsonStr = jsonMatch ? jsonMatch[1] : rawResponse;
|
||||
|
||||
const parsed = JSON.parse(jsonStr.trim());
|
||||
|
||||
// 验证状态值
|
||||
const validStatuses = ['PASS', 'FAIL', 'UNCERTAIN'];
|
||||
const status = validStatuses.includes(parsed.status?.toUpperCase())
|
||||
? parsed.status.toUpperCase()
|
||||
: 'UNCERTAIN';
|
||||
|
||||
return {
|
||||
status: status as 'PASS' | 'FAIL' | 'UNCERTAIN',
|
||||
reason: parsed.reason || '未提供理由',
|
||||
evidence: parsed.evidence || {},
|
||||
confidence: typeof parsed.confidence === 'number'
|
||||
? Math.min(1, Math.max(0, parsed.confidence))
|
||||
: 0.5,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.warn('[SoftRuleEngine] Failed to parse LLM response', {
|
||||
checkId: check.id,
|
||||
rawResponse: rawResponse.substring(0, 500),
|
||||
});
|
||||
|
||||
// 解析失败时尝试简单匹配
|
||||
const lowerResponse = rawResponse.toLowerCase();
|
||||
if (lowerResponse.includes('pass') || lowerResponse.includes('通过')) {
|
||||
return {
|
||||
status: 'PASS',
|
||||
reason: rawResponse,
|
||||
evidence: {},
|
||||
confidence: 0.6,
|
||||
};
|
||||
} else if (lowerResponse.includes('fail') || lowerResponse.includes('失败') || lowerResponse.includes('不符合')) {
|
||||
return {
|
||||
status: 'FAIL',
|
||||
reason: rawResponse,
|
||||
evidence: {},
|
||||
confidence: 0.6,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'UNCERTAIN',
|
||||
reason: `无法解析响应: ${rawResponse.substring(0, 200)}`,
|
||||
evidence: {},
|
||||
confidence: 0.3,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量执行检查
|
||||
*
|
||||
* @param records 记录列表
|
||||
* @param checks 检查列表
|
||||
* @returns 所有记录的检查结果
|
||||
*/
|
||||
async executeBatch(
|
||||
records: Array<{ recordId: string; data: Record<string, any> }>,
|
||||
checks: SoftRuleCheck[]
|
||||
): Promise<SoftRuleEngineResult[]> {
|
||||
const results: SoftRuleEngineResult[] = [];
|
||||
|
||||
for (const record of records) {
|
||||
const result = await this.execute(record.recordId, record.data, checks);
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 工厂函数
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 创建 SoftRuleEngine 实例
|
||||
*
|
||||
* @param projectId 项目ID
|
||||
* @param config 可选配置
|
||||
* @returns SoftRuleEngine 实例
|
||||
*/
|
||||
export function createSoftRuleEngine(
|
||||
projectId: string,
|
||||
config?: SoftRuleEngineConfig
|
||||
): SoftRuleEngine {
|
||||
return new SoftRuleEngine(projectId, config);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 预置检查模板
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 入排标准检查模板
|
||||
*/
|
||||
export const INCLUSION_EXCLUSION_CHECKS: SoftRuleCheck[] = [
|
||||
{
|
||||
id: 'IE-001',
|
||||
name: '年龄入组标准',
|
||||
description: '检查受试者年龄是否符合入组标准',
|
||||
promptTemplate: '请根据受试者数据,判断其年龄是否在研究方案规定的入组范围内。如果年龄字段缺失,请标记为 UNCERTAIN。',
|
||||
requiredTags: ['#demographics'],
|
||||
category: 'inclusion',
|
||||
severity: 'critical',
|
||||
},
|
||||
{
|
||||
id: 'IE-002',
|
||||
name: '确诊时间入组标准',
|
||||
description: '检查受试者确诊时间是否符合入组标准(通常要求确诊在一定时间内)',
|
||||
promptTemplate: '请根据受试者的确诊日期和入组日期,判断确诊时间是否符合研究方案要求。',
|
||||
requiredTags: ['#demographics', '#medical_history'],
|
||||
category: 'inclusion',
|
||||
severity: 'critical',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* AE 事件检测模板
|
||||
*/
|
||||
export const AE_DETECTION_CHECKS: SoftRuleCheck[] = [
|
||||
{
|
||||
id: 'AE-001',
|
||||
name: 'Lab 异常与 AE 一致性',
|
||||
description: '检查实验室检查异常值是否已在 AE 表中报告',
|
||||
promptTemplate: '请对比实验室检查数据和不良事件记录,判断是否存在未报告的实验室异常(Grade 3 及以上)。',
|
||||
requiredTags: ['#lab', '#ae'],
|
||||
category: 'ae_detection',
|
||||
severity: 'critical',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 方案偏离检测模板
|
||||
*/
|
||||
export const PROTOCOL_DEVIATION_CHECKS: SoftRuleCheck[] = [
|
||||
{
|
||||
id: 'PD-001',
|
||||
name: '访视超窗检测',
|
||||
description: '检查访视是否在方案规定的时间窗口内',
|
||||
promptTemplate: '请根据访视日期数据,判断各访视之间的时间间隔是否符合方案规定的访视窗口。',
|
||||
requiredTags: ['#visits'],
|
||||
category: 'protocol_deviation',
|
||||
severity: 'warning',
|
||||
},
|
||||
];
|
||||
@@ -3,4 +3,6 @@
|
||||
*/
|
||||
|
||||
export * from './HardRuleEngine.js';
|
||||
export * from './SoftRuleEngine.js';
|
||||
export * from './SopEngine.js';
|
||||
export * from './SkillRunner.js';
|
||||
|
||||
@@ -21,6 +21,15 @@ import { PrismaClient } from '@prisma/client';
|
||||
import { RedcapAdapter } from '../adapters/RedcapAdapter.js';
|
||||
import { getVectorSearchService } from '../../../common/rag/index.js';
|
||||
import { HardRuleEngine, createHardRuleEngine, QCResult } from '../engines/HardRuleEngine.js';
|
||||
import {
|
||||
PromptBuilder,
|
||||
buildClinicalSlice,
|
||||
buildQcSummary,
|
||||
buildEnrollmentSummary,
|
||||
buildRecordDetail,
|
||||
buildQcIssuesList,
|
||||
wrapAsSystemMessage
|
||||
} from './PromptBuilder.js';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
@@ -74,6 +83,9 @@ export class ChatService {
|
||||
} else if (intent === 'query_qc_status') {
|
||||
// ⭐ 质控状态查询(优先查询质控表)
|
||||
toolResult = await this.queryQcStatus();
|
||||
} else if (intent === 'query_qc_report') {
|
||||
// ⭐ V3.0 质控报告查询(报告驱动模式)
|
||||
toolResult = await this.getQcReport();
|
||||
}
|
||||
|
||||
// 4. 如果需要查询文档(自研RAG知识库),执行检索
|
||||
@@ -146,7 +158,7 @@ export class ChatService {
|
||||
* 简单意图识别(基于关键词)
|
||||
*/
|
||||
private detectIntent(message: string): {
|
||||
intent: 'query_record' | 'count_records' | 'project_info' | 'query_protocol' | 'qc_record' | 'qc_all' | 'query_enrollment' | 'query_qc_status' | 'general_chat';
|
||||
intent: 'query_record' | 'count_records' | 'project_info' | 'query_protocol' | 'qc_record' | 'qc_all' | 'query_enrollment' | 'query_qc_status' | 'query_qc_report' | 'general_chat';
|
||||
params?: any;
|
||||
} {
|
||||
const lowerMessage = message.toLowerCase();
|
||||
@@ -159,10 +171,26 @@ export class ChatService {
|
||||
return { intent: 'query_enrollment' };
|
||||
}
|
||||
|
||||
// ⭐ V3.1 识别质控报告查询(优先级最高 - 报告驱动模式)
|
||||
// 增强:支持更多自然语言表达方式
|
||||
if (/(质控|QC).*(报告|概述|分析|总结)/.test(message) ||
|
||||
/报告.*?(质控|分析|问题)/.test(message) ||
|
||||
/(给我|生成|查看|提供).*?报告/.test(message) ||
|
||||
/(严重|警告|违规).*?(问题|几项|几条|多少|有哪些|列表|详情)/.test(message) ||
|
||||
/(表单|CRF).*?(统计|通过率)/.test(message) ||
|
||||
// V3.1: 增强匹配 - 问题数量类查询
|
||||
/有几(条|项|个).*?(质控|问题|违规)/.test(message) ||
|
||||
/(质控|问题|违规).*?有几(条|项|个)/.test(message) ||
|
||||
/质控问题/.test(message) ||
|
||||
/严重违规/.test(message) ||
|
||||
/record.*?问题/.test(lowerMessage)) {
|
||||
return { intent: 'query_qc_report' };
|
||||
}
|
||||
|
||||
// ⭐ 识别质控状态查询(从质控表快速返回)
|
||||
// "质控情况"、"质控状态"、"有多少问题"等
|
||||
if (/(质控|QC).*?(情况|状态|汇总|统计|概况|结果)/.test(message) ||
|
||||
/(问题|错误|警告).*?(多少|几个|统计)/.test(message) ||
|
||||
/(问题|错误|警告).*?(多少|几个|几条|几项|统计)/.test(message) ||
|
||||
/哪些.*?(问题|不合格|失败)/.test(message)) {
|
||||
return { intent: 'query_qc_status' };
|
||||
}
|
||||
@@ -234,6 +262,8 @@ export class ChatService {
|
||||
|
||||
/**
|
||||
* 构建包含数据的LLM消息
|
||||
*
|
||||
* ⭐ V2.9.1 优化:使用 PromptBuilder 的 XML 临床切片格式
|
||||
*/
|
||||
private buildMessagesWithData(
|
||||
userMessage: string,
|
||||
@@ -251,19 +281,12 @@ export class ChatService {
|
||||
content: this.getSystemPromptWithData(userId)
|
||||
});
|
||||
|
||||
// 2. 如果有REDCap查询结果,注入到System消息
|
||||
// 2. 如果有数据查询结果,使用 PromptBuilder 格式化注入
|
||||
if (toolResult) {
|
||||
const formattedContent = this.formatToolResultWithPromptBuilder(toolResult, intent);
|
||||
messages.push({
|
||||
role: 'system',
|
||||
content: `【REDCap数据查询结果 - 这是真实数据,必须使用】
|
||||
${JSON.stringify(toolResult, null, 2)}
|
||||
|
||||
⚠️ 重要提示:
|
||||
1. 上述数据是从REDCap系统实时查询的真实数据
|
||||
2. 你必须且只能使用上述数据中的字段值回答用户
|
||||
3. 如果某个字段为空或不存在,请如实告知"该字段未填写"
|
||||
4. 绝对禁止编造任何不在上述数据中的信息
|
||||
5. 如果数据中包含error字段,说明查询失败,请友好地告知用户`
|
||||
content: formattedContent
|
||||
});
|
||||
} else {
|
||||
// 没有查询到数据时的提示
|
||||
@@ -316,6 +339,8 @@ ${ragKnowledge}
|
||||
|
||||
/**
|
||||
* 新的System Prompt(强调基于真实数据)
|
||||
*
|
||||
* V2.1 优化:添加"定位→提取→引用→诚实"思维链约束
|
||||
*/
|
||||
private getSystemPromptWithData(userId: string): string {
|
||||
return `你是IIT Manager智能助手,负责帮助PI管理临床研究项目。
|
||||
@@ -333,12 +358,30 @@ ${ragKnowledge}
|
||||
|
||||
编造临床研究信息可能导致严重医疗事故!
|
||||
|
||||
【V2.1 思维链约束 - 回答质控报告问题时必须遵循】
|
||||
当回答质控报告相关问题时,请严格按照以下 4 步进行:
|
||||
|
||||
1. **定位 (Locate)**:在 <qc_context> 中找到相关章节
|
||||
- 统计类问题 → 查看 <summary>
|
||||
- 问题详情类 → 查看 <critical_issues> 或 <warning_issues>
|
||||
|
||||
2. **提取 (Extract)**:精确提取数值和信息
|
||||
- 不要推测、不要计算(除非数据明确给出)
|
||||
|
||||
3. **引用 (Cite)**:回答时引用来源
|
||||
- 例:"根据报告,记录 ID 1 存在年龄超标问题 (当前 45岁,标准 25-35岁)"
|
||||
|
||||
4. **诚实 (Grounding)**:如果报告中没有相关信息
|
||||
- 明确说"报告中未包含该信息"
|
||||
- 不要编造、不要推测
|
||||
|
||||
【你的能力】
|
||||
- 回答研究进展问题(仅基于REDCap实时数据)
|
||||
- 查询患者记录详情(仅基于REDCap实时数据)
|
||||
- 统计入组人数(仅基于REDCap实时数据)
|
||||
- 解答研究方案问题(仅基于知识库检索到的文档)
|
||||
- 数据质控(仅基于系统执行的规则检查结果)
|
||||
- 质控报告分析(仅基于预生成的 QC 报告)
|
||||
|
||||
【质控结果解读】
|
||||
- PASS:记录完全符合所有规则
|
||||
@@ -364,6 +407,7 @@ ${ragKnowledge}
|
||||
2. 诚实告知不足:没有数据就说"暂无相关信息"
|
||||
3. 简洁专业:控制在200字以内
|
||||
4. 引导行动:建议登录REDCap或联系管理员获取更多信息
|
||||
5. 引用来源:回答质控问题时,引用报告中的具体数据
|
||||
|
||||
【当前用户】
|
||||
企业微信UserID: ${userId}
|
||||
@@ -877,6 +921,53 @@ ${ragKnowledge}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取质控报告(V3.0 报告驱动模式)
|
||||
*
|
||||
* ⭐ 特点:
|
||||
* - 预生成的 XML 报告,LLM 直接阅读理解
|
||||
* - 包含完整的问题汇总、表单统计、严重问题列表
|
||||
* - 比实时质控更快、更全面
|
||||
*/
|
||||
private async getQcReport(): Promise<any> {
|
||||
try {
|
||||
const project = await prisma.iitProject.findFirst({
|
||||
where: { status: 'active' },
|
||||
select: { id: true, name: true }
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
return { error: '未找到活跃项目配置' };
|
||||
}
|
||||
|
||||
// 动态导入 QcReportService(避免循环依赖)
|
||||
const { QcReportService } = await import('./QcReportService.js');
|
||||
|
||||
// 获取报告
|
||||
const report = await QcReportService.getReport(project.id);
|
||||
|
||||
logger.info('[ChatService] 获取质控报告成功', {
|
||||
projectId: project.id,
|
||||
criticalIssues: report.criticalIssues.length,
|
||||
warningIssues: report.warningIssues.length,
|
||||
passRate: report.summary.passRate,
|
||||
});
|
||||
|
||||
return {
|
||||
projectName: project.name,
|
||||
source: 'qc_report',
|
||||
type: 'qc_report', // 标识类型,用于格式化
|
||||
report: report,
|
||||
// LLM 友好的 XML 报告
|
||||
llmReport: report.llmFriendlyXml,
|
||||
};
|
||||
|
||||
} catch (error: any) {
|
||||
logger.error('[ChatService] 获取质控报告失败', { error: error.message });
|
||||
return { error: `获取报告失败: ${error.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询知识库(研究方案文档)- 使用自研 RAG 引擎
|
||||
*/
|
||||
@@ -949,6 +1040,385 @@ ${ragKnowledge}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// PromptBuilder 格式化方法
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 根据意图使用 PromptBuilder 格式化工具结果
|
||||
*
|
||||
* ⭐ V2.9.1 新增:使用 XML 临床切片格式,减少 LLM 幻觉
|
||||
*/
|
||||
private formatToolResultWithPromptBuilder(toolResult: any, intent: string): string {
|
||||
// 如果有错误,直接返回错误信息
|
||||
if (toolResult.error) {
|
||||
return `【查询失败】\n${toolResult.error}\n\n请告知用户查询失败,并建议稍后重试或联系管理员。`;
|
||||
}
|
||||
|
||||
try {
|
||||
switch (intent) {
|
||||
case 'query_record':
|
||||
return this.formatRecordDetailXml(toolResult);
|
||||
|
||||
case 'qc_record':
|
||||
return this.formatQcRecordXml(toolResult);
|
||||
|
||||
case 'qc_all':
|
||||
return this.formatQcAllXml(toolResult);
|
||||
|
||||
case 'query_enrollment':
|
||||
return this.formatEnrollmentXml(toolResult);
|
||||
|
||||
case 'query_qc_status':
|
||||
return this.formatQcStatusXml(toolResult);
|
||||
|
||||
case 'query_qc_report':
|
||||
return this.formatQcReportXml(toolResult);
|
||||
|
||||
case 'count_records':
|
||||
return this.formatCountRecordsXml(toolResult);
|
||||
|
||||
case 'project_info':
|
||||
return this.formatProjectInfoXml(toolResult);
|
||||
|
||||
default:
|
||||
// 默认使用 JSON 格式(兼容旧逻辑)
|
||||
return wrapAsSystemMessage(
|
||||
`<data>\n${JSON.stringify(toolResult, null, 2)}\n</data>`,
|
||||
'REDCap'
|
||||
);
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error('[ChatService] PromptBuilder 格式化失败,回退到 JSON', {
|
||||
intent,
|
||||
error: error.message
|
||||
});
|
||||
return `【REDCap数据查询结果】\n${JSON.stringify(toolResult, null, 2)}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化记录详情为 XML
|
||||
*/
|
||||
private formatRecordDetailXml(result: any): string {
|
||||
const xmlContent = buildRecordDetail({
|
||||
projectName: result.projectName || '未知项目',
|
||||
recordId: result.recordId,
|
||||
data: result,
|
||||
qcResult: result.qcResult
|
||||
});
|
||||
|
||||
return wrapAsSystemMessage(xmlContent, 'REDCap');
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化单条质控结果为 XML
|
||||
*/
|
||||
private formatQcRecordXml(result: any): string {
|
||||
const { projectName, recordId, source, qcResult } = result;
|
||||
|
||||
// 构建临床切片
|
||||
const xmlContent = buildClinicalSlice({
|
||||
task: `核查记录 ${recordId} 的质控结果`,
|
||||
patientData: {}, // 数据已在 qcResult 中
|
||||
instruction: '请向用户解释质控结果,重点说明发现的问题及其严重程度。'
|
||||
});
|
||||
|
||||
// 添加质控结果
|
||||
const qcXml = `
|
||||
<qc_result record_id="${recordId}" status="${qcResult?.overallStatus || 'UNKNOWN'}" source="${source}">
|
||||
<summary>
|
||||
<total_rules>${qcResult?.summary?.totalRules || 0}</total_rules>
|
||||
<passed>${qcResult?.summary?.passed || 0}</passed>
|
||||
<failed>${qcResult?.summary?.failed || 0}</failed>
|
||||
</summary>
|
||||
${qcResult?.errors?.length > 0 ? `
|
||||
<errors>
|
||||
${qcResult.errors.map((e: any) =>
|
||||
`<error field="${e.field || 'unknown'}" rule="${e.rule || ''}">${e.message}</error>`
|
||||
).join('\n ')}
|
||||
</errors>` : ''}
|
||||
${qcResult?.warnings?.length > 0 ? `
|
||||
<warnings>
|
||||
${qcResult.warnings.map((w: any) =>
|
||||
`<warning field="${w.field || 'unknown'}" rule="${w.rule || ''}">${w.message}</warning>`
|
||||
).join('\n ')}
|
||||
</warnings>` : ''}
|
||||
</qc_result>`;
|
||||
|
||||
// 生成自然语言摘要
|
||||
const summary = buildQcSummary({
|
||||
projectName: projectName || '当前项目',
|
||||
totalRecords: 1,
|
||||
passedRecords: qcResult?.overallStatus === 'PASS' ? 1 : 0,
|
||||
failedRecords: qcResult?.overallStatus === 'FAIL' ? 1 : 0,
|
||||
warningRecords: qcResult?.overallStatus === 'WARNING' ? 1 : 0,
|
||||
topIssues: qcResult?.errors?.slice(0, 3).map((e: any) => ({
|
||||
issue: e.message,
|
||||
count: 1
|
||||
})) || []
|
||||
});
|
||||
|
||||
return wrapAsSystemMessage(
|
||||
`${qcXml}\n\n<natural_summary>\n${summary}\n</natural_summary>`,
|
||||
source === 'cached' ? 'QC_TABLE' : 'REDCap'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化批量质控结果为 XML
|
||||
*/
|
||||
private formatQcAllXml(result: any): string {
|
||||
const { projectName, totalRecords, summary, problemRecords } = result;
|
||||
|
||||
const xmlContent = buildQcIssuesList({
|
||||
projectName: projectName || '当前项目',
|
||||
totalRecords: totalRecords || 0,
|
||||
passedRecords: summary?.pass || 0,
|
||||
failedRecords: summary?.fail || 0,
|
||||
problemRecords: (problemRecords || []).map((r: any) => ({
|
||||
recordId: r.recordId,
|
||||
status: r.status,
|
||||
issues: (r.topIssues || []).map((i: any) => ({
|
||||
message: i.message
|
||||
}))
|
||||
}))
|
||||
});
|
||||
|
||||
// 添加自然语言摘要
|
||||
const naturalSummary = buildQcSummary({
|
||||
projectName: projectName || '当前项目',
|
||||
totalRecords: totalRecords || 0,
|
||||
passedRecords: summary?.pass || 0,
|
||||
failedRecords: summary?.fail || 0,
|
||||
warningRecords: summary?.warning || 0
|
||||
});
|
||||
|
||||
return wrapAsSystemMessage(
|
||||
`${xmlContent}\n\n<natural_summary>\n${naturalSummary}\n</natural_summary>`,
|
||||
'REDCap'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化录入进度为 XML
|
||||
*/
|
||||
private formatEnrollmentXml(result: any): string {
|
||||
if (result.message) {
|
||||
return `【录入进度查询】\n${result.message}`;
|
||||
}
|
||||
|
||||
const naturalSummary = buildEnrollmentSummary({
|
||||
projectName: result.projectName || '当前项目',
|
||||
totalRecords: result.totalRecords || 0,
|
||||
avgCompletionRate: parseFloat(result.avgCompletionRate) || 0,
|
||||
recentEnrollments: result.recentEnrollments || 0,
|
||||
byQcStatus: result.byQcStatus || { pass: 0, fail: 0, warning: 0, pending: 0 }
|
||||
});
|
||||
|
||||
// 构建最近记录列表
|
||||
const recentRecordsXml = result.recentRecords?.length > 0
|
||||
? `<recent_records>
|
||||
${result.recentRecords.map((r: any) =>
|
||||
` <record id="${r.recordId}" completion="${r.completionRate}%" qc_status="${r.qcStatus}" />`
|
||||
).join('\n')}
|
||||
</recent_records>`
|
||||
: '';
|
||||
|
||||
return wrapAsSystemMessage(
|
||||
`<enrollment_summary project="${result.projectName}">\n${naturalSummary}\n</enrollment_summary>\n${recentRecordsXml}`,
|
||||
'SUMMARY_TABLE'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化质控状态为 XML
|
||||
*/
|
||||
private formatQcStatusXml(result: any): string {
|
||||
if (result.message) {
|
||||
return `【质控状态查询】\n${result.message}`;
|
||||
}
|
||||
|
||||
const { projectName, stats, recentChecks, problemRecords } = result;
|
||||
|
||||
let xmlContent = `<qc_status project="${projectName}">`;
|
||||
|
||||
if (stats) {
|
||||
xmlContent += `
|
||||
<stats>
|
||||
<total_records>${stats.totalRecords}</total_records>
|
||||
<passed>${stats.passedRecords}</passed>
|
||||
<failed>${stats.failedRecords}</failed>
|
||||
<warning>${stats.warningRecords}</warning>
|
||||
<pass_rate>${stats.passRate}</pass_rate>
|
||||
<last_updated>${stats.lastUpdated}</last_updated>
|
||||
</stats>`;
|
||||
}
|
||||
|
||||
if (problemRecords?.length > 0) {
|
||||
xmlContent += `
|
||||
<problem_records count="${problemRecords.length}">
|
||||
${problemRecords.map((r: any) =>
|
||||
` <record id="${r.recordId}" status="${r.status}" issues="${r.issueCount}" checked_at="${r.checkedAt}" />`
|
||||
).join('\n')}
|
||||
</problem_records>`;
|
||||
}
|
||||
|
||||
xmlContent += `\n</qc_status>`;
|
||||
|
||||
// 生成自然语言摘要
|
||||
const naturalSummary = stats ? buildQcSummary({
|
||||
projectName: projectName || '当前项目',
|
||||
totalRecords: stats.totalRecords,
|
||||
passedRecords: stats.passedRecords,
|
||||
failedRecords: stats.failedRecords,
|
||||
warningRecords: stats.warningRecords
|
||||
}) : '暂无质控统计数据';
|
||||
|
||||
return wrapAsSystemMessage(
|
||||
`${xmlContent}\n\n<natural_summary>\n${naturalSummary}\n</natural_summary>`,
|
||||
'QC_TABLE'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化记录统计为 XML
|
||||
*/
|
||||
private formatCountRecordsXml(result: any): string {
|
||||
const { projectName, totalRecords, recordIds } = result;
|
||||
|
||||
const xmlContent = `<record_count project="${projectName}">
|
||||
<total>${totalRecords}</total>
|
||||
<record_ids>${(recordIds || []).slice(0, 10).join(', ')}${totalRecords > 10 ? '...' : ''}</record_ids>
|
||||
</record_count>`;
|
||||
|
||||
const naturalSummary = `项目【${projectName}】当前共有 ${totalRecords} 条记录。`;
|
||||
|
||||
return wrapAsSystemMessage(
|
||||
`${xmlContent}\n\n<natural_summary>${naturalSummary}</natural_summary>`,
|
||||
'REDCap'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化项目信息为 XML
|
||||
*/
|
||||
private formatProjectInfoXml(result: any): string {
|
||||
const { projectId, projectName, description, redcapProjectId, lastSyncAt, createdAt } = result;
|
||||
|
||||
const xmlContent = `<project_info id="${projectId}">
|
||||
<name>${projectName}</name>
|
||||
<description>${description || '暂无描述'}</description>
|
||||
<redcap_project_id>${redcapProjectId || '未配置'}</redcap_project_id>
|
||||
<last_sync>${lastSyncAt}</last_sync>
|
||||
<created_at>${createdAt}</created_at>
|
||||
</project_info>`;
|
||||
|
||||
const naturalSummary = `当前项目:【${projectName}】
|
||||
- 项目描述:${description || '暂无'}
|
||||
- REDCap 项目 ID:${redcapProjectId || '未配置'}
|
||||
- 最后同步时间:${lastSyncAt}
|
||||
- 创建时间:${createdAt}`;
|
||||
|
||||
return wrapAsSystemMessage(
|
||||
`${xmlContent}\n\n<natural_summary>\n${naturalSummary}\n</natural_summary>`,
|
||||
'REDCap'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化质控报告为 XML(V3.0 报告驱动模式)
|
||||
*
|
||||
* V2.1 优化:
|
||||
* - 直接使用 QcReportService 预生成的 LLM 友好 XML 报告
|
||||
* - 添加思维链约束提示
|
||||
* - 信息自包含,减少 LLM 幻觉
|
||||
*/
|
||||
private formatQcReportXml(result: any): string {
|
||||
const { projectName, llmReport, report } = result;
|
||||
|
||||
// V2.1: 使用预生成的 LLM 友好报告
|
||||
if (llmReport) {
|
||||
const intro = `【质控报告 - 请严格基于此报告回答】
|
||||
项目: ${projectName}
|
||||
生成时间: ${report?.generatedAt || new Date().toISOString()}
|
||||
|
||||
---
|
||||
请按照"定位→提取→引用→诚实"思维链回答用户问题:
|
||||
1. 定位:在报告中找到相关章节
|
||||
2. 提取:精确提取数值(不要推测)
|
||||
3. 引用:回答时引用具体数据和受试者ID
|
||||
4. 诚实:报告中没有的信息,明确说"报告中未包含"
|
||||
---`;
|
||||
|
||||
return wrapAsSystemMessage(
|
||||
`${intro}\n\n${llmReport}`,
|
||||
'QC_REPORT'
|
||||
);
|
||||
}
|
||||
|
||||
// 回退:如果没有预生成报告,从 report 对象构建
|
||||
if (report) {
|
||||
const summary = report.summary || {};
|
||||
const topIssues = report.topIssues || [];
|
||||
const groupedIssues = report.groupedIssues || [];
|
||||
|
||||
// V2.1: 构建简化的 LLM 友好格式
|
||||
let fallbackXml = `<qc_context project="${projectName}">
|
||||
|
||||
<summary>
|
||||
- 状态: ${groupedIssues.length}/${summary.totalRecords || 0} 记录存在严重违规
|
||||
- 通过率: ${summary.passRate || 0}%
|
||||
- 严重问题: ${summary.criticalIssues || 0} | 警告: ${summary.warningIssues || 0}`;
|
||||
|
||||
if (topIssues.length > 0) {
|
||||
fallbackXml += `
|
||||
- Top ${Math.min(3, topIssues.length)} 问题:
|
||||
${topIssues.slice(0, 3).map((t: any, i: number) => ` ${i + 1}. ${t.ruleName} (${t.count}人)`).join('\n')}`;
|
||||
}
|
||||
|
||||
fallbackXml += `
|
||||
</summary>
|
||||
|
||||
`;
|
||||
|
||||
// 添加分组的严重问题
|
||||
if (groupedIssues.length > 0) {
|
||||
fallbackXml += ` <critical_issues record_count="${groupedIssues.length}">\n`;
|
||||
for (const group of groupedIssues.slice(0, 10)) {
|
||||
fallbackXml += ` <record id="${group.recordId}">\n`;
|
||||
fallbackXml += ` 严重违规 (${group.issueCount}项):\n`;
|
||||
for (let i = 0; i < Math.min(group.issues.length, 5); i++) {
|
||||
const issue = group.issues[i];
|
||||
const actualDisplay = issue.actualValue !== undefined ? `**${issue.actualValue}**` : '**空**';
|
||||
const expectedDisplay = issue.expectedValue ? ` (标准: ${issue.expectedValue})` : '';
|
||||
fallbackXml += ` ${i + 1}. **${issue.ruleName}**: 当前值 ${actualDisplay}${expectedDisplay}\n`;
|
||||
}
|
||||
fallbackXml += ` </record>\n`;
|
||||
}
|
||||
fallbackXml += ` </critical_issues>\n\n`;
|
||||
}
|
||||
|
||||
fallbackXml += `</qc_context>`;
|
||||
|
||||
const intro = `【质控报告 - 请严格基于此报告回答】
|
||||
项目: ${projectName}
|
||||
|
||||
请按照"定位→提取→引用→诚实"思维链回答用户问题。`;
|
||||
|
||||
return wrapAsSystemMessage(
|
||||
`${intro}\n\n${fallbackXml}`,
|
||||
'QC_REPORT'
|
||||
);
|
||||
}
|
||||
|
||||
// 最终回退:无数据
|
||||
return wrapAsSystemMessage(
|
||||
`<qc_report_error>无法获取质控报告数据</qc_report_error>`,
|
||||
'QC_REPORT'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除用户会话(用于重置对话)
|
||||
*/
|
||||
|
||||
447
backend/src/modules/iit-manager/services/PromptBuilder.ts
Normal file
447
backend/src/modules/iit-manager/services/PromptBuilder.ts
Normal file
@@ -0,0 +1,447 @@
|
||||
/**
|
||||
* PromptBuilder - LLM 提示词构建器
|
||||
*
|
||||
* 功能:
|
||||
* - 构建 XML 临床切片格式(对 LLM 更友好,减少幻觉)
|
||||
* - 生成自然语言摘要
|
||||
* - 支持按语义标签过滤数据
|
||||
*
|
||||
* 设计原则:
|
||||
* - XML 格式比 JSON 更适合 LLM 理解
|
||||
* - 语义化标签便于上下文切片
|
||||
* - 明确的任务指令减少幻觉
|
||||
*
|
||||
* @see docs/03-业务模块/IIT Manager Agent/04-开发计划/07-质控系统UI与LLM格式优化计划.md
|
||||
*/
|
||||
|
||||
import { logger } from '../../../common/logging/index.js';
|
||||
|
||||
// ============================================================
|
||||
// 类型定义
|
||||
// ============================================================
|
||||
|
||||
export interface ClinicalSliceParams {
|
||||
/** 任务描述 */
|
||||
task: string;
|
||||
/** 研究方案中的标准(入排标准等) */
|
||||
criteria?: string[];
|
||||
/** 患者数据 */
|
||||
patientData: Record<string, any>;
|
||||
/** 语义标签(用于过滤数据) */
|
||||
tags?: string[];
|
||||
/** 额外的指令 */
|
||||
instruction?: string;
|
||||
/** 字段元数据(用于增强展示) */
|
||||
fieldMetadata?: Record<string, FieldMetadata>;
|
||||
}
|
||||
|
||||
export interface FieldMetadata {
|
||||
/** 字段标签(中文名) */
|
||||
label?: string;
|
||||
/** 字段类型 */
|
||||
type?: 'text' | 'number' | 'date' | 'radio' | 'checkbox' | 'dropdown';
|
||||
/** 正常范围(数值型) */
|
||||
normalRange?: { min?: number; max?: number };
|
||||
/** 选项(选择型) */
|
||||
options?: Array<{ value: string; label: string }>;
|
||||
/** 语义标签 */
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export interface QcSummaryParams {
|
||||
projectName: string;
|
||||
totalRecords: number;
|
||||
passedRecords: number;
|
||||
failedRecords: number;
|
||||
warningRecords?: number;
|
||||
topIssues?: Array<{ issue: string; count: number }>;
|
||||
}
|
||||
|
||||
export interface EnrollmentSummaryParams {
|
||||
projectName: string;
|
||||
totalRecords: number;
|
||||
avgCompletionRate: number;
|
||||
recentEnrollments: number;
|
||||
byQcStatus: {
|
||||
pass: number;
|
||||
fail: number;
|
||||
warning: number;
|
||||
pending: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface RecordDetailParams {
|
||||
projectName: string;
|
||||
recordId: string;
|
||||
data: Record<string, any>;
|
||||
fieldMetadata?: Record<string, FieldMetadata>;
|
||||
qcResult?: {
|
||||
status: string;
|
||||
errors?: Array<{ field: string; message: string; actualValue?: any }>;
|
||||
warnings?: Array<{ field: string; message: string; actualValue?: any }>;
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// PromptBuilder 实现
|
||||
// ============================================================
|
||||
|
||||
export class PromptBuilder {
|
||||
/**
|
||||
* 构建 XML 临床切片格式
|
||||
*
|
||||
* 输出格式示例:
|
||||
* ```xml
|
||||
* <task>核查该患者是否符合研究入排标准</task>
|
||||
*
|
||||
* <protocol_criteria>
|
||||
* 1. 年龄 16-35 岁。
|
||||
* 2. 月经周期规律(28±7天)。
|
||||
* </protocol_criteria>
|
||||
*
|
||||
* <patient_slice tag="#demographics, #screening">
|
||||
* - 出生日期:2003-01-07(当前年龄 22 岁)✅
|
||||
* - 月经周期:45 天 ⚠️ 超出范围
|
||||
* </patient_slice>
|
||||
*
|
||||
* <instruction>
|
||||
* 请一步步推理,对比患者数据与标准。如发现异常,说明具体哪条标准被违反。
|
||||
* </instruction>
|
||||
* ```
|
||||
*/
|
||||
static buildClinicalSlice(params: ClinicalSliceParams): string {
|
||||
const {
|
||||
task,
|
||||
criteria = [],
|
||||
patientData,
|
||||
tags = [],
|
||||
instruction,
|
||||
fieldMetadata = {}
|
||||
} = params;
|
||||
|
||||
const parts: string[] = [];
|
||||
|
||||
// 1. 任务描述
|
||||
parts.push(`<task>${task}</task>`);
|
||||
parts.push('');
|
||||
|
||||
// 2. 研究方案标准(如果有)
|
||||
if (criteria.length > 0) {
|
||||
parts.push('<protocol_criteria>');
|
||||
criteria.forEach((c, i) => {
|
||||
parts.push(` ${i + 1}. ${c}`);
|
||||
});
|
||||
parts.push('</protocol_criteria>');
|
||||
parts.push('');
|
||||
}
|
||||
|
||||
// 3. 患者数据切片
|
||||
const tagAttr = tags.length > 0 ? ` tag="${tags.join(', ')}"` : '';
|
||||
parts.push(`<patient_slice${tagAttr}>`);
|
||||
|
||||
// 格式化患者数据
|
||||
const formattedData = PromptBuilder.formatPatientData(patientData, fieldMetadata);
|
||||
formattedData.forEach(line => {
|
||||
parts.push(` ${line}`);
|
||||
});
|
||||
|
||||
parts.push('</patient_slice>');
|
||||
parts.push('');
|
||||
|
||||
// 4. 指令(如果有)
|
||||
if (instruction) {
|
||||
parts.push('<instruction>');
|
||||
parts.push(instruction);
|
||||
parts.push('</instruction>');
|
||||
}
|
||||
|
||||
return parts.join('\n').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化患者数据为可读格式
|
||||
*/
|
||||
private static formatPatientData(
|
||||
data: Record<string, any>,
|
||||
metadata: Record<string, FieldMetadata>
|
||||
): string[] {
|
||||
const lines: string[] = [];
|
||||
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
// 跳过系统字段
|
||||
if (key.startsWith('redcap_') || key === 'record_id') continue;
|
||||
|
||||
const meta = metadata[key];
|
||||
const label = meta?.label || key;
|
||||
const formattedValue = PromptBuilder.formatFieldValue(value, meta);
|
||||
const statusIcon = PromptBuilder.getStatusIcon(value, meta);
|
||||
|
||||
lines.push(`- ${label}: ${formattedValue}${statusIcon ? ' ' + statusIcon : ''}`);
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化字段值
|
||||
*/
|
||||
private static formatFieldValue(value: any, meta?: FieldMetadata): string {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return '未填写';
|
||||
}
|
||||
|
||||
// 日期类型
|
||||
if (meta?.type === 'date' && typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
|
||||
// 选择类型
|
||||
if (meta?.options && meta.options.length > 0) {
|
||||
const option = meta.options.find(o => o.value === String(value));
|
||||
return option ? option.label : String(value);
|
||||
}
|
||||
|
||||
// 数值类型 - 添加范围信息
|
||||
if (meta?.type === 'number' && meta.normalRange) {
|
||||
const { min, max } = meta.normalRange;
|
||||
const numValue = Number(value);
|
||||
if (!isNaN(numValue)) {
|
||||
const rangeInfo: string[] = [];
|
||||
if (min !== undefined) rangeInfo.push(`≥${min}`);
|
||||
if (max !== undefined) rangeInfo.push(`≤${max}`);
|
||||
if (rangeInfo.length > 0) {
|
||||
return `${value} (正常范围: ${rangeInfo.join(', ')})`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return String(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取状态图标
|
||||
*/
|
||||
private static getStatusIcon(value: any, meta?: FieldMetadata): string {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return '⚠️'; // 缺失
|
||||
}
|
||||
|
||||
// 检查数值是否在范围内
|
||||
if (meta?.normalRange) {
|
||||
const numValue = Number(value);
|
||||
if (!isNaN(numValue)) {
|
||||
const { min, max } = meta.normalRange;
|
||||
if ((min !== undefined && numValue < min) || (max !== undefined && numValue > max)) {
|
||||
return '❌ 超出范围';
|
||||
}
|
||||
return '✅';
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建质控结果摘要(自然语言)
|
||||
*
|
||||
* 输出示例:
|
||||
* "项目 test0207 共有 13 条记录,质控通过率 0%。主要问题包括:知情同意未签署(13条)、入排标准不符(8条)。"
|
||||
*/
|
||||
static buildQcSummary(params: QcSummaryParams): string {
|
||||
const {
|
||||
projectName,
|
||||
totalRecords,
|
||||
passedRecords,
|
||||
failedRecords,
|
||||
warningRecords = 0,
|
||||
topIssues = []
|
||||
} = params;
|
||||
|
||||
const passRate = totalRecords > 0
|
||||
? ((passedRecords / totalRecords) * 100).toFixed(1)
|
||||
: '0';
|
||||
|
||||
let summary = `项目【${projectName}】共有 ${totalRecords} 条记录,质控通过率 ${passRate}%。`;
|
||||
|
||||
if (failedRecords > 0) {
|
||||
summary += `\n\n【质控统计】\n`;
|
||||
summary += `- 通过: ${passedRecords} 条\n`;
|
||||
summary += `- 失败: ${failedRecords} 条\n`;
|
||||
if (warningRecords > 0) {
|
||||
summary += `- 警告: ${warningRecords} 条\n`;
|
||||
}
|
||||
}
|
||||
|
||||
if (topIssues.length > 0) {
|
||||
const issueText = topIssues
|
||||
.slice(0, 3)
|
||||
.map(i => `${i.issue}(${i.count}条)`)
|
||||
.join('、');
|
||||
summary += `\n【主要问题】${issueText}`;
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建录入进度摘要(自然语言)
|
||||
*/
|
||||
static buildEnrollmentSummary(params: EnrollmentSummaryParams): string {
|
||||
const {
|
||||
projectName,
|
||||
totalRecords,
|
||||
avgCompletionRate,
|
||||
recentEnrollments,
|
||||
byQcStatus
|
||||
} = params;
|
||||
|
||||
let summary = `项目【${projectName}】录入概况:\n\n`;
|
||||
summary += `【基本统计】\n`;
|
||||
summary += `- 总记录数: ${totalRecords} 条\n`;
|
||||
summary += `- 平均完成率: ${avgCompletionRate.toFixed(1)}%\n`;
|
||||
summary += `- 近一周新增: ${recentEnrollments} 条\n`;
|
||||
|
||||
summary += `\n【质控分布】\n`;
|
||||
summary += `- 通过: ${byQcStatus.pass} 条\n`;
|
||||
summary += `- 失败: ${byQcStatus.fail} 条\n`;
|
||||
summary += `- 警告: ${byQcStatus.warning} 条\n`;
|
||||
summary += `- 待质控: ${byQcStatus.pending} 条`;
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建记录详情的 XML 格式
|
||||
*/
|
||||
static buildRecordDetail(params: RecordDetailParams): string {
|
||||
const { projectName, recordId, data, fieldMetadata = {}, qcResult } = params;
|
||||
|
||||
const parts: string[] = [];
|
||||
|
||||
// 1. 记录头部信息
|
||||
parts.push(`<record id="${recordId}" project="${projectName}">`);
|
||||
parts.push('');
|
||||
|
||||
// 2. 数据内容
|
||||
parts.push(' <data>');
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
if (key.startsWith('redcap_') || key === 'record_id') continue;
|
||||
|
||||
const meta = fieldMetadata[key];
|
||||
const label = meta?.label || key;
|
||||
const formattedValue = value ?? '未填写';
|
||||
parts.push(` <field name="${key}" label="${label}">${formattedValue}</field>`);
|
||||
}
|
||||
parts.push(' </data>');
|
||||
|
||||
// 3. 质控结果(如果有)
|
||||
if (qcResult) {
|
||||
parts.push('');
|
||||
parts.push(` <qc_result status="${qcResult.status}">`);
|
||||
|
||||
if (qcResult.errors && qcResult.errors.length > 0) {
|
||||
parts.push(' <errors>');
|
||||
qcResult.errors.forEach(e => {
|
||||
parts.push(` <error field="${e.field}">${e.message}</error>`);
|
||||
});
|
||||
parts.push(' </errors>');
|
||||
}
|
||||
|
||||
if (qcResult.warnings && qcResult.warnings.length > 0) {
|
||||
parts.push(' <warnings>');
|
||||
qcResult.warnings.forEach(w => {
|
||||
parts.push(` <warning field="${w.field}">${w.message}</warning>`);
|
||||
});
|
||||
parts.push(' </warnings>');
|
||||
}
|
||||
|
||||
parts.push(' </qc_result>');
|
||||
}
|
||||
|
||||
parts.push('');
|
||||
parts.push('</record>');
|
||||
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建质控问题列表的 XML 格式
|
||||
* 用于批量质控结果展示
|
||||
*/
|
||||
static buildQcIssuesList(params: {
|
||||
projectName: string;
|
||||
totalRecords: number;
|
||||
passedRecords: number;
|
||||
failedRecords: number;
|
||||
problemRecords: Array<{
|
||||
recordId: string;
|
||||
status: string;
|
||||
issues: Array<{ field?: string; message: string }>;
|
||||
}>;
|
||||
}): string {
|
||||
const { projectName, totalRecords, passedRecords, failedRecords, problemRecords } = params;
|
||||
|
||||
const parts: string[] = [];
|
||||
|
||||
// 1. 项目概览
|
||||
parts.push(`<qc_overview project="${projectName}">`);
|
||||
parts.push(` <stats>`);
|
||||
parts.push(` <total>${totalRecords}</total>`);
|
||||
parts.push(` <passed>${passedRecords}</passed>`);
|
||||
parts.push(` <failed>${failedRecords}</failed>`);
|
||||
parts.push(` <pass_rate>${((passedRecords / totalRecords) * 100).toFixed(1)}%</pass_rate>`);
|
||||
parts.push(` </stats>`);
|
||||
parts.push('');
|
||||
|
||||
// 2. 问题记录列表
|
||||
if (problemRecords.length > 0) {
|
||||
parts.push(' <problem_records>');
|
||||
problemRecords.forEach(record => {
|
||||
parts.push(` <record id="${record.recordId}" status="${record.status}">`);
|
||||
record.issues.slice(0, 3).forEach(issue => {
|
||||
const fieldAttr = issue.field ? ` field="${issue.field}"` : '';
|
||||
parts.push(` <issue${fieldAttr}>${issue.message}</issue>`);
|
||||
});
|
||||
parts.push(' </record>');
|
||||
});
|
||||
parts.push(' </problem_records>');
|
||||
}
|
||||
|
||||
parts.push('</qc_overview>');
|
||||
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* 包装 XML 内容为 LLM 系统消息
|
||||
*/
|
||||
static wrapAsSystemMessage(xmlContent: string, dataSource: 'REDCap' | 'QC_TABLE' | 'SUMMARY_TABLE' | 'QC_REPORT'): string {
|
||||
const sourceLabel = {
|
||||
'REDCap': 'REDCap 系统实时数据',
|
||||
'QC_TABLE': '质控日志表缓存数据',
|
||||
'SUMMARY_TABLE': '录入汇总表缓存数据',
|
||||
'QC_REPORT': '质控报告(预生成)'
|
||||
}[dataSource];
|
||||
|
||||
return `【${sourceLabel} - 以下是真实数据,必须使用】
|
||||
|
||||
${xmlContent}
|
||||
|
||||
⚠️ 重要提示:
|
||||
1. 上述数据是从系统查询的真实数据
|
||||
2. 你必须且只能使用上述数据中的内容回答用户
|
||||
3. 如果某个字段为空或标记为"未填写",请如实告知
|
||||
4. 绝对禁止编造任何不在上述数据中的信息
|
||||
5. 如果数据中包含 <error> 标签,说明存在质控问题,需要重点说明`;
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例便捷方法
|
||||
export const buildClinicalSlice = PromptBuilder.buildClinicalSlice;
|
||||
export const buildQcSummary = PromptBuilder.buildQcSummary;
|
||||
export const buildEnrollmentSummary = PromptBuilder.buildEnrollmentSummary;
|
||||
export const buildRecordDetail = PromptBuilder.buildRecordDetail;
|
||||
export const buildQcIssuesList = PromptBuilder.buildQcIssuesList;
|
||||
export const wrapAsSystemMessage = PromptBuilder.wrapAsSystemMessage;
|
||||
|
||||
export default PromptBuilder;
|
||||
979
backend/src/modules/iit-manager/services/QcReportService.ts
Normal file
979
backend/src/modules/iit-manager/services/QcReportService.ts
Normal file
@@ -0,0 +1,979 @@
|
||||
/**
|
||||
* QcReportService - 质控报告生成服务
|
||||
*
|
||||
* 功能:
|
||||
* - 聚合质控统计数据
|
||||
* - 生成 LLM 友好的 XML 报告
|
||||
* - 缓存报告以提高查询效率
|
||||
*
|
||||
* 设计原则:
|
||||
* - 报告驱动:预计算报告,LLM 只需阅读
|
||||
* - 双模输出:人类可读 + LLM 友好格式
|
||||
* - 智能缓存:报告有效期内直接返回缓存
|
||||
*/
|
||||
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { logger } from '../../../common/logging/index.js';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// ============================================================
|
||||
// 类型定义
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 报告摘要
|
||||
*/
|
||||
export interface ReportSummary {
|
||||
totalRecords: number;
|
||||
completedRecords: number;
|
||||
criticalIssues: number;
|
||||
warningIssues: number;
|
||||
pendingQueries: number;
|
||||
passRate: number;
|
||||
lastQcTime: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 问题项
|
||||
*/
|
||||
export interface ReportIssue {
|
||||
recordId: string;
|
||||
ruleId: string;
|
||||
ruleName: string;
|
||||
severity: 'critical' | 'warning' | 'info';
|
||||
message: string;
|
||||
field?: string;
|
||||
actualValue?: any;
|
||||
expectedValue?: any;
|
||||
evidence?: Record<string, any>;
|
||||
detectedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 表单统计
|
||||
*/
|
||||
export interface FormStats {
|
||||
formName: string;
|
||||
formLabel: string;
|
||||
totalChecks: number;
|
||||
passed: number;
|
||||
failed: number;
|
||||
passRate: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 质控报告
|
||||
*/
|
||||
export interface QcReport {
|
||||
projectId: string;
|
||||
reportType: 'daily' | 'weekly' | 'on_demand';
|
||||
generatedAt: string;
|
||||
expiresAt: string | null;
|
||||
summary: ReportSummary;
|
||||
criticalIssues: ReportIssue[];
|
||||
warningIssues: ReportIssue[];
|
||||
formStats: FormStats[];
|
||||
topIssues: TopIssue[]; // V2.1: Top 问题统计
|
||||
groupedIssues: GroupedIssues[]; // V2.1: 按受试者分组
|
||||
llmFriendlyXml: string; // V2.1: LLM 友好格式
|
||||
legacyXml?: string; // V2.1: 兼容旧格式
|
||||
}
|
||||
|
||||
/**
|
||||
* 报告选项
|
||||
*/
|
||||
export interface ReportOptions {
|
||||
forceRefresh?: boolean; // 强制刷新,忽略缓存
|
||||
reportType?: 'daily' | 'weekly' | 'on_demand';
|
||||
expirationHours?: number; // 报告有效期(小时)
|
||||
format?: 'xml' | 'llm-friendly'; // V2.1: 输出格式
|
||||
}
|
||||
|
||||
/**
|
||||
* V2.1: 按受试者分组的问题
|
||||
*/
|
||||
export interface GroupedIssues {
|
||||
recordId: string;
|
||||
issueCount: number;
|
||||
issues: ReportIssue[];
|
||||
}
|
||||
|
||||
/**
|
||||
* V2.1: Top 问题统计
|
||||
*/
|
||||
export interface TopIssue {
|
||||
ruleName: string;
|
||||
ruleId: string;
|
||||
count: number;
|
||||
affectedRecords: string[];
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// QcReportService 实现
|
||||
// ============================================================
|
||||
|
||||
class QcReportServiceClass {
|
||||
/**
|
||||
* 获取项目质控报告
|
||||
*
|
||||
* @param projectId 项目 ID
|
||||
* @param options 报告选项
|
||||
* @returns 质控报告
|
||||
*/
|
||||
async getReport(projectId: string, options?: ReportOptions): Promise<QcReport> {
|
||||
const reportType = options?.reportType || 'on_demand';
|
||||
const expirationHours = options?.expirationHours || 24;
|
||||
|
||||
// 1. 检查缓存
|
||||
if (!options?.forceRefresh) {
|
||||
const cached = await this.getCachedReport(projectId, reportType);
|
||||
if (cached) {
|
||||
logger.debug('[QcReportService] Returning cached report', {
|
||||
projectId,
|
||||
reportType,
|
||||
generatedAt: cached.generatedAt,
|
||||
});
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 生成新报告
|
||||
logger.info('[QcReportService] Generating new report', {
|
||||
projectId,
|
||||
reportType,
|
||||
});
|
||||
|
||||
const report = await this.generateReport(projectId, reportType, expirationHours);
|
||||
|
||||
// 3. 缓存报告
|
||||
await this.cacheReport(report);
|
||||
|
||||
return report;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取缓存的报告
|
||||
*/
|
||||
private async getCachedReport(
|
||||
projectId: string,
|
||||
reportType: string
|
||||
): Promise<QcReport | null> {
|
||||
try {
|
||||
const cached = await prisma.iitQcReport.findFirst({
|
||||
where: {
|
||||
projectId,
|
||||
reportType,
|
||||
OR: [
|
||||
{ expiresAt: null },
|
||||
{ expiresAt: { gt: new Date() } },
|
||||
],
|
||||
},
|
||||
orderBy: { generatedAt: 'desc' },
|
||||
});
|
||||
|
||||
if (!cached) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const issuesData = cached.issues as any || {};
|
||||
|
||||
return {
|
||||
projectId: cached.projectId,
|
||||
reportType: cached.reportType as 'daily' | 'weekly' | 'on_demand',
|
||||
generatedAt: cached.generatedAt.toISOString(),
|
||||
expiresAt: cached.expiresAt?.toISOString() || null,
|
||||
summary: cached.summary as unknown as ReportSummary,
|
||||
criticalIssues: (issuesData.critical || []) as ReportIssue[],
|
||||
warningIssues: (issuesData.warning || []) as ReportIssue[],
|
||||
formStats: (issuesData.formStats || []) as FormStats[],
|
||||
topIssues: (issuesData.topIssues || []) as TopIssue[],
|
||||
groupedIssues: (issuesData.groupedIssues || []) as GroupedIssues[],
|
||||
llmFriendlyXml: cached.llmReport,
|
||||
legacyXml: issuesData.legacyXml,
|
||||
};
|
||||
} catch (error: any) {
|
||||
logger.warn('[QcReportService] Failed to get cached report', {
|
||||
projectId,
|
||||
error: error.message,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成新报告
|
||||
*
|
||||
* V2.1 优化:支持双格式输出
|
||||
*/
|
||||
private async generateReport(
|
||||
projectId: string,
|
||||
reportType: 'daily' | 'weekly' | 'on_demand',
|
||||
expirationHours: number
|
||||
): Promise<QcReport> {
|
||||
const startTime = Date.now();
|
||||
|
||||
// 1. 获取项目信息
|
||||
const project = await prisma.iitProject.findUnique({
|
||||
where: { id: projectId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
fieldMappings: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw new Error(`项目不存在: ${projectId}`);
|
||||
}
|
||||
|
||||
// 2. 聚合质控统计
|
||||
const summary = await this.aggregateStats(projectId);
|
||||
|
||||
// 3. 获取问题列表
|
||||
const { criticalIssues, warningIssues } = await this.getIssues(projectId);
|
||||
|
||||
// 4. 获取表单统计
|
||||
const formStats = await this.getFormStats(projectId);
|
||||
|
||||
// 5. V2.1: 计算 Top Issues 和分组
|
||||
const allIssues = [...criticalIssues, ...warningIssues];
|
||||
const topIssues = this.calculateTopIssues(allIssues);
|
||||
const groupedIssues = this.groupIssuesByRecord(criticalIssues);
|
||||
|
||||
// 6. V2.1: 生成双格式 XML 报告
|
||||
const llmFriendlyXml = this.buildLlmXmlReport(
|
||||
projectId,
|
||||
project.name || projectId,
|
||||
summary,
|
||||
criticalIssues,
|
||||
warningIssues,
|
||||
formStats
|
||||
);
|
||||
|
||||
const legacyXml = this.buildLegacyXmlReport(
|
||||
projectId,
|
||||
project.name || projectId,
|
||||
summary,
|
||||
criticalIssues,
|
||||
warningIssues,
|
||||
formStats
|
||||
);
|
||||
|
||||
const generatedAt = new Date();
|
||||
const expiresAt = new Date(generatedAt.getTime() + expirationHours * 60 * 60 * 1000);
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
logger.info('[QcReportService] Report generated', {
|
||||
projectId,
|
||||
reportType,
|
||||
duration: `${duration}ms`,
|
||||
criticalCount: criticalIssues.length,
|
||||
warningCount: warningIssues.length,
|
||||
topIssuesCount: topIssues.length,
|
||||
groupedRecordCount: groupedIssues.length,
|
||||
});
|
||||
|
||||
return {
|
||||
projectId,
|
||||
reportType,
|
||||
generatedAt: generatedAt.toISOString(),
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
summary,
|
||||
criticalIssues,
|
||||
warningIssues,
|
||||
formStats,
|
||||
topIssues,
|
||||
groupedIssues,
|
||||
llmFriendlyXml,
|
||||
legacyXml,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 聚合质控统计
|
||||
*
|
||||
* V3.1: 修复记录数统计和 issues 格式兼容性
|
||||
*/
|
||||
private async aggregateStats(projectId: string): Promise<ReportSummary> {
|
||||
// 获取记录汇总(用于 completedRecords 统计)
|
||||
const recordSummaries = await prisma.iitRecordSummary.findMany({
|
||||
where: { projectId },
|
||||
});
|
||||
|
||||
const completedRecords = recordSummaries.filter(r =>
|
||||
r.completionRate && (r.completionRate as number) >= 100
|
||||
).length;
|
||||
|
||||
// V3.1: 获取每个 record+event 的最新质控日志(避免重复)
|
||||
const latestQcLogs = await prisma.$queryRaw<Array<{
|
||||
record_id: string;
|
||||
event_id: string | null;
|
||||
form_name: string | null;
|
||||
status: string;
|
||||
issues: any;
|
||||
created_at: Date;
|
||||
}>>`
|
||||
SELECT DISTINCT ON (record_id, COALESCE(event_id, ''))
|
||||
record_id, event_id, form_name, status, issues, created_at
|
||||
FROM iit_schema.qc_logs
|
||||
WHERE project_id = ${projectId}
|
||||
ORDER BY record_id, COALESCE(event_id, ''), created_at DESC
|
||||
`;
|
||||
|
||||
// V3.1: 从质控日志获取独立 record_id 数量
|
||||
const uniqueRecordIds = new Set(latestQcLogs.map(log => log.record_id));
|
||||
const totalRecords = uniqueRecordIds.size;
|
||||
|
||||
// V3.1: 统计问题数量(按 recordId + ruleId 去重)
|
||||
const seenCritical = new Set<string>();
|
||||
const seenWarning = new Set<string>();
|
||||
let pendingQueries = 0;
|
||||
|
||||
for (const log of latestQcLogs) {
|
||||
// V3.1: 兼容两种 issues 格式
|
||||
const rawIssues = log.issues as any;
|
||||
let issues: any[] = [];
|
||||
|
||||
if (Array.isArray(rawIssues)) {
|
||||
issues = rawIssues;
|
||||
} else if (rawIssues && Array.isArray(rawIssues.items)) {
|
||||
issues = rawIssues.items;
|
||||
}
|
||||
|
||||
for (const issue of issues) {
|
||||
const ruleId = issue.ruleId || issue.ruleName || 'unknown';
|
||||
const key = `${log.record_id}:${ruleId}`;
|
||||
const severity = issue.severity || issue.level;
|
||||
|
||||
if (severity === 'critical' || severity === 'RED' || severity === 'error') {
|
||||
seenCritical.add(key);
|
||||
} else if (severity === 'warning' || severity === 'YELLOW') {
|
||||
seenWarning.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
if (log.status === 'UNCERTAIN' || log.status === 'PENDING') {
|
||||
pendingQueries++;
|
||||
}
|
||||
}
|
||||
|
||||
const criticalIssues = seenCritical.size;
|
||||
const warningIssues = seenWarning.size;
|
||||
|
||||
// 计算通过率
|
||||
const passedRecords = latestQcLogs.filter(log =>
|
||||
log.status === 'PASS' || log.status === 'GREEN'
|
||||
).length;
|
||||
const passRate = totalRecords > 0
|
||||
? Math.round((passedRecords / totalRecords) * 100 * 10) / 10
|
||||
: 0;
|
||||
|
||||
// 获取最后质控时间
|
||||
const lastQcLog = await prisma.iitQcLog.findFirst({
|
||||
where: { projectId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: { createdAt: true },
|
||||
});
|
||||
|
||||
return {
|
||||
totalRecords,
|
||||
completedRecords,
|
||||
criticalIssues,
|
||||
warningIssues,
|
||||
pendingQueries,
|
||||
passRate,
|
||||
lastQcTime: lastQcLog?.createdAt?.toISOString() || null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取问题列表
|
||||
*/
|
||||
private async getIssues(projectId: string): Promise<{
|
||||
criticalIssues: ReportIssue[];
|
||||
warningIssues: ReportIssue[];
|
||||
}> {
|
||||
// V3.1: 获取每个 record+event 的最新质控日志
|
||||
const latestQcLogs = await prisma.$queryRaw<Array<{
|
||||
record_id: string;
|
||||
event_id: string | null;
|
||||
form_name: string | null;
|
||||
status: string;
|
||||
issues: any;
|
||||
created_at: Date;
|
||||
}>>`
|
||||
SELECT DISTINCT ON (record_id, COALESCE(event_id, ''))
|
||||
record_id, event_id, form_name, status, issues, created_at
|
||||
FROM iit_schema.qc_logs
|
||||
WHERE project_id = ${projectId}
|
||||
ORDER BY record_id, COALESCE(event_id, ''), created_at DESC
|
||||
`;
|
||||
|
||||
const criticalIssues: ReportIssue[] = [];
|
||||
const warningIssues: ReportIssue[] = [];
|
||||
|
||||
for (const log of latestQcLogs) {
|
||||
// V2.1: 兼容两种 issues 格式
|
||||
// 新格式: { items: [...], summary: {...} }
|
||||
// 旧格式: [...]
|
||||
const rawIssues = log.issues as any;
|
||||
let issues: any[] = [];
|
||||
|
||||
if (Array.isArray(rawIssues)) {
|
||||
// 旧格式:直接是数组
|
||||
issues = rawIssues;
|
||||
} else if (rawIssues && Array.isArray(rawIssues.items)) {
|
||||
// 新格式:对象包含 items 数组
|
||||
issues = rawIssues.items;
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const issue of issues) {
|
||||
// V2.1: 构建自包含的 LLM 友好消息
|
||||
const llmMessage = this.buildSelfContainedMessage(issue);
|
||||
|
||||
const reportIssue: ReportIssue = {
|
||||
recordId: log.record_id,
|
||||
ruleId: issue.ruleId || issue.ruleName || 'unknown',
|
||||
ruleName: issue.ruleName || issue.message || 'Unknown Rule',
|
||||
severity: this.normalizeSeverity(issue.severity || issue.level),
|
||||
message: llmMessage, // V2.1: 使用自包含消息
|
||||
field: issue.field,
|
||||
actualValue: issue.actualValue,
|
||||
expectedValue: issue.expectedValue || this.extractExpectedFromMessage(issue.message),
|
||||
evidence: issue.evidence,
|
||||
detectedAt: log.created_at.toISOString(),
|
||||
};
|
||||
|
||||
if (reportIssue.severity === 'critical') {
|
||||
criticalIssues.push(reportIssue);
|
||||
} else if (reportIssue.severity === 'warning') {
|
||||
warningIssues.push(reportIssue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// V3.1: 按 recordId + ruleId 去重,保留最新的问题(避免多事件重复)
|
||||
const deduplicateCritical = this.deduplicateIssues(criticalIssues);
|
||||
const deduplicateWarning = this.deduplicateIssues(warningIssues);
|
||||
|
||||
return { criticalIssues: deduplicateCritical, warningIssues: deduplicateWarning };
|
||||
}
|
||||
|
||||
/**
|
||||
* V3.1: 按 recordId + ruleId 去重问题
|
||||
*
|
||||
* 同一记录的同一规则只保留一条(最新的,因为数据已按时间倒序排列)
|
||||
*/
|
||||
private deduplicateIssues(issues: ReportIssue[]): ReportIssue[] {
|
||||
const seen = new Map<string, ReportIssue>();
|
||||
|
||||
for (const issue of issues) {
|
||||
const key = `${issue.recordId}:${issue.ruleId}`;
|
||||
if (!seen.has(key)) {
|
||||
seen.set(key, issue);
|
||||
}
|
||||
// 如果已存在,跳过(因为按时间倒序,第一个就是最新的)
|
||||
}
|
||||
|
||||
return Array.from(seen.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* V2.1: 构建自包含的 LLM 友好消息
|
||||
*/
|
||||
private buildSelfContainedMessage(issue: any): string {
|
||||
const ruleName = issue.ruleName || issue.message || 'Unknown';
|
||||
const actualValue = issue.actualValue;
|
||||
const expectedValue = issue.expectedValue || this.extractExpectedFromMessage(issue.message);
|
||||
|
||||
// 如果已经有自包含格式,直接返回
|
||||
if (issue.llmMessage) {
|
||||
return issue.llmMessage;
|
||||
}
|
||||
|
||||
// 构建自包含格式
|
||||
const actualDisplay = actualValue !== undefined && actualValue !== null && actualValue !== ''
|
||||
? `**${actualValue}**`
|
||||
: '**空**';
|
||||
|
||||
if (expectedValue) {
|
||||
return `**${ruleName}**: 当前值 ${actualDisplay} (标准: ${expectedValue})`;
|
||||
}
|
||||
|
||||
return `**${ruleName}**: 当前值 ${actualDisplay}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* V2.1: 从原始消息中提取期望值
|
||||
* 例如:"年龄不在 25-35 岁范围内" -> "25-35 岁"
|
||||
*/
|
||||
private extractExpectedFromMessage(message: string): string | undefined {
|
||||
if (!message) return undefined;
|
||||
|
||||
// 尝试提取数字范围:如 "25-35"
|
||||
const rangeMatch = message.match(/(\d+)\s*[-~至到]\s*(\d+)/);
|
||||
if (rangeMatch) {
|
||||
return `${rangeMatch[1]}-${rangeMatch[2]}`;
|
||||
}
|
||||
|
||||
// 尝试提取日期范围
|
||||
const dateRangeMatch = message.match(/(\d{4}-\d{2}-\d{2})\s*至\s*(\d{4}-\d{2}-\d{2})/);
|
||||
if (dateRangeMatch) {
|
||||
return `${dateRangeMatch[1]} 至 ${dateRangeMatch[2]}`;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 标准化严重程度
|
||||
*/
|
||||
private normalizeSeverity(severity: string): 'critical' | 'warning' | 'info' {
|
||||
const lower = (severity || '').toLowerCase();
|
||||
if (lower === 'critical' || lower === 'red' || lower === 'error') {
|
||||
return 'critical';
|
||||
} else if (lower === 'warning' || lower === 'yellow') {
|
||||
return 'warning';
|
||||
}
|
||||
return 'info';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取表单统计
|
||||
*/
|
||||
private async getFormStats(projectId: string): Promise<FormStats[]> {
|
||||
// 获取表单标签映射
|
||||
const project = await prisma.iitProject.findUnique({
|
||||
where: { id: projectId },
|
||||
select: { fieldMappings: true },
|
||||
});
|
||||
const formLabels: Record<string, string> =
|
||||
((project?.fieldMappings as any)?.formLabels) || {};
|
||||
|
||||
// 按表单统计
|
||||
const formStatsRaw = await prisma.$queryRaw<Array<{
|
||||
form_name: string;
|
||||
total: bigint;
|
||||
passed: bigint;
|
||||
failed: bigint;
|
||||
}>>`
|
||||
SELECT
|
||||
form_name,
|
||||
COUNT(*) as total,
|
||||
COUNT(*) FILTER (WHERE status = 'PASS' OR status = 'GREEN') as passed,
|
||||
COUNT(*) FILTER (WHERE status = 'FAIL' OR status = 'RED') as failed
|
||||
FROM (
|
||||
SELECT DISTINCT ON (record_id, form_name)
|
||||
record_id, form_name, status
|
||||
FROM iit_schema.qc_logs
|
||||
WHERE project_id = ${projectId} AND form_name IS NOT NULL
|
||||
ORDER BY record_id, form_name, created_at DESC
|
||||
) latest_logs
|
||||
GROUP BY form_name
|
||||
ORDER BY form_name
|
||||
`;
|
||||
|
||||
return formStatsRaw.map(row => {
|
||||
const total = Number(row.total);
|
||||
const passed = Number(row.passed);
|
||||
const failed = Number(row.failed);
|
||||
return {
|
||||
formName: row.form_name,
|
||||
formLabel: formLabels[row.form_name] || this.formatFormName(row.form_name),
|
||||
totalChecks: total,
|
||||
passed,
|
||||
failed,
|
||||
passRate: total > 0 ? Math.round((passed / total) * 100 * 10) / 10 : 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化表单名称
|
||||
*/
|
||||
private formatFormName(formName: string): string {
|
||||
return formName
|
||||
.replace(/_/g, ' ')
|
||||
.replace(/\b\w/g, c => c.toUpperCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* V2.1: 按受试者分组问题
|
||||
*/
|
||||
private groupIssuesByRecord(issues: ReportIssue[]): GroupedIssues[] {
|
||||
const grouped = new Map<string, ReportIssue[]>();
|
||||
|
||||
for (const issue of issues) {
|
||||
const existing = grouped.get(issue.recordId) || [];
|
||||
existing.push(issue);
|
||||
grouped.set(issue.recordId, existing);
|
||||
}
|
||||
|
||||
return Array.from(grouped.entries())
|
||||
.map(([recordId, issues]) => ({
|
||||
recordId,
|
||||
issueCount: issues.length,
|
||||
issues,
|
||||
}))
|
||||
.sort((a, b) => a.recordId.localeCompare(b.recordId, undefined, { numeric: true }));
|
||||
}
|
||||
|
||||
/**
|
||||
* V2.1: 计算 Top Issues 统计
|
||||
*/
|
||||
private calculateTopIssues(issues: ReportIssue[], limit: number = 5): TopIssue[] {
|
||||
const ruleStats = new Map<string, { ruleName: string; ruleId: string; records: Set<string> }>();
|
||||
|
||||
for (const issue of issues) {
|
||||
const key = issue.ruleId || issue.ruleName;
|
||||
const existing = ruleStats.get(key) || {
|
||||
ruleName: issue.ruleName,
|
||||
ruleId: issue.ruleId,
|
||||
records: new Set<string>(),
|
||||
};
|
||||
existing.records.add(issue.recordId);
|
||||
ruleStats.set(key, existing);
|
||||
}
|
||||
|
||||
return Array.from(ruleStats.values())
|
||||
.map(stat => ({
|
||||
ruleName: stat.ruleName,
|
||||
ruleId: stat.ruleId,
|
||||
count: stat.records.size,
|
||||
affectedRecords: Array.from(stat.records),
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建 LLM 友好的 XML 报告
|
||||
*
|
||||
* V2.1 优化:
|
||||
* - 按受试者分组
|
||||
* - 添加 Top Issues 统计
|
||||
* - 自包含的 message 格式
|
||||
*/
|
||||
private buildLlmXmlReport(
|
||||
projectId: string,
|
||||
projectName: string,
|
||||
summary: ReportSummary,
|
||||
criticalIssues: ReportIssue[],
|
||||
warningIssues: ReportIssue[],
|
||||
formStats: FormStats[]
|
||||
): string {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// V2.1: 计算 Top Issues
|
||||
const allIssues = [...criticalIssues, ...warningIssues];
|
||||
const topIssues = this.calculateTopIssues(allIssues);
|
||||
|
||||
// V2.1: 按受试者分组
|
||||
const groupedCritical = this.groupIssuesByRecord(criticalIssues);
|
||||
const failedRecordCount = groupedCritical.length;
|
||||
|
||||
let xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<qc_context project_id="${projectId}" project_name="${this.escapeXml(projectName)}" generated="${now}">
|
||||
|
||||
<!-- 1. 宏观统计 (Aggregate) -->
|
||||
<summary>
|
||||
- 状态: ${failedRecordCount}/${summary.totalRecords} 记录存在严重违规 (${summary.totalRecords > 0 ? Math.round((failedRecordCount / summary.totalRecords) * 100) : 0}% Fail)
|
||||
- 通过率: ${summary.passRate}%
|
||||
- 严重问题: ${summary.criticalIssues} | 警告: ${summary.warningIssues}
|
||||
${topIssues.length > 0 ? ` - Top ${Math.min(3, topIssues.length)} 问题:
|
||||
${topIssues.slice(0, 3).map((t, i) => ` ${i + 1}. ${t.ruleName} (${t.count}人)`).join('\n')}` : ''}
|
||||
</summary>
|
||||
|
||||
`;
|
||||
|
||||
// V3.1: 严重问题详情(按受试者分组,显示所有问题)
|
||||
if (groupedCritical.length > 0) {
|
||||
xml += ` <!-- 2. 严重问题详情 (按受试者分组) -->\n`;
|
||||
xml += ` <critical_issues record_count="${groupedCritical.length}" issue_count="${criticalIssues.length}">\n\n`;
|
||||
|
||||
for (const group of groupedCritical) {
|
||||
xml += ` <record id="${group.recordId}">\n`;
|
||||
xml += ` **严重违规 (${group.issueCount}项)**:\n`;
|
||||
|
||||
// V3.1: 显示所有问题,不再限制
|
||||
for (let i = 0; i < group.issues.length; i++) {
|
||||
const issue = group.issues[i];
|
||||
const llmLine = this.buildLlmIssueLine(issue, i + 1);
|
||||
xml += ` ${llmLine}\n`;
|
||||
}
|
||||
|
||||
xml += ` </record>\n\n`;
|
||||
}
|
||||
|
||||
xml += ` </critical_issues>\n\n`;
|
||||
} else {
|
||||
xml += ` <critical_issues record_count="0" issue_count="0" />\n\n`;
|
||||
}
|
||||
|
||||
// V3.1: 警告问题(显示所有)
|
||||
const groupedWarning = this.groupIssuesByRecord(warningIssues);
|
||||
if (groupedWarning.length > 0) {
|
||||
xml += ` <!-- 3. 警告问题 -->\n`;
|
||||
xml += ` <warning_issues record_count="${groupedWarning.length}" issue_count="${warningIssues.length}">\n`;
|
||||
|
||||
for (const group of groupedWarning) {
|
||||
xml += ` <record id="${group.recordId}">\n`;
|
||||
xml += ` **警告 (${group.issueCount}项)**:\n`;
|
||||
for (let i = 0; i < group.issues.length; i++) {
|
||||
const issue = group.issues[i];
|
||||
const llmLine = this.buildLlmIssueLine(issue, i + 1);
|
||||
xml += ` ${llmLine}\n`;
|
||||
}
|
||||
xml += ` </record>\n`;
|
||||
}
|
||||
|
||||
xml += ` </warning_issues>\n\n`;
|
||||
}
|
||||
|
||||
xml += `</qc_context>`;
|
||||
|
||||
return xml;
|
||||
}
|
||||
|
||||
/**
|
||||
* V2.1: 构建 LLM 友好的单行问题描述
|
||||
*
|
||||
* 格式: [规则ID] **问题类型**: 当前 **值** (标准: xxx)
|
||||
*/
|
||||
private buildLlmIssueLine(issue: ReportIssue, index: number): string {
|
||||
const ruleId = issue.ruleId !== 'unknown' ? `[${issue.ruleId}]` : '';
|
||||
|
||||
// 尝试使用 llmMessage(如果 HardRuleEngine 已经生成)
|
||||
if (issue.message && issue.message.includes('**')) {
|
||||
// 已经是自包含格式
|
||||
return `${index}. ${ruleId} ${issue.message}`;
|
||||
}
|
||||
|
||||
// 回退:手动构建
|
||||
const actualDisplay = issue.actualValue !== undefined && issue.actualValue !== null && issue.actualValue !== ''
|
||||
? `**${issue.actualValue}**`
|
||||
: '**空**';
|
||||
|
||||
const expectedDisplay = issue.expectedValue
|
||||
? ` (标准: ${issue.expectedValue})`
|
||||
: '';
|
||||
|
||||
return `${index}. ${ruleId} **${issue.ruleName}**: 当前值 ${actualDisplay}${expectedDisplay}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建原始 XML 报告(兼容旧格式)
|
||||
*/
|
||||
private buildLegacyXmlReport(
|
||||
projectId: string,
|
||||
projectName: string,
|
||||
summary: ReportSummary,
|
||||
criticalIssues: ReportIssue[],
|
||||
warningIssues: ReportIssue[],
|
||||
formStats: FormStats[]
|
||||
): string {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
let xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<qc_report project_id="${projectId}" project_name="${this.escapeXml(projectName)}" generated="${now}">
|
||||
|
||||
<summary>
|
||||
<total_records>${summary.totalRecords}</total_records>
|
||||
<completed_records>${summary.completedRecords}</completed_records>
|
||||
<critical_issues>${summary.criticalIssues}</critical_issues>
|
||||
<warning_issues>${summary.warningIssues}</warning_issues>
|
||||
<pending_queries>${summary.pendingQueries}</pending_queries>
|
||||
<pass_rate>${summary.passRate}%</pass_rate>
|
||||
<last_qc_time>${summary.lastQcTime || 'N/A'}</last_qc_time>
|
||||
</summary>
|
||||
|
||||
`;
|
||||
|
||||
// V3.1: 严重问题列表(显示所有)
|
||||
if (criticalIssues.length > 0) {
|
||||
xml += ` <critical_issues count="${criticalIssues.length}">\n`;
|
||||
for (const issue of criticalIssues) {
|
||||
xml += this.buildIssueXml(issue, ' ');
|
||||
}
|
||||
xml += ` </critical_issues>\n\n`;
|
||||
} else {
|
||||
xml += ` <critical_issues count="0" />\n\n`;
|
||||
}
|
||||
|
||||
// V3.1: 警告问题列表(显示所有)
|
||||
if (warningIssues.length > 0) {
|
||||
xml += ` <warning_issues count="${warningIssues.length}">\n`;
|
||||
for (const issue of warningIssues) {
|
||||
xml += this.buildIssueXml(issue, ' ');
|
||||
}
|
||||
xml += ` </warning_issues>\n\n`;
|
||||
} else {
|
||||
xml += ` <warning_issues count="0" />\n\n`;
|
||||
}
|
||||
|
||||
// 表单统计
|
||||
if (formStats.length > 0) {
|
||||
xml += ` <form_statistics>\n`;
|
||||
for (const form of formStats) {
|
||||
xml += ` <form name="${this.escapeXml(form.formName)}" label="${this.escapeXml(form.formLabel)}">\n`;
|
||||
xml += ` <total_checks>${form.totalChecks}</total_checks>\n`;
|
||||
xml += ` <passed>${form.passed}</passed>\n`;
|
||||
xml += ` <failed>${form.failed}</failed>\n`;
|
||||
xml += ` <pass_rate>${form.passRate}%</pass_rate>\n`;
|
||||
xml += ` </form>\n`;
|
||||
}
|
||||
xml += ` </form_statistics>\n\n`;
|
||||
}
|
||||
|
||||
xml += `</qc_report>`;
|
||||
|
||||
return xml;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建单个问题的 XML
|
||||
*/
|
||||
private buildIssueXml(issue: ReportIssue, indent: string): string {
|
||||
let xml = `${indent}<issue record="${issue.recordId}" rule="${this.escapeXml(issue.ruleId)}" severity="${issue.severity}">\n`;
|
||||
xml += `${indent} <rule_name>${this.escapeXml(issue.ruleName)}</rule_name>\n`;
|
||||
xml += `${indent} <message>${this.escapeXml(issue.message)}</message>\n`;
|
||||
|
||||
if (issue.field) {
|
||||
xml += `${indent} <field>${this.escapeXml(String(issue.field))}</field>\n`;
|
||||
}
|
||||
if (issue.actualValue !== undefined) {
|
||||
xml += `${indent} <actual_value>${this.escapeXml(String(issue.actualValue))}</actual_value>\n`;
|
||||
}
|
||||
if (issue.expectedValue !== undefined) {
|
||||
xml += `${indent} <expected_value>${this.escapeXml(String(issue.expectedValue))}</expected_value>\n`;
|
||||
}
|
||||
if (issue.evidence && Object.keys(issue.evidence).length > 0) {
|
||||
xml += `${indent} <evidence>\n`;
|
||||
for (const [key, value] of Object.entries(issue.evidence)) {
|
||||
xml += `${indent} <${this.escapeXml(key)}>${this.escapeXml(String(value))}</${this.escapeXml(key)}>\n`;
|
||||
}
|
||||
xml += `${indent} </evidence>\n`;
|
||||
}
|
||||
|
||||
xml += `${indent} <detected_at>${issue.detectedAt}</detected_at>\n`;
|
||||
xml += `${indent}</issue>\n`;
|
||||
|
||||
return xml;
|
||||
}
|
||||
|
||||
/**
|
||||
* XML 转义
|
||||
*/
|
||||
private escapeXml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* 缓存报告
|
||||
*/
|
||||
private async cacheReport(report: QcReport): Promise<void> {
|
||||
try {
|
||||
await prisma.iitQcReport.create({
|
||||
data: {
|
||||
projectId: report.projectId,
|
||||
reportType: report.reportType,
|
||||
summary: report.summary as any,
|
||||
issues: {
|
||||
critical: report.criticalIssues,
|
||||
warning: report.warningIssues,
|
||||
formStats: report.formStats,
|
||||
topIssues: report.topIssues, // V2.1
|
||||
groupedIssues: report.groupedIssues, // V2.1
|
||||
legacyXml: report.legacyXml, // V2.1
|
||||
} as any,
|
||||
llmReport: report.llmFriendlyXml,
|
||||
generatedAt: new Date(report.generatedAt),
|
||||
expiresAt: report.expiresAt ? new Date(report.expiresAt) : null,
|
||||
},
|
||||
});
|
||||
|
||||
logger.debug('[QcReportService] Report cached', {
|
||||
projectId: report.projectId,
|
||||
reportType: report.reportType,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.warn('[QcReportService] Failed to cache report', {
|
||||
projectId: report.projectId,
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 LLM 友好的报告(用于问答)
|
||||
*
|
||||
* @param projectId 项目 ID
|
||||
* @param format 格式:'llm-friendly' (默认) 或 'xml' (兼容格式)
|
||||
* @returns XML 报告
|
||||
*/
|
||||
async getLlmReport(projectId: string, format: 'llm-friendly' | 'xml' = 'llm-friendly'): Promise<string> {
|
||||
const report = await this.getReport(projectId);
|
||||
|
||||
if (format === 'xml') {
|
||||
return report.legacyXml || report.llmFriendlyXml;
|
||||
}
|
||||
|
||||
return report.llmFriendlyXml;
|
||||
}
|
||||
|
||||
/**
|
||||
* V2.1: 获取 Top Issues 统计
|
||||
*/
|
||||
async getTopIssues(projectId: string, limit: number = 5): Promise<TopIssue[]> {
|
||||
const report = await this.getReport(projectId);
|
||||
return report.topIssues.slice(0, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* V2.1: 获取按受试者分组的问题
|
||||
*/
|
||||
async getGroupedIssues(projectId: string): Promise<GroupedIssues[]> {
|
||||
const report = await this.getReport(projectId);
|
||||
return report.groupedIssues;
|
||||
}
|
||||
|
||||
/**
|
||||
* 强制刷新报告
|
||||
*
|
||||
* @param projectId 项目 ID
|
||||
* @returns 新生成的报告
|
||||
*/
|
||||
async refreshReport(projectId: string): Promise<QcReport> {
|
||||
return this.getReport(projectId, { forceRefresh: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理过期报告
|
||||
*/
|
||||
async cleanupExpiredReports(): Promise<number> {
|
||||
const result = await prisma.iitQcReport.deleteMany({
|
||||
where: {
|
||||
expiresAt: {
|
||||
lt: new Date(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
logger.info('[QcReportService] Cleaned up expired reports', {
|
||||
count: result.count,
|
||||
});
|
||||
|
||||
return result.count;
|
||||
}
|
||||
}
|
||||
|
||||
// 单例导出
|
||||
export const QcReportService = new QcReportServiceClass();
|
||||
@@ -17,6 +17,7 @@ import { PrismaClient } from '@prisma/client';
|
||||
import { logger } from '../../../common/logging/index.js';
|
||||
import { RedcapAdapter } from '../adapters/RedcapAdapter.js';
|
||||
import { createHardRuleEngine, QCResult } from '../engines/HardRuleEngine.js';
|
||||
import { createSkillRunner } from '../engines/SkillRunner.js';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
@@ -430,10 +431,10 @@ export class ToolsService {
|
||||
}
|
||||
});
|
||||
|
||||
// 3. batch_quality_check - 批量质控
|
||||
// 3. batch_quality_check - 批量质控(事件级)
|
||||
this.registerTool({
|
||||
name: 'batch_quality_check',
|
||||
description: '对所有患者数据执行批量质控检查,返回汇总统计。',
|
||||
description: '对所有患者数据执行事件级批量质控检查,每个 record+event 组合独立质控。',
|
||||
category: 'compute',
|
||||
parameters: [],
|
||||
execute: async (params, context) => {
|
||||
@@ -442,66 +443,72 @@ export class ToolsService {
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. 获取所有记录
|
||||
const allRecords = await context.redcapAdapter.exportRecords({});
|
||||
if (allRecords.length === 0) {
|
||||
// ⭐ 使用 SkillRunner 进行事件级质控
|
||||
const runner = await createSkillRunner(context.projectId);
|
||||
const results = await runner.runByTrigger('manual');
|
||||
|
||||
if (results.length === 0) {
|
||||
return {
|
||||
success: true,
|
||||
data: { message: '暂无记录' }
|
||||
data: { message: '暂无记录或未配置质控规则' }
|
||||
};
|
||||
}
|
||||
|
||||
// 2. 去重
|
||||
const recordMap = new Map<string, Record<string, any>>();
|
||||
for (const r of allRecords) {
|
||||
if (!recordMap.has(r.record_id)) {
|
||||
recordMap.set(r.record_id, r);
|
||||
}
|
||||
// 统计汇总(按 record+event 组合)
|
||||
const passCount = results.filter(r => r.overallStatus === 'PASS').length;
|
||||
const failCount = results.filter(r => r.overallStatus === 'FAIL').length;
|
||||
const warningCount = results.filter(r => r.overallStatus === 'WARNING').length;
|
||||
const uncertainCount = results.filter(r => r.overallStatus === 'UNCERTAIN').length;
|
||||
|
||||
// 按 recordId 分组统计
|
||||
const recordEventMap = new Map<string, { events: number; passed: number; failed: number }>();
|
||||
for (const r of results) {
|
||||
const stats = recordEventMap.get(r.recordId) || { events: 0, passed: 0, failed: 0 };
|
||||
stats.events++;
|
||||
if (r.overallStatus === 'PASS') stats.passed++;
|
||||
if (r.overallStatus === 'FAIL') stats.failed++;
|
||||
recordEventMap.set(r.recordId, stats);
|
||||
}
|
||||
|
||||
// 3. 批量质控
|
||||
const engine = await createHardRuleEngine(context.projectId);
|
||||
const records = Array.from(recordMap.entries()).map(([id, data]) => ({
|
||||
recordId: id,
|
||||
data
|
||||
}));
|
||||
|
||||
const qcResults = engine.executeBatch(records);
|
||||
|
||||
// 4. 统计汇总
|
||||
const passCount = qcResults.filter(r => r.overallStatus === 'PASS').length;
|
||||
const failCount = qcResults.filter(r => r.overallStatus === 'FAIL').length;
|
||||
const warningCount = qcResults.filter(r => r.overallStatus === 'WARNING').length;
|
||||
|
||||
// 5. 问题记录
|
||||
const problemRecords = qcResults
|
||||
// 问题记录(取前10个问题 record+event 组合)
|
||||
const problemRecords = results
|
||||
.filter(r => r.overallStatus !== 'PASS')
|
||||
.slice(0, 10)
|
||||
.map(r => ({
|
||||
recordId: r.recordId,
|
||||
eventName: r.eventName,
|
||||
eventLabel: r.eventLabel,
|
||||
forms: r.forms,
|
||||
status: r.overallStatus,
|
||||
issues: [...r.errors, ...r.warnings].slice(0, 3).map(i => ({
|
||||
issues: r.allIssues?.slice(0, 3).map((i: any) => ({
|
||||
rule: i.ruleName,
|
||||
message: i.message
|
||||
}))
|
||||
message: i.message,
|
||||
severity: i.severity
|
||||
})) || []
|
||||
}));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
totalRecords: records.length,
|
||||
totalRecordEventCombinations: results.length,
|
||||
uniqueRecords: recordEventMap.size,
|
||||
summary: {
|
||||
pass: passCount,
|
||||
fail: failCount,
|
||||
warning: warningCount,
|
||||
passRate: `${((passCount / records.length) * 100).toFixed(1)}%`
|
||||
uncertain: uncertainCount,
|
||||
passRate: `${((passCount / results.length) * 100).toFixed(1)}%`
|
||||
},
|
||||
problemRecords
|
||||
problemRecords,
|
||||
recordStats: Array.from(recordEventMap.entries()).map(([recordId, stats]) => ({
|
||||
recordId,
|
||||
...stats
|
||||
}))
|
||||
},
|
||||
metadata: {
|
||||
executionTime: 0,
|
||||
recordCount: records.length,
|
||||
source: 'HardRuleEngine'
|
||||
source: 'SkillRunner-EventLevel',
|
||||
version: 'v3.1'
|
||||
}
|
||||
};
|
||||
} catch (error: any) {
|
||||
|
||||
10
backend/src/modules/iit-manager/services/index.ts
Normal file
10
backend/src/modules/iit-manager/services/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* IIT Manager Services 导出
|
||||
*/
|
||||
|
||||
export * from './ChatService.js';
|
||||
export * from './PromptBuilder.js';
|
||||
export * from './QcService.js';
|
||||
export * from './QcReportService.js';
|
||||
export * from './SyncManager.js';
|
||||
export * from './ToolsService.js';
|
||||
Reference in New Issue
Block a user