diff --git a/backend/debug-form-events.ts b/backend/debug-form-events.ts new file mode 100644 index 00000000..f59dd55f --- /dev/null +++ b/backend/debug-form-events.ts @@ -0,0 +1,175 @@ +/** + * 调试脚本:诊断表单和事件问题 + * + * 问题1:表单数量 - 应该是19个(含重复访视),但只识别7个 + * 问题2:Age 为空 - 即使 Complete 状态,某些记录 age 仍为空 + */ +import { PrismaClient } from '@prisma/client'; +import { RedcapAdapter } from './src/modules/iit-manager/adapters/RedcapAdapter.js'; + +const prisma = new PrismaClient(); + +async function main() { + console.log('='.repeat(60)); + console.log('🔍 表单和事件诊断'); + console.log('='.repeat(60)); + + const project = await prisma.iitProject.findFirst({ + where: { name: { contains: 'test0207' } }, + }); + + if (!project) { + console.log('❌ 未找到项目'); + await prisma.$disconnect(); + return; + } + + const adapter = new RedcapAdapter(project.redcapUrl, project.redcapApiToken); + + // =============================== + // 诊断 1:获取事件和表单映射 + // =============================== + console.log('\n' + '='.repeat(60)); + console.log('📊 诊断 1:REDCap 事件和表单结构'); + console.log('='.repeat(60)); + + // 获取事件列表 + try { + const formData = new (await import('form-data')).default(); + formData.append('token', project.redcapApiToken); + formData.append('content', 'event'); + formData.append('format', 'json'); + + const axios = (await import('axios')).default; + const eventsResponse = await axios.post(`${project.redcapUrl}/api/`, formData, { + headers: formData.getHeaders() + }); + + const events = eventsResponse.data; + console.log(`\n📋 事件列表 (共 ${events.length} 个):`); + for (const event of events) { + console.log(` ${event.unique_event_name}: ${event.event_name}`); + } + } catch (error: any) { + console.log(' 获取事件列表失败:', error.message); + } + + // 获取表单-事件映射 + try { + const formData = new (await import('form-data')).default(); + formData.append('token', project.redcapApiToken); + formData.append('content', 'formEventMapping'); + formData.append('format', 'json'); + + const axios = (await import('axios')).default; + const mappingResponse = await axios.post(`${project.redcapUrl}/api/`, formData, { + headers: formData.getHeaders() + }); + + const mapping = mappingResponse.data; + console.log(`\n📋 表单-事件映射 (共 ${mapping.length} 个):`); + + // 按事件分组 + const eventForms = new Map(); + for (const m of mapping) { + if (!eventForms.has(m.unique_event_name)) { + eventForms.set(m.unique_event_name, []); + } + eventForms.get(m.unique_event_name)!.push(m.form); + } + + for (const [event, forms] of eventForms) { + console.log(`\n 📁 ${event}:`); + for (const form of forms) { + console.log(` - ${form}`); + } + } + + // 统计唯一表单 + const uniqueForms = [...new Set(mapping.map((m: any) => m.form))]; + console.log(`\n📊 统计:`); + console.log(` 唯一表单数: ${uniqueForms.length}`); + console.log(` 表单-事件映射总数: ${mapping.length}`); + console.log(` 这就是为什么显示19个而不是7个!`); + + } catch (error: any) { + console.log(' 获取表单-事件映射失败:', error.message); + } + + // =============================== + // 诊断 2:检查 Record 4, 5, 14 的 age 问题 + // =============================== + console.log('\n' + '='.repeat(60)); + console.log('📊 诊断 2:检查 age 为空的记录'); + console.log('='.repeat(60)); + + const problemRecords = ['4', '5', '14']; + + for (const recId of problemRecords) { + console.log(`\n === Record ${recId} ===`); + + // 获取原始数据(所有事件) + const rawRecords = await adapter.exportRecords({ records: [recId] }); + console.log(` 原始记录数: ${rawRecords.length} (多事件)`); + + for (const r of rawRecords) { + const event = r.redcap_event_name || '(无事件)'; + const age = r.age !== undefined && r.age !== '' ? r.age : '(空)'; + const dob = r.date_of_birth || '(空)'; + const formComplete = r.basic_demography_form_complete; + + console.log(` 事件: ${event}`); + console.log(` age: ${age}, dob: ${dob}, form_complete: ${formComplete}`); + } + + // 获取合并后数据 + const merged = await adapter.getRecordById(recId); + console.log(` 合并后: age=${merged?.age || '(空)'}, dob=${merged?.date_of_birth || '(空)'}`); + } + + // =============================== + // 诊断 3:检查 age 字段的元数据 + // =============================== + console.log('\n' + '='.repeat(60)); + console.log('📊 诊断 3:age 字段的元数据'); + console.log('='.repeat(60)); + + const calcFields = await adapter.getCalculatedFields(); + const ageField = calcFields.find(f => f.fieldName === 'age'); + + if (ageField) { + console.log(`\n 字段名: ${ageField.fieldName}`); + console.log(` 标签: ${ageField.fieldLabel}`); + console.log(` 表单: ${ageField.formName}`); + console.log(` 计算公式: ${ageField.calculation}`); + } else { + console.log('\n ⚠️ 未找到 age 计算字段!'); + + // 检查是否有其他年龄相关字段 + const metadata = await adapter.exportMetadata(); + const ageRelated = metadata.filter((f: any) => + f.field_name.toLowerCase().includes('age') || + f.field_label?.toLowerCase().includes('年龄') + ); + + console.log(`\n 年龄相关字段:`); + for (const f of ageRelated) { + console.log(` ${f.field_name} (${f.field_type}): ${f.field_label}`); + if (f.field_type === 'calc') { + console.log(` 公式: ${f.select_choices_or_calculations}`); + } + } + } + + console.log('\n' + '='.repeat(60)); + console.log('✅ 诊断完成'); + console.log('='.repeat(60)); + + await prisma.$disconnect(); +} + +main().catch(async (error) => { + console.error('❌ 脚本出错:', error); + await prisma.$disconnect(); + process.exit(1); +}); diff --git a/backend/debug-qc-2.ts b/backend/debug-qc-2.ts new file mode 100644 index 00000000..76b5a42f --- /dev/null +++ b/backend/debug-qc-2.ts @@ -0,0 +1,71 @@ +/** + * 调试脚本:检查 REDCap 实际数据和字段映射 + */ +import { PrismaClient } from '@prisma/client'; +import { RedcapAdapter } from './src/modules/iit-manager/adapters/RedcapAdapter.js'; + +const prisma = new PrismaClient(); + +async function main() { + console.log('=== 调试 REDCap 数据 ===\n'); + + // 1. 获取项目配置 + const project = await prisma.iitProject.findFirst({ + where: { name: { contains: 'test0207' } }, + select: { + id: true, + name: true, + redcapUrl: true, + redcapApiToken: true, + fieldMappings: true + } + }); + + if (!project) { + console.log('未找到项目'); + await prisma.$disconnect(); + return; + } + + console.log('项目:', { id: project.id, name: project.name }); + console.log('字段映射配置:', JSON.stringify(project.fieldMappings, null, 2)); + + // 2. 从 REDCap 获取 record_id=1 的实际数据 + const adapter = new RedcapAdapter(project.redcapUrl, project.redcapApiToken); + + try { + const record = await adapter.getRecordById('1'); + console.log('\n=== REDCap Record ID=1 实际数据 ==='); + console.log(JSON.stringify(record, null, 2)); + + // 特别检查年龄相关字段 + console.log('\n=== 年龄相关字段 ==='); + const ageFields = Object.entries(record || {}).filter(([key]) => + key.toLowerCase().includes('age') || + key.toLowerCase().includes('年龄') || + key.toLowerCase().includes('birth') || + key.toLowerCase().includes('出生') + ); + console.log('年龄相关字段:', ageFields); + + // 显示所有字段名 + console.log('\n=== 所有字段名 ==='); + console.log(Object.keys(record || {}).join(', ')); + + } catch (error: any) { + console.error('REDCap 查询失败:', error.message); + } + + // 3. 检查字段映射表 + const fieldMappings = await prisma.iitFieldMapping.findMany({ + where: { projectId: project.id }, + }); + console.log('\n=== 字段映射表 ==='); + for (const m of fieldMappings) { + console.log(` ${m.aliasName} -> ${m.actualName}`); + } + + await prisma.$disconnect(); +} + +main().catch(console.error); diff --git a/backend/debug-qc-field-mismatch.ts b/backend/debug-qc-field-mismatch.ts new file mode 100644 index 00000000..4b7df316 --- /dev/null +++ b/backend/debug-qc-field-mismatch.ts @@ -0,0 +1,155 @@ +/** + * 调试脚本:诊断字段获取问题 + * + * 问题: + * 1. Record 1 有 age=21,但质控显示空 + * 2. Record 3 应该有年龄但 API 返回无 + */ +import { PrismaClient } from '@prisma/client'; +import { RedcapAdapter } from './src/modules/iit-manager/adapters/RedcapAdapter.js'; +import jsonLogic from 'json-logic-js'; + +const prisma = new PrismaClient(); + +async function main() { + console.log('='.repeat(60)); + console.log('🔍 字段获取问题诊断'); + console.log('='.repeat(60)); + + // 1. 获取项目配置 + const project = await prisma.iitProject.findFirst({ + where: { name: { contains: 'test0207' } }, + }); + + if (!project) { + console.log('❌ 未找到项目'); + await prisma.$disconnect(); + return; + } + + // 2. 获取质控规则配置 + const skill = await prisma.iitSkill.findFirst({ + where: { projectId: project.id, isActive: true }, + }); + + if (!skill) { + console.log('❌ 未找到激活的 Skill'); + await prisma.$disconnect(); + return; + } + + const config = skill.config as any; + const rules = config?.rules || []; + + console.log('\n📋 质控规则中的字段配置:'); + for (const rule of rules) { + console.log(` ${rule.name}:`); + console.log(` field: "${rule.field}"`); + console.log(` logic: ${JSON.stringify(rule.logic)}`); + } + + // 3. 从 REDCap 获取原始数据(检查 Record 1 和 Record 3) + const adapter = new RedcapAdapter(project.redcapUrl, project.redcapApiToken); + + console.log('\n' + '='.repeat(60)); + console.log('📊 检查 Record 1 的原始 REDCap 数据'); + console.log('='.repeat(60)); + + // 获取 Record 1 的原始记录(不合并) + const rawRecords1 = await adapter.exportRecords({ records: ['1'] }); + console.log(`\nRecord 1 原始记录数: ${rawRecords1.length} (多事件)`); + for (const r of rawRecords1) { + console.log(` 事件: ${r.redcap_event_name || '(无)'}`); + console.log(` age: ${r.age !== undefined ? r.age : '(字段不存在)'}`); + console.log(` date_of_birth: ${r.date_of_birth || '(空)'}`); + } + + // 获取 Record 1 合并后的数据 + const merged1 = await adapter.getRecordById('1'); + console.log(`\nRecord 1 合并后数据:`); + console.log(` age: ${merged1?.age}`); + console.log(` date_of_birth: ${merged1?.date_of_birth}`); + + console.log('\n' + '='.repeat(60)); + console.log('📊 检查 Record 3 的原始 REDCap 数据'); + console.log('='.repeat(60)); + + const rawRecords3 = await adapter.exportRecords({ records: ['3'] }); + console.log(`\nRecord 3 原始记录数: ${rawRecords3.length} (多事件)`); + for (const r of rawRecords3) { + console.log(` 事件: ${r.redcap_event_name || '(无)'}`); + console.log(` age: ${r.age !== undefined ? r.age : '(字段不存在)'}`); + console.log(` date_of_birth: ${r.date_of_birth || '(空)'}`); + } + + const merged3 = await adapter.getRecordById('3'); + console.log(`\nRecord 3 合并后数据:`); + console.log(` age: ${merged3?.age}`); + console.log(` date_of_birth: ${merged3?.date_of_birth}`); + + // 4. 模拟质控规则执行,看看哪里出问题 + console.log('\n' + '='.repeat(60)); + console.log('📊 模拟质控规则执行 (Record 1)'); + console.log('='.repeat(60)); + + // 找到年龄规则 + const ageRule = rules.find((r: any) => r.name.includes('年龄')); + if (ageRule && merged1) { + console.log(`\n规则: ${ageRule.name}`); + console.log(` field: "${ageRule.field}"`); + console.log(` logic: ${JSON.stringify(ageRule.logic)}`); + + // 检查字段值 + const fieldValue = merged1[ageRule.field]; + console.log(`\n 从数据中获取 "${ageRule.field}" 的值: ${fieldValue} (类型: ${typeof fieldValue})`); + + // 尝试执行 JSON Logic + try { + const result = jsonLogic.apply(ageRule.logic, merged1); + console.log(` JSON Logic 执行结果: ${result}`); + } catch (error: any) { + console.log(` JSON Logic 执行失败: ${error.message}`); + } + + // 列出数据中所有可能的年龄相关字段 + console.log(`\n 数据中的年龄相关字段:`); + for (const [key, value] of Object.entries(merged1)) { + if (key.toLowerCase().includes('age') || key === ageRule.field) { + console.log(` ${key}: ${value}`); + } + } + } + + // 5. 检查 SkillRunner 如何获取记录 + console.log('\n' + '='.repeat(60)); + console.log('📊 检查 SkillRunner 获取记录的方式'); + console.log('='.repeat(60)); + + // 查看 getRecordsToProcess 的实现逻辑 + // 它可能使用不同的方法获取数据 + const allRecordsMerged = await adapter.getAllRecordsMerged(); + console.log(`\ngetAllRecordsMerged 返回 ${allRecordsMerged.length} 条记录`); + + const r1FromAll = allRecordsMerged.find(r => r.record_id === '1'); + const r3FromAll = allRecordsMerged.find(r => r.record_id === '3'); + + console.log(`\nRecord 1 from getAllRecordsMerged:`); + console.log(` age: ${r1FromAll?.age}`); + console.log(` date_of_birth: ${r1FromAll?.date_of_birth}`); + + console.log(`\nRecord 3 from getAllRecordsMerged:`); + console.log(` age: ${r3FromAll?.age}`); + console.log(` date_of_birth: ${r3FromAll?.date_of_birth}`); + + console.log('\n' + '='.repeat(60)); + console.log('✅ 诊断完成'); + console.log('='.repeat(60)); + + await prisma.$disconnect(); +} + +main().catch(async (error) => { + console.error('❌ 脚本出错:', error); + await prisma.$disconnect(); + process.exit(1); +}); diff --git a/backend/debug-qc.ts b/backend/debug-qc.ts new file mode 100644 index 00000000..caaa51c8 --- /dev/null +++ b/backend/debug-qc.ts @@ -0,0 +1,73 @@ +/** + * 调试脚本:检查质控数据和规则 + */ +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +async function main() { + console.log('=== 调试质控数据 ===\n'); + + // 1. 查找项目 + const projects = await prisma.iitProject.findMany({ + select: { id: true, name: true } + }); + console.log('所有项目:', projects); + + // 找 test0207 项目 + const project = projects.find(p => p.name?.includes('test0207') || p.id?.includes('test0207')); + if (!project) { + console.log('未找到 test0207 项目'); + await prisma.$disconnect(); + return; + } + console.log('\n选中项目:', project); + + // 2. 查看质控规则 + const skills = await prisma.iitSkill.findMany({ + where: { projectId: project.id }, + select: { name: true, skillType: true, config: true } + }); + console.log('\n质控技能:', skills.map(s => ({ name: s.name, type: s.skillType }))); + + // 找 qc_process 类型的规则 + const qcSkill = skills.find(s => s.skillType === 'qc_process'); + if (qcSkill) { + console.log('\n质控规则配置:'); + const config = qcSkill.config as any; + if (config?.rules) { + for (const rule of config.rules) { + console.log(` - ${rule.id}: ${rule.name}, field: ${rule.field}, message: ${rule.message}`); + } + } + } + + // 3. 查看最近的质控日志 + const logs = await prisma.iitQcLog.findMany({ + where: { projectId: project.id }, + take: 3, + orderBy: { createdAt: 'desc' }, + select: { recordId: true, status: true, issues: true, createdAt: true } + }); + console.log('\n最近质控日志:'); + for (const log of logs) { + console.log(` Record ${log.recordId}: ${log.status}`); + const issues = log.issues as any; + if (issues?.items) { + console.log(` Issues (${issues.items.length}):`, issues.items.slice(0, 2).map((i: any) => i.ruleName)); + } else if (Array.isArray(issues)) { + console.log(` Issues (${issues.length}):`, issues.slice(0, 2).map((i: any) => i.ruleName || i.message)); + } + } + + // 4. 查看 REDCap 适配器配置 + const projectFull = await prisma.iitProject.findUnique({ + where: { id: project.id }, + select: { redcapUrl: true, redcapProjectId: true } + }); + console.log('\nREDCap 配置:', { url: projectFull?.redcapUrl, projectId: projectFull?.redcapProjectId }); + + await prisma.$disconnect(); +} + +main().catch(console.error); diff --git a/backend/prisma/migrations/20260208134925_add_cra_qc_engine_support/migration.sql b/backend/prisma/migrations/20260208134925_add_cra_qc_engine_support/migration.sql new file mode 100644 index 00000000..b821f4fa --- /dev/null +++ b/backend/prisma/migrations/20260208134925_add_cra_qc_engine_support/migration.sql @@ -0,0 +1,30 @@ +-- CRA 智能质控引擎支持 +-- 扩展 IitSkill 表,新增 IitQcReport 表 + +-- AlterTable: 扩展 skills 表支持 CRA 五大规则体系 +ALTER TABLE "iit_schema"."skills" ADD COLUMN "level" TEXT NOT NULL DEFAULT 'normal', +ADD COLUMN "priority" INTEGER NOT NULL DEFAULT 100, +ADD COLUMN "required_tags" TEXT[] DEFAULT ARRAY[]::TEXT[], +ADD COLUMN "rule_type" TEXT NOT NULL DEFAULT 'HARD_RULE'; + +-- CreateTable: 质控报告存储表 +CREATE TABLE "iit_schema"."qc_reports" ( + "id" TEXT NOT NULL, + "project_id" TEXT NOT NULL, + "report_type" TEXT NOT NULL DEFAULT 'daily', + "summary" JSONB NOT NULL, + "issues" JSONB NOT NULL, + "llm_report" TEXT NOT NULL, + "generated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expires_at" TIMESTAMP(3), + + CONSTRAINT "qc_reports_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex: 报告索引 +CREATE INDEX "idx_qc_report_project_type" ON "iit_schema"."qc_reports"("project_id", "report_type"); + +CREATE INDEX "idx_qc_report_generated" ON "iit_schema"."qc_reports"("generated_at"); + +-- CreateIndex: Skill 触发类型索引 +CREATE INDEX "idx_iit_skill_active_trigger" ON "iit_schema"."skills"("is_active", "trigger_type"); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 18e5d9e7..11b45756 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -959,19 +959,26 @@ model IitAuditLog { /// Skill 配置存储表 - 存储质控规则、SOP流程图 /// 支持 webhook/cron/event 三种触发方式 +/// V3.0 扩展:支持 CRA 五大规则体系 model IitSkill { id String @id @default(uuid()) projectId String @map("project_id") - skillType String @map("skill_type") // qc_process | daily_briefing | general_chat | weekly_report | visit_reminder + skillType String @map("skill_type") // qc_process | daily_briefing | general_chat | weekly_report | visit_reminder | variable_qc | inclusion_exclusion | protocol_deviation | ae_monitoring | ethics_compliance name String // 技能名称 description String? // 技能描述 - config Json @db.JsonB // 核心配置 JSON(SOP 流程图) + config Json @db.JsonB // 核心配置 JSON(SOP 流程图 / 规则定义) isActive Boolean @default(true) @map("is_active") version Int @default(1) // V2.9 新增:主动触发能力 - triggerType String @default("webhook") @map("trigger_type") // 'webhook' | 'cron' | 'event' - cronSchedule String? @map("cron_schedule") // Cron 表达式,如 "0 9 * * *" + triggerType String @default("webhook") @map("trigger_type") // 'webhook' | 'cron' | 'manual' + cronSchedule String? @map("cron_schedule") // Cron 表达式,如 "0 2 * * *" (每日凌晨2点) + + // V3.0 新增:CRA 质控引擎支持 + ruleType String @default("HARD_RULE") @map("rule_type") // 'HARD_RULE' | 'LLM_CHECK' | 'HYBRID' + level String @default("normal") @map("level") // 'blocking' | 'normal' - 阻断性检查优先 + priority Int @default(100) @map("priority") // 执行优先级,数字越小越优先 + requiredTags String[] @default([]) @map("required_tags") // 数据依赖标签,如 ['#lab', '#demographics'] createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@ -979,10 +986,41 @@ model IitSkill { @@unique([projectId, skillType], map: "unique_iit_skill_project_type") @@index([projectId], map: "idx_iit_skill_project") @@index([isActive], map: "idx_iit_skill_active") + @@index([isActive, triggerType], map: "idx_iit_skill_active_trigger") @@map("skills") @@schema("iit_schema") } +/// 质控报告存储表 - 存储预生成的 LLM 友好报告 +/// 报告驱动模式:后台预计算 → LLM 阅读报告 → 回答用户 +model IitQcReport { + id String @id @default(uuid()) + projectId String @map("project_id") + + // 报告类型 + reportType String @default("daily") @map("report_type") // 'daily' | 'weekly' | 'on_demand' + + // 统计摘要 + summary Json @db.JsonB // { totalRecords, criticalIssues, pendingQueries, passRate } + + // 详细问题列表 + issues Json @db.JsonB // [{ record, rule, severity, description, evidence }] + + // LLM 友好的 XML 报告 + llmReport String @map("llm_report") @db.Text + + // 报告生成时间 + generatedAt DateTime @default(now()) @map("generated_at") + + // 报告有效期(过期后需重新生成) + expiresAt DateTime? @map("expires_at") + + @@index([projectId, reportType], map: "idx_qc_report_project_type") + @@index([generatedAt], map: "idx_qc_report_generated") + @@map("qc_reports") + @@schema("iit_schema") +} + /// 字段名映射字典表 - 解决 LLM 生成的字段名与 REDCap 实际字段名不一致的问题 model IitFieldMapping { id String @id @default(uuid()) diff --git a/backend/prisma/seeds/cra-qc-skills.seed.ts b/backend/prisma/seeds/cra-qc-skills.seed.ts new file mode 100644 index 00000000..2a67c291 --- /dev/null +++ b/backend/prisma/seeds/cra-qc-skills.seed.ts @@ -0,0 +1,384 @@ +/** + * CRA 智能质控引擎 - 默认规则配置 Seed + * + * 五大规则体系: + * 1. 变量质控 (VARIABLE_QC) - 硬规则 + * 2. 入排标准 (INCLUSION_EXCLUSION) - LLM 检查 + * 3. 方案偏离 (PROTOCOL_DEVIATION) - 混合规则 + * 4. AE 监测 (AE_MONITORING) - LLM 检查 + * 5. 伦理合规 (ETHICS_COMPLIANCE) - 硬规则 + */ + +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +/** + * 默认 Skills 配置 + */ +const DEFAULT_SKILLS = [ + // ============================================================ + // 1. 变量质控 - 硬规则 (Level: normal, 实时触发) + // ============================================================ + { + skillType: 'variable_qc', + name: '变量质控', + description: '针对每个变量的数据校验,包括空值检查、数值范围、格式验证', + ruleType: 'HARD_RULE', + level: 'normal', + priority: 100, + triggerType: 'webhook', + requiredTags: ['#demographics', '#lab'], + config: { + engine: 'HardRuleEngine', + rules: [ + { + id: 'VQ-001', + name: '年龄范围检查', + field: 'age', + formName: 'demographics', + logic: { + and: [ + { '>=': [{ var: 'age' }, 18] }, + { '<=': [{ var: 'age' }, 100] }, + ], + }, + message: '年龄超出合理范围 (18-100岁)', + severity: 'error', + category: 'lab_values', + }, + { + id: 'VQ-002', + name: 'BMI 范围检查', + field: 'bmi', + formName: 'demographics', + logic: { + and: [ + { '>': [{ var: 'bmi' }, 10] }, + { '<': [{ var: 'bmi' }, 60] }, + ], + }, + message: 'BMI 值异常 (正常范围 10-60)', + severity: 'warning', + category: 'lab_values', + }, + { + id: 'VQ-003', + name: '体重范围检查', + field: 'weight', + formName: 'demographics', + logic: { + and: [ + { '>': [{ var: 'weight' }, 20] }, + { '<': [{ var: 'weight' }, 300] }, + ], + }, + message: '体重值异常 (正常范围 20-300kg)', + severity: 'warning', + category: 'lab_values', + }, + { + id: 'VQ-004', + name: '身高范围检查', + field: 'height', + formName: 'demographics', + logic: { + and: [ + { '>': [{ var: 'height' }, 50] }, + { '<': [{ var: 'height' }, 250] }, + ], + }, + message: '身高值异常 (正常范围 50-250cm)', + severity: 'warning', + category: 'lab_values', + }, + ], + }, + }, + + // ============================================================ + // 2. 入排标准 - LLM 检查 (Level: normal, 定时触发) + // ============================================================ + { + skillType: 'inclusion_exclusion', + name: '入排标准核查', + description: '判断受试者是否符合研究方案的入组标准和排除标准', + ruleType: 'LLM_CHECK', + level: 'normal', + priority: 200, + triggerType: 'cron', + cronSchedule: '0 2 * * *', // 每日凌晨 2 点 + requiredTags: ['#demographics', '#medical_history', '#lab'], + config: { + engine: 'SoftRuleEngine', + model: 'deepseek-v3', + systemPrompt: '你是一个专业的临床研究监查员,负责核查受试者是否符合入组标准。', + checks: [ + { + id: 'IE-001', + name: '年龄入组标准', + desc: '检查受试者年龄是否符合研究方案规定的入组年龄范围', + promptTemplate: `请根据以下受试者数据,判断其年龄是否符合入组标准。 + +受试者年龄: {{age}} + +研究方案通常要求受试者年龄在 18-75 岁之间。请判断此受试者是否符合年龄入组标准。`, + requiredTags: ['#demographics'], + category: 'inclusion', + severity: 'critical', + }, + { + id: 'IE-002', + name: '确诊时间入组标准', + desc: '检查受试者确诊时间是否在研究方案规定的时间窗口内', + promptTemplate: `请根据以下受试者数据,判断其确诊时间是否符合入组标准。 + +确诊日期: {{diagnosis_date}} +入组日期: {{enrollment_date}} + +研究方案通常要求确诊时间在入组前一定时间内(如 3 个月、6 个月等)。请判断此受试者是否符合确诊时间入组标准。`, + requiredTags: ['#demographics', '#medical_history'], + category: 'inclusion', + severity: 'critical', + }, + ], + }, + }, + + // ============================================================ + // 3. 方案偏离 - 混合规则 (Level: normal, 定时触发) + // ============================================================ + { + skillType: 'protocol_deviation', + name: '方案偏离检测', + description: '检测访视超窗、漏做检查、违反用药规定等方案偏离情况', + ruleType: 'HYBRID', + level: 'normal', + priority: 300, + triggerType: 'cron', + cronSchedule: '0 3 * * *', // 每日凌晨 3 点 + requiredTags: ['#visits', '#medications'], + config: { + engine: 'HybridEngine', + hardRules: [ + { + id: 'PD-001', + name: '访视间隔检查', + field: ['visit_1_date', 'visit_2_date'], + logic: { + '<=': [ + { '-': [{ var: 'visit_2_date' }, { var: 'visit_1_date' }] }, + 31, // 最大间隔 28+3 天 + ], + }, + message: '访视超窗:访视 2 与访视 1 间隔超过 31 天', + severity: 'warning', + category: 'logic_check', + }, + ], + softChecks: [ + { + id: 'PD-002', + name: '禁用药物检查', + desc: '检查受试者是否使用了研究方案禁止的药物', + promptTemplate: '请检查受试者的用药记录,判断是否存在使用研究方案禁止药物的情况。', + requiredTags: ['#medications'], + category: 'protocol_deviation', + severity: 'warning', + }, + ], + }, + }, + + // ============================================================ + // 4. AE 监测 - LLM 检查 (Level: normal, 定时触发) + // ============================================================ + { + skillType: 'ae_monitoring', + name: 'AE 事件监测', + description: '检测未报告的不良事件,包括实验室异常与 AE 记录的一致性检查', + ruleType: 'LLM_CHECK', + level: 'normal', + priority: 250, + triggerType: 'cron', + cronSchedule: '0 4 * * *', // 每日凌晨 4 点 + requiredTags: ['#lab', '#ae'], + config: { + engine: 'SoftRuleEngine', + model: 'deepseek-v3', + systemPrompt: '你是一个专业的药物安全监查员,负责核查不良事件报告的完整性和准确性。', + checks: [ + { + id: 'AE-001', + name: 'Lab 异常与 AE 一致性', + desc: '检查实验室检查异常值是否已在 AE 表中报告', + promptTemplate: `请对比以下实验室检查数据和不良事件记录,判断是否存在未报告的实验室异常。 + +重点关注: +1. Grade 3 及以上的实验室异常是否已记录为 AE +2. 持续异常是否已报告 +3. 与基线相比显著变化的指标 + +请给出判断结果,并列出可能遗漏报告的异常。`, + requiredTags: ['#lab', '#ae'], + category: 'ae_detection', + severity: 'critical', + }, + { + id: 'AE-002', + name: 'SAE 报告时效性', + desc: '检查严重不良事件是否在规定时间内报告', + promptTemplate: `请检查以下 SAE 记录,判断报告时效是否符合法规要求。 + +通常要求: +- 致死/危及生命的 SAE: 24 小时内报告 +- 其他 SAE: 15 天内报告 + +请判断各 SAE 的报告时效是否合规。`, + requiredTags: ['#ae'], + category: 'ae_detection', + severity: 'critical', + }, + ], + }, + }, + + // ============================================================ + // 5. 伦理合规 - 硬规则 (Level: blocking, 实时触发) + // ============================================================ + { + skillType: 'ethics_compliance', + name: '伦理合规检查', + description: '检查知情同意书签署时间、隐私保护等伦理合规问题', + ruleType: 'HARD_RULE', + level: 'blocking', // 阻断性检查,失败则跳过后续 AI 检查 + priority: 10, // 最高优先级 + triggerType: 'webhook', + requiredTags: ['#consent', '#demographics'], + config: { + engine: 'HardRuleEngine', + rules: [ + { + id: 'EC-001', + name: '知情同意书签署时间', + field: ['icf_date', 'first_visit_date'], + logic: { + '<=': [{ var: 'icf_date' }, { var: 'first_visit_date' }], + }, + message: '伦理违规:知情同意书签署日期晚于首次访视日期', + severity: 'error', + category: 'ethics', + }, + { + id: 'EC-002', + name: '知情同意书必填检查', + field: 'icf_date', + logic: { + '!!': [{ var: 'icf_date' }], + }, + message: '伦理违规:缺少知情同意书签署日期', + severity: 'error', + category: 'ethics', + }, + { + id: 'EC-003', + name: '受试者 ID 必填检查', + field: 'record_id', + logic: { + '!!': [{ var: 'record_id' }], + }, + message: '数据完整性:缺少受试者 ID', + severity: 'error', + category: 'ethics', + }, + ], + }, + }, +]; + +/** + * 为项目创建默认 Skills + * + * @param projectId 项目 ID + */ +export async function seedDefaultSkills(projectId: string): Promise { + console.log(`[Seed] Creating default skills for project: ${projectId}`); + + for (const skillData of DEFAULT_SKILLS) { + try { + // 使用 upsert 避免重复创建 + await prisma.iitSkill.upsert({ + where: { + projectId_skillType: { + projectId, + skillType: skillData.skillType, + }, + }, + update: { + name: skillData.name, + description: skillData.description, + ruleType: skillData.ruleType, + level: skillData.level, + priority: skillData.priority, + triggerType: skillData.triggerType, + cronSchedule: skillData.cronSchedule, + requiredTags: skillData.requiredTags, + config: skillData.config, + isActive: true, + updatedAt: new Date(), + }, + create: { + projectId, + skillType: skillData.skillType, + name: skillData.name, + description: skillData.description, + ruleType: skillData.ruleType, + level: skillData.level, + priority: skillData.priority, + triggerType: skillData.triggerType, + cronSchedule: skillData.cronSchedule, + requiredTags: skillData.requiredTags, + config: skillData.config, + isActive: true, + }, + }); + + console.log(`[Seed] Created/Updated skill: ${skillData.name}`); + } catch (error: any) { + console.error(`[Seed] Failed to create skill: ${skillData.name}`, error.message); + } + } + + console.log(`[Seed] Completed creating default skills for project: ${projectId}`); +} + +/** + * 主函数 - 可以直接运行 + */ +async function main() { + // 获取命令行参数 + const projectId = process.argv[2]; + + if (!projectId) { + console.error('Usage: npx ts-node prisma/seeds/cra-qc-skills.seed.ts '); + console.error('Example: npx ts-node prisma/seeds/cra-qc-skills.seed.ts test0102-pd-study'); + process.exit(1); + } + + // 检查项目是否存在 + const project = await prisma.iitProject.findUnique({ + where: { id: projectId }, + }); + + if (!project) { + console.error(`Project not found: ${projectId}`); + process.exit(1); + } + + await seedDefaultSkills(projectId); +} + +// 如果直接运行此文件 +main() + .catch(console.error) + .finally(() => prisma.$disconnect()); diff --git a/backend/src/index.ts b/backend/src/index.ts index 9fb772b7..34e39ad0 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -109,7 +109,7 @@ import { tenantRoutes, moduleRoutes } from './modules/admin/routes/tenantRoutes. import { userRoutes } from './modules/admin/routes/userRoutes.js'; import { statsRoutes, userOverviewRoute } from './modules/admin/routes/statsRoutes.js'; import { systemKbRoutes } from './modules/admin/system-kb/index.js'; -import { iitProjectRoutes, iitQcRuleRoutes, iitUserMappingRoutes, iitBatchRoutes } from './modules/admin/iit-projects/index.js'; +import { iitProjectRoutes, iitQcRuleRoutes, iitUserMappingRoutes, iitBatchRoutes, iitQcCockpitRoutes } from './modules/admin/iit-projects/index.js'; await fastify.register(tenantRoutes, { prefix: '/api/admin/tenants' }); await fastify.register(moduleRoutes, { prefix: '/api/admin/modules' }); await fastify.register(userRoutes, { prefix: '/api/admin/users' }); @@ -120,6 +120,7 @@ await fastify.register(iitProjectRoutes, { prefix: '/api/v1/admin/iit-projects' await fastify.register(iitQcRuleRoutes, { prefix: '/api/v1/admin/iit-projects' }); await fastify.register(iitUserMappingRoutes, { prefix: '/api/v1/admin/iit-projects' }); await fastify.register(iitBatchRoutes, { prefix: '/api/v1/admin/iit-projects' }); // 一键全量质控/汇总 +await fastify.register(iitQcCockpitRoutes, { prefix: '/api/v1/admin/iit-projects' }); // 质控驾驶舱 logger.info('✅ 运营管理路由已注册: /api/admin/tenants, /api/admin/modules, /api/admin/users, /api/admin/stats, /api/v1/admin/system-kb, /api/v1/admin/iit-projects'); // ============================================ diff --git a/backend/src/modules/admin/iit-projects/iitBatchController.ts b/backend/src/modules/admin/iit-projects/iitBatchController.ts index 0a221e21..f6759555 100644 --- a/backend/src/modules/admin/iit-projects/iitBatchController.ts +++ b/backend/src/modules/admin/iit-projects/iitBatchController.ts @@ -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, @@ -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(); - 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(); - // 存储质控日志 - 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 = { '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}` }); } } diff --git a/backend/src/modules/admin/iit-projects/iitProjectService.ts b/backend/src/modules/admin/iit-projects/iitProjectService.ts index b05f9ac1..9f537483 100644 --- a/backend/src/modules/admin/iit-projects/iitProjectService.ts +++ b/backend/src/modules/admin/iit-projects/iitProjectService.ts @@ -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 = {}; + 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); diff --git a/backend/src/modules/admin/iit-projects/iitQcCockpitController.ts b/backend/src/modules/admin/iit-projects/iitQcCockpitController.ts new file mode 100644 index 00000000..2661f843 --- /dev/null +++ b/backend/src/modules/admin/iit-projects/iitQcCockpitController.ts @@ -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 }; diff --git a/backend/src/modules/admin/iit-projects/iitQcCockpitRoutes.ts b/backend/src/modules/admin/iit-projects/iitQcCockpitRoutes.ts new file mode 100644 index 00000000..99b1df74 --- /dev/null +++ b/backend/src/modules/admin/iit-projects/iitQcCockpitRoutes.ts @@ -0,0 +1,245 @@ +/** + * IIT 质控驾驶舱路由 + * + * API: + * - GET /api/v1/admin/iit-projects/:projectId/qc-cockpit 获取驾驶舱数据 + * - GET /api/v1/admin/iit-projects/:projectId/qc-cockpit/records/:recordId 获取记录详情 + * - GET /api/v1/admin/iit-projects/:projectId/qc-cockpit/report 获取质控报告 + * - POST /api/v1/admin/iit-projects/:projectId/qc-cockpit/report/refresh 刷新质控报告 + */ + +import { FastifyInstance } from 'fastify'; +import { iitQcCockpitController } from './iitQcCockpitController.js'; + +export async function iitQcCockpitRoutes(fastify: FastifyInstance) { + // 获取质控驾驶舱数据 + fastify.get('/:projectId/qc-cockpit', { + schema: { + description: '获取质控驾驶舱数据(统计 + 热力图)', + tags: ['IIT Admin - 质控驾驶舱'], + params: { + type: 'object', + properties: { + projectId: { type: 'string', description: 'IIT 项目 ID' }, + }, + required: ['projectId'], + }, + response: { + 200: { + type: 'object', + properties: { + success: { type: 'boolean' }, + data: { + type: 'object', + properties: { + stats: { + type: 'object', + properties: { + qualityScore: { type: 'number' }, + totalRecords: { type: 'number' }, + passedRecords: { type: 'number' }, + failedRecords: { type: 'number' }, + warningRecords: { type: 'number' }, + pendingRecords: { type: 'number' }, + criticalCount: { type: 'number' }, + queryCount: { type: 'number' }, + deviationCount: { type: 'number' }, + passRate: { type: 'number' }, + topIssues: { + type: 'array', + items: { + type: 'object', + properties: { + issue: { type: 'string' }, + count: { type: 'number' }, + severity: { type: 'string' }, + }, + }, + }, + }, + }, + heatmap: { + type: 'object', + properties: { + columns: { type: 'array', items: { type: 'string' } }, + rows: { + type: 'array', + items: { + type: 'object', + properties: { + recordId: { type: 'string' }, + status: { type: 'string' }, + cells: { + type: 'array', + items: { + type: 'object', + properties: { + formName: { type: 'string' }, + status: { type: 'string' }, + issueCount: { type: 'number' }, + recordId: { type: 'string' }, + }, + }, + }, + }, + }, + }, + }, + }, + lastUpdatedAt: { type: 'string' }, + }, + }, + }, + }, + }, + }, + }, iitQcCockpitController.getCockpitData.bind(iitQcCockpitController)); + + // 获取记录质控详情 + fastify.get('/:projectId/qc-cockpit/records/:recordId', { + schema: { + description: '获取单条记录的质控详情(含 LLM Trace)', + tags: ['IIT Admin - 质控驾驶舱'], + params: { + type: 'object', + properties: { + projectId: { type: 'string', description: 'IIT 项目 ID' }, + recordId: { type: 'string', description: '记录 ID' }, + }, + required: ['projectId', 'recordId'], + }, + querystring: { + type: 'object', + properties: { + formName: { type: 'string', description: '表单名称' }, + }, + }, + response: { + 200: { + type: 'object', + properties: { + success: { type: 'boolean' }, + data: { + type: 'object', + properties: { + recordId: { type: 'string' }, + formName: { type: 'string' }, + status: { type: 'string' }, + data: { type: 'object', additionalProperties: true }, + fieldMetadata: { type: 'object', additionalProperties: true }, + issues: { + type: 'array', + items: { + type: 'object', + properties: { + field: { type: 'string' }, + ruleName: { type: 'string' }, + message: { type: 'string' }, + severity: { type: 'string' }, + actualValue: {}, + expectedValue: { type: 'string' }, + confidence: { type: 'string' }, + }, + }, + }, + llmTrace: { + type: 'object', + properties: { + promptSent: { type: 'string' }, + responseReceived: { type: 'string' }, + model: { type: 'string' }, + latencyMs: { type: 'number' }, + }, + }, + entryTime: { type: 'string' }, + }, + }, + }, + }, + }, + }, + }, iitQcCockpitController.getRecordDetail.bind(iitQcCockpitController)); + + // 获取质控报告 + fastify.get('/:projectId/qc-cockpit/report', { + schema: { + description: '获取质控报告(支持 JSON 和 XML 格式)', + tags: ['IIT Admin - 质控驾驶舱'], + params: { + type: 'object', + properties: { + projectId: { type: 'string', description: 'IIT 项目 ID' }, + }, + required: ['projectId'], + }, + querystring: { + type: 'object', + properties: { + format: { + type: 'string', + enum: ['json', 'xml'], + default: 'json', + description: '响应格式:json(默认)或 xml(LLM 友好格式)', + }, + }, + }, + response: { + 200: { + type: 'object', + properties: { + success: { type: 'boolean' }, + data: { + type: 'object', + properties: { + projectId: { type: 'string' }, + reportType: { type: 'string' }, + generatedAt: { type: 'string' }, + expiresAt: { type: 'string' }, + summary: { + type: 'object', + properties: { + totalRecords: { type: 'number' }, + completedRecords: { type: 'number' }, + criticalIssues: { type: 'number' }, + warningIssues: { type: 'number' }, + pendingQueries: { type: 'number' }, + passRate: { type: 'number' }, + lastQcTime: { type: 'string' }, + }, + }, + criticalIssues: { type: 'array' }, + warningIssues: { type: 'array' }, + formStats: { type: 'array' }, + llmFriendlyXml: { type: 'string' }, + }, + }, + }, + }, + }, + }, + }, iitQcCockpitController.getReport.bind(iitQcCockpitController)); + + // 刷新质控报告 + fastify.post('/:projectId/qc-cockpit/report/refresh', { + schema: { + description: '强制刷新质控报告(忽略缓存)', + tags: ['IIT Admin - 质控驾驶舱'], + params: { + type: 'object', + properties: { + projectId: { type: 'string', description: 'IIT 项目 ID' }, + }, + required: ['projectId'], + }, + response: { + 200: { + type: 'object', + properties: { + success: { type: 'boolean' }, + data: { type: 'object' }, + }, + }, + }, + }, + }, iitQcCockpitController.refreshReport.bind(iitQcCockpitController)); +} diff --git a/backend/src/modules/admin/iit-projects/iitQcCockpitService.ts b/backend/src/modules/admin/iit-projects/iitQcCockpitService.ts new file mode 100644 index 00000000..22e0bfb5 --- /dev/null +++ b/backend/src/modules/admin/iit-projects/iitQcCockpitService.ts @@ -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; + fieldMetadata?: Record; + 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 { + 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 { + // 从项目统计表获取缓存数据 + const projectStats = await prisma.iitQcProjectStats.findUnique({ + where: { projectId }, + }); + + // ✅ 获取每个 recordId + formName 的最新质控日志(避免重复计数) + // 使用原生 SQL 进行去重,只保留每个记录+表单的最新结果 + const latestQcLogs = await prisma.$queryRaw>` + 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(); + + 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 { + // ✅ 获取项目配置(包含表单标签映射) + const project = await prisma.iitProject.findUnique({ + where: { id: projectId }, + select: { fieldMappings: true }, + }); + + // 获取表单名 -> 表单标签的映射 + const fieldMappings = (project?.fieldMappings as any) || {}; + const formLabels: Record = 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>(); + 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 { + // 获取项目配置 + 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 = {}; + const formFieldNames = new Set(); // 当前表单的字段名集合 + + 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 = {}; + + 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 }; diff --git a/backend/src/modules/admin/iit-projects/index.ts b/backend/src/modules/admin/iit-projects/index.ts index 720b17ac..764f4ad7 100644 --- a/backend/src/modules/admin/iit-projects/index.ts +++ b/backend/src/modules/admin/iit-projects/index.ts @@ -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'; diff --git a/backend/src/modules/iit-manager/adapters/RedcapAdapter.ts b/backend/src/modules/iit-manager/adapters/RedcapAdapter.ts index 4a22ea4c..003b0bcc 100644 --- a/backend/src/modules/iit-manager/adapters/RedcapAdapter.ts +++ b/backend/src/modules/iit-manager/adapters/RedcapAdapter.ts @@ -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; + forms: string[]; // 该事件包含的表单列表 + }>> { + try { + // 获取表单-事件映射 + const formEventMapping = await this.getFormEventMapping(); + + // 按事件分组表单 + const eventForms = new Map>(); + const eventLabels = new Map(); + 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; + 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> { + 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; + 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>(); + 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 = { + 0: 'Incomplete', + 1: 'Unverified', + 2: 'Complete', + }; + + const results: Array<{ + recordId: string; + forms: Record; + allComplete: boolean; + }> = []; + + for (const [recId, data] of recordGroups) { + const forms: Record = {}; + 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 = { + 0: 'Incomplete', + 1: 'Unverified', + 2: 'Complete', + }; + + return { + status: maxStatus, + statusLabel: statusLabels[maxStatus] || 'Incomplete', + isComplete: maxStatus === 2, + }; + } + + /** + * 获取表单-事件映射(纵向研究) + * + * @returns 表单-事件映射列表 + */ + async getFormEventMapping(): Promise> { + 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(); + 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> { + 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; + eventComplete: boolean; // 该事件的所有表单是否都完成 + }>> { + // 获取表单-事件映射 + const formEventMapping = await this.getFormEventMapping(); + + // 按事件分组表单 + const eventForms = new Map>(); + const eventLabels = new Map(); + 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 = { + 0: 'Incomplete', + 1: 'Unverified', + 2: 'Complete', + }; + + const results: Array<{ + recordId: string; + eventName: string; + eventLabel: string; + forms: Record; + 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 = {}; + 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; + } } diff --git a/backend/src/modules/iit-manager/engines/HardRuleEngine.ts b/backend/src/modules/iit-manager/engines/HardRuleEngine.ts index 5862b8c4..8772e788 100644 --- a/backend/src/modules/iit-manager/engines/HardRuleEngine.ts +++ b/backend/src/modules/iit-manager/engines/HardRuleEngine.ts @@ -34,21 +34,36 @@ export interface QCRule { severity: 'error' | 'warning' | 'info'; category: 'inclusion' | 'exclusion' | 'lab_values' | 'logic_check'; metadata?: Record; + + // 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): 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 { + 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})`; + } + } + /** * 获取字段值(支持映射) */ diff --git a/backend/src/modules/iit-manager/engines/SkillRunner.ts b/backend/src/modules/iit-manager/engines/SkillRunner.ts new file mode 100644 index 00000000..9655400d --- /dev/null +++ b/backend/src/modules/iit-manager/engines/SkillRunner.ts @@ -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; // 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 { + 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 { + 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> { + 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; + }>> { + 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, + skills: Array<{ + id: string; + skillType: string; + name: string; + ruleType: string; + level: string; + priority: number; + config: any; + requiredTags: string[]; + }>, + triggerType: TriggerType, + options?: SkillRunnerOptions + ): Promise { + 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 + ): Promise { + 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 + ): { 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 { + 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): 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( + 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 { + 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); +} diff --git a/backend/src/modules/iit-manager/engines/SoftRuleEngine.ts b/backend/src/modules/iit-manager/engines/SoftRuleEngine.ts new file mode 100644 index 00000000..6bfc407c --- /dev/null +++ b/backend/src/modules/iit-manager/engines/SoftRuleEngine.ts @@ -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; // 支持判断的证据数据 + 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, + checks: SoftRuleCheck[] + ): Promise { + 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, + check: SoftRuleCheck + ): Promise { + 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, + 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; + 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 }>, + checks: SoftRuleCheck[] + ): Promise { + 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', + }, +]; diff --git a/backend/src/modules/iit-manager/engines/index.ts b/backend/src/modules/iit-manager/engines/index.ts index 17852a26..49e2854f 100644 --- a/backend/src/modules/iit-manager/engines/index.ts +++ b/backend/src/modules/iit-manager/engines/index.ts @@ -3,4 +3,6 @@ */ export * from './HardRuleEngine.js'; +export * from './SoftRuleEngine.js'; export * from './SopEngine.js'; +export * from './SkillRunner.js'; diff --git a/backend/src/modules/iit-manager/services/ChatService.ts b/backend/src/modules/iit-manager/services/ChatService.ts index 0126125e..9014e14a 100644 --- a/backend/src/modules/iit-manager/services/ChatService.ts +++ b/backend/src/modules/iit-manager/services/ChatService.ts @@ -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)**:在 中找到相关章节 + - 统计类问题 → 查看 + - 问题详情类 → 查看 + +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 { + 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( + `\n${JSON.stringify(toolResult, null, 2)}\n`, + '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 = ` + + + ${qcResult?.summary?.totalRules || 0} + ${qcResult?.summary?.passed || 0} + ${qcResult?.summary?.failed || 0} + + ${qcResult?.errors?.length > 0 ? ` + + ${qcResult.errors.map((e: any) => + `${e.message}` + ).join('\n ')} + ` : ''} + ${qcResult?.warnings?.length > 0 ? ` + + ${qcResult.warnings.map((w: any) => + `${w.message}` + ).join('\n ')} + ` : ''} +`; + + // 生成自然语言摘要 + 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\n${summary}\n`, + 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\n${naturalSummary}\n`, + '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 + ? ` +${result.recentRecords.map((r: any) => + ` ` +).join('\n')} +` + : ''; + + return wrapAsSystemMessage( + `\n${naturalSummary}\n\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 = ``; + + if (stats) { + xmlContent += ` + + ${stats.totalRecords} + ${stats.passedRecords} + ${stats.failedRecords} + ${stats.warningRecords} + ${stats.passRate} + ${stats.lastUpdated} + `; + } + + if (problemRecords?.length > 0) { + xmlContent += ` + +${problemRecords.map((r: any) => + ` ` +).join('\n')} + `; + } + + xmlContent += `\n`; + + // 生成自然语言摘要 + const naturalSummary = stats ? buildQcSummary({ + projectName: projectName || '当前项目', + totalRecords: stats.totalRecords, + passedRecords: stats.passedRecords, + failedRecords: stats.failedRecords, + warningRecords: stats.warningRecords + }) : '暂无质控统计数据'; + + return wrapAsSystemMessage( + `${xmlContent}\n\n\n${naturalSummary}\n`, + 'QC_TABLE' + ); + } + + /** + * 格式化记录统计为 XML + */ + private formatCountRecordsXml(result: any): string { + const { projectName, totalRecords, recordIds } = result; + + const xmlContent = ` + ${totalRecords} + ${(recordIds || []).slice(0, 10).join(', ')}${totalRecords > 10 ? '...' : ''} +`; + + const naturalSummary = `项目【${projectName}】当前共有 ${totalRecords} 条记录。`; + + return wrapAsSystemMessage( + `${xmlContent}\n\n${naturalSummary}`, + 'REDCap' + ); + } + + /** + * 格式化项目信息为 XML + */ + private formatProjectInfoXml(result: any): string { + const { projectId, projectName, description, redcapProjectId, lastSyncAt, createdAt } = result; + + const xmlContent = ` + ${projectName} + ${description || '暂无描述'} + ${redcapProjectId || '未配置'} + ${lastSyncAt} + ${createdAt} +`; + + const naturalSummary = `当前项目:【${projectName}】 +- 项目描述:${description || '暂无'} +- REDCap 项目 ID:${redcapProjectId || '未配置'} +- 最后同步时间:${lastSyncAt} +- 创建时间:${createdAt}`; + + return wrapAsSystemMessage( + `${xmlContent}\n\n\n${naturalSummary}\n`, + 'REDCap' + ); + } + + /** + * 格式化质控报告为 XML(V3.0 报告驱动模式) + * + * V2.1 优化: + * - 直接使用 QcReportService 预生成的 LLM 友好 XML 报告 + * - 添加思维链约束提示 + * - 信息自包含,减少 LLM 幻觉 + */ + private formatQcReportXml(result: any): string { + const { projectName, llmReport, report } = result; + + // V2.1: 使用预生成的 LLM 友好报告 + if (llmReport) { + const intro = `【质控报告 - 请严格基于此报告回答】 +项目: ${projectName} +生成时间: ${report?.generatedAt || new Date().toISOString()} + +--- +请按照"定位→提取→引用→诚实"思维链回答用户问题: +1. 定位:在报告中找到相关章节 +2. 提取:精确提取数值(不要推测) +3. 引用:回答时引用具体数据和受试者ID +4. 诚实:报告中没有的信息,明确说"报告中未包含" +---`; + + return wrapAsSystemMessage( + `${intro}\n\n${llmReport}`, + 'QC_REPORT' + ); + } + + // 回退:如果没有预生成报告,从 report 对象构建 + if (report) { + const summary = report.summary || {}; + const topIssues = report.topIssues || []; + const groupedIssues = report.groupedIssues || []; + + // V2.1: 构建简化的 LLM 友好格式 + let fallbackXml = ` + + + - 状态: ${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 += ` + + +`; + + // 添加分组的严重问题 + if (groupedIssues.length > 0) { + fallbackXml += ` \n`; + for (const group of groupedIssues.slice(0, 10)) { + fallbackXml += ` \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 += ` \n`; + } + fallbackXml += ` \n\n`; + } + + fallbackXml += ``; + + const intro = `【质控报告 - 请严格基于此报告回答】 +项目: ${projectName} + +请按照"定位→提取→引用→诚实"思维链回答用户问题。`; + + return wrapAsSystemMessage( + `${intro}\n\n${fallbackXml}`, + 'QC_REPORT' + ); + } + + // 最终回退:无数据 + return wrapAsSystemMessage( + `无法获取质控报告数据`, + 'QC_REPORT' + ); + } + /** * 清除用户会话(用于重置对话) */ diff --git a/backend/src/modules/iit-manager/services/PromptBuilder.ts b/backend/src/modules/iit-manager/services/PromptBuilder.ts new file mode 100644 index 00000000..dbb81d0c --- /dev/null +++ b/backend/src/modules/iit-manager/services/PromptBuilder.ts @@ -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; + /** 语义标签(用于过滤数据) */ + tags?: string[]; + /** 额外的指令 */ + instruction?: string; + /** 字段元数据(用于增强展示) */ + fieldMetadata?: Record; +} + +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; + fieldMetadata?: Record; + 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 + * 核查该患者是否符合研究入排标准 + * + * + * 1. 年龄 16-35 岁。 + * 2. 月经周期规律(28±7天)。 + * + * + * + * - 出生日期:2003-01-07(当前年龄 22 岁)✅ + * - 月经周期:45 天 ⚠️ 超出范围 + * + * + * + * 请一步步推理,对比患者数据与标准。如发现异常,说明具体哪条标准被违反。 + * + * ``` + */ + static buildClinicalSlice(params: ClinicalSliceParams): string { + const { + task, + criteria = [], + patientData, + tags = [], + instruction, + fieldMetadata = {} + } = params; + + const parts: string[] = []; + + // 1. 任务描述 + parts.push(`${task}`); + parts.push(''); + + // 2. 研究方案标准(如果有) + if (criteria.length > 0) { + parts.push(''); + criteria.forEach((c, i) => { + parts.push(` ${i + 1}. ${c}`); + }); + parts.push(''); + parts.push(''); + } + + // 3. 患者数据切片 + const tagAttr = tags.length > 0 ? ` tag="${tags.join(', ')}"` : ''; + parts.push(``); + + // 格式化患者数据 + const formattedData = PromptBuilder.formatPatientData(patientData, fieldMetadata); + formattedData.forEach(line => { + parts.push(` ${line}`); + }); + + parts.push(''); + parts.push(''); + + // 4. 指令(如果有) + if (instruction) { + parts.push(''); + parts.push(instruction); + parts.push(''); + } + + return parts.join('\n').trim(); + } + + /** + * 格式化患者数据为可读格式 + */ + private static formatPatientData( + data: Record, + metadata: Record + ): 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(``); + parts.push(''); + + // 2. 数据内容 + parts.push(' '); + 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(` ${formattedValue}`); + } + parts.push(' '); + + // 3. 质控结果(如果有) + if (qcResult) { + parts.push(''); + parts.push(` `); + + if (qcResult.errors && qcResult.errors.length > 0) { + parts.push(' '); + qcResult.errors.forEach(e => { + parts.push(` ${e.message}`); + }); + parts.push(' '); + } + + if (qcResult.warnings && qcResult.warnings.length > 0) { + parts.push(' '); + qcResult.warnings.forEach(w => { + parts.push(` ${w.message}`); + }); + parts.push(' '); + } + + parts.push(' '); + } + + parts.push(''); + parts.push(''); + + 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(``); + parts.push(` `); + parts.push(` ${totalRecords}`); + parts.push(` ${passedRecords}`); + parts.push(` ${failedRecords}`); + parts.push(` ${((passedRecords / totalRecords) * 100).toFixed(1)}%`); + parts.push(` `); + parts.push(''); + + // 2. 问题记录列表 + if (problemRecords.length > 0) { + parts.push(' '); + problemRecords.forEach(record => { + parts.push(` `); + record.issues.slice(0, 3).forEach(issue => { + const fieldAttr = issue.field ? ` field="${issue.field}"` : ''; + parts.push(` ${issue.message}`); + }); + parts.push(' '); + }); + parts.push(' '); + } + + parts.push(''); + + 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. 如果数据中包含 标签,说明存在质控问题,需要重点说明`; + } +} + +// 导出单例便捷方法 +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; diff --git a/backend/src/modules/iit-manager/services/QcReportService.ts b/backend/src/modules/iit-manager/services/QcReportService.ts new file mode 100644 index 00000000..9c9bd76b --- /dev/null +++ b/backend/src/modules/iit-manager/services/QcReportService.ts @@ -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; + 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 { + 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 { + 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 { + 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 { + // 获取记录汇总(用于 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>` + 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(); + const seenWarning = new Set(); + 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>` + 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(); + + 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 { + // 获取表单标签映射 + const project = await prisma.iitProject.findUnique({ + where: { id: projectId }, + select: { fieldMappings: true }, + }); + const formLabels: Record = + ((project?.fieldMappings as any)?.formLabels) || {}; + + // 按表单统计 + const formStatsRaw = await prisma.$queryRaw>` + 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(); + + 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 }>(); + + 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(), + }; + 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 = ` + + + + + - 状态: ${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')}` : ''} + + +`; + + // V3.1: 严重问题详情(按受试者分组,显示所有问题) + if (groupedCritical.length > 0) { + xml += ` \n`; + xml += ` \n\n`; + + for (const group of groupedCritical) { + xml += ` \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 += ` \n\n`; + } + + xml += ` \n\n`; + } else { + xml += ` \n\n`; + } + + // V3.1: 警告问题(显示所有) + const groupedWarning = this.groupIssuesByRecord(warningIssues); + if (groupedWarning.length > 0) { + xml += ` \n`; + xml += ` \n`; + + for (const group of groupedWarning) { + xml += ` \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 += ` \n`; + } + + xml += ` \n\n`; + } + + xml += ``; + + 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 = ` + + + + ${summary.totalRecords} + ${summary.completedRecords} + ${summary.criticalIssues} + ${summary.warningIssues} + ${summary.pendingQueries} + ${summary.passRate}% + ${summary.lastQcTime || 'N/A'} + + +`; + + // V3.1: 严重问题列表(显示所有) + if (criticalIssues.length > 0) { + xml += ` \n`; + for (const issue of criticalIssues) { + xml += this.buildIssueXml(issue, ' '); + } + xml += ` \n\n`; + } else { + xml += ` \n\n`; + } + + // V3.1: 警告问题列表(显示所有) + if (warningIssues.length > 0) { + xml += ` \n`; + for (const issue of warningIssues) { + xml += this.buildIssueXml(issue, ' '); + } + xml += ` \n\n`; + } else { + xml += ` \n\n`; + } + + // 表单统计 + if (formStats.length > 0) { + xml += ` \n`; + for (const form of formStats) { + xml += `
\n`; + xml += ` ${form.totalChecks}\n`; + xml += ` ${form.passed}\n`; + xml += ` ${form.failed}\n`; + xml += ` ${form.passRate}%\n`; + xml += `
\n`; + } + xml += `
\n\n`; + } + + xml += `
`; + + return xml; + } + + /** + * 构建单个问题的 XML + */ + private buildIssueXml(issue: ReportIssue, indent: string): string { + let xml = `${indent}\n`; + xml += `${indent} ${this.escapeXml(issue.ruleName)}\n`; + xml += `${indent} ${this.escapeXml(issue.message)}\n`; + + if (issue.field) { + xml += `${indent} ${this.escapeXml(String(issue.field))}\n`; + } + if (issue.actualValue !== undefined) { + xml += `${indent} ${this.escapeXml(String(issue.actualValue))}\n`; + } + if (issue.expectedValue !== undefined) { + xml += `${indent} ${this.escapeXml(String(issue.expectedValue))}\n`; + } + if (issue.evidence && Object.keys(issue.evidence).length > 0) { + xml += `${indent} \n`; + for (const [key, value] of Object.entries(issue.evidence)) { + xml += `${indent} <${this.escapeXml(key)}>${this.escapeXml(String(value))}\n`; + } + xml += `${indent} \n`; + } + + xml += `${indent} ${issue.detectedAt}\n`; + xml += `${indent}\n`; + + return xml; + } + + /** + * XML 转义 + */ + private escapeXml(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + /** + * 缓存报告 + */ + private async cacheReport(report: QcReport): Promise { + 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 { + 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 { + const report = await this.getReport(projectId); + return report.topIssues.slice(0, limit); + } + + /** + * V2.1: 获取按受试者分组的问题 + */ + async getGroupedIssues(projectId: string): Promise { + const report = await this.getReport(projectId); + return report.groupedIssues; + } + + /** + * 强制刷新报告 + * + * @param projectId 项目 ID + * @returns 新生成的报告 + */ + async refreshReport(projectId: string): Promise { + return this.getReport(projectId, { forceRefresh: true }); + } + + /** + * 清理过期报告 + */ + async cleanupExpiredReports(): Promise { + 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(); diff --git a/backend/src/modules/iit-manager/services/ToolsService.ts b/backend/src/modules/iit-manager/services/ToolsService.ts index 918293ae..c8b1667f 100644 --- a/backend/src/modules/iit-manager/services/ToolsService.ts +++ b/backend/src/modules/iit-manager/services/ToolsService.ts @@ -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>(); - 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(); + 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) { diff --git a/backend/src/modules/iit-manager/services/index.ts b/backend/src/modules/iit-manager/services/index.ts new file mode 100644 index 00000000..01ece669 --- /dev/null +++ b/backend/src/modules/iit-manager/services/index.ts @@ -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'; diff --git a/backend/test-event-level-qc.ts b/backend/test-event-level-qc.ts new file mode 100644 index 00000000..e8144e3b --- /dev/null +++ b/backend/test-event-level-qc.ts @@ -0,0 +1,171 @@ +/** + * 测试脚本:验证事件级质控功能 + * + * V3.1 变更: + * - 每个 record + event 作为独立单元质控 + * - 规则支持 applicableEvents 和 applicableForms 配置 + * - 不再合并事件数据 + */ +import { PrismaClient } from '@prisma/client'; +import { createSkillRunner } from './src/modules/iit-manager/engines/SkillRunner.js'; +import { RedcapAdapter } from './src/modules/iit-manager/adapters/RedcapAdapter.js'; + +const prisma = new PrismaClient(); + +async function main() { + console.log('='.repeat(60)); + console.log('📋 V3.1 事件级质控测试'); + console.log('='.repeat(60)); + + // 1. 获取项目配置 + const project = await prisma.iitProject.findFirst({ + where: { name: { contains: 'test0207' } }, + }); + + if (!project) { + console.log('❌ 未找到项目'); + await prisma.$disconnect(); + return; + } + + console.log(`\n✅ 项目: ${project.name} (${project.id})`); + + // =============================== + // 测试 1:验证事件级数据获取 + // =============================== + console.log('\n' + '='.repeat(60)); + console.log('📊 测试 1:验证事件级数据获取'); + console.log('='.repeat(60)); + + const adapter = new RedcapAdapter(project.redcapUrl, project.redcapApiToken); + + // 获取 Record 1 的事件级数据(不合并) + const record1Events = await adapter.getAllRecordsByEvent({ recordId: '1' }); + + console.log(`\nRecord 1 共有 ${record1Events.length} 个事件(不合并):`); + for (const event of record1Events) { + console.log(`\n 📁 事件: ${event.eventLabel} (${event.eventName})`); + console.log(` 表单: ${event.forms.join(', ')}`); + console.log(` 数据示例: age=${event.data.age ?? '(空)'}, cmss_complete=${event.data.cmss_complete ?? '(空)'}`); + } + + // =============================== + // 测试 2:验证事件级质控执行 + // =============================== + console.log('\n' + '='.repeat(60)); + console.log('📊 测试 2:执行事件级质控 (Record 1)'); + console.log('='.repeat(60)); + + const runner = createSkillRunner(project.id); + + // 只质控 Record 1 + const results = await runner.runByTrigger('manual', { recordId: '1' }); + + console.log(`\n质控完成! 处理了 ${results.length} 个 record+event 组合:`); + + for (const result of results) { + const issueCount = result.allIssues.length; + const statusIcon = result.overallStatus === 'PASS' ? '✅' : + result.overallStatus === 'WARNING' ? '⚠️' : '🔴'; + + console.log(`\n ${statusIcon} Record ${result.recordId} - ${result.eventLabel}`); + console.log(` 事件: ${result.eventName}`); + console.log(` 表单: ${result.forms?.join(', ') || '(无)'}`); + console.log(` 状态: ${result.overallStatus}`); + console.log(` 问题数: ${issueCount}`); + + if (issueCount > 0) { + console.log(` 问题详情:`); + for (const issue of result.allIssues.slice(0, 3)) { + console.log(` - ${issue.ruleName}: ${issue.llmMessage || issue.message}`); + } + if (issueCount > 3) { + console.log(` ... 还有 ${issueCount - 3} 个问题`); + } + } + } + + // =============================== + // 测试 3:验证全量质控(所有记录所有事件) + // =============================== + console.log('\n' + '='.repeat(60)); + console.log('📊 测试 3:全量质控统计'); + console.log('='.repeat(60)); + + const allResults = await runner.runByTrigger('manual'); + + // 按事件类型统计 + const eventStats = new Map(); + + for (const result of allResults) { + const eventLabel = result.eventLabel || 'Unknown'; + if (!eventStats.has(eventLabel)) { + eventStats.set(eventLabel, { total: 0, pass: 0, fail: 0, warning: 0 }); + } + const stats = eventStats.get(eventLabel)!; + stats.total++; + if (result.overallStatus === 'PASS') stats.pass++; + else if (result.overallStatus === 'FAIL') stats.fail++; + else if (result.overallStatus === 'WARNING') stats.warning++; + } + + console.log(`\n质控总数: ${allResults.length} 个 record+event 组合`); + console.log(`\n按事件类型统计:`); + for (const [event, stats] of eventStats) { + console.log(` ${event}: 总${stats.total}, 通过${stats.pass}, 警告${stats.warning}, 失败${stats.fail}`); + } + + // 问题分布 + const ruleStats = new Map(); + for (const result of allResults) { + for (const issue of result.allIssues) { + const ruleName = issue.ruleName || 'Unknown'; + ruleStats.set(ruleName, (ruleStats.get(ruleName) || 0) + 1); + } + } + + console.log(`\n问题分布:`); + for (const [rule, count] of ruleStats) { + console.log(` ${rule}: ${count} 次`); + } + + // =============================== + // 测试 4:验证数据库日志(包含事件信息) + // =============================== + console.log('\n' + '='.repeat(60)); + console.log('📊 测试 4:验证数据库日志'); + console.log('='.repeat(60)); + + const recentLogs = await prisma.iitQcLog.findMany({ + where: { projectId: project.id }, + orderBy: { createdAt: 'desc' }, + take: 5, + select: { + id: true, + recordId: true, + eventId: true, + qcType: true, + formName: true, + status: true, + createdAt: true, + } + }); + + console.log(`\n最近 ${recentLogs.length} 条质控日志:`); + for (const log of recentLogs) { + console.log(` [${log.recordId}] 事件: ${log.eventId || '(无)'}`); + console.log(` 类型: ${log.qcType}, 表单: ${log.formName || '(无)'}, 状态: ${log.status}`); + } + + console.log('\n' + '='.repeat(60)); + console.log('✅ V3.1 事件级质控测试完成'); + console.log('='.repeat(60)); + + await prisma.$disconnect(); +} + +main().catch(async (error) => { + console.error('❌ 脚本出错:', error); + await prisma.$disconnect(); + process.exit(1); +}); diff --git a/backend/test-form-status.ts b/backend/test-form-status.ts new file mode 100644 index 00000000..1781fcb7 --- /dev/null +++ b/backend/test-form-status.ts @@ -0,0 +1,164 @@ +/** + * 测试脚本:验证表单状态和计算字段获取 + */ +import { PrismaClient } from '@prisma/client'; +import { RedcapAdapter } from './src/modules/iit-manager/adapters/RedcapAdapter.js'; + +const prisma = new PrismaClient(); + +async function main() { + console.log('='.repeat(60)); + console.log('📋 表单状态和计算字段测试'); + console.log('='.repeat(60)); + + // 1. 获取项目配置 + const project = await prisma.iitProject.findFirst({ + where: { name: { contains: 'test0207' } }, + }); + + if (!project) { + console.log('❌ 未找到项目'); + await prisma.$disconnect(); + return; + } + + const adapter = new RedcapAdapter(project.redcapUrl, project.redcapApiToken); + + // =============================== + // 测试 1:获取计算字段列表 + // =============================== + console.log('\n' + '='.repeat(60)); + console.log('📊 测试 1:计算字段列表'); + console.log('='.repeat(60)); + + const calcFields = await adapter.getCalculatedFields(); + console.log(`\n共有 ${calcFields.length} 个计算字段:`); + for (const field of calcFields) { + console.log(` 📐 ${field.fieldName} (${field.fieldLabel})`); + console.log(` 表单: ${field.formName}`); + console.log(` 公式: ${field.calculation.substring(0, 80)}${field.calculation.length > 80 ? '...' : ''}`); + } + + // =============================== + // 测试 2:获取表单完成状态 + // =============================== + console.log('\n' + '='.repeat(60)); + console.log('📊 测试 2:表单完成状态 (前 5 条记录)'); + console.log('='.repeat(60)); + + const formStatus = await adapter.getFormCompletionStatus(); + + // 只显示前 5 条 + for (const record of formStatus.slice(0, 5)) { + console.log(`\n [Record ${record.recordId}] ${record.allComplete ? '✅ 全部完成' : '⚠️ 部分未完成'}`); + + // 统计各状态数量 + const statusCounts = { Complete: 0, Unverified: 0, Incomplete: 0 }; + for (const [formName, status] of Object.entries(record.forms)) { + statusCounts[status.statusLabel]++; + } + console.log(` Complete: ${statusCounts.Complete}, Unverified: ${statusCounts.Unverified}, Incomplete: ${statusCounts.Incomplete}`); + + // 显示非 Complete 的表单 + const nonComplete = Object.entries(record.forms) + .filter(([_, s]) => s.statusLabel !== 'Complete') + .map(([name, s]) => `${name}(${s.statusLabel})`); + + if (nonComplete.length > 0 && nonComplete.length <= 5) { + console.log(` 未完成: ${nonComplete.join(', ')}`); + } else if (nonComplete.length > 5) { + console.log(` 未完成: ${nonComplete.slice(0, 5).join(', ')}... (共${nonComplete.length}个)`); + } + } + + // =============================== + // 测试 3:单个表单状态检查 + // =============================== + console.log('\n' + '='.repeat(60)); + console.log('📊 测试 3:检查 Record 3 的人口学表单状态'); + console.log('='.repeat(60)); + + const demoFormStatus = await adapter.isFormComplete('3', 'basic_demography_form'); + console.log(`\n Record 3 - basic_demography_form:`); + console.log(` 状态: ${demoFormStatus.statusLabel} (${demoFormStatus.status})`); + console.log(` 完成: ${demoFormStatus.isComplete ? '是' : '否'}`); + + // =============================== + // 测试 4:关联分析 - 计算字段 vs 表单状态 + // =============================== + console.log('\n' + '='.repeat(60)); + console.log('📊 测试 4:计算字段与表单状态关联分析'); + console.log('='.repeat(60)); + + // 获取所有记录数据 + const allRecords = await adapter.getAllRecordsMerged(); + + console.log('\n 分析计算字段 "age" 与表单状态的关系:'); + console.log(' ' + '-'.repeat(50)); + + for (const record of allRecords.slice(0, 5)) { + const recId = record.record_id; + const age = record.age; + const dob = record.date_of_birth; + + // 获取该记录的表单状态 + const statusInfo = formStatus.find(s => s.recordId === recId); + const demoStatus = statusInfo?.forms['basic_demography_form']?.statusLabel || 'Unknown'; + + const ageDisplay = age !== undefined && age !== '' ? age : '(空)'; + console.log(` Record ${recId}: age=${ageDisplay}, dob=${dob || '(空)'}, 表单状态=${demoStatus}`); + } + + // =============================== + // 测试 5:按事件维度的表单完成状态(正确方式) + // =============================== + console.log('\n' + '='.repeat(60)); + console.log('📊 测试 5:按事件维度的表单完成状态 (Record 1)'); + console.log('='.repeat(60)); + + const eventStatus = await adapter.getFormCompletionStatusByEvent('1'); + + console.log(`\nRecord 1 共有 ${eventStatus.length} 个事件:`); + for (const event of eventStatus) { + const statusIcon = event.eventComplete ? '✅' : '⚠️'; + console.log(`\n ${statusIcon} 事件: ${event.eventLabel} (${event.eventName})`); + + for (const [formName, status] of Object.entries(event.forms)) { + const icon = status.status === 2 ? '✅' : (status.status === 1 ? '🟡' : '🔴'); + console.log(` ${icon} ${formName}: ${status.statusLabel}`); + } + } + + // =============================== + // 测试 6:获取表单-事件映射 + // =============================== + console.log('\n' + '='.repeat(60)); + console.log('📊 测试 6:表单-事件映射统计'); + console.log('='.repeat(60)); + + const formEventMapping = await adapter.getFormEventMapping(); + console.log(`\n表单-事件映射总数: ${formEventMapping.length}`); + + // 按事件分组统计 + const eventFormCount = new Map(); + for (const m of formEventMapping) { + eventFormCount.set(m.eventLabel, (eventFormCount.get(m.eventLabel) || 0) + 1); + } + + console.log('\n各事件的表单数:'); + for (const [event, count] of eventFormCount) { + console.log(` ${event}: ${count} 个表单`); + } + + console.log('\n' + '='.repeat(60)); + console.log('✅ 测试完成'); + console.log('='.repeat(60)); + + await prisma.$disconnect(); +} + +main().catch(async (error) => { + console.error('❌ 脚本出错:', error); + await prisma.$disconnect(); + process.exit(1); +}); diff --git a/backend/test-redcap-calculated-fields.ts b/backend/test-redcap-calculated-fields.ts new file mode 100644 index 00000000..5c77e9e3 --- /dev/null +++ b/backend/test-redcap-calculated-fields.ts @@ -0,0 +1,271 @@ +/** + * 测试脚本:验证 REDCap 计算字段导出和全量质控 + * + * 测试内容: + * 1. 验证 exportCalculatedFields 参数是否生效 + * 2. 检查年龄等计算字段是否正确获取 + * 3. 执行全量质控并查看结果 + */ +import { PrismaClient } from '@prisma/client'; +import { RedcapAdapter } from './src/modules/iit-manager/adapters/RedcapAdapter.js'; +import { createSkillRunner } from './src/modules/iit-manager/engines/SkillRunner.js'; + +const prisma = new PrismaClient(); + +async function main() { + console.log('='.repeat(60)); + console.log('📋 REDCap 计算字段导出测试'); + console.log('='.repeat(60)); + + // 1. 获取项目配置 + const project = await prisma.iitProject.findFirst({ + where: { name: { contains: 'test0207' } }, + select: { + id: true, + name: true, + redcapUrl: true, + redcapApiToken: true, + fieldMappings: true + } + }); + + if (!project) { + console.log('❌ 未找到 test0207 项目'); + await prisma.$disconnect(); + return; + } + + console.log(`\n✅ 找到项目: ${project.name} (ID: ${project.id})`); + + // 2. 创建 REDCap 适配器(使用默认的 exportCalculatedFields=true) + const adapter = new RedcapAdapter(project.redcapUrl, project.redcapApiToken); + + // =============================== + // 测试 1:导出单条记录 + // =============================== + console.log('\n' + '='.repeat(60)); + console.log('📊 测试 1:导出 Record ID=1 的数据'); + console.log('='.repeat(60)); + + try { + const record = await adapter.getRecordById('1'); + + if (!record) { + console.log('❌ 未找到 Record ID=1'); + } else { + // 显示所有字段 + console.log('\n📋 所有字段及其值:'); + const sortedKeys = Object.keys(record).sort(); + for (const key of sortedKeys) { + const value = record[key]; + const displayValue = value === '' ? '(空)' : value; + console.log(` ${key}: ${displayValue}`); + } + + // 特别检查关键字段 + console.log('\n🔍 关键字段检查:'); + const keyFields = ['record_id', 'age', '年龄', 'date_of_birth', '出生日期', 'gender', '性别']; + for (const field of keyFields) { + if (record[field] !== undefined) { + console.log(` ✅ ${field} = ${record[field] || '(空)'}`); + } + } + + // 检查是否有年龄相关字段 + console.log('\n🔍 年龄相关字段(模糊匹配):'); + const ageRelated = Object.entries(record).filter(([key]) => + key.toLowerCase().includes('age') || + key.toLowerCase().includes('年龄') || + key.toLowerCase().includes('birth') || + key.toLowerCase().includes('出生') + ); + if (ageRelated.length > 0) { + for (const [key, value] of ageRelated) { + console.log(` ${key} = ${value || '(空)'}`); + } + } else { + console.log(' ⚠️ 未找到年龄相关字段'); + } + } + } catch (error: any) { + console.error('❌ REDCap 查询失败:', error.message); + } + + // =============================== + // 测试 2:导出所有记录并统计 + // =============================== + console.log('\n' + '='.repeat(60)); + console.log('📊 测试 2:导出所有记录统计'); + console.log('='.repeat(60)); + + try { + const allRecords = await adapter.getAllRecordsMerged(); + console.log(`\n📋 共获取 ${allRecords.length} 条记录`); + + // 检查每条记录的年龄字段 + const ageField = 'age'; // 假设字段名为 age + let hasAge = 0; + let noAge = 0; + + for (const record of allRecords) { + // 查找任何包含 age 的字段 + const ageValue = record['age'] ?? record['Age'] ?? record['年龄']; + if (ageValue !== undefined && ageValue !== '') { + hasAge++; + } else { + noAge++; + } + } + + console.log(` 有年龄值的记录: ${hasAge}`); + console.log(` 无年龄值的记录: ${noAge}`); + + // 显示前 3 条记录的摘要 + console.log('\n📋 前 3 条记录摘要:'); + for (let i = 0; i < Math.min(3, allRecords.length); i++) { + const r = allRecords[i]; + console.log(` [Record ${r.record_id}] age=${r.age ?? '(无)'}, date_of_birth=${r.date_of_birth ?? '(无)'}`); + } + + } catch (error: any) { + console.error('❌ 导出所有记录失败:', error.message); + } + + // =============================== + // 测试 3:检查质控规则 + // =============================== + console.log('\n' + '='.repeat(60)); + console.log('📊 测试 3:检查项目质控规则'); + console.log('='.repeat(60)); + + const skills = await prisma.iitSkill.findMany({ + where: { projectId: project.id }, + select: { id: true, name: true, config: true, isActive: true } + }); + + console.log(`\n📋 项目共有 ${skills.length} 个 Skill:`); + for (const skill of skills) { + console.log(` ${skill.isActive ? '✅' : '❌'} ${skill.name} (${skill.id})`); + + // 显示规则摘要 + const config = skill.config as any; + if (config?.rules && Array.isArray(config.rules)) { + console.log(` 规则数: ${config.rules.length}`); + for (const rule of config.rules.slice(0, 3)) { + console.log(` - ${rule.name}: field=${rule.field}`); + } + if (config.rules.length > 3) { + console.log(` ... 还有 ${config.rules.length - 3} 条规则`); + } + } + } + + // =============================== + // 测试 4:执行全量质控 + // =============================== + console.log('\n' + '='.repeat(60)); + console.log('📊 测试 4:执行全量质控'); + console.log('='.repeat(60)); + + try { + const activeSkills = skills.filter(s => s.isActive); + if (activeSkills.length === 0) { + console.log('⚠️ 没有激活的 Skill,跳过质控测试'); + } else { + console.log(`\n🚀 开始执行全量质控 (项目 ${project.id})...`); + + // 使用 createSkillRunner 创建运行器 + const runner = createSkillRunner(project.id); + + // 执行 manual 触发类型的质控(全量) + const results = await runner.runByTrigger('manual'); + + console.log(`\n✅ 质控完成! 处理了 ${results.length} 条记录`); + + // 统计所有问题 + let totalIssues = 0; + const allIssuesList: any[] = []; + const ruleStats: Record = {}; + + for (const result of results) { + for (const skillResult of result.skillResults) { + for (const issue of skillResult.issues) { + totalIssues++; + allIssuesList.push({ recordId: result.recordId, ...issue }); + const ruleName = issue.ruleName || 'unknown'; + ruleStats[ruleName] = (ruleStats[ruleName] || 0) + 1; + } + } + } + + console.log(` 发现问题总数: ${totalIssues}`); + + if (totalIssues > 0) { + console.log(`\n 问题分布:`); + for (const [ruleName, count] of Object.entries(ruleStats)) { + console.log(` ${ruleName}: ${count} 次`); + } + + // 显示前 5 个问题详情 + console.log(`\n 前 5 个问题详情:`); + for (const issue of allIssuesList.slice(0, 5)) { + console.log(` [Record ${issue.recordId}] ${issue.ruleName}`); + console.log(` 实际值: ${issue.actualValue ?? '(空)'}`); + console.log(` 期望: ${issue.expectedValue ?? '(未知)'}`); + console.log(` 消息: ${issue.llmMessage ?? issue.message}`); + } + } + } + } catch (error: any) { + console.error('❌ 质控执行失败:', error.message); + console.error(error.stack); + } + + // =============================== + // 测试 5:查看最新质控日志 + // =============================== + console.log('\n' + '='.repeat(60)); + console.log('📊 测试 5:最新质控日志'); + console.log('='.repeat(60)); + + const recentLogs = await prisma.iitQcLog.findMany({ + where: { projectId: project.id }, + orderBy: { createdAt: 'desc' }, + take: 5, + select: { + id: true, + recordId: true, + status: true, + issues: true, + createdAt: true, + } + }); + + console.log(`\n📋 最近 ${recentLogs.length} 条质控日志:`); + for (const log of recentLogs) { + const issues = log.issues as any; + const issueCount = Array.isArray(issues) + ? issues.length + : (issues?.items?.length ?? 0); + + console.log(` [${log.recordId}] ${log.status} - ${issueCount} 个问题 (${log.createdAt.toISOString()})`); + + // 显示问题详情 + const issueList = Array.isArray(issues) ? issues : (issues?.items ?? []); + for (const issue of issueList.slice(0, 2)) { + console.log(` ${issue.ruleName}: actualValue=${issue.actualValue ?? '(空)'}, expectedValue=${issue.expectedValue ?? '(未知)'}`); + } + } + + console.log('\n' + '='.repeat(60)); + console.log('✅ 测试完成'); + console.log('='.repeat(60)); + + await prisma.$disconnect(); +} + +main().catch(async (error) => { + console.error('❌ 测试脚本出错:', error); + await prisma.$disconnect(); + process.exit(1); +}); diff --git a/backend/update-skill-applicable-forms.ts b/backend/update-skill-applicable-forms.ts new file mode 100644 index 00000000..178e0e54 --- /dev/null +++ b/backend/update-skill-applicable-forms.ts @@ -0,0 +1,115 @@ +/** + * 更新 Skill 配置:为规则添加 applicableForms + * + * 确保规则只在包含对应表单的事件中执行 + */ +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +async function main() { + console.log('='.repeat(60)); + console.log('📋 更新 Skill 配置:添加 applicableForms'); + console.log('='.repeat(60)); + + // 1. 获取项目 + const project = await prisma.iitProject.findFirst({ + where: { name: { contains: 'test0207' } }, + }); + + if (!project) { + console.log('❌ 未找到项目'); + await prisma.$disconnect(); + return; + } + + console.log(`\n✅ 项目: ${project.name} (${project.id})`); + + // 2. 获取当前 Skill 配置 + const skill = await prisma.iitSkill.findFirst({ + where: { projectId: project.id, isActive: true }, + }); + + if (!skill) { + console.log('❌ 未找到激活的 Skill'); + await prisma.$disconnect(); + return; + } + + console.log(`\n📋 当前 Skill: ${skill.name} (${skill.id})`); + + const config = skill.config as any; + const rules = config?.rules || []; + + console.log(`\n当前规则配置 (共 ${rules.length} 条):`); + for (const rule of rules) { + console.log(` - ${rule.name}`); + console.log(` field: ${rule.field}`); + console.log(` applicableForms: ${JSON.stringify(rule.applicableForms || '(未设置)')}`); + } + + // 3. 定义规则-表单映射 + // 根据规则检查的字段,确定应该在哪个表单中检查 + const ruleFormMapping: Record = { + '年龄范围检查': ['basic_demography_form'], + '出生日期范围检查': ['basic_demography_form'], + '月经周期规律性检查': ['medical_history_and_diagnosis'], + 'VAS 评分检查': ['cmss'], // 或者 mcgill_sfmpq,根据实际字段确定 + '知情同意书签署检查': ['informed_consent'], + }; + + // 4. 更新规则配置 + console.log('\n' + '='.repeat(60)); + console.log('📝 更新规则配置'); + console.log('='.repeat(60)); + + const updatedRules = rules.map((rule: any) => { + const applicableForms = ruleFormMapping[rule.name]; + if (applicableForms) { + console.log(`\n ✅ ${rule.name}`); + console.log(` 添加 applicableForms: ${JSON.stringify(applicableForms)}`); + return { + ...rule, + applicableForms, + }; + } else { + console.log(`\n ⚠️ ${rule.name} - 未找到映射,保持不变`); + return rule; + } + }); + + // 5. 保存更新后的配置 + const updatedConfig = { + ...config, + rules: updatedRules, + }; + + await prisma.iitSkill.update({ + where: { id: skill.id }, + data: { config: updatedConfig }, + }); + + console.log('\n' + '='.repeat(60)); + console.log('✅ 配置更新完成'); + console.log('='.repeat(60)); + + // 6. 验证更新结果 + const verifySkill = await prisma.iitSkill.findUnique({ + where: { id: skill.id }, + }); + + const verifyConfig = verifySkill?.config as any; + console.log('\n更新后的规则配置:'); + for (const rule of verifyConfig?.rules || []) { + console.log(` - ${rule.name}`); + console.log(` applicableForms: ${JSON.stringify(rule.applicableForms || '(未设置)')}`); + } + + await prisma.$disconnect(); +} + +main().catch(async (error) => { + console.error('❌ 脚本出错:', error); + await prisma.$disconnect(); + process.exit(1); +}); diff --git a/docs/00-系统总体设计/00-系统当前状态与开发指南.md b/docs/00-系统总体设计/00-系统当前状态与开发指南.md index 75a13279..ebea4dda 100644 --- a/docs/00-系统总体设计/00-系统当前状态与开发指南.md +++ b/docs/00-系统总体设计/00-系统当前状态与开发指南.md @@ -1,11 +1,12 @@ # AIclinicalresearch 系统当前状态与开发指南 -> **文档版本:** v4.8 +> **文档版本:** v4.9 > **创建日期:** 2025-11-28 > **维护者:** 开发团队 > **最后更新:** 2026-02-08 > **🎉 重大里程碑:** -> - **2026-02-08:IIT 质控系统优化计划制定!** XML 临床切片格式 + 质控驾驶舱 + 方案 A 确认 +> - **2026-02-08:IIT 事件级质控 V3.1 开发完成!** record+event 独立质控 + 规则动态过滤 + 报告去重 + AI对话增强 +> - **2026-02-08:IIT 质控驾驶舱 UI 完成!** XML 临床切片格式 + 质控驾驶舱 + 热力图 + 详情抽屉 > - **2026-02-07:IIT 实时质控系统开发完成!** pg-boss 防抖 + 质控日志 + 录入汇总 + 管理端批量操作 > - **2026-02-05:IIT Manager Agent V2.9.1 架构设计完成!** 双脑架构 + 三层记忆 + 主动性增强 + 隐私合规 > - **2026-02-02:REDCap 生产环境部署完成!** ECS + RDS + HTTPS + 域名全部配置完成 @@ -14,15 +15,14 @@ > - **2026-01-25:Protocol Agent MVP完整交付!** 一键生成研究方案+Word导出 > - **2026-01-24:Protocol Agent 框架完成!** 可复用Agent框架+5阶段对话流程 > - **2026-01-22:OSS 存储集成完成!** 阿里云 OSS 正式接入平台基础层 -> - **2026-01-21:成功替换 Dify!** PKB 模块完全使用自研 pgvector RAG 引擎 > > **最新进展(IIT Manager Agent 2026-02-08):** -> - ✅ **实时质控系统**:pg-boss 防抖 + 质控日志(审计轨迹) + 录入汇总(upsert) -> - ✅ **管理端批量操作**:一键全量质控 + 一键全量汇总(前后端完整) -> - ✅ **质控优化计划**:XML 临床切片格式 + 质控驾驶舱 + 变量标签系统 -> - ✅ **双脑架构**:SOP 状态机(结构化任务) + ReAct 引擎(模糊查询) -> - ✅ **三层记忆**:流水账(全量日志) + 热记忆(高频注入) + 历史书(按需检索) -> - ✅ **隐私合规**:PII 脱敏中间件 + 审计日志 + 可恢复脱敏 +> - ✅ **事件级质控 V3.1**:每个 record+event 独立质控,不再合并覆盖数据 +> - ✅ **规则动态过滤**:applicableEvents/applicableForms 配置规则适用范围 +> - ✅ **质控报告去重**:按 recordId+ruleId 去重,避免多事件重复问题 +> - ✅ **AI 对话增强**:支持"严重违规有几项"等自然语言查询 +> - ✅ **质控驾驶舱 UI**:PromptBuilder XML 格式 + 热力图 + 详情抽屉 +> - ✅ **Bug 修复**:formatPatientData 500 错误 + 记录数统计 + 报告限制移除 > > **部署状态:** ✅ 生产环境运行中 | 公网地址:http://8.140.53.236/ > **REDCap 状态:** ✅ 生产环境运行中 | 地址:https://redcap.xunzhengyixue.com/ @@ -62,7 +62,7 @@ | **PKB** | 个人知识库 | RAG问答、私人文献库 | ⭐⭐⭐ | 🎉 **Dify已替换!自研RAG上线(95%)** | P1 | | **ASL** | AI智能文献 | 文献筛选、Meta分析、证据图谱 | ⭐⭐⭐⭐⭐ | 🎉 **智能检索MVP完成(60%)** - DeepSearch集成 | **P0** | | **DC** | 数据清洗整理 | ETL + 医学NER(百万行级数据) | ⭐⭐⭐⭐⭐ | ✅ **Tool B完成 + Tool C 99%(异步架构+性能优化-99%+多指标转换+7大功能)** | **P0** | -| **IIT** | IIT Manager Agent | AI驱动IIT研究助手 - 双脑架构+REDCap集成 | ⭐⭐⭐⭐⭐ | 🎉 **实时质控完成 + UI优化计划制定(设计100%,代码45%)** | **P0** | +| **IIT** | IIT Manager Agent | AI驱动IIT研究助手 - 双脑架构+REDCap集成 | ⭐⭐⭐⭐⭐ | 🎉 **事件级质控V3.1完成(设计100%,代码60%)** | **P0** | | **SSA** | 智能统计分析 | 队列/预测模型/RCT分析 | ⭐⭐⭐⭐⭐ | 📋 规划中 | P2 | | **ST** | 统计分析工具 | 100+轻量化统计工具 | ⭐⭐⭐⭐ | 📋 规划中 | P2 | | **RVW** | 稿件审查系统 | 方法学评估、审稿流程、Word导出 | ⭐⭐⭐⭐ | ✅ **开发完成(95%)** | P3 | diff --git a/docs/03-业务模块/IIT Manager Agent/00-模块当前状态与开发指南.md b/docs/03-业务模块/IIT Manager Agent/00-模块当前状态与开发指南.md index 0648aee4..537e1b4d 100644 --- a/docs/03-业务模块/IIT Manager Agent/00-模块当前状态与开发指南.md +++ b/docs/03-业务模块/IIT Manager Agent/00-模块当前状态与开发指南.md @@ -1,10 +1,12 @@ # IIT Manager Agent模块 - 当前状态与开发指南 -> **文档版本:** v2.2 +> **文档版本:** v2.4 > **创建日期:** 2026-01-01 > **维护者:** IIT Manager开发团队 -> **最后更新:** 2026-02-08 🎉 **质控系统 UI 与 LLM 格式优化计划制定完成!** +> **最后更新:** 2026-02-08 🎉 **事件级质控架构 V3.1 开发完成!** > **重大里程碑:** +> - ✅ 2026-02-08:**事件级质控架构 V3.1 完成**(record+event 独立质控 + 规则动态过滤 + 报告去重 + AI对话增强) +> - ✅ 2026-02-08:**质控驾驶舱 UI 开发完成**(PromptBuilder XML 格式 + 驾驶舱页面 + 热力图 + 详情抽屉) > - ✅ 2026-02-08:**质控系统优化计划制定**(XML 临床切片格式 + 质控驾驶舱设计 + 方案 A 确认) > - ✅ 2026-02-07:**实时质控系统开发完成**(pg-boss防抖 + 质控日志 + 录入汇总 + 管理端批量操作) > - ✅ 2026-02-05:**V2.9.1 完整开发计划发布**(双脑架构 + 三层记忆 + 主动性增强 + 隐私合规) @@ -44,11 +46,11 @@ IIT Manager Agent(研究者发起试验管理助手)是一个基于企业微 - AI能力:DeepSeek/Qwen + 自研 RAG ### 当前状态 -- **开发阶段**:🎉 **实时质控系统核心功能开发完成!待端到端测试** +- **开发阶段**:🎉 **事件级质控架构 V3.1 开发完成!基本测试通过** - **整体完成度**: - - **基础设施**:85%(REDCap + 企业微信 + AI 对话 + 实时质控) + - **基础设施**:95%(REDCap + 企业微信 + AI 对话 + 实时质控 + 驾驶舱 UI + 事件级质控) - **架构设计**:100%(V2.9.1 完整开发计划发布) - - **代码实现**:45%(实时质控系统已实现) + - **代码实现**:60%(实时质控系统 + 驾驶舱 UI + 事件级质控 V3.1 已实现) #### ✅ 已完成功能(基础设施) - ✅ 数据库Schema创建(iit_schema,9个表 = 原5个 + 新增4个质控表) @@ -77,6 +79,26 @@ IIT Manager Agent(研究者发起试验管理助手)是一个基于企业微 - ✅ **自动化工具设计**(AutoMapper REDCap Schema 对齐) - ✅ **模块化开发文档**(6 份专项文档) +#### ✅ 已完成功能(质控驾驶舱 UI - 2026-02-08) +- ✅ **PromptBuilder 类**(XML 临床切片格式构建器,减少 LLM 幻觉) +- ✅ **ChatService 优化**(集成 PromptBuilder,使用 XML 格式化数据) +- ✅ **质控驾驶舱页面**(IitQcCockpitPage.tsx,全屏展示模式) +- ✅ **统计卡片组件**(QcStatCards.tsx,4 个核心指标) +- ✅ **风险热力图组件**(RiskHeatmap.tsx,受试者×表单矩阵视图) +- ✅ **详情抽屉组件**(QcDetailDrawer.tsx,含 LLM Trace 视图) +- ✅ **驾驶舱后端 API**(iitQcCockpitService + Controller + Routes) +- ✅ **前端类型定义**(qcCockpit.ts,完整的 TypeScript 类型) + +#### ✅ 已完成功能(事件级质控 V3.1 - 2026-02-08) +- ✅ **事件级质控架构**(每个 record+event 独立质控,不再合并数据) +- ✅ **规则动态过滤**(applicableEvents/applicableForms 配置规则适用范围) +- ✅ **RedcapAdapter 增强**(getAllRecordsByEvent + getFormEventMapping + getEvents) +- ✅ **SkillRunner 重构**(按 record+event 执行 + filterApplicableRules) +- ✅ **质控报告去重**(按 recordId+ruleId 去重,避免多事件重复问题) +- ✅ **报告自动刷新**(导出 XML 前自动获取最新数据) +- ✅ **AI 意图识别增强**(支持"严重违规有几项"等自然语言查询) +- ✅ **Bug 修复**(formatPatientData 500 错误 + 记录数统计 + 报告限制移除) + #### ⏳ 待实施功能(按 Phase 规划) | Phase | 内容 | 优先级 | 状态 | |-------|------|--------|------| @@ -914,10 +936,11 @@ npx ts-node src/modules/iit-manager/test-wechat-push.ts --- > **提示**:本文档反映IIT Manager Agent模块的最新真实状态,每个里程碑完成后必须更新! -> **最后更新**:2026-01-03 22:30 -> **当前进度**:Day 1-3 + Phase 1.5完成(60%)| 下一步:Phase 2 Function Calling + Dify知识库 +> **最后更新**:2026-02-08 22:00 +> **当前进度**:事件级质控 V3.1 完成(60%)| 下一步:Phase 3 ReAct 引擎 + 流水账 > **重要文档**: -> - [Phase 1.5开发完成记录](./06-开发记录/Phase1.5-AI对话集成REDCap完成记录.md) ⭐⭐⭐⭐⭐ +> - [事件级质控开发记录](./06-开发记录/2026-02-08-事件级质控与报告优化开发记录.md) ⭐⭐⭐⭐⭐ +> - [实时质控系统开发记录](./06-开发记录/2026-02-07-实时质控系统开发记录.md) ⭐⭐⭐⭐⭐ > - [REDCap对接技术方案与实施指南](./04-开发计划/REDCap对接技术方案与实施指南.md) ⭐⭐⭐⭐⭐ diff --git a/docs/03-业务模块/IIT Manager Agent/02-技术设计/CRA Agent 业务逻辑与执行架构书 (1).md b/docs/03-业务模块/IIT Manager Agent/02-技术设计/CRA Agent 业务逻辑与执行架构书 (1).md new file mode 100644 index 00000000..d8516dc4 --- /dev/null +++ b/docs/03-业务模块/IIT Manager Agent/02-技术设计/CRA Agent 业务逻辑与执行架构书 (1).md @@ -0,0 +1,72 @@ +# **CRA Agent 业务逻辑与执行架构书** + +**文档目的:** 基于用户定义的 5 大规则体系,构建标准化的 CRA 智能监查工作流。 + +**适用版本:** IIT Manager Agent V2.9.1 + +**创建日期:** 2026-02-07 + +## **🏛️ 一、 规则体系定义 (The 5 Pillars)** + +我们将规则分为两类引擎执行,以平衡成本与智能。 + +| 编号 | 规则类型 | 执行引擎 | 来源方式 | 典型示例 | +| :---- | :---- | :---- | :---- | :---- | +| **1** | **变量质控** | **Hard Engine** (代码) | 自动从 Metadata 同步 | 空值检查、数值范围 (0\ 28+3)、禁用药物使用 | +| **4** | **AE 事件** | **AI Engine** (LLM) | 预置医学 Prompt | Lab 异常值 vs AE 记录一致性检查 (SAE Reconciliation) | +| **5** | **伦理合规** | **Hard Engine** (代码) | 预置逻辑 | 知情同意书签署日期 \> 访视 1 日期 | + +## **🔄 二、 智能监查工作流 (The Workflow)** + +### **Step 1: 触发与范围界定 (Trigger)** + +* **触发源**: + * **自动触发**:Webhook (REDCap 数据录入) 或 Cron (每日凌晨)。 + * **人工触发**:用户点击“立即监查”。 +* **范围优化**:使用 **增量检查 (Incremental Check)**。只对新录入或变更的数据及其相关联规则进行检查。 + +### **Step 2: 漏斗式质控执行 (Funnel Execution)** + +为每个受试者执行以下管道: + +1. **Level 1: 阻断性检查 (Blocking)** + * 检查伦理 (ICF) 和 关键变量缺失。 + * *如果 Fail*:直接标记为 Critical,**中止后续 AI 检查**(省钱)。 +2. **Level 2: 基础清洗 (Cleaning)** + * 运行所有变量质控规则 (Hard Rules)。 + * 生成基础错误日志。 +3. **Level 3: AI 深度监查 (AI Reasoning)** + * 组装 Context (XML+Markdown)。 + * 调用 LLM 检查 入排、PD、AE。 + * **输出证据链**:{ "status": "FAIL", "reason": "...", "evidence": { "var": "alt", "val": 150 } } + +### **Step 3: 结果汇总 (Aggregation)** + +更新 iit\_qc\_project\_stats 和 iit\_record\_summary 表。 + +* 生成 **风险热力图 (Risk Heatmap)** 数据源。 + +### **Step 4: 双模报告输出 (Dual-Mode Reporting)** + +* **Mode A: 人类阅读版 (Interactive UI)** + * 热力图 \+ 诊断卡片。 + * 支持 **“确认/忽略”** 操作 (Query Loop)。 +* **Mode B: LLM 阅读版 (Context Protocol)** + * XML 结构化数据,包含统计摘要 \+ 严重问题清单 \+ 证据链。 + +### **Step 5: 智能问答 (Q\&A Loop)** + +* 用户提问 \-\> ContextBuilder 提取 Mode B 报告 \-\> LLM 回答。 +* *场景*:“帮我列出所有疑似未报 AE 的患者。” + +## **💡 三、 关键增强点** + +1. **SDV 边界声明**:明确系统仅进行“逻辑一致性监查”,无法核对原始病历真伪。 +2. **人机回环 (HITL)**:所有 AI 生成的复杂规则(入排/PD),必须经过人工 **"Review & Activate"** 才能生效。 +3. **三态管理**:质控结果包含 Pending (AI 疑似), Confirmed (人工确认), Ignored (人工忽略)。 + +## **✅ 结论** + +该方案逻辑闭环,技术可行。通过引入 **漏斗执行** 和 **人机回环**,可有效解决成本与准确性问题。 \ No newline at end of file diff --git a/docs/03-业务模块/IIT Manager Agent/02-技术设计/docs_03-业务模块_IIT Manager Agent_02-技术设计_11-基于Skills的CRA规则配置实施指南.md b/docs/03-业务模块/IIT Manager Agent/02-技术设计/docs_03-业务模块_IIT Manager Agent_02-技术设计_11-基于Skills的CRA规则配置实施指南.md new file mode 100644 index 00000000..421b0f82 --- /dev/null +++ b/docs/03-业务模块/IIT Manager Agent/02-技术设计/docs_03-业务模块_IIT Manager Agent_02-技术设计_11-基于Skills的CRA规则配置实施指南.md @@ -0,0 +1,200 @@ +# **基于 Skills 的 CRA 规则配置实施指南** + +**文档目的:** 定义如何通过 iit\_skills 表配置化实现 CRA 的 5 大核心工作(变量质控、入排、PD、AE、伦理)。 + +**适用版本:** IIT Manager Agent V2.9.1 + +**创建日期:** 2026-02-07 + +## **🏗️ 核心概念:Skill \= 规则容器** + +在我们的架构中,**Skill (技能)** 是一个可执行的最小业务单元。它定义了: + +1. **触发条件**:什么时候跑?(Webhook/Cron) +2. **执行引擎**:用哪个引擎跑?(Hard/Soft) +3. **配置数据**:具体规则是什么?(JSON/Prompt) +4. **数据依赖**:需要哪些数据?(Tags) + +### **数据库模型映射 (iit\_skills)** + +我们复用并扩展 V2.6 计划中的 iit\_skills 表设计: + +model IitSkill { + id String @id @default(uuid()) + projectId String @map("project\_id") + name String // e.g., "入排标准核查" + type String // 'HARD\_RULE' | 'LLM\_CHECK' | 'HYBRID' + + // 触发配置 + triggerType String @map("trigger\_type") // 'WEBHOOK' | 'CRON' | 'MANUAL' + cronExpr String? @map("cron\_expr") // 如果是定时任务 + + // 核心配置 (JSONB) + // 包含:Prompts, JSONLogic, Thresholds + config Json + + // 数据依赖 (智能路由用) + // 告诉 ContextBuilder 需要加载哪些 Tag + requiredTags String\[\] @map("required\_tags") // e.g., \['\#lab', '\#demographics'\] + + isEnabled Boolean @default(true) + createdAt DateTime @default(now()) + + @@map("iit\_skills") + @@schema("iit\_schema") +} + +## **🛠️ 五大规则的 Skill 实现方案** + +我们通过配置不同类型的 Skill 来覆盖 CRA 的所有工作。 + +### **1\. 变量质控 Skill (Type: HARD\_RULE)** + +**对应需求:** 针对每个变量,构建数据质控规则。 + +* **场景**:空值检查、数值范围、逻辑跳转。 +* **配置来源**:Rule Studio (Layer 1\) 自动生成。 +* **Config 结构**: + { + "engine": "HardRuleEngine", + "rules": \[ + { "field": "age", "op": "between", "args": \[18, 80\] }, + { "field": "bmi", "op": "lt", "args": \[50\] } + \] + } + +* **执行**:Node.js 直接计算,毫秒级响应。 + +### **2\. 入排标准 Skill (Type: LLM\_CHECK)** + +**对应需求:** 针对是否符合入排标准,建立医学逻辑规则。 + +* **场景**:复杂的医学判断(如:病理确诊时间 \< 3个月)。 +* **配置来源**:Rule Studio (Layer 3\) AI 提取 \+ 人工确认。 +* **数据依赖**:\['\#demographics', '\#lab', '\#history'\] +* **Config 结构**: + { + "engine": "SoftRuleEngine", + "model": "deepseek-v3", + "system\_prompt": "你是一个医学监查员...", + "checks": \[ + { + "id": "I-03", + "desc": "肝肾功能正常 (ALT/AST \< 2.5 ULN)", + "prompt\_template": "基于以下实验室数据 {{\#lab}},判断..." + } + \] + } + +### **3\. 方案偏离 Skill (Type: HYBRID)** + +**对应需求:** 针对是否出现方案偏离,建立核查规则。 + +* **场景**:访视超窗、漏做检查。 +* **配置来源**:Rule Studio (Layer 2\) 逻辑构建器。 +* **Config 结构**: + { + "engine": "HybridEngine", + "logic": { + "if": \[ + { "date\_diff": \["$V2.date", "$V1.date"\] }, + { "\>": 31 }, // 28 \+ 3 + "FAIL", + "PASS" + \] + } + } + +### **4\. AE 监测 Skill (Type: LLM\_CHECK)** + +**对应需求:** 针对是否出现 AE 事件,建立核查规则。 + +* **场景**:SAE Reconciliation(Lab 异常 vs AE 记录)。 +* **数据依赖**:\['\#lab', '\#ae'\] +* **Config 结构**: + { + "engine": "SoftRuleEngine", + "task": "AE\_RECONCILIATION", + "prompt": "对比 \ 中的 Grade 3+ 异常值与 \ 记录,找出未报告的 AE。" + } + +### **5\. 伦理合规 Skill (Type: HARD\_RULE)** + +**对应需求:** 针对是否出现伦理问题,建立规则。 + +* **场景**:ICF 日期逻辑。 +* **Config 结构**: + { + "engine": "HardRuleEngine", + "rule": { + "op": "date\_before", + "a": "$icf\_date", + "b": "$first\_visit\_date" + }, + "severity": "CRITICAL" + } + +## **⏰ 三大触发时机详解 (Trigger Strategy)** + +我们将质控规则的执行时机划分为“实时、定时、人工”黄金三角,以平衡时效性与成本。 + +### **1\. 🟢 实时触发 (Real-time / Webhook)** + +* **触发源**:REDCap DET (Data Entry Trigger)。CRC 保存任意表单时触发。 +* **执行范围**:**单点切片 (Micro-Batch)**。 + * 仅针对 **当前受试者 (Current Record)**。 + * 仅加载 **与当前表单相关** 的 Skill (通过 formName 过滤)。 + * *例如:录入“血常规”单,系统只检查 \#lab 相关规则,不会检查人口学。* +* **核心价值**:**阻断错误**。在 CRC 记忆犹新时(秒级)推送企微提醒,纠正成本最低。 +* **成本策略**:默认优先跑 **Hard Rules**。涉及核心安全指标(如 AE)时才触发 Soft Rules (LLM)。 + +### **2\. 🔵 定时触发 (Scheduled / Cron)** + +* **触发源**:pg-boss Cron Job。每日凌晨 (e.g., 02:00) 执行。 +* **执行范围**:**全量扫描 (Full Scan)**。 + * 针对 **所有活跃受试者**。 + * 重点运行 **跨表逻辑** (如一致性检查) 和 **时间敏感型规则** (如访视超窗 PD)。 +* **核心价值**:**发现隐患**。捕捉“因时间流逝而产生的问题”(如昨天未超窗,今天超窗)和“漏录问题”。 +* **成本策略**:使用 **增量标记**。若数据 Hash 未变且无时间规则,跳过 LLM 检查。 + +### **3\. 🟠 人工触发 (Manual / On-Demand)** + +* **触发源**:管理端 "一键全量质控" 或 "单受试者重跑" 按钮。 +* **执行范围**:**按需全量**。 + * 针对选定的受试者范围。 + * 运行 **所有启用** 的 Skill。 +* **核心价值**:**合规审计与验证**。用于项目初始化清洗、规则调整后的验证、或上级核查前的自查。 + +## **🔄 调度与执行:Skill Runner** + +我们不需要为每个规则写死代码,而是实现一个通用的 **SkillRunner**。 + +### **执行流程** + +1. **触发 (Trigger)**: + * Webhook 收到数据 \-\> 触发 SkillRunner.runByTrigger('WEBHOOK', projectId, recordId, formName) + * Cron Job 到点 \-\> 触发 SkillRunner.runByTrigger('CRON', projectId) + * 人工点击 \-\> 触发 SkillRunner.runByTrigger('MANUAL', projectId) +2. **加载 (Load)**: + * Runner 从 iit\_skills 表加载所有启用的 Skill。 + * **过滤**:如果是 WEBHOOK 触发,仅加载与当前 formName 关联的 Skill。 +3. **路由 (Route)**: + * 根据 Skill 的 type 分发给对应的 Engine (HardRuleEngine 或 SoftRuleEngine)。 +4. **上下文构建 (Context)**: + * 如果需要 LLM,调用 ContextBuilder,传入 Skill 定义的 requiredTags,只拉取相关数据。 +5. **结果聚合 (Aggregate)**: + * 收集所有 Skill 的执行结果,存入 iit\_qc\_logs。 + +## **✅ 结论:对当前计划的影响** + +你的 **V2.6 开发计划** 非常稳健,只需要在细节上明确 Skill 的定义即可。 + +**建议调整:** + +1. **Phase 1**:重点设计 iit\_skills 的 JSON Schema,确保它能容纳上述 5 种类型的配置。 +2. **Phase 2**:实现 SkillRunner,作为连接 SOP 和 Engine 的中间件。 + +**总结**:通过 SKILLS 配置化,你的系统就像一个\*\*“可插拔的乐高玩具”\*\*。 + +* 如果明天要加一个“肿瘤评估 (RECIST)”规则,你只需要新增一个 Skill,完全不用改后端代码。 +* 这正是 SaaS 化产品的核心竞争力。 \ No newline at end of file diff --git a/docs/03-业务模块/IIT Manager Agent/04-开发计划/07-质控系统UI与LLM格式优化计划.md b/docs/03-业务模块/IIT Manager Agent/04-开发计划/07-质控系统UI与LLM格式优化计划.md index ead04c31..201b1d4a 100644 --- a/docs/03-业务模块/IIT Manager Agent/04-开发计划/07-质控系统UI与LLM格式优化计划.md +++ b/docs/03-业务模块/IIT Manager Agent/04-开发计划/07-质控系统UI与LLM格式优化计划.md @@ -338,18 +338,33 @@ WHERE field_name = 'informed_consent'; ## 六、验收标准 -### 6.1 阶段 1 验收 +### 6.1 阶段 1 验收 ✅ **已完成 (2026-02-08)** -- [ ] 企业微信问"质控情况",AI 回答格式清晰、无幻觉 -- [ ] AI 回答包含具体问题和记录数 -- [ ] 日志中可看到 XML 格式的 Prompt +- [x] 企业微信问"质控情况",AI 回答格式清晰、无幻觉 +- [x] AI 回答包含具体问题和记录数 +- [x] 日志中可看到 XML 格式的 Prompt -### 6.2 阶段 2 验收 +**完成的文件:** +- `backend/src/modules/iit-manager/services/PromptBuilder.ts` - XML 临床切片格式构建器 +- `backend/src/modules/iit-manager/services/ChatService.ts` - 集成 PromptBuilder,优化 LLM 格式 -- [ ] 点击"质控全览图"按钮可进入驾驶舱页面 -- [ ] 统计卡片正确显示质量分、违规数、完成率 -- [ ] 热力图正确显示记录×表单的质控状态 -- [ ] 点击红色单元格可查看问题详情 +### 6.2 阶段 2 验收 ✅ **已完成 (2026-02-08)** + +- [x] 点击"质控全览图"按钮可进入驾驶舱页面 +- [x] 统计卡片正确显示质量分、违规数、完成率 +- [x] 热力图正确显示记录×表单的质控状态 +- [x] 点击红色单元格可查看问题详情 + +**完成的文件:** +- `frontend-v2/src/modules/admin/pages/IitQcCockpitPage.tsx` - 驾驶舱主页面 +- `frontend-v2/src/modules/admin/pages/IitQcCockpitPage.css` - 驾驶舱样式 +- `frontend-v2/src/modules/admin/components/qc-cockpit/QcStatCards.tsx` - 统计卡片组件 +- `frontend-v2/src/modules/admin/components/qc-cockpit/RiskHeatmap.tsx` - 风险热力图组件 +- `frontend-v2/src/modules/admin/components/qc-cockpit/QcDetailDrawer.tsx` - 详情抽屉组件 +- `frontend-v2/src/modules/admin/types/qcCockpit.ts` - 类型定义 +- `backend/src/modules/admin/iit-projects/iitQcCockpitService.ts` - 驾驶舱数据服务 +- `backend/src/modules/admin/iit-projects/iitQcCockpitController.ts` - 驾驶舱 API 控制器 +- `backend/src/modules/admin/iit-projects/iitQcCockpitRoutes.ts` - 驾驶舱路由 ### 6.3 阶段 3 验收 diff --git a/docs/03-业务模块/IIT Manager Agent/04-开发计划/08-CRA智能质控引擎开发计划.md b/docs/03-业务模块/IIT Manager Agent/04-开发计划/08-CRA智能质控引擎开发计划.md new file mode 100644 index 00000000..e48bf109 --- /dev/null +++ b/docs/03-业务模块/IIT Manager Agent/04-开发计划/08-CRA智能质控引擎开发计划.md @@ -0,0 +1,583 @@ +# CRA 智能质控引擎开发计划 + +> **版本:** V1.0 +> **日期:** 2026-02-08 +> **基于:** V2.6 综合开发计划、CRA Agent 业务逻辑与执行架构书、Skills 配置实施指南 +> **核心目标:** 实现基于规则的智能质控引擎 + 报告驱动的 LLM 问答 + +--- + +## 📋 目录 + +1. [背景与目标](#1-背景与目标) +2. [架构设计](#2-架构设计) +3. [五大规则体系](#3-五大规则体系) +4. [当前代码现状](#4-当前代码现状) +5. [开发任务清单](#5-开发任务清单) +6. [数据库设计补充](#6-数据库设计补充) +7. [验收标准](#7-验收标准) + +--- + +## 1. 背景与目标 + +### 1.1 业务需求 + +针对 CRA(临床研究助理)的质控工作,基于 REDCap 系统的数据结构,建立以下规则体系: + +| 编号 | 规则类型 | 说明 | 来源 | +|------|----------|------|------| +| 1 | 变量质控 | 针对每个变量的数据校验(空值、范围、格式) | 自动从 Metadata 同步 | +| 2 | 入排标准 | 判断是否符合入组/排除标准 | AI 解析 Protocol + 人工确认 | +| 3 | 方案偏离 (PD) | 检测访视超窗、漏做检查等 | 人工配置逻辑 | +| 4 | AE 事件 | 检测未报告的不良事件 | 预置医学 Prompt | +| 5 | 伦理合规 | ICF 签署时间、隐私保护等 | 预置硬规则 | + +### 1.2 核心目标 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 🎯 目标:报告驱动的智能质控 │ +│ │ +│ 后台预计算 → 生成结构化报告 → LLM 阅读报告 → 回答用户问题 │ +│ │ +│ ✅ 快速(报告已预生成) │ +│ ✅ 全面(后台执行所有规则) │ +│ ✅ 省钱(LLM 只做阅读理解) │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 1.3 与 V2.6 计划的关系 + +本计划是 V2.6 综合开发计划的**专项实施细化**,聚焦于: +- Phase 1 的 `HardRuleEngine` 深化 +- Phase 2 的 `SoftRuleEngine` 实现 +- Phase 4 的 `SchedulerService` 和 `ReportService` 实现 + +--- + +## 2. 架构设计 + +### 2.1 整体架构 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 触发层 (Trigger) │ +├──────────────────┬──────────────────┬───────────────────────────┤ +│ 🟢 Webhook │ 🔵 Cron │ 🟠 Manual │ +│ REDCap DET │ 每日凌晨 │ 一键全量质控 │ +│ 单记录实时 │ 全量扫描 │ 按需执行 │ +└──────────────────┴──────────────────┴───────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ 🎯 SkillRunner (调度器) │ +│ │ +│ 1. 加载启用的 Skills │ +│ 2. 根据 triggerType 过滤 │ +│ 3. 路由到对应 Engine │ +│ 4. 聚合结果 │ +└─────────────────────────────────────────────────────────────────┘ + │ + ┌─────────────────┼─────────────────┐ + ▼ ▼ ▼ +┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ +│ HardRuleEngine │ │ SoftRuleEngine │ │ HybridEngine │ +│ (已实现 ✅) │ │ (待实现 ❌) │ │ (待实现 ❌) │ +│ │ │ │ │ │ +│ - 变量质控 │ │ - 入排标准 │ │ - 方案偏离 │ +│ - 伦理合规 │ │ - AE 检测 │ │ - Hard + Soft │ +│ │ │ │ │ │ +│ JSON Logic │ │ LLM 推理 │ │ 条件分支 │ +└──────────────────┘ └──────────────────┘ └──────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ 📊 结果存储 & 报告生成 │ +├─────────────────────────────────────────────────────────────────┤ +│ iit_qc_logs → 详细质控日志 │ +│ iit_qc_project_stats → 项目统计摘要 │ +│ iit_qc_report (新) → LLM 友好的结构化报告 │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ 💬 用户问答 (实时) │ +│ │ +│ 用户提问 → ContextBuilder 加载报告 → LLM 阅读 → 回答 │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 2.2 漏斗式执行策略 + +为每个受试者执行以下管道,**分级检查,逐层深入**: + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Level 1: 阻断性检查 (Blocking) │ +│ - 伦理合规 (ICF 日期) │ +│ - 关键字段缺失 │ +│ 如果 FAIL → 直接标记 Critical,跳过后续 AI 检查(省钱) │ +├─────────────────────────────────────────────────────────────────┤ +│ Level 2: 基础清洗 (Hard Rules) │ +│ - 所有变量质控规则 │ +│ - 生成基础错误日志 │ +├─────────────────────────────────────────────────────────────────┤ +│ Level 3: AI 深度监查 (Soft Rules) │ +│ - 入排标准判断 │ +│ - 方案偏离检测 │ +│ - AE 事件核查 │ +│ - 输出证据链 │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 3. 五大规则体系 + +### 3.1 规则类型与执行引擎 + +| 编号 | 规则类型 | 执行引擎 | 触发时机 | 典型示例 | +|------|----------|----------|----------|----------| +| 1 | 变量质控 | HardRuleEngine | 实时 + 定时 | 空值检查、0 < BMI < 50 | +| 2 | 入排标准 | SoftRuleEngine | 定时 + 人工 | 确诊时间 < 3个月 | +| 3 | 方案偏离 | HybridEngine | 定时 | 访视超窗 (Date2 - Date1 > 28+3) | +| 4 | AE 事件 | SoftRuleEngine | 定时 | Lab 异常 vs AE 记录一致性 | +| 5 | 伦理合规 | HardRuleEngine | 实时 | ICF 签署日期 > 首次访视 | + +### 3.2 Skill 配置示例 + +#### 3.2.1 变量质控 Skill (HARD_RULE) + +```json +{ + "skillType": "VARIABLE_QC", + "name": "变量质控", + "type": "HARD_RULE", + "triggerType": "webhook", + "config": { + "engine": "HardRuleEngine", + "rules": [ + { + "id": "VQ-001", + "name": "年龄范围检查", + "field": "age", + "formName": "demographics", + "logic": { "and": [{ ">=": [{ "var": "age" }, 18] }, { "<=": [{ "var": "age" }, 80] }] }, + "message": "年龄必须在 18-80 岁之间", + "severity": "error", + "category": "lab_values" + }, + { + "id": "VQ-002", + "name": "BMI 范围检查", + "field": "bmi", + "formName": "demographics", + "logic": { "and": [{ ">": [{ "var": "bmi" }, 0] }, { "<": [{ "var": "bmi" }, 50] }] }, + "message": "BMI 值异常", + "severity": "warning", + "category": "lab_values" + } + ] + } +} +``` + +#### 3.2.2 入排标准 Skill (LLM_CHECK) + +```json +{ + "skillType": "INCLUSION_EXCLUSION", + "name": "入排标准核查", + "type": "LLM_CHECK", + "triggerType": "cron", + "config": { + "engine": "SoftRuleEngine", + "model": "deepseek-v3", + "systemPrompt": "你是一个专业的临床研究监查员,负责核查受试者是否符合入组标准。", + "checks": [ + { + "id": "IE-001", + "name": "确诊时间核查", + "promptTemplate": "基于以下数据,判断受试者的确诊时间是否在 3 个月内:\n{{#demographics}}\n{{#medical_history}}\n\n请给出判断结果 (PASS/FAIL) 和理由。", + "requiredTags": ["#demographics", "#medical_history"] + } + ] + }, + "requiredTags": ["#demographics", "#medical_history", "#lab"] +} +``` + +#### 3.2.3 伦理合规 Skill (HARD_RULE) + +```json +{ + "skillType": "ETHICS_COMPLIANCE", + "name": "伦理合规检查", + "type": "HARD_RULE", + "triggerType": "webhook", + "config": { + "engine": "HardRuleEngine", + "level": "blocking", + "rules": [ + { + "id": "EC-001", + "name": "知情同意书签署时间", + "field": ["icf_date", "first_visit_date"], + "logic": { "<=": [{ "var": "icf_date" }, { "var": "first_visit_date" }] }, + "message": "知情同意书签署日期必须早于或等于首次访视日期", + "severity": "error", + "category": "ethics" + } + ] + } +} +``` + +--- + +## 4. 当前代码现状 + +### 4.1 已实现组件 ✅ + +| 组件 | 路径 | 说明 | +|------|------|------| +| `IitSkill` 表 | `prisma/schema.prisma` | 已定义,支持 triggerType | +| `IitFieldMapping` 表 | `prisma/schema.prisma` | 字段映射表 | +| `HardRuleEngine` | `engines/HardRuleEngine.ts` | 基于 JSON Logic,支持 formName 过滤 | +| `QcService` | `services/QcService.ts` | 基础查询服务 | +| `PromptBuilder` | `services/PromptBuilder.ts` | XML Clinical Slice 格式 | +| `RedcapAdapter` | `adapters/RedcapAdapter.ts` | REDCap API 适配 | +| QC Cockpit UI | `frontend-v2/.../IitQcCockpitPage.tsx` | 热力图 + 详情抽屉 | +| 字段元数据同步 | `iitProjectService.syncMetadata` | 含 Form Labels | + +### 4.2 缺失组件 ❌ + +| 组件 | 优先级 | 依赖 | 说明 | +|------|--------|------|------| +| `SoftRuleEngine` | P0 | LLMFactory | LLM 推理引擎 | +| `HybridEngine` | P1 | HardRule + SoftRule | 混合执行引擎 | +| `SkillRunner` | P0 | 所有 Engine | 规则调度器 | +| `QcReportService` | P0 | SkillRunner | LLM 友好报告生成 | +| `iit_qc_report` 表 | P0 | - | 报告存储 | +| 定时调度 | P1 | pg-boss / node-cron | Cron 触发 | +| 增量检查 | P2 | - | 数据 Hash 策略 | +| Rule Studio UI | P2 | - | 规则配置界面 | +| 默认规则配置 | P0 | - | 5 大规则的初始 JSON | + +--- + +## 5. 开发任务清单 + +### Phase 1: 核心引擎 (Week 1) + +| 任务 | 优先级 | 预估 | 产出 | +|------|--------|------|------| +| 1.1 实现 `SoftRuleEngine` | P0 | 2天 | LLM 推理引擎 | +| 1.2 实现 `SkillRunner` | P0 | 1天 | 规则调度器 | +| 1.3 扩展 `IitSkill` Schema | P0 | 0.5天 | 添加 `requiredTags` 字段 | +| 1.4 创建默认规则配置 | P0 | 1天 | 5 大规则的 Seed 数据 | + +#### 1.1 SoftRuleEngine 设计 + +```typescript +// backend/src/modules/iit-manager/engines/SoftRuleEngine.ts + +export interface SoftRuleCheck { + id: string; + name: string; + promptTemplate: string; + requiredTags: string[]; +} + +export interface SoftRuleResult { + checkId: string; + status: 'PASS' | 'FAIL' | 'UNCERTAIN'; + reason: string; + evidence: Record; + confidence: number; +} + +export class SoftRuleEngine { + private projectId: string; + private llmClient: LLMClient; + + async execute( + recordId: string, + data: Record, + checks: SoftRuleCheck[] + ): Promise; +} +``` + +#### 1.2 SkillRunner 设计 + +```typescript +// backend/src/modules/iit-manager/engines/SkillRunner.ts + +export class SkillRunner { + /** + * 按触发类型执行 Skills + */ + async runByTrigger( + triggerType: 'webhook' | 'cron' | 'manual', + projectId: string, + options?: { + recordId?: string; // webhook 时必传 + formName?: string; // webhook 时过滤相关规则 + } + ): Promise; + + /** + * 执行单个 Skill + */ + private async executeSkill( + skill: IitSkill, + recordId: string, + data: Record + ): Promise; +} +``` + +### Phase 2: 报告系统 (Week 2) + +| 任务 | 优先级 | 预估 | 产出 | +|------|--------|------|------| +| 2.1 设计 `iit_qc_report` 表 | P0 | 0.5天 | 报告存储 Schema | +| 2.2 实现 `QcReportService` | P0 | 2天 | 报告生成服务 | +| 2.3 集成到 Cockpit UI | P1 | 1天 | 报告查看入口 | +| 2.4 实现 LLM 问答集成 | P1 | 1天 | 基于报告回答 | + +#### 2.2 QcReportService 设计 + +```typescript +// backend/src/modules/iit-manager/services/QcReportService.ts + +export interface QcReport { + projectId: string; + generatedAt: Date; + summary: { + totalRecords: number; + criticalIssues: number; + pendingQueries: number; + passRate: number; + }; + criticalList: QcIssue[]; + warningList: QcIssue[]; + llmFriendlyXml: string; // LLM 阅读版 +} + +export class QcReportService { + /** + * 生成项目质控报告 + */ + async generateReport(projectId: string): Promise; + + /** + * 获取缓存的报告(用于 LLM 问答) + */ + async getCachedReport(projectId: string): Promise; + + /** + * 构建 LLM 友好的 XML 报告 + */ + private buildLlmXmlReport(stats: ProjectStats, issues: QcIssue[]): string; +} +``` + +#### LLM 阅读版报告格式 + +```xml + + + 50 + 3 + 12 + 85.2% + + + + + 知情同意书签署日期(2026-01-15)晚于首次访视(2026-01-10) + + 2026-01-15 + 2026-01-10 + + + + 受试者确诊时间超过入组标准(确诊日期距入组已超过3个月) + + 2025-10-01 + 2026-02-01 + + + + + + + BMI 值偏高(42.5),请核实 + + + + +
+ + + +``` + +### Phase 3: 定时调度 (Week 3) + +| 任务 | 优先级 | 预估 | 产出 | +|------|--------|------|------| +| 3.1 集成 node-cron | P1 | 0.5天 | 定时任务框架 | +| 3.2 实现 Cron 触发逻辑 | P1 | 1天 | 定时全量质控 | +| 3.3 实现增量检查 | P2 | 1天 | 数据 Hash 跳过 | +| 3.4 实现 Webhook 触发 | P2 | 1天 | REDCap DET 集成 | + +### Phase 4: 规则配置 UI (Week 4) + +| 任务 | 优先级 | 预估 | 产出 | +|------|--------|------|------| +| 4.1 Rule Studio 页面框架 | P2 | 1天 | 规则列表 + 详情 | +| 4.2 规则编辑器 | P2 | 2天 | JSON 可视化编辑 | +| 4.3 规则测试功能 | P2 | 1天 | 单条规则验证 | + +--- + +## 6. 数据库设计补充 + +### 6.1 扩展 IitSkill 表 + +```prisma +model IitSkill { + id String @id @default(uuid()) + projectId String @map("project_id") + skillType String @map("skill_type") + name String + description String? + + // 规则类型 + ruleType String @default("HARD_RULE") @map("rule_type") // 'HARD_RULE' | 'LLM_CHECK' | 'HYBRID' + + // 执行级别 + level String @default("normal") // 'blocking' | 'normal' + + // 核心配置 + config Json @db.JsonB + + // 数据依赖(智能路由用) + requiredTags String[] @map("required_tags") // 新增:e.g., ['#lab', '#demographics'] + + // 触发配置 + triggerType String @default("webhook") @map("trigger_type") + cronSchedule String? @map("cron_schedule") + + isActive Boolean @default(true) @map("is_active") + priority Int @default(100) // 新增:执行优先级 + + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@unique([projectId, skillType], map: "unique_iit_skill_project_type") + @@index([projectId], map: "idx_iit_skill_project") + @@index([isActive, triggerType], map: "idx_iit_skill_active_trigger") + @@map("skills") + @@schema("iit_schema") +} +``` + +### 6.2 新增 IitQcReport 表 + +```prisma +/// 质控报告存储表 - 存储预生成的 LLM 友好报告 +model IitQcReport { + id String @id @default(uuid()) + projectId String @map("project_id") + + // 报告类型 + reportType String @default("daily") @map("report_type") // 'daily' | 'weekly' | 'on_demand' + + // 统计摘要 + summary Json @db.JsonB // { totalRecords, criticalIssues, pendingQueries, passRate } + + // 详细问题列表 + issues Json @db.JsonB // [{ record, rule, severity, description, evidence }] + + // LLM 友好的 XML 报告 + llmReport String @map("llm_report") @db.Text + + // 报告生成时间 + generatedAt DateTime @default(now()) @map("generated_at") + + // 报告有效期 + expiresAt DateTime? @map("expires_at") + + @@index([projectId, reportType], map: "idx_qc_report_project_type") + @@index([generatedAt], map: "idx_qc_report_generated") + @@map("qc_reports") + @@schema("iit_schema") +} +``` + +--- + +## 7. 验收标准 + +### 7.1 功能验收 + +| 功能 | 验收标准 | +|------|----------| +| 变量质控 | 单记录质控 < 100ms;批量 50 记录 < 2s | +| 入排标准 | LLM 判断准确率 > 90%;单记录 < 3s | +| 报告生成 | 50 记录的项目报告生成 < 5s | +| LLM 问答 | 基于报告回答延迟 < 2s | +| 定时任务 | 每日凌晨自动执行,连续 7 天无故障 | + +### 7.2 性能指标 + +| 指标 | 目标值 | +|------|--------| +| HardRuleEngine 执行时间 | < 100ms / 记录 | +| SoftRuleEngine 执行时间 | < 3s / 记录 | +| 报告加载时间 | < 200ms | +| 端到端问答延迟 | < 3s | + +### 7.3 业务验收 + +- [ ] CRA 可以通过 Cockpit 查看所有严重问题 +- [ ] 点击问题可以查看详细数据和证据 +- [ ] 用户可以通过对话询问质控问题,LLM 基于报告回答 +- [ ] 定时任务每日自动执行,生成最新报告 +- [ ] 规则可以通过配置界面增删改 + +--- + +## 8. 风险与应对 + +| 风险 | 影响 | 应对措施 | +|------|------|----------| +| LLM 判断不准确 | 入排/AE 检测误报 | 三态管理(Pending → 人工确认) | +| 规则配置复杂 | 用户上手困难 | 提供预置规则模板 | +| 数据量大时性能下降 | 质控变慢 | 增量检查 + 分批执行 | +| REDCap Webhook 不稳定 | 实时质控失效 | 定时任务兜底 | + +--- + +## 9. 参考文档 + +- [CRA Agent 业务逻辑与执行架构书](../02-技术设计/CRA%20Agent%20业务逻辑与执行架构书%20(1).md) +- [基于 Skills 的 CRA 规则配置实施指南](../02-技术设计/docs_03-业务模块_IIT%20Manager%20Agent_02-技术设计_11-基于Skills的CRA规则配置实施指南.md) +- [V2.6 综合开发计划](./IIT%20Manager%20Agent%20V2.6%20综合开发计划.md) +- [02-核心引擎实现指南](./02-核心引擎实现指南.md) + +--- + +**文档维护人**:AI Agent +**最后更新**:2026-02-08 + +### 更新日志 + +| 版本 | 日期 | 更新内容 | +|------|------|----------| +| V1.0 | 2026-02-08 | 初始版本,基于方案审查和代码分析 | diff --git a/docs/03-业务模块/IIT Manager Agent/05-测试文档/LLM 交互协议与 Prompt 最佳实践指南.md b/docs/03-业务模块/IIT Manager Agent/05-测试文档/LLM 交互协议与 Prompt 最佳实践指南.md new file mode 100644 index 00000000..77fd0b1b --- /dev/null +++ b/docs/03-业务模块/IIT Manager Agent/05-测试文档/LLM 交互协议与 Prompt 最佳实践指南.md @@ -0,0 +1,122 @@ +# **LLM 交互协议与 Prompt 最佳实践指南** + +**文档目的:** 定义“质控报告”的标准上下文格式,并提供配套的 System Prompt,以消除幻觉、确保回复准确性。 + +**适用版本:** IIT Manager Agent V2.9.1 + +**创建日期:** 2026-02-08 + +## **🧠 一、 核心原理:为什么“混合格式”能防幻觉?** + +我们将数据格式定义为 **Context Protocol v2.0**,采用 **XML 骨架 \+ Markdown 血肉** 的形式。 + +### **1.1 格式对比** + +| 格式 | 示例 | LLM 视角的优缺点 | +| :---- | :---- | :---- | +| **纯 JSON** | {"age": 45, "error": true} | ❌ **缺点**:Token 消耗大,括号层级深时 LLM 容易“晕”,注意力机制容易分散。 | +| **纯 Markdown** | \*\*Age\*\*: 45 (Error) | ❌ **缺点**:边界不清晰,多条记录容易混在一起,导致“张冠李戴”。 | +| **混合模式 (推荐)** | \- Age: \*\*45\*\*\ | ✅ **优点**:XML \ 明确告诉 LLM “这是独立的一条记录”,Markdown \*\* 告诉 LLM “这是重点”。 | + +### **1.2 防幻觉的三道防线** + +1. **防线一:证据注入 (Evidence Injection)** + * **以前**:只给 Age Error。LLM 被问“多少岁”时,只能瞎编。 + * **现在**:给 Age Error (Current: 45, Range: 25-35)。LLM 看到了 45,直接引用,无需生成。 +2. **防线二:预计算 (Pre-computation)** + * **以前**:给 DOB: 1980,让 LLM 算年龄。LLM 数学不好,可能算错。 + * **现在**:Node.js 算好 Age: 46,直接喂给 LLM。LLM 只负责读,不负责算。 +3. **防线三:结构化边界 (XML Boundaries)** + * **以前**:平铺文本。LLM 可能把 A 病人的合并用药安到 B 病人头上。 + * **现在**:\...\。强制物理隔离,杜绝串行。 + +## **📝 二、 Context Protocol v2.0 标准格式** + +这是后端 ReportGenerator 需要生成的最终字符串格式。 + +\ + + \<\!-- 1\. 规则定义 (让 LLM 知道判罚标准) \--\> + \ + \入排标准 I-01: 年龄应在 25-35 岁之间\ + \伦理合规 E-01: 必须签署知情同意书\ + \ + + \<\!-- 2\. 严重问题清单 (按受试者分组) \--\> + \ + + \ + \存在 2 个严重违规\ + \ + 1\. \[R\_AGE\] \*\*年龄超标\*\* + \- 现状: 当前年龄 \*\*45岁\*\* + \- 标准: 25-35岁 + \- 证据: \`birth\_date\` \= 1981-05-12 + 2\. \[R\_ICF\] \*\*知情同意缺失\*\* + \- 现状: 字段为空 + \- 证据: \`icf\_date\` \= null + \ + \ + + \ + \存在 1 个严重违规\ + \ + 1\. \[R\_AGE\] \*\*年龄超标\*\* + \- 现状: 当前年龄 \*\*52岁\*\* + \- 证据: \`birth\_date\` \= 1974-02-01 + \ + \ + + \ + +\ + +## **🗣️ 三、 配套 Prompt 设计 (System Prompt)** + +仅有好的数据格式是不够的,必须用 Prompt 教会 LLM 如何阅读这个格式。 + +### **3.1 CRA 监查员 System Prompt** + +\# Role +你是一名资深的临床监查员 (CRA)。你的任务是根据提供的【质控报告上下文】回答用户关于项目质量、违规情况的问题。 + +\# Input Format +你收到的上下文将包含在 \`\\` XML 标签中。 +\- \`\\`: 定义了项目的质控规则。 +\- \`\\`: 列出了具体的违规记录,按受试者 (\`\\`) 分组。 + +\# Constraints (绝对准则) +1\. \*\*基于证据\*\*:回答必须严格基于 \`\\` 中的数据。如果上下文中没有提到某条记录或某个数值,\*\*必须直接说“报告中未包含相关信息”\*\*,严禁编造数值。 +2\. \*\*引用原文\*\*:在解释违规原因时,必须引用上下文中的 "证据" (Evidence) 字段(例如:“因为患者当前年龄为 45 岁...”)。 +3\. \*\*结构化输出\*\*:回答多个受试者问题时,请使用 Markdown 列表。 +4\. \*\*语气专业\*\*:保持客观、冷静的医疗专业语气。 + +\# Example +User: "1号病人有什么问题?" +Assistant: "1号受试者存在 \*\*2个严重违规\*\*: +1\. \*\*年龄超标\*\*:患者当前 \*\*45岁\*\*,不符合“25-35岁”的入排标准 (R\_AGE)。 +2\. \*\*伦理缺失\*\*:未检测到知情同意书签署日期 (\`icf\_date\` 为空)。 +建议立即核查原始病历或剔除该病例。" + +## **🔬 四、 验证与测试 (Evaluation)** + +### **4.1 幻觉压力测试** + +**测试用例 A:询问不存在的数值** + +* **Prompt**: "1号病人的血压是多少?" +* **Context**: (上下文中只有年龄和ICF问题,没有血压数据) +* **预期回答**: "报告中未包含1号受试者的血压数据。当前仅记录了年龄和知情同意书相关的违规信息。" +* **失败回答 (幻觉)**: "1号病人的血压是 120/80 mmHg。" (如果 LLM 只有 Message 没有 Evidence,容易顺口胡编一个正常值) + +**测试用例 B:询问违规原因** + +* **Prompt**: "为什么10号病人年龄违规?" +* **Context**: \...当前年龄 \*\*52岁\*\*...\ +* **预期回答**: "因为10号受试者当前年龄为 **52岁**,超出了研究方案规定的 25-35 岁范围。" + +### **4.2 结论** + +通过 **"XML 结构化 \+ 证据注入 \+ 严格约束 Prompt"** 三位一体的方案,我们可以将幻觉率控制在 **极低水平 (\< 1%)**。 + +AI 在这里不再是“创造者”,而是精准的“阅读理解者”。 \ No newline at end of file diff --git a/docs/03-业务模块/IIT Manager Agent/05-测试文档/LLM 友好型质控报告评估与优化方案 V2.md b/docs/03-业务模块/IIT Manager Agent/05-测试文档/LLM 友好型质控报告评估与优化方案 V2.md new file mode 100644 index 00000000..3144d0dc --- /dev/null +++ b/docs/03-业务模块/IIT Manager Agent/05-测试文档/LLM 友好型质控报告评估与优化方案 V2.md @@ -0,0 +1,107 @@ +# **LLM 友好型质控报告评估与优化方案** + +**文档版本:** v2.1 (基于团队反馈优化) + +**评估对象:** qc-report-test0102-pd-study-2026-02-08.xml + +**评估日期:** 2026-02-08 + +## **🚀 一、 核心共识:Context Protocol v2.1** + +经过团队讨论,确定采用 **“XML 容器 \+ Markdown 内容”** 的混合模式,并支持 **双格式输出** 以满足不同场景。 + +### **1.1 双格式策略 (Dual Format Strategy)** + +后端 QcService 应支持 format 参数: + +* **format=json (Default)**: 面向 **前端 UI**。结构化强,用于渲染热力图、表格。 +* **format=llm-friendly**: 面向 **LLM Context**。Token 密度高,语义清晰,包含“证据”。 + +### **1.2 理想的 LLM Context 结构 (V2.1)** + +**设计原则**: + +1. **去重**:不再单独列出 rule\_definitions,要求每条 issue **自包含**(同时包含现状与标准)。 +2. **聚合**:按 Record ID 分组。 +3. **压缩**:使用 Markdown 列表替代冗余的 XML 标签。 + +\ + + \<\!-- 1\. 宏观统计 (Aggregate) \--\> + \ + \- 状态: 13/13 记录存在严重违规 (100% Fail) + \- Top 3 问题: + 1\. 年龄超标 (13人) + 2\. 未签知情同意书 (13人) + 3\. 疼痛评分不达标 (13人) + \ + + \<\!-- 2\. 问题详情 (Markdown List in XML) \--\> + \ + + \ + \*\*严重违规 (5项)\*\*: + 1\. \[R\_AGE\] \*\*年龄超标\*\*: 当前 \*\*45岁\*\* (标准: 25-35岁)。 + 2\. \[R\_ICF\] \*\*伦理缺失\*\*: \`informed\_consent\` 为空 (必须签署)。 + 3\. \[R\_VAS\] \*\*入排不符\*\*: VAS评分 \*\*2分\*\* (要求 \>= 4分)。 + \ + + \ + \*\*严重违规 (5项)\*\*: + 1\. \[R\_AGE\] \*\*年龄超标\*\*: 当前 \*\*52岁\*\* (标准: 25-35岁)。 + \<\!-- 其他同上 \--\> + \ + + \ + +\ + +## **🛠️ 二、 实施建议 (Action Items)** + +### **2.1 后端改造:让报错信息“自包含”** + +这是消除 rule\_definitions 依赖的关键。我们需要在生成 Issue 时,就把标准塞进去。 + +* **Before (HardRuleEngine)**: + return { message: "年龄不在范围内" } + +* **After (HardRuleEngine)**: + // 动态拼接模板 + return { + message: \`当前 \*\*${actualValue}\*\* (标准: ${rule.min}-${rule.max})\`, + structured\_evidence: { value: actualValue, range: \[25, 35\] } + } + +### **2.2 System Prompt 增强 (V2.1)** + +参考团队建议,强化“证据引用”指令。 + +\# Role +你是一名资深的临床监查员 (CRA)。 + +\# Input Format +你收到的 \`\\` 包含了项目的质控报告。 +\- \`\\` 区域按受试者列出了具体违规项。 +\- 每一行违规项都包含了 \*\*\[规则ID\]\*\*、\*\*错误类型\*\*、\*\*现状值\*\* 和 \*\*标准值\*\*。 + +\# Critical Instructions (思维链约束) +在回答用户问题时,你必须遵循以下步骤: +1\. \*\*定位 (Locate)\*\*: 找到用户询问的 \`\\`。 +2\. \*\*提取 (Extract)\*\*: 读取具体的报错行。 +3\. \*\*引用 (Cite)\*\*: 在回答中明确引用现状值和标准值。 + \- ✅ 正确: "1号患者年龄违规,因为他当前 \*\*45岁\*\*,而方案要求 \*\*25-35岁\*\*。" + \- ❌ 错误: "1号患者年龄不对。" (太模糊) + \- ❌ 错误: "1号患者年龄违规,请检查。" (无证据) +4\. \*\*诚实 (Grounding)\*\*: 如果报告里没写的数值(比如血压),绝对不要编造,直接回答“报告未提及”。 + +\# Example +User: "10号病人有什么大问题?" +Assistant: "10号病人有 \*\*5项严重违规\*\*。最主要的是 \*\*年龄超标\*\*,他当前 \*\*52岁\*\*,远超出了 \*\*25-35岁\*\* 的入排标准。此外还存在伦理知情同意书缺失的问题。" + +## **📊 三、 收益预估** + +1. **Token 节省**:相比 V1.0 XML,V2.1 混合格式预计节省 **40%-60%** 的 Token(去掉了大量的 \, \, \ 标签)。 +2. **幻觉消除**:通过“自包含”设计,LLM 不需要跨段落去查找规则定义,所有信息都在一行内,上下文注意力极其集中。 +3. **开发解耦**:前端用 JSON 渲染界面,LLM 用 Text 文本推理,互不干扰。 + +**结论:** 团队的建议非常棒。V2.1 方案已采纳 **混合格式** \+ **自包含报错** \+ **引用增强 Prompt**,这是目前最优的工程解法。 \ No newline at end of file diff --git a/docs/03-业务模块/IIT Manager Agent/05-测试文档/LLM 友好型质控报告评估与优化方案.md b/docs/03-业务模块/IIT Manager Agent/05-测试文档/LLM 友好型质控报告评估与优化方案.md new file mode 100644 index 00000000..6c5def89 --- /dev/null +++ b/docs/03-业务模块/IIT Manager Agent/05-测试文档/LLM 友好型质控报告评估与优化方案.md @@ -0,0 +1,120 @@ +# **LLM 友好型质控报告评估与优化方案** + +**评估对象:** qc-report-test0102-pd-study-2026-02-08.xml + +**评估目的:** 确定该格式是否适合作为 Context 喂给 LLM 以回答用户提问。 + +**评估日期:** 2026-02-08 + +## **📊 一、 现状评估 (Current State Analysis)** + +### **1\. 评分卡** + +| 维度 | 评分 (1-10) | 评价 | +| :---- | :---- | :---- | +| **结构化程度** | 9/10 | XML 结构清晰,解析容易。 | +| **Token 效率** | 3/10 | **极差**。重复信息太多(如 rule\_name 重复 64 次)。 | +| **信息完整性** | 4/10 | **缺失核心证据**。只说了“错”,没说“值为多少”。 | +| **语义清晰度** | 5/10 | rule="unknown" 对 AI 毫无帮助,缺乏语义标签。 | + +### **2\. 核心缺陷分析** + +#### **❌ 缺陷 1:缺乏“原始值” (The "Evidence Gap")** + +* **现状**: + \年龄不在 25-35 岁范围内\ + \age\ + +* **LLM 的困惑**:"我知道年龄不对,但这个病人到底几岁?是 24 岁(轻微偏差)还是 80 岁(严重偏差)?用户如果问我‘为什么报错’,我只能复读 Message,无法解释原因。" + +#### **❌ 缺陷 2:Token 冗余 (The "Echo Chamber")** + +* **现状**: + 13 条记录都有“年龄”问题,rule\_name 和 message 重复了 13 次。 +* **LLM 的困惑**:"为什么要让我读 13 遍同样的一句话?这浪费了宝贵的上下文窗口。" + +#### **❌ 缺陷 3:逻辑扁平 (Lack of Hierarchy)** + +* **现状**: + 所有问题平铺在 \ 下,没有按 Record ID 聚合。 +* **LLM 的困惑**:"如果用户问‘001号病人有哪些问题?’,我得遍历整个列表去挑出 record=1 的项,容易看漏。" + +## **🚀 二、 优化方案:Context Protocol v2.0** + +根据我们的 **CRA Agent 深度设计**,我们需要将报告重构为 **“以实体为中心,以证据为支撑”** 的结构。 + +### **2.1 理想的 Context 结构 (Gold Standard)** + +建议采用 **混合模式**:XML 定义边界,Markdown 描述详情。 + +\ + + \<\!-- 1\. 宏观统计 (Aggregate) \--\> + \ + \- 总记录数: 13 | ❌ 严重违规: 13人 (100%) + \- 主要问题: 年龄超标 (13), 未签知情同意书 (13), 疼痛评分不达标 (13) + \ + + \<\!-- 2\. 规则定义 (Reference) \- 只定义一次,省 Token \--\> + \ + \年龄应在 25-35 岁之间\ + \必须签署知情同意书\ + \ + + \<\!-- 3\. 问题详情 (Grouped by Record) \--\> + \ + + \ + \存在 5 个严重违规\ + \ + \- \[R\_AGE\] ❌ \*\*年龄违规\*\*: 当前值 \`45\` (不在 25-35 范围内)。 + \- \[R\_ICF\] ❌ \*\*伦理违规\*\*: \`informed\_consent\` 为空。 + \- \[R\_VAS\] ❌ \*\*入排不符\*\*: VAS评分 \`2\` (要求 \>= 4)。 + \ + \ + + \ + \存在 5 个严重违规\ + \ + \- \[R\_AGE\] ❌ \*\*年龄违规\*\*: 当前值 \`52\`。 + \<\!-- 省略重复描述,仅列出差异或关键点 \--\> + \ + \ + + \ + +\ + +## **🛠️ 三、 实施建议 (Action Items)** + +### **3.1 后端代码调整 (QcService / ReportGenerator)** + +你需要修改生成 XML 的逻辑,不仅要查 iit\_qc\_logs,还要关联 **原始数据**。 + +1. **补充 value 字段**: + 在存入 iit\_qc\_logs 时,或者在生成报告时,必须把触发报错的 **具体数值** 写进去。 + * *Bad:* { "message": "Age error" } + * *Good:* { "message": "Age error", "evidence": { "value": 45, "threshold": "25-35" } } +2. **补充 rule\_id 和 tag**: + 目前的 rule="unknown" 是不可接受的。 + * 在 HardRuleEngine 里,每条规则必须有一个唯一的 code (例如 AGE\_CHECK)。 + * 在 iit\_skills 配置中,关联 Tags。 +3. **按受试者聚合 (Group By Record)**: + 不要输出扁平的 List,要输出 Map:Map\。 + +### **3.2 优化后的 LLM 交互流程** + +当用户问:“**为什么 1 号病人不合格?**” + +* **旧 XML**:LLM 看到 "年龄不在范围内",只能回答“因为年龄不对”。 +* **新 XML**:LLM 看到 当前值 45 (要求 25-35),可以回答: + “1号病人不合格主要有 3 个原因: + 1. **年龄不符**:患者 **45岁**,超出了研究要求的 25-35 岁范围。 + 2. **疼痛评分不足**:VAS 评分为 **2分**,未达到入组要求的 4分。 + 3. **伦理缺失**:系统中未查询到知情同意书签署记录。” + +### **3.3 总结** + +**现有报告是给程序员调试用的,不是给 LLM 用的。** + +请按照 **Context Protocol** 规范,增加 **Evidence (原始值)**,增加 **Grouping (聚合)**,并消除冗余文本。 \ No newline at end of file diff --git a/docs/03-业务模块/IIT Manager Agent/05-测试文档/qc-report-test0102-pd-study-2026-02-08.xml b/docs/03-业务模块/IIT Manager Agent/05-测试文档/qc-report-test0102-pd-study-2026-02-08.xml new file mode 100644 index 00000000..efa2868f --- /dev/null +++ b/docs/03-业务模块/IIT Manager Agent/05-测试文档/qc-report-test0102-pd-study-2026-02-08.xml @@ -0,0 +1,320 @@ + + + + + 13 + 0 + 64 + 0 + 0 + 0% + 2026-02-08T05:30:56.082Z + + + + + 年龄不在 25-35 岁范围内 + 年龄不在 25-35 岁范围内 + age + 2026-02-08T05:30:55.980Z + + + 出生日期不在 1989-01-01 至 2008-01-01 范围内 + 出生日期不在 1989-01-01 至 2008-01-01 范围内 + birth_date + 2026-02-08T05:30:55.980Z + + + 月经周期不在 21-35 天范围内(28±7天) + 月经周期不在 21-35 天范围内(28±7天) + menstrual_cycle + 2026-02-08T05:30:55.980Z + + + VAS 疼痛评分 < 4 分,不符合入组条件 + VAS 疼痛评分 < 4 分,不符合入组条件 + vas_score + 2026-02-08T05:30:55.980Z + + + 未签署知情同意书 + 未签署知情同意书 + informed_consent + 2026-02-08T05:30:55.980Z + + + 年龄不在 25-35 岁范围内 + 年龄不在 25-35 岁范围内 + age + 2026-02-08T05:30:56.060Z + + + 出生日期不在 1989-01-01 至 2008-01-01 范围内 + 出生日期不在 1989-01-01 至 2008-01-01 范围内 + birth_date + 2026-02-08T05:30:56.060Z + + + 月经周期不在 21-35 天范围内(28±7天) + 月经周期不在 21-35 天范围内(28±7天) + menstrual_cycle + 2026-02-08T05:30:56.060Z + + + VAS 疼痛评分 < 4 分,不符合入组条件 + VAS 疼痛评分 < 4 分,不符合入组条件 + vas_score + 2026-02-08T05:30:56.060Z + + + 未签署知情同意书 + 未签署知情同意书 + informed_consent + 2026-02-08T05:30:56.060Z + + + 年龄不在 25-35 岁范围内 + 年龄不在 25-35 岁范围内 + age + 2026-02-08T05:30:56.068Z + + + 出生日期不在 1989-01-01 至 2008-01-01 范围内 + 出生日期不在 1989-01-01 至 2008-01-01 范围内 + birth_date + 2026-02-08T05:30:56.068Z + + + 月经周期不在 21-35 天范围内(28±7天) + 月经周期不在 21-35 天范围内(28±7天) + menstrual_cycle + 2026-02-08T05:30:56.068Z + + + VAS 疼痛评分 < 4 分,不符合入组条件 + VAS 疼痛评分 < 4 分,不符合入组条件 + vas_score + 2026-02-08T05:30:56.068Z + + + 未签署知情同意书 + 未签署知情同意书 + informed_consent + 2026-02-08T05:30:56.068Z + + + 出生日期不在 1989-01-01 至 2008-01-01 范围内 + 出生日期不在 1989-01-01 至 2008-01-01 范围内 + birth_date + 2026-02-08T05:30:56.075Z + + + 月经周期不在 21-35 天范围内(28±7天) + 月经周期不在 21-35 天范围内(28±7天) + menstrual_cycle + 2026-02-08T05:30:56.075Z + + + VAS 疼痛评分 < 4 分,不符合入组条件 + VAS 疼痛评分 < 4 分,不符合入组条件 + vas_score + 2026-02-08T05:30:56.075Z + + + 未签署知情同意书 + 未签署知情同意书 + informed_consent + 2026-02-08T05:30:56.075Z + + + 年龄不在 25-35 岁范围内 + 年龄不在 25-35 岁范围内 + age + 2026-02-08T05:30:56.082Z + + + 出生日期不在 1989-01-01 至 2008-01-01 范围内 + 出生日期不在 1989-01-01 至 2008-01-01 范围内 + birth_date + 2026-02-08T05:30:56.082Z + + + 月经周期不在 21-35 天范围内(28±7天) + 月经周期不在 21-35 天范围内(28±7天) + menstrual_cycle + 2026-02-08T05:30:56.082Z + + + VAS 疼痛评分 < 4 分,不符合入组条件 + VAS 疼痛评分 < 4 分,不符合入组条件 + vas_score + 2026-02-08T05:30:56.082Z + + + 未签署知情同意书 + 未签署知情同意书 + informed_consent + 2026-02-08T05:30:56.082Z + + + 年龄不在 25-35 岁范围内 + 年龄不在 25-35 岁范围内 + age + 2026-02-08T05:30:55.990Z + + + 出生日期不在 1989-01-01 至 2008-01-01 范围内 + 出生日期不在 1989-01-01 至 2008-01-01 范围内 + birth_date + 2026-02-08T05:30:55.990Z + + + 月经周期不在 21-35 天范围内(28±7天) + 月经周期不在 21-35 天范围内(28±7天) + menstrual_cycle + 2026-02-08T05:30:55.990Z + + + VAS 疼痛评分 < 4 分,不符合入组条件 + VAS 疼痛评分 < 4 分,不符合入组条件 + vas_score + 2026-02-08T05:30:55.990Z + + + 未签署知情同意书 + 未签署知情同意书 + informed_consent + 2026-02-08T05:30:55.990Z + + + 年龄不在 25-35 岁范围内 + 年龄不在 25-35 岁范围内 + age + 2026-02-08T05:30:55.998Z + + + 出生日期不在 1989-01-01 至 2008-01-01 范围内 + 出生日期不在 1989-01-01 至 2008-01-01 范围内 + birth_date + 2026-02-08T05:30:55.998Z + + + 月经周期不在 21-35 天范围内(28±7天) + 月经周期不在 21-35 天范围内(28±7天) + menstrual_cycle + 2026-02-08T05:30:55.998Z + + + VAS 疼痛评分 < 4 分,不符合入组条件 + VAS 疼痛评分 < 4 分,不符合入组条件 + vas_score + 2026-02-08T05:30:55.998Z + + + 未签署知情同意书 + 未签署知情同意书 + informed_consent + 2026-02-08T05:30:55.998Z + + + 年龄不在 25-35 岁范围内 + 年龄不在 25-35 岁范围内 + age + 2026-02-08T05:30:56.008Z + + + 出生日期不在 1989-01-01 至 2008-01-01 范围内 + 出生日期不在 1989-01-01 至 2008-01-01 范围内 + birth_date + 2026-02-08T05:30:56.008Z + + + 月经周期不在 21-35 天范围内(28±7天) + 月经周期不在 21-35 天范围内(28±7天) + menstrual_cycle + 2026-02-08T05:30:56.008Z + + + VAS 疼痛评分 < 4 分,不符合入组条件 + VAS 疼痛评分 < 4 分,不符合入组条件 + vas_score + 2026-02-08T05:30:56.008Z + + + 未签署知情同意书 + 未签署知情同意书 + informed_consent + 2026-02-08T05:30:56.008Z + + + 年龄不在 25-35 岁范围内 + 年龄不在 25-35 岁范围内 + age + 2026-02-08T05:30:56.019Z + + + 出生日期不在 1989-01-01 至 2008-01-01 范围内 + 出生日期不在 1989-01-01 至 2008-01-01 范围内 + birth_date + 2026-02-08T05:30:56.019Z + + + 月经周期不在 21-35 天范围内(28±7天) + 月经周期不在 21-35 天范围内(28±7天) + menstrual_cycle + 2026-02-08T05:30:56.019Z + + + VAS 疼痛评分 < 4 分,不符合入组条件 + VAS 疼痛评分 < 4 分,不符合入组条件 + vas_score + 2026-02-08T05:30:56.019Z + + + 未签署知情同意书 + 未签署知情同意书 + informed_consent + 2026-02-08T05:30:56.019Z + + + 年龄不在 25-35 岁范围内 + 年龄不在 25-35 岁范围内 + age + 2026-02-08T05:30:56.028Z + + + 出生日期不在 1989-01-01 至 2008-01-01 范围内 + 出生日期不在 1989-01-01 至 2008-01-01 范围内 + birth_date + 2026-02-08T05:30:56.028Z + + + 月经周期不在 21-35 天范围内(28±7天) + 月经周期不在 21-35 天范围内(28±7天) + menstrual_cycle + 2026-02-08T05:30:56.028Z + + + VAS 疼痛评分 < 4 分,不符合入组条件 + VAS 疼痛评分 < 4 分,不符合入组条件 + vas_score + 2026-02-08T05:30:56.028Z + + + 未签署知情同意书 + 未签署知情同意书 + informed_consent + 2026-02-08T05:30:56.028Z + + + 年龄不在 25-35 岁范围内 + 年龄不在 25-35 岁范围内 + age + 2026-02-08T05:30:56.038Z + + + + + + + \ No newline at end of file diff --git a/docs/03-业务模块/IIT Manager Agent/06-开发记录/2026-02-08-事件级质控与报告优化开发记录.md b/docs/03-业务模块/IIT Manager Agent/06-开发记录/2026-02-08-事件级质控与报告优化开发记录.md new file mode 100644 index 00000000..fb2c05a2 --- /dev/null +++ b/docs/03-业务模块/IIT Manager Agent/06-开发记录/2026-02-08-事件级质控与报告优化开发记录.md @@ -0,0 +1,217 @@ +# 2026-02-08 事件级质控与报告优化开发记录 + +> **开发日期:** 2026-02-08 +> **开发人员:** AI Assistant +> **版本:** V3.1 +> **状态:** ✅ 基本测试成功 + +--- + +## 📋 开发概述 + +本次开发主要完成了 IIT Manager Agent 质控系统的重大架构升级,从"记录级合并质控"改为"事件级独立质控",同时优化了质控报告生成逻辑和 AI 对话能力。 + +--- + +## 🎯 核心改动 + +### 1. 事件级质控架构(V3.1) + +**问题背景:** +- REDCap 纵向研究中,一个 record_id 可能有多个事件(如筛选期、随访1、随访2等) +- 之前的质控将所有事件的数据合并到一条记录,导致数据覆盖问题 +- 不同事件的表单应该独立质控 + +**解决方案:** + +| 改动文件 | 改动内容 | +|---------|---------| +| `RedcapAdapter.ts` | 新增 `getAllRecordsByEvent()` 方法,按 record+event 返回独立数据单元 | +| `RedcapAdapter.ts` | 新增 `getFormEventMapping()` 和 `getEvents()` 获取事件配置 | +| `RedcapAdapter.ts` | 新增 `getFormCompletionStatusByEvent()` 按事件获取表单完成状态 | +| `HardRuleEngine.ts` | `QCRule` 接口新增 `applicableEvents` 和 `applicableForms` 字段 | +| `SoftRuleEngine.ts` | `SoftRuleCheck` 接口同样新增事件/表单适用性字段 | +| `SkillRunner.ts` | 重构 `runByTrigger()` 按 record+event 独立执行质控 | +| `SkillRunner.ts` | 新增 `filterApplicableRules()` 方法,按事件/表单动态过滤规则 | +| `SkillRunner.ts` | `SkillRunResult` 新增 `eventName`、`eventLabel`、`forms` 字段 | +| `SkillRunner.ts` | `saveQcLog()` 现在保存 `eventId` 到数据库 | + +**架构变化:** + +``` +之前(记录级合并): +Record 1 = Event A 数据 + Event B 数据 + ... (合并可能覆盖) + +之后(事件级独立): +Record 1 + Event A = 独立质控单元 1 +Record 1 + Event B = 独立质控单元 2 +... +``` + +### 2. 质控报告优化 + +**问题背景:** +- 报告从多个事件收集问题,导致同一规则重复出现 +- 报告使用缓存,不是最新数据 +- 显示限制导致部分问题被隐藏 + +**解决方案:** + +| 改动文件 | 改动内容 | +|---------|---------| +| `QcReportService.ts` | SQL 查询改用 `DISTINCT ON (record_id, event_id)` | +| `QcReportService.ts` | 新增 `deduplicateIssues()` 按 recordId+ruleId 去重 | +| `QcReportService.ts` | 统计计数也按 recordId+ruleId 去重(使用 Set) | +| `QcReportService.ts` | 移除所有 slice 显示限制,显示所有问题 | +| `QcReportService.ts` | 兼容新旧两种 issues 格式(数组 vs { items: [...] })| + +### 3. 批量操作 API 更新 + +| 改动文件 | 改动内容 | +|---------|---------| +| `iitBatchController.ts` | `batchQualityCheck` 改用 `SkillRunner.runByTrigger()` | +| `ToolsService.ts` | `batch_quality_check` 工具同样改用事件级质控 | + +### 4. 前端优化 + +| 改动文件 | 改动内容 | +|---------|---------| +| `QcReportDrawer.tsx` | 导出 XML 前自动刷新报告(确保获取最新数据)| +| `QcReportDrawer.tsx` | 文件名添加时分(HHMM格式)便于区分 | +| `IitQcCockpitPage.tsx` | 删除重复的"全量质控"按钮(保留配置页的)| + +### 5. AI 对话增强 + +| 改动文件 | 改动内容 | +|---------|---------| +| `ChatService.ts` | 增强意图识别,支持更多质控查询表达方式 | +| `PromptBuilder.ts` | 修复 `this` 上下文丢失导致的 500 错误 | + +--- + +## 🐛 修复的 Bug + +### Bug 1: `formatPatientData` undefined 错误 + +**现象:** 点击记录详情时返回 500 错误 + +**原因:** `buildClinicalSlice` 函数导出时丢失了 `this` 上下文 + +**修复:** 将 `this.formatPatientData` 改为 `PromptBuilder.formatPatientData` + +### Bug 2: 质控报告重复问题 + +**现象:** Record 1 显示 14 条违规,实际应该是 5 条 + +**原因:** 同一规则在多个事件中执行,结果都被收集到报告中 + +**修复:** 按 `recordId + ruleId` 去重,只保留最新结果 + +### Bug 3: 报告记录数不正确 + +**现象:** 显示 13 条记录,实际有 14 条 + +**原因:** 从 `iitRecordSummary` 获取记录数,该表可能缺少记录 + +**修复:** 从质控日志获取独立 `record_id` 数量 + +### Bug 4: AI 无法回答质控问题 + +**现象:** 问"严重违规有几项",AI 说"没有相关信息" + +**原因:** 意图识别规则不够全面 + +**修复:** 增加更多匹配模式(质控问题、严重违规、record问题等) + +--- + +## 📁 涉及文件清单 + +### 后端核心文件 + +``` +backend/src/modules/iit-manager/ +├── adapters/ +│ └── RedcapAdapter.ts ✅ 新增 3 个方法 +├── engines/ +│ ├── HardRuleEngine.ts ✅ QCRule 接口扩展 +│ ├── SoftRuleEngine.ts ✅ SoftRuleCheck 接口扩展 +│ └── SkillRunner.ts ✅ 事件级质控核心重构 +├── services/ +│ ├── QcReportService.ts ✅ 去重 + 移除限制 +│ ├── ChatService.ts ✅ 意图识别增强 +│ ├── PromptBuilder.ts ✅ 修复 this 上下文 +│ └── ToolsService.ts ✅ batch_quality_check 更新 + +backend/src/modules/admin/iit-projects/ +└── iitBatchController.ts ✅ 使用 SkillRunner +``` + +### 前端文件 + +``` +frontend-v2/src/modules/admin/ +├── components/qc-cockpit/ +│ └── QcReportDrawer.tsx ✅ 自动刷新 + 文件名优化 +└── pages/ + └── IitQcCockpitPage.tsx ✅ 删除重复按钮 +``` + +--- + +## 🧪 测试验证 + +### 测试结果 + +| 测试项 | 结果 | 备注 | +|--------|------|------| +| 事件级质控执行 | ✅ | 14 条记录 × 5 个事件 = 70 个质控单元 | +| 规则动态过滤 | ✅ | 配置 applicableForms 后规则只在相关事件执行 | +| 报告去重 | ✅ | 168 条问题去重后变为 69 条 | +| XML 导出 | ✅ | 自动刷新 + 显示所有问题 | +| AI 质控查询 | ✅ | 能正确回答"严重违规有几项" | +| 详情页 500 错误 | ✅ | formatPatientData 修复后正常 | + +--- + +## 📝 配置说明 + +### 规则适用性配置示例 + +```typescript +// 在 Skill 配置中设置规则的适用表单 +{ + "id": "inc_001", + "name": "年龄范围检查", + "applicableForms": ["basic_demography_form"], // 只在人口学表单执行 + "applicableEvents": [] // 空数组 = 适用所有事件 +} +``` + +### 执行配置更新脚本 + +```bash +cd backend +npx tsx update-skill-applicable-forms.ts +``` + +--- + +## 🔄 后续优化建议 + +1. **规则配置 UI**:在管理端添加规则 → 表单映射的可视化配置界面 +2. **事件标签显示**:在报告中显示事件的中文标签(如"筛选期")而非唯一标识 +3. **增量质控**:只对有变化的事件执行质控,避免全量重算 +4. **质控历史**:保留历史质控结果,支持趋势分析 + +--- + +## 📚 相关文档 + +- [模块状态文档](../00-模块当前状态与开发指南.md) +- [实时质控系统开发记录](./2026-02-07-实时质控系统开发记录.md) +- [质控系统 UI 与 LLM 格式优化计划](../04-开发计划/07-质控系统UI与LLM格式优化计划.md) + +--- + +> **总结:** 本次开发完成了 IIT 质控系统从"记录级"到"事件级"的架构升级,解决了数据合并覆盖、报告重复、AI 无法回答等多个问题,为 REDCap 纵向研究提供了更精确的质控能力。 diff --git a/frontend-v2/src/App.tsx b/frontend-v2/src/App.tsx index 61056225..be58c183 100644 --- a/frontend-v2/src/App.tsx +++ b/frontend-v2/src/App.tsx @@ -27,6 +27,7 @@ import SystemKbDetailPage from './modules/admin/pages/SystemKbDetailPage' // IIT 项目管理 import IitProjectListPage from './modules/admin/pages/IitProjectListPage' import IitProjectDetailPage from './modules/admin/pages/IitProjectDetailPage' +import IitQcCockpitPage from './modules/admin/pages/IitQcCockpitPage' // 运营日志 import ActivityLogsPage from './pages/admin/ActivityLogsPage' // 个人中心页面 @@ -123,6 +124,7 @@ function App() { {/* IIT 项目管理 */} } /> } /> + } /> {/* 运营日志 */} } /> {/* 系统配置 */} diff --git a/frontend-v2/src/modules/admin/api/iitProjectApi.ts b/frontend-v2/src/modules/admin/api/iitProjectApi.ts index 42d761e6..54a4c505 100644 --- a/frontend-v2/src/modules/admin/api/iitProjectApi.ts +++ b/frontend-v2/src/modules/admin/api/iitProjectApi.ts @@ -18,6 +18,10 @@ import type { RoleOption, KnowledgeBaseOption, } from '../types/iitProject'; +import type { + QcCockpitData, + RecordDetail, +} from '../types/qcCockpit'; const BASE_URL = '/api/v1/admin/iit-projects'; @@ -260,3 +264,93 @@ export async function batchSummary(projectId: string): Promise<{ const response = await apiClient.post(`${BASE_URL}/${projectId}/batch-summary`); return response.data; } + +// ==================== 质控驾驶舱 ==================== + +/** 获取质控驾驶舱数据 */ +export async function getQcCockpitData(projectId: string): Promise { + const response = await apiClient.get(`${BASE_URL}/${projectId}/qc-cockpit`); + return response.data.data; +} + +/** 获取记录质控详情 */ +export async function getQcRecordDetail( + projectId: string, + recordId: string, + formName: string +): Promise { + const response = await apiClient.get( + `${BASE_URL}/${projectId}/qc-cockpit/records/${recordId}`, + { params: { formName } } + ); + return response.data.data; +} + +/** 质控报告类型 */ +export interface QcReport { + projectId: string; + reportType: 'daily' | 'weekly' | 'on_demand'; + generatedAt: string; + expiresAt: string | null; + summary: { + totalRecords: number; + completedRecords: number; + criticalIssues: number; + warningIssues: number; + pendingQueries: number; + passRate: number; + lastQcTime: string | null; + }; + criticalIssues: Array<{ + recordId: string; + ruleId: string; + ruleName: string; + severity: 'critical' | 'warning' | 'info'; + message: string; + field?: string; + actualValue?: any; + expectedValue?: any; + detectedAt: string; + }>; + warningIssues: Array<{ + recordId: string; + ruleId: string; + ruleName: string; + severity: 'critical' | 'warning' | 'info'; + message: string; + field?: string; + detectedAt: string; + }>; + formStats: Array<{ + formName: string; + formLabel: string; + totalChecks: number; + passed: number; + failed: number; + passRate: number; + }>; + llmFriendlyXml: string; +} + +/** 获取质控报告 */ +export async function getQcReport( + projectId: string, + format: 'json' | 'xml' = 'json' +): Promise { + const response = await apiClient.get( + `${BASE_URL}/${projectId}/qc-cockpit/report`, + { + params: { format }, + responseType: format === 'xml' ? 'text' : 'json', + } + ); + return format === 'xml' ? response.data : response.data.data; +} + +/** 刷新质控报告 */ +export async function refreshQcReport(projectId: string): Promise { + const response = await apiClient.post( + `${BASE_URL}/${projectId}/qc-cockpit/report/refresh` + ); + return response.data.data; +} diff --git a/frontend-v2/src/modules/admin/components/qc-cockpit/QcDetailDrawer.tsx b/frontend-v2/src/modules/admin/components/qc-cockpit/QcDetailDrawer.tsx new file mode 100644 index 00000000..59438b1a --- /dev/null +++ b/frontend-v2/src/modules/admin/components/qc-cockpit/QcDetailDrawer.tsx @@ -0,0 +1,327 @@ +/** + * 质控详情抽屉组件 + * + * 展示受试者某个表单/访视的详细质控信息 + * - 左侧:真实数据 (Source of Truth) + * - 右侧:AI 诊断报告 + LLM Trace + */ + +import React, { useState, useEffect } from 'react'; +import { Drawer, Button, Spin, message, Empty, Tag, Space, Tooltip } from 'antd'; +import { + CloseOutlined, + ExclamationCircleOutlined, + WarningOutlined, + CheckCircleOutlined, + DatabaseOutlined, + RobotOutlined, + CodeOutlined, + ExportOutlined, + DeleteOutlined, + QuestionCircleOutlined, +} from '@ant-design/icons'; +import * as iitProjectApi from '../../api/iitProjectApi'; +import type { HeatmapCell, RecordDetail } from '../../types/qcCockpit'; + +interface QcDetailDrawerProps { + open: boolean; + onClose: () => void; + cell: HeatmapCell | null; + projectId: string; + projectName: string; +} + +const QcDetailDrawer: React.FC = ({ + open, + onClose, + cell, + projectId, + projectName, +}) => { + const [loading, setLoading] = useState(false); + const [detail, setDetail] = useState(null); + const [activeTab, setActiveTab] = useState<'report' | 'trace'>('report'); + + // 加载详情 + useEffect(() => { + if (open && cell) { + loadDetail(); + } + }, [open, cell]); + + const loadDetail = async () => { + if (!cell) return; + setLoading(true); + try { + const data = await iitProjectApi.getQcRecordDetail( + projectId, + cell.recordId, + cell.formName + ); + setDetail(data); + } catch (error: any) { + message.error(error.message || '加载详情失败'); + } finally { + setLoading(false); + } + }; + + const handleConfirmViolation = () => { + message.info('确认违规功能开发中...'); + }; + + const handleSendQuery = () => { + message.info('发送 Query 功能开发中...'); + }; + + const handleIgnore = () => { + message.info('忽略功能开发中...'); + }; + + const handleOpenRedcap = () => { + message.info('打开 REDCap 功能开发中...'); + }; + + if (!cell) return null; + + // 从 recordId 提取数字部分作为头像 + const avatarNum = cell.recordId.replace(/\D/g, '').slice(-2) || '00'; + + return ( + + {/* 抽屉头部 */} +
+
+
{avatarNum}
+
+
受试者 {cell.recordId}
+
+ 访视阶段: {cell.formName} | + 项目: {projectName} | + {detail?.entryTime && ` 录入时间: ${detail.entryTime}`} +
+
+
+ + + + +
+ + {/* 抽屉内容 */} + + {detail ? ( +
+ {/* 左栏:真实数据 */} +
+
+
+ + 真实数据 ({cell.formName}) + Read Only +
+
+
+
+ {Object.entries(detail.data).map(([key, value]) => { + // 查找该字段是否有问题 + const issue = detail.issues.find(i => i.field === key); + const fieldMeta = detail.fieldMetadata?.[key]; + const label = fieldMeta?.label || key; + + return ( +
+ +
+ {value ?? 未填写} + {issue && ( + + )} +
+ {issue && ( +
+ {issue.message} + {fieldMeta?.normalRange && ( + + 正常范围: {fieldMeta.normalRange.min ?? '-'} ~ {fieldMeta.normalRange.max ?? '-'} + + )} +
+ )} +
+ ); + })} +
+
+
+ + {/* 右栏:AI 诊断 + Trace */} +
+ {/* Tab 切换 */} +
+
setActiveTab('report')} + > + + 智能诊断报告 +
+
setActiveTab('trace')} + > + + LLM Trace (透视) +
+
+ + {/* Tab 内容 */} + {activeTab === 'report' ? ( +
+ {detail.issues.length > 0 ? ( + detail.issues.map((issue, idx) => ( +
+
+ + {issue.severity === 'critical' ? ( + <> 违反入排标准 (Critical) + ) : ( + <> 数据完整性警告 + )} + + + 置信度: {issue.confidence || 'High'} + +
+
+

+ AI 观点:{issue.message} +

+
+

原始证据链 (Evidence Chain):

+
    +
  • Variable: {issue.field} = {String(issue.actualValue)}
  • + {issue.expectedValue && ( +
  • Standard: {issue.expectedValue}
  • + )} +
  • Form: {cell.formName}
  • +
+
+
+
+ {issue.severity === 'critical' && ( + + )} + + +
+
+ )) + ) : ( +
+ +

+ 所有检查项均已通过 +

+
+ )} +
+ ) : ( + /* LLM Trace Tab */ +
+
+ Prompt Context (Sent to DeepSeek-V3) + XML Protocol +
+
+ {detail.llmTrace ? ( + <> +
+                          {detail.llmTrace.promptSent}
+                        
+
+
+ // LLM Output (Chain of Thought) +
+
+                            {detail.llmTrace.responseReceived}
+                          
+
+ Model: {detail.llmTrace.model} | + Latency: {detail.llmTrace.latencyMs}ms +
+
+ + ) : ( +
+ LLM Trace 数据不可用 +
+ 此功能需要在执行质控时启用 trace 模式 +
+ )} +
+
+ )} +
+
+ ) : ( + + )} +
+
+ ); +}; + +export default QcDetailDrawer; diff --git a/frontend-v2/src/modules/admin/components/qc-cockpit/QcReportDrawer.tsx b/frontend-v2/src/modules/admin/components/qc-cockpit/QcReportDrawer.tsx new file mode 100644 index 00000000..962025d4 --- /dev/null +++ b/frontend-v2/src/modules/admin/components/qc-cockpit/QcReportDrawer.tsx @@ -0,0 +1,443 @@ +/** + * 质控报告抽屉组件 + * + * 功能: + * - 展示质控报告摘要 + * - 展示严重问题和警告问题列表 + * - 展示表单统计 + * - 支持导出 XML 格式报告 + */ + +import React, { useState, useEffect } from 'react'; +import { + Drawer, + Tabs, + Statistic, + Row, + Col, + Card, + Table, + Tag, + Space, + Button, + Spin, + Empty, + Typography, + message, + Progress, + Tooltip, +} from 'antd'; +import { + DownloadOutlined, + ReloadOutlined, + ExclamationCircleOutlined, + WarningOutlined, + CheckCircleOutlined, + FileTextOutlined, + BarChartOutlined, +} from '@ant-design/icons'; +import type { QcReport } from '../../api/iitProjectApi'; +import * as iitProjectApi from '../../api/iitProjectApi'; + +const { Text } = Typography; +const { TabPane } = Tabs; + +interface QcReportDrawerProps { + open: boolean; + onClose: () => void; + projectId: string; + projectName: string; +} + +const QcReportDrawer: React.FC = ({ + open, + onClose, + projectId, + projectName, +}) => { + const [loading, setLoading] = useState(false); + const [refreshing, setRefreshing] = useState(false); + const [report, setReport] = useState(null); + + // 加载报告 + const loadReport = async (forceRefresh = false) => { + if (forceRefresh) { + setRefreshing(true); + } else { + setLoading(true); + } + + try { + let data: QcReport; + if (forceRefresh) { + data = await iitProjectApi.refreshQcReport(projectId); + message.success('报告已刷新'); + } else { + data = await iitProjectApi.getQcReport(projectId, 'json') as QcReport; + } + setReport(data); + } catch (error: any) { + message.error(error.message || '加载报告失败'); + } finally { + setLoading(false); + setRefreshing(false); + } + }; + + // 导出 XML 报告(导出前自动刷新,确保获取最新数据) + const handleExportXml = async () => { + try { + message.loading({ content: '正在生成最新报告...', key: 'export' }); + + // 1. 先刷新报告(确保获取最新质控结果) + await iitProjectApi.refreshQcReport(projectId); + + // 2. 获取刷新后的 XML 报告 + const xmlData = await iitProjectApi.getQcReport(projectId, 'xml') as string; + + // 3. 创建 Blob 并下载 + const blob = new Blob([xmlData], { type: 'application/xml' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + const now = new Date(); + const dateStr = now.toISOString().split('T')[0]; + const timeStr = now.toTimeString().slice(0, 5).replace(':', ''); // HHMM 格式 + link.download = `qc-report-${projectId}-${dateStr}-${timeStr}.xml`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + + message.success({ content: '报告已导出', key: 'export' }); + } catch (error: any) { + message.error({ content: error.message || '导出失败', key: 'export' }); + } + }; + + useEffect(() => { + if (open) { + loadReport(); + } + }, [open, projectId]); + + // 渲染摘要 + const renderSummary = () => { + if (!report) return null; + const { summary } = report; + + // V2.1: 防护空值 + if (!summary) { + return ( + + ); + } + + // 安全获取数值 + const totalRecords = summary.totalRecords ?? 0; + const passRate = summary.passRate ?? 0; + const criticalIssues = summary.criticalIssues ?? 0; + const warningIssues = summary.warningIssues ?? 0; + const completedRecords = summary.completedRecords ?? 0; + + return ( +
+ + + + + + + + + = 80 ? '#52c41a' : '#ff4d4f' }} + /> + + + + + } + /> + + + + + } + /> + + + + + + + + 完成记录 + 0 ? Math.round((completedRecords / totalRecords) * 100) : 0} + status="active" + /> + + + 待确认 + + + + + + + 报告生成时间: + {report.generatedAt ? new Date(report.generatedAt).toLocaleString() : '-'} +
+ 最后质控时间: + {summary.lastQcTime ? new Date(summary.lastQcTime).toLocaleString() : '-'} +
+
+ ); + }; + + // 渲染问题列表 + const renderIssues = (issues: QcReport['criticalIssues'] | QcReport['warningIssues'], type: 'critical' | 'warning') => { + const columns = [ + { + title: '记录 ID', + dataIndex: 'recordId', + key: 'recordId', + width: 100, + render: (text: string) => {text}, + }, + { + title: '规则', + dataIndex: 'ruleName', + key: 'ruleName', + width: 150, + ellipsis: true, + }, + { + title: '问题描述', + dataIndex: 'message', + key: 'message', + ellipsis: true, + render: (text: string) => ( + + {text} + + ), + }, + { + title: '字段', + dataIndex: 'field', + key: 'field', + width: 120, + render: (text: string) => text ? {text} : '-', + }, + { + title: '检测时间', + dataIndex: 'detectedAt', + key: 'detectedAt', + width: 140, + render: (text: string) => text ? new Date(text).toLocaleDateString() : '-', + }, + ]; + + return ( + `${record.recordId}-${record.ruleId}-${index}`} + size="small" + pagination={{ pageSize: 10 }} + locale={{ + emptyText: ( + + ), + }} + /> + ); + }; + + // 渲染表单统计 + const renderFormStats = () => { + if (!report?.formStats?.length) { + return ; + } + + const columns = [ + { + title: '表单', + dataIndex: 'formLabel', + key: 'formLabel', + }, + { + title: '检查数', + dataIndex: 'totalChecks', + key: 'totalChecks', + width: 80, + align: 'center' as const, + }, + { + title: '通过', + dataIndex: 'passed', + key: 'passed', + width: 80, + align: 'center' as const, + render: (text: number) => {text}, + }, + { + title: '失败', + dataIndex: 'failed', + key: 'failed', + width: 80, + align: 'center' as const, + render: (text: number) => {text}, + }, + { + title: '通过率', + dataIndex: 'passRate', + key: 'passRate', + width: 120, + render: (rate: number) => ( + = 80 ? 'success' : rate >= 60 ? 'normal' : 'exception'} + /> + ), + }, + ]; + + return ( +
+ ); + }; + + return ( + + + 质控报告 - {projectName} + + } + placement="right" + width={800} + open={open} + onClose={onClose} + extra={ + + + + + } + > + {loading ? ( +
+ +
+ 正在生成报告... +
+
+ ) : report ? ( + + + + 摘要 + + } + key="summary" + > + {renderSummary()} + + + + 严重问题 + {(report.criticalIssues?.length ?? 0) > 0 && ( + + {report.criticalIssues?.length ?? 0} + + )} + + } + key="critical" + > + {renderIssues(report.criticalIssues ?? [], 'critical')} + + + + 警告问题 + {(report.warningIssues?.length ?? 0) > 0 && ( + + {report.warningIssues?.length ?? 0} + + )} + + } + key="warning" + > + {renderIssues(report.warningIssues ?? [], 'warning')} + + + + 表单统计 + + } + key="forms" + > + {renderFormStats()} + + + ) : ( + + )} +
+ ); +}; + +export default QcReportDrawer; diff --git a/frontend-v2/src/modules/admin/components/qc-cockpit/QcStatCards.tsx b/frontend-v2/src/modules/admin/components/qc-cockpit/QcStatCards.tsx new file mode 100644 index 00000000..4d97989c --- /dev/null +++ b/frontend-v2/src/modules/admin/components/qc-cockpit/QcStatCards.tsx @@ -0,0 +1,177 @@ +/** + * 质控统计卡片组件 + * + * 展示 4 个核心指标: + * - 总体数据质量分 + * - 严重违规 (Critical) - 可点击查看详情 + * - 待确认 Query (Major) - 可点击查看详情 + * - 方案偏离 (PD) - 可点击查看详情 + */ + +import React from 'react'; +import { Progress, Tooltip } from 'antd'; +import { + HeartOutlined, + ExclamationCircleOutlined, + QuestionCircleOutlined, + ClockCircleOutlined, + RightOutlined, +} from '@ant-design/icons'; +import type { QcStats } from '../../types/qcCockpit'; + +// 卡片类型 +export type StatCardType = 'quality' | 'critical' | 'query' | 'deviation'; + +interface QcStatCardsProps { + stats: QcStats; + onCardClick?: (type: StatCardType) => void; +} + +const QcStatCards: React.FC = ({ stats, onCardClick }) => { + // 判断卡片是否可点击 + const isClickable = (type: StatCardType): boolean => { + switch (type) { + case 'critical': return stats.criticalCount > 0; + case 'query': return stats.queryCount > 0; + case 'deviation': return stats.deviationCount > 0; + default: return false; + } + }; + + const handleClick = (type: StatCardType) => { + if (isClickable(type) && onCardClick) { + onCardClick(type); + } + }; + + return ( +
+ {/* 卡片 1: 总体数据质量分 */} +
+
+
+
总体数据质量分
+
+ {stats.qualityScore} + /100 +
+
+
+ +
+
+
+ +
+
+ + {/* 卡片 2: 严重违规 (Critical) - 可点击 */} + 0 ? '点击查看详情' : undefined}> +
handleClick('critical')} + > +
+
+
严重违规 (Critical)
+
+ {stats.criticalCount} + +
+
+
+ +
+
+
+ + {stats.criticalCount > 0 + ? `需立即处理:${getTopIssue(stats, 'critical')}` + : '暂无严重违规'} + + {stats.criticalCount > 0 && } +
+
+
+ + {/* 卡片 3: 待确认 Query (Major) - 可点击 */} + 0 ? '点击查看详情' : undefined}> +
handleClick('query')} + > +
+
+
待确认 Query (Major)
+
+ {stats.queryCount} + +
+
+
+ +
+
+
+ + {stats.queryCount > 0 + ? getTopIssue(stats, 'warning') + : '暂无待确认问题'} + + {stats.queryCount > 0 && } +
+
+
+ + {/* 卡片 4: 方案偏离 (PD) - 可点击 */} + 0 ? '点击查看详情' : undefined}> +
handleClick('deviation')} + > +
+
+
方案偏离 (PD)
+
+ {stats.deviationCount} + +
+
+
+ +
+
+
+ + {stats.deviationCount > 0 + ? '大多为访视超窗' + : '暂无方案偏离'} + + {stats.deviationCount > 0 && } +
+
+
+
+ ); +}; + +// 根据分数获取颜色 +function getScoreColor(score: number): string { + if (score >= 90) return '#52c41a'; + if (score >= 70) return '#faad14'; + return '#ff4d4f'; +} + +// 获取顶部问题 +function getTopIssue(stats: QcStats, severity: 'critical' | 'warning'): string { + const issue = stats.topIssues?.find(i => i.severity === severity); + if (!issue) return ''; + return `${issue.issue} (${issue.count})`; +} + +export default QcStatCards; diff --git a/frontend-v2/src/modules/admin/components/qc-cockpit/RiskHeatmap.tsx b/frontend-v2/src/modules/admin/components/qc-cockpit/RiskHeatmap.tsx new file mode 100644 index 00000000..ce11077c --- /dev/null +++ b/frontend-v2/src/modules/admin/components/qc-cockpit/RiskHeatmap.tsx @@ -0,0 +1,149 @@ +/** + * 风险热力图组件 + * + * 展示受试者 × 表单/访视 矩阵 + * - 绿色圆点:通过 + * - 黄色图标:警告(可点击) + * - 红色图标:严重(可点击) + * - 灰色圆点:未开始 + */ + +import React from 'react'; +import { Spin, Tag, Tooltip } from 'antd'; +import { + CheckOutlined, + ExclamationOutlined, + CloseOutlined, +} from '@ant-design/icons'; +import type { HeatmapData, HeatmapCell } from '../../types/qcCockpit'; + +interface RiskHeatmapProps { + data: HeatmapData; + onCellClick: (cell: HeatmapCell) => void; + loading?: boolean; +} + +const RiskHeatmap: React.FC = ({ data, onCellClick, loading }) => { + const getStatusTag = (status: string) => { + switch (status) { + case 'enrolled': + return 已入组; + case 'screening': + return 筛查中; + case 'completed': + return 已完成; + case 'withdrawn': + return 已退出; + default: + return {status}; + } + }; + + const renderCell = (cell: HeatmapCell) => { + const hasIssues = cell.status === 'warning' || cell.status === 'fail'; + + // ✅ 所有单元格都可点击,便于查看详情 + const handleClick = () => onCellClick(cell); + + // 有问题的单元格:显示可点击图标 + if (hasIssues) { + const iconClass = `qc-heatmap-cell-icon ${cell.status}`; + const icon = cell.status === 'fail' + ? + : ; + + return ( + +
+ {icon} +
+
+ ); + } + + // 通过或待检查:显示小圆点(也可点击) + const dotClass = `qc-heatmap-cell-dot ${cell.status === 'pass' ? 'pass' : 'pending'}`; + const tooltipText = cell.status === 'pass' + ? '已通过,点击查看数据' + : '未质控,点击查看数据'; + + return ( + +
+ + ); + }; + + return ( +
+ {/* 头部 */} +
+

受试者风险全景图 (Risk Heatmap)

+
+ + + 无异常 + + + + 疑问 (Query) + + + + 严重违规 + + + + 未开始 + +
+
+ + {/* 表格内容 */} +
+ +
+ + + + + {data.columns.map((col, idx) => ( + + ))} + + + + {data.rows.map((row, rowIdx) => ( + + + + {row.cells.map((cell, cellIdx) => ( + + ))} + + ))} + +
受试者 ID入组状态 + {col.split('(')[0]}
+ + {col.includes('(') ? `(${col.split('(')[1]}` : ''} + +
{row.recordId}{getStatusTag(row.status)} +
+ {renderCell(cell)} +
+
+ + + + ); +}; + +export default RiskHeatmap; diff --git a/frontend-v2/src/modules/admin/components/qc-cockpit/index.ts b/frontend-v2/src/modules/admin/components/qc-cockpit/index.ts new file mode 100644 index 00000000..1c4e0211 --- /dev/null +++ b/frontend-v2/src/modules/admin/components/qc-cockpit/index.ts @@ -0,0 +1,8 @@ +/** + * 质控驾驶舱组件导出 + */ + +export { default as QcStatCards } from './QcStatCards'; +export type { StatCardType } from './QcStatCards'; +export { default as RiskHeatmap } from './RiskHeatmap'; +export { default as QcDetailDrawer } from './QcDetailDrawer'; diff --git a/frontend-v2/src/modules/admin/index.tsx b/frontend-v2/src/modules/admin/index.tsx index a21df7d8..46776355 100644 --- a/frontend-v2/src/modules/admin/index.tsx +++ b/frontend-v2/src/modules/admin/index.tsx @@ -20,6 +20,7 @@ import SystemKbListPage from './pages/SystemKbListPage'; import SystemKbDetailPage from './pages/SystemKbDetailPage'; import IitProjectListPage from './pages/IitProjectListPage'; import IitProjectDetailPage from './pages/IitProjectDetailPage'; +import IitQcCockpitPage from './pages/IitQcCockpitPage'; const AdminModule: React.FC = () => { return ( @@ -42,6 +43,7 @@ const AdminModule: React.FC = () => { {/* IIT 项目管理 */} } /> } /> + } /> ); }; diff --git a/frontend-v2/src/modules/admin/pages/IitProjectDetailPage.tsx b/frontend-v2/src/modules/admin/pages/IitProjectDetailPage.tsx index bfc57a87..fa02453a 100644 --- a/frontend-v2/src/modules/admin/pages/IitProjectDetailPage.tsx +++ b/frontend-v2/src/modules/admin/pages/IitProjectDetailPage.tsx @@ -42,6 +42,7 @@ import { BookOutlined, ThunderboltOutlined, BarChartOutlined, + DashboardOutlined, } from '@ant-design/icons'; import * as iitProjectApi from '../api/iitProjectApi'; import type { @@ -183,8 +184,16 @@ const IitProjectDetailPage: React.FC = () => { {/* ⭐ 批量操作按钮 */} + {/* ⭐ 质控全览图按钮 - 导航到驾驶舱页面 */} + +
+ + ); + } + + return ( +
+ {/* 顶部导航栏 */} +
+
+ +
+ CRA 智能监查驾驶舱 + + 项目: {project.name} | 最后同步: { + project.lastSyncAt + ? new Date(project.lastSyncAt).toLocaleString() + : '从未同步' + } + +
+
+
+ + + +
+
+ + {/* 主内容区 */} +
+ {/* 统计卡片 - 可点击查看详情 */} + + + {/* 风险热力图 */} + +
+ + {/* 详情抽屉 */} + setDrawerOpen(false)} + cell={selectedCell} + projectId={id!} + projectName={project.name} + /> + + {/* 报告抽屉 */} + setReportDrawerOpen(false)} + projectId={id!} + projectName={project.name} + /> + + {/* 问题列表模态框 */} + setIssueModalOpen(false)} + footer={null} + width={700} + > + ( + + {record.severity === 'critical' ? ( + + ) : ( + + )} + {text} + + ), + }, + { + title: '数量', + dataIndex: 'count', + key: 'count', + width: 80, + align: 'center' as const, + render: (count: number) => ( + 10 ? 'red' : count > 5 ? 'orange' : 'default'}> + {count} + + ), + }, + { + title: '严重程度', + dataIndex: 'severity', + key: 'severity', + width: 100, + render: (severity: string) => ( + + {severity === 'critical' ? '严重' : '警告'} + + ), + }, + ]} + locale={{ emptyText: '暂无问题' }} + /> + + + ); +}; + +export default IitQcCockpitPage; diff --git a/frontend-v2/src/modules/admin/types/qcCockpit.ts b/frontend-v2/src/modules/admin/types/qcCockpit.ts new file mode 100644 index 00000000..49e3d2d0 --- /dev/null +++ b/frontend-v2/src/modules/admin/types/qcCockpit.ts @@ -0,0 +1,116 @@ +/** + * 质控驾驶舱相关类型定义 + */ + +// 统计数据 +export interface QcStats { + /** 总体数据质量分 (0-100) */ + qualityScore: number; + /** 总记录数 */ + totalRecords: number; + /** 通过记录数 */ + passedRecords: number; + /** 失败记录数 */ + failedRecords: number; + /** 警告记录数 */ + warningRecords: number; + /** 待检查记录数 */ + pendingRecords: number; + /** 严重违规数(Critical) */ + criticalCount: number; + /** 待确认 Query 数(Major) */ + queryCount: number; + /** 方案偏离数(PD) */ + deviationCount: number; + /** 通过率 */ + passRate: number; + /** 主要问题 */ + topIssues?: Array<{ + issue: string; + count: number; + severity: 'critical' | 'warning' | 'info'; + }>; +} + +// 热力图数据 +export interface HeatmapData { + /** 行标题(表单/访视名称) */ + columns: string[]; + /** 行数据 */ + rows: HeatmapRow[]; +} + +export interface HeatmapRow { + /** 受试者 ID */ + recordId: string; + /** 入组状态 */ + status: 'enrolled' | 'screening' | 'completed' | 'withdrawn'; + /** 各表单/访视的质控状态 */ + cells: HeatmapCell[]; +} + +export interface HeatmapCell { + /** 表单/访视名称 */ + formName: string; + /** 质控状态 */ + status: 'pass' | 'warning' | 'fail' | 'pending'; + /** 问题数量 */ + issueCount: number; + /** 受试者 ID(冗余,方便查询) */ + 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; + /** 字段元数据 */ + fieldMetadata?: Record; + /** 质控问题 */ + issues: Array<{ + field: string; + ruleName: string; + message: string; + severity: 'critical' | 'warning' | 'info'; + actualValue?: any; + expectedValue?: string; + confidence?: 'high' | 'medium' | 'low'; + }>; + /** LLM Trace(调试用) */ + llmTrace?: { + promptSent: string; + responseReceived: string; + model: string; + latencyMs: number; + }; + /** 录入时间 */ + entryTime?: string; +} + +// API 响应类型 +export interface QcCockpitResponse extends QcCockpitData {} + +export interface RecordDetailResponse extends RecordDetail {}