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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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;
}
}

View File

@@ -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})`;
}
}
/**
* 获取字段值(支持映射)
*/

View 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);
}

View 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',
},
];

View File

@@ -3,4 +3,6 @@
*/
export * from './HardRuleEngine.js';
export * from './SoftRuleEngine.js';
export * from './SopEngine.js';
export * from './SkillRunner.js';

View File

@@ -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'
);
}
/**
* 格式化质控报告为 XMLV3.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'
);
}
/**
* 清除用户会话(用于重置对话)
*/

View 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;

View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
/**
* 缓存报告
*/
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();

View File

@@ -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) {

View 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';