diff --git a/backend/prisma/migrations/20260228_add_iit_phase2_user_project_rbac/migration.sql b/backend/prisma/migrations/20260228_add_iit_phase2_user_project_rbac/migration.sql new file mode 100644 index 00000000..0e4476c4 --- /dev/null +++ b/backend/prisma/migrations/20260228_add_iit_phase2_user_project_rbac/migration.sql @@ -0,0 +1,21 @@ +-- Phase 2: User-Project Association + RBAC +-- 1. IitUserMapping: add userId (nullable FK to platform_schema.users) +-- 2. IitProject: add tenantId (nullable FK to platform_schema.tenants, for Phase 3) +-- 3. UserRole enum: add IIT_OPERATOR role + +-- 1. Add user_id to user_mappings (nullable, gradual migration) +ALTER TABLE "iit_schema"."user_mappings" ADD COLUMN IF NOT EXISTS "user_id" VARCHAR(255); +ALTER TABLE "iit_schema"."user_mappings" ADD CONSTRAINT "user_mappings_user_id_fkey" + FOREIGN KEY ("user_id") REFERENCES "platform_schema"."users"("id") + ON DELETE SET NULL ON UPDATE CASCADE; +CREATE INDEX IF NOT EXISTS "idx_iit_user_mappings_user_id" ON "iit_schema"."user_mappings"("user_id"); + +-- 2. Add tenant_id to projects (nullable, Phase 3 will make it required) +ALTER TABLE "iit_schema"."projects" ADD COLUMN IF NOT EXISTS "tenant_id" VARCHAR(255); +ALTER TABLE "iit_schema"."projects" ADD CONSTRAINT "projects_tenant_id_fkey" + FOREIGN KEY ("tenant_id") REFERENCES "platform_schema"."tenants"("id") + ON DELETE SET NULL ON UPDATE CASCADE; +CREATE INDEX IF NOT EXISTS "idx_iit_projects_tenant_id" ON "iit_schema"."projects"("tenant_id"); + +-- 3. Add IIT_OPERATOR to UserRole enum +ALTER TYPE "platform_schema"."UserRole" ADD VALUE IF NOT EXISTS 'IIT_OPERATOR' BEFORE 'USER'; diff --git a/backend/prisma/migrations/20260228_add_iit_project_is_demo/migration.sql b/backend/prisma/migrations/20260228_add_iit_project_is_demo/migration.sql new file mode 100644 index 00000000..e9b4baee --- /dev/null +++ b/backend/prisma/migrations/20260228_add_iit_project_is_demo/migration.sql @@ -0,0 +1,2 @@ +-- Add is_demo flag to IIT projects (体验项目标记) +ALTER TABLE iit_schema.projects ADD COLUMN IF NOT EXISTS is_demo BOOLEAN NOT NULL DEFAULT false; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 5cb6e2ee..b5089340 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -46,6 +46,7 @@ model User { updatedAt DateTime @updatedAt @map("updated_at") tenant_members tenant_members[] user_modules user_modules[] + iitUserMappings IitUserMapping[] departments departments? @relation(fields: [department_id], references: [id]) tenants tenants @relation(fields: [tenant_id], references: [id]) @@ -950,17 +951,21 @@ model IitProject { lastSyncAt DateTime? @map("last_sync_at") cronEnabled Boolean @default(false) @map("cron_enabled") cronExpression String? @map("cron_expression") + isDemo Boolean @default(false) @map("is_demo") status String @default("active") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") deletedAt DateTime? @map("deleted_at") + tenantId String? @map("tenant_id") auditLogs IitAuditLog[] pendingActions IitPendingAction[] taskRuns IitTaskRun[] userMappings IitUserMapping[] + tenant tenants? @relation(fields: [tenantId], references: [id]) @@index([status, deletedAt]) @@index([knowledgeBaseId], map: "idx_iit_project_kb") + @@index([tenantId]) @@map("projects") @@schema("iit_schema") } @@ -1022,6 +1027,7 @@ model IitUserMapping { id String @id @default(uuid()) projectId String @map("project_id") systemUserId String @map("system_user_id") + userId String? @map("user_id") redcapUsername String @map("redcap_username") wecomUserId String? @map("wecom_user_id") miniProgramOpenId String? @unique @map("mini_program_open_id") @@ -1030,11 +1036,13 @@ model IitUserMapping { createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") project IitProject @relation(fields: [projectId], references: [id]) + user User? @relation(fields: [userId], references: [id]) @@unique([projectId, systemUserId]) @@unique([projectId, redcapUsername]) @@index([wecomUserId]) @@index([miniProgramOpenId]) + @@index([userId]) @@map("user_mappings") @@schema("iit_schema") } @@ -1763,6 +1771,7 @@ model tenants { tenant_quotas tenant_quotas[] users User[] user_modules user_modules[] + iitProjects IitProject[] @@index([code], map: "idx_tenants_code") @@index([status], map: "idx_tenants_status") @@ -1819,6 +1828,7 @@ enum UserRole { HOSPITAL_ADMIN PHARMA_ADMIN DEPARTMENT_ADMIN + IIT_OPERATOR USER @@schema("platform_schema") diff --git a/backend/prisma/seed-iit-qc-rules.ts b/backend/prisma/seed-iit-qc-rules.ts index 944e6655..2b993d30 100644 --- a/backend/prisma/seed-iit-qc-rules.ts +++ b/backend/prisma/seed-iit-qc-rules.ts @@ -27,60 +27,95 @@ const INCLUSION_RULES = [ name: '年龄范围检查', field: 'age', logic: { - and: [ - { '>=': [{ var: 'age' }, 16] }, - { '<=': [{ var: 'age' }, 35] } + or: [ + { '==': [{ var: 'age' }, null] }, + { '==': [{ var: 'age' }, ''] }, + { + and: [ + { '>=': [{ var: 'age' }, 16] }, + { '<=': [{ var: 'age' }, 35] } + ] + } ] }, message: '年龄不在 16-35 岁范围内', severity: 'error', - category: 'inclusion' + category: 'inclusion', + applicableEvents: [] as string[], }, { id: 'inc_002', name: '出生日期范围检查', field: 'birth_date', logic: { - and: [ - { '>=': [{ var: 'birth_date' }, '1989-01-01'] }, - { '<=': [{ var: 'birth_date' }, '2008-01-01'] } + or: [ + { '==': [{ var: 'birth_date' }, null] }, + { '==': [{ var: 'birth_date' }, ''] }, + { + and: [ + { '>=': [{ var: 'birth_date' }, '1989-01-01'] }, + { '<=': [{ var: 'birth_date' }, '2008-01-01'] } + ] + } ] }, message: '出生日期不在 1989-01-01 至 2008-01-01 范围内', severity: 'error', - category: 'inclusion' + category: 'inclusion', + applicableEvents: [] as string[], }, { id: 'inc_003', name: '月经周期规律性检查', field: 'menstrual_cycle', logic: { - and: [ - { '>=': [{ var: 'menstrual_cycle' }, 21] }, - { '<=': [{ var: 'menstrual_cycle' }, 35] } + or: [ + { '==': [{ var: 'menstrual_cycle' }, null] }, + { '==': [{ var: 'menstrual_cycle' }, ''] }, + { + and: [ + { '>=': [{ var: 'menstrual_cycle' }, 21] }, + { '<=': [{ var: 'menstrual_cycle' }, 35] } + ] + } ] }, message: '月经周期不在 21-35 天范围内(28±7天)', severity: 'error', - category: 'inclusion' + category: 'inclusion', + applicableEvents: [] as string[], }, { id: 'inc_004', name: 'VAS 评分检查', field: 'vas_score', - logic: { '>=': [{ var: 'vas_score' }, 4] }, + logic: { + or: [ + { '==': [{ var: 'vas_score' }, null] }, + { '==': [{ var: 'vas_score' }, ''] }, + { '>=': [{ var: 'vas_score' }, 4] } + ] + }, message: 'VAS 疼痛评分 < 4 分,不符合入组条件', severity: 'error', - category: 'inclusion' + category: 'inclusion', + applicableEvents: [] as string[], }, { id: 'inc_005', name: '知情同意书签署检查', field: 'informed_consent', - logic: { '==': [{ var: 'informed_consent' }, 1] }, + logic: { + or: [ + { '==': [{ var: 'informed_consent' }, null] }, + { '==': [{ var: 'informed_consent' }, ''] }, + { '==': [{ var: 'informed_consent' }, 1] } + ] + }, message: '未签署知情同意书', severity: 'error', - category: 'inclusion' + category: 'inclusion', + applicableEvents: [] as string[], } ]; @@ -92,37 +127,65 @@ const EXCLUSION_RULES = [ id: 'exc_001', name: '继发性痛经排除', field: 'secondary_dysmenorrhea', - logic: { '!=': [{ var: 'secondary_dysmenorrhea' }, 1] }, + logic: { + or: [ + { '==': [{ var: 'secondary_dysmenorrhea' }, null] }, + { '==': [{ var: 'secondary_dysmenorrhea' }, ''] }, + { '!=': [{ var: 'secondary_dysmenorrhea' }, 1] } + ] + }, message: '存在继发性痛经(盆腔炎、子宫内膜异位症、子宫腺肌病等)', severity: 'error', - category: 'exclusion' + category: 'exclusion', + applicableEvents: [] as string[], }, { id: 'exc_002', name: '妊娠哺乳期排除', field: 'pregnancy_lactation', - logic: { '!=': [{ var: 'pregnancy_lactation' }, 1] }, + logic: { + or: [ + { '==': [{ var: 'pregnancy_lactation' }, null] }, + { '==': [{ var: 'pregnancy_lactation' }, ''] }, + { '!=': [{ var: 'pregnancy_lactation' }, 1] } + ] + }, message: '妊娠或哺乳期妇女,不符合入组条件', severity: 'error', - category: 'exclusion' + category: 'exclusion', + applicableEvents: [] as string[], }, { id: 'exc_003', name: '严重疾病排除', field: 'severe_disease', - logic: { '!=': [{ var: 'severe_disease' }, 1] }, + logic: { + or: [ + { '==': [{ var: 'severe_disease' }, null] }, + { '==': [{ var: 'severe_disease' }, ''] }, + { '!=': [{ var: 'severe_disease' }, 1] } + ] + }, message: '合并有心脑血管、肝、肾、造血系统等严重疾病或精神病', severity: 'error', - category: 'exclusion' + category: 'exclusion', + applicableEvents: [] as string[], }, { id: 'exc_004', name: '月经周期不规律排除', field: 'irregular_menstruation', - logic: { '!=': [{ var: 'irregular_menstruation' }, 1] }, + logic: { + or: [ + { '==': [{ var: 'irregular_menstruation' }, null] }, + { '==': [{ var: 'irregular_menstruation' }, ''] }, + { '!=': [{ var: 'irregular_menstruation' }, 1] } + ] + }, message: '月经周期不规律或间歇性痛经发作', severity: 'error', - category: 'exclusion' + category: 'exclusion', + applicableEvents: [] as string[], } ]; @@ -242,7 +305,7 @@ async function main() { // 1. 先获取项目 ID const project = await prisma.iitProject.findFirst({ - where: { name: 'test0102' } + where: { name: { in: ['test0207', 'test0102'] } } }); if (!project) { diff --git a/backend/scripts/test-qc-pipeline.ts b/backend/scripts/test-qc-pipeline.ts new file mode 100644 index 00000000..6b03cd1a --- /dev/null +++ b/backend/scripts/test-qc-pipeline.ts @@ -0,0 +1,553 @@ +/** + * IIT 质控全链路诊断脚本 + * + * 从 REDCap 原始数据 → 质控规则执行 → 数据库存储 → LLM 报告生成, + * 端到端可视化验证整个 QC 管线。 + * + * 用法:npx tsx scripts/test-qc-pipeline.ts + */ + +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'; +import { QcReportService } from '../src/modules/iit-manager/services/QcReportService.js'; + +const prisma = new PrismaClient(); +const PROJECT_ID = 'test0102-pd-study'; + +// ─── 工具函数 ──────────────────────────────────────────────── +const SEP = '═'.repeat(80); +const SEP2 = '─'.repeat(80); + +function section(title: string) { + console.log(`\n${SEP}`); + console.log(` ${title}`); + console.log(SEP); +} + +function sub(title: string) { + console.log(`\n${SEP2}`); + console.log(` ${title}`); + console.log(SEP2); +} + +function table(rows: Record[], maxRows = 5) { + if (rows.length === 0) { console.log(' (空)'); return; } + const display = rows.slice(0, maxRows); + console.table(display); + if (rows.length > maxRows) { + console.log(` ... 共 ${rows.length} 行,仅展示前 ${maxRows} 行`); + } +} + +function jsonPreview(obj: any, maxLen = 2000) { + const str = JSON.stringify(obj, null, 2); + if (str.length <= maxLen) { + console.log(str); + } else { + console.log(str.substring(0, maxLen)); + console.log(`\n ... (截断,总长 ${str.length} 字符)`); + } +} + +// ─── Part 1:REDCap 原始数据 ───────────────────────────────── +async function part1_redcapRawData() { + section('Part 1:REDCap 原始数据'); + + const project = await prisma.iitProject.findUnique({ + where: { id: PROJECT_ID }, + select: { id: true, name: true, redcapUrl: true, redcapApiToken: true }, + }); + + if (!project) { + console.log('❌ 项目不存在:', PROJECT_ID); + return; + } + + console.log(` 项目: ${project.name} (${project.id})`); + console.log(` REDCap: ${project.redcapUrl}`); + + const adapter = new RedcapAdapter(project.redcapUrl, project.redcapApiToken); + + // 先检测 REDCap 是否可达 + let redcapAvailable = true; + try { + await adapter.exportRecords({ fields: ['record_id'] }); + } catch { + redcapAvailable = false; + console.log('\n ⚠️ REDCap 服务不可达,跳过 REDCap 数据拉取,仅验证数据库数据。'); + console.log(' 请确保 REDCap (localhost:8080) 已启动后再次运行。'); + return; + } + + // 1a. 事件映射 + sub('1a. 表单-事件映射 (Form-Event Mapping)'); + try { + const formEventMapping = await adapter.getFormEventMapping(); + table(formEventMapping, 20); + } catch (e: any) { + console.log(` ⚠️ 获取失败: ${e.message}(可能不是纵向研究项目)`); + } + + // 1b. 所有 instruments + sub('1b. REDCap 表单列表 (Instruments)'); + try { + const instruments = await adapter.exportInstruments(); + table(instruments, 20); + } catch (e: any) { + console.log(` ⚠️ 获取失败: ${e.message}`); + } + + // 1c. 原始记录 + sub('1c. REDCap 原始记录 (Raw Records - 前 5 行)'); + let rawRecords: any[] = []; + try { + rawRecords = await adapter.exportRecords({}); + console.log(` 总行数: ${rawRecords.length}`); + for (let i = 0; i < Math.min(5, rawRecords.length); i++) { + console.log(`\n --- Record ${i + 1} ---`); + jsonPreview(rawRecords[i], 1500); + } + } catch (e: any) { + console.log(` ⚠️ 获取失败: ${e.message}`); + } + + // 1d. 事件级数据 + sub('1d. 事件级数据 (getAllRecordsByEvent)'); + let eventRecords: Array<{ recordId: string; eventName: string; eventLabel: string; forms: string[]; data: Record }> = []; + try { + eventRecords = await adapter.getAllRecordsByEvent({}); + console.log(` 总 record+event 组合数: ${eventRecords.length}`); + + const uniqueRecords = new Set(eventRecords.map(r => r.recordId)); + const uniqueEvents = new Set(eventRecords.map(r => r.eventName)); + console.log(` 唯一 record 数: ${uniqueRecords.size}`); + console.log(` 唯一 event 数: ${uniqueEvents.size}`); + console.log(` 事件列表: ${[...uniqueEvents].join(', ')}`); + } catch (e: any) { + console.log(` ⚠️ 获取失败: ${e.message}(可能不是纵向研究设计)`); + + // 回退:把 rawRecords 当作单事件处理 + if (rawRecords.length > 0) { + console.log(' 📌 回退:使用 exportRecords 数据作为平铺记录'); + const uniqueIds = new Set(rawRecords.map(r => r.record_id)); + eventRecords = rawRecords.map(r => ({ + recordId: r.record_id, + eventName: r.redcap_event_name || 'default', + eventLabel: r.redcap_event_name || 'default', + forms: [], + data: r, + })); + console.log(` 唯一 record 数: ${uniqueIds.size}, 总行数: ${eventRecords.length}`); + } + } + + if (eventRecords.length > 0) { + // 1e. Record-Event 分布 + const recordGroups = new Map(); + for (const r of eventRecords) { + if (!recordGroups.has(r.recordId)) recordGroups.set(r.recordId, []); + recordGroups.get(r.recordId)!.push(r); + } + + const recordEventSummary: Array<{ recordId: string; events: string; dataFieldCount: number }> = []; + for (const [recordId, events] of recordGroups) { + recordEventSummary.push({ + recordId, + events: events.map(e => e.eventLabel || e.eventName).join(' | '), + dataFieldCount: events.reduce((sum, e) => sum + Object.keys(e.data).length, 0), + }); + } + sub('1e. Record-Event 分布'); + table(recordEventSummary, 20); + + // 1f. 纳入/排除关键字段可用性 + sub('1f. 纳入/排除关键字段可用性检查'); + const keyFields = ['age', 'birth_date', 'menstrual_cycle', 'vas_score', 'informed_consent', + 'secondary_dysmenorrhea', 'pregnancy_lactation', 'severe_disease', 'irregular_menstruation']; + + const fieldAvailability: Array<{ recordId: string; event: string; [key: string]: any }> = []; + for (const r of eventRecords.slice(0, 30)) { + const row: any = { recordId: r.recordId, event: r.eventLabel || r.eventName }; + for (const f of keyFields) { + const val = r.data[f]; + row[f] = val === undefined || val === null || val === '' ? '❌' : `✅ ${val}`; + } + fieldAvailability.push(row); + } + table(fieldAvailability, 30); + } + + return { eventRecords, rawRecords }; +} + +// ─── Part 2:执行质控 + 数据库存储验证 ───────────────────── +async function part2_qcExecutionAndStorage() { + section('Part 2:质控执行与数据库存储验证'); + + // 2a. 查看当前 QC 规则 + sub('2a. 当前加载的 QC 规则'); + const skill = await prisma.iitSkill.findFirst({ + where: { projectId: PROJECT_ID, skillType: 'qc_process', isActive: true }, + select: { id: true, name: true, config: true }, + }); + + if (!skill) { + console.log('❌ 未找到 QC 规则'); + return; + } + + const config = skill.config as any; + const rules = config?.rules || []; + console.log(` 规则总数: ${rules.length}`); + + const ruleOverview = rules.map((r: any) => ({ + id: r.id, + name: r.name, + category: r.category, + severity: r.severity, + field: Array.isArray(r.field) ? r.field.join(',') : r.field, + hasNullTolerance: JSON.stringify(r.logic).includes('"=="') && JSON.stringify(r.logic).includes('null') ? '✅' : '❌', + })); + table(ruleOverview, 40); + + // 2b. 执行质控前 - 记录当前 qc_logs 行数 + sub('2b. 执行质控前 - 数据库状态'); + const logCountBefore = await prisma.iitQcLog.count({ where: { projectId: PROJECT_ID } }); + console.log(` qc_logs 现有行数: ${logCountBefore}`); + + const statsBefore = await prisma.iitQcProjectStats.findUnique({ where: { projectId: PROJECT_ID } }); + if (statsBefore) { + console.log(` project_stats: total=${statsBefore.totalRecords}, pass=${statsBefore.passedRecords}, fail=${statsBefore.failedRecords}, warn=${statsBefore.warningRecords}`); + } + + // 2c. 清理旧版日志(event_id 为 NULL 的遗留数据) + sub('2c-0. 清理旧版质控日志 (event_id IS NULL)'); + const deletedLegacy = await prisma.iitQcLog.deleteMany({ + where: { projectId: PROJECT_ID, eventId: null } + }); + console.log(` 删除旧版日志: ${deletedLegacy.count} 条`); + + // 2c. 执行全量质控 + sub('2c. 执行全量质控 (SkillRunner.runByTrigger)'); + const runner = createSkillRunner(PROJECT_ID); + const startTime = Date.now(); + const results = await runner.runByTrigger('manual'); + const duration = Date.now() - startTime; + + console.log(` 耗时: ${duration}ms`); + console.log(` 结果总数 (record+event 组合): ${results.length}`); + + // 状态分布 + const statusDist: Record = {}; + for (const r of results) { + statusDist[r.overallStatus] = (statusDist[r.overallStatus] || 0) + 1; + } + console.log(` 事件级状态分布: ${JSON.stringify(statusDist)}`); + + // record 级别聚合 + const statusPriority: Record = { 'FAIL': 3, 'WARNING': 2, 'UNCERTAIN': 1, 'PASS': 0 }; + const recordWorst = new Map(); + for (const r of results) { + const existing = recordWorst.get(r.recordId); + const curP = statusPriority[r.overallStatus] ?? 0; + const exP = existing ? (statusPriority[existing] ?? 0) : -1; + if (curP > exP) recordWorst.set(r.recordId, r.overallStatus); + } + + const recordStatusDist: Record = {}; + for (const s of recordWorst.values()) { + recordStatusDist[s] = (recordStatusDist[s] || 0) + 1; + } + console.log(` Record 级别状态分布: ${JSON.stringify(recordStatusDist)}`); + const totalRec = recordWorst.size; + const passRec = recordStatusDist['PASS'] || 0; + console.log(` 通过率 (record级): ${totalRec > 0 ? ((passRec / totalRec) * 100).toFixed(1) : 0}%`); + + // V3.2: 用批量结果更新 record_summary(覆盖旧状态) + for (const [recordId, worstStatus] of recordWorst) { + await prisma.iitRecordSummary.upsert({ + where: { projectId_recordId: { projectId: PROJECT_ID, recordId } }, + create: { + projectId: PROJECT_ID, recordId, + lastUpdatedAt: new Date(), latestQcStatus: worstStatus, + latestQcAt: new Date(), formStatus: {}, updateCount: 1 + }, + update: { latestQcStatus: worstStatus, latestQcAt: new Date() } + }); + } + + // 展示每个 result 的详情 + sub('2d. 各 record+event 质控详情'); + const resultSummary = results.map(r => ({ + recordId: r.recordId, + event: r.eventLabel || r.eventName || '-', + status: r.overallStatus, + issueCount: r.allIssues.length, + criticalCount: r.criticalIssues.length, + warningCount: r.warningIssues.length, + issues: r.allIssues.slice(0, 3).map(i => `[${i.ruleId}] ${i.ruleName}`).join('; ') || '(无)', + })); + table(resultSummary, 50); + + // 2e. 验证数据库写入 + sub('2e. 执行质控后 - 数据库状态'); + const logCountAfter = await prisma.iitQcLog.count({ where: { projectId: PROJECT_ID } }); + console.log(` qc_logs 行数: ${logCountBefore} → ${logCountAfter} (新增 ${logCountAfter - logCountBefore})`); + + // 2f. 验证 DISTINCT ON 只取最新 + sub('2f. 验证去重逻辑 - DISTINCT ON (record_id, event_id) 只取最新'); + const latestLogs = await prisma.$queryRawUnsafe(` + SELECT DISTINCT ON (record_id, COALESCE(event_id, '')) + record_id, event_id, status, created_at, + (SELECT COUNT(*) FROM iit_schema.qc_logs t2 + WHERE t2.project_id = t1.project_id + AND t2.record_id = t1.record_id + AND COALESCE(t2.event_id, '') = COALESCE(t1.event_id, '') + ) as total_versions + FROM iit_schema.qc_logs t1 + WHERE project_id = '${PROJECT_ID}' + ORDER BY record_id, COALESCE(event_id, ''), created_at DESC + `); + + console.log(` 去重后的 record+event 组合数: ${latestLogs.length}`); + + const dedup = latestLogs.map((l: any) => ({ + record_id: l.record_id, + event_id: l.event_id || '-', + status: l.status, + total_versions: Number(l.total_versions), + created_at: l.created_at, + })); + table(dedup, 30); + + const hasMultipleVersions = dedup.some(d => d.total_versions > 1); + console.log(` 是否存在多版本: ${hasMultipleVersions ? '✅ 是(去重逻辑生效)' : '仅单版本'}`); + + // 2g. 查看一条 qc_log 的完整 issues 内容 + sub('2g. qc_log issues 字段样本(取第一条有 issues 的)'); + const sampleLog = await prisma.iitQcLog.findFirst({ + where: { projectId: PROJECT_ID, status: { not: 'PASS' } }, + orderBy: { createdAt: 'desc' }, + select: { recordId: true, eventId: true, status: true, issues: true, createdAt: true }, + }); + + if (sampleLog) { + console.log(` recordId: ${sampleLog.recordId}, eventId: ${sampleLog.eventId}, status: ${sampleLog.status}`); + console.log(` issues 内容:`); + jsonPreview(sampleLog.issues, 3000); + } else { + console.log(' ✅ 所有日志都是 PASS(没有 issues)'); + } + + // 2h. record_summary 表验证 + sub('2h. record_summary 表内容'); + const summaries = await prisma.iitRecordSummary.findMany({ + where: { projectId: PROJECT_ID }, + select: { recordId: true, latestQcStatus: true, latestQcAt: true, completionRate: true, totalForms: true, completedForms: true }, + orderBy: { recordId: 'asc' }, + }); + table(summaries.map(s => ({ + recordId: s.recordId, + qcStatus: s.latestQcStatus || '-', + qcAt: s.latestQcAt ? s.latestQcAt.toISOString().replace('T', ' ').substring(0, 19) : '-', + completionRate: s.completionRate != null ? `${s.completionRate}%` : '-', + })), 20); + + return results; +} + +// ─── Part 3:LLM 报告 ─────────────────────────────────────── +async function part3_llmReport() { + section('Part 3:最终呈现给 LLM 的质控报告'); + + // 3a. 强制生成新报告 + sub('3a. 调用 QcReportService.getReport (forceRefresh=true)'); + const report = await QcReportService.getReport(PROJECT_ID, { forceRefresh: true }); + + console.log(` reportType: ${report.reportType}`); + console.log(` generatedAt: ${report.generatedAt}`); + + // 3b. Summary 统计 + sub('3b. 报告 Summary'); + console.log(JSON.stringify(report.summary, null, 2)); + + // 3c. Critical Issues + sub('3c. 严重问题 (Critical Issues)'); + console.log(` 总数: ${report.criticalIssues.length}`); + table(report.criticalIssues.map(i => ({ + recordId: i.recordId, + ruleId: i.ruleId, + ruleName: i.ruleName, + severity: i.severity, + actualValue: i.actualValue ?? '空', + expectedValue: i.expectedValue ?? '-', + })), 30); + + // 3d. Warning Issues + sub('3d. 警告问题 (Warning Issues)'); + console.log(` 总数: ${report.warningIssues.length}`); + table(report.warningIssues.map(i => ({ + recordId: i.recordId, + ruleId: i.ruleId, + ruleName: i.ruleName, + actualValue: i.actualValue ?? '空', + })), 20); + + // 3e. Top Issues + sub('3e. Top Issues'); + table(report.topIssues, 10); + + // 3f. LLM XML 报告全文 + sub('3f. LLM XML 报告全文 (llmFriendlyXml)'); + console.log(report.llmFriendlyXml); + + // 3g. 验证数据库 qc_reports 表 + sub('3g. qc_reports 缓存表验证'); + const cachedReports = await prisma.iitQcReport.findMany({ + where: { projectId: PROJECT_ID }, + select: { id: true, reportType: true, generatedAt: true, expiresAt: true }, + orderBy: { generatedAt: 'desc' }, + take: 5, + }); + table(cachedReports.map(r => ({ + id: r.id.substring(0, 8) + '...', + reportType: r.reportType, + generatedAt: r.generatedAt.toISOString().replace('T', ' ').substring(0, 19), + expiresAt: r.expiresAt?.toISOString().replace('T', ' ').substring(0, 19) || '-', + })), 10); +} + +// ─── 回退:仅查看数据库现有 QC 数据 ───────────────────────── +async function part2_dbOnly() { + section('Part 2 (回退):数据库现有 QC 数据查看'); + + const logCount = await prisma.iitQcLog.count({ where: { projectId: PROJECT_ID } }); + console.log(` qc_logs 总行数: ${logCount}`); + + if (logCount === 0) { + console.log(' ⚠️ 没有质控日志,请先执行一键全量质控'); + return; + } + + sub('数据库 qc_logs 去重后最新状态'); + const latestLogs = await prisma.$queryRawUnsafe(` + SELECT DISTINCT ON (record_id, COALESCE(event_id, '')) + record_id, event_id, status, issues, created_at + FROM iit_schema.qc_logs + WHERE project_id = '${PROJECT_ID}' + ORDER BY record_id, COALESCE(event_id, ''), created_at DESC + `); + + const dedup = latestLogs.map((l: any) => ({ + record_id: l.record_id, + event_id: l.event_id || '-', + status: l.status, + created_at: l.created_at, + })); + table(dedup, 30); + + sub('record_summary 表'); + const summaries = await prisma.iitRecordSummary.findMany({ + where: { projectId: PROJECT_ID }, + select: { recordId: true, latestQcStatus: true, latestQcAt: true, completionRate: true }, + orderBy: { recordId: 'asc' }, + }); + table(summaries.map(s => ({ + recordId: s.recordId, + qcStatus: s.latestQcStatus || '-', + completionRate: s.completionRate != null ? `${s.completionRate}%` : '-', + })), 20); + + sub('project_stats 表'); + const stats = await prisma.iitQcProjectStats.findUnique({ where: { projectId: PROJECT_ID } }); + if (stats) { + console.log(JSON.stringify({ + totalRecords: stats.totalRecords, + passedRecords: stats.passedRecords, + failedRecords: stats.failedRecords, + warningRecords: stats.warningRecords, + }, null, 2)); + } + + sub('qc_log issues 字段样本'); + const sampleLog = await prisma.iitQcLog.findFirst({ + where: { projectId: PROJECT_ID, status: { not: 'PASS' } }, + orderBy: { createdAt: 'desc' }, + select: { recordId: true, eventId: true, status: true, issues: true }, + }); + if (sampleLog) { + console.log(` recordId: ${sampleLog.recordId}, eventId: ${sampleLog.eventId}, status: ${sampleLog.status}`); + jsonPreview(sampleLog.issues, 3000); + } else { + console.log(' ✅ 所有日志都是 PASS'); + } +} + +async function part3_dbOnly() { + section('Part 3 (回退):从数据库缓存读取报告'); + + const cached = await prisma.iitQcReport.findFirst({ + where: { projectId: PROJECT_ID }, + orderBy: { generatedAt: 'desc' }, + }); + + if (!cached) { + console.log(' ⚠️ 数据库中没有缓存报告'); + return; + } + + console.log(` reportType: ${cached.reportType}`); + console.log(` generatedAt: ${cached.generatedAt}`); + + sub('Summary'); + console.log(JSON.stringify(cached.summary, null, 2)); + + sub('LLM XML 报告 (llmReport)'); + console.log(cached.llmReport || '(空)'); +} + +// ─── 主流程 ────────────────────────────────────────────────── +async function main() { + console.log('╔════════════════════════════════════════════════════════════════╗'); + console.log('║ IIT 质控全链路诊断脚本 (QC Pipeline Deep Test) ║'); + console.log('║ 项目: ' + PROJECT_ID.padEnd(47) + '║'); + console.log('╚════════════════════════════════════════════════════════════════╝'); + + try { + // Part 1: REDCap 原始数据(可能因 REDCap 不可达而跳过) + try { + await part1_redcapRawData(); + } catch (e: any) { + console.log(`\n ⚠️ Part 1 出错: ${e.message}(继续执行后续部分)`); + } + + // Part 2: 质控执行 + 数据库验证 + try { + await part2_qcExecutionAndStorage(); + } catch (e: any) { + console.log(`\n ⚠️ Part 2 出错: ${e.message}`); + console.log(' 尝试仅验证数据库现有数据...\n'); + await part2_dbOnly(); + } + + // Part 3: LLM 报告 + try { + await part3_llmReport(); + } catch (e: any) { + console.log(`\n ⚠️ Part 3 出错: ${e.message}`); + console.log(' 尝试从数据库缓存读取报告...\n'); + await part3_dbOnly(); + } + + section('✅ 全链路诊断完成'); + console.log(' 请检查以上输出确认数据正确性。'); + } catch (error: any) { + console.error('\n❌ 诊断过程出错:', error.message); + console.error(error.stack); + } finally { + await prisma.$disconnect(); + } +} + +main(); diff --git a/backend/src/index.ts b/backend/src/index.ts index a0e44b31..2ad4773d 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -111,19 +111,27 @@ 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, iitQcCockpitRoutes, iitEqueryRoutes } from './modules/admin/iit-projects/index.js'; +import { authenticate, requireRoles } from './common/auth/auth.middleware.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' }); await fastify.register(statsRoutes, { prefix: '/api/admin/stats' }); await fastify.register(userOverviewRoute, { prefix: '/api/admin/users' }); await fastify.register(systemKbRoutes, { prefix: '/api/v1/admin/system-kb' }); -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' }); // 质控驾驶舱 -await fastify.register(iitEqueryRoutes, { prefix: '/api/v1/admin/iit-projects' }); // eQuery 闭环 -logger.info('✅ 运营管理路由已注册: /api/admin/tenants, /api/admin/modules, /api/admin/users, /api/admin/stats, /api/v1/admin/system-kb, /api/v1/admin/iit-projects'); + +// IIT 项目管理路由 — 认证 + 角色守卫(SUPER_ADMIN, PROMPT_ENGINEER, IIT_OPERATOR, PHARMA_ADMIN, HOSPITAL_ADMIN 可访问) +await fastify.register(async (scope) => { + scope.addHook('preHandler', authenticate); + scope.addHook('preHandler', requireRoles('SUPER_ADMIN', 'PROMPT_ENGINEER', 'IIT_OPERATOR', 'PHARMA_ADMIN', 'HOSPITAL_ADMIN')); + await scope.register(iitProjectRoutes); + await scope.register(iitQcRuleRoutes); + await scope.register(iitUserMappingRoutes); + await scope.register(iitBatchRoutes); + await scope.register(iitQcCockpitRoutes); + await scope.register(iitEqueryRoutes); +}, { 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 (authenticated)'); // ============================================ // 【临时】平台基础设施测试API diff --git a/backend/src/modules/admin/iit-projects/iitBatchController.ts b/backend/src/modules/admin/iit-projects/iitBatchController.ts index f6759555..df0c656f 100644 --- a/backend/src/modules/admin/iit-projects/iitBatchController.ts +++ b/backend/src/modules/admin/iit-projects/iitBatchController.ts @@ -67,79 +67,86 @@ export class IitBatchController { }); } - // 3. 统计(按 record+event 组合) - let passCount = 0; - let failCount = 0; - let warningCount = 0; - let uncertainCount = 0; - - const uniqueRecords = new Set(); + // 3. V3.2: 按 record 级别聚合状态(每个 record 取最严重状态) + const statusPriority: Record = { 'FAIL': 3, 'WARNING': 2, 'UNCERTAIN': 1, 'PASS': 0 }; + const recordWorstStatus = new Map(); 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++; - - // 更新录入汇总表(取最差状态) - const existingSummary = await prisma.iitRecordSummary.findUnique({ - where: { projectId_recordId: { projectId, recordId: result.recordId } } - }); - - 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 (!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() - } - }); + const existing = recordWorstStatus.get(result.recordId); + const currentPrio = statusPriority[result.overallStatus] ?? 0; + const existingPrio = existing ? (statusPriority[existing] ?? 0) : -1; + if (currentPrio > existingPrio) { + recordWorstStatus.set(result.recordId, result.overallStatus); } } - // 4. 更新项目统计表 + // V3.2: 用本次批量质控结果更新 record_summary(覆盖旧状态) + for (const [recordId, worstStatus] of recordWorstStatus) { + await prisma.iitRecordSummary.upsert({ + where: { projectId_recordId: { projectId, recordId } }, + create: { + projectId, + recordId, + lastUpdatedAt: new Date(), + latestQcStatus: worstStatus, + latestQcAt: new Date(), + formStatus: {}, + updateCount: 1 + }, + update: { + latestQcStatus: worstStatus, + latestQcAt: new Date() + } + }); + } + + // V3.2: 清理该项目旧版本日志(event_id 为 NULL 的遗留数据) + const deletedLegacy = await prisma.iitQcLog.deleteMany({ + where: { projectId, eventId: null } + }); + if (deletedLegacy.count > 0) { + logger.info('🧹 清理旧版质控日志', { projectId, deletedCount: deletedLegacy.count }); + } + + // V3.2: record 级别统计 + let passCount = 0; + let failCount = 0; + let warningCount = 0; + + for (const status of recordWorstStatus.values()) { + if (status === 'PASS') passCount++; + else if (status === 'FAIL') failCount++; + else warningCount++; + } + + const totalRecords = recordWorstStatus.size; + + // 4. 更新项目统计表(record 级别) await prisma.iitQcProjectStats.upsert({ where: { projectId }, create: { projectId, - totalRecords: uniqueRecords.size, + totalRecords, passedRecords: passCount, failedRecords: failCount, - warningRecords: warningCount + uncertainCount + warningRecords: warningCount }, update: { - totalRecords: uniqueRecords.size, + totalRecords, passedRecords: passCount, failedRecords: failCount, - warningRecords: warningCount + uncertainCount + warningRecords: warningCount } }); const durationMs = Date.now() - startTime; logger.info('✅ 事件级全量质控完成', { projectId, - uniqueRecords: uniqueRecords.size, + totalRecords, totalEventCombinations: results.length, passCount, failCount, warningCount, - uncertainCount, durationMs }); @@ -147,13 +154,14 @@ export class IitBatchController { success: true, message: '事件级全量质控完成', stats: { - totalRecords: uniqueRecords.size, + totalRecords, totalEventCombinations: results.length, passed: passCount, failed: failCount, warnings: warningCount, - uncertain: uncertainCount, - passRate: `${((passCount / results.length) * 100).toFixed(1)}%` + passRate: totalRecords > 0 + ? `${((passCount / totalRecords) * 100).toFixed(1)}%` + : '0%' }, durationMs }); diff --git a/backend/src/modules/admin/iit-projects/iitProjectController.ts b/backend/src/modules/admin/iit-projects/iitProjectController.ts index 0a1f4122..31977287 100644 --- a/backend/src/modules/admin/iit-projects/iitProjectController.ts +++ b/backend/src/modules/admin/iit-projects/iitProjectController.ts @@ -16,6 +16,7 @@ interface ProjectIdParams { interface ListProjectsQuery { status?: string; search?: string; + tenantId?: string; } interface TestConnectionBody { @@ -29,6 +30,27 @@ interface LinkKbBody { // ==================== 控制器函数 ==================== +/** + * 获取租户选项列表(供创建项目时选择租户) + */ +export async function listTenantOptions( + request: FastifyRequest, + reply: FastifyReply +) { + try { + const tenants = await prisma.tenants.findMany({ + where: { status: 'ACTIVE' }, + select: { id: true, code: true, name: true, type: true }, + orderBy: { name: 'asc' }, + }); + return reply.send({ success: true, data: tenants }); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + logger.error('获取租户列表失败', { error: msg }); + return reply.status(500).send({ success: false, error: msg }); + } +} + /** * 获取项目列表 */ @@ -37,9 +59,17 @@ export async function listProjects( reply: FastifyReply ) { try { - const { status, search } = request.query; + const { status, search, tenantId: queryTenantId } = request.query; + const user = request.user; const service = getIitProjectService(prisma); - const projects = await service.listProjects({ status, search }); + + // Phase 3 租户隔离:非 SUPER_ADMIN/IIT_OPERATOR 只能看自己租户的项目 + let effectiveTenantId = queryTenantId; + if (user && user.role !== 'SUPER_ADMIN' && user.role !== 'IIT_OPERATOR') { + effectiveTenantId = user.tenantId; + } + + const projects = await service.listProjects({ status, search, tenantId: effectiveTenantId }); return reply.send({ success: true, @@ -92,11 +122,12 @@ export async function getProject( * 创建项目 */ export async function createProject( - request: FastifyRequest<{ Body: CreateProjectInput }>, + request: FastifyRequest<{ Body: CreateProjectInput & { tenantId?: string } }>, reply: FastifyReply ) { try { const input = request.body; + const user = request.user; // 验证必填字段 if (!input.name) { @@ -113,6 +144,17 @@ export async function createProject( }); } + // Phase 3: 自动绑定 tenantId + // SUPER_ADMIN/IIT_OPERATOR: 可以指定 tenantId(创建时选择为哪个客户创建) + // PHARMA_ADMIN/HOSPITAL_ADMIN: 自动绑定自己的 tenantId + if (user) { + if (user.role === 'SUPER_ADMIN' || user.role === 'IIT_OPERATOR') { + // 可以使用请求体中指定的 tenantId,或不指定 + } else { + input.tenantId = user.tenantId; + } + } + const service = getIitProjectService(prisma); const project = await service.createProject(input); diff --git a/backend/src/modules/admin/iit-projects/iitProjectRoutes.ts b/backend/src/modules/admin/iit-projects/iitProjectRoutes.ts index ff9c1a3d..b3c49ae3 100644 --- a/backend/src/modules/admin/iit-projects/iitProjectRoutes.ts +++ b/backend/src/modules/admin/iit-projects/iitProjectRoutes.ts @@ -6,6 +6,9 @@ import { FastifyInstance } from 'fastify'; import * as controller from './iitProjectController.js'; export async function iitProjectRoutes(fastify: FastifyInstance) { + // ==================== 租户选项(创建项目时选择) ==================== + fastify.get('/tenant-options', controller.listTenantOptions); + // ==================== 项目 CRUD ==================== // 获取项目列表 diff --git a/backend/src/modules/admin/iit-projects/iitProjectService.ts b/backend/src/modules/admin/iit-projects/iitProjectService.ts index 9f537483..174bab70 100644 --- a/backend/src/modules/admin/iit-projects/iitProjectService.ts +++ b/backend/src/modules/admin/iit-projects/iitProjectService.ts @@ -17,6 +17,7 @@ export interface CreateProjectInput { redcapApiToken: string; fieldMappings?: Record; knowledgeBaseId?: string; + tenantId?: string; } export interface UpdateProjectInput { @@ -28,6 +29,7 @@ export interface UpdateProjectInput { fieldMappings?: Record; knowledgeBaseId?: string; status?: string; + isDemo?: boolean; } export interface TestConnectionResult { @@ -41,6 +43,7 @@ export interface TestConnectionResult { export interface ProjectListFilters { status?: string; search?: string; + tenantId?: string; } // ==================== 服务实现 ==================== @@ -60,6 +63,10 @@ export class IitProjectService { where.status = filters.status; } + if (filters?.tenantId) { + where.tenantId = filters.tenantId; + } + if (filters?.search) { where.OR = [ { name: { contains: filters.search, mode: 'insensitive' } }, @@ -77,10 +84,14 @@ export class IitProjectService { redcapProjectId: true, redcapUrl: true, knowledgeBaseId: true, + tenantId: true, status: true, lastSyncAt: true, createdAt: true, updatedAt: true, + tenant: { + select: { id: true, name: true, code: true }, + }, _count: { select: { userMappings: true, @@ -91,6 +102,8 @@ export class IitProjectService { return projects.map((p) => ({ ...p, + tenantName: p.tenant?.name || null, + tenantCode: p.tenant?.code || null, userMappingCount: p._count.userMappings, })); } @@ -102,6 +115,7 @@ export class IitProjectService { const project = await this.prisma.iitProject.findFirst({ where: { id, deletedAt: null }, include: { + tenant: { select: { id: true, name: true, code: true } }, userMappings: { select: { id: true, @@ -173,8 +187,9 @@ export class IitProjectService { redcapUrl: input.redcapUrl, redcapProjectId: input.redcapProjectId, redcapApiToken: input.redcapApiToken, - fieldMappings: input.fieldMappings || {}, + fieldMappings: (input.fieldMappings || {}) as any, knowledgeBaseId: input.knowledgeBaseId, + tenantId: input.tenantId || null, status: 'active', }, }); @@ -207,9 +222,10 @@ export class IitProjectService { redcapUrl: input.redcapUrl, redcapProjectId: input.redcapProjectId, redcapApiToken: input.redcapApiToken, - fieldMappings: input.fieldMappings, + fieldMappings: input.fieldMappings as any, knowledgeBaseId: input.knowledgeBaseId, status: input.status, + isDemo: input.isDemo, updatedAt: new Date(), }, }); diff --git a/backend/src/modules/admin/iit-projects/iitUserMappingController.ts b/backend/src/modules/admin/iit-projects/iitUserMappingController.ts index b86c3e5c..f3a58440 100644 --- a/backend/src/modules/admin/iit-projects/iitUserMappingController.ts +++ b/backend/src/modules/admin/iit-projects/iitUserMappingController.ts @@ -97,23 +97,21 @@ export async function createUserMapping( const { projectId } = request.params; const input = request.body; - // 验证必填字段 - 只有企业微信用户 ID 是必填的 - if (!input.wecomUserId) { + // 验证:至少提供 userId(平台用户)或 wecomUserId(企业微信用户) + if (!input.userId && !input.wecomUserId) { return reply.status(400).send({ success: false, - error: '请输入企业微信用户 ID', + error: '请选择平台用户或输入企业微信用户 ID', }); } - // 如果没有提供 systemUserId,使用 wecomUserId 作为默认值 + const fallbackId = input.wecomUserId || input.userId || 'unknown'; if (!input.systemUserId) { - input.systemUserId = input.wecomUserId; + input.systemUserId = fallbackId; } - // 如果没有提供 redcapUsername,使用 wecomUserId 作为默认值 if (!input.redcapUsername) { - input.redcapUsername = input.wecomUserId; + input.redcapUsername = fallbackId; } - // 如果没有提供 role,默认为 PI if (!input.role) { input.role = 'PI'; } diff --git a/backend/src/modules/admin/iit-projects/iitUserMappingService.ts b/backend/src/modules/admin/iit-projects/iitUserMappingService.ts index 52d2ca17..1cb883d0 100644 --- a/backend/src/modules/admin/iit-projects/iitUserMappingService.ts +++ b/backend/src/modules/admin/iit-projects/iitUserMappingService.ts @@ -12,6 +12,7 @@ export interface CreateUserMappingInput { systemUserId: string; redcapUsername: string; wecomUserId?: string; + userId?: string; role: string; } @@ -19,6 +20,7 @@ export interface UpdateUserMappingInput { systemUserId?: string; redcapUsername?: string; wecomUserId?: string; + userId?: string; role?: string; } @@ -54,6 +56,9 @@ export class IitUserMappingService { const mappings = await this.prisma.iitUserMapping.findMany({ where, + include: { + user: { select: { id: true, name: true, phone: true, email: true } }, + }, orderBy: { createdAt: 'desc' }, }); @@ -106,6 +111,7 @@ export class IitUserMappingService { systemUserId: input.systemUserId, redcapUsername: input.redcapUsername, wecomUserId: input.wecomUserId, + userId: input.userId || null, role: input.role, }, }); @@ -132,6 +138,7 @@ export class IitUserMappingService { systemUserId: input.systemUserId, redcapUsername: input.redcapUsername, wecomUserId: input.wecomUserId, + userId: input.userId, role: input.role, }, }); @@ -164,6 +171,7 @@ export class IitUserMappingService { */ getRoleOptions() { return [ + { value: 'PM', label: '项目管理员 (PM)' }, { value: 'PI', label: '主要研究者 (PI)' }, { value: 'Sub-I', label: '次要研究者 (Sub-I)' }, { value: 'CRC', label: '临床研究协调员 (CRC)' }, diff --git a/backend/src/modules/admin/services/userService.ts b/backend/src/modules/admin/services/userService.ts index ac53fc33..365ba526 100644 --- a/backend/src/modules/admin/services/userService.ts +++ b/backend/src/modules/admin/services/userService.ts @@ -50,7 +50,8 @@ export async function getUserQueryScope( switch (userRole) { case 'SUPER_ADMIN': case 'PROMPT_ENGINEER': - return {}; // 无限制 + case 'IIT_OPERATOR': + return {}; // 无限制(IIT 项目跨机构协作,需搜索所有租户用户) case 'HOSPITAL_ADMIN': case 'PHARMA_ADMIN': return { tenantId }; // 只能查看本租户 diff --git a/backend/src/modules/admin/system-kb/systemKbRoutes.ts b/backend/src/modules/admin/system-kb/systemKbRoutes.ts index 812536e9..203affd6 100644 --- a/backend/src/modules/admin/system-kb/systemKbRoutes.ts +++ b/backend/src/modules/admin/system-kb/systemKbRoutes.ts @@ -19,8 +19,8 @@ import { import { authenticate, requireRoles } from '../../../common/auth/auth.middleware.js'; export async function systemKbRoutes(fastify: FastifyInstance) { - // 所有路由都需要认证 + SUPER_ADMIN 或 ADMIN 角色 - const preHandler = [authenticate, requireRoles('SUPER_ADMIN', 'ADMIN')]; + // 认证 + 角色守卫:SUPER_ADMIN / ADMIN / IIT_OPERATOR 均可完整操作知识库 + const preHandler = [authenticate, requireRoles('SUPER_ADMIN', 'ADMIN', 'IIT_OPERATOR')]; // ==================== 知识库 CRUD ==================== diff --git a/backend/src/modules/admin/system-kb/systemKbService.ts b/backend/src/modules/admin/system-kb/systemKbService.ts index cfcef4e0..b44fb8b9 100644 --- a/backend/src/modules/admin/system-kb/systemKbService.ts +++ b/backend/src/modules/admin/system-kb/systemKbService.ts @@ -298,7 +298,7 @@ export class SystemKbService { try { // 3. 生成 OSS 存储路径并上传 - const ossKey = this.generateOssKey(kbId, doc.id, filename); + const ossKey = this.generateOssKey(kbId, doc.id, filename, kb.category); const ossUrl = await storage.upload(ossKey, fileBuffer); // 4. 更新 file_path @@ -473,15 +473,15 @@ export class SystemKbService { /** * 生成 OSS 存储路径 * - * 格式:system/knowledge-bases/{kbId}/{docId}.{ext} - * - * @param kbId - 知识库 ID - * @param docId - 文档 ID - * @param filename - 原始文件名(用于获取扩展名) + * 系统知识库: system/knowledge-bases/{kbId}/{docId}.{ext} + * IIT 项目知识库: system/iit-knowledge-bases/{kbId}/{docId}.{ext} */ - private generateOssKey(kbId: string, docId: string, filename: string): string { + private generateOssKey(kbId: string, docId: string, filename: string, category?: string | null): string { const ext = path.extname(filename).toLowerCase(); - return `system/knowledge-bases/${kbId}/${docId}${ext}`; + const prefix = category === 'iit_project' + ? 'system/iit-knowledge-bases' + : 'system/knowledge-bases'; + return `${prefix}/${kbId}/${docId}${ext}`; } /** diff --git a/backend/src/modules/iit-manager/engines/HardRuleEngine.ts b/backend/src/modules/iit-manager/engines/HardRuleEngine.ts index 8772e788..5fac36a1 100644 --- a/backend/src/modules/iit-manager/engines/HardRuleEngine.ts +++ b/backend/src/modules/iit-manager/engines/HardRuleEngine.ts @@ -52,6 +52,7 @@ export interface RuleResult { ruleName: string; field: string | string[]; passed: boolean; + skipped?: boolean; // V3.2: 字段缺失时标记为跳过 message: string; // 基础消息 llmMessage?: string; // V2.1: LLM 友好的自包含消息 severity: 'error' | 'warning' | 'info'; @@ -231,29 +232,50 @@ export class HardRuleEngine { return records.map(r => this.execute(r.recordId, r.data)); } + /** + * V3.2: 检查规则所需字段是否在数据中可用 + * + * 当所有字段均为 null/undefined/空字符串时返回 false + */ + private isFieldAvailable(field: string | string[], data: Record): boolean { + const fields = Array.isArray(field) ? field : [field]; + return fields.some(f => { + const val = data[f]; + return val !== undefined && val !== null && val !== ''; + }); + } + /** * 执行单条规则 * - * V2.1 优化:生成自包含的 LLM 友好消息 + * V3.2: 字段缺失时标记为 SKIP 而非 FAIL */ private executeRule(rule: QCRule, data: Record): RuleResult { try { - // 获取字段值 const fieldValue = this.getFieldValue(rule.field, data); - // 执行 JSON Logic - const passed = jsonLogic.apply(rule.logic, data) as boolean; + // V3.2: 字段缺失预检查 - 缺失时跳过而非判定失败 + if (!this.isFieldAvailable(rule.field, data)) { + return { + ruleId: rule.id, + ruleName: rule.name, + field: rule.field, + passed: true, + skipped: true, + message: '字段缺失,跳过检查', + severity: rule.severity, + category: rule.category, + actualValue: fieldValue, + }; + } - // V2.1: 解析期望值(从 JSON Logic 中提取) + const passed = jsonLogic.apply(rule.logic, data) as boolean; 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, diff --git a/backend/src/modules/iit-manager/engines/SkillRunner.ts b/backend/src/modules/iit-manager/engines/SkillRunner.ts index 9655400d..dab979e9 100644 --- a/backend/src/modules/iit-manager/engines/SkillRunner.ts +++ b/backend/src/modules/iit-manager/engines/SkillRunner.ts @@ -273,8 +273,9 @@ export class SkillRunner { /** * 获取要处理的记录(事件级别) * - * V3.1: 返回事件级数据,每个 record+event 作为独立单元 - * 不再合并事件数据,确保每个访视独立质控 + * V3.2: 将每个 record 的第一个 event(筛选/基线)数据 + * 合并到后续 event 中,确保纳入/排除规则的字段在所有事件可用。 + * 后续 event 自身的字段值优先(覆盖基线值)。 */ private async getRecordsToProcess( options?: SkillRunnerOptions @@ -287,19 +288,54 @@ export class SkillRunner { }>> { 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, - })); + // V3.2: 按 recordId 分组,找到每个 record 的第一个 event 作为基线 + const recordGroups = new Map(); + for (const r of eventRecords) { + if (!recordGroups.has(r.recordId)) { + recordGroups.set(r.recordId, []); + } + recordGroups.get(r.recordId)!.push(r); + } + + const results: Array<{ + recordId: string; + eventName: string; + eventLabel: string; + forms: string[]; + data: Record; + }> = []; + + for (const [recordId, events] of recordGroups) { + const baselineData = events[0]?.data || {}; + + for (const event of events) { + // 基线字段作为底层,当前事件字段覆盖(当前事件有值的字段优先) + const mergedData: Record = { ...baselineData }; + for (const [key, val] of Object.entries(event.data)) { + if (val !== undefined && val !== null && val !== '') { + mergedData[key] = val; + } + } + // 保持 REDCap 元字段为当前事件的值 + mergedData.redcap_event_name = event.data.redcap_event_name; + mergedData.record_id = event.data.record_id; + + results.push({ + recordId: event.recordId, + eventName: event.eventName, + eventLabel: event.eventLabel, + forms: event.forms, + data: mergedData, + }); + } + } + + return results; } /** @@ -542,6 +578,17 @@ export class SkillRunner { * * V2.1 优化:添加 expectedValue, llmMessage, evidence 字段 */ + /** + * V3.2: 检查规则所需字段是否在数据中可用 + */ + private isFieldAvailable(field: string | string[], data: Record): boolean { + const fields = Array.isArray(field) ? field : [field]; + return fields.some(f => { + const val = data[f]; + return val !== undefined && val !== null && val !== ''; + }); + } + private executeHardRulesDirectly( rules: QCRule[], recordId: string, @@ -553,16 +600,22 @@ export class SkillRunner { for (const rule of rules) { try { + // V3.2: 字段缺失预检查 - 缺失时跳过而非判定失败 + if (!this.isFieldAvailable(rule.field, data)) { + logger.debug('[SkillRunner] Skipping rule - field not available', { + ruleId: rule.id, + field: rule.field, + recordId, + }); + continue; + } + 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({ @@ -570,11 +623,11 @@ export class SkillRunner { ruleName: rule.name, field: rule.field, message: rule.message, - llmMessage, // V2.1: 自包含消息 + llmMessage, severity, actualValue, - expectedValue, // V2.1: 期望值 - evidence: { // V2.1: 结构化证据 + expectedValue, + evidence: { value: actualValue, threshold: expectedValue, unit: (rule.metadata as any)?.unit, diff --git a/backend/src/modules/iit-manager/routes/index.ts b/backend/src/modules/iit-manager/routes/index.ts index 60971d74..be4135ad 100644 --- a/backend/src/modules/iit-manager/routes/index.ts +++ b/backend/src/modules/iit-manager/routes/index.ts @@ -468,6 +468,39 @@ export async function registerIitRoutes(fastify: FastifyInstance) { // ============================================= const { getChatOrchestrator } = await import('../services/ChatOrchestrator.js'); + /** 路由层兜底:过滤 LLM 泄漏的 DSML/XML 工具调用标签 */ + function sanitizeLlmReply(text: string): string { + // 策略1:关键词检测 + 截断(最可靠) + // 如果文本包含 "DSML" 关键词,截取到第一个 DSML 出现之前的内容 + if (text.includes('DSML')) { + // 找到包含 DSML 的第一个 < 符号位置 + const dsmlIdx = text.indexOf('DSML'); + // 向前搜索最近的 < 符号 + let cutStart = text.lastIndexOf('<', dsmlIdx); + if (cutStart === -1) cutStart = dsmlIdx; + const before = text.substring(0, cutStart).trim(); + // 尝试找到最后一个 > 后的文本(DSML 块之后可能还有正常内容) + const lastClose = text.lastIndexOf('>'); + const after = lastClose > dsmlIdx ? text.substring(lastClose + 1).trim() : ''; + const result = (before + (after ? '\n' + after : '')).trim(); + logger.info('[sanitizeLlmReply] Stripped DSML via keyword detection', { + originalLen: text.length, + cleanLen: result.length, + cutStart, + }); + return result; + } + + // 策略2:正则兜底(处理非 DSML 格式的工具调用标签) + let cleaned = text; + cleaned = cleaned.replace(/<\s*\/?\s*function_calls?\s*>[\s\S]*?<\s*\/\s*function_calls?\s*>/gi, ''); + cleaned = cleaned.replace(/<\s*\/?\s*function_calls?\s*>/gi, ''); + cleaned = cleaned.replace(/<\s*\/?\s*invoke\s*[^>]*>/gi, ''); + cleaned = cleaned.replace(/<\s*\/?\s*parameter\s*[^>]*>/gi, ''); + cleaned = cleaned.replace(/<\s*\/?\s*tool_call\s*[^>]*>/gi, ''); + return cleaned.trim(); + } + fastify.post( '/api/v1/iit/chat', { @@ -480,15 +513,6 @@ export async function registerIitRoutes(fastify: FastifyInstance) { userId: { type: 'string' }, }, }, - response: { - 200: { - type: 'object', - properties: { - reply: { type: 'string' }, - duration: { type: 'number' }, - }, - }, - }, }, }, async (request: any, reply) => { @@ -497,15 +521,22 @@ export async function registerIitRoutes(fastify: FastifyInstance) { const { message, userId } = request.body; const uid = userId || request.user?.id || 'web-user'; const orchestrator = await getChatOrchestrator(); - const replyText = await orchestrator.handleMessage(uid, message); + const rawReply = await orchestrator.handleMessage(uid, message); + const cleanReply = sanitizeLlmReply(rawReply); + + logger.info('[WebChat] Reply sanitized', { + hadDsml: rawReply !== cleanReply, + rawLen: rawReply.length, + cleanLen: cleanReply.length, + }); return reply.code(200).send({ - reply: replyText, + reply: cleanReply || '抱歉,我暂时无法回答这个问题。', duration: Date.now() - startTime, }); } catch (error: any) { logger.error('Web chat failed', { error: error.message }); - return reply.code(500).send({ + return (reply as any).code(500).send({ reply: '系统处理出错,请稍后重试。', duration: Date.now() - startTime, }); @@ -514,6 +545,75 @@ export async function registerIitRoutes(fastify: FastifyInstance) { ); logger.info('Registered route: POST /api/v1/iit/chat'); + + // ============================================= + // My Projects API(当前用户关联的 IIT 项目) + // ============================================= + const { authenticate } = await import('../../../common/auth/auth.middleware.js'); + const { PrismaClient } = await import('@prisma/client'); + const prismaForMyProjects = new PrismaClient(); + + fastify.get( + '/api/v1/iit/my-projects', + { preHandler: [authenticate] }, + async (request: any, reply) => { + try { + const userId = request.user?.userId; + if (!userId) { + return reply.code(401).send({ error: 'Unauthorized' }); + } + + const mappings = await prismaForMyProjects.iitUserMapping.findMany({ + where: { userId }, + include: { + project: { + select: { + id: true, + name: true, + description: true, + status: true, + redcapProjectId: true, + createdAt: true, + }, + }, + }, + }); + + const projects = mappings + .filter(m => m.project.status === 'active' && !('deletedAt' in m.project && (m.project as any).deletedAt)) + .map(m => ({ + id: m.project.id, + name: m.project.name, + description: m.project.description, + status: m.project.status, + redcapProjectId: m.project.redcapProjectId, + createdAt: m.project.createdAt, + myRole: m.role, + isDemo: false, + })); + + if (projects.length === 0) { + const demoProjects = await prismaForMyProjects.iitProject.findMany({ + where: { isDemo: true, status: 'active', deletedAt: null }, + select: { + id: true, name: true, description: true, status: true, + redcapProjectId: true, createdAt: true, isDemo: true, + }, + }); + const demoList = demoProjects.map(p => ({ + ...p, myRole: 'VIEWER', isDemo: true, + })); + return reply.code(200).send({ success: true, data: demoList }); + } + + return reply.code(200).send({ success: true, data: projects }); + } catch (error: any) { + logger.error('Failed to get my projects', { error: error.message }); + return reply.code(500).send({ success: false, error: '获取项目列表失败' }); + } + } + ); + logger.info('Registered route: GET /api/v1/iit/my-projects'); } diff --git a/backend/src/modules/iit-manager/services/ChatOrchestrator.ts b/backend/src/modules/iit-manager/services/ChatOrchestrator.ts index 5328b91d..b39c736e 100644 --- a/backend/src/modules/iit-manager/services/ChatOrchestrator.ts +++ b/backend/src/modules/iit-manager/services/ChatOrchestrator.ts @@ -16,6 +16,30 @@ const prisma = new PrismaClient(); const MAX_ROUNDS = 3; const DEFAULT_MODEL = 'deepseek-v3' as const; +/** + * 过滤 LLM 输出中泄漏的 DSML / XML 工具调用标签。 + * DeepSeek 有时会在 content 中混入 DSML function_calls 等标记。 + * 使用关键词检测而非纯正则,因为 LLM 输出可能含有不可见 Unicode 字符。 + */ +function stripToolCallXml(text: string): string { + if (text.includes('DSML')) { + const dsmlIdx = text.indexOf('DSML'); + let cutStart = text.lastIndexOf('<', dsmlIdx); + if (cutStart === -1) cutStart = dsmlIdx; + const before = text.substring(0, cutStart).trim(); + const lastClose = text.lastIndexOf('>'); + const after = lastClose > dsmlIdx ? text.substring(lastClose + 1).trim() : ''; + return (before + (after ? '\n' + after : '')).trim(); + } + if (text.includes('function_calls')) { + const idx = text.indexOf('function_calls'); + let cutStart = text.lastIndexOf('<', idx); + if (cutStart === -1) cutStart = idx; + return text.substring(0, cutStart).trim(); + } + return text.trim(); +} + const SYSTEM_PROMPT = `You are a CRA Agent (Clinical Research Associate AI) monitoring an IIT clinical study. Your users are PIs (principal investigators) and research coordinators. @@ -93,15 +117,15 @@ export class ChatOrchestrator { }); if (!response.toolCalls?.length || response.finishReason === 'stop') { - const answer = response.content || '抱歉,我暂时无法回答这个问题。'; + const answer = stripToolCallXml(response.content || '') || '抱歉,我暂时无法回答这个问题。'; this.saveConversation(userId, userMessage, answer, startTime); return answer; } - // Append assistant message with tool_calls + // Append assistant message with tool_calls (strip leaked XML from content) messages.push({ role: 'assistant', - content: response.content, + content: stripToolCallXml(response.content || ''), tool_calls: response.toolCalls, }); @@ -127,7 +151,7 @@ export class ChatOrchestrator { maxTokens: 1000, }); - const answer = finalResponse.content || '抱歉,处理超时,请简化问题后重试。'; + const answer = stripToolCallXml(finalResponse.content || '') || '抱歉,处理超时,请简化问题后重试。'; this.saveConversation(userId, userMessage, answer, startTime); return answer; } catch (error: any) { diff --git a/backend/src/modules/iit-manager/services/QcReportService.ts b/backend/src/modules/iit-manager/services/QcReportService.ts index 9c9bd76b..49c11766 100644 --- a/backend/src/modules/iit-manager/services/QcReportService.ts +++ b/backend/src/modules/iit-manager/services/QcReportService.ts @@ -361,9 +361,19 @@ class QcReportServiceClass { const criticalIssues = seenCritical.size; const warningIssues = seenWarning.size; - // 计算通过率 - const passedRecords = latestQcLogs.filter(log => - log.status === 'PASS' || log.status === 'GREEN' + // V3.2: 按 record 级别计算通过率(每个 record 取最严重状态) + const statusPriority: Record = { 'FAIL': 3, 'WARNING': 2, 'UNCERTAIN': 1, 'PASS': 0, 'GREEN': 0 }; + const recordWorstStatus = new Map(); + for (const log of latestQcLogs) { + const existing = recordWorstStatus.get(log.record_id); + const currentPrio = statusPriority[log.status] ?? 0; + const existingPrio = existing ? (statusPriority[existing] ?? 0) : -1; + if (currentPrio > existingPrio) { + recordWorstStatus.set(log.record_id, log.status); + } + } + const passedRecords = [...recordWorstStatus.values()].filter( + s => s === 'PASS' || s === 'GREEN' ).length; const passRate = totalRecords > 0 ? Math.round((passedRecords / totalRecords) * 100 * 10) / 10 diff --git a/backend/src/modules/legacy-bridge/token-bridge.html b/backend/src/modules/legacy-bridge/token-bridge.html index 0e0f9934..5faad755 100644 --- a/backend/src/modules/legacy-bridge/token-bridge.html +++ b/backend/src/modules/legacy-bridge/token-bridge.html @@ -89,7 +89,7 @@ html, body { width: 100%; height: 100%; overflow: hidden; function showGrantButton() { document.querySelector('#status .spinner').style.display = 'none'; - document.getElementById('msg').textContent = '需要授权以访问旧系统'; + document.getElementById('msg').textContent = '需要浏览器授权以继续访问'; var btn = document.getElementById('btn'); btn.style.display = 'inline-block'; btn.onclick = function() { diff --git a/backend/test-output/Herrschaft 2012.pdf.md b/backend/test-output/Herrschaft 2012.pdf.md new file mode 100644 index 00000000..ae22dd71 --- /dev/null +++ b/backend/test-output/Herrschaft 2012.pdf.md @@ -0,0 +1,264 @@ +# Ginkgo biloba extract EGb $7 6 1 ^ { \textregistered }$ in dementia with neuropsychiatric features: A randomised, placebo-controlled trial to confirm the efficacy and safety of a daily dose of 240 mg + +Horst Herrschaft a,1 , Anatol Nacu b,1 , Sergey Likhachev c,1 , Ilya Sholomov d,1 , Robert Hoerr e,*,1 , Sandra Schlaefke e,1 + +a Medical Faculty, University of Cologne, Cologne, Germany +b Faculty of Psychiatry, State Medical and Pharmaceutical University “N. Testemitianu”, Chis¸ inau, Republic of Moldova - +c Department of Neurology, Republican Research and Application Center of Neurology and Neurosurgery, Minsk, Belarus +d Faculty of Neurology, Saratov State Medical University of Federal Agency of Healthcare and Social Development, Saratov, Russian Federation +e Clinical Research Department, Dr. Willmar Schwabe GmbH & Co. KG Pharmaceuticals, Willmar-Schwabe-Str. 4, 76227 Karlsruhe, Germany + +# a r t i c l e i n f o + +Article history: + +Received 22 November 2011 + +Received in revised form + +3 February 2012 + +Accepted 2 March 2012 + +Keywords: + +Dementia + +Alzheimer disease + +Vascular dementia + +Neuropsychiatric symptoms + +Ginkgo biloba + +EGb 761 + +Randomised controlled trial + +# a b s t r a c t + +A multi-centre, double-blind, randomised, placebo-controlled, 24-week trial with 410 outpatients was conducted to demonstrate efficacy and safety of a $2 4 0 \mathrm { m g }$ once-daily formulation of Ginkgo biloba extract EGb $7 6 1 ^ { \textregistered }$ in patients with mild to moderate dementia (Alzheimer’s disease or vascular dementia) associated with neuropsychiatric symptoms. Patients scored 9 to 23 on the SKT cognitive battery, at least 6 on the Neuropsychiatric Inventory (NPI), with at least one of four key items rated at least 4. Primary outcomes were the changes from baseline to week 24 in the SKT and NPI total scores. The ADCS Clinical Global Impression of Change (ADCS-CGIC), Verbal Fluency Test, Activities of Daily Living International Scale (ADL-IS), DEMQOL-Proxy quality-of-life scale and 11-point box scales for tinnitus and dizziness were secondary outcome measures. Patients treated with EGb $7 6 1 ^ { \textregistered }$ $n = 2 0 0 ^ { \cdot }$ improved by $2 . 2 \pm 3 . 5$ points (mean $\pm \ s \mathsf { d }$ ) on the SKT total score, whereas those receiving placebo ${ \mathit { n } } = 2 0 2 { \mathit { \Omega } }$ ) changed only slightly by $0 . 3 \pm 3 . 7$ points. The NPI composite score improved by $4 . 6 \pm 7 . 1$ in the EGb $7 6 1 ^ { \textregistered }$ -treated group and by $2 . 1 \pm 6 . 5$ in the placebo group. Both drug-placebo comparisons were significant at $p < 0 . 0 0 1$ . Patients treated with EGb $7 6 1 ^ { \textregistered }$ also showed a more favourable course in most of the secondary efficacy variables. In conclusion, treatment with EGb $7 6 1 ^ { \textregistered }$ at a once-daily dose of 240 mg was safe and resulted in a significant and clinically relevant improvement in cognition, psychopathology, functional measures and quality of life of patients and caregivers. + +$©$ 2012 Elsevier Ltd. All rights reserved. + +# 1. Objectives and background + +The ageing of populations and the resulting increase in people’s risk for both Alzheimer’s disease (AD) and vascular dementia (VaD) has significant implications worldwide. The forecast indicates a considerable increase in the number of demented elderly from 25 million in the year 2000 to 63 million in 2030 (Wimo et al., 2003). In spite of the urgent need for treatments (Lindesay et al., 2010), extensive research and enormous investments in research, it still remains unclear what causes AD (Daviglus et al., 2010). This seriously hampers the + +development of curative new drugs and has led to the failure of recently tested, putatively causal treatments (Holmes et al., 2008; Green et al., 2009). In the absence of causal treatments for AD, and faced with fragmentary knowledge about AD pathogenesis together with evidence of multiple and common risk factors for AD and VaD (Daviglus et al., 2010; Förstl, 2003; Newman et al., 2005) as well as a large proportion of dementias with mixed pathologies (Schneider et al., 2007), substances that interfere with both AD and vascular pathology and that have previously proven effective in the treatment of dementia syndromes should be considered as therapeutic options. + +The Ginkgo biloba extract EGb $7 6 1 ^ { \textregistered }$ interferes with various pathomechanisms relevant to dementia, such as $\mathsf { A } \beta$ aggregation and toxicity (Wu et al., 2006), mitochondrial dysfunction (Abdel-Kader et al., 2007) and compromised hippocampal neurogenesis (Tchantchou et al., 2007). EGb $7 6 1 ^ { \textregistered }$ decreases blood viscosity and + +enhances microperfusion (Költringer et al., 1995). In a recent study, EGb $7 6 1 ^ { \textregistered }$ specifically increased dopamine levels in rat pre-frontal cortex, a region involved in working memory and monitoring of actions (Yoshitake et al., 2010). Recent reviews and meta-analyses of randomised controlled trials concluded that EGb $7 6 1 ^ { \textregistered }$ is effective in the treatment of dementia, including Alzheimer’s disease, vascular dementia and mixed forms (Weinmann et al., 2010; Janssen et al., 2010). The drug seems to be particularly useful when dementia is accompanied by neuropsychiatric symptoms (NPS) (Weinmann et al., 2010; Ihl et al., 2010; Schneider et al., 2005). Inconclusive results have been achieved in prevention trials completed so far (DeKosky et al., 2008; Vellas et al., 2010), yet prevention is something clearly distinct from symptomatic treatment. + +The objective of the present clinical trial was to confirm the findings of a preceding study of the same dosage regimen of EGb $7 6 1 ^ { \textregistered }$ in dementia (Ihl et al., 2011) and to further substantiate clinically relevant treatment effects of the once-daily formulation containing $2 4 0 ~ \mathrm { m g }$ of G. biloba extract EGb $7 6 1 ^ { \textregistered }$ on cognition and psychopathology in patients with AD and VaD, both associated with NPS. + +# 2. Patients and methods + +The clinical trial was conducted in accordance with the Declaration of Helsinki, the harmonized tripartite guideline for Good Clinical Practice (GCP) issued by the International Conference on Harmonization (ICH, 1996) and the requirements of the local legislation. The protocol was approved by the National Ethics Committee of the Republic of Moldova, the Ethics Committee of the Republican Clinical Mental Hospital Minsk, Republic of Belarus, and the Ethics Committee under the Federal Service on Surveillance in Healthcare and Social Development of the Russian Federation. Oral and written informed consent was obtained from all patients and caregivers before any trial-related procedures were undertaken. Investigators and clinical staff involved in the clinical trial underwent comprehensive briefing on the provisions of applicable laws and international GCP standards by experienced clinical research professionals. + +# 2.1. Clinical trial population + +Outpatients were recruited at 17 psychiatry or neurology clinics in three countries with Russian-speaking populations (Republic of Belarus, Republic of Moldova, and Russian Federation). These countries were chosen, because cholinesterase inhibitor use was not common and the quick recruitment of untreated patients with dementia speaking the same language to a placebo-controlled study was possible. Patients were eligible for this clinical trial if they were at least 50 years of age (no upper limit) and were suffering from mild to moderate AD or VaD. The diagnosis was based on the following criteria: a) probable AD in accordance with the NINCDS-ADRDA criteria (McKhann et al., 1984), b) possible AD with cerebro-vascular disease (CVD) as defined by the NINDS-AIREN criteria (Román et al., 1993), or c) probable VaD according to NINDS-AIREN. Symptoms of dementia had to have been present for at least 6 months. An MRI scan, consistent with the diagnosis of AD, VaD or AD with CVD and without evidence of other brain lesions, recorded not longer than 1 year prior to the screening visit had to be available. + +The “Test for Early Detection of Dementia with Discrimination from Depression” (TE4D) (Ihl et al., 2000; Mahoney et al., 2005) was used as a screening instrument and to verify the presence of cognitive impairment in at least two domains. This was preferred to the “Mini-Mental State Examination” (MMSE) because of its higher + +sensitivity and specificity to discriminate between demented and non-demented subjects (Mahoney et al., 2005; Ihl et al., 2005). A total score of 35 or below, i.e. in the range indicating dementia, was required for inclusion. Patients were shown to have mild to moderate dementia as demonstrated by a total score from 9 to 23 (both inclusive) on the SKT test battery (Syndrom-Kurztest) (Erzigkeit, 1992; Kim et al., 1993), which roughly corresponds to a range from 14 to 25 on the MMSE or 17 to 35 on the cognitive subscale of the Alzheimer’s Disease Assessment Scale (ADAS-cog) (Ihl et al., 1999). The “Clock-Drawing Test” (CDT) (Sunderland et al., 1989) was used as a second screening instrument, the score of which had to be below 6. Patients were required to have a total score of at least 6 on the 12-item Neuropsychiatric Inventory (NPI) (Cummings, 1997), with at least one of the items “anxiety”, “apathy/ indifference”, “irritability/lability” or “depression/dysphoria” rated 4 or higher. Severe depression was excluded by requiring a score below 20, with item 1 rated no higher than 3 on the 17-item “Hamilton Rating Scale for Depression” (HAMD) (Hamilton, 1960). + +The presence of a caregiver/close relative who was willing to ensure that the patient complied with all aspects of the protocol was required: This included ensuring regular drug intake, reporting adverse events, accompanying the patient to the clinical visits and providing information about the patient. This person had to have regular personal contact with the patient on at least 4 days a week and to participate actively in caring for the patient in order to be able to provide information on the patient’s behaviour and ability to perform activities of daily living. + +Patients were excluded from the clinical trial if they had any other type of dementia or neurological disorder, major short-term fluctuations in symptom severity, current or recent major depression or other psychiatric disorder, severe or insufficiently controlled cardiovascular, renal, or hepatic disorder, diabetes, anaemia, or thyroid dysfunction. Patients suffering from active malignant disease, HIV, syphilis infection or gastrointestinal disease with uncertain absorption were not acceptable. Treatment with other anti-dementia drugs, cognitive enhancers, cholinergic, anticholinergic or haemorheologically active drugs, anti-Parkinson drugs or Ginkgo products was prohibited during the clinical trial and for at least 8 weeks preceding randomisation. + +# 2.2. Clinical trial design and intervention + +This was a double-blind, parallel-group, multi-centre clinical trial. A screening period (up to 4 weeks) was required for examinations and washout of discontinued medications, followed by a 24-week treatment period. Randomisation (1:1, stratified by centre and in blocks of four), was performed with the aid of a validated computer program that linked random numbers to drug or placebo treatment, respectively. The sealed randomisation code was stored safely at the biometrics unit, and the length of randomisation blocks within which numbers of drug and placebo were balanced was not disclosed to investigators. Upon successful screening, each patient was assigned the treatment package with the lowest drug number still available at a site. Active drug and placebo tablets were indistinguishable in appearance, packaging and labelling. Patients were requested to take one tablet in the morning. Drug dispensation and return was handled by persons not involved otherwise in the conduct of the clinical trial, usually the hospital pharmacists. The investigational product, EGb 761,2 is a dry extract from G. biloba leaves (35e67:1), extraction solvent: acetone $6 0 \%$ (w/w). The extract is adjusted to $2 2 . 0 { - } 2 7 . 0 \%$ ginkgo + +flavonoids calculated as ginkgo flavone glycosides and $5 . 0 { - } 7 . 0 \%$ terpene lactones consisting of $2 . 8 { - } 3 . 4 \%$ ginkgolides A, B, C and $2 . 6 { - } 3 . 2 \%$ bilobalide, and contains less than 5 ppm ginkgolic acids. In this trial, a once-daily formulation containing $2 4 0 ~ \mathrm { m g }$ of EGb $7 6 1 ^ { \textregistered }$ per tablet was used. + +# 2.3. Outcome measures + +Primary efficacy measures were the SKT, a 9-item cognitive test battery, the score of which ranges from 0 to 27, and the 12-item NPI, which assesses frequency and severity of neuropsychiatric symptoms (composite score) and ranges from 0 to 144. On both scales, higher scores indicate more severe impairment. The SKT was used for cognitive testing because it is well validated across many cultures and languages, including Russian (Lehfeld et al., 1997a,b). For memory testing it uses images instead of word lists to circumvent the problem of creating equivalent word lists in different languages, and in the stage of dementia scores are equivalent across countries (Lehfeld et al., 1997a,b), which is not generally the case for the cognitive subscale of the Alzheimer’s Disease Assessment Scale (Verhey et al., 2004). An NPI version in Russian language was used that had been culturally and linguistically validated following standard procedures. + +The NPI caregiver distress score (range 0e60); the Clinical Global Impression of Change (CGIC) as adapted by the Alzheimer’s Disease Cooperative Study (ADCS) (Schneider et al., 1997); the Alzheimer’s Disease Activities of Daily Living International Scale (ADL-IS) (Reisberg et al., 2001); rates of clinically meaningful response in primary outcomes; the DEMQOL-Proxy, which is a health-related quality-of-life scale for people with dementia (Smith et al., 2005); and the Verbal Fluency Test (animal fluency, as adapted by Mahoney et al. (2005)) were chosen as secondary efficacy variables. Patient self-ratings of presence and severity of dizziness and tinnitus were documented using 11-point box scales, 0 representing absence and 10 indicating extreme severity of a symptom. All cognitive tests and all interviews, except the ADCS-CGIC, were administered by the investigators and sub-investigators all of whom were psychiatrists or neurologists. The ADCS-CGIC ratings were done by independent physicians or psychologists who were not otherwise involved in the trial or the treatment of the patients and who had no knowledge of the results of other ratings and cognitive tests. Investigators and investigational staff were trained in the administration of tests and scales by an experienced geriatric psychiatrist and neuropsychologist. This involved demonstration and exercises using original scales and test kits. All efficacy assessments were performed at baseline, at week 12 and 24. + +Safety was evaluated by physical examination, electrocardiography and laboratory tests at screening and week 24. Adverse events were recorded at all visits and during phone calls at weeks 6 and 18. + +# 2.4. Sample size, data sets and statistical analysis + +The trial was conducted to confirm the clinical efficacy of a oncedaily formulation of 240 mg EGb $7 6 1 ^ { \textregistered }$ in patients suffering from AD, VaD or AD with vascular components, all with neuropsychiatric symptoms. In the confirmatory analysis, this was done for both the primary cognitive efficacy variable “change of the SKT total score” and the primary variable for the assessment of the effects on neuropsychiatric symptoms “change of the NPI total composite score (items 1e12)” between baseline and week 24. The intersection-union principle (Berger, 1982) was used to test the hypothesis concerning the difference between $\mathsf { E G b } ^ { \mathbb { B } }$ 761 and placebo with respect to both endpoints. Thus, a difference between + +EGb $7 6 1 ^ { \textregistered }$ and placebo could be established at a two-sided type I error rate of $\alpha = 0 . 0 5$ if both single null-hypotheses (no difference in the change of the SKT total score, no difference in the NPI composite score) were rejected at level $\alpha = 0 . 0 5$ (two-sided). The two single null-hypotheses were tested applying an analysis of covariance model with the factors treatment and centre and the baseline value of the respective variable as a covariate. + +The confirmatory analysis was primarily based on the full analysis set (FAS) including all patients who received randomised clinical trial medication at least once and having at least one measurement of the primary efficacy parameters during the randomised treatment period. Missing Data were handled by the last observation carried forward method (LOCF-method). + +A sensitivity analysis (per protocol analysis) was performed including all patients of the full analysis set without major protocol violations (per protocol set, PPS). All patients who received randomised clinical trial medication at least once were analysed with regard to safety measures (safety analysis set, SAF). + +The sample size was determined to provide a power of at least $9 5 \%$ for the rejection of the two null-hypotheses for each of the primary variables SKT and NPI at a type I error rate of $\alpha = 0 . 0 5$ . This assures a power of at least $9 0 \%$ for the simultaneous rejection of both null-hypotheses. The sample size calculation was based on a common standard deviation of 4 points for the change of the SKT total score and 7 points for the change of the NPI total score, each between baseline and week 24. The sample size was assumed to be high enough to detect a clinically relevant difference between the treatment groups of at least 2 points with respect to change of the SKT total score and a difference of at least 2.5 points with respect to the change of the NPI total score. Under these assumptions, $2 \times 2 0 5 = 4 1 0$ patients were needed for the full analysis set in order to fulfil the power requirements given above if the two sample ttest were to be used for the analysis. + +# 3. Results + +# 3.1. Patient sample + +Of 472 patients who were screened for eligibility, 410 were randomised and 62 failed screening and did not take any trial medication. All randomised patients received the allocated treatment at least once and were included in the SAF. The first patient entered the clinical trial in March 2008, and the last patient was completed in October 2009. + +Eight patients of the SAF could not be included in the FAS due to missing efficacy assessments after baseline evaluation. Thus the FAS, on which the primary statistical analysis was based, consisted of 402 patients. The PPS comprised 371 patients without relevant protocol deviations. Since both FAS and PP analyses yielded nearly identical results, only those for the FAS analysis are provided. Patient disposition is depicted in Fig. 1, demographic characteristics and baseline data are summarised in Table 1. No clinically relevant differences were detected between the treatment groups. + +Most patients had concomitant diseases at baseline, the most common of which were vascular disorders (EGb $7 6 1 ^ { \textregistered }$ : $8 6 \%$ placebo: $8 5 \%$ ; mainly hypertension and atherosclerosis), nervous system disorders (EGb $7 6 1 ^ { \textregistered }$ : $8 5 \%$ , placebo: $8 0 \%$ ; mainly dizziness and CVD), cardiac disorders (EGb $7 6 1 ^ { \textregistered }$ : $5 5 \%$ , placebo: $45 \%$ ; mainly coronary artery disease), and ear and labyrinth disorders (EGb $7 6 1 ^ { \textcircled { \mathrm {8 } } }$ : $5 2 \%$ , placebo: $4 9 \%$ ; mainly tinnitus). More than $7 5 \%$ of the patients were taking concomitant medications when starting trial medication, with agents acting on the renineangiotensin system (EGb $7 6 1 ^ { \textregistered }$ : $5 4 \%$ , placebo: $4 8 \%$ ), antithrombotic agents (EGb $7 6 1 ^ { \textregistered }$ : $2 9 \%$ , placebo: $23 \%$ ) and beta blocking agents (EGb $7 6 1 ^ { \textregistered }$ : $1 8 \%$ placebo: $1 8 \%$ ) being the most frequently taken types of drugs. + +![](images/31ce771e8fed163e11c45a62e6295032a74f2abb1c6608e254529ade7cae2d19.jpg) +Fig. 1. Patient disposition and flow. + +Table 1 Baseline characteristics of the full analysis set; means $\pm$ standard deviations and $p$ - values of the two-sided t-test or numbers (percent) and $p$ -values of the two-sided Chi-square test. + +
EGb 761®(N = 200)Placebo(N = 202)p-value
Sex female139 (69.5)140 (69.3)0.966
Age [y]65.1 ± 8.864.9 ± 9.40.870
Weight [kg]75.7 ± 13.372.5 ± 12.80.017
Height [cm]166.9 ± 7.8166.1 ± 7.40.282
Duration of memory problems [y]3.2 ± 2.23.4 ± 2.80.815
Type of dementiaProbable AD107 (54)101 (50)0.781
Possible AD with CVD73 (36)79 (39)
Probable VaD20 (10)22 (11)
SKT total score15.1 ± 4.115.3 ± 4.20.593
NPI total score16.8 ± 6.916.7 ± 6.40.961
NPI caregiver distress score10.2 ± 5.310.1 ± 5.10.883
Verbal Fluency Test7.6 ± 1.97.6 ± 1.90.769
TE4D cognitive scorea29.4 ± 4.028.9 ± 4.80.234
TE4D depression scorea3.9 ± 3.34.0 ± 3.60.814
ADL-IS mean score1.7 ± 0.61.8 ± 0.60.573
DEMQOL-Proxy total score85.7 ± 10.686.0 ± 10.30.810
11-point box scale tinnitus1.4 ± 1.81.4 ± 1.80.996
11-point box scale dizziness2.0 ± 1.81.8 ± 1.80.280
+ +a TE4D scores at screening, the test was not repeated at baseline. + +Overall, the treatment groups were well balanced with regard to medical history and concomitant medication. + +Use of psychoactive drugs was rare: Three patients took antipsychotics (EGb $7 6 1 ^ { \textregistered }$ : 2, placebo 1) for short periods (up to 14 days), two patients (both on EGb $7 6 1 ^ { \textregistered }$ ) took anxiolytics for two to four days, and six patients (EGb $7 6 1 ^ { \textregistered }$ : 5, placebo: 1) occasionally took sedatives (only valerian/valerate products). Prior short-term treatment with nootropics or cholinesterase inhibitors (ChEI) was reported for 13 patients (ChEI: 1) randomised to EGb $7 6 1 ^ { \textregistered }$ and for 24 patients (ChEI: 3) of the placebo group. Caregivers were immediate family (adult child, $4 4 \%$ ), spouses $( 3 1 \% )$ or other relatives or friends $( 2 5 \% )$ . + +Compliance with the investigational treatment regimen, estimated from the number of unused tablets returned at week 12 and week 24, turned out to be very good during the whole double-blind treatment period (EGb $7 6 1 ^ { \textregistered }$ : $9 9 . 6 \pm 7 . 3 \%$ ; placebo: $9 9 . 8 \pm 1 . 8 \%$ ). + +# 3.2. Primary outcome measures + +Patients treated with EGb $7 6 1 ^ { \textregistered }$ improved in both cognitive test performance and neuropsychiatric symptoms, whereas there was little change in the placebo group. This resulted in statistically significant superiority of EGb $7 6 1 ^ { \textregistered }$ over placebo in both primary outcome measures $p < 0 . 0 0 1$ , Table 2, Figs. 2 and 3). + +In individual patients, a decrease in the SKT total score by at least 3 points, which is fairly equivalent to a 4-point decrease in the ADAS-cog score, is considered as clinically meaningful (Ihl et al., 1999; Rogers et al., 1998), and so is a decrease by 4 points or more in the NPI total score (Mega et al., 1999). So defined clinically significant responses were observed more frequently among patients treated with EGb $7 6 1 ^ { \textregistered }$ (SKT: $4 3 \%$ , NPI $5 7 \%$ ) than in those taking placebo (SKT: $23 \%$ , NPI: $3 9 \%$ ). The differences between treatment groups were statistically significant for both comparisons $( p < 0 . 0 0 1 )$ ). + +Improvements and response rates under EGb $7 6 1 ^ { \textregistered }$ treatment were similar for patients with probable AD and those with CVD (i.e. possible AD with CVD or probable VaD), whereas placebo response was slightly higher in patients with CVD. Due to the well-known low sensitivity of the NINDS/AIREN criteria for probable vascular dementia this subgroup was too small to perform meaningful analyses. + +A quantitative interaction of treatment by centre was observed (Källén, 1997). In 14 of 15 centres analysed (at least 16 patients each; 3 smaller centres were pooled) the effects of EGb $7 6 1 ^ { \textregistered }$ were more pronounced than in the placebo group for at least one of the primary efficacy parameters. + +Table 2 +Changes from baseline in primary and secondary outcome measures, score at week 24 for ADCS-CGIC; full analysis set; means $9 5 \%$ confidence intervals); $p$ -values of the ANCOVA for the primary efficacy variables and of two-sided t-test for secondary efficacy variables. + +
EGb 761® (N = 200)Placebo (N = 202)p-value
SKT total score-2.2 (-2.7; -1.8)-0.3 (-0.9; 0.2)<0.001a
NPI total score-4.6 (-5.6; -3.6)-2.1 (-3.0; -1.2)<0.001a
NPI caregiver distress score-2.4 (-3.1; -1.8)-0.5 (-1.1; 0.0)<0.001
ADCS-CGIC3.1 (3.0; 3.3)3.8 (3.6; 4.0)<0.001
ADL-IS overall mean score-0.11 (-0.16; -0.06)0.04 (0.0; 0.08)<0.001
DEMQOL-Proxy total score3.5 (2.2; 4.7)1.5 (0.3; 2.7)0.027
Verbal Fluency Test0.7 (0.3; 1.0)0.0 (-0.3; 0.3)<0.01
TE4D cognitive scoreb2.9 (2.2; 3.6)0.7 (0.2; 1.3)<0.001
11-point box scale dizziness-0.6 (-0.8; -0.5)-0.2 (-0.4; -0.1)<0.001
11-point box scale tinnitus-0.4 (-0.5; -0.2)-0.3 (-0.4; -0.1)0.31
+ +a $p$ -value of the analysis of covariance with treatment and centre as factors and the baseline value as covariate. +b TE4D difference to screening, the test was not done at baseline. + +![](images/442e8ecee2fc57a39edeb881a5ba75065d32f42adfc21e6a800cd046197bd0e3.jpg) +Fig. 2. Change in SKT total score from baseline to week 24; full analysis set $n = 4 1 0 ^ { \cdot }$ ); means and $9 5 \%$ confidence intervals; $^ { * * } p < 0 . 0 0 1$ , ANCOVA with treatment and centre as factors and the baseline value as covariate. + +# 3.3. Secondary outcome measures + +There was a consistent statistically and clinically relevant superiority of EGb $7 6 1 ^ { \textcircled { \mathrm {8 } } }$ over placebo across most of the secondary outcome measures (Table 2), including improved ability to cope with the demands of everyday living, improved quality of life and clinicians’ global judgement (Table 2, Fig. 4). + +# 3.4. Safety and tolerability + +Between baseline and two days after the end of the treatment period, 119 adverse events (AEs) were reported by 91 patients $( 4 4 . 4 \% )$ treated with EGb $7 6 1 ^ { \textregistered }$ and 126 AEs were documented for 82 patients $( 4 0 . 0 \% )$ of the placebo group. AEs observed for at least three per cent of patients in either treatment group are summarized in Table 3. No major differences were discernible, except for dizziness, which was more than three times more frequent in the placebo group $( 7 . 3 \% )$ than in the active treatment group $( 2 . 0 \% )$ . For 59 AEs in 50 patients of the EGb $7 6 1 ^ { \textregistered }$ group and for 61 AEs in 46 patients of the placebo group a causal relationship could not be ruled out in double-blind assessment. There was no event of bleeding or impaired blood clotting in the group treated with EGb $7 6 1 ^ { \circledast }$ . + +Three serious adverse events (SAEs) were observed during treatment with active drug: a lethal cardiac arrest due to chronic heart failure in a patient suffering from multiple illnesses; a lethal + +![](images/1f7076ae5a9d375d5bfb1e292569320dda89a4478aef43d3f4b1f22dd8ad9544.jpg) +Fig. 3. Change in NPI composite score from baseline to week 24; full analysis set $n = 4 1 0 ^ { \cdot }$ ); means and $9 5 \%$ confidence intervals; $\stackrel { * } { p } < 0 . 0 1$ , $^ { * * } p < 0 . 0 0 1$ , ANCOVA with treatment and centre as factors and the baseline value as covariate. + +![](images/6e99f0941dde41aac94d733173aa43c9659dcdb866332c51ca71c3b2c6e86865.jpg) +Fig. 4. Clinical global impression of change (ADCS-CGIC) at week 24; full analysis set $n = 4 1 0 ^ { \cdot }$ ); $p < 0 . 0 0 1$ , Chi-square test. + +ischaemic infarction in the region of the terminal branches of the middle and posterior cerebral arteries in a patient with a history of diabetes mellitus, hypertension, atherosclerosis, myocardial infarction and a previous stroke; and a transitory ischaemic attack in the region of the left medial cerebral artery in a patient with insufficiently controlled arterial hypertension. One SAE, a death due to pneumonia, probably caused by aspiration due to a bulbar syndrome, was reported in the placebo group. All SAEs were judged by the investigators to be related to concomitant conditions and unrelated to the trial medication. + +The incidence and the profile of AEs reflect the typical pattern of events expected in elderly patients with dementia and cerebrovascular disease. The most common adverse event potentially related to treatment was headache, which is an unspecific symptom that is often reported by patients in any study, yet the frequency of headaches was slightly higher in the placebo group (EGb $7 6 1 ^ { \textregistered }$ : $5 . 9 \%$ , placebo: $6 . 3 \%$ ). + +Physical and neurological examination, 12-lead ECG, blood pressure, heart rate, and laboratory tests did not reveal any conspicuous or systematic changes under EGb $7 6 1 ^ { \textregistered }$ treatment. + +# 4. Discussion + +The present clinical trial demonstrates the efficacy of the oncedaily administration of EGb $7 6 1 ^ { \textregistered }$ at a dose of $2 4 0 ~ \mathrm { m g }$ in the treatment of dementia. Active treatment was significantly superior to placebo in improving patients’ cognitive performance and neuropsychiatric symptoms which were the primary outcomes of the study. Using the NPI as co-primary outcome measure, rather than a functional or global assessment was a logical consequence of the + +Table 3 Adverse events reported by at least $3 \%$ of patients of either treatment group with onset during and within two days after randomised treatment (safety analysis set); number and per cent of patients with adverse events. + +
EGb 761® (N = 205)Placebo (N = 205)EGb 761® – placebo Diff. of rates with 95% CIa
N%N%
Headache157.3178.3-1.0 [-6.4; 4.4]
Dizziness42.0157.3-5.4 [-9.9; -1.3]
Viral respiratory tract infection83.962.91.0 [-2.9; 4.9]
Hypertension52.483.9-1.5 [-5.3; 2.2]
Somnolence83.942.02.0 [-1.6; 5.8]
Upper abdominal pain52.473.4-1.0 [-4.7; 2.6]
+ +a $9 5 \%$ confidence intervals calculated according to Newcombe (1998), Method 10. + +choice of the target population, i.e. patients with clinically significant NPS. The clinical significance of the drug effects was demonstrated by the consistency of both primary outcomes and corroborated by the secondary outcomes, including functional abilities, global assessment, quality of life and response rates. As a consequence, the distress experienced by caregivers due to the patients’ aberrant behaviours was alleviated. + +The results are in line with those from former well-controlled clinical trials of EGb $7 6 1 ^ { \textregistered }$ which, according to the most recent reviews and meta-analyses, demonstrate efficacy and clinical benefits of EGb $7 6 1 ^ { \textregistered }$ in the treatment of dementia (Weinmann et al., 2010; Janssen et al., 2010). In particular, they are consistent with the findings from a recently published, independent trial using the same once-daily formulation of EGb $7 6 1 ^ { \textregistered }$ and an identical design (Ihl et al., 2011), thus confirming efficacy and safety of the 240 mg once-daily regimen. + +In contrast to many anti-dementia drug trials performed during the 1990s, we did not exclude patients with clinically significant NPS, but decided to specifically select such patients. Taking into account that up to $8 0 \%$ of patients with dementia have NPS, with depression, apathy and anxiety being the most prevalent symptoms (Di Iulio et al., 2010; Steinberg et al., 2008), our patient sample was probably more representative of dementia patients seen in everyday practice. There have been two concerns about patients with NPS in anti-dementia drug trials: improving cognitive performance with drugs that have anti-depressant rather than cognition-enhancing effects and interference of the effects of psychoactive drugs with efficacy assessments. The latter is negligible, since only a low rate of participants received an additional psychopharmacological medication. The former is unjustified, because Powlishta et al. (2004) demonstrated that in patients with dementia, cognitive performance is determined by the severity of dementia and is not further aggravated by depression. Hence, improvement in cognitive function is not to be expected by antidepressant treatment alone. + +The low average age of the patients and the enrolment of outpatients only might limit the generalizability of our findings. The average age reflects the life expectancy in Eastern European countries, which is clearly lower than in the Western world. However, the considerable burden of co-morbidity found in our patient sample seems to compare fairly well with that of older patients with dementia seen in Western countries. Moreover, the age range extends to 87 years and there was no relationship between age and treatment outcomes in this trial, suggesting that the study results were not biased by the patients’ age. A considerable proportion of patients with dementia live in nursing homes, but due to the limited requirements and possibilities to perform instrumental activities of daily living in a nursing home, ADL scales used in studies of outpatients would not be feasible for inpatients. Taking into account, however, that caregiver distress related to NPS is a significant predictor of nursing home placement (de Vugt et al., 2005), our outpatient sample was probably not fundamentally different from nursing home patients. + +According to Bornhöft et al. (2006), one of the fundamental questions regarding the external validity of a clinical trial is to what extent the inclusion and exclusion criteria define the “everyday or target” population. The patients included in the present study were selected by the widely used diagnostic criteria proposed by the NINCDS/ADRDA (McKhann et al., 1984) and NINDS/AIREN (Román et al., 1993) working groups. Due to the rare use of cholinesterase inhibitors and memantine in Eastern European countries, the study sample could be drawn from the broad spectrum of dementia patients normally referred to investigators’ clinics rather than from minority groups of patients who either did not respond to or did not tolerate other treatments. Since the pharmacodynamics of EGb + +$7 6 1 ^ { \textregistered }$ interfere with both Alzheimer’s and cerebro-vascular pathology, there was no need to exclude patients with VaD or those with AD and CVD, who represent the largest proportion of patients with dementia (Schneider et al., 2007; Korczyn, 2002). For methodological reasons, it is not possible to admit all patients seeking treatment for dementia to a phase-III study. However, by recruiting our study sample from the vast majority of dementia patients who have NPS (Di Iulio et al., 2010; Steinberg et al., 2008), by admitting the large proportion of patients with cerebro-vascular pathology (Schneider et al., 2007; Korczyn, 2002), and by running the study in a region where the recruitment of unmedicated dementia patients was possible, we achieved a sample that can be considered to be representative of the “everyday or target” population. Hence, there is reason to assume that our findings are relevant to the patients with mild to moderate dementia encountered in daily practice. + +The validity of outcome measures most of which were developed in Western countries needs consideration. As mentioned above, the SKT battery, which may not be used as commonly as the ADAS-cog, is particularly feasible for multi-national research programs, because it uses images instead of word lists to test memory functions. The test has been validated in cross-cultural studies including Russian-speaking populations (Lehfeld et al., 1997a,b), and SKT total scores are highly correlated with ADAScog scores, suggesting that to a large extent both tests measure the same abilities (Ihl et al., 1999). The NPI is a widely used rating scale for the evaluation of severity and treatment-related changes of NPS that has been adapted linguistically and culturally in accordance with standard procedures for administration in Russian-speaking countries. To further demonstrate the clinical relevance of cognitive improvement in terms of activities of daily living and a global rating, as required by current guidelines (EMEA, 2008), we also administered the ADL-IS and the ADCS-CGIC, which consistently indicated superiority of EGb $7 6 1 ^ { \textregistered }$ over placebo. The ADL-IS was developed and validated in an international research project in which, among Japan, Western European and North American countries, Russia was involved (Lehfeld et al., 1997b). The 7-point scale of the ADCS-CGIC as well as the instructions and key words of the working sheets, although easy to understand, were adapted by forward and backward translation and reconciliation by a bilingual psychiatrist. Altogether, the assessment instruments used in the present study can be regarded as feasible and valid for use in Russian-speaking countries. + +Overall, the present clinical trial corroborates findings from previous studies that showed G. biloba extract EGb $7 6 1 ^ { \textcircled { \mathbb { \mathbb { B } } } } ,$ , and specifically at a 240 mg daily dose, to be both safe and effective in the treatment of patients with dementia associated with neuropsychiatric features. + +# Role of funding source + +Employees of the sponsor (RH, SS) were involved in the planning and conduct of the trial, in the data analysis and interpretation as well as in the preparation of the manuscript. + +# Contributors + +H. Herrschaft was involved in the development of the trial protocol and in quality assurance measures. A. Nacu was the Coordinating Investigator, S. Likhachev and I. Sholomov were investigators in the trial and were involved in data collection. R. Hoerr designed the study and wrote the trial protocol, S. Schlaefke was in charge of data management and analysis. All authors contributed to and have approved the final manuscript. + +# Author disclosure + +H. Herrschaft has consulted for Dr. Willmar Schwabe GmbH & Co. KG. A. Nacu, S. Likhachev and I. Sholomov participated in the study as investigators and received investigator fees. R. Hoerr and S. Schlaefke are employees of Schwabe receiving fixed salaries. + +# Acknowledgements + +Dr. Willmar Schwabe GmbH & Co. KG Pharmaceuticals was the sponsor of the clinical trial. + +# References + +Abdel-Kader R, Hauptmann S, Keil U, Scherping I, Leuner K, Eckert A, et al. Stabilization of mitochondrial function by Ginkgo biloba extract (EGb $7 6 1 ^ { \textcircled { \mathbb { B } } }$ ). Pharmacological Research 2007;56:493e502. +Berger RL. Multiparameter hypothesis testing and acceptance sampling. Technometrics 1982;24:295e300. +Bornhöft G, Maxion-Bergemann S, Wolf U, Kienle GS, Michalsen A, Vollmar HC, et al. Checklist for the qualitative evaluation of clinical studies with particular focus on external validity and model validity. BMC Medical Research Methodology 2006;6:56. +Cummings JL. The Neuropsychiatric Inventory: assessing psychopathology in dementia patients. Neurology 1997;48(Suppl. 6):S10e6. +Daviglus ML, Bell CC, Berrettini W, Bowen PE, Connolly ES, Cox NJ, et al. National institutes of health state-of-the-science conference statement: preventing Alzheimer’s disease and cognitive decline. NIH Consensus and State-of-the-Science Statements 2010;27:1e30, http://consensus.nih.gov/2010/docs/alz/ ALZ_Final_Statement.pdf [accessed 28.10.11]. +DeKosky ST, Williamson JD, Fitzpatrick AL, Kornmal RA, Ives DG, Saxton JA, et al. Ginkgo biloba for prevention of dementia. A randomized controlled trial. Journal of the American Medical Association 2008;300:2253e62. +de Vugt ME, Stevens F, Aalten P, Lousberg R, Jaspers N, Verhey FRJ. A prospective study of the effects of behavioral symptoms on the institutionalization of patients with dementia. International Psychogeriatrics 2005;17:1e13. +Di Iulio F, Palmer K, Blundo C, Casini AR, Gianni W, Caltagirone C, et al. Occurrence of neuropsychiatric symptoms and psychiatric disorders in mild Alzheimer’s disease and mild cognitive impairment subtypes. International Psychogeriatrics 2010;22:629e40. +EMEA European Medicines Agency. Guideline on medicinal products for the treatment of Alzheimer’s disease and other dementias [CPMP/EWP/553/95 Rev 1]. London: EMEA; 2008. +Erzigkeit H. SKT manual. A short cognitive performance test for assessing memory and attention. Concise version. Geromed: Castrop-Rauxel; 1992. +Förstl H. Alzheimer-plus. International Psychogeriatrics 2003;15:7e8. +Green RC, Schneider LS, Amato DA, Beelen AP, Wilcock G, Swabb EA, et al. Effect of tarenflurbil on cognitive decline and activities of daily living in patients with mild Alzheimer disease. Journal of the American Medical Association 2009;302: 2557e64. +Hamilton M. A rating scale for depression. Journal of Neurology, Neurosurgery and Psychiatry 1960;23:56e62. +Holmes C, Boche D, Wilkinson D, Yadegarfar G, Hopkins V, Bayer A, et al. Long-term effects of $\mathsf { A } \beta _ { 4 2 }$ immunisation in Alzheimer’s disease: follow-up of a randomised, placebo-controlled phase I trial. The Lancet 2008;372:216e23. +ICH International Conference on Harmonisation of Technical Requirements for Registration of Pharmaceuticals for Human Use. Good clinical practice: consolidated guideline. Geneva: ICH; 1996. +Ihl R, Grass-Kapanke B, Jänner M, Weyer G. Neuropsychometric tests in cross sectional and longitudinal studies e a regression analysis of ADAS-Cog, SKT and MMSE. Pharmacopsychiatry 1999;32:248e54. +Ihl R, Grass-Kapanke B, Lahrem P, Brinkmeyer J, Fischer S, Gaab N, et al. Entwicklung und Validierung eines Tests zur Früherkennung der Demenz mit Depressionsabgrenzung (TFDD). Fortschritte der Neurologie e Psychiatrie 2000;68: 413e22. +Ihl R, Biesenbach A, Brieber S, Grass-Kapanke B, Salomon T. A head-to-head comparison of the sensitivity of two screening tests for dementia. Mini-Mental-State-Examination (MMSE) and the test for Early Detection of Dementia with Discrimination from Depression (TE4D). Pychogeriatria Polska 2005;2:263e71. +Ihl R, Tribanek M, Bachinskaya N. Baseline neuropsychiatric symptoms are effect modifiers in Ginkgo biloba extract (EGb $7 6 1 ^ { \circ }$ ) treatment of dementia with neuropsychiatric features. Retrospective data analyses of a randomized controlled trial. Journal of the Neurological Sciences 2010;299:184e7. +Ihl R, Bachinskaya N, Korczyn AD, Vakhapova V, Tribanek M, Hoerr R, et al. Efficacy and safety of a once-daily formulation of Ginkgo biloba extract EGb $7 6 1 ^ { \textregistered }$ in dementia with neuropsychiatric features: a randomized controlled trial. International Journal of Geriatric Psychiatry 2011;26:1186e94. doi:10.1002/gps.2662. +Janssen IM, Sturtz S, Skipka G, Zentner A, Garrido MV, Busse R. Ginkgo biloba in Alzheimer’s disease: a systematic review. Wiener Medizinische Wochenschrift 2010;160:539e46. + +Källén A. Treatment-by-center interaction: what is the issue? Drug Information Journal 1997;31:927e36. +Kim YS, Nibbelink DW, Overall JE. Factor structure and scoring of the SKT test battery. Journal of Clinical Psychology 1993;49:61e71. +Költringer P, Langsteger W, Ober O. Dose-dependent hemorheological effects and microcirculatory modifications following intravenous administration of Ginkgo biloba special extract EGb 761. Clinical Hemorheology 1995;15:649e56. +Korczyn AD. Mixed dementia e the most common cause of dementia. Annals of the New York Academy of Sciences 2002;977:129e34. +Lehfeld H, Rudinger G, Rietz C, Heinrich C, Wied V, Fornazzari L, et al. Evidence of the cross-cultural stability of the factor structure of the SKT short test for assessing deficits of memory and attention. International Psychogeriatrics 1997a;9:139e53. +Lehfeld H, Reisberg B, Finkel S, Kanowski S, Wied V, Pittas J, et al. Informant-rated activities-of-daily-lilving (ADL) assessments: results of a study of 141 items in the USA, Germany, Russia, and Greece from the international ADL scale development project. Alzheimer Disease and Associated Disorders 1997b;11 (Suppl. 4):S39e44. +Lindesay J, Bullock R, Daniels H, Emre M, Förstl H, Frölich L, et al. Turning principles into practice in Alzheimer’s disease. International Journal of Clinical Practice 2010;64:1198e209. +Mahoney R, Johnston K, Katona C, Maxmin K, Livingston G. The TE4D-Cog: a new test for detecting early dementia in English-speaking populations. International Journal of Geriatric Psychiatry 2005;20:1172e9. +McKhann G, Drachman D, Folstein M, Katzman R, Price D, Stadlan EM. Clinical diagnosis of Alzheimer’s disease: report of the NINCDS-ADRDA work group under the auspices of department of health and human services task force on Alzheimer’s disease. Neurology 1984;34:939e44. +Mega MS, Masterman DM, O’Connor SM, Barclay TR, Cummings JL. The spectrum of behavioral responses to cholinesterase inhibitor therapy in Alzheimer disease. Archives of Neurology 1999;56:1388e93. +Newcombe RG. Interval estimation for the difference between independent proportions: comparison of eleven methods. Statistics in Medicine 1998;17: 873e90. +Newman AB, Fitzpatrick AL, Lopez O, Jackson S, Lyketsos C, Jagust W, et al. Dementia and Alzheimer’s disease incidence in relationship to cardiovascular disease in the Cardiovascular Health Study Cohort. Journal of the American Geriatrics Society 2005;53:1101e7. +Powlishta KK, Storandt M, Mandernach TA, Hogan E, Grant EA, Morris JC. Absence of effect of depression on cognitive performance in early-stage Alzheimer disease. Archives of Neurology 2004;61:1265e8. +Reisberg B, Finkel S, Overall J, Schmidt-Gollas N, Kanowski S, Lehfeld H, et al. The Alzheimer’s disease activities of daily living international scale (ADL-IS). International Psychogeriatrics 2001;13:163e81. +Rogers Sl, Farlow MR, Doody RS, Mohs R, Friedhoff LT, the Donepezil Study Group. A 24 week, double-blind, placebo-controlled trial of donepezil in patients with Alzheimer’s disease. Neurology 1998;50:136e45. +Román GC, Tatemichi TK, Erkinjuntti T, Cummings JL, Masdeu JC, Garcia JH, et al. Vascular dementia: diagnostic criteria for research studies. Report of the NINDS-AIREN International Workshop. Neurology 1993;43:250e60. +Schneider JA, Arvanitakis Z, Bang W, Bennett DA. Mixed brain pathologies account for most dementia cases in community-dwelling older persons. Neurology 2007;69:2197e204. +Schneider LS, Olin JT, Doody RS, Clark CM, Morris JC, Reisberg B, et al. Validity and reliability of the Alzheimer’s disease cooperative study-clinical global impression of change. Alzheimer Disease and Associated Disorders 1997;11(Suppl. 2): S22e32. +Schneider LS, DeKosky ST, Farlow MR, Tariot PN, Hoerr R, Kieser M. A randomized, double-blind, placebo-controlled trial of two doses of Ginkgo biloba extract in dementia of the Alzheimer’s type. Current Alzheimer Research 2005;2:541e51. +Smith SC, Lamping DL, Banerjee S, Harwood R, Foley B, Smith P, et al. Measurement of health-related quality of life for people with dementia: development of a new instrument (DEMQOL) and an evaluation of current methodology. Health Technology Assessment 2005;9(No. 10). +Steinberg M, Shao H, Zandi P, Lyketsos CG, Welsh-Bohmer KA, Norton MC, et al. Point and 5-year prevalence of neuropsychiatric symptoms in dementia: the Cache County Study. International Journal of Geriatric Psychiatry 2008;23: 170e7. +Sunderland T, Hill J, Mellow A, Lawlor BA, Gundersheimer J, Newhouse PA, et al. Clock drawing in Alzheimer’s disease: a novel measure of dementia severity. Journal of the American Geriatrics Society 1989;37:725e9. +Tchantchou F, Xu Y, Wu Y, Christen Y, Luo Y. EGb $7 6 1 ^ { \textregistered }$ enhances adult hippocampal neurogenesis and phosphorylation of CREB in transgenic mouse model of Alzheimer’s disease. FASEB Journal 2007;21:2400e8. +Vellas B, Coley N, Ousset PJ, Berrut G, Dartigues JF, Dubois B, et al. Results of GuidAge e a 5-year placebo-controlled study of the efficacy on EGb $7 6 1 ^ { \textcircled { \times } } 1 2 0 \mathrm { m g }$ to prevent or delay Alzheimer’s dementia onset in elderly subjects with memory complaint. The Journal of Nutrition, Health and Aging 2010;14(Suppl. 2):S23. +Verhey FR, Houx P, van Lang N, Huppert F, Stoppe G, Saerens J, et al. Cross-national comparison and validation of the Alzheimer’s disease assessment scale: results from the European harmonization project for instruments in dementia. International Journal of Geriatric Psychiatry 2004;19:41e50. +Weinmann S, Roll S, Schwarzbach C, Vauth C, Willich SN. Effects of Ginkgo biloba in dementia: systematic review and meta-analysis. BMC Geriatrics 2010;10. Art 14. + +Wimo A, Winblad B, Aguero-Torres H, von Strauss E. The magnitude of dementia occurrence in the world. Alzheimer Disease and Associated Disorders 2003;17: 63e7. +Wu Y, Wu Z, Butko P, Christen Y, Lambert MP, Klein WL, et al. Amyloid- $\cdot \beta$ -induced pathological behaviors are suppressed by Ginkgo biloba extract EGb $7 6 1 ^ { \textcircled { \times } }$ and + +ginkgolides in transgenic Caenorhabditis elegans. Journal of Neuroscience 2006; 26:13102e13. +Yoshitake T, Yoshitake S, Kehr J. The Ginkgo biloba extract EGb $7 6 1 ^ { \textregistered }$ and its main constituent flavonoids and ginkgolides increase extracellular dopamine levels in the rat prefrontal cortex. British Journal of Pharmacology 2010;159:659e68. \ No newline at end of file diff --git a/backend/tests/e2e-phase2-phase3-test.ts b/backend/tests/e2e-phase2-phase3-test.ts new file mode 100644 index 00000000..451590e1 --- /dev/null +++ b/backend/tests/e2e-phase2-phase3-test.ts @@ -0,0 +1,267 @@ +/** + * Phase 2 + Phase 3 联合端到端测试 + * + * 测试内容: + * 1. 认证中间件(IIT admin 路由需要 Token) + * 2. RBAC 角色权限(不同角色看到不同内容) + * 3. /my-projects API(用户-项目关联) + * 4. 租户过滤(项目按 tenantId 隔离) + * 5. 租户选项 API + * 6. 项目创建带 tenantId + * 7. IIT_OPERATOR 角色枚举 + * 8. UserRole / TenantOption 类型完整性 + * + * 运行方式: npx tsx tests/e2e-phase2-phase3-test.ts + */ + +const AUTH_BASE = 'http://localhost:3001/api/v1/auth'; +const ADMIN_IIT_BASE = 'http://localhost:3001/api/v1/admin/iit-projects'; +const IIT_BASE = 'http://localhost:3001/api/v1/iit'; + +let passCount = 0; +let failCount = 0; +const results: { name: string; ok: boolean; detail?: string }[] = []; + +// ========== Helpers ========== + +async function rawFetch(url: string, opts?: RequestInit) { + const res = await fetch(url, opts); + const json = await res.json().catch(() => null); + return { status: res.status, data: json }; +} + +async function authApi(method: string, url: string, token: string, body?: any) { + const opts: RequestInit = { + method, + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + }; + if (body) opts.body = JSON.stringify(body); + return rawFetch(url, opts); +} + +async function login(phone: string, password: string = '123456'): Promise { + const { status, data } = await rawFetch(`${AUTH_BASE}/login/password`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ phone, password }), + }); + if (status === 200 && data?.data?.tokens?.accessToken) { + return data.data.tokens.accessToken; + } + return null; +} + +function assert(name: string, condition: boolean, detail?: string) { + if (condition) { + passCount++; + results.push({ name, ok: true }); + console.log(` ✅ ${name}`); + } else { + failCount++; + results.push({ name, ok: false, detail }); + console.log(` ❌ ${name}${detail ? ` — ${detail}` : ''}`); + } +} + +// ========== Test Suites ========== + +async function testAuthMiddleware() { + console.log('\n🔒 [1/8] IIT Admin 路由认证中间件'); + + // 无 Token 访问应返回 401 + const { status: noTokenStatus } = await rawFetch(`${ADMIN_IIT_BASE}`, { method: 'GET' }); + assert('无 Token 访问 GET /iit-projects → 401', noTokenStatus === 401, `status=${noTokenStatus}`); + + const { status: noTokenStatus2 } = await rawFetch(`${ADMIN_IIT_BASE}/tenant-options`, { method: 'GET' }); + assert('无 Token 访问 GET /tenant-options → 401', noTokenStatus2 === 401, `status=${noTokenStatus2}`); +} + +async function testRoleForbidden() { + console.log('\n🚫 [2/8] RBAC: 普通 USER 不能访问 IIT Admin'); + + const userToken = await login('13800138003'); // 王医生, role=USER + assert('普通用户登录成功', userToken !== null); + + if (userToken) { + const { status } = await authApi('GET', ADMIN_IIT_BASE, userToken); + assert('USER 角色访问 IIT Admin → 403', status === 403, `status=${status}`); + } +} + +async function testSuperAdminAccess() { + console.log('\n👑 [3/8] SUPER_ADMIN 全权限访问'); + + const adminToken = await login('13800000001'); // 超级管理员 + assert('SUPER_ADMIN 登录成功', adminToken !== null); + + if (adminToken) { + const { status, data } = await authApi('GET', ADMIN_IIT_BASE, adminToken); + assert('SUPER_ADMIN 访问项目列表 → 200', status === 200, `status=${status}`); + assert('返回项目数组', Array.isArray(data?.data), `type=${typeof data?.data}`); + + // 项目带 tenantName + const projects = data?.data || []; + if (projects.length > 0) { + assert('项目含 tenantName 字段', projects[0].tenantName !== undefined, + `keys=${Object.keys(projects[0]).join(',')}`); + assert('项目含 tenantId 字段', projects[0].tenantId !== undefined); + } + } + + return adminToken; +} + +async function testTenantOptions(adminToken: string) { + console.log('\n🏢 [4/8] 租户选项 API'); + + const { status, data } = await authApi('GET', `${ADMIN_IIT_BASE}/tenant-options`, adminToken); + assert('GET /tenant-options → 200', status === 200, `status=${status}`); + assert('返回租户数组', Array.isArray(data?.data), `type=${typeof data?.data}`); + + const tenants = data?.data || []; + assert('租户数量 > 0', tenants.length > 0, `count=${tenants.length}`); + + if (tenants.length > 0) { + assert('租户含 id/name/code/type', + tenants[0].id && tenants[0].name && tenants[0].code && tenants[0].type, + `keys=${Object.keys(tenants[0]).join(',')}`); + } + + return tenants; +} + +async function testTenantFiltering(adminToken: string) { + console.log('\n🔍 [5/8] 租户过滤'); + + // 壹证循科技 的 tenantId + const yizhengxunId = 'eb4e93b7-0210-4bf5-b853-cbea49cdadf8'; + + // 不带租户过滤 → 返回所有项目 + const { data: allData } = await authApi('GET', ADMIN_IIT_BASE, adminToken); + const allCount = allData?.data?.length || 0; + assert('无过滤返回所有项目', allCount > 0, `count=${allCount}`); + + // 带租户过滤 → 只返回该租户项目 + const { data: filteredData } = await authApi('GET', + `${ADMIN_IIT_BASE}?tenantId=${yizhengxunId}`, adminToken); + const filteredProjects = filteredData?.data || []; + assert('按租户过滤返回结果', filteredProjects.length > 0, `count=${filteredProjects.length}`); + assert('过滤结果中所有项目 tenantId 一致', + filteredProjects.every((p: any) => p.tenantId === yizhengxunId), + `tenantIds=${filteredProjects.map((p: any) => p.tenantId).join(',')}`); + + // 不存在的租户 → 返回空数组 + const { data: emptyData } = await authApi('GET', + `${ADMIN_IIT_BASE}?tenantId=non-existent-tenant`, adminToken); + assert('不存在的租户返回空数组', (emptyData?.data?.length || 0) === 0); +} + +async function testPharmaAdminIsolation() { + console.log('\n🏭 [6/8] PHARMA_ADMIN 租户隔离'); + + const pharmaToken = await login('13800000006'); // 药企管理员, tenant=takeda + assert('PHARMA_ADMIN 登录成功', pharmaToken !== null); + + if (pharmaToken) { + const { status, data } = await authApi('GET', ADMIN_IIT_BASE, pharmaToken); + assert('PHARMA_ADMIN 可以访问项目列表', status === 200, `status=${status}`); + + const projects = data?.data || []; + // PHARMA_ADMIN 的 tenantId 是 tenant-takeda,而测试项目属于 壹证循科技 + // 所以不应该看到测试项目 + const takedaTenantId = 'tenant-takeda'; + const allMatchTenant = projects.every((p: any) => p.tenantId === takedaTenantId); + assert('PHARMA_ADMIN 只能看到自己租户的项目(或空)', + projects.length === 0 || allMatchTenant, + `count=${projects.length}, tenantIds=${projects.map((p: any) => p.tenantId).join(',')}`); + } +} + +async function testMyProjectsApi() { + console.log('\n👤 [7/8] /my-projects API(用户-项目关联)'); + + // 无 Token → 401 + const { status: noTokenStatus } = await rawFetch(`${IIT_BASE}/my-projects`, { method: 'GET' }); + assert('无 Token 访问 /my-projects → 401', noTokenStatus === 401, `status=${noTokenStatus}`); + + // 登录用户(有 IitUserMapping 关联) + const adminToken = await login('13800000001'); + if (adminToken) { + const { status, data } = await authApi('GET', `${IIT_BASE}/my-projects`, adminToken); + assert('/my-projects 返回 200', status === 200, `status=${status}`); + assert('返回 data 数组', Array.isArray(data?.data), `type=${typeof data?.data}`); + + // 当前超级管理员可能没有 IitUserMapping,所以可能是空数组 + // 但 API 不应报错 + const projects = data?.data || []; + assert('/my-projects 不报错(即使无关联项目)', true, `count=${projects.length}`); + + if (projects.length > 0) { + assert('项目含 myRole 字段', projects[0].myRole !== undefined, + `keys=${Object.keys(projects[0]).join(',')}`); + } + } +} + +async function testProjectDetailWithTenant(adminToken: string) { + console.log('\n📋 [8/8] 项目详情含租户信息'); + + const projectId = 'test0102-pd-study'; + const { status, data } = await authApi('GET', `${ADMIN_IIT_BASE}/${projectId}`, adminToken); + assert('GET 项目详情 → 200', status === 200, `status=${status}`); + + const project = data?.data; + assert('项目详情含 tenantId', project?.tenantId !== undefined && project?.tenantId !== null, + `tenantId=${project?.tenantId}`); + assert('项目详情含 tenant 对象', project?.tenant !== undefined, + `tenant=${JSON.stringify(project?.tenant)}`); + if (project?.tenant) { + assert('tenant 含 name', !!project.tenant.name, `name=${project.tenant.name}`); + } +} + +// ========== Main ========== + +async function main() { + console.log('🚀 Phase 2 + Phase 3 联合端到端测试'); + console.log('='.repeat(60)); + + try { + // Phase 2 Tests + await testAuthMiddleware(); + await testRoleForbidden(); + const adminToken = await testSuperAdminAccess(); + await testMyProjectsApi(); + + // Phase 3 Tests + if (adminToken) { + const tenants = await testTenantOptions(adminToken); + await testTenantFiltering(adminToken); + await testProjectDetailWithTenant(adminToken); + } + await testPharmaAdminIsolation(); + + } catch (err) { + console.error('\n💥 测试执行异常:', err); + failCount++; + } + + // Summary + console.log('\n' + '='.repeat(60)); + console.log(`📊 测试结果: ${passCount} 通过, ${failCount} 失败, 共 ${passCount + failCount} 项`); + + if (failCount > 0) { + console.log('\n❌ 失败项:'); + results.filter(r => !r.ok).forEach(r => { + console.log(` • ${r.name}${r.detail ? ` — ${r.detail}` : ''}`); + }); + } + + console.log('\n' + (failCount === 0 ? '🎉 全部通过!' : '⚠️ 有失败项,请检查')); + process.exit(failCount > 0 ? 1 : 0); +} + +main(); diff --git a/docs/01-平台基础层/07-数据库/01-Prisma迁移历史与变更日志.md b/docs/01-平台基础层/07-数据库/01-Prisma迁移历史与变更日志.md index 5e136d87..9a3d3436 100644 --- a/docs/01-平台基础层/07-数据库/01-Prisma迁移历史与变更日志.md +++ b/docs/01-平台基础层/07-数据库/01-Prisma迁移历史与变更日志.md @@ -8,7 +8,7 @@ ## 1. 迁移时间线总览 -当前共 **14 个已应用迁移**,覆盖 2025-10 至 2026-02 的演进过程。 +当前共 **15 个已应用迁移**,覆盖 2025-10 至 2026-02 的演进过程。 ``` 2025-10 ████ init + conversations + batch + review_tasks @@ -140,6 +140,15 @@ | 核心内容 | 对齐 Schema 类型注解与数据库实际类型:ssa_workflows/ssa_workflow_steps 的 VARCHAR 长度和 TIMESTAMP 精度 + 清理 2 个重复外键 | | 性质 | Schema 精度对齐,SQL 对实际数据库为幂等操作 | +### #15 — 20260228_add_iit_phase2_user_project_rbac + +| 属性 | 值 | +|------|-----| +| 应用时间 | 2026-02-28 12:17:00 | +| 影响 Schema | iit_schema, platform_schema | +| 核心内容 | Phase 2 用户-项目关联 + RBAC:user_mappings 增加 user_id(FK → platform_schema.users),projects 增加 tenant_id(FK → platform_schema.tenants),UserRole 枚举增加 IIT_OPERATOR | +| 创建方式 | 手动创建 + `prisma migrate resolve --applied`(降级方案) | + --- ## 3. 迁移创建方式说明 @@ -148,7 +157,7 @@ |------|---------|---------| | `prisma migrate dev` | 标准开发流程 | #1 ~ #9 | | `prisma migrate dev --create-only` | 仅生成不执行 | — | -| 手动创建 + `prisma migrate resolve --applied` | Shadow DB 无法重放时 | #12, #13, #14 | +| 手动创建 + `prisma migrate resolve --applied` | Shadow DB 无法重放时 | #12, #13, #14, #15 | | `prisma db push` (⚠️ 非标准) | 快速原型,不产生迁移记录 | 造成了 drift patch 的必要性 | --- diff --git a/docs/03-业务模块/IIT Manager Agent/00-模块当前状态与开发指南.md b/docs/03-业务模块/IIT Manager Agent/00-模块当前状态与开发指南.md index a04ed7f2..a85c22a0 100644 --- a/docs/03-业务模块/IIT Manager Agent/00-模块当前状态与开发指南.md +++ b/docs/03-业务模块/IIT Manager Agent/00-模块当前状态与开发指南.md @@ -1,23 +1,25 @@ # IIT Manager Agent模块 - 当前状态与开发指南 -> **文档版本:** v3.0 +> **文档版本:** v3.1 > **创建日期:** 2026-01-01 > **维护者:** IIT Manager开发团队 -> **最后更新:** 2026-02-26 **前端架构调整 + Web Chat 上线** +> **最后更新:** 2026-03-01 **质控引擎 V3.1 架构升级计划定稿(五级数据结构 + 多维报告)** > **重大里程碑:** +> - **2026-03-01:质控引擎 V3.1 架构设计完成!** 五级数据结构(CDISC ODM)+ D1-D7 多维报告 + 三批次落地计划 +> - **2026-03-01:架构团队评审完成!** 采纳 InstanceID/规则分类/状态冒泡/LLM 三不原则,暂缓 SDV/自动映射/GCP 全量报表 > - **2026-02-26:前端架构调整完成!** 运营管理端恢复 IIT 项目管理 + 业务端精简为日常使用 + Web AI 对话页面上线 > - **2026-02-26:CRA Agent V3.0 P0+P1 全部完成!** 自驱动质控流水线 + ChatOrchestrator + LLM Function Calling + E2E 54/54 通过 -> - **2026-02-25:CRA Agent V3.0 开发计划定稿**(替代 CRA 定位 + 报告驱动架构 + 4 语义化工具 + 统一驾驶舱) +> - ✅ 2026-02-25:QC 系统深度修复(null tolerance + baseline merge + record-level pass rate) > - ✅ 2026-02-08:事件级质控架构 V3.1 完成(record+event 独立质控 + 规则动态过滤 + 报告去重) -> - ✅ 2026-02-08:质控驾驶舱 UI 开发完成(驾驶舱页面 + 热力图 + 详情抽屉) > - ✅ 2026-02-07:实时质控系统开发完成(pg-boss防抖 + 质控日志 + 录入汇总 + 管理端批量操作) > - ✅ 2026-02-02:REDCap 生产环境部署完成(ECS + RDS + HTTPS) > - ✅ 2026-01-04:混合检索架构实现(REDCap实时数据 + Dify文档知识库) > **文档目的:** 反映模块真实状态,记录开发历程 -> **V3.0 核心文档:** -> - [CRA Agent V3.0 开发计划](./04-开发计划/V3.0全新开发计划/V3.0全新开发计划.md) -> - [统一数字 CRA 质控平台 PRD](./04-开发计划/V3.0全新开发计划/统一数字%20CRA%20质控平台产品需求文档(PRD).md) -> - [UI 原型](./04-开发计划/V3.0全新开发计划/Final%20CRA质控平台V3.html) +> **V3.1 核心文档(质控引擎架构升级):** +> - [质控引擎 V3.1 开发计划(唯一开发文档)](./04-开发计划/V3.0全新开发计划/质控引擎V3.1架构升级-五级数据结构与多维报告开发计划.md) +> - [CRA Agent 质控体系全景技术路径(策略评审稿)](./00-系统设计/CRA%20Agent%20质控体系全景技术路径(策略评审稿).md) +> - [五层数据架构方案评审反馈](./09-技术评审报告/五层数据架构方案评审反馈.md) +> - [CRA 四大工具工作原理说明](./08-对外输出报告/CRA智能质控Agent-四大工具工作原理说明.md) --- @@ -53,24 +55,22 @@ CRA Agent 是一个**替代 CRA 岗位的自主 AI Agent**,而非辅助 CRA - AI能力:DeepSeek/Qwen + 自研 RAG(pgvector)+ LLM Tool Use ### 当前状态 -- **开发阶段**:**V3.0 P0 + P1 已完成,E2E 测试 54/54 通过** -- **P0 已完成**(自驱动质控流水线): - - 变量清单导入 + 可视化 - - 规则配置增强(4 类规则 + AI 辅助建议) - - 定时质控 + 报告生成 + eQuery 闭环 + 重大事件归档 - - 统一质控驾驶舱(健康分 + 趋势图 + 风险热力图)+ AI Stream Timeline -- **P1 已完成**(对话层 Tool Use 改造): - - LLM Adapter 原生 Function Calling(DeepSeek / GPT-5 / Claude-4.5) - - 4 语义化工具:`read_report` / `look_up_data` / `check_quality` / `search_knowledge` - - ChatOrchestrator 轻量 ReAct(max 3 轮 Function Calling loop) - - ChatService (1,442行) 已废弃,替换为 ChatOrchestrator (~160行) -- **前端架构调整已完成**(2026-02-26): - - 运营管理端:恢复 IIT 项目管理(项目列表 + 配置 + 驾驶舱),供运营团队使用 - - 业务端 CRA 质控:精简为 5 个日常页面(驾驶舱 / 报告 / AI 工作流水 / AI 对话 / eQuery),供 PI/CRC 使用 - - 新增 Web AI 对话页面(`/iit/chat`)+ 后端 `POST /api/v1/iit/chat` 端点 +- **开发阶段**:**V3.0 P0 + P1 已完成 → 正在规划 V3.1 质控引擎架构升级** +- **V3.0 已完成**: + - P0 自驱动质控流水线 + P1 对话层 Tool Use 改造 + E2E 54/54 通过 + - QC 系统深度修复(null tolerance + baseline merge + record-level pass rate + LLM 报告修正) + - 前端架构调整(运营管理端 + 业务端 + Web AI 对话) + - 项目成员管理(跨租户添加成员 + 项目知识库) + - CRA 四大工具工作原理文档输出 +- **V3.1 规划中**(质控引擎架构升级): + - **核心升级**:三级数据结构 → CDISC ODM 五级结构(Record → Event → Form → Instance → Field) + - **报告维度**:从单一通过率 → D1-D7 七维质控报告(入排/完整性/准确性/Query/安全性/方案偏离/药物管理) + - **触发模式**:手动 + 定时 Cron + REDCap DET 实时触发 + - **关键机制**:状态冒泡 + eQuery 自动闭环 + Record-Level Context + Event-Aware 完整性 + - **落地策略**:三批次落地(A 数据地基 → B 聚合冒泡 → C 新维度引擎) + - **开发计划文档**:[质控引擎 V3.1 开发计划](./04-开发计划/V3.0全新开发计划/质控引擎V3.1架构升级-五级数据结构与多维报告开发计划.md) - **产品定位明确**:运营团队配置项目(白手套服务) → 终端用户使用质控平台 -- **待开发**:P1-2 对话体验优化 / P2 可选功能 -- **代码规模**:后端 ~14,000+ 行(净减 ~1,100 行)/ 20 张表(iit_schema) +- **代码规模**:后端 ~14,000+ 行 / 20 张表(iit_schema) #### ✅ 已完成功能(基础设施) - ✅ 数据库Schema创建(iit_schema,20个表 = 原5个 + 4质控表 + 2新增(equery/critical_events) + 9其他) @@ -108,6 +108,17 @@ CRA Agent 是一个**替代 CRA 岗位的自主 AI Agent**,而非辅助 CRA - 运营管理端(`/admin/iit-projects`)→ 运营团队:创建项目、配置 REDCap、设规则 - 业务端(`/iit/`)→ PI/CRC:驾驶舱、报告、AI 工作流水、AI 对话、eQuery +#### ✅ 已完成功能(QC 系统深度修复 + 架构评审 - 2026-02-25 ~ 03-01) +- ✅ **QC 规则修复**(seed-iit-qc-rules.ts:null/空字符串容忍 + applicableEvents 配置) +- ✅ **HardRuleEngine 增强**(字段可用性预检查 + skipped 状态) +- ✅ **SkillRunner 基线数据合并**(随访事件自动合并基线数据) +- ✅ **QcReportService 修正**(Record-Level 通过率计算 + LLM XML 报告准确性) +- ✅ **批量质控修复**(iitBatchController:legacy log 清理 + upsert RecordSummary) +- ✅ **CRA 四大工具原理文档**(面向临床专家的工具工作原理说明文档) +- ✅ **质控引擎 V3.1 架构设计**(五级数据结构 + D1-D7 维度 + 三批次落地计划) +- ✅ **架构团队评审完成**(4 份白皮书评审 + 评审反馈文档 + 4 条关键落地建议采纳) +- ✅ **V3.0 → V3.1 计划迁移**(V3.1 为唯一开发文档,V3.0 存档供参考) + #### ✅ 已完成功能(实时质控系统 - 2026-02-07) - ✅ **质控数据库表**(iit_qc_logs + iit_record_summary + iit_qc_project_stats + iit_field_metadata) - ✅ **pg-boss 防抖机制**(WebhookController + singletonKey) @@ -145,18 +156,37 @@ CRA Agent 是一个**替代 CRA 岗位的自主 AI Agent**,而非辅助 CRA - ✅ **AI 意图识别增强**(支持"严重违规有几项"等自然语言查询) - ✅ **Bug 修复**(formatPatientData 500 错误 + 记录数统计 + 报告限制移除) -#### ⏳ V3.0 开发计划(2026-02-25 定稿) +#### ✅ V3.0 开发计划(已完成) > 详见:[CRA Agent V3.0 开发计划](./04-开发计划/V3.0全新开发计划/V3.0全新开发计划.md) -| 阶段 | 内容 | 预估 | 状态 | +| 阶段 | 内容 | 状态 | +|------|------|------| +| **P0-1** | ChatOrchestrator + ToolsService 重构 | ✅ 已完成 | +| **P0-2** | read_report + search_knowledge 实现 | ✅ 已完成 | +| **P0-3** | 定时质控 + 报告生成 + eQuery 闭环 | ✅ 已完成 | +| **P0-4** | 统一质控驾驶舱 | ✅ 已完成 | +| **P1** | 对话层 Tool Use + E2E 测试 54/54 | ✅ 已完成 | + +#### ✅ QC 系统深度修复(2026-02-25) + +| 修复项 | 说明 | +|--------|------| +| Null Tolerance | 质控规则增加 null/空字符串容忍,未填数据不再误判为 FAIL | +| Baseline Data Merge | SkillRunner 合并基线事件数据,解决随访期规则缺字段问题 | +| Record-Level Pass Rate | QcReportService 改为按受试者最严重状态计算通过率 | +| Legacy Log Cleanup | 批量质控前清理 eventId 为 null 的历史脏数据 | +| LLM 报告修正 | XML 报告准确反映真实质控结果(从 0% → 92.9%) | + +#### ⏳ V3.1 质控引擎架构升级(规划中) + +> 详见:[质控引擎 V3.1 开发计划(唯一开发文档)](./04-开发计划/V3.0全新开发计划/质控引擎V3.1架构升级-五级数据结构与多维报告开发计划.md) + +| 批次 | 内容 | 预估 | 状态 | |------|------|------|------| -| **P0-1** | ChatOrchestrator + ToolsService 重构(4 语义化工具 + LLM Tool Use) | 2 天 | 待开始 | -| **P0-2** | report 工具补全(read_report + search_knowledge 实现) | 1.5 天 | 待开始 | -| **P0-3** | 定时质控 + 报告生成 + eQuery 闭环(pg-boss cron + 全量/增量报告 + eQuery 状态机) | 3.5 天 | 待开始 | -| **P0-4** | 统一质控驾驶舱(概览 + AI Stream + eQuery 管理 + 报告归档 + 重大事件库) | 2.5 天 | 待开始 | -| **P1-1** | 企业微信主动推送(日报/告警/eQuery 通知) | 2 天 | 待开始 | -| **P1-2** | 管理端配置增强(规则模板 + 多项目切换) | 2 天 | 待开始 | +| **Batch A** | 数据地基(五级状态表 DDL + QcExecutor + InstanceID 标准化 + D1/D3 迁移) | ~5 天 | 待开始 | +| **Batch B** | 聚合冒泡(状态冒泡 + 多维报告 + Cron 灵活配置 + 驾驶舱升级) | ~5 天 | 待开始(依赖 A) | +| **Batch C** | 新维度引擎(D2 完整性 + D5 安全性 + D6 方案偏离 + 沙盒测试) | ~5 天 | 待开始(依赖 B + 临床专家确认) | | **P2** | 长期可选(PII 脱敏 / SDV 视觉核对 / AutoMapper / 数据响应质量评级) | - | 待开始 | **P0 合计:约 9.5 天 / P0+P1 合计:约 13.5 天(1 人)** diff --git a/docs/03-业务模块/IIT Manager Agent/00-系统设计/CRA Agent 质控体系全景技术路径(策略评审稿).md b/docs/03-业务模块/IIT Manager Agent/00-系统设计/CRA Agent 质控体系全景技术路径(策略评审稿).md new file mode 100644 index 00000000..4d54bc72 --- /dev/null +++ b/docs/03-业务模块/IIT Manager Agent/00-系统设计/CRA Agent 质控体系全景技术路径(策略评审稿).md @@ -0,0 +1,479 @@ +# CRA Agent 质控体系全景技术路径(策略评审稿) + +> **文档性质**:策略与路径评审文档,供团队内部和临床研究专家讨论可行性 +> **版本**:V1.0 | 2026-03-01 +> **目标读者**:技术团队、产品负责人、临床研究顾问 +> **下一步**:经评审认可后,基于本文档形成具体开发计划 + +--- + +## 一、背景与核心问题 + +### 1.1 CRA 做什么 + +临床监查员(CRA)的工作本质上是一个**多维度的数据质量守门人**。参照行业标准(ICH-GCP E6 R2/R3),CRA 在常规监查阶段的工作可以分解为以下 7 个维度: + +| 维度 | CRA 具体工作 | 输出物 | +|------|------------|--------| +| **D1 入组质量** | 核查入排标准、知情同意时序、筛选流程 | 筛选与入组日志 | +| **D2 数据完整性** | 检查缺失字段、录入时效、分支逻辑完整性 | 数据缺失率报表 | +| **D3 变量准确性** | 极值校验、时序逻辑、跨表单关联 | 变量质控清单 | +| **D4 数据质疑管理** | 生成 Query、跟踪回复、二次复核、关闭 | eQuery 清单 | +| **D5 安全性监测** | AE/SAE 识别、编码、分级、因果判定、时效监管 | AE/SAE 追踪表 | +| **D6 方案偏离侦测** | 访视超窗、剂量偏差、操作遗漏、CAPA 跟踪 | 方案偏离记录 | +| **D7 药物管理** | 发药/回收核算、依从性计算、批号追踪 | IP 管理明细 | + +以上 7 个维度最终汇聚为一张 **项目健康度评分卡**,供 PI 和项目管理层决策。 + +### 1.2 我们现在能做什么 + +当前 CRA Agent 系统已实现的能力: + +``` + ┌──────────────────────────────────────────┐ + │ CRA 工作全景(7 维度) │ + │ │ + 已实现 ──────► │ ■ D1 入组质量(入排标准规则已有) │ + │ □ D2 数据完整性 │ + 部分实现 ────► │ ■ D3 变量准确性(基础范围检查) │ + │ □ D4 数据质疑管理(eQuery 表已建) │ + │ □ D5 安全性监测 │ + │ □ D6 方案偏离侦测 │ + 未涉及 ─────► │ □ D7 药物管理 │ + │ │ + │ □ 项目健康度评分卡 │ + └──────────────────────────────────────────┘ + + ■ 已实现 ■ 部分实现 □ 未涉及 +``` + +**覆盖率估算**:约 20-25%(仅 D1 部分 + D3 基础),距离替代 CRA 70-80% 工作的目标差距明显。 + +### 1.3 差距的根源 + +差距不仅是"规则数量不够",而是**架构层面的三个结构性问题**: + +| 问题 | 影响 | +|------|------| +| **规则体系缺乏分类** | 所有规则混在一起,无法按 CRA 工作维度组织和统计 | +| **数据结构粒度不足** | 只有记录级状态,缺少事件级和变量级独立状态表 | +| **报告缺乏维度** | 只有一张扁平的"通过/不通过"报告,无法反映 7 个维度的各自状况 | + +--- + +## 二、核心认知:一条完整的链路 + +### 2.1 从方案到报告的 1:1 映射链 + +``` +研究方案(Protocol) + │ + │ "年龄 16-35 岁、已签知情同意、无排除标准..." + │ + ▼ +变量清单(Data Dictionary) + │ + │ age(数值型)、consent_date(日期型)、exclusion_criteria(选择型)... + │ + ▼ +质控规则(QC Rules)──── 按 7 大维度分类 + │ + │ D1: { "and": [{">=": ["age", 16]}, {"<=": ["age", 35]}] } + │ D3: { "<": ["consent_date", "screening_date"] } + │ D6: { "<=": [{"abs_diff": ["visit_date", "target_date"]}, 3] } + │ + ▼ +三级质控状态(变量级 → 事件级 → 记录级) + │ + │ record_3 / baseline / exclusion_criteria → FAIL (D1:排除标准) + │ record_3 / baseline → FAIL + │ record_3 → FAIL + │ + ▼ +多维质控报告(7 个维度各自独立汇总) + │ + │ D1 入组质量: 13/14 通过 (92.9%) + │ D3 变量准确性: 98.5% 合格率 + │ D5 安全性: 0 例未报告 AE + │ ... + │ + ▼ +项目健康度评分(加权综合) + │ + │ 综合评分: 87/100 + │ 建议: 关注 3 号受试者入组资格 + │ + ▼ +行动(eQuery 派发 / 告警推送 / 监查报告) +``` + +**关键洞察**:这条链路中的每一环都需要"维度(category)"这根线串起来。变量清单中的字段属于哪个表单→ 规则属于哪个维度→ 质控结果按维度汇总→ 报告按维度呈现。 + +### 2.2 IIT 场景的特殊性 + +我们的目标用户是 IIT(研究者发起的试验),与注册临床试验有显著区别: + +| 特征 | 注册试验 | IIT 项目 | +|------|---------|---------| +| 有无专职 CRA | 有,全职 CRA 团队 | 通常没有,或仅兼职 | +| 监管严格度 | 极高(FDA/NMPA 审查) | 中等(伦理委员会审查) | +| EDC 系统 | 商业 EDC(Medidata 等) | 多用 REDCap(免费) | +| 预算 | 充足 | 有限 | +| AI 替代的意义 | 从 100% 提升到 110%(锦上添花) | **从 0% 提升到 70%(雪中送炭)** | + +**这意味着**: +- 我们不需要追求注册试验级别的完美覆盖 +- 优先覆盖 IIT 中**最常出问题、最容易被忽视**的维度 +- 先做"有胜于无"的基础覆盖,再做精细化提升 + +--- + +## 三、技术路径:三阶段演进 + +### 阶段一:数据质量基石(当前 → 近期) + +**目标**:把已有的能力做扎实,建立正确的数据基础架构。 + +#### 核心工作 + +``` +1. 三级质控数据结构 + ├── 变量级状态表(qc_field_status) + ├── 事件级状态表(qc_event_status) + └── 记录级状态表(record_summary 改造) + +2. 规则分类体系 + └── 每条规则标注所属维度(D1-D7) + +3. 多模式触发 + ├── 实时触发(REDCap DET Webhook,已有代码) + ├── 定时触发(项目级 Cron 配置,取代全局硬编码) + └── 手动触发(一键全量质控 + AI 对话) + +4. 报告结构升级 + └── 从扁平报告 → 多维度报告框架 +``` + +#### 覆盖的 CRA 维度 + +| 维度 | 阶段一目标 | +|------|-----------| +| D1 入组质量 | 已有入排规则,补充知情同意时序检查 | +| D3 变量准确性 | 已有范围检查,补充时序逻辑和跨表单关联 | +| D4 数据质疑 | 已有 eQuery 表,补充闭环状态机 | + +#### 需要确认的问题 + +> **问临床专家**: +> 1. IIT 项目中,入排标准检查和变量范围检查是否是最高优先级? +> 2. 知情同意日期 < 筛选日期 < 基线日期的时序校验,在 IIT 中是否必须? +> 3. 跨表单校验(如性别 vs 妊娠检查)在 IIT 中常见吗? + +--- + +### 阶段二:方案合规覆盖(中期) + +**目标**:覆盖 CRA 日常监查中最高频的工作——数据完整性检查和方案偏离侦测。 + +#### 核心工作 + +``` +1. 数据完整性引擎(D2) + ├── 按事件/表单统计应填字段 vs 实际已填 + ├── 分支逻辑感知(排除不应填的字段) + ├── 录入时效统计(REDCap 审计日志 → 计算延迟天数) + └── 缺失率阈值告警 + +2. 方案偏离侦测引擎(D6) + ├── 访视窗口检查(目标日 ± 允许天数) + ├── 操作遗漏检查(Schedule of Assessment 对照) + └── 偏离严重性自动分级(Major / Minor) + +3. 报告增强 + ├── 新增"数据完整性"章节 + ├── 新增"方案偏离"章节 + └── 健康度评分初版(D1+D2+D3+D6 加权) +``` + +#### 覆盖的 CRA 维度 + +| 维度 | 阶段二目标 | +|------|-----------| +| D2 数据完整性 | **新增**:缺失率、录入时效、分支逻辑计算 | +| D6 方案偏离 | **新增**:超窗检测、操作遗漏、偏离分级 | + +#### 需要确认的问题 + +> **问临床专家**: +> 1. IIT 项目中最常见的方案偏离类型是什么?(超窗?漏检?用药偏差?) +> 2. 访视窗口 ±N 天的 N 值,一般如何确定?是每个方案不同还是有通用标准? +> 3. 数据缺失率到多少算"红灯"?IIT 行业有没有通用标准(如 3%、5%)? +> 4. 分支逻辑的处理——REDCap 中的分支逻辑字段在 Data Dictionary 中是否完整可读? + +#### 技术前提 + +- **依赖阶段一**的三级数据结构和规则分类体系 +- 需要从 REDCap 拉取 Schedule of Assessment(表单-事件映射 + 必填字段清单) +- 需要从 REDCap 审计日志(Logging API)获取录入时间信息 + +--- + +### 阶段三:安全性与药物管理(远期) + +**目标**:覆盖 CRA 工作中专业性最强的部分——不良事件监测和药物管理。 + +#### 核心工作 + +``` +1. AE/SAE 安全性引擎(D5) + ├── AE 识别触发(实验室异常自动触发 AE 检查) + ├── 严重程度分级(CTCAE 标准内嵌) + ├── 时序逻辑(AE 起止时间 vs 用药时间) + ├── SAE 时效监控(发现后 24h 上报 deadline) + └── LLM 辅助:因果关系初步评估(概率性判断) + +2. 药物依从性引擎(D7) + ├── 发药/回收核算 + ├── 依从性 = (发药量 - 回收量) / 预期量 × 100% + ├── 80%-120% 合格区间判定 + └── 批号有效期交叉比对 + +3. 实验室预警增强 + ├── 绝对值 vs 正常范围 + ├── 基线变化率监测(连续升高趋势) + └── DILI(药物性肝损伤)早期预警模型 + +4. 合并用药核查 + ├── 方案禁忌药物比对 + ├── 新增用药 ↔ AE 关联推断 + └── 洗脱期计算 + +5. 完整健康度评分 + └── 7 维度加权综合评分 + AI 建议后续行动 +``` + +#### 覆盖的 CRA 维度 + +| 维度 | 阶段三目标 | +|------|-----------| +| D5 安全性监测 | **新增**:AE/SAE 追踪、时效监控、因果评估 | +| D7 药物管理 | **新增**:依从性计算、批号追踪 | + +#### 需要确认的问题 + +> **问临床专家**: +> 1. IIT 项目中,AE/SAE 的管理是由 PI 团队自行负责还是有外部安全委员会? +> 2. IIT 项目中是否普遍使用 CTCAE 分级?还是只在肿瘤相关 IIT 中常用? +> 3. 药物依从性在 IIT 中的重要性如何?是否每个 IIT 都涉及试验药物管理? +> 4. AI 做因果关系"初步评估"(而非最终判定),这在临床上能否被接受? +> 5. 合并用药的禁忌药物清单,是每个方案自定义还是有标准数据库可以引用? + +#### 技术前提 + +- 依赖阶段一、二的基础设施 +- AE/SAE 需要 REDCap 中有对应的 CRF 表单(不是所有 IIT 项目都有) +- 因果关系评估涉及 LLM 概率性推理(SoftRuleEngine),需要人类确认机制 +- 药物管理需要 IWRS 或 REDCap 药物管理模块的数据接口 + +--- + +## 四、三级数据架构概要 + +三个阶段共用同一套数据架构,一次设计到位: + +``` + ┌──────────────────────────────────┐ + │ qc_field_status │ + │ (变量级,保持最新) │ + │ │ + │ project × record × event × field │ + │ + status + rule_category │ + │ + actual_value + expected_value │ + └───────────────┬──────────────────┘ + │ 聚合 + ┌───────────────▼──────────────────┐ + │ qc_event_status │ + │ (事件级,保持最新) │ + │ │ + │ project × record × event │ + │ + status (所有变量中最严重的) │ + │ + 各维度计数 (d1_issues, d2_...) │ + └───────────────┬──────────────────┘ + │ 聚合 + ┌───────────────▼──────────────────┘ + │ record_summary │ + │ (记录级,保持最新) │ + │ │ + │ project × record │ + │ + overall_status (所有事件最严重) │ + │ + events_total / passed / failed │ + └───────────────┬──────────────────┘ + │ 聚合 + ┌───────────────▼──────────────────┐ + │ 多维度质控报告 │ + │ │ + │ D1 入组质量: 92.9% │ + │ D2 数据完整性: --(阶段二) │ + │ D3 变量准确性: 98.5% │ + │ D4 eQuery: 3 Open / 12 Closed │ + │ D5 安全性: --(阶段三) │ + │ D6 方案偏离: --(阶段二) │ + │ D7 药物管理: --(阶段三) │ + │ ───────────────────────── │ + │ 健康度评分: 87 / 100 │ + └──────────────────────────────────┘ +``` + +**关键设计决策**:`rule_category` 字段贯穿整个体系,阶段一只用到 `inclusion`/`exclusion`/`variable`,阶段二加入 `completeness`/`protocol_deviation`,阶段三加入 `ae_safety`/`ip_compliance`。**表结构不需要改,只是规则类别在扩展**。 + +--- + +## 五、规则类型与引擎匹配 + +不同维度的规则,其"确定性"不同,需要匹配不同的执行引擎: + +| 维度 | 规则性质 | 适用引擎 | 举例 | +|------|---------|---------|------| +| D1 入组质量 | 确定性 100% | HardRuleEngine (JSON Logic) | age ∈ [16,35] | +| D2 数据完整性 | 确定性 100% | 专用 CompletenessEngine | 必填字段 A 为空 | +| D3 变量准确性 | 确定性 90%+ | HardRuleEngine | consent_date < screening_date | +| D4 数据质疑 | 管理流程 | 状态机 (eQuery Workflow) | Open→Answered→Closed | +| D5 安全性 | 确定性 60-80% | HardRule + SoftRuleEngine (LLM) | CTCAE 分级确定,因果关系需 LLM | +| D6 方案偏离 | 确定性 95% | HardRuleEngine + 日历引擎 | visit_date 超出窗口 ±3 天 | +| D7 药物管理 | 确定性 100% | HardRuleEngine | 依从性 = 85% ∈ [80,120] | + +**核心原则**: +- **确定性规则用硬引擎**(HardRuleEngine / JSON Logic)——结果可审计、可复现 +- **概率性判断用软引擎**(SoftRuleEngine / LLM)——必须附带"AI 建议 + 人类确认"机制 +- **D4 不是规则引擎问题**,而是流程管理问题(状态机) + +--- + +## 六、覆盖率演进预期 + +``` + 100%─┬───────────────────────────────────────────── + │ ┌─────── + 80%─┤ ┌────────┤ 阶段三 + │ │ D5+D7 │ 70-80% + 60%─┤ ┌───────────┤ │ + │ │ D2+D6 │ 阶段二 │ + 40%─┤ │ │ 50-60% │ + │ ┌──────────┤ │ │ + 20%─┤ │ D1+D3+D4 │ 阶段一 │ │ + │ │ │ 25-35% │ │ + 0%─┴──┴──────────┴───────────┴────────┴───────── + 现状 阶段一 阶段二 阶段三 +``` + +**说明**:百分比是估算值,表示对 CRA 可替代工作(70-80%)的覆盖程度。阶段三完成后,系统应能覆盖 CRA 可替代工作的绝大部分。 + +--- + +## 七、与现有四大工具的关系 + +当前对话层的 4 个工具(`read_report`、`look_up_data`、`check_quality`、`search_knowledge`)在三个阶段中保持稳定,**工具不变,内涵在扩展**: + +| 工具 | 阶段一 | 阶段二 | 阶段三 | +|------|--------|--------|--------| +| `read_report` | D1+D3 报告 | + D2+D6 章节 | + D5+D7 章节 + 健康度评分 | +| `look_up_data` | 原始数据 + 质控标注 | + 缺失率标注 | + AE/ConMed 数据 | +| `check_quality` | 入排 + 变量检查 | + 完整性 + 偏离检查 | + AE + 依从性检查 | +| `search_knowledge` | 研究方案/CRF 搜索 | + Schedule of Assessment | + IB(研究者手册) | + +**这是一个很好的设计**——对 LLM 来说,工具数量和接口不变,它不需要"学习"新工具。内部的规则引擎和报告内容在持续丰富,对 LLM 透明。 + +--- + +## 八、风险与约束 + +### 8.1 数据源约束 + +| 数据需求 | 来源 | 约束 | +|---------|------|------| +| 变量值 | REDCap Record API | ✅ 已打通 | +| 变量定义(Data Dictionary) | REDCap Metadata API | ✅ 已打通 | +| 表单-事件映射 | REDCap Form-Event API | ✅ 已打通 | +| 录入时间(审计日志) | REDCap Logging API | ✅ 已实现(exportLogging) | +| 分支逻辑 | Data Dictionary.branching_logic | ⚠️ 需解析 REDCap 分支逻辑语法 | +| Schedule of Assessment | 研究方案 PDF + 人工配置 | ⚠️ 需要项目管理员手动配置 | +| AE/SAE 表单 | REDCap CRF | ⚠️ 取决于项目是否建了 AE 表单 | +| 药物管理数据 | REDCap CRF 或外部 IWRS | ❌ 可能不在 REDCap 中 | + +### 8.2 IIT 场景的现实约束 + +- **不是所有 IIT 都有 AE 表单**:观察性研究可能没有不良事件采集 +- **不是所有 IIT 都涉及试验药物**:非药物干预研究(如手术方案对比)无 D7 +- **方案复杂度差异大**:简单的 IIT 可能只有 2 个访视,复杂的可能有 20+ +- **REDCap 配置标准化程度低**:不同机构的 REDCap 项目结构差异很大 + +**应对策略**:系统必须支持"维度可选"——管理员在项目配置时勾选本项目适用的维度,未勾选的维度不执行、不展示。 + +### 8.3 LLM 在临床决策中的边界 + +| 场景 | LLM 角色 | 人类角色 | +|------|---------|---------| +| 数值范围检查 | 直接判定 | 不需要 | +| 时序逻辑检查 | 直接判定 | 不需要 | +| 数据缺失统计 | 直接计算 | 不需要 | +| AE 严重程度分级 | 根据 CTCAE 标准建议分级 | PI 确认 | +| AE 因果关系 | **初步评估(建议)** | **PI 必须确认** | +| 方案偏离严重性 | 按规则初分 Major/Minor | PI 可修正 | +| 综合健康度建议 | 提供建议行动 | PI 决策 | + +**铁律**:凡涉及医学判断的结论,AI 只提供建议,必须经人类确认后才能写入正式记录。 + +--- + +## 九、待讨论的关键问题 + +以下问题需要在技术评审中与临床研究专家确认: + +### 优先级与范围 + +1. **IIT 项目中,以下 7 个维度的重要性排序是什么?** 哪些是"必须有",哪些是"有了更好"? +2. **D5(安全性)和 D7(药物管理)在 IIT 中的覆盖率有多高?** 是不是很多 IIT 项目并不涉及? +3. **三个阶段的划分是否合理?** 是否有维度需要提前或推迟? + +### 规则可行性 + +4. **时序逻辑的通用性**:知情同意日 < 筛选日 < 基线日,这个时序在所有 IIT 中都成立吗? +5. **访视窗口**:IIT 中 ±N 天的窗口一般怎么定?是写在方案里的还是有行业惯例? +6. **数据缺失率的"红线"**:IIT 行业是否有通用的缺失率阈值标准? +7. **跨表单校验的实用性**:性别 vs 妊娠检查这类跨表单逻辑在 IIT 中常见吗? + +### 临床接受度 + +8. **AI 做因果关系"初步评估"**,PI 是否愿意参考?还是更倾向完全自行判断? +9. **自动生成的 eQuery 语气和内容**,是否需要 PI 先审核再发给 CRC? +10. **健康度评分**:PI 能否接受一个 AI 打分?评分维度和权重怎么定才合理? + +### 数据源 + +11. **REDCap 中 Schedule of Assessment 的信息**是否足够完整?还是需要额外配置? +12. **AE 表单在 IIT REDCap 项目中的标准化程度如何?** 字段命名是否有规范? + +--- + +## 十、总结 + +``` +┌──────────────────────────────────────────────────────────────────────┐ +│ │ +│ 研究方案 ──→ 变量清单 ──→ 质控规则 ──→ 三级质控状态 ──→ 多维报告 │ +│ (7大维度) (变量/事件/记录) (7大章节) │ +│ │ +│ 阶段一:D1 入组 + D3 变量 + D4 eQuery ── 数据质量基石 │ +│ 阶段二:D2 完整性 + D6 方案偏离 ── 方案合规覆盖 │ +│ 阶段三:D5 安全性 + D7 药物管理 ── 全维度覆盖 │ +│ │ +│ 确定性规则 → HardRuleEngine(可审计、可复现) │ +│ 概率性判断 → SoftRuleEngine + LLM(AI 建议 + 人类确认) │ +│ │ +│ 工具层不变(4 工具),内涵逐步扩展 │ +│ 数据架构一次设计到位(三级状态表 + rule_category 字段) │ +│ │ +└──────────────────────────────────────────────────────────────────────┘ +``` + +**本文档的目的不是确定"怎么做",而是确定"做什么、按什么顺序做、做到什么程度"。** 经团队和临床专家评审认可后,再基于此形成具体的开发计划和技术设计。 diff --git a/docs/03-业务模块/IIT Manager Agent/04-开发计划/V3.0全新开发计划/用户权限与多租户三阶段实施计划.md b/docs/03-业务模块/IIT Manager Agent/04-开发计划/V3.0全新开发计划/用户权限与多租户三阶段实施计划.md new file mode 100644 index 00000000..222404b6 --- /dev/null +++ b/docs/03-业务模块/IIT Manager Agent/04-开发计划/V3.0全新开发计划/用户权限与多租户三阶段实施计划.md @@ -0,0 +1,442 @@ +# CRA Agent V3.0 — 用户权限与多租户三阶段实施计划 + +> **版本:** v1.0 +> **日期:** 2026-02-28 +> **状态:** 待实施 +> **前置依赖:** P0 + P1 已完成(质控流水线 + ChatOrchestrator) +> **关联文档:** +> - [V3.0 全新开发计划](./V3.0全新开发计划.md) +> - [统一数字 CRA 质控平台 PRD](./统一数字%20CRA%20质控平台产品需求文档(PRD).md) + +--- + +## 1. 问题背景 + +### 1.1 当前系统存在的三个核心问题 + +**问题 A:业务端 CRA 质控平台无法运行** + +- 所有业务端页面(驾驶舱、eQuery、报告、AI 对话)从 URL 参数 `?projectId=xxx` 获取项目 ID +- 没有任何机制把登录用户与 IIT 项目关联,导致页面显示"请先选择一个项目" +- 侧边栏项目信息为硬编码假数据("IIT-2026-001") + +**问题 B:运营管理端 IIT 项目管理定位不当** + +- "IIT 项目管理"与"Prompt 管理""租户管理""用户管理"混在同一菜单中 +- 普通项目运营人员不应看到平台级管理功能 +- 药企/医院客户的项目管理人员不应看到其他企业的项目 + +**问题 C:用户-项目关联机制缺失** + +- `IitUserMapping.systemUserId` 存储的是随意字符串(当前值为 `"FengZhiBo"`),未关联平台 `User` 表 +- `IitProject` 没有 `tenantId` 字段,无法实现租户隔离 +- IIT API 路由没有认证中间件,无访问控制 + +### 1.2 当前数据库实际状态 + +``` +-- iit_schema.projects(1 条记录) +id: test0102-pd-study | name: test0207 | status: active + +-- iit_schema.user_mappings(1 条记录) +system_user_id: FengZhiBo | wecom_user_id: FengZhiBo | role: PI + +-- public.users(1 条记录) +id: user-mock-001 | name: 测试用户 | role: user +(注:此用户为占位假数据,非真实用户) +``` + +两张表之间没有任何外键关联。 + +### 1.3 当前角色体系 + +**平台级角色(public.users.role):** + +| 角色 | 说明 | 当前使用 | +|------|------|---------| +| SUPER_ADMIN | 平台超级管理员 | 可访问运营管理端全部功能 | +| PROMPT_ENGINEER | Prompt 工程师 | 可访问运营管理端 | +| HOSPITAL_ADMIN | 医院管理员 | 已定义,未使用 | +| PHARMA_ADMIN | 药企管理员 | 已定义,未使用 | +| DEPARTMENT_ADMIN | 科室管理员 | 已定义,未使用 | +| USER | 普通用户 | 默认角色 | + +**IIT 项目级角色(iit_schema.user_mappings.role):** + +| 角色 | 说明 | 当前使用 | +|------|------|---------| +| PI | 主要研究者 | 已使用 | +| Sub-I | 次要研究者 | 已定义 | +| CRC | 临床研究协调员 | 已定义 | +| CRA | 临床监查员 | 已定义 | +| DM | 数据管理员 | 已定义 | +| Statistician | 统计师 | 已定义 | +| Other | 其他 | 已定义 | + +**缺失的角色:** + +| 角色 | 层级 | 说明 | +|------|------|------| +| **PM** | IIT 项目级 | 项目管理员,负责项目配置和管理 | +| **IIT_OPERATOR** | 平台级 | IIT 项目运营,负责为客户创建和配置项目(Phase 2 实现) | + +--- + +## 2. 三阶段实施计划 + +``` +Phase 1(立即) Phase 2(近期) Phase 3(中期) +让业务端能跑 用户-项目关联 多租户隔离 +───────────────── → ──────────────────── → ───────────────── +自动选中活跃项目 systemUserId 关联 User IitProject 加 tenantId +Provider 注入上下文 /my-projects API 项目按租户过滤 +管理端侧边栏分组 项目角色权限矩阵 PHARMA_ADMIN 自助管理 +配置功能补全 IIT 路由加认证 + IIT_OPERATOR 平台角色 +``` + +--- + +## 3. Phase 1:让业务端能跑(预估 0.5 天) + +### 3.1 目标 + +- CRA 质控平台业务端页面能正常加载和显示数据 +- 运营管理端 IIT 项目管理菜单位置合理 +- 项目配置功能补全(定时质控、变量清单) + +### 3.2 方案 + +#### 3.2.1 创建 IitProjectContext Provider + +**设计原则:** 通用方案,适用于用户关联 0 / 1 / N 个项目的所有场景。 + +**核心逻辑:** + +``` +用户进入 /iit → IitProjectProvider 初始化 + → 调用 GET /api/v1/admin/iit-projects 获取项目列表 + → 过滤 status = 'active' + → 0 个 → 显示空状态页:"暂无关联的 IIT 项目" + → 1 个 → 自动选中,不弹选择器 + → N 个 → 恢复 localStorage 上次选择;若无记录,默认选第一个 + → 用户可随时通过侧边栏顶部下拉选择器切换项目 + → 切换后写入 localStorage,子页面自动刷新 + → 通过 useIitProject() hook 暴露: + { projectId, project, projects, loading, switchProject(id) } +``` + +**项目选择器 UI:** + +放在 IitLayout 侧边栏顶部(替换当前硬编码的项目名称),具体表现: + +- 1 个项目:显示项目名称(纯文本,无下拉箭头) +- N 个项目:显示当前项目名 + 下拉箭头,点击弹出 Select 选择器 +- 选择器选项:项目名称 + 项目编号(如 "原发性痛经队列研究 / IIT-2026-001") +- 切换项目后,所有子页面通过 Context 自动获取新 projectId 并刷新数据 + +**改动文件:** + +| 文件 | 改动 | +|------|------| +| `frontend-v2/src/modules/iit/context/IitProjectContext.tsx` | 新建 Context + Provider + useIitProject hook | +| `frontend-v2/src/modules/iit/IitLayout.tsx` | 集成 Provider,侧边栏顶部改为项目选择器 | +| `frontend-v2/src/modules/iit/pages/DashboardPage.tsx` | 删除 URL 参数读取,改用 `useIitProject()` | +| `frontend-v2/src/modules/iit/pages/EQueryPage.tsx` | 同上 | +| `frontend-v2/src/modules/iit/pages/ReportsPage.tsx` | 同上 | +| `frontend-v2/src/modules/iit/pages/AiStreamPage.tsx` | 同上 | +| `frontend-v2/src/modules/iit/pages/AiChatPage.tsx` | 同上 | + +#### 3.2.2 管理端侧边栏三区重组(方案 B:逻辑拆分) + +**决策背景:** 概念上存在三种管理职能(平台管理、项目运营、商务运营),但当前阶段团队规模小,同一人可能跨职能操作。采用**方案 B(逻辑拆分)**:保留单一 `/admin` 入口,侧边栏按职能分为三个带标题的菜单组,通过 RBAC 控制每个角色看到哪些组。未来用户群体分化后可平滑升级为物理拆分(独立路由 + 独立 Layout)。 + +**侧边栏结构(SUPER_ADMIN 视角,看到全部):** + +``` +┌─ 平台管理 ─────────────┐ +│ 运营概览 │ +│ Prompt 管理 │ +│ 系统知识库 │ +│ LLM 配置 │ +│ 系统设置 │ +├─ 项目运营 ─────────────┤ +│ IIT 项目管理 │ +├─ 商务运营 ─────────────┤ +│ 租户管理 │ +│ 用户管理 │ +└────────────────────────┘ +``` + +**各角色可见性:** + +| 菜单组 | SUPER_ADMIN | PROMPT_ENGINEER | IIT_OPERATOR | PHARMA_ADMIN | +|--------|:-----------:|:---------------:|:------------:|:------------:| +| 平台管理 | 全部 | Prompt 管理 + 系统知识库 | - | - | +| 项目运营 | 全部项目 | - | 全部项目 | 本租户项目 | +| 商务运营 | 全部 | - | - | - | + +**改动文件:** `frontend-v2/src/framework/layout/AdminLayout.tsx` + +#### 3.2.3 项目配置功能补全 + +**A. 定时质控配置 UI** + +数据库 `iit_schema.projects` 已有 `cron_enabled` 和 `cron_expression` 字段,前端未暴露。 + +在 `IitProjectDetailPage.tsx` 的"REDCap 配置"Tab 中增加: +- 定时质控开关(Switch,绑定 `cronEnabled`) +- Cron 表达式输入(Input + 预设选项:"每天 8:00" / "每周一 9:00" / 自定义) + +**B. 变量清单 Tab** + +后端 `GET /:id/field-metadata` API 已存在。在 `IitProjectDetailPage.tsx` 新增第 5 个 Tab"变量清单",展示 REDCap 变量表格。 + +**C. PM 角色新增** + +在 `iitUserMappingService.ts` 的 `getRoleOptions()` 中添加: +``` +{ value: 'PM', label: '项目管理员 (PM)' } +``` + +### 3.3 Phase 1 不做的事 + +- 不改数据库 Schema +- 不做用户-项目关联(systemUserId 改造) +- 不加认证中间件 +- 不做租户隔离 + +--- + +## 4. Phase 2:用户-项目关联 + 角色权限(预估 2 天) + +### 4.1 目标 + +- 登录用户自动看到自己关联的 IIT 项目 +- IIT 项目角色对应明确的权限 +- IIT API 路由有认证保护 +- 运营管理端按角色控制菜单可见性 +- 新增 IIT_OPERATOR 平台角色,使项目运营人员无需 SUPER_ADMIN 权限即可创建和配置 IIT 项目 + +### 4.2 数据模型改动 + +#### 4.2.1 IitUserMapping 增强 + +```prisma +model IitUserMapping { + // ... 现有字段 ... + systemUserId String @map("system_user_id") // 改为必须是 User.id + // 新增: + userId String? @map("user_id") // 平台 User 表外键(可选,渐进式关联) + + // 新增关联 + user User? @relation(fields: [userId], references: [id]) +} +``` + +**迁移策略:** +- 新增 `userId` 字段(nullable),不破坏现有数据 +- 新建用户映射时要求填写真实 `userId` +- 旧数据逐步补齐 + +#### 4.2.2 IitProject 增强(为 Phase 3 预留) + +```prisma +model IitProject { + // ... 现有字段 ... + // 新增: + tenantId String? @map("tenant_id") // 租户归属(Phase 3 正式启用) +} +``` + +### 4.3 API 改动 + +#### 4.3.1 新增接口 + +``` +GET /api/v1/iit/my-projects +``` + +逻辑: +1. 从 JWT Token 获取当前用户 `userId` +2. 查询 `IitUserMapping WHERE userId = :userId` +3. 返回关联的项目列表 + 用户在每个项目中的角色 + +响应示例: +```json +{ + "projects": [ + { + "id": "test0102-pd-study", + "name": "test0207", + "status": "active", + "myRole": "PI", + "description": "原发性痛经队列研究" + } + ] +} +``` + +#### 4.3.2 IIT 路由加认证 + +所有 `/api/v1/admin/iit-projects` 路由添加 `authenticate` 中间件。 + +#### 4.3.3 项目级权限检查 + +``` +中间件:requireProjectRole(projectId, allowedRoles) + → 查 IitUserMapping WHERE projectId AND userId + → 检查 role 是否在 allowedRoles 中 +``` + +### 4.4 项目角色权限矩阵 + +| 功能 | PM | PI | CRA | CRC | DM | Sub-I | Statistician | +|------|----|----|-----|-----|----|-------|-------------| +| 查看驾驶舱 | 读 | 读 | 读 | 读 | 读 | 读 | 读 | +| 查看/处理 eQuery | 读写 | 读 | 读写 | 读写 | 读 | 读 | - | +| 查看报告 | 读 | 读 | 读 | 读 | 读 | 读 | 读 | +| AI 对话 | 是 | 是 | 是 | 是 | 是 | 是 | 是 | +| 项目配置(REDCap/规则/通知/知识库) | 读写 | - | - | - | - | - | - | +| 查看变量清单 | 读 | 读 | 读 | 读 | 读 | 读 | 读 | +| 管理质控规则 | 读写 | - | 读 | - | 读 | - | - | + +### 4.5 管理端菜单权限(承接 Phase 1 三区分组) + +Phase 1 已完成侧边栏三区分组的 UI 结构,Phase 2 加入真正的 RBAC 控制: + +| 角色 | 可进入 /admin | 平台管理 | 项目运营 | 商务运营 | +|------|:------------:|:-------:|:-------:|:-------:| +| SUPER_ADMIN | 是 | 全部 | 全部项目 | 全部 | +| PROMPT_ENGINEER | 是 | Prompt + 知识库 | - | - | +| IIT_OPERATOR(本阶段新增) | 是 | - | 全部项目 | - | +| PHARMA_ADMIN | 是 | - | 本租户项目 | - | +| HOSPITAL_ADMIN | 是 | - | 本租户项目 | - | +| USER | 否 | - | - | - | + +**实现方式:** +- 路由守卫:`/admin` 入口检查 `user.role` 是否在允许列表中 +- 侧边栏渲染:根据 `user.role` 过滤 `menuItems`,角色看不到的组直接不渲染 +- API 层:每个管理端 API 添加 `authenticate` + `requireRole(allowedRoles)` 中间件 + +### 4.6 前端改动 + +- `IitProjectContext` 改为调用 `/api/v1/iit/my-projects` +- 根据 `myRole` 控制业务端 Tab 可见性(PM 看到更多设置入口) +- `AdminLayout.tsx` 按 `user.role` 过滤侧边栏菜单组可见性(Phase 1 已分组,此处加权限判断) +- `/admin` 路由入口添加角色守卫 + +--- + +## 5. Phase 3:多租户隔离(预估 1.5 天) + +### 5.1 目标 + +- IIT 项目按租户隔离,药企只能看到自己的项目 +- PHARMA_ADMIN 可在运营管理端管理自己租户的 IIT 项目 +- SUPER_ADMIN 可跨租户查看所有项目 + +### 5.2 数据模型 + +启用 Phase 2 预留的 `tenantId` 字段,设为必填: + +```prisma +model IitProject { + tenantId String @map("tenant_id") + tenant Tenant @relation(fields: [tenantId], references: [id]) +} +``` + +### 5.3 API 改动 + +所有项目查询接口添加租户过滤: + +```sql +-- 非 SUPER_ADMIN: +SELECT * FROM iit_schema.projects WHERE tenant_id = :currentUserTenantId + +-- SUPER_ADMIN: +SELECT * FROM iit_schema.projects -- 无限制 +``` + +### 5.4 项目创建流程 + +``` +Phase 2 完成后: + SUPER_ADMIN 或 IIT_OPERATOR 创建项目 → 手动指定 tenantId(选择为哪个客户创建) + +Phase 3 完成后(客户自助): + PHARMA_ADMIN 创建项目 → 自动绑定自己的 tenantId +``` + +### 5.5 前端改动 + +- 运营管理端项目列表:SUPER_ADMIN 看到全部 + 租户筛选器;PHARMA_ADMIN 只看到自己的 +- 项目创建表单:SUPER_ADMIN 需选择租户;PHARMA_ADMIN 自动绑定 + +--- + +## 6. 实施优先级总结 + +| 阶段 | 核心内容 | 预估工时 | 数据库改动 | 前置条件 | +|------|---------|---------|-----------|---------| +| **Phase 1** | Provider + 管理端分组 + 配置补全 | 0.5 天 | 无 | 无 | +| **Phase 2** | userId 关联 + /my-projects + 角色权限 + 认证 + IIT_OPERATOR 角色 | 2 天 | 新增 userId 列、tenantId 列、IIT_OPERATOR 角色枚举 | Phase 1 | +| **Phase 3** | 租户隔离 + PHARMA_ADMIN 自助 | 1.5 天 | tenantId 改为必填 | Phase 2 | + +### Phase 1 立即可执行的原因 + +- 0 数据库改动 +- 只改前端代码 +- 当前只有 1 个活跃项目 + 1 个测试用户,自动选中即可 +- 让业务端立即可演示 + +### Phase 2 的触发条件 + +- 有真实用户需要登录系统 +- 需要区分不同用户看到不同项目 +- 需要保护 API 安全 + +### Phase 3 的触发条件 + +- 有多个客户(药企/医院)同时使用系统 +- 需要租户级别的数据隔离 + +--- + +## 7. 附录:改动文件清单 + +### Phase 1 + +| 文件 | 类型 | 说明 | +|------|------|------| +| `frontend-v2/src/modules/iit/context/IitProjectContext.tsx` | 新建 | 项目上下文 Provider | +| `frontend-v2/src/modules/iit/IitLayout.tsx` | 修改 | 集成 Provider + 真实项目信息 | +| `frontend-v2/src/modules/iit/pages/DashboardPage.tsx` | 修改 | 改用 useIitProject() | +| `frontend-v2/src/modules/iit/pages/EQueryPage.tsx` | 修改 | 改用 useIitProject() | +| `frontend-v2/src/modules/iit/pages/ReportsPage.tsx` | 修改 | 改用 useIitProject() | +| `frontend-v2/src/modules/iit/pages/AiStreamPage.tsx` | 修改 | 改用 useIitProject() | +| `frontend-v2/src/modules/iit/pages/AiChatPage.tsx` | 修改 | 改用 useIitProject() | +| `frontend-v2/src/framework/layout/AdminLayout.tsx` | 修改 | 侧边栏分组 | +| `frontend-v2/src/modules/admin/pages/IitProjectDetailPage.tsx` | 修改 | 定时质控配置 + 变量清单 Tab | +| `backend/src/modules/admin/iit-projects/iitUserMappingService.ts` | 修改 | 添加 PM 角色 | + +### Phase 2 + +| 文件 | 类型 | 说明 | +|------|------|------| +| `backend/prisma/schema.prisma` | 修改 | IitUserMapping 加 userId、IitProject 加 tenantId | +| `backend/src/modules/iit-manager/routes/index.ts` | 修改 | 加 /my-projects 路由 + authenticate 中间件 | +| `backend/src/common/auth/auth.middleware.ts` | 修改 | 新增 requireProjectRole 中间件 | +| `backend/prisma/schema.prisma` (Role enum) | 修改 | 新增 IIT_OPERATOR 角色枚举值 | +| `frontend-v2/src/modules/iit/context/IitProjectContext.tsx` | 修改 | 改调 /my-projects | +| `frontend-v2/src/framework/layout/AdminLayout.tsx` | 修改 | 按角色控制菜单组可见性(IIT_OPERATOR 只看项目运营) | + +### Phase 3 + +| 文件 | 类型 | 说明 | +|------|------|------| +| `backend/prisma/schema.prisma` | 修改 | tenantId 改为必填 | +| `backend/src/modules/admin/iit-projects/iitProjectService.ts` | 修改 | 查询加租户过滤 | +| `frontend-v2/src/modules/admin/pages/IitProjectListPage.tsx` | 修改 | 租户筛选器 | +| `frontend-v2/src/modules/admin/pages/IitProjectDetailPage.tsx` | 修改 | 创建时选择租户 | diff --git a/docs/03-业务模块/IIT Manager Agent/04-开发计划/V3.0全新开发计划/质控引擎V3.1架构升级-五级数据结构与多维报告开发计划.md b/docs/03-业务模块/IIT Manager Agent/04-开发计划/V3.0全新开发计划/质控引擎V3.1架构升级-五级数据结构与多维报告开发计划.md new file mode 100644 index 00000000..63594dac --- /dev/null +++ b/docs/03-业务模块/IIT Manager Agent/04-开发计划/V3.0全新开发计划/质控引擎V3.1架构升级-五级数据结构与多维报告开发计划.md @@ -0,0 +1,920 @@ +# 质控引擎 V3.1 架构升级 — 五级数据结构与多维报告开发计划 + +> **版本**:V1.1(合并架构团队二次评审 4 条关键建议) +> **日期**:2026-03-01 +> **定位**:基于架构团队评审意见,在 V3.0 三级数据结构基础上升级为 CDISC ODM 五级结构,分三批次落地 +> **前置文档**: +> - [五层数据架构方案评审反馈](../../09-技术评审报告/五层数据架构方案评审反馈.md)(采纳/暂缓清单) +> - [CRA Agent 质控体系全景技术路径(策略评审稿)](../../00-系统设计/CRA%20Agent%20质控体系全景技术路径(策略评审稿).md) +> - [V3.0 三级数据结构技术设计](./质控引擎架构升级-三级数据结构与多模式触发技术设计.md)(历史版本,供参考) +> - 架构团队:核心数据架构与业务落地白皮书 / 核心转换机制白皮书 / Skill 化配置架构技术设计 / CRA 质控报告自动化生成与 LLM 友好型设计规范 +> +> **V1.1 变更记录**: +> - 新增 3.3 节:状态优先级与 SKIPPED 处理 +> - 新增 3.8 节:Record-Level Context(跨表单质控上下文全拉取) +> - 新增 3.9 节:eQuery 自动闭环(State Transition Hook) +> - 修正 6.3 节:D2 缺失率增加 Event-Aware 时序过滤 +> - 修正 3.4 节:聚合防抖粒度从项目级细化为受试者级 + +--- + +## 1. V3.0 → V3.1 升级概要 + +### 1.1 为什么从三级升到五级 + +V3.0 设计的三级结构是 Record → Event → Field,足以覆盖常规表单。但架构团队指出了一个关键盲区:**REDCap 重复表单(Repeating Instruments)**。 + +一个患者可以在同一个访视下填写多条 AE(不良事件)、多次合并用药。没有 Form 层和 Instance 层,就无法精确定位"3 号受试者 → 随访 2 → AE 表 → 第 2 条 AE → 事件名称字段"。 + +### 1.2 五级坐标体系 + +``` +Record(受试者) + └── Event(访视/事件) + └── Form(表单) ← 新增 + └── Instance(实例) ← 新增(核心突破) + └── Field(变量/字段) +``` + +每一个质控状态、每一条 eQuery,都必须绑定在这个五维坐标上,坐标不完整不落盘。 + +### 1.3 与 V3.0 的主要差异 + +| 维度 | V3.0 | V3.1 | +|------|------|------| +| 数据层级 | 3 级(Record → Event → Field) | 5 级(+ Form + Instance) | +| `qc_field_status` 唯一键 | project × record × event × field | project × record × event × form × instance × field | +| 规则分类 | `inclusion`/`exclusion`/`lab_values`/`logic_check` | D1-D7 七大维度 | +| 字段语义化 | 无 | `IitFieldMapping.semanticLabel`(反向映射) | +| 冒泡机制 | 应用层逐级 UPDATE | 异步防抖聚合(避免并发死锁) | +| 报告结构 | 扁平单章 | 按 D1-D7 分章节 | +| REDCap InstanceID | 未处理 | `RedcapAdapter` 层强制标准化 | + +--- + +## 2. 数据库设计 + +### 2.1 新增表:`qc_field_status`(变量级质控状态 — 五级坐标) + +每个 **project × record × event × form × instance × field** 唯一一行,反映最新质控状态。 + +```sql +CREATE TABLE iit_schema.qc_field_status ( + id TEXT PRIMARY KEY DEFAULT gen_random_uuid(), + project_id TEXT NOT NULL, + record_id TEXT NOT NULL, + event_id TEXT NOT NULL, + form_name TEXT NOT NULL, -- 表单名(如 ae_log, conmed_log) + instance_id INT NOT NULL DEFAULT 1, -- 实例编号(非重复表单 = 1) + field_name TEXT NOT NULL, + + -- 质控结果 + status TEXT NOT NULL, -- 'PASS' | 'FAIL' | 'WARNING' + rule_id TEXT, + rule_name TEXT, + rule_category TEXT, -- 'D1' | 'D2' | 'D3' | 'D5' | 'D6' | 'D7' + severity TEXT, -- 'critical' | 'warning' | 'info' + message TEXT, + actual_value TEXT, + expected_value TEXT, + + -- 溯源 + source_qc_log_id TEXT, + triggered_by TEXT NOT NULL, -- 'webhook' | 'cron' | 'manual' + last_qc_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- 唯一约束:五级坐标 + CONSTRAINT uq_field_status + UNIQUE (project_id, record_id, event_id, form_name, instance_id, field_name), + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- 高频查询索引 +CREATE INDEX idx_fs_record ON iit_schema.qc_field_status (project_id, record_id); +CREATE INDEX idx_fs_event ON iit_schema.qc_field_status (project_id, record_id, event_id); +CREATE INDEX idx_fs_fail ON iit_schema.qc_field_status (project_id, status) WHERE status IN ('FAIL', 'WARNING'); +CREATE INDEX idx_fs_cat ON iit_schema.qc_field_status (project_id, rule_category); +``` + +### 2.2 新增表:`qc_event_status`(事件级质控状态) + +每个 **project × record × event** 唯一一行,由 `qc_field_status` 聚合。 + +```sql +CREATE TABLE iit_schema.qc_event_status ( + id TEXT PRIMARY KEY DEFAULT gen_random_uuid(), + project_id TEXT NOT NULL, + record_id TEXT NOT NULL, + event_id TEXT NOT NULL, + event_label TEXT, + + -- 聚合状态 + status TEXT NOT NULL, -- 最严重的子级状态 + fields_total INT NOT NULL DEFAULT 0, + fields_passed INT NOT NULL DEFAULT 0, + fields_failed INT NOT NULL DEFAULT 0, + fields_warning INT NOT NULL DEFAULT 0, + + -- 维度计数(方便多维报告直接读取) + d1_issues INT NOT NULL DEFAULT 0, + d2_issues INT NOT NULL DEFAULT 0, + d3_issues INT NOT NULL DEFAULT 0, + d5_issues INT NOT NULL DEFAULT 0, + d6_issues INT NOT NULL DEFAULT 0, + d7_issues INT NOT NULL DEFAULT 0, + + -- 表单级摘要 + forms_checked TEXT[] DEFAULT '{}', + top_issues JSONB DEFAULT '[]', + + triggered_by TEXT NOT NULL, + last_qc_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT uq_event_status + UNIQUE (project_id, record_id, event_id), + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_es_record ON iit_schema.qc_event_status (project_id, record_id); +CREATE INDEX idx_es_fail ON iit_schema.qc_event_status (project_id, status) WHERE status IN ('FAIL', 'WARNING'); +``` + +### 2.3 改造表:`record_summary` + +在现有 `IitRecordSummary` 上新增聚合字段: + +```sql +ALTER TABLE iit_schema.record_summary + ADD COLUMN IF NOT EXISTS events_total INT DEFAULT 0, + ADD COLUMN IF NOT EXISTS events_passed INT DEFAULT 0, + ADD COLUMN IF NOT EXISTS events_failed INT DEFAULT 0, + ADD COLUMN IF NOT EXISTS events_warning INT DEFAULT 0, + ADD COLUMN IF NOT EXISTS fields_total INT DEFAULT 0, + ADD COLUMN IF NOT EXISTS fields_passed INT DEFAULT 0, + ADD COLUMN IF NOT EXISTS fields_failed INT DEFAULT 0, + ADD COLUMN IF NOT EXISTS d1_issues INT DEFAULT 0, + ADD COLUMN IF NOT EXISTS d2_issues INT DEFAULT 0, + ADD COLUMN IF NOT EXISTS d3_issues INT DEFAULT 0, + ADD COLUMN IF NOT EXISTS d5_issues INT DEFAULT 0, + ADD COLUMN IF NOT EXISTS d6_issues INT DEFAULT 0, + ADD COLUMN IF NOT EXISTS d7_issues INT DEFAULT 0, + ADD COLUMN IF NOT EXISTS top_issues JSONB DEFAULT '[]'; +``` + +### 2.4 改造表:`IitQcLog` + `IitEquery` 增加 Instance 层 + +```sql +ALTER TABLE iit_schema.qc_logs + ADD COLUMN IF NOT EXISTS form_name TEXT, + ADD COLUMN IF NOT EXISTS instance_id INT DEFAULT 1; + +ALTER TABLE iit_schema.iit_equeries + ADD COLUMN IF NOT EXISTS instance_id INT DEFAULT 1; +``` + +### 2.5 改造表:`IitFieldMapping` 增加反向语义标签 + +```sql +ALTER TABLE iit_schema.field_mapping + ADD COLUMN IF NOT EXISTS semantic_label TEXT, -- 中文语义标签(如"谷丙转氨酶(ALT)") + ADD COLUMN IF NOT EXISTS form_name TEXT, -- 所属表单 + ADD COLUMN IF NOT EXISTS rule_category TEXT; -- 所属维度 D1-D7 +``` + +### 2.6 保留不变:`qc_logs`(审计日志) + +继续作为追加型审计日志,每次质控执行新增一行,永不删改。新增 `form_name` 和 `instance_id` 字段后,日志也具备完整的五级坐标。 + +### 2.7 完整数据模型 + +``` + REDCap 原始数据(5 层结构) + │ + │ 质控引擎执行 + ▼ + ┌─────────────┐ ┌──────────────────┐ + │ qc_logs │ │ qc_field_status │ + │ (审计日志) │←ref─│ (变量级, 5层坐标) │ + │ 追加型 │ │ + rule_category │ + └─────────────┘ └────────┬─────────┘ + │ 异步防抖聚合 + ┌────────▼─────────┐ + │ qc_event_status │ + │ (事件级) │ + │ + d1..d7_issues │ + └────────┬─────────┘ + │ 异步防抖聚合 + ┌────────▼─────────┐ + │ record_summary │ + │ (记录级) │ + │ + d1..d7_issues │ + └────────┬─────────┘ + │ 聚合 + ┌────────▼─────────┐ ┌──────────────┐ + │ qc_project_stats │────►│ qc_reports │ + │ (项目级) │ 生成 │ (LLM 报告) │ + └──────────────────┘ └──────────────┘ +``` + +--- + +## 3. 关键工程设计 + +### 3.1 REDCap InstanceID 标准化(RedcapAdapter 层洗线) + +**问题**:REDCap API 对 `redcap_repeat_instance` 的返回值不一致: +- 非重复表单:字段不存在或为空字符串 +- 重复表单第一行:有时为空字符串,有时为 `"1"` +- 重复表单后续行:`"2"`, `"3"`, ... + +**解法**:在 `RedcapAdapter` 返回数据之前,强制标准化所有记录: + +```typescript +// RedcapAdapter.ts — 新增 normalizeInstance() 方法 + +private normalizeInstances(records: RedcapRecord[]): NormalizedRecord[] { + return records.map(record => { + const formName = record.redcap_repeat_instrument || this.inferFormName(record); + let instanceId: number; + + if (!record.redcap_repeat_instrument) { + // 非重复表单:强制 instanceId = 1 + instanceId = 1; + } else { + // 重复表单:空或无效值强制为 1,否则取实际值 + const raw = record.redcap_repeat_instance; + instanceId = (raw && !isNaN(Number(raw)) && Number(raw) > 0) + ? Number(raw) + : 1; + } + + return { + ...record, + _normalized: { + recordId: String(record.record_id), + eventId: record.redcap_event_name || 'default', + formName, + instanceId, + }, + }; + }); +} +``` + +**原则**:`RedcapAdapter` 之后的所有下游(引擎、状态表、报告)都使用标准化后的五级坐标,无需二次处理。 + +### 3.2 QcExecutor 重构(统一执行入口) + +将分散在 `SkillRunner`、`iitBatchController`、`QcReportService` 中的执行逻辑统一收归: + +```typescript +class QcExecutor { + + /** + * 单记录单事件质控(实时触发 / AI 调用) + */ + async executeSingle( + projectId: string, + recordId: string, + eventId: string, + options?: { triggeredBy: 'webhook' | 'manual' } + ): Promise { + // 1. RedcapAdapter 拉取并标准化(含 InstanceID 洗线) + // 2. 加载适用规则(按 applicableEvents + applicableForms 过滤) + // 3. 逐 Form × Instance × Field 执行规则 + // 4. 写入 qc_logs(追加审计) + // 5. upsert qc_field_status(五级坐标) + // 6. 标记需聚合(推入防抖队列,不立即冒泡) + } + + /** + * 批量质控(定时 / 手动 / 一键全量) + */ + async executeBatch( + projectId: string, + options?: { triggeredBy: 'cron' | 'manual' } + ): Promise { + // 1. RedcapAdapter 拉取全量并标准化 + // 2. 基线数据合并 + // 3. 逐 record × event 复用 executeSingle 核心逻辑 + // 4. 批量完成后触发一次聚合(而非逐条) + // 5. 刷新 qc_reports + } + + /** + * 异步防抖聚合(解决冒泡并发死锁问题) + */ + async aggregateDeferred(projectId: string): Promise { + // 详见 3.3 节 + } +} +``` + +### 3.3 状态优先级与 SKIPPED 处理 + +聚合时始终取"最严重的状态": + +``` +FAIL (3) > WARNING (2) > UNCERTAIN (1) > PASS (0) +``` + +- **事件状态** = 其下所有变量状态中最严重的 +- **记录状态** = 其下所有事件状态中最严重的 + +**SKIPPED 处理**:当某个变量在某个事件中不存在时(如随访期没有 age 字段),**不写入 `qc_field_status`**,不参与聚合计算。只有被规则实际检查过的变量才写入。 + +### 3.4 状态冒泡:异步防抖聚合方案 + +**问题**:如果批量执行 1000 条规则,每次 Field 变更都同步 UPDATE 上级,会导致数据库行锁冲突甚至死锁。 + +**解法**:冒泡不做同步级联,改为"批量完成后统一聚合"。 + +``` +执行阶段(高频写入,无锁竞争): + HardRuleEngine 执行 1000 条规则 + ↓ 逐条 upsert + qc_field_status(各自独立行,无锁冲突) + +聚合阶段(受试者级防抖,避免项目级锁竞争): + executeSingle 完成后 + ↓ 推入 pg-boss 防抖队列 + singletonKey: `aggregate_${projectId}_${recordId}` + ↓ 只重算该受试者的 event_status 和 record_summary + + executeBatch 完成后 + ↓ 直接调用 aggregateDeferred(projectId) + ↓ 一次性 SQL 聚合全项目 + + 项目级统计(qc_project_stats) + ↓ 由独立的低频定时任务刷新(如每 5 分钟) + ↓ 或在 aggregateDeferred 末尾追加一次 +``` + +```typescript +async aggregateDeferred(projectId: string): Promise { + // 1. 事件级聚合:一条 SQL 搞定 + await prisma.$executeRaw` + INSERT INTO iit_schema.qc_event_status + (id, project_id, record_id, event_id, status, + fields_total, fields_passed, fields_failed, fields_warning, + d1_issues, d2_issues, d3_issues, d5_issues, d6_issues, d7_issues, + triggered_by, last_qc_at, created_at, updated_at) + SELECT + gen_random_uuid(), + fs.project_id, fs.record_id, fs.event_id, + -- 最严重状态 + CASE + WHEN COUNT(*) FILTER (WHERE fs.status = 'FAIL') > 0 THEN 'FAIL' + WHEN COUNT(*) FILTER (WHERE fs.status = 'WARNING') > 0 THEN 'WARNING' + ELSE 'PASS' + END, + COUNT(*), + COUNT(*) FILTER (WHERE fs.status = 'PASS'), + COUNT(*) FILTER (WHERE fs.status = 'FAIL'), + COUNT(*) FILTER (WHERE fs.status = 'WARNING'), + -- 维度计数 + COUNT(*) FILTER (WHERE fs.rule_category = 'D1' AND fs.status = 'FAIL'), + COUNT(*) FILTER (WHERE fs.rule_category = 'D2' AND fs.status = 'FAIL'), + COUNT(*) FILTER (WHERE fs.rule_category = 'D3' AND fs.status = 'FAIL'), + COUNT(*) FILTER (WHERE fs.rule_category = 'D5' AND fs.status = 'FAIL'), + COUNT(*) FILTER (WHERE fs.rule_category = 'D6' AND fs.status = 'FAIL'), + COUNT(*) FILTER (WHERE fs.rule_category = 'D7' AND fs.status = 'FAIL'), + 'manual', NOW(), NOW(), NOW() + FROM iit_schema.qc_field_status fs + WHERE fs.project_id = ${projectId} + GROUP BY fs.project_id, fs.record_id, fs.event_id + ON CONFLICT (project_id, record_id, event_id) + DO UPDATE SET + status = EXCLUDED.status, + fields_total = EXCLUDED.fields_total, + fields_passed = EXCLUDED.fields_passed, + fields_failed = EXCLUDED.fields_failed, + fields_warning = EXCLUDED.fields_warning, + d1_issues = EXCLUDED.d1_issues, + d2_issues = EXCLUDED.d2_issues, + d3_issues = EXCLUDED.d3_issues, + d5_issues = EXCLUDED.d5_issues, + d6_issues = EXCLUDED.d6_issues, + d7_issues = EXCLUDED.d7_issues, + updated_at = NOW() + `; + + // 2. 记录级聚合:同理从 qc_event_status 聚合到 record_summary + // 3. 项目级聚合:从 record_summary 聚合到 qc_project_stats +} +``` + +**核心优势**: +- 执行阶段只写 `qc_field_status`,各行互不冲突,可高并发 +- 聚合阶段用 SQL `INSERT ... ON CONFLICT` 一次性完成,无应用层循环 +- 实时触发用受试者级防抖(`singletonKey: aggregate_${projectId}_${recordId}`),多 CRC 同时录入不同受试者时互不干扰 +- 项目级统计(`qc_project_stats`)独立刷新,避免单行频繁锁竞争 + +### 3.5 规则分类体系(D1-D7 维度枚举) + +扩展 `QCRule.category` 和 `IitSkill.skillType`: + +| 维度代码 | 含义 | 对应规则类型 | 当前状态 | +|---------|------|------------|---------| +| `D1` | 入排合规性 | HardRule | 已有 `inclusion`/`exclusion`,需重新标注 | +| `D2` | 数据完整性 | CompletenessEngine(批次 C) | 未实现 | +| `D3` | 变量准确性 | HardRule | 已有 `lab_values`/`logic_check`,需重新标注 | +| `D4` | 数据质疑管理 | 状态机(eQuery 流转) | 已有 eQuery 表 | +| `D5` | 安全性监测 | HardRule + SoftRule(批次 C) | SoftRuleEngine 已有框架 | +| `D6` | 方案偏离 | HardRule + SoftRule(批次 C) | 未实现 | +| `D7` | 药物管理 | HardRule(批次 C) | 未实现 | + +**改造点**: +- `QCRule` 接口的 `category` 字段从 4 种值改为 D1-D7 +- 种子规则 `seed-iit-qc-rules.ts` 重新标注所有现有规则的维度 +- `qc_field_status.rule_category` 存储维度代码,支持按维度聚合 + +### 3.6 字段语义化(IitFieldMapping 反向增强) + +当前 `IitFieldMapping` 只用于 LLM 输入方向(alias→actual)。增强为双向: + +``` +LLM 输入方向(已有): "年龄" → age (LLM 对话中说"年龄",系统查 age) +LLM 输出方向(新增): age → "年龄(岁)" (报告中展示"年龄(岁)"而非 age) +``` + +**数据来源**:REDCap Data Dictionary 的 `field_label` 字段天然就是中文语义标签。项目初始化时自动同步: + +```typescript +async function syncSemanticLabels(projectId: string) { + const metadata = await redcapAdapter.exportMetadata(); + for (const field of metadata) { + await prisma.iitFieldMapping.upsert({ + where: { projectId_aliasName: { projectId, aliasName: field.field_name } }, + update: { semanticLabel: field.field_label, formName: field.form_name }, + create: { + projectId, + aliasName: field.field_name, + actualName: field.field_name, + semanticLabel: field.field_label, + formName: field.form_name, + fieldType: field.field_type, + }, + }); + } +} +``` + +**使用场景**:`QcReportService` 生成 LLM XML 时,用 `semanticLabel` 替代物理字段名。 + +### 3.7 LLM 三不原则(正式化为设计规范) + +所有 LLM-facing 输出必须遵循: + +| 原则 | 含义 | 实现方式 | +|------|------|---------| +| **不喂全量** | 只传 FAIL/WARNING 的切片 | `QcReportService` 从 `qc_field_status` WHERE status IN ('FAIL','WARNING') | +| **不喂物理字段** | 字段名用中文语义 | 查 `IitFieldMapping.semanticLabel` 替换 | +| **不让 LLM 算数** | 百分比、天数差等由 Node.js 预算 | 所有数值结论以"标签"形式传入(如"超窗 5 天"而非让 LLM 算日期差) | + +### 3.8 Record-Level Context:跨表单质控上下文全拉取 + +**问题**:REDCap DET Webhook 推送的 payload 只包含**当前保存的表单**数据。如果 CRC 保存了"实验室检查表",Webhook 只带这一个表单。但 D5 规则(如"ALT 异常但没报 AE")需要同时查看 AE 表的数据——拿不到就会误判。 + +**设计原则**:在 IIT 场景下,单个患者的全量数据通常只有几十到几百行(几 KB),不存在性能问题。因此,**不论 Webhook 传来了什么表单,`QcExecutor` 一律拉取该患者的全量数据**。 + +```typescript +// QcExecutor.executeSingle() 中的数据拉取逻辑 +async executeSingle(projectId: string, recordId: string, eventId: string, ...) { + // ⚠️ 关键:不论 Webhook 传来的 instrument 是什么,都拉全量 + // 这样跨表单规则(D5 AE 漏报、D6 合并用药禁忌)才能正确执行 + const allRecords = await redcapAdapter.exportRecords({ records: [recordId] }); + + // 标准化五级坐标(含 InstanceID 洗线) + const normalized = redcapAdapter.normalizeInstances(allRecords); + + // 按 event 分组,但在内存中保持全量,供跨表单规则访问 + const patientContext = this.buildPatientContext(normalized); + + // 执行规则时传入完整上下文 + for (const rule of applicableRules) { + const result = engine.executeRule(rule, patientContext); + // ... + } +} +``` + +**关键区别**: +- 旧设计(V3.0):`getRecordById()` 拉全量但 **merge 成扁平对象**,丢失五级结构 +- 新设计(V3.1):`exportRecords()` 拉全量且 **保持原始多行结构**,每行 = 一个 event × form × instance + +### 3.9 eQuery 自动闭环:State Transition Hook + +**问题**:当质控发现 Field 为 FAIL 时会生成 eQuery。但当 CRC 修正数据后,Webhook 再次触发质控,Field 变回 PASS——此时**没有任何机制自动关闭之前的 eQuery**。系统变成了"只开 Query 不销账"的半成品。 + +**设计**:在 `QcExecutor` 的 `upsert qc_field_status` 逻辑中,比较新旧状态,触发自动闭环。 + +```typescript +// QcExecutor 内部:upsert 前先读取旧状态 +async upsertFieldStatus(data: FieldStatusData): Promise { + const oldRecord = await prisma.qcFieldStatus.findUnique({ + where: { + uq_field_status: { + project_id: data.projectId, + record_id: data.recordId, + event_id: data.eventId, + form_name: data.formName, + instance_id: data.instanceId, + field_name: data.fieldName, + } + } + }); + + const oldStatus = oldRecord?.status; + + // 执行 upsert + await prisma.qcFieldStatus.upsert({ ... }); + + // ===== State Transition Hook ===== + // FAIL → PASS:自动关闭关联的 eQuery + if (oldStatus === 'FAIL' && data.status === 'PASS') { + await prisma.iitEquery.updateMany({ + where: { + projectId: data.projectId, + recordId: data.recordId, + eventId: data.eventId, + formName: data.formName, + instanceId: data.instanceId, + fieldName: data.fieldName, + status: { in: ['pending', 'reopened'] }, + }, + data: { + status: 'auto_closed', // 区分于人工 'closed' + respondedAt: new Date(), + responseText: 'AI 自动复核通过:数据已修正,质控结果变为 PASS', + } + }); + } + + // PASS → FAIL:如果之前有 auto_closed 的 eQuery,自动重开 + if (oldStatus === 'PASS' && data.status === 'FAIL') { + // 新建 eQuery(不重开旧的,保持审计链清晰) + await equeryService.create({ ... }); + } +} +``` + +**设计决策**: +- 自动关闭使用 `auto_closed` 状态(而非复用 `closed`),便于审计区分"AI 自动销账"和"CRC 回复后人工关闭" +- `IitEquery` 的 status 枚举需扩展:`pending | responded | reviewing | closed | reopened | auto_closed` +- 回退场景(PASS → FAIL)创建新 eQuery 而非重开旧的,保持审计链完整 + +--- + +## 4. 批次 A:数据底座加固(预估 1.5-2 周) + +### 4.1 任务清单 + +| # | 任务 | 工作量 | 依赖 | +|---|------|--------|------| +| A1 | Prisma Schema 升级 + Migration | 1 天 | 无 | +| | - 新建 `QcFieldStatus` model(五级坐标) | | | +| | - `IitQcLog` 加 `formName`, `instanceId` | | | +| | - `IitEquery` 加 `instanceId` | | | +| | - `IitFieldMapping` 加 `semanticLabel`, `formName`, `ruleCategory` | | | +| A2 | `RedcapAdapter.normalizeInstances()` | 0.5 天 | 无 | +| | - InstanceID 幽灵状态洗线 | | | +| | - 非重复表单强制 `instanceId: 1` | | | +| | - 重复表单第一行强制 `1` | | | +| A3 | `QcExecutor` 核心服务 | 2.5 天 | A1, A2 | +| | - `executeSingle()` 方法(含 Record-Level Context 全量拉取) | | | +| | - `executeBatch()` 方法 | | | +| | - 五级坐标 upsert `qc_field_status` | | | +| | - State Transition Hook:FAIL→PASS 自动关闭 eQuery | | | +| | - `IitEquery.status` 枚举扩展 `auto_closed` | | | +| | - 改造 `iitBatchController` 调用 `QcExecutor` | | | +| A4 | 规则维度重新标注 | 0.5 天 | A1 | +| | - `seed-iit-qc-rules.ts` 所有规则加 `ruleCategory: 'D1'|'D3'` | | | +| | - `HardRuleEngine.QCRule.category` 枚举扩展 | | | +| A5 | 字段语义同步 + 报告语义化 | 1 天 | A1 | +| | - `syncSemanticLabels()` 从 REDCap Metadata 自动填充 | | | +| | - `QcReportService` 生成报告时查 `semanticLabel` 替换字段名 | | | +| A6 | 验证脚本 | 0.5 天 | A3, A4, A5 | +| | - 验证 `qc_field_status` 五级坐标正确性 | | | +| | - 验证 LLM 报告字段名已语义化 | | | +| | - 回归测试:现有 D1 + D3 功能不退化 | | | + +### 4.2 验收标准 + +- [ ] 执行一键全量质控后,`qc_field_status` 包含完整的 project × record × event × form × instance × field 数据 +- [ ] 非重复表单的 `instance_id` 统一为 1 +- [ ] 重复表单(如 AE)的多个 Instance 各自独立记录 +- [ ] LLM XML 报告中字段名为中文语义(如"年龄"而非"age") +- [ ] 每条 `qc_field_status` 记录的 `rule_category` 正确标注为 D1-D7 +- [ ] 跨表单规则可正确执行(如 D5 规则需要同时访问实验室表和 AE 表) +- [ ] Field 从 FAIL 变 PASS 时,关联的 eQuery 自动变为 `auto_closed` +- [ ] 现有的入排标准检查 + 变量范围检查功能不回归 + +--- + +## 5. 批次 B:聚合层与冒泡机制(预估 1.5-2 周) + +### 5.1 任务清单 + +| # | 任务 | 工作量 | 依赖 | +|---|------|--------|------| +| B1 | Prisma Schema:新建 `QcEventStatus`,改造 `RecordSummary` | 0.5 天 | 批次 A 完成 | +| B2 | `QcExecutor.aggregateDeferred()` | 1.5 天 | B1 | +| | - SQL 聚合 field_status → event_status | | | +| | - SQL 聚合 event_status → record_summary | | | +| | - SQL 聚合 record_summary → project_stats | | | +| | - 批量执行后触发一次聚合(防抖) | | | +| | - 单条执行后推入 pg-boss 防抖队列 | | | +| B3 | 多维报告框架 | 1 天 | B2 | +| | - `QcReportService` 按 D1-D7 分章节生成 LLM XML | | | +| | - 新增 `` 章节 | | | +| | - 新增 `` 章节(各维度通过率) | | | +| B4 | 定时质控灵活配置 | 1 天 | 批次 A 完成 | +| | - 后端:`registerProjectCrons()` 从全局硬编码改为读取项目 `cronExpression` | | | +| | - 前端:可视化配置面板(每天/每周/每N小时/高级 Cron) | | | +| | - Cron 表达式参考:`0 8 * * *`(每天8:00) / `0 9 * * 1`(每周一) / `0 8 * * 1,3,5`(一三五) | | | +| B5 | 前端:受试者×表单热力图原型 | 1 天 | B2 | +| | - 从 `qc_event_status` 读取数据 | | | +| | - 行 = 受试者,列 = 事件/表单,颜色 = 状态 | | | +| B6 | 实时质控激活 | 0.5 天 | B2 | +| | - `WebhookController` 接入 `QcExecutor.executeSingle()` | | | +| | - 执行后推入防抖聚合队列 | | | +| B7 | 端到端验证 | 0.5 天 | B2-B6 | + +### 5.2 验收标准 + +- [ ] `qc_field_status` FAIL 后,对应 `qc_event_status` 自动为 FAIL +- [ ] `qc_event_status` FAIL 后,对应 `record_summary.latestQcStatus` 自动为 FAIL +- [ ] 维度计数正确:`qc_event_status.d1_issues` 等于该事件下 D1 类别 FAIL 的数量 +- [ ] 批量 1000 条规则执行无死锁(聚合在执行完成后统一进行) +- [ ] 不同项目可独立配置定时质控策略 +- [ ] REDCap 保存表单后 30 秒内三级状态表更新(实时触发) +- [ ] 热力图正确展示红/黄/绿状态 +- [ ] LLM 报告包含维度分章节 + 事件概览 +- [ ] 报告生成时间 < 2 秒(100 条记录、500 个变量规模) + +--- + +## 6. 批次 C:新维度引擎(按需,依赖临床专家输入) + +### 6.1 前提条件 + +- 批次 A + B 已稳定运行至少 1 周 +- 临床专家已确认各维度规则的可行性和优先级 +- 有真实 IIT 项目数据可供验证 + +### 6.2 任务清单(按优先级排序) + +| # | 任务 | 工作量 | 说明 | +|---|------|--------|------| +| C1 | D2 CompletenessEngine(简化版) | 2 天 | 仅统计 required=true 且无 branching_logic 的绝对必填字段缺失率 | +| C2 | D6 方案偏离引擎 | 2 天 | 访视超窗检测(目标日 ± N 天) | +| C3 | D5 AE 漏报侦测 | 2-3 天 | SoftRule + RAG,实验室异常 → 检查 AE 表有无匹配 | +| C4 | 项目健康度评分 | 1 天 | D1-D7 加权综合,可视化展示 | +| C5 | 沙盒测试机制 | 1 天 | 历史数据回放 + 结果导出 Excel | + +### 6.3 D2 缺失率的折中过渡法(V1.1 修正:增加时序过滤) + +**问题 1(字段维度)**:在完整的 Branching Logic 解析器实现之前,简单的"总字段数 - 实填数"会严重高估缺失率(把因分支逻辑隐藏的字段也算作缺失)。 + +**问题 2(时序维度,V1.1 新增)**:如果一个项目有 V1-V10 共 10 次访视,患者昨天刚入组(当前在 V1),系统去算缺失率会把 V2-V10 的必填字段全部算作"已缺失",导致新入组患者缺失率高达 90%。这是**致命的临床逻辑错误**——那些访视根本还没发生。 + +**解法**:双重过滤——字段过滤 + 时序过滤。 + +```typescript +async function calculateMissingRate( + projectId: string, + recordId: string +): Promise<{ rate: number; denominator: number; numerator: number }> { + + // 1. 字段过滤:只统计绝对必填字段(required=y 且无 branching_logic) + const metadata = await redcapAdapter.exportMetadata(); + const absoluteRequired = metadata.filter( + f => f.required_field === 'y' && !f.branching_logic + ); + + // 2. 时序过滤(V1.1 关键补丁): + // 找出该患者在 REDCap 中有实质数据的事件列表 + const patientRecords = await redcapAdapter.exportRecords({ records: [recordId] }); + const activeEvents = new Set( + patientRecords + .filter(r => hasSubstantiveData(r)) // 排除只有 record_id 的空行 + .map(r => r.redcap_event_name) + ); + + // 3. 只统计已到达事件中的绝对必填字段 + const formEventMapping = await redcapAdapter.getFormEventMapping(); + let denominator = 0; + let filled = 0; + + for (const field of absoluteRequired) { + // 该字段所属的表单,在哪些事件中出现 + const fieldEvents = formEventMapping + .filter(m => m.form === field.form_name) + .map(m => m.unique_event_name); + + // 只计算患者已到达的事件 + for (const event of fieldEvents) { + if (!activeEvents.has(event)) continue; // 未来事件,跳过 + denominator++; + const record = patientRecords.find( + r => r.redcap_event_name === event + ); + if (record && record[field.field_name] != null && record[field.field_name] !== '') { + filled++; + } + } + } + + const numerator = denominator - filled; + const rate = denominator > 0 ? Math.round((numerator / denominator) * 1000) / 10 : 0; + return { rate, denominator, numerator }; +} + +function hasSubstantiveData(record: Record): boolean { + // 排除只有 record_id / redcap_event_name 等元数据的空行 + const metaFields = ['record_id', 'redcap_event_name', 'redcap_repeat_instrument', 'redcap_repeat_instance']; + return Object.entries(record).some( + ([key, val]) => !metaFields.includes(key) && val != null && val !== '' + ); +} +``` + +**总结**: +- **字段过滤**:`required=y` 且无 `branching_logic` → 排除条件字段 +- **时序过滤**:只统计患者已有数据的事件 → 排除未来访视 +- 两层过滤后,分子分母才是临床上无争议的 + +### 6.4 沙盒测试机制 + +**问题**:临床专家不懂代码,怎么验证新规则的准确性? + +**解法**:开发"历史数据回放"功能: + +``` +1. 管理员配置新的 D5/D6 规则(状态设为"草稿",不在生产环境生效) +2. 点击"沙盒测试"按钮 +3. 系统拿该项目所有历史患者数据,用草稿规则跑一遍 +4. 结果不写入 qc_field_status(只存临时表或内存) +5. 导出 Excel:"AI 抓出了这 5 个疑似漏报 AE,请主任确认" +6. 专家确认 OK → 规则状态改为"已发布",正式生效 +``` + +**实现要点**: +- `IitSkill` 增加 `status` 字段:`draft` | `published` | `archived` +- `QcExecutor` 加载规则时只取 `status = 'published'` 的 +- 沙盒执行复用 `QcExecutor` 核心逻辑,但结果写入临时存储 + +--- + +## 7. LLM 报告升级(批次 B 产出) + +### 7.1 多维报告 XML 结构 + +```xml + + + + + + - 通过率: 92.9%(14 条记录,13 通过,1 失败) + - 事件覆盖: 42 个事件已质控,39 通过 + - 严重问题: 1 | 警告: 2 + + + + + - D1 入排合规: 13/14 通过 (92.9%) — 1 条排除标准违规 + - D3 变量准确: 550/556 通过 (98.9%) — 6 条极值异常 + - D2 数据完整: --(尚未启用) + - D5 安全性: --(尚未启用) + - D6 方案偏离: --(尚未启用) + + + + + - 筛选期: 14/14 通过 (100%) + - 基线期: 13/14 通过 (92.9%) + - 随访1: 12/12 通过 (100%) + + + + + + +
+ 1. [D1][exc_001] **排除标准检查**: 排除标准第1项 = **1**(标准: 0) +
+
+
+
+ + + + + +
+ 1. [D3][lab_003] **谷丙转氨酶(ALT)**: 当前值 **52 U/L**(正常上限: 50 U/L) +
+
+
+
+ +
+``` + +**对比 V3.0**: +- 新增 `` —— 各维度独立呈现 +- 新增 `` —— 事件级统计 +- 问题定位从 `record → field` 精确到 `record → event → form → instance → field` +- 每条问题标注维度代码(如 `[D1]`、`[D3]`) + +--- + +## 8. 与四大工具的集成变更 + +| 工具 | 批次 A 变更 | 批次 B 变更 | +|------|------------|------------| +| `read_report` | 报告字段名语义化 | 新增 `section=dimension_summary` / `section=event_overview` | +| `look_up_data` | 无变更 | 可附带每个字段的 `qc_status`(从 `qc_field_status` 读取) | +| `check_quality` | 调用 `QcExecutor` 替代旧逻辑 | 自动触发聚合,结果写入三级状态表 | +| `search_knowledge` | 无变更 | 无变更 | + +--- + +## 9. 风险与缓解 + +| 风险 | 概率 | 影响 | 缓解措施 | +|------|------|------|---------| +| REDCap InstanceID 返回值不一致 | 高 | 五级坐标不完整 | `RedcapAdapter.normalizeInstances()` 强制标准化(3.1 节) | +| 跨表单规则拿不到完整上下文 | 高 | D5 规则误判 | Record-Level Context 全量拉取(3.8 节) | +| eQuery 只开不关 | 高 | 系统不可信 | State Transition Hook 自动闭环(3.9 节) | +| 批量质控冒泡导致死锁 | 中 | 数据库卡顿 | 受试者级防抖聚合(3.4 节) | +| D2 缺失率被高估(无分支逻辑) | 高 | 临床不信任 | 绝对必填字段 + 时序过滤(6.3 节) | +| D2 缺失率计入未来访视 | 高 | 新入组患者 90% 缺失 | Event-Aware 过滤,只统计已到达事件(6.3 节) | +| 新规则上线后误报过多 | 中 | 影响用户信心 | 沙盒测试 + 专家确认后再发布(6.4 节) | +| Migration 影响现有数据 | 低 | 数据丢失 | 新增表/列均为追加型,不修改现有数据 | + +--- + +## 10. 关键决策记录 + +| 决策 | 选择 | 理由 | +|------|------|------| +| 数据层级 | 五级(Record → Event → Form → Instance → Field) | 对齐 CDISC ODM,解决重复表单定位 | +| InstanceID 标准化位置 | `RedcapAdapter` 层统一处理 | 下游无需关心 REDCap 返回值不一致 | +| 状态优先级 | FAIL(3) > WARNING(2) > UNCERTAIN(1) > PASS(0) | 聚合时始终取最严重状态 | +| SKIPPED 变量处理 | 不写入 `qc_field_status` | 减少无意义数据,简化聚合逻辑 | +| 实时质控数据拉取范围 | Record-Level(全量拉取该患者所有事件/表单) | 跨表单规则必须有完整上下文 | +| eQuery 闭环策略 | State Transition Hook(FAIL→PASS 自动关闭) | 避免"只开不关",状态用 `auto_closed` 区分人工关闭 | +| 冒泡防抖粒度 | 实时触发用受试者级,批量触发用项目级 | 多 CRC 同时录入不同受试者时互不干扰 | +| D2 缺失率过渡方案 | 绝对必填字段 + Event-Aware 时序过滤 | 排除条件字段和未来访视,分子分母临床无争议 | +| 规则发布流程 | draft → sandbox test → published | 临床专家可验证准确性 | +| 规则分类 | D1-D7 七大维度 | 对齐 CRA 工作维度,支撑多维报告 | +| 字段语义化数据源 | REDCap Data Dictionary `field_label` | 已有数据,自动同步,DM 可微调 | +| 自动映射 vs 半自动映射 | 半自动(系统建议 + DM 确认) | 全自动 LLM 映射准确率不可控 | +| V3.0 文档处置 | 保留为历史版本 | 记录从三级到五级的设计演进 | + +--- + +## 11. 时间线总览 + +``` + 批次 A 批次 B 批次 C + (数据底座加固) (聚合与冒泡) (新维度引擎) + ┌──────────────────┐ ┌──────────────────┐ ┌──────────────┐ + │ 1.5-2 周 │ │ 1.5-2 周 │ │ 按需 │ + │ │ │ │ │ │ + Week 1-2 │ A1 Schema 升级 │ │ │ │ │ + │ A2 InstanceID 洗线│ │ │ │ │ + │ A3 QcExecutor │ │ │ │ │ + │ A4 规则维度标注 │ │ │ │ │ + Week 2-3 │ A5 语义化同步 │ │ │ │ │ + │ A6 验证 │ │ │ │ │ + └────────┬─────────┘ │ │ │ │ + │ 验收通过 │ │ │ │ + Week 3-4 └──────────────►│ B1 Event 表 │ │ │ + │ B2 防抖聚合 │ │ │ + │ B3 多维报告 │ │ │ + Week 4-5 │ B4 定时配置 │ │ │ + │ B5 热力图 │ │ │ + │ B6 实时质控 │ │ │ + │ B7 端到端验证 │ │ │ + └────────┬─────────┘ │ │ + │ 验收通过 + │ │ + │ 临床专家确认 │ │ + Week 6+ └──────────────►│ C1 D2 缺失率 │ + │ C2 D6 偏离 │ + │ C3 D5 AE │ + │ C4 健康度 │ + │ C5 沙盒测试 │ + └──────────────┘ +``` + +--- + +> **一句话总结**:五级坐标让每个质控结论都有精确的 GPS 定位,维度分类让报告从扁平变为多维,异步防抖聚合让冒泡机制可靠落地——这三者共同构成 V3.1 质控引擎的架构基石。先把底座做对(批次 A),再把聚合做稳(批次 B),最后把覆盖做广(批次 C)。 diff --git a/docs/03-业务模块/IIT Manager Agent/04-开发计划/V3.0全新开发计划/质控引擎架构升级-三级数据结构与多模式触发技术设计.md b/docs/03-业务模块/IIT Manager Agent/04-开发计划/V3.0全新开发计划/质控引擎架构升级-三级数据结构与多模式触发技术设计.md new file mode 100644 index 00000000..0db0fe86 --- /dev/null +++ b/docs/03-业务模块/IIT Manager Agent/04-开发计划/V3.0全新开发计划/质控引擎架构升级-三级数据结构与多模式触发技术设计.md @@ -0,0 +1,697 @@ +# 质控引擎架构升级 — 三级数据结构与多模式触发技术设计 + +> **版本:** V1.0 +> **日期:** 2026-03-01 +> **定位:** 质控引擎核心架构升级,解决当前数据粒度不足、调度僵化、无法精准溯源的根本问题 +> **关联文档:** +> - [V3.0 全新开发计划](./V3.0全新开发计划.md) +> - [CRA 智能质控 Agent 四大工具工作原理说明](../../08-对外输出报告/CRA智能质控Agent-四大工具工作原理说明.md) +> - [CRA AI 替代工作梳理](../../09-技术评审报告/CRA%20AI%20替代工作梳理2.md) + +--- + +## 1. 核心认知:研究方案 → 变量清单 → 质控规则 → 质控报告 + +在 IIT 临床研究中,质控体系不是一堆孤立的模块,而是一条**从研究方案到质控报告的完整链条**: + +``` +研究方案(Protocol) + │ 定义了:纳入/排除标准、访视窗口、用药规范、AE 监测要求 + ▼ +变量清单(Data Dictionary / CRF 变量) + │ 将方案中的每一条要求,具体化为 EDC 系统中可采集的字段 + │ 例:方案规定"年龄 16-35 岁" → CRF 中 age 字段(数值型,范围 16-35) + ▼ +质控规则(QC Rules) + │ 将变量的合规要求,编码为机器可执行的逻辑 + │ 例:{ "and": [{ ">=": ["age", 16] }, { "<=": ["age", 35] }] } + ▼ +质控报告(QC Report) + │ 将规则执行的结果,聚合为人和 AI 可理解的报告 + │ 例:通过率 92.9%,1 条严重违规(3 号受试者排除标准不合规) + ▼ +行动(eQuery / 告警 / 监查报告) + 将报告中的问题,转化为具体的跟进动作 +``` + +**这四者是 1:1:1:1 的映射关系**——方案中的每一条要求,对应变量清单中的具体字段,对应一条或多条质控规则,最终体现在质控报告中的一条具体结论。 + +**当前问题**:我们的质控引擎在"规则执行"层面已经成熟(HardRuleEngine + SkillRunner),但在**数据存储粒度**和**调度灵活性**上存在显著不足,导致报告聚合困难、历史追溯低效、调度僵化。 + +--- + +## 2. 问题诊断 + +### 2.1 数据结构粒度不足 + +REDCap 中的数据天然具有三级结构: + +``` +Record(受试者记录) + └── Event(访视事件,如:筛选期、基线期、随访 1、随访 2...) + └── Variable(变量/字段,如:age、gender、lab_alt、consent_date...) +``` + +**但当前质控数据结构只有 1.5 级**: + +| 数据层级 | 当前是否有独立状态表 | 问题 | +|---------|------------------|------| +| **记录级(Record)** | `record_summary` 表,有 `latestQcStatus` | 有,但缺乏事件维度详情 | +| **事件级(Event)** | **无独立表** | 事件状态藏在 `qc_logs` 日志里,查询需 `DISTINCT ON` 聚合,效率低、易出错 | +| **变量级(Variable)** | **无独立表** | 变量级结果藏在 `qc_logs.issues` 的 JSON 数组里,无法直接 SQL 查询 | + +**实际后果**: + +1. 之前出现的"通过率为 0%"的 Bug,根本原因之一就是缺少事件级状态表,报告聚合时把不同事件的旧数据混入计算 +2. 无法回答"3 号受试者在基线期的 age 字段质控状态是什么"这样的精确查询 +3. 每次生成报告都要从日志中做复杂的 SQL 聚合,性能差且容易遗漏 + +### 2.2 定时质控调度僵化 + +当前定时质控**全局硬编码**为每天 08:00: + +```typescript +// 当前代码(iit-manager/index.ts) +await jobQueue.schedule('iit_daily_qc', '0 0 * * *', {}, { tz: 'Asia/Shanghai' }); +``` + +**问题**: +- 所有项目共享一个全局调度,无法按项目独立配置 +- 不支持"每周一三五"、"每 6 小时"等灵活策略 +- 数据库已预留 `cronEnabled` / `cronExpression` 字段,但代码未使用 + +### 2.3 实时质控未激活 + +系统**已实现**了 REDCap DET(Data Entry Trigger)Webhook 接收端,但: +- REDCap 端是否配置了 DET 取决于部署环境 +- Webhook 处理逻辑写入的也是旧的 `qc_logs` 结构,同样存在粒度不足问题 + +--- + +## 3. 设计目标 + +### 3.1 三级质控状态体系 + +建立与 REDCap 数据结构 **1:1 对齐**的三级质控状态: + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ Record 10 │ +│ 记录级状态: FAIL(取所有事件中最严重的) │ +│ │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ Event A: 筛选期 │ │ +│ │ 事件级状态: PASS │ │ +│ │ │ │ +│ │ ┌─────────────────────────────────────────────────┐ │ │ +│ │ │ age = 28 → QC: PASS (规则: 16-35岁) │ │ │ +│ │ │ gender = 1 → QC: PASS (规则: 非空) │ │ │ +│ │ │ consent = 1 → QC: PASS (规则: 已签署) │ │ │ +│ │ └─────────────────────────────────────────────────┘ │ │ +│ └──────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ Event B: 基线期 │ │ +│ │ 事件级状态: FAIL │ │ +│ │ │ │ +│ │ ┌─────────────────────────────────────────────────┐ │ │ +│ │ │ exclusion = 1 → QC: FAIL (规则: 应为0) │ │ │ +│ │ │ lab_alt = 5.2 → QC: PASS (规则: <40) │ │ │ +│ │ └─────────────────────────────────────────────────┘ │ │ +│ └──────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ Event C: 随访 1 │ │ +│ │ 事件级状态: PASS │ │ +│ │ │ │ +│ │ ┌─────────────────────────────────────────────────┐ │ │ +│ │ │ followup_date = 2026-03-01 → QC: PASS │ │ │ +│ │ │ vitals_bp = 120/80 → QC: PASS │ │ │ +│ │ └─────────────────────────────────────────────────┘ │ │ +│ └──────────────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────────┘ +``` + +### 3.2 多模式触发体系 + +| 触发模式 | 描述 | 粒度 | 延迟 | +|---------|------|------|------| +| **实时触发** | REDCap DET Webhook,录入即质控 | 单记录 × 单事件 × 单表单 | 秒级 | +| **定时触发** | 按项目独立配置 Cron 策略 | 全量记录 × 全量事件 | 分钟级 | +| **手动触发** | 用户点击"一键全量质控"或 AI 调用 | 全量或指定记录 | 分钟级 | + +### 3.3 自底向上的聚合链 + +``` +qc_logs(审计日志,追加型,不删不改,保留完整历史) + ↓ 每次质控后 upsert ↓ +qc_field_status(变量级,保持最新状态) + ↓ 自底向上聚合 ↓ +qc_event_status(事件级,保持最新状态) + ↓ 自底向上聚合 ↓ +record_summary(记录级,保持最新状态) + ↓ 自底向上聚合 ↓ +qc_project_stats(项目级统计) + ↓ 格式化生成 ↓ +qc_reports(LLM 友好报告 + 人类可读报告) +``` + +--- + +## 4. 数据库设计 + +### 4.1 新增表:`qc_field_status`(变量级质控状态) + +> 每个 **project × record × event × field** 唯一一行,始终反映该变量的**最新**质控状态。 + +```sql +CREATE TABLE iit_schema.qc_field_status ( + id TEXT PRIMARY KEY DEFAULT gen_random_uuid(), + project_id TEXT NOT NULL, + record_id TEXT NOT NULL, + event_id TEXT NOT NULL, -- REDCap event_name + field_name TEXT NOT NULL, -- 变量名(REDCap field_name) + + -- 质控结果 + status TEXT NOT NULL, -- 'PASS' | 'FAIL' | 'WARNING' | 'SKIPPED' + rule_id TEXT, -- 触发的规则 ID + rule_name TEXT, -- 规则名称(冗余,查询方便) + severity TEXT, -- 'critical' | 'warning' | 'info' + message TEXT, -- 质控结论描述 + actual_value TEXT, -- 实际值 + expected_value TEXT, -- 期望值/标准 + + -- 溯源 + source_qc_log_id TEXT, -- 关联到 qc_logs 的具体记录 + triggered_by TEXT NOT NULL, -- 'webhook' | 'cron' | 'manual' + last_qc_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- 唯一约束:每个变量只保留最新状态 + CONSTRAINT unique_field_status + UNIQUE (project_id, record_id, event_id, field_name), + + -- 时间戳 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- 索引 +CREATE INDEX idx_field_status_record + ON iit_schema.qc_field_status (project_id, record_id); +CREATE INDEX idx_field_status_event + ON iit_schema.qc_field_status (project_id, record_id, event_id); +CREATE INDEX idx_field_status_fail + ON iit_schema.qc_field_status (project_id, status) + WHERE status IN ('FAIL', 'WARNING'); +CREATE INDEX idx_field_status_rule + ON iit_schema.qc_field_status (project_id, rule_id); +``` + +**关键设计决策**: + +- `actual_value` 和 `expected_value` 用 `TEXT` 存储(非 JSONB),因为变量值可能是数字、日期、字符串等多种类型,统一为文本便于展示和比较 +- `source_qc_log_id` 指向 `qc_logs` 表,支持从状态追溯到具体的质控执行记录 +- `SKIPPED` 状态用于"字段在该事件中不存在"的情况(如随访期没有 age 字段) + +### 4.2 新增表:`qc_event_status`(事件级质控状态) + +> 每个 **project × record × event** 唯一一行,状态 = 其下所有变量的最严重状态。 + +```sql +CREATE TABLE iit_schema.qc_event_status ( + id TEXT PRIMARY KEY DEFAULT gen_random_uuid(), + project_id TEXT NOT NULL, + record_id TEXT NOT NULL, + event_id TEXT NOT NULL, -- REDCap event_name + event_label TEXT, -- 事件显示名(如"筛选期") + + -- 聚合状态 + status TEXT NOT NULL, -- 'PASS' | 'FAIL' | 'WARNING' + fields_total INT NOT NULL DEFAULT 0, -- 已检查的变量总数 + fields_passed INT NOT NULL DEFAULT 0, + fields_failed INT NOT NULL DEFAULT 0, + fields_warning INT NOT NULL DEFAULT 0, + fields_skipped INT NOT NULL DEFAULT 0, + + -- 问题摘要(方便快速查询,不需要 JOIN field_status) + top_issues JSONB DEFAULT '[]', -- 最严重的 N 条问题摘要 + + -- 关联的表单列表 + forms_checked TEXT[] DEFAULT '{}', -- 该事件中参与质控的表单 + + -- 溯源 + triggered_by TEXT NOT NULL, + last_qc_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- 唯一约束 + CONSTRAINT unique_event_status + UNIQUE (project_id, record_id, event_id), + + -- 时间戳 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- 索引 +CREATE INDEX idx_event_status_record + ON iit_schema.qc_event_status (project_id, record_id); +CREATE INDEX idx_event_status_fail + ON iit_schema.qc_event_status (project_id, status) + WHERE status IN ('FAIL', 'WARNING'); +``` + +### 4.3 改造表:`record_summary`(记录级质控状态) + +> 在现有 `record_summary` 基础上新增事件维度统计字段: + +```sql +-- 新增列(在已有表上 ALTER) +ALTER TABLE iit_schema.record_summary + ADD COLUMN IF NOT EXISTS events_total INT DEFAULT 0, + ADD COLUMN IF NOT EXISTS events_passed INT DEFAULT 0, + ADD COLUMN IF NOT EXISTS events_failed INT DEFAULT 0, + ADD COLUMN IF NOT EXISTS events_warning INT DEFAULT 0, + ADD COLUMN IF NOT EXISTS fields_total INT DEFAULT 0, + ADD COLUMN IF NOT EXISTS fields_passed INT DEFAULT 0, + ADD COLUMN IF NOT EXISTS fields_failed INT DEFAULT 0, + ADD COLUMN IF NOT EXISTS top_issues JSONB DEFAULT '[]'; +``` + +### 4.4 保留表:`qc_logs`(审计日志,不变) + +`qc_logs` 继续作为**追加型审计日志**,每次质控执行都新增一行,永不删除、永不修改。它是完整的历史记录,用于: + +- 趋势分析(对比不同时间点的质控结果) +- 审计追踪(谁在什么时间触发了什么质控) +- 回溯调查(某个问题是何时首次被发现的) + +### 4.5 完整数据模型关系图 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ iit_schema │ +│ │ +│ ┌─────────────┐ ┌──────────────┐ ┌───────────────┐ │ +│ │ qc_logs │ │ qc_field_ │ │ qc_event_ │ │ +│ │ (审计日志) │◄────│ status │─────►│ status │ │ +│ │ │ ref │ (变量级最新) │ 聚合 │ (事件级最新) │ │ +│ │ 追加型 │ │ │ │ │ │ +│ │ 不删不改 │ │ project_id │ │ project_id │ │ +│ │ │ │ record_id │ │ record_id │ │ +│ │ 每次质控 │ │ event_id │ │ event_id │ │ +│ │ 一行记录 │ │ field_name │ │ │ │ +│ │ │ │ status │ │ status │ │ +│ │ │ │ rule_id │ │ fields_total │ │ +│ │ │ │ actual_value │ │ fields_passed │ │ +│ │ │ │ expected_val │ │ fields_failed │ │ +│ └─────────────┘ └──────────────┘ └───────┬───────┘ │ +│ │ 聚合 │ +│ ▼ │ +│ ┌──────────────┐ ┌───────────────┐ ┌───────────────┐ │ +│ │ qc_reports │◄────│ qc_project_ │◄────│ record_ │ │ +│ │ (LLM报告) │ 生成 │ stats │ 聚合 │ summary │ │ +│ │ │ │ (项目级统计) │ │ (记录级最新) │ │ +│ │ llm_report │ │ │ │ │ │ +│ │ summary │ │ total_records │ │ latestQcStatus│ │ +│ │ issues │ │ passed_records│ │ events_total │ │ +│ │ │ │ passRate │ │ events_passed │ │ +│ └──────────────┘ └───────────────┘ └───────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 5. 多模式触发设计 + +### 5.1 实时触发(REDCap DET Webhook) + +**已有基础**:`WebhookController.ts` 已实现 DET 接收端。 + +**升级点**:Webhook 处理完毕后,不仅写 `qc_logs`,还需同步更新三级状态表。 + +``` +REDCap 保存表单 + ↓ DET POST +/api/v1/iit/webhooks/redcap + ↓ 立即返回 200,异步处理 +WebhookController.processWebhookAsync() + ↓ +pg-boss 队列(iit_quality_check,5 分钟防抖) + ↓ +QcExecutor.executeSingleRecord(projectId, recordId, eventId, formName) + ├── 1. RedcapAdapter 拉取该记录该事件的最新数据 + ├── 2. HardRuleEngine 逐规则执行 + ├── 3. 结果写入 qc_logs(追加,审计) + ├── 4. upsert qc_field_status(逐变量更新最新状态) + ├── 5. upsert qc_event_status(聚合该事件所有变量状态) + ├── 6. upsert record_summary(聚合该记录所有事件状态) + └── 7. 刷新 qc_project_stats 和 qc_reports 缓存 +``` + +### 5.2 定时触发(项目级 Cron 配置) + +**改造方案**: + +#### 5.2.1 数据库配置 + +`IitProject` 表已有 `cronEnabled` 和 `cronExpression` 字段,直接使用。 + +#### 5.2.2 前端配置界面 + +在项目管理页面新增"定时质控"配置面板: + +``` +┌─────────────────────────────────────────────┐ +│ 定时质控配置 │ +│ │ +│ ○ 关闭定时质控 │ +│ ● 开启定时质控 │ +│ │ +│ 频率: │ +│ ┌─────────────────────────────┐ │ +│ │ ○ 每天 时间 [08:00]│ │ +│ │ ○ 每周指定日期 │ │ +│ │ ☑ 周一 ☐ 周二 ☑ 周三 │ │ +│ │ ☐ 周四 ☑ 周五 ☐ 周六 ☐ 周日│ │ +│ │ 时间 [08:00] │ │ +│ │ ○ 每隔 N 小时 [6] 小时 │ │ +│ │ ○ 高级(Cron 表达式) │ │ +│ │ [0 8 * * 1,3,5] │ │ +│ │ ⓘ Cron 表达式说明 │ │ +│ └─────────────────────────────┘ │ +│ │ +│ 下次执行时间:2026-03-03 周一 08:00 │ +│ │ +│ [保存配置] │ +└─────────────────────────────────────────────┘ +``` + +**Cron 表达式说明**(对临床专家的通俗解释): + +| 配置需求 | 对应表达式 | 含义 | +|---------|-----------|------| +| 每天 8:00 | `0 8 * * *` | 每天早上 8 点执行 | +| 每周一 9:00 | `0 9 * * 1` | 每周一早上 9 点 | +| 每周一三五 8:00 | `0 8 * * 1,3,5` | 周一、周三、周五早上 8 点 | +| 每 6 小时 | `0 */6 * * *` | 每天 0:00, 6:00, 12:00, 18:00 | +| 工作日每天 9:00 | `0 9 * * 1-5` | 周一到周五每天 9 点 | +| 每天 8:00 和 20:00 | `0 8,20 * * *` | 早晚各一次 | + +> **说明**:大多数用户通过可视化界面选择即可,系统自动生成 Cron 表达式。只有高级用户需要直接填写表达式。 + +#### 5.2.3 后端调度改造 + +```typescript +// 改造后的逻辑(伪代码) +async function registerProjectCrons() { + const projects = await prisma.iitProject.findMany({ + where: { cronEnabled: true, status: 'active' }, + select: { id: true, cronExpression: true }, + }); + + for (const project of projects) { + const cronExpr = project.cronExpression || '0 0 * * *'; // 默认每天 08:00 + await jobQueue.schedule( + `iit_qc_${project.id}`, // 每个项目独立的任务名 + cronExpr, + { projectId: project.id }, + { tz: 'Asia/Shanghai' } + ); + } +} +``` + +### 5.3 手动触发 + +- **前端入口**:质控驾驶舱"一键全量质控"按钮 +- **AI 入口**:`check_quality` 工具(用户对话中说"帮我跑一下质控") +- **执行流程**:与定时触发相同,复用 `QcExecutor.executeBatch()` + +--- + +## 6. 质控执行器重构(QcExecutor) + +### 6.1 核心流程 + +将当前分散在 `SkillRunner`、`iitBatchController`、`QcReportService` 中的质控执行逻辑,统一收归到 `QcExecutor` 服务中: + +```typescript +class QcExecutor { + + /** + * 单记录质控(实时触发 / AI 调用) + */ + async executeSingleRecord( + projectId: string, + recordId: string, + eventId: string, + options?: { triggeredBy: 'webhook' | 'manual' } + ): Promise { + // 1. 从 REDCap 拉取该 record × event 的最新数据 + // 2. 加载适用于该事件的质控规则 + // 3. 逐规则执行 → 收集变量级结果 + // 4. 写入 qc_logs(追加审计日志) + // 5. upsert qc_field_status(逐变量) + // 6. upsert qc_event_status(聚合事件) + // 7. upsert record_summary(聚合记录) + // 8. 更新 qc_project_stats + // 9. 标记 qc_reports 缓存过期 + } + + /** + * 批量质控(定时触发 / 手动触发) + */ + async executeBatch( + projectId: string, + options?: { triggeredBy: 'cron' | 'manual' } + ): Promise { + // 1. 从 REDCap 拉取全量记录(按事件分开) + // 2. 基线数据合并(将基线事件数据合并到后续事件) + // 3. 逐 record × event 调用 executeSingleRecord 逻辑 + // 4. 汇总批量结果 + // 5. 强制刷新 qc_reports + // 6. 推送通知(企微等) + } + + /** + * 自底向上聚合(内部方法) + * field_status → event_status → record_summary → project_stats + */ + private async aggregateUpward( + projectId: string, + recordId: string, + eventId: string + ): Promise { + // 事件级:从 qc_field_status 聚合 + // status = 所有变量中最严重的状态 + // fields_total = COUNT(*) + // fields_passed = COUNT(status='PASS') + // fields_failed = COUNT(status='FAIL') + + // 记录级:从 qc_event_status 聚合 + // latestQcStatus = 所有事件中最严重的状态 + // events_total = COUNT(*) + // events_passed = COUNT(status='PASS') + + // 项目级:从 record_summary 聚合 + // passRate = passed_records / total_records * 100 + } +} +``` + +### 6.2 状态优先级 + +聚合时始终取"最严重的状态": + +``` +FAIL (3) > WARNING (2) > UNCERTAIN (1) > PASS (0) +``` + +规则: +- **事件状态** = 其下所有变量状态中最严重的 +- **记录状态** = 其下所有事件状态中最严重的 +- 只要有一个变量 FAIL,事件就是 FAIL +- 只要有一个事件 FAIL,记录就是 FAIL + +### 6.3 SKIPPED 处理 + +当某个变量在某个事件中不存在(如随访期没有 age 字段): +- `qc_field_status` 中不写入该变量的记录(而非写 SKIPPED) +- 该变量不参与事件级聚合计算 +- 只有被规则实际检查过的变量才写入 `qc_field_status` + +--- + +## 7. 报告生成优化 + +### 7.1 当前问题 + +`QcReportService.aggregateStats()` 目前直接从 `qc_logs` 做 `DISTINCT ON` 聚合,效率低、逻辑复杂。 + +### 7.2 优化后 + +有了三级状态表后,报告生成变得简单高效: + +```typescript +// 报告概览 — 直接从 record_summary 读取 +const records = await prisma.iitRecordSummary.findMany({ + where: { projectId }, +}); +const totalRecords = records.length; +const passedRecords = records.filter(r => r.latestQcStatus === 'PASS').length; +const passRate = (passedRecords / totalRecords * 100).toFixed(1); + +// 严重问题列表 — 直接从 qc_field_status 读取 +const criticalIssues = await prisma.qcFieldStatus.findMany({ + where: { projectId, status: 'FAIL', severity: 'critical' }, + orderBy: { lastQcAt: 'desc' }, +}); + +// 事件级统计 — 直接从 qc_event_status 读取 +const eventStats = await prisma.qcEventStatus.findMany({ + where: { projectId }, +}); +``` + +**对比**: + +| 维度 | 改造前 | 改造后 | +|------|-------|-------| +| 聚合方式 | `DISTINCT ON` 从 qc_logs 实时聚合 | 直接 SELECT 状态表 | +| 查询复杂度 | 复杂 SQL + JSON 解析 | 简单 WHERE 过滤 | +| 性能 | 随日志增长线性下降 | 恒定(状态表大小固定) | +| 正确性 | 容易被旧日志污染 | 始终是最新状态 | + +### 7.3 LLM 报告增强 + +三级数据结构让 LLM 报告可以新增"事件维度"章节: + +```xml + + + + - 通过率: 92.9%(14 条记录,13 条通过,1 条失败) + - 事件覆盖: 42 个事件已质控,39 个通过 + - 严重问题: 1 | 警告: 0 + + + + + + 1. [exc_001] **排除标准检查**: 当前值 **1** (标准: 0) + + + + + + + - 筛选期: 14/14 通过 (100%) + - 基线期: 13/14 通过 (92.9%) + - 随访1: 12/12 通过 (100%) + + + +``` + +--- + +## 8. 与四大工具的集成 + +### 8.1 `read_report` — 直接受益 + +报告数据来源从"日志聚合"变为"状态表直查",响应更快、数据更准确。 + +新增支持按事件维度查询: +- `section=event_overview`:各事件的通过率统计 +- `section=record_detail&record_id=3`:某个记录的三级完整状态 + +### 8.2 `look_up_data` — 可增加质控标注 + +查询原始数据时,可附带每个字段的质控状态: + +```json +{ + "record_id": "3", + "age": { "value": "28", "qc_status": "PASS" }, + "exclusion": { "value": "1", "qc_status": "FAIL", "message": "排除标准应为0" } +} +``` + +### 8.3 `check_quality` — 执行后更新三级状态 + +调用 `QcExecutor`,结果自动写入三级状态表。 + +### 8.4 `search_knowledge` — 不受影响 + +知识库检索与质控数据结构无关,不需要改动。 + +--- + +## 9. 实施计划 + +### Phase 1:三级数据结构(2 天) + +| 任务 | 工作量 | 说明 | +|------|--------|------| +| 编写 Prisma Schema + Migration | 0.5 天 | 新增 `qc_field_status`、`qc_event_status`,改造 `record_summary` | +| 实现 `QcExecutor` 服务 | 1 天 | 统一质控执行 + 三级状态更新逻辑 | +| 改造 `iitBatchController` | 0.5 天 | 调用 `QcExecutor` 替代原有分散逻辑 | + +### Phase 2:定时质控灵活配置(1 天) + +| 任务 | 工作量 | 说明 | +|------|--------|------| +| 后端:项目级 Cron 调度改造 | 0.5 天 | 从全局硬编码改为读取项目配置 | +| 前端:定时质控配置面板 | 0.5 天 | 可视化选择 + 高级 Cron 输入 | + +### Phase 3:报告生成优化 + 工具集成(1 天) + +| 任务 | 工作量 | 说明 | +|------|--------|------| +| 改造 `QcReportService` | 0.5 天 | 从状态表直查替代日志聚合 | +| 改造 `ToolsService` | 0.5 天 | `read_report` 和 `check_quality` 适配新结构 | + +### Phase 4:实时质控激活 + 验证(1 天) + +| 任务 | 工作量 | 说明 | +|------|--------|------| +| 改造 Webhook Worker | 0.5 天 | 处理逻辑接入 `QcExecutor` | +| 端到端验证脚本 | 0.5 天 | 覆盖三种触发模式 + 三级数据一致性验证 | + +**总计:约 5 天** + +--- + +## 10. 验收标准 + +| 验收项 | 标准 | +|--------|------| +| 三级数据一致性 | 执行质控后,`qc_field_status` → `qc_event_status` → `record_summary` 的聚合结果一致 | +| 变量级查询 | 能直接查询"3 号受试者在基线期的 age 字段质控状态" | +| 事件级查询 | 能直接查询"3 号受试者基线期的整体质控状态" | +| 定时质控配置 | 不同项目可配置不同的 Cron 策略,互不影响 | +| 实时质控 | REDCap 保存表单后,30 秒内三级状态表更新 | +| 报告准确性 | 报告中的通过率与 `record_summary` 中的统计一致 | +| 历史追溯 | `qc_logs` 保留完整历史,可追溯任意时间点的质控结果 | +| 性能 | 报告生成时间 < 2 秒(100 条记录、500 个变量规模) | + +--- + +## 11. 关键决策记录 + +| 决策 | 选择 | 理由 | +|------|------|------| +| 变量级状态是否独立建表 | 是,`qc_field_status` | 支持精准查询、避免 JSON 解析、支持索引 | +| 事件级状态是否独立建表 | 是,`qc_event_status` | 消除日志聚合的复杂性和出错风险 | +| `qc_logs` 是否保留 | 保留,追加型不变 | 审计追踪和趋势分析不可或缺 | +| 聚合方向 | 自底向上(变量→事件→记录→项目) | 与 REDCap 数据结构一致,逻辑清晰 | +| SKIPPED 变量处理 | 不写入 `qc_field_status` | 减少无意义数据,简化聚合逻辑 | +| Cron 配置粒度 | 项目级 | 不同项目节奏不同(I 期 vs III 期) | +| 前端 Cron 配置 | 可视化优先 + 高级模式 | 临床团队无需学习 Cron 语法 | +| 实时质控防抖 | 5 分钟(pg-boss singleton) | 避免快速连续保存时重复执行 | +| `QcExecutor` 统一入口 | 三种触发模式共用一个执行器 | 确保三级数据更新逻辑一致 | + +--- + +> **一句话总结**:研究方案定义了"查什么",变量清单定义了"查哪些字段",质控规则定义了"怎么查",三级数据结构记录了"查的结果",质控报告汇总了"结论是什么"——这条链路从头到尾 1:1 对齐,是 CRA Agent 准确运行的根基。 diff --git a/docs/03-业务模块/IIT Manager Agent/08-对外输出报告/CRA智能质控Agent-四大工具工作原理说明.md b/docs/03-业务模块/IIT Manager Agent/08-对外输出报告/CRA智能质控Agent-四大工具工作原理说明.md new file mode 100644 index 00000000..c1576797 --- /dev/null +++ b/docs/03-业务模块/IIT Manager Agent/08-对外输出报告/CRA智能质控Agent-四大工具工作原理说明.md @@ -0,0 +1,357 @@ +# CRA 智能质控 Agent — 四大工具工作原理说明 + +> **文档用途**:面向临床专家、PI(主要研究者)及项目管理人员,说明 CRA 智能质控 Agent 背后的四个核心工具是如何工作的。 +> **最后更新**:2026-02-25 + +--- + +## 一、整体工作方式 + +CRA 智能质控 Agent 是一个基于大语言模型(LLM)的 AI 助手,专门用于 IIT 临床研究项目的质量监控。当用户在对话框中提问时,AI **不是凭空回答**,而是通过调用 4 个专用工具获取真实数据后,再基于数据生成回答。 + +### 工作流程 + +``` +用户提问(如:"当前通过率是多少?") + ↓ +AI 分析问题,自动选择合适的工具 + ↓ +工具执行,获取真实数据 + ↓ +AI 基于工具返回的数据,生成自然语言回答 + ↓ +用户看到回答(如:"当前整体通过率为 92.9%,14 条记录中 13 条通过...") +``` + +### 核心原则 + +- **所有回答必须基于真实数据**,AI 不会编造临床数据 +- **只读不写**:AI 不会修改任何临床数据,如果被要求修改数据会拒绝 +- **优先使用报告**:80% 的问题通过预计算的质控报告直接回答,保证速度和一致性 + +--- + +## 二、四个工具详解 + +### 工具 1:`read_report` — 质控报告查阅 + +**一句话说明**:查阅预先生成好的质控报告,是最常用的工具。 + +#### 什么时候使用? + +用户问以下类型的问题时,AI 会调用此工具: +- "目前通过率是多少?" +- "有哪些严重问题?" +- "3 号受试者有什么质控问题?" +- "哪个表单通过率最低?" +- "最近质控发现了什么趋势?" + +#### 报告包含哪些内容? + +| 报告章节 | 包含信息 | +|---------|---------| +| **概览(summary)** | 总记录数、已完成记录数、通过率、严重问题数、警告数、最后质控时间 | +| **严重问题(critical_issues)** | 每个有问题的受试者具体违反了哪条规则、实际值是什么、标准应该是什么 | +| **警告(warning_issues)** | 非致命性的数据异常提醒 | +| **表单统计(form_stats)** | 每张 CRF 表单的通过率 | +| **问题排名(trend)** | 哪些规则被违反最多,影响了多少人 | + +#### 报告示例 + +以下是 AI 实际读取的报告格式(修复后的真实报告): + +```xml + + + + - 状态: 1/14 记录存在严重违规 (7% Fail) + - 通过率: 92.9% + - 严重问题: 1 | 警告: 0 + - Top 1 问题: + 1. 排除标准检查 (1人) + + + + + **严重违规 (1项)**: + 1. [exc_001] **排除标准检查**: 当前值 **1** (标准: 应为0) + + + + +``` + +#### 报告数据从哪里来? + +``` +质控报告 ← 数据库中的质控日志(qc_logs 表) + ↓ + 每个受试者 × 每个访视事件,取最新一条质控记录 + ↓ + 按受试者+规则去重,避免重复计数 + ↓ + 通过率 = 按受试者级别计算(每个受试者取所有访视中最严重的状态) +``` + +#### 缓存机制 + +报告生成后会缓存 **24 小时**。在缓存有效期内,多次提问不会重复计算,保证了响应速度。执行"一键全量质控"后会自动刷新报告缓存。 + +#### 常见问题 + +**Q:报告里只有质控问题,不包含录入的原始数据吗?** +A:对。`read_report` 只包含质控结论(通过/不通过、违规详情)。如果要看具体患者的原始录入数据(如年龄、实验室指标),AI 会自动调用下面的 `look_up_data` 工具。 + +--- + +### 工具 2:`look_up_data` — 查询原始临床数据 + +**一句话说明**:从 REDCap 系统实时拉取患者的原始录入数据。 + +#### 什么时候使用? + +用户问以下类型的问题时,AI 会调用此工具: +- "3 号受试者的年龄是多少?" +- "帮我查一下 5 号患者的实验室检查结果" +- "这个患者的知情同意日期是什么时候?" + +#### 工作流程 + +``` +AI 调用 look_up_data(受试者编号=3, 查询字段=[年龄, 性别]) + ↓ +系统将中文字段名翻译为 REDCap 内部字段名(如:年龄 → age) + ↓ +通过 REDCap API 实时查询该患者数据 + ↓ +如果是纵向研究(多次访视),自动合并所有访视的数据 + ↓ +返回给 AI:{ record_id: "3", age: "28", gender: "1" } + ↓ +AI 组织成自然语言回答用户 +``` + +#### 关键特性 + +- **实时查询**:每次调用都从 REDCap 获取最新数据,不使用缓存 +- **支持中文查询**:用户可以说"帮我查年龄",系统会自动翻译为 REDCap 字段名 `age` +- **纵向数据自动合并**:同一患者在不同访视(筛选期、基线、随访等)中录入的数据会自动合并为一条完整记录 +- **只读操作**:只查询数据,绝不修改 + +#### 数据来源 + +直接通过 REDCap REST API 从 REDCap 数据库实时获取,数据路径为: + +``` +AI → REDCap API(http://REDCap服务器/api/)→ REDCap 数据库 → 返回记录 +``` + +--- + +### 工具 3:`check_quality` — 即时质控检查 + +**一句话说明**:对患者数据立即执行质控规则检查,可以检查单个患者或全部患者。 + +#### 什么时候使用? + +用户**明确要求重新检查**时,AI 才会调用此工具: +- "帮我重新检查一下 3 号受试者" +- "现在执行一次全量质控" +- "数据刚更新了,帮我跑一下质控" + +> **注意**:如果用户只是问"通过率是多少"这样的查询性问题,AI 会使用 `read_report` 而不是 `check_quality`,因为报告中已有预计算的结果。 + +#### 两种检查模式 + +##### 模式 A:单患者检查 + +``` +AI 调用 check_quality(record_id="3") + ↓ +从 REDCap 拉取 3 号患者的最新数据 + ↓ +加载项目配置的所有质控规则(如:年龄范围检查、排除标准检查等) + ↓ +逐条规则执行: + - 字段缺失?→ 跳过该规则(不算失败) + - 字段有值?→ 用 JSON Logic 规则引擎判定通过/失败 + ↓ +返回结果: + 整体状态: FAIL + 通过 8 / 9 条规则 + 违规: 排除标准检查 — 当前值为 1(应为 0) +``` + +##### 模式 B:全量批量检查 + +``` +AI 调用 check_quality()(不传受试者编号) + ↓ +从 REDCap 拉取所有患者的所有访视数据 + ↓ +基线数据合并:将基线访视的数据(如年龄、性别)合并到后续访视中 + ↓ +逐个 受试者 × 访视 执行规则引擎 + ↓ +结果写入数据库(追加新记录,不覆盖历史) + ↓ +返回汇总: + 总计: 42 条检查 + 通过: 39 条 + 未通过: 3 条 + 通过率: 92.9% + 问题受试者: [3号 - 排除标准检查违规] +``` + +#### 质控规则引擎说明 + +系统使用 **JSON Logic 规则引擎** 执行质控检查。目前配置的规则包括: + +| 规则类别 | 示例规则 | 说明 | +|---------|---------|------| +| **纳入标准** | 年龄范围检查(16-35岁) | 不在范围内则标记为严重问题 | +| **纳入标准** | 知情同意检查 | 必须已签署知情同意 | +| **排除标准** | 排除标准符合性检查 | 符合任何排除标准则标记为严重问题 | + +**缺失数据处理策略**(V3.2 修复后): +- 如果某个字段在当前访视中没有录入(如随访期没有"年龄"字段),该规则**自动跳过**,不算失败 +- 只有字段有值但不符合规则时,才判定为失败 + +--- + +### 工具 4:`search_knowledge` — 知识库检索 + +**一句话说明**:在项目文档(研究方案、CRF、伦理批件等)中搜索信息。 + +#### 什么时候使用? + +用户问以下类型的问题时,AI 会调用此工具: +- "纳入标准是什么?" +- "研究方案中对随访间隔是怎么规定的?" +- "主要疗效指标是什么?" +- "知情同意的要求有哪些?" + +#### 工作流程 + +``` +AI 调用 search_knowledge(query="纳入标准是什么") + ↓ +查找该项目关联的知识库 + ↓ +将用户问题转化为向量(语义表示) + ↓ +在知识库中进行语义相似度搜索(不是简单的关键词匹配) + ↓ +返回最相关的 5 个文档片段: + 1. 研究方案V2.0.pdf(相关度 87.3%) + 内容:"纳入标准:1. 年龄16-35岁 2. 确诊为..." + 2. ICF知情同意书.pdf(相关度 72.1%) + 内容:"..." + ↓ +AI 综合这些文档片段,生成回答 +``` + +#### 关键特性 + +- **语义搜索**:不是简单的关键词匹配,而是理解问题含义后搜索。比如问"入组条件"也能找到"纳入标准"的内容 +- **文档来源标注**:每个搜索结果都标注来自哪个文档,方便追溯 +- **相关度评分**:只返回相关度 ≥ 30% 的结果,过滤无关内容 +- **支持多种文档**:研究方案、CRF 说明、伦理批件、操作手册等上传到知识库的文档均可搜索 + +#### 知识库管理 + +知识库中的文档需要通过项目管理界面上传。上传后,系统会自动: +1. 解析文档内容(支持 PDF、Word 等格式) +2. 将文档分块(每块约 500-1000 字) +3. 为每个文档块生成向量嵌入(用于语义搜索) +4. 存入向量数据库 + +--- + +## 三、工具协作示例 + +以下是一个典型的多工具协作场景: + +### 场景:用户问"3 号受试者有什么问题?详细说明一下" + +``` +第 1 轮:AI 调用 read_report(section="critical_issues", record_id="3") + → 获取质控报告中 3 号受试者的问题列表 + → 发现:排除标准检查失败,实际值为 1 + +第 2 轮:AI 调用 look_up_data(record_id="3", fields=["exclusion_criteria"]) + → 从 REDCap 获取原始数据确认 + → 确认 exclusion_criteria 字段值确实为 1 + +AI 最终回答: +"3 号受试者存在 1 项严重违规:排除标准检查未通过。 + 该受试者的排除标准字段值为 1(应为 0,即不符合任何排除标准)。 + 建议核实该受试者是否确实符合入组条件。" +``` + +### 场景:用户问"纳入标准中年龄范围是多少?有没有超龄的患者?" + +``` +第 1 轮:AI 调用 search_knowledge(query="纳入标准年龄范围") + → 从研究方案中找到:"纳入标准第1条:年龄16-35岁" + +第 2 轮:AI 调用 read_report(section="critical_issues") + → 检查是否有年龄相关的质控问题 + → 当前无年龄违规记录 + +AI 最终回答: +"根据研究方案,纳入标准规定年龄范围为 16-35 岁。 + 当前质控报告显示,所有已录入受试者的年龄均在合规范围内, + 未发现超龄问题。" +``` + +--- + +## 四、总结对比 + +| 工具 | 核心用途 | 数据来源 | 实时性 | 使用频率 | +|------|---------|---------|--------|---------| +| **read_report** | 查阅质控报告 | 数据库(预计算报告) | 缓存 24 小时 | ~80% | +| **look_up_data** | 查询原始数据 | REDCap 实时 API | 实时 | ~10% | +| **check_quality** | 执行质控检查 | REDCap + 规则引擎 | 实时执行 | ~5% | +| **search_knowledge** | 搜索文档知识 | 项目知识库(向量搜索) | 准实时 | ~5% | + +### 设计理念 + +**报告优先,工具兜底**(Report-first, Tools-fallback) + +- 绝大多数质控问题通过预计算的报告直接回答,保证响应速度(秒级) +- 只在需要查看具体原始数据或重新执行质控时,才调用其他工具 +- AI 会根据问题类型自动选择最合适的工具,无需用户干预 + +--- + +## 附录:技术架构简图 + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ 用户对话界面 │ +│ (Web / 微信公众号) │ +└──────────────────────┬───────────────────────────────────────────┘ + │ 用户提问 + ▼ +┌──────────────────────────────────────────────────────────────────┐ +│ ChatOrchestrator(对话编排器) │ +│ │ +│ System Prompt(角色定义 + 工具选择策略) │ +│ ↓ │ +│ LLM(DeepSeek-V3)← Function Calling 循环(最多 3 轮) │ +│ ↓ │ +│ ┌─────────────┐ ┌─────────────┐ ┌──────────────┐ ┌───────────┐ │ +│ │ read_report │ │look_up_data │ │check_quality │ │search_ │ │ +│ │ │ │ │ │ │ │knowledge │ │ +│ └──────┬──────┘ └──────┬──────┘ └──────┬───────┘ └─────┬─────┘ │ +└─────────┼───────────────┼───────────────┼───────────────┼────────┘ + │ │ │ │ + ▼ ▼ ▼ ▼ + ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐ + │ QcReport │ │ REDCap │ │ REDCap │ │ pgvector │ + │ Service │ │ REST API │ │ REST API │ │ 向量搜索 │ + │ (数据库) │ │ (实时) │ │ + 规则引擎 │ │ (知识库) │ + └────────────┘ └────────────┘ └────────────┘ └────────────┘ +``` diff --git a/docs/03-业务模块/IIT Manager Agent/09-技术评审报告/CRA AI 替代工作梳理2.md b/docs/03-业务模块/IIT Manager Agent/09-技术评审报告/CRA AI 替代工作梳理2.md new file mode 100644 index 00000000..2ad63915 --- /dev/null +++ b/docs/03-业务模块/IIT Manager Agent/09-技术评审报告/CRA AI 替代工作梳理2.md @@ -0,0 +1,198 @@ +# **临床研究CRA自动化:基于人工智能的监查逻辑、工作规则与质控报告体系架构深度解析** + +## **临床研究监查的演进与人工智能干预的系统性背景** + +在当前的医学科研生态中,研究者发起的临床试验(Investigator-Initiated Trials, IIT)正处于指数级增长的阶段,其作为推动医学转化和探索超适应症疗法的重要引擎,正在重塑现代医学的创新边界。然而,随着临床试验方案复杂度的日益增加以及监管机构对数据完整性(Data Integrity)和受试者安全要求标准(如ICH-GCP E6 R2/R3)的不断提升,临床试验的质量控制面临着前所未有的压力。在这一体系中,临床监查员(Clinical Research Associate, CRA)扮演着核心的“守门人”角色,负责确保试验遵循方案执行、受试者权益得到保护以及临床数据准确无误地录入电子数据采集(Electronic Data Capture, EDC)系统。然而,传统的CRA工作模式高度依赖人力进行密集型的现场核查与数据比对,这种模式在面对海量、多源、异构的医疗数据时,不仅成本高昂、效率低下,且不可避免地受制于人类的认知疲劳、主观判断偏差以及知识盲区。 + +针对这一行业痛点,开发具备深度学习、自然语言处理(NLP)和复杂逻辑推理能力的自动化临床监查代理(CRA Agent),成为了临床研究数字化转型的必然趋势。构建一个能够切实替代或深度增强人类CRA工作的AI智能体,并非简单地将纸质核查清单转化为数字表单,而是需要对CRA的工作职责进行精确的本体论解构,将其隐性知识与临床试验的监管法规转化为机器可读的、确定性与概率性交织的算法规则集。本报告旨在穷尽式地梳理临床试验CRA的工作职责及其被AI替代的边界,深度解构AI执行变量质控、入排判断、不良事件评估等任务的核心逻辑规则,并全景式地定义CRA质控报告的底层架构与详尽的数据表单字段,从而为CRA Agent的系统研发提供具有学术深度与工程指导价值的底层框架。 + +## **CRA工作职责的全生命周期图谱及人工智能的替代性评估** + +临床研究的生命周期通常被划分为四个核心阶段:研究中心筛选(Site Selection)、研究中心启动(Site Initiation)、常规监查(Routine Monitoring)以及研究中心关闭(Site Close-out)。CRA的职责贯穿于这四个阶段,涉及法规伦理合规性审查、研究者关系维护、受试者安全监测以及海量临床数据的核查。为了让AI系统有效地介入并替代CRA的工作,必须对这些职责的“可计算性”与“物理依赖性”进行降维分析,从而界定AI替代的可行性边界。 + +### **高度不可替代性职责:物理空间介入与复杂关系管理** + +在当前的技术约束下,部分CRA职责由于高度依赖物理空间的感知或需要复杂的社会学情感交互,属于AI难以完全替代的领域,此类任务主要集中在项目启动与机构管理层面。例如,在研究中心筛选与启动阶段,CRA需要实地考察研究机构的硬件设施,包括确认试验药物(Investigational Product, IP)储存药房的温湿度监控系统是否合规、离心机等实验室设备的校准记录是否真实有效,以及受试者档案室的安全保密措施是否达标。此外,与主要研究者(Principal Investigator, PI)就试验方案的科学性进行深度学术探讨、与临床研究协调员(Clinical Research Coordinator, CRC)进行面对面的项目实操培训、以及在面对研究机构伦理委员会(IRB/IEC)和临床试验机构办公室(GCP Office)的行政审批阻力时进行的沟通与斡旋,均需要人类特有的情商、谈判技巧和临场应变能力。尽管AI可以通过生成标准化培训视频、自动发送合规性调查问卷来辅助这些环节,但其实体执行的缺位决定了这类工作仍需保留人类CRA的参与。 + +### **中度可替代性职责:人机协同与临床语境解析** + +中度可替代性职责主要指向那些涉及非结构化数据解析与复杂医学语境判断的任务,在这一层级,CRA Agent将作为人类的高级副手(Copilot)发挥作用。核心代表是原始数据审查(Source Data Review, SDR)。SDR不同于简单的数据比对,它要求CRA深入研读医生的病程记录、护理记录等非结构化文本,以评估研究的整体质量、方案依从性以及是否存在未被报告的隐匿性临床事件。凭借大型语言模型(LLM)的进步,CRA Agent能够以毫秒级的速度吞吐数百页的电子病历(eMR),通过语义识别技术高亮出潜在的逻辑矛盾。例如,AI可以识别出护理记录中提及的“患者主诉昨夜有轻微心悸”,并提示人类CRA关注这是否构成一个未记录的不良事件(AE)。然而,由于医学语言的模糊性、缩写的地域性差异以及病理进展的复杂多变,最终确认该症状的临床意义往往仍需要人类的医学背景介入。知情同意书(Informed Consent Form, ICF)的审查同样属于此类,AI可完美核对签名日期与版本号的匹配度,但对于知情同意过程中是否存在“胁迫”或“未充分理解”的伦理判断,仍超出算法的处理范畴。 + +### **高度可替代性职责:确定性数据核查与算法逻辑执行** + +在常规监查阶段,CRA将消耗其70%以上的工作时间用于原始数据核对(Source Data Verification, SDV)以及基于电子数据采集(EDC)系统的逻辑审查。这一领域具有极强的数据结构化特征、确定性的逻辑边界和高频重复性,是CRA Agent实现全面替代的主阵地。在互操作性标准(如HL7 FHIR)的支持下,只要实现了电子医疗记录系统与EDC系统的数据接口打通,CRA Agent即可实现100%的实时自动SDV,彻底消除人类CRA在疲劳状态下可能产生的数据核查遗漏。此外,系统变量的逻辑核查、受试者入排标准的数学判定、试验药物依从性的公式计算、随访超窗的日历追踪、以及海量数据查询(Data Clarification Form, DCF)的自动生成与闭环管理,完全可以被转化为计算机的底层指令。通过接管这些高密度、重体力的信息处理工作,AI不仅能将错误率无限逼近于零,更能将数据核查的延迟从传统的数周缩短至数据生成的瞬间,从而实现真正意义上的基于风险的动态质量管理(RBQM)。 + +## **CRA Agent核心逻辑规则库的深度解析与算法构建** + +为了使AI能够胜任高度可替代的数据监查工作,必须将GCP规范、临床试验方案以及人类CRA的经验直觉,转化为机器可执行的穷尽式规则集(Rule Engine)。这一规则库不仅包含简单的布尔逻辑判断,更涉及复杂的跨表单映射、时序关系推演以及医学本体库的匹配调用。以下是CRA Agent必须掌握的核心规则体系。 + +### **EDC系统变量质控规则(数据结构与逻辑完整性)** + +EDC系统中的变量质控是整个数据质量的地基,CRA Agent必须在这一层级部署多维度的验证算法,超越传统的系统内置数据边界检查(Edit Checks)。 + +第一维度是极值与生理合理性校验。系统不仅要判断数据类型(如数字、日期、文本),还需根据医学常识设定动态边界。例如,对于实验室指标“收缩压”,若AI读取到的数值为“1500 mmHg”,系统需立刻触发异常识别机制,判定其为极大概率的键盘录入偏移错误或单位错误,并自动生成数据质疑。此类校验还需结合受试者的基线特征进行动态调整,儿童与成人的心率合理区间存在显著差异,AI必须根据受试者人口学表单中的年龄字段动态匹配相应的校验阈值。 + +第二维度是时间轴与时序逻辑的一致性验证。临床试验的每一个操作都必须遵循严格的因果时序。最核心的时序法则是:知情同意(ICF)签署的时间戳必须严格早于任何与研究相关的筛选检查或干预措施的时间戳。用伪代码表示为:IF (Time\_of\_Any\_Study\_Procedure \< Time\_of\_Informed\_Consent) THEN Trigger\_Protocol\_Deviation(Major\_Violation)。此外,不良事件的结束日期不可早于开始日期,试验药物的回收日期不可早于发放日期,合并用药的服用周期必须与其适应症(即医疗史或AE)的时间窗口存在逻辑重叠。 + +第三维度是跨表单关联逻辑与性别/生理特征互斥校验。CRA Agent需要具备全局视野,穿透孤立的表单进行交叉验证。如果“人口统计学表单”中记录受试者性别为“男性”,AI必须自动锁定整个EDC系统中所有与女性生殖系统相关的字段(如妊娠试验结果、末次月经日期、妇科检查等),一旦发现数据录入则立即报警。同样,如果“试验终止表单”勾选了“因不良事件退出研究”,AI必须回溯“不良事件记录表”,验证是否存在至少一条严重程度足以导致退出的AE,且该AE的“对试验药物采取的措施”字段必须被标记为“永久停药”。任何缺失的逻辑闭环都将被AI转化为质控报告中的缺陷项。 + +### **受试者入排标准(I/E Criteria)的高级算法判断规则** + +受试者入选与排除标准是临床试验的安全底线与科学基石,违反入排标准的入组(Enrollment Protocol Deviation)是监管视察中的重大红线。人类CRA在核对入排标准时容易因漏看某项历史化验单而产生失误,而CRA Agent则通过构建多层逻辑树进行绝对严密的判断。 + +针对入选标准(Inclusion Criteria),AI需要执行复杂的提取、计算与对比任务。以典型标准“年龄在18至65岁之间,患有2型糖尿病至少5年,且筛选期HbA1c介于7.0%至10.5%之间”为例,CRA Agent的工作流如下:首先,系统提取受试者身份证号或出生日期,结合签署ICF的具体日期,通过精确到天的算法计算其年龄并进行布尔判定;其次,系统利用NLP技术从既往电子病历的自由文本中提取首次确诊2型糖尿病的日期(Anchor Date),计算病程并与5年的阈值比对;最后,系统通过API接口从中央实验室或本地LIS系统中直接抓取筛选期的糖化血红蛋白数值,判断其是否落在闭区间 \[7.0, 10.5\] 内。只有当所有维度的验证结果均返回 TRUE 时,AI才允许该标准通过。 + +针对排除标准(Exclusion Criteria),AI的核心能力在于对庞大的禁忌症与禁用药物数据库的交叉比对。临床方案通常会排除使用特定代谢酶抑制剂或具有某种严重并发症的患者。CRA Agent需要将受试者的“既往史表”与“合并用药表”映射至标准医学词典库(如ICD-10, WHODrug)。如果排除标准规定“禁用强效CYP3A4抑制剂”,AI必须解析受试者的用药清单,若发现“酮康唑”或“伊曲康唑”等药物,并计算其停药时间距离随机化时间的间隔(洗脱期,Washout Period)小于方案规定的周期(如5个半衰期或30天),AI将强制拒绝该受试者入组,并在筛选日志中详细记录驳回原因。 + +### **不良事件(AE)与严重不良事件(SAE)的多维智能评估规则** + +不良事件的管理是药物警戒(Pharmacovigilance)的核心。CRA Agent在此领域的规则集最为复杂,涉及医学编码、严重程度分级、因果关系判定与时效性监管。 + +首先是AE的自动编码与术语标准化规则。临床研究中心(Site)录入的AE描述往往是口语化或不规范的(例如“患者觉得恶心想吐”)。AI必须搭载深度学习编码引擎,将其自动映射为医学规管活动词典(MedDRA)中的首选语(Preferred Term, PT)(如“恶心”与“呕吐”)以及系统器官分类(SOC)(如“胃肠系统疾病”)。这种自动编码能力保证了跨中心数据的高度同质化,为后续的安全信号聚合分析奠定基础。 + +其次是严重程度(Severity)的分级规则。CRA Agent必须内嵌通用不良事件术语标准(CTCAE)逻辑引擎。当系统检测到患者实验室数据异常(如绝对嗜中性粒细胞计数 ANC 降至 0.8 x 10^9/L)时,AI应自动比对CTCAE标准,将其直接定级为“3级 中性粒细胞减少症”,并对比基线数据,如果属于新发或加重,则自动触发生成AE记录单的指令。 + +第三是因果关系(Causality)与预期性(Expectedness)判定规则。AI需计算AE发生时间与研究药物最后一次给药时间的差值(Time-to-onset),结合半衰期模型评估药代动力学上的关联可能。同时,AI将自动检索研究者手册(Investigator's Brochure, IB),比对该不良事件是否已被列为已知的不良反应。若是,则标记为预期(Expected);若否,则标记为非预期(Unexpected)。 + +对于SAE的判断与时效规则,一旦AE记录中被勾选了“导致死亡”、“危及生命”、“导致住院或住院时间延长”、“导致持续或显著的残疾/功能丧失”或“先天性异常/出生缺陷”这五大严重标准之一,AI将立刻赋予该事件最高级警报状态。系统必须内置严苛的时效计时器:从研究者获知该SAE的时间点起算,强制要求在24小时内生成初步SAE报告并自动发送至申办方(Sponsor)和伦理委员会,若倒计时剩余4小时仍未提交,AI将通过多渠道(短信、邮件、系统弹窗)自动追捕(Escalate)相关责任人。 + +### **方案偏离(Protocol Deviation, PD)的侦测与分类规则** + +方案偏离记录着研究实施过程中偏离既定协议的任何行为,其精确捕捉对评估试验结果的有效性至关重要。AI通过构建时空与行为坐标系来捕捉异常。 + +最常见的偏离是随访超窗(Visit Window Deviation)。试验方案会设定目标访视日及允许的窗口期(如访视3应在随机化后的第30天±3天进行)。AI规则引擎会根据基线锚点日期(Anchor Date)自动生成日历轴: + +![][image1] +![][image2] +若实际访视日历未落在该闭区间内,系统自动记录一条“超窗偏离”。 + +药物剂量偏离(Dosing Error)是另一种需要严密监控的情况。对于需要根据患者体重或体表面积(BSA)动态调整剂量的试验,AI将在每次访视时重新读取体征数据,运算出理论应服药量。若药物分发记录与理论值之差超出方案允许的范围,AI将判定为剂量偏离。此外,对于遗漏检查项目(Procedural Omission)的判定,AI会逐项核对“试验操作流程图(Schedule of Assessment)”,一旦发现某项必须执行的检查(如心电图)在相应访视期内未产生对应的数据录入或源文件支持,即判定为方案偏离。 + +### **扩展监控规则:合并用药、实验室预警与研究药物依从性(深度完善补充)** + +为了实现无死角监查,CRA Agent的规则库还需涵盖以下三个关键领域。 + +合并用药(Concomitant Medications, ConMed)核查规则:患者在试验期间使用的非研究药物必须被全面追踪。AI通过接入WHODrug词典,将药物商品名转化为解剖学治疗学化学(ATC)分类代码。核心规则在于时序关联校验:如果患者新增了一种抗生素,AI会反向推导并扫描AE记录,质询是否存在未被报告的感染事件;如果发现患者服用了方案禁止的急救药物,AI将立即触发主要方案偏离报告并提醒研究者评估该受试者是否应退出研究。 + +实验室预警系统(Lab Alert System)规则:针对中心实验室或本地实验室上传的数据,AI不仅比对绝对正常值范围,更需结合患者基线进行相对变化率的监测。例如,即使肝功能指标(ALT/AST)尚未突破正常上限的3倍(通常作为肝毒性判定标准),但如果其较基线连续两次呈50%以上的增长趋势,AI规则将提前触发预警信号,要求研究中心加强关注,防范潜在的药物性肝损伤(DILI)事件。 + +研究药物(IP)依从性计算规则:确保患者按照医嘱服药是疗效评价的前提。AI需建立闭环的药物核算体系。其计算逻辑如下: + +![][image3] +![][image4] +![][image5] +常规临床研究要求依从性必须处于 ![][image6] 的区间。若AI计算结果低于80%或高于120%,则自动生成依从性不良的记录,并要求中心在EDC中填写偏差原因说明,这直接关系到该受试者的数据是否能被纳入符合方案集(Per-Protocol Set, PPS)。 + +## **详尽的CRA质控报告体系与表单结构解析** + +人工智能取代人类CRA的最终输出形式是高度结构化、可视化且具有即时可操作性的质控报告(Quality Control Reports)。一个成熟的CRA Agent必须能够自动、定期或在触发特定阈值时,生成全套符合监管审评要求的报表体系。整个报告体系不仅要展示结果,更要提供审计轨迹(Audit Trail)。以下是每张核心数据表的详细字段设计及其底层AI生成逻辑。 + +### **表1:筛选与入选登记日志(Screening & Enrollment Log)** + +本表旨在全面监控研究中心的受试者招募漏斗及入组质量,是防范不合格受试者进入试验的第一道防线。它向申办方展示了入组转化率,并作为GCP稽查的重点文件。 + +| 受试者编号 (Subject ID) | 签署知情同意日期 (ICF Date) | 筛选检查日期 (Screening Date) | 入排标准是否全部满足 (I/E Met?) | 主要筛选失败原因描述 (Primary Screen Failure Reason) | 随机化/入组日期 (Randomization Date) | 分配组别/队列 (Cohort/Arm) | 筛选评估医生电子签名状态 (PI Signature Status) | +| :---- | :---- | :---- | :---- | :---- | :---- | :---- | :---- | +| 字符串 (如 S-001) | 日期 (YYYY-MM-DD) | 日期 (YYYY-MM-DD) | 布尔值 (Y/N) | 文本 (由AI自动提取违背的具体条款) | 日期 (YYYY-MM-DD) | 分类变量 | 布尔值 (已签/未签) | + +**AI填报逻辑解析:** + +CRA Agent实时监控前置筛选模块。入排标准是否全部满足这一字段并非由人工手动勾选,而是AI聚合引擎的输出结果:只有当底层逻辑运算中所有的入选条件返回 TRUE 且所有的排除条件返回 FALSE 时,该字段才自动呈现为 Y。若为 N,AI将从预设的词库中提取具体的失败条款填充到主要筛选失败原因描述中(例如“未满足入选标准第3条:左心室射血分数 \< 50%”)。此外,报告严格把控流程锁,如果评估医生电子签名状态为“未签”,该受试者的记录将被系统锁定,阻止其进入随机化系统(IWRS)。 + +### **表2:数据录入时效性与缺失率记录表(Data Entry Metrics & Missingness Log)** + +临床试验数据的质量在很大程度上取决于数据录入的及时性(Timeliness)和完整性(Completeness)。此表反映了研究中心(Site)的运作效率和配合度,是风险评估的核心指标。 + +| 中心编号 (Site ID) | 访视轮次 (Visit Name) | 预期应填字段总数 (Expected Fields) | 实际已填字段总数 (Entered Fields) | 缺失字段数量 (Missing Fields) | 数据缺失率 (%) (Missing Rate) | 平均录入延迟 (天) (Avg Entry Delay) | 录入质量警报级别 (Quality Alert Level) | +| :---- | :---- | :---- | :---- | :---- | :---- | :---- | :---- | +| 字符串 (如 Site-A) | 字符串 (如 Visit 2\) | 整数型 (由AI动态计算) | 整数型 | 整数型 | 浮点数百分比 | 浮点数 | 分类变量 (绿/黄/红) | + +**AI填报逻辑解析:** + +AI在此表中最复杂的工作是计算预期应填字段总数。由于EDC系统存在复杂的逻辑跳转(分支逻辑),如果受试者在“是否吸烟”选了“否”,后续关于“吸烟年限”和“日均支数”的字段就不属于“预期应填”范畴。AI会根据动态表单的渲染结果精确计算分母。缺失率的计算公式为:![][image7]。平均录入延迟则通过计算受试者实际访视日期与EDC系统中首次保存(Save)时间戳的差值得出。一旦缺失率超过方案预设容忍度(如 3%)或延迟超过临床试验协议规定的5个工作日,AI将自动把警报级别调至“红灯”,并触发一封自动催办邮件给具体的临床协调员(CRC)。 + +### **表3:数据质疑表(Data Clarification Form, DCF / Query Log)** + +数据质疑表是CRA Agent与临床研究中心进行业务沟通的桥梁。由系统发出的Query必须清晰、准确且具有非惩罚性的建设性语气。 + +| 质疑编号 (Query ID) | 受试者编号 (Subject ID) | 表单/模块名 (Form Name) | 变量名称 (Variable) | 质疑详情描述 (Query Text) | 生成来源 (Source) | 当前状态 (Status) | 中心答复/整改内容 (Site Response) | 质疑关闭所需天数 (Days to Close) | +| :---- | :---- | :---- | :---- | :---- | :---- | :---- | :---- | :---- | +| 唯一识别码 | 字符串 | 字符串 | 字符串 | 文本 (通过自然语言生成技术NLG自动书写) | 分类 (AI/人类) | 分类 (Open/Answered/Closed) | 文本 | 浮点数 | + +**AI填报逻辑解析:** + +当AI的后台规则引擎捕捉到任何如前文所述的逻辑悖论时,它将调用自然语言生成(NLG)模板组装质疑详情描述。例如,相比于生硬的系统报错“Date Error”,AI会生成类似于:“检测到合并用药表中的‘阿司匹林’起始服药日期为2025-05-10,该日期晚于停止服药日期2025-05-01。请核对源文件病历,并在必要时更新正确的时序数据”。在流程管理上,当CRC回复了质疑并修改了数据,状态自动转为“Answered”。随后,CRA Agent会瞬间重新执行该变量的逻辑校验,如果修改后的数据满足了合理性规则,AI将无缝地将状态更改为“Closed”,实现质疑管理的完全自动化闭环,极大地减少了数据锁定(Database Lock)前的时间成本。 + +### **表4:AE与SAE不良事件追踪记录表(AE/SAE Tracking Log)** + +该表涉及严苛的规管责任,是药物安全性评价(Pharmacovigilance, PV)的关键载体。它不仅是一个记录表格,更是一个动态跟进安全风险的仪表盘。 + +| 受试者编号 (Subject) | AE原始主诉词 (Verbatim Term) | MedDRA首选语 (PT) 及编码 | 开始日期 (Start) | 结束日期 (End) | CTCAE等级 (Grade 1-5) | 与试验药物的因果关系 (Causality) | 对试验药物采取的行动 (Action Taken) | 临床转归 (Outcome) | SAE标志 (SAE Y/N) | 监管报告提交状态 (Reg Reporting) | +| :---- | :---- | :---- | :---- | :---- | :---- | :---- | :---- | :---- | :---- | :---- | +| 字符串 | 文本 | 文本 (AI映射) | 日期 | 日期/持续中 | 分类 (自动分级或人工确认) | 分类 (肯定/很可能/可能/不可能) | 分类 (剂量不变/减量/暂停/永久停药) | 分类 (痊愈/好转/未愈/死亡等) | 布尔值 | 分类与时间戳 | + +**AI填报逻辑解析:** + +除了前述规则引擎中提到的自动编码与评级外,AI重点管理各列之间的逻辑依赖网络。例如,如果临床转归列选择了“死亡”,AI会强制锁定CTCAE等级为“5级(致死性)”,并强制SAE标志变更为“Y”。此外,监管报告提交状态列会与研究中心的监管事务模块联动,如果系统识别到这是一个SAE,但该列显示“未向伦理委员会/卫健委提交”,且距离AI捕获事件的时间已逼近24小时限期,AI将在系统首页亮起最优先级的红灯警告,甚至自动阻断该中心的新受试者入组权限,直至合规操作完成。 + +### **表5:方案偏离记录汇总表(Protocol Deviation Record Log)** + +该表用于衡量研究方案在执行层面的受损程度。过多的方案偏离可能导致临床试验数据的拒收。 + +| 偏离编号 (PD ID) | 受试者编号 (Subject) | 偏离发生日期 (Date of Event) | 偏离类别 (PD Category) | 偏离详情描述 (Description) | 严重程度分类 (Severity Major/Minor) | 根本原因分析 (Root Cause) | 纠正与预防措施落实状态 (CAPA Status) | 是否需报告伦理 (IRB Reporting) | +| :---- | :---- | :---- | :---- | :---- | :---- | :---- | :---- | :---- | +| 唯一识别码 | 字符串 | 日期 | 分类 (如随访超窗/剂量错误/程序遗漏/违背入排) | 文本 | 分类 | 文本 (中心填写) | 分类 (已起草/实施中/已验证并关闭) | 布尔值 | + +**AI填报逻辑解析:** + +CRA Agent的分类器根据预设的树状图将捕捉到的偏离自动分配到相应的偏离类别中。在严重程度分类方面,AI实施基于风险的管理原则:推迟1天的访视通常被标记为“Minor(轻微)”,而导致受试者面临生命风险的操作失误(如误服禁忌药)则被判定为“Major(重大)”。对于被标记为Major的偏离,AI强制启动CAPA(纠正与预防措施)工作流。AI会追踪CAPA Status,只有当研究机构提交了书面的根本原因分析、执行了相应的补救措施(如重新培训员工),并在后续监控中(如随后5名受试者的访视中)未再发生同类错误时,AI才允许将该CAPA的状态流转为“已验证并关闭”。 + +### **表6:研究药物(IP)管理与依从性明细表(IP Accountability Log)** + +这不仅关乎治疗效果的严谨性,更是防范药品流失和滥用的重要核查手段。 + +| 中心编号 (Site ID) | 受试者编号 (Subject) | 发药日期 (Dispense Date) | 发药批号/药盒编号 (Kit Number) | 发放数量 (Qty Dispensed) | 回收日期 (Return Date) | 回收数量 (Qty Returned) | 预期应使用量 (Expected Qty) | 受试者依从性 (Compliance %) | 差异处理说明 (Discrepancy Note) | +| :---- | :---- | :---- | :---- | :---- | :---- | :---- | :---- | :---- | :---- | +| 字符串 | 字符串 | 日期 | 字符串 | 浮点数/整数 | 日期 | 浮点数/整数 | 浮点数/整数 (AI运算) | 浮点数百分比 (AI运算) | 文本 | + +**AI填报逻辑解析:** + +前文提及的公式在这里得到系统化应用。AI不仅进行简单的加减法,还需要处理“遗失药品”的复杂情况。如果患者报告不慎丢失了5粒胶囊,AI要求中心在系统内明确记录,并在计算公式中进行权责剥离,以反映真实的生理依从性而非简单的物理回收率。对于批号追踪,AI会交叉比对IWRS(交互式网络响应系统)的数据,若发现中心错发了另一患者的药盒,或发放了已经过期的批次,AI会立刻将此事件升级为“重大方案偏离”,并启动潜在的受试者安全性评估流程。 + +### **表7:研究中心整体健康度量表与预警摘要(Site Health Scorecard & Monitoring Summary)** + +这是一张高度提炼的宏观报表,供项目经理(PM)、申办方或数据监查委员会(DMC)在高层决策时使用。它标志着AI从执行层迈向了管理层。 + +| 中心名称 (Site Name) | 招募达成率 (%) (Enrolment Target %) | 整体数据缺失率 (%) | 开放中未解决质疑数量 (Open Queries) | 重大方案偏离发生率 (%) (Major PD Rate) | 未逾期SAE上报率 (%) (SAE Compliance) | 中心健康度评分 (Site Health Score) | 建议后续行动 (Recommended Action) | +| :---- | :---- | :---- | :---- | :---- | :---- | :---- | :---- | +| 字符串 | 浮点数百分比 | 浮点数百分比 | 整数 | 浮点数百分比 | 浮点数百分比 | 整数 (0-100分) | 文本 (由AI推荐决策) | + +**AI填报逻辑解析:** + +CRA Agent通过赋予各个维度不同的权重参数(如SAE上报违规扣除20分,缺失率每高出基准线1%扣除5分),利用加权平均算法实时计算出一个动态的中心健康度评分。这一报告是驱动RBQM(基于风险的质量管理)的核心。如果某个中心的评分跌破阈值(如60分),AI将在建议后续行动中输出类似“立即停止该中心新的受试者招募,并建议委派人类CRA于7日内进行线下有因监查(For-Cause Audit)”的指令。这种基于数据的智能分级管理,使得申办方能够将宝贵的医疗资源和人工精力精准投放至高风险机构。 + +## **CRA Agent 的全域系统工作流架构及实施策略** + +为了支撑上述规则库的高效运转和质控报告的精准输出,CRA Agent在软件工程层面需要一套高度耦合的数据流转架构。这套工作流可以解构为四个核心中枢。 + +首先是“全源数据摄取中枢(Data Ingestion Hub)”。AI Agent不能仅仅是一个被动接收EDC数据的孤岛。它必须通过API、HL7标准接口或光学字符识别(OCR)技术,持续不断地从医院电子病历(EMR)、实验室信息系统(LIS)、影像归档和通信系统(PACS)以及患者电子报告结局(ePRO)工具中抓取底层数据。数据的多源汇聚是实现自动化原始数据核查(SDV)的先决条件。 + +其次是“动态逻辑运算引擎(Rule Execution Engine)”。这是AI的“大脑”,负责运行所有确定性的临床试验法规判断(如年龄计算、访视超窗比对)和概率性的判断(如NLP驱动的潜在AE发掘)。系统必须支持规则的可视化配置,因为不同的IIT项目方案千差万别,创业团队需要提供一个低代码(Low-Code)平台,让医学经理能够根据具体协议快速调整诸如“化疗药剂量偏差允许范围为±5%”之类的专属规则参数。 + +第三是“自适应干预与通信网关(Adaptive Communication Gateway)”。当AI发现了数据错误,它不应仅仅在后台默默记录,而必须主动发起通信。通过生成我们前文详述的“数据质疑表”和“自动化警告”,AI能够以邮件、应用内消息乃至短信的形式直接与临床研究协调员交互。该网关需要具备跟踪反馈时效的能力,并能根据事件的紧急程度进行多级升级(Escalation)调度。 + +最后是“全景态势感知终端(Dashboard & Reporting Terminal)”。这是面向临床研究管理者的前端。所有结构化的质控报告(表1至表7)在此汇集,以交互式可视化的方式展现。数据管理人员可以向下钻取(Drill-down),从宏观的中心健康度评分一路追踪到某一位特定患者具体的血常规异常未上报记录,从而实现宏观洞察与微观审计的无缝衔接。 + +[image1]: + +[image2]: + +[image3]: + +[image4]: + +[image5]: + +[image6]: + +[image7]: \ No newline at end of file diff --git a/docs/03-业务模块/IIT Manager Agent/09-技术评审报告/CRA 质控报告自动化生成与 LLM 友好型设计规范.md b/docs/03-业务模块/IIT Manager Agent/09-技术评审报告/CRA 质控报告自动化生成与 LLM 友好型设计规范.md new file mode 100644 index 00000000..42feaf43 --- /dev/null +++ b/docs/03-业务模块/IIT Manager Agent/09-技术评审报告/CRA 质控报告自动化生成与 LLM 友好型设计规范.md @@ -0,0 +1,106 @@ +# **CRA 质控报告自动化生成与 LLM 友好型设计规范** + +**文档版本**:V2.1 (5 层架构剪枝与 LLM 实战版) + +**设计目标**:利用“五层数据坐标剪枝”与“语义组装”,形成对大模型(LLM)极度友好、绝不产生幻觉的 Payload。 + +## **核心设计原则:LLM 友好性 (LLM Readiness)** + +要让 LLM 稳定输出,必须遵循“三不”原则: + +1. **不喂全量数据**:依托 5 层架构执行 JSON 树剪枝,只提取 status \= FAIL 的切片。 +2. **不喂物理字段**:所有的 lb\_alt\_v 必须经过 AutoMapper 映射为 谷丙转氨酶(ALT)。 +3. **不让 LLM 算数**:日期差、缺失率由 Node.js 算好,直接把“结论标签”喂给 LLM。 + +## **报告生成链路与 LLM 模拟实战 (基于 5 层坐标)** + +### **场景一:数据质疑管理 (eQuery Log) \- D3 规则** + +**【业务场景】** D3 逻辑发现异常,生成一条像资深 CRA 写的质疑描述。 + +**【剪枝后的 5层 LLM 友好输入片段 ✅】** + +{ + "system\_instruction": "你是一个严谨的临床数据经理。请根据下方异常数据,生成一条专业的电子质疑(eQuery)。指出矛盾点并给建议,100字以内。", + "clinical\_context": { + "受试者ID": "P005", + "访视阶段": "随访2 (Week 4)", + "触发表单": "实验室检查", + "数据实例": "Instance 1" + }, + "anomaly\_data": { + "异常指标": "谷丙转氨酶 (ALT)", + "当前录入值": "150 U/L", + "HardRuleEngine\_计算结论": "超出正常上限 (50 U/L) 的 3 倍", + "跨表核查结果": "查询《不良事件(AE)表》所有 Instance,当前记录数为 0" + } +} + +**【LLM 输出示例】** + +“检测到该受试者在「随访2」的「实验室检查」中,谷丙转氨酶(ALT)为 150 U/L,已超出正常上限 3 倍。经跨表核查,未发现对应的《不良事件(AE)》记录。请核实检验结果是否录入无误;若无误,请评估是否需要补充填报 AE。” + +### **场景二:不良事件风险评估 (AE Log) \- D5 规则** + +**【业务场景】** 提取录入的 AE,结合患者的其他重复表单(如多次服药),提示风险。 + +**【应对重复表单 (Repeating Forms) 的完美 JSON 结构 ✅】** + +系统精确拉取 AE\_Log 和 ConMed\_Log 下的不同 InstanceID: + +{ + "system\_instruction": "作为临床安全评估助手,请分析患者新发的 AE,结合既往病史和用药,提示潜在风险关联。你无权下定论,仅供 PI 参考。", + "patient\_context": { + "受试者": "P012", + "合并用药表 (ConMed\_Log)": \[ + {"instance\_id": 1, "药物名称": "阿司匹林"}, + {"instance\_id": 2, "药物名称": "布洛芬"} + \] + }, + "不良事件表 (AE\_Log)": \[ + {"instance\_id": 3, "事件名称": "消化道出血", "严重程度": "CTCAE 3级"} + \], + "rag\_knowledge\_retrieval": "方案规定:非甾体抗炎药(NSAIDs)如布洛芬、阿司匹林联用会显著增加消化道出血风险。" +} + +**【LLM 输出示例】** + +“该受试者新发 3 级「消化道出血」(Instance 3)。系统监测到患者正在同时服用「阿司匹林」(Instance 1)与「布洛芬」(Instance 2)。结合方案提示,非甾体抗炎药联用是高危出血因素。建议 PI 重点关注该 AE 是否与合并用药具有因果关系。” + +### **场景三:方案偏离记录 (PD Log) \- D6 规则** + +**【喂给 LLM 的切片 ✅】** + +{ + "system\_instruction": "请根据硬逻辑引擎计算出的偏差事实,撰写连贯的方案偏离描述,并提出纠正预防措施(CAPA)。", + "deviation\_facts": { + "坐标": "Record:P002 \-\> Event:随访3 \-\> Form:访视表 \-\> Instance:1", + "偏离类型": "访视超窗", + "HardRuleEngine\_计算依据": { + "方案允许窗口": "2026-02-15 ±3天", + "实际发生日期": "2026-02-25", + "计算结果": "延误 10 天" + } + } +} + +### **场景四:数据缺失率总结 (Missing Rate Log) \- D2 规则** + +**【特殊说明:此报告尽量不用 LLM】** + +数据缺失率表严禁使用 LLM 计算百分比!完全由 Node.js CompletenessEngine 根据 Instance 和分支逻辑动态计算完成。LLM 仅根据最终聚合数值生成执行摘要。 + +**【喂给 LLM 的切片 ✅】** + +{ + "report\_type": "数据完整性摘要", + "total\_missing\_rate": "5.0%", + "top\_missing\_forms": \[ + {"form": "SF-36 生活质量问卷", "missing\_rate": "40%"} + \], + "task": "基于以上统计数据,写两句话的执行摘要。" +} + +**技术收益总结**: + +五层架构(特别是 InstanceID 实例层)的引入,让发给 LLM 的 JSON 数据结构具备了完美的数组映射能力,彻底消灭了多行数据相互覆盖的 Bug,并最大程度防范了 AI 幻觉! \ No newline at end of file diff --git a/docs/03-业务模块/IIT Manager Agent/09-技术评审报告/Skill化配置架构技术设计.md b/docs/03-业务模块/IIT Manager Agent/09-技术评审报告/Skill化配置架构技术设计.md new file mode 100644 index 00000000..b0258cb4 --- /dev/null +++ b/docs/03-业务模块/IIT Manager Agent/09-技术评审报告/Skill化配置架构技术设计.md @@ -0,0 +1,99 @@ +# **Skill 化配置架构与业务流转技术设计文档** + +**文档版本**:V2.1 (5层架构与语义解耦全景版) + +**核心理念**:规则必须脱离底层代码;质控逻辑必须基于“医学语义”而非“物理字段”进行配置,以适应不同的 IIT 项目。 + +## **第一章 架构总览:从“硬编码”到“Skill 驱动”** + +我们将数据、规则与报告的生成链路设计为高度解耦的三段式架构: + +\[阶段一:基建与抽象\] \[阶段二:Skill 编排配置\] \[阶段三:执行与映射\] + (项目前置准备) (DM 规则配置) (引擎自动计算) + +1\. REDCap 字典同步 1\. 选择 Skill 模板 1\. 监听数据变化 (Webhook) +2\. 知识库向量化 (RAG) \-\> 2\. 绑定语义标签 \-\> 2\. 提取 5 层级联坐标 +3\. LLM 语义映射对齐 3\. 设定阈值与报警级别 3\. 引擎执行逻辑 (硬/软) + (AutoMapper) (赋能 D1-D7 维度) 4\. 结果落入 5层数据底座 + 5\. 自动组装 5大 GCP 报告 + +## **第二章 配置前置准备 (Project Initialization)** + +在配置具体的质控 Skill 前,系统必须完成项目的“数字孪生”构建: + +### **2.1 结构化数据基建:REDCap 元数据同步** + +* **动作**:调用 exportMetadata 获取字典,包含分支逻辑(Branching Logic)。这为 D2 (缺失率) 计算打下基石。 + +### **2.2 非结构化知识基建:建立专属 RAG 大脑** + +* **动作**:上传方案 Protocol、ICF,进行向量化。为 D5/D6 的 SoftRule 模糊推理提供依据。 + +### **2.3 核心解耦:AutoMapper 语义映射 (重中之重)** + +* **动作**:利用 LLM,将 REDCap 毫无规律的物理变量名(如 ie\_01, ae\_term),映射为**系统内置标准医学语义标签**(如 \[入选标准\_年龄\], \[不良事件\_名称\])。 +* **价值**:DM 面向通用语义配置规则,无需关心物理表名,实现通用 Skill 的跨项目复用。 + +## **第三章 Skill 核心数据模型设计 (The Skill Schema)** + +### **3.1 数据库表结构 (iit\_skills)** + +model IitSkill { + id String @id @default(cuid()) + projectId String // 绑定到具体项目 + name String // e.g., "ALT 重度异常监测" + + category String // 关键!挂载到 7 大维度: D1, D2, D3, D5, D6 + // 质控深度,全面升级支持 5 层架构 + targetLevel String // RECORD, EVENT, FORM, INSTANCE, FIELD + + engineType String // HARD\_RULE (JSON Logic) 或 SOFT\_RULE (LLM) + triggerType String // WEBHOOK (实时) 或 CRON (定时) + + configuration Json // JSON 配置体 + isActive Boolean @default(true) +} + +### **3.2 Configuration 字段设计 (配置解析)** + +**A. HardRule (硬规则 \- 适用于 D1, D3, D7)** + +采用 JSON Logic 语法,实现毫秒级绝对判定。 + +{ + "semantic\_tags": \["实验室\_谷丙转氨酶"\], + "logic": { + "\>": \[{"var": "实验室\_谷丙转氨酶"}, 150\] + }, + "on\_fail\_message": "谷丙转氨酶(ALT)达到重度异常标准,触发安全预警。", + "severity": "CRITICAL" +} + +**B. SoftRule (软规则 \- 适用于 D5, D6)** + +采用 Prompt 模板,供 LLM 结合 RAG 在各个 Instance 间进行推理。 + +{ + "semantic\_tags": \["不良事件\_症状描述", "合并用药\_药物名称"\], + "prompt\_template": "患者主诉发生了 {不良事件\_症状描述},请检索知识库禁忌药目录,评估这是否与患者填报的第 {InstanceID} 行用药 {合并用药\_药物名称} 存在禁忌冲突?", + "rag\_enabled": true, + "severity": "WARNING" +} + +## **第四章 配置工作流与执行链路 (How it works in action)** + +### **4.1 DM 配置工作流 (UI 交互层)** + +1. **选择维度**:DM 选择配置【D1 入排标准】。 +2. **选择模板**:选择“数值范围检验”。 +3. **绑定语义**:下拉选择 \[入选标准\_年龄\]。 +4. **设置条件**:\>= 18 且 \<= 75。保存发布。 + +### **4.2 引擎自动化执行链路 (Execution Pipeline)** + +1. **触发与拦截**:REDCap Webhook 抵达。 +2. **提取五层坐标**:解析出 Record:P005 \-\> Event:V2 \-\> Form:AE\_Log \-\> Instance:2 \-\> Field:ae\_term。 +3. **加载 Skills**:查询匹配的 active Skills。 +4. **装填数据**:按 AutoMapper 映射装填数值。 +5. **引擎计算**:调起 HardRuleEngine 或 SoftRuleEngine。 +6. **状态回写**:若失败,将原因写入 qc\_field\_status,携带 Category (如 D5),红灯沿五层坐标向上一路冒泡至 Record 级。 \ No newline at end of file diff --git a/docs/03-业务模块/IIT Manager Agent/09-技术评审报告/五层数据架构方案评审反馈.md b/docs/03-业务模块/IIT Manager Agent/09-技术评审报告/五层数据架构方案评审反馈.md new file mode 100644 index 00000000..76971ac0 --- /dev/null +++ b/docs/03-业务模块/IIT Manager Agent/09-技术评审报告/五层数据架构方案评审反馈.md @@ -0,0 +1,99 @@ +# 五层数据架构方案评审反馈 + +> **评审对象**:架构团队提交的 4 份技术文档 +> 1. 核心数据架构与业务落地白皮书 +> 2. 核心转换机制白皮书:数据·规则·报告链路设计 +> 3. Skill 化配置架构技术设计 +> 4. CRA 质控报告自动化生成与 LLM 友好型设计规范 +> +> **评审日期**:2026-03-01 +> **评审结论**:方向认可,分层落地 + +--- + +## 一、总体评价 + +方案方向正确、视野开阔,5 层数据架构和 7 维度报告体系在理论上完整且符合 CDISC 标准。但它是一份**理想态蓝图**,与系统实际现状有较大距离,需要区分"方向性采纳"和"现阶段过度设计"。 + +--- + +## 二、值得借鉴的建议(采纳) + +| # | 建议 | 采纳理由 | 系统现状差距 | +|---|------|---------|-------------| +| 1 | **InstanceID 实例层(第 4 层)** | AE、合并用药等重复表单必须精确到"第几条"才能定位问题,这是架构方案中**最有价值的贡献** | `IitQcLog`、`IitEquery` 均缺少 `instanceId` 字段 | +| 2 | **规则按 D1-D7 维度分类** | 多维报告的前提,也是上轮内部讨论的共识 | 当前 `QCRule.category` 仅 4 种值,未对齐 D1-D7 | +| 3 | **状态冒泡机制** | Field → Instance → Form → Event → Record 的级联逻辑清晰,便于前端热力图展示和报告聚合 | 仅有记录级 `IitRecordSummary`,无独立的事件级/变量级状态表 | +| 4 | **LLM 三不原则** | "不喂全量、不喂物理字段、不让 LLM 算数"与我们已有实践高度一致,值得正式化为设计规范 | 已有剪枝和预计算,但字段语义化尚未实现 | +| 5 | **AutoMapper 语义映射** | REDCap 字段名(如 `ie_01`、`ae_term`)对 LLM 不友好,反向语义化是必要的 | `IitFieldMapping` 表已有 alias→actual 映射,缺 actual→semantic 方向 | +| 6 | **优先级调整:D2 提至 P0** | IIT 中数据缺失率是最普遍的问题,D2 重要性应高于 D6/D7 | 我们原规划将 D2 放在阶段二,可考虑提前 | + +--- + +## 三、暂不采纳的建议 + +| # | 建议 | 暂不采纳的理由 | +|---|------|---------------| +| 1 | **D8 核心数据 SDV(AI 多模态视觉比对)** | 这是一个独立产品功能(前端上传 + OCR + 比对),不是质控引擎的架构问题。技术难度极高,且 IIT 场景 SDV 比率不高,应单独立项 | +| 2 | **AutoMapper 全自动 LLM 映射** | 用 LLM 自动将物理变量名映射为医学语义标签,准确率不可控。建议改为**半自动**:系统基于 Data Dictionary 的 `field_label` 提供建议,DM 人工确认 | +| 3 | **5 张 GCP 报表同时规划** | 系统连 D2(缺失率)都还未实现,一次性规划 5 张完整 GCP 报表脱离现实。数据底座做好后,报告生成是顺水推舟的事 | +| 4 | **Branching Logic 解析器(阶段一)** | REDCap 分支逻辑语法是非标准格式,解析器开发成本高。阶段一先用简单统计替代,分支逻辑解析推迟到 D2 专项实现时 | + +--- + +## 四、落地策略:分 3 个批次 + +**不建议一次性修复。** 原因:改动范围涉及 schema + 引擎 + 报告服务 + 前端,回归测试压力大;D5/D6/D7 的规则尚无临床专家输入,空设架构无意义。 + +### 批次 A:数据底座加固(1-2 周) + +让现有功能的数据粒度正确。 + +- `IitQcLog` + `IitEquery` 增加 `instanceId` 字段 +- `QCRule.category` 扩展为 D1-D7 维度枚举 +- `IitFieldMapping` 增加 `semanticLabel`(反向语义化) +- 新建 `qc_field_status` 表(5 层坐标 + category + status) +- `HardRuleEngine` 执行结果写入 `qc_field_status` +- `QcReportService` 基于 `qc_field_status` 生成语义化报告 + +**验收标准**:一键全量质控后 `qc_field_status` 有正确的 5 层坐标数据;LLM 报告中字段名为中文语义。 + +### 批次 B:聚合层与冒泡机制(1-2 周) + +完成五级聚合,支撑多维报告和前端热力图。 + +- 新建 `qc_event_status` 表(由 `field_status` 聚合) +- 改造 `IitRecordSummary`(由 `event_status` 聚合) +- 实现状态冒泡逻辑(field → instance → form → event → record) +- 多维报告框架(按 D1-D7 分章节) +- 前端:受试者×表单热力图原型 + +**验收标准**:字段 FAIL 后对应 event 和 record 自动变红;报告按维度分章节。 + +### 批次 C:新维度引擎(按需) + +扩展质控覆盖面,**依赖临床专家确认规则可行性**。 + +- D2 CompletenessEngine(先简单统计,后续加分支逻辑解析) +- D6 方案偏离引擎(访视超窗检测) +- D5 SoftRule + RAG 联动(AE 漏报侦测) +- 项目健康度评分模型 + +**前提条件**:临床专家已确认各维度规则;有真实 IIT 项目数据验证;批次 A+B 已稳定运行。 + +--- + +## 五、架构团队二次评审补充建议(4 条,全部采纳) + +| # | 建议 | 采纳结论 | 落地位置 | +|---|------|---------|---------| +| 1 | **跨表单"上下文断层"**:Webhook 只带单表单数据,跨表规则(D5 AE 漏报)无法执行 | 完全采纳。`QcExecutor.executeSingle()` 一律拉取该患者全量数据(Record-Level Context) | 开发计划 3.7 节 | +| 2 | **eQuery 自动闭环缺失**:Field 从 FAIL 变 PASS 时无人关闭关联 eQuery | 完全采纳。新增 State Transition Hook,用 `auto_closed` 状态区分人工关闭 | 开发计划 3.8 节 | +| 3 | **D2 缺失率"未来时空"陷阱**:新入组患者未来访视的必填字段被算作缺失 | 完全采纳。增加 Event-Aware 时序过滤,只统计已到达事件的必填字段 | 开发计划 6.3 节 | +| 4 | **聚合防抖锁粒度**:项目级聚合在多 CRC 并发录入时有行锁竞争 | 部分采纳。实时触发用受试者级防抖,项目级统计独立低频刷新 | 开发计划 3.3 节 | + +--- + +## 六、一句话总结 + +> 5 层数据架构方向正确,**InstanceID** 和 **rule_category** 是我们最缺的两块拼图,必须尽快补上。表结构可以一次设计到位,但填充内容要一层一层来——先底座(批次 A),再聚合(批次 B),最后扩维度(批次 C)。 diff --git a/docs/03-业务模块/IIT Manager Agent/09-技术评审报告/核心数据架构与业务落地白皮书.md b/docs/03-业务模块/IIT Manager Agent/09-技术评审报告/核心数据架构与业务落地白皮书.md new file mode 100644 index 00000000..c6cbc495 --- /dev/null +++ b/docs/03-业务模块/IIT Manager Agent/09-技术评审报告/核心数据架构与业务落地白皮书.md @@ -0,0 +1,111 @@ +# **纯数字 CRA 平台:五层数据底座与核心引擎技术白皮书** + +**文档版本**:V2.1 (CDISC ODM 国际标准 5 层全景版) + +**核心理念**:以 CDISC ODM 国际标准为底座,彻底解决重复表单问题,打造对人类透明、符合 GCP 规范、对 LLM 友好的临床质控体系。 + +## **第一章 核心数据架构:五层状态级联树 (The 5-Layer Hierarchy)** + +为了支撑前端的“受试者 × 表单”热力图,并彻底解决重复表单(Repeating Forms,如多次发生的 AE、多次服药记录)的数据覆盖与追踪问题,我们严格对齐 CDISC ODM 标准,采用 **5 层状态级联架构**作为系统的影子质控底座。 + +### **1.1 数据实体定义与 GPS 坐标** + +系统中的任何一个质控状态、任何一条 eQuery,都必须唯一且绝对精确地绑定在这个五维坐标上: + +1. **RecordID (受试者级)**:例如 P005。反映该患者的整体健康度。 +2. **EventID (访视/事件级)**:例如 Visit\_2(随访2)。反映特定时间点的数据质量。 +3. **FormID (表单级)**:例如 AE\_Log(不良事件表)。**(前端风险热力图的 X 轴核心来源)** +4. **InstanceID (实例/行号级)**:例如 1, 2, 3。**(核心突破!用于区分同一患者填写的第 1 个 AE、第 2 个 AE。非重复表单默认值为 1)** +5. **FieldID (字段/变量级)**:例如 ae\_name(不良事件名称)。最底层的原子数据。 + +### **1.2 状态级联与冒泡机制 (State Bubbling)** + +当后台的 HardRuleEngine 或 SoftRuleEngine 发现数据异常时: + +1. **精准打标**:系统首先在最底层的 qc\_field\_status 表中,将坐标 P005 \-\> Visit\_2 \-\> AE\_Log \-\> Instance\_2 \-\> ae\_name 标记为 FAIL,并生成一条 eQuery。 +2. **状态冒泡 (自动向上传递)**: + * 包含该字段的实例 Instance\_2 (第2条不良事件) 状态变为 FAIL。 + * 包含该实例的表单 AE\_Log 的状态自动变为 FAIL(热力图该格子变红)。 + * 包含该表单的访视 Visit\_2 自动变为 FAIL。 + * 受试者 P005 的全局状态自动变为 FAIL。 +3. **闭环消警**:当 CRC 在 REDCap 修改了该实例 Instance\_2 的值,引擎复核通过,底层 Field 变绿,绿灯逐级向上点亮,直至受试者恢复健康状态。 + +## **第二章 CRA 质控工作优先级策略** + +面对庞杂的临床规则,我们应遵循\*\*“高频易错、AI 擅长、IIT 痛点”\*\*的原则排定工作优先级: + +### **📌 优先级一:P0 级(系统上线必须具备的核心基石)** + +这些是 IIT 试验中最容易崩盘,且传统 CRC 最头疼的硬逻辑工作。AI 处理这些具有 100% 的准确性和碾压级效率。 + +1. **D2 数据完整性 (Completeness)**:扫描 Form、Instance 和 Field 级数据,基于 REDCap 的分支逻辑(Branching Logic)计算真实的缺失率。 +2. **D3 逻辑准确性 (Accuracy \- Edit Checks)**:时序校验(知情同意 \< 筛选 \< 首次用药)、数值极值校验、跨表单关联必填项校验。 +3. **D1 入排合规性 (Eligibility)**:对入选/排除标准表单进行强逻辑逆向校验。 + +### **📌 优先级二:P1 级(建立系统护城河的亮点功能)** + +1. **D8 核心数据 SDV 强制核对 (AI Vision)**:针对主要研究终点(如肿瘤大小、关键生化指标),强制要求 CRC 在前端上传原始影像/化验单。AI 通过多模态视觉比对。 +2. **D5 安全性事件漏报侦测 (Safety)**:跨表单与跨实例关联。AI 发现化验单达到 3 级毒性,但 AE\_Log 表中没有任何一个 Instance 记录该事件,立刻触发告警。 + +### **📌 优先级三:P2 级(锦上添花的拓展功能)** + +* **D6 方案偏离 (超窗检测)** 与 **D7 试验药物依从性管理**。由于 IIT 很多是非药物研究且随访不严,可作为可选模块后期加入。 + +## **第三章 以终为始:多维 CRA 质控报告体系设计** + +结合系统化的 SaaS 管理看板与极其严谨的 GCP 规范要求,我们将最终输出的质控报告划分为两大层级:**“宏观管理驾驶舱层”与“核心业务报表层”**。 + +### **3.1 宏观管理层 (Executive Dashboard)** + +用于系统前台实时展示,提供全局项目健康度: + +* **项目综合健康度评分**(基于 D1\~D6 加权计算)。 +* **受试者风险热力图**(直观展示哪位受试者的哪个表单异常)。 +* **CRC 数据录入绩效**(eQuery 平均关闭时间、录入时效)。 + +### **3.2 核心业务报表层 (GCP 规范自动化日志)** + +这是数字 CRA 真正替代人工的交付物。系统基于“五层数据底座”结合 AI 判定,**自动生成以下 5 张标准化的临床记录表**: + +#### **表 1:筛选与入选登记表 (对应 D1)** + +* **涵盖字段**:筛选编号、性别/年龄、诊断、纳入/排除标准是否符合(AI 判定)、筛选结果(入组/失败)、失败原因(AI 自动抓取不符条款)、电子签名。 + +#### **表 2:数据录入率 & 缺失率记录表 (对应 D2)** + +* **涵盖字段**:表格名称、应录入总字段数(基于 Branching Logic 动态计算)、实际已录入字段数、**数据录入率 & 缺失率 %**、缺失变量与原因、审核时间戳。 + +#### **表 3:数据质疑管理跟踪表 (eQuery Log) (对应 D3/D4)** + +* **涵盖字段**:质疑编号、5层精确坐标定位、AI 质疑描述(客观描述矛盾点)、发现日期/发现人(AI)、CRC 解释与回复、二次复核状态、关闭日期。 + +#### **表 4:AE 不良事件记录与监查表 (对应 D5 \- 核心合规文件)** + +* **涵盖字段**:受试者信息、AE 名称/症状、起止日期、严重程度、**与干预的因果关系(AI 提示禁忌,PI 最终定性)**、处理措施与转归、SAE 时效监控、**⚠️ AI 挖掘出的疑似漏报事件高亮**。 + +#### **表 5:方案偏离记录表 (PD Log) (对应 D6)** + +* **涵盖字段**:偏离编号、发生日期、**偏离类型(AI 自动分类:超窗/禁忌用药/未采集等)**、偏离描述、对安全性影响评估(AI 初判,人工复核)、纠正预防措施(CAPA)。 + +## **第四章 架构的 LLM 友好性设计与优化 (LLM Readiness)** + +我们的 5 层级联树状架构,天生就是为了喂给 LLM 而设计的最佳结构。采用四大 LLM 优化策略: + +### **优化 1:语义化映射网关 (AutoMapper) —— 解决 LLM 看不懂** + +* **痛点**:REDCap 导出的变量名叫 lb\_dt, ae\_yn。 +* **优化**:通过 iit\_field\_mapping 表,将其转换为具有临床语义的键:{"采样日期": "2026-02-25", "是否发生不良事件": "否"}。 + +### **优化 2:基于实例的剪枝 (Instance-Level Pruning) —— 解决 Token 溢出** + +* **痛点**:写报告时没必要把 95% 正确的数据喂给模型。 +* **优化**:执行 JSON 树剪枝。只 query 出 qc\_field\_status \= 'FAIL' 的那一小块实例分支: + {"受试者\_P005": {"不良事件表": \[{"Instance\_2": {"事件": "发烧", "质控": "未填严重程度"}}\]}}。 + +### **优化 3:XML 提示词封装 —— 解决上下文幻觉** + +* 利用 \, \, \, \ 严格包裹数据,先进的大模型对 XML 层级的理解极佳,能完美规避张冠李戴。 + +### **优化 4:计算下放 (CPU over GPU) —— 解决算术薄弱** + +* LLM 绝对不做日期和数值计算(如算缺失率百分比、算超窗天数)。所有硬核计算由 Node.js HardRuleEngine 算好,直接把“结论标签”传给 LLM 组装文字。 \ No newline at end of file diff --git a/docs/03-业务模块/IIT Manager Agent/09-技术评审报告/核心转换机制白皮书:数据·规则·报告链路设计.md b/docs/03-业务模块/IIT Manager Agent/09-技术评审报告/核心转换机制白皮书:数据·规则·报告链路设计.md new file mode 100644 index 00000000..1200a44f --- /dev/null +++ b/docs/03-业务模块/IIT Manager Agent/09-技术评审报告/核心转换机制白皮书:数据·规则·报告链路设计.md @@ -0,0 +1,72 @@ +# **核心转换机制白皮书:从五层数据底座到多维质控报告的生成逻辑** + +**文档定位**:系统设计核心枢纽,连接底层数据库、规则引擎与前台业务报表。 + +**阅读对象**:后端架构师、规则配置工程师(DM)、产品经理 + +## **核心理念:质控的本质是“坐标映射与计算”** + +在我们的系统中,没有任何一份报告是凭空“写”出来的。所有的报告,其本质都是: + +1. 取出特定 **五层坐标 (Record \-\> Event \-\> Form \-\> Instance \-\> Field)** 上的数据。 +2. 放入特定的 **规则引擎 (D1\~D7)** 进行计算。 +3. 将计算结果(PASS/FAIL/数值)**组装并投影**到 5 张标准化 GCP 报告的单元格中。 + +## **逐表拆解:5大核心报告的生成机制与数据链路** + +### **报告一:筛选与入选登记表 (对应 D1 规则)** + +**业务目的**:证明患者入组是完全合规的,没有放错人进来。 + +| 报告显示的列 (微小内容) | 依赖的 5 层数据坐标 (查什么) | 调用的质控规则与逻辑 (算什么) | 执行引擎 | +| :---- | :---- | :---- | :---- | +| **筛选日期** | Event=筛选期 \-\> Form=知情同意表 \-\> Instance=1 \-\> Field=签署日期 | 提取知情书的签署日期。 | 基础查询 | +| **纳入/排除是否符合** | Event=基线 \-\> Form=入排标准表 \-\> Instance=1 \-\> Field=所有入排项 | **\[D1 逻辑\]** 验证纳入标准是否全为“是”,排除标准是否全为“否”。 | HardRuleEngine | +| **时序合规校验** | Form=知情表 vs Form=基线表 | **\[D1 逻辑\]** 校验 知情签署日期 \<= 筛选检查发生日期。违规即阻断。 | HardRuleEngine | +| **失败原因** | qc\_field\_status \= 'FAIL' 的具体 Field | 提取亮红灯的 FieldID 对应的字典标签(如:排除标准3不符)。 | AutoMapper | + +### **报告二:数据录入率 & 缺失率记录表 (对应 D2 规则)** + +**业务目的**:证明数据的完整性。最考验系统工程能力的一张表。 + +| 报告显示的列 | 依赖的 5 层数据坐标 (查什么) | 调用的质控规则与逻辑 (算什么) | 执行引擎 | +| :---- | :---- | :---- | :---- | +| **应录入总字段数** | REDCap 字典 & Branching Logic | **\[D2 逻辑 \- 极度核心\]** 代入患者其他字段值,解析分支逻辑。例如:如果 性别=男,则该患者所有 妊娠检查 相关的 Instance 应录入数 \= 0。 | CompletenessEngine | +| **实际已录入字段数** | 遍历当前 Form 下所有 Instance 的所有 Field | 计算非空 (value \!= null) 的字段总数。 | 基础查询 | +| **数据缺失率 (%)** | 上述两个计算结果 | 公式:(应录入 \- 实录入) / 应录入 \* 100%。 | CPU 计算 | + +### **报告三:数据质疑管理跟踪表 (eQuery Log) (对应 D3/D4 规则)** + +**业务目的**:记录逻辑矛盾和笔误的闭环过程。 + +| 报告显示的列 | 依赖的 5 层数据坐标 (查什么) | 调用的质控规则与逻辑 (算什么) | 执行引擎 | +| :---- | :---- | :---- | :---- | +| **质疑编号 & 定位** | 取 status \= 'FAIL' 的 5 层全路径 | 拼接坐标:\[Record\]-\[Event\]-\[Form\]-\[Instance\]-\[Field\]。 | 基础查询 | +| **问题描述 (AI 生成)** | 提取异常 Field 的 actual\_value,预期规则 | **\[D3 逻辑\]** 极值/关联校验。 **LLM组装**:将硬规则警报抛给 LLM,翻译成严谨的人类句子。 | HardRule \+ SoftRule | +| **当前状态 & 解决时间** | pending\_actions 表流转状态 | **\[D4 逻辑\]** 监听该 Instance 的数据变更。复核通过,状态从 OPEN 变 CLOSED。 | 状态机 | + +### **报告四:AE 不良事件记录表 (对应 D5 规则 \- 核心亮点)** + +**业务目的**:展现 AI 作为“数字 CRA”挖掘隐患的强大能力。 + +| 报告显示的列 | 依赖的 5 层数据坐标 (查什么) | 调用的质控规则与逻辑 (算什么) | 执行引擎 | +| :---- | :---- | :---- | :---- | +| **显性 AE 记录** | Form=不良事件表 的所有 Instance | 遍历每个独立 Instance(第 1 次 AE, 第 2 次 AE),提取名称、开始/结束日期。 | 基础查询 | +| **⚠️ 疑似漏报 AE 挖掘** | 遍历 Form=实验室检查 等表单的 Field | **\[D5 逻辑\]** 发现化验数值达到 2 级及以上异常,跨表检索 AE表 所有 Instance,若无匹配记录,触发告警。 | HardRule | +| **与干预的因果关系** | Form=不良事件表 \+ Form=合并用药表(所有Instance) | 提取患者服用的所有药物。**LLM知识库比对**,若属禁忌,提示 PI 关注。 | SoftRule (RAG) | + +### **报告五:方案偏离记录表 (PD Log) (对应 D6 规则)** + +**业务目的**:抓取不按方案日历和标准操作的违规行为。 + +| 报告显示的列 | 依赖的 5 层数据坐标 (查什么) | 调用的质控规则与逻辑 (算什么) | 执行引擎 | +| :---- | :---- | :---- | :---- | +| **偏离类型:访视超窗** | Event=本次随访 实际日期 vs Event=基线 日期 | **\[D6 逻辑\]** 依据方案 SoA,计算天数差。若超窗则判定为偏离。 | HardRule | +| **偏离类型:禁忌药** | Form=合并用药表 \-\> 所有 Instance \-\> 药物名称 | **\[D6 逻辑\]** 将各 Instance 录入药物名与方案知识库比对。 | SoftRule (RAG) | +| **偏离描述与分级建议** | 综合上述计算结果 | LLM 将“超窗 5 天”总结为连贯描述,并根据规则库预判 Major/Minor 偏离。 | SoftRule (LLM) | + +## **终极总结:这三者是如何协同工作的?(The Orchestration)** + +1. **底层地基 (数据坐标)**:CDISC 5层架构提供了极其精确的 GPS 定位,彻底搞定了重复表单数据覆盖问题。 +2. **流水线加工 (规则引擎)**:当 Webhook 带着 InstanceID 涌入时,系统调取 D1\~D7 规则。算缺失率用 D2 解析分支;查超窗用 D6 算日期;查漏报 AE 用 D5 跨 Instance 匹配。 +3. **顶层组装 (质控报告与 LLM)**:引擎产生带标签的坐标点(如 P005-V2-AE\_Log-Instance\_3: 严重程度未填)。系统将这些剪枝后的碎片喂给 LLM,LLM “翻译”并填入 5 张标准的 GCP 报表。 \ No newline at end of file diff --git a/docs/04-开发规范/11-OSS存储开发规范.md b/docs/04-开发规范/11-OSS存储开发规范.md index db3606e2..ed3051b9 100644 --- a/docs/04-开发规范/11-OSS存储开发规范.md +++ b/docs/04-开发规范/11-OSS存储开发规范.md @@ -118,6 +118,12 @@ ai-clinical-data[-dev]/ │ └── {uuid}.xlsx # Tool C 上传、ASL 导入等 │ └── system/ # 系统级资源 + ├── knowledge-bases/ # 系统知识库(Prompt 等) + │ └── {kbId}/ + │ └── {docId}.pdf + ├── iit-knowledge-bases/ # IIT 项目知识库(按项目隔离) + │ └── {kbId}/ + │ └── {docId}.pdf ├── templates/ # 预置模板 │ └── gcp_guide.pdf └── samples/ # 演示数据 @@ -143,6 +149,7 @@ ai-clinical-data[-dev]/ | SSA 统计分析 | `ssa` | Excel | 用户私有 | | EKB 企业知识库 | `ekb` | PDF | 租户共享 | | EMR 病历数据 | `emr` | JSON | 租户共享 | +| IIT 项目知识库 | `iit-kb` | PDF、Word | 系统文件 (`system/iit-knowledge-bases/`) | | Tool C 数据清洗 | - | Excel | 临时文件 | --- diff --git a/docs/05-部署文档/00-阿里云SAE最新真实状态记录.md b/docs/05-部署文档/00-阿里云SAE最新真实状态记录.md index 331d93f6..13dd51b3 100644 --- a/docs/05-部署文档/00-阿里云SAE最新真实状态记录.md +++ b/docs/05-部署文档/00-阿里云SAE最新真实状态记录.md @@ -1,7 +1,7 @@ # 🚀 AI临床研究平台 - 阿里云SAE最新真实状态记录 > **文档用途**:记录阿里云SAE服务器最新真实状态 + 每次部署记录 -> **最后更新**:2026-02-27 +> **最后更新**:2026-02-28 > **维护人员**:开发团队 > **说明**:本文档准确记录SAE上所有应用的当前状态,包括内网地址、镜像版本、用户名密码等关键资源信息 @@ -12,9 +12,9 @@ | 服务名称 | 部署状态 | 镜像版本 | 部署位置 | 最后更新时间 | |---------|---------|---------|---------|-------------| | **PostgreSQL数据库** | ✅ 运行中 | PostgreSQL 15 + 插件 | RDS | 2026-02-27 | -| **前端Nginx服务** | ✅ 运行中 | **v1.8** | SAE | 2026-02-27 | +| **前端Nginx服务** | ✅ 运行中 | **v1.9** | SAE | 2026-02-28 | | **Python微服务** | ✅ 运行中 | **v1.2** | SAE | 2026-02-27 | -| **Node.js后端** | ✅ 运行中 | **v2.1** | SAE | 2026-02-27 | +| **Node.js后端** | ✅ 运行中 | **v2.3** | SAE | 2026-02-28 | | **R统计引擎** | ✅ 运行中 | **v1.0.1** | SAE | 2026-02-27 | | **Dify AI服务** | ⚠️ 已废弃 | - | - | 使用pgvector替代 | @@ -37,8 +37,8 @@ |---------|---------|---------|---------| | **python-extraction** | **v1.2** | ~1.1GB | `crpi-cd5ij4pjt65mweeo-vpc.cn-beijing.personal.cr.aliyuncs.com/ai-clinical/python-extraction:v1.2` | | **ssa-r-statistics** | **v1.0.1** | ~1.8GB | `crpi-cd5ij4pjt65mweeo-vpc.cn-beijing.personal.cr.aliyuncs.com/ai-clinical/ssa-r-statistics:v1.0.1` | -| **ai-clinical_frontend-nginx** | v1.3 | ~50MB | `crpi-cd5ij4pjt65mweeo-vpc.cn-beijing.personal.cr.aliyuncs.com/ai-clinical/ai-clinical_frontend-nginx:v1.3` | -| **backend-service** | v1.7 | ~838MB | `crpi-cd5ij4pjt65mweeo-vpc.cn-beijing.personal.cr.aliyuncs.com/ai-clinical/backend-service:v1.7` | +| **ai-clinical_frontend-nginx** | **v1.9** | ~50MB | `crpi-cd5ij4pjt65mweeo-vpc.cn-beijing.personal.cr.aliyuncs.com/ai-clinical/ai-clinical_frontend-nginx:v1.3` | +| **backend-service** | **v2.3** | ~838MB | `crpi-cd5ij4pjt65mweeo-vpc.cn-beijing.personal.cr.aliyuncs.com/ai-clinical/backend-service:v1.7` | --- @@ -138,10 +138,13 @@ postgresql://airesearch:Xibahe%40fengzhibo117@pgm-2zex1m2y3r23hdn5.pg.rds.aliyun ```bash NODE_ENV=production PORT=3001 -DATABASE_URL=postgresql://airesearch:Xibahe%40fengzhibo117@pgm-2zex1m2y3r23hdn5.pg.rds.aliyuncs.com:5432/ai_clinical_research?connection_limit=18&pool_timeout=10 +DATABASE_URL=postgresql://airesearch:Xibahe%40fengzhibo117@pgm-2zex1m2y3r23hdn5.pg.rds.aliyuncs.com:5432/ai_clinical_research_test?connection_limit=18&pool_timeout=10 # Python微服务地址 -EXTRACTION_SERVICE_URL=http://172.17.173.66:8000 +EXTRACTION_SERVICE_URL=http://172.17.173.102:8000 + +# R统计引擎地址 +R_SERVICE_URL=http://172.17.173.101:8080 # OSS配置 OSS_ACCESS_KEY_ID=LTAI5tB2Dt3NdvBL3G7nYGv7 @@ -149,6 +152,7 @@ OSS_ACCESS_KEY_SECRET=1iSN9k39RkApP93QjUhC1DcPIeMG4V OSS_BUCKET=ai-clinical-research OSS_ENDPOINT=oss-cn-beijing-internal.aliyuncs.com OSS_REGION=cn-beijing +STORAGE_TYPE=oss # JWT密钥 JWT_SECRET=8a3f9e7c2d1b5a4e6f8c9d0a3b5e7f1c2a4b6c8d0e2f4a6b8c0d2e4f6a8b0c2d4 @@ -163,9 +167,11 @@ CLOSEAI_API_KEY=sk-cu0iepbXYGGx2jc7BqP6ogtSWmP6fk918qV3RUdtGC3Ed1po CLOSEAI_OPENAI_BASE_URL=https://api.openai-proxy.org/v1 CLOSEAI_CLAUDE_BASE_URL=https://api.openai-proxy.org/anthropic -# Dify配置 -DIFY_API_URL=http://localhost/v1 -DIFY_API_KEY=dataset-mfvdiKvQ213NvxWm7RoYMN3c +# Deep Research +UNIFUNCS_API_KEY=sk-2fNwqUH7... + +# MinerU PDF提取 +MINERU_API_BASE=https://mineru.net/api/v4 # Postgres-Only架构 QUEUE_TYPE=pgboss @@ -175,11 +181,17 @@ CACHE_TYPE=postgres WECHAT_CORP_ID=ww6ab493470ab4f377 WECHAT_AGENT_ID=1000002 WECHAT_CORP_SECRET=AZIVxMtoLb0rEszXS81e4dBRl-I9kgTjygIS0cFfENU + +# 旧系统 MySQL 连接(Legacy Bridge) +LEGACY_MYSQL_HOST=8.154.22.149 +LEGACY_MYSQL_PORT=3306 +LEGACY_MYSQL_USER=root +LEGACY_MYSQL_DATABASE=xzyx_online ``` **前端Nginx(frontend-nginx-service)**: ```bash -BACKEND_SERVICE_HOST=172.17.173.73 +BACKEND_SERVICE_HOST=172.17.197.30 BACKEND_SERVICE_PORT=3001 ``` @@ -247,25 +259,26 @@ TEMP_DIR=/tmp/extraction_service ### 3.2 前端Nginx服务 -**当前部署版本**:v1.2 +**当前部署版本**:v1.9 **镜像信息**: - **仓库名称**:`ai-clinical_frontend-nginx` -- **镜像版本**:`v1.2` ✅(当前部署版本) +- **镜像版本**:`v1.9` ✅(当前部署版本) - **镜像大小**:约50MB - **基础镜像**:`nginx:alpine` -- **构建时间**:2026-01-01 -- **镜像摘要**:sha256:1b444d3d04e0e0e62a50009f165991b7d0192d7e21804aa9a3b3558e5aec7961 +- **构建时间**:2026-02-28 +- **镜像摘要**:sha256:ff3c19152f5f5944d4be187d7bc5af986fd00b69501effbd0d53a6f8b064228a **部署状态**: -- ✅ 已成功部署到SAE(2026-01-01) -- ✅ 服务运行正常(内网地址:http://172.17.173.80:80) +- ✅ 已成功部署到SAE(2026-02-28) +- ✅ 服务运行正常(内网地址:http://172.17.197.31:80) - ✅ 企业微信域名验证文件已部署(WW_verify_YnhsQBwI0ARnNoG0.txt) -**v1.2版本更新内容**: -- ✅ 修复Windows换行符问题(docker-entrypoint.sh CRLF→LF) -- ✅ 添加企业微信域名验证文件 -- ⚠️ 部署后内网地址已变更:172.17.173.72 → 172.17.173.80 +**v1.9版本更新内容**: +- ✅ 新增旧系统集成前端模块(Legacy iframe嵌入) +- ✅ 新增 ASL 图表生成器 / Meta分析引擎 +- ✅ 新增 IIT AI对话页 +- ⚠️ 部署后内网地址变更:172.17.197.29 → 172.17.197.31 **Git文件结构**: ``` @@ -282,16 +295,16 @@ AIclinicalresearch/frontend-v2/ ### 3.3 Node.js后端服务 -**当前部署版本**:v1.3 +**当前部署版本**:v2.3 **镜像信息**: - **仓库名称**:`backend-service` -- **镜像版本**:`v1.3` ✅(已部署) -- **镜像大小**:838MB (压缩后 ~186MB) -- **基础镜像**:`node:22-alpine` -- **构建时间**:2025-12-25 18:43 +- **镜像版本**:`v2.3` ✅(已部署) +- **镜像大小**:~838MB +- **基础镜像**:`node:alpine` +- **构建时间**:2026-02-28 - **构建策略**:改进版方案B(本地编译+Docker打包) -- **镜像摘要**:sha256:a4ffb61c15af1cd1ed9de187b4464a1aab773918e5b41b4df5b8ad96514f9941 +- **镜像摘要**:sha256:6e25d13cc7e1b228638a30eda35bd4aa70e0f7d52283cf66a2ffb21bee4ea288 **技术架构**: - **Node.js版本**:22.x @@ -302,8 +315,8 @@ AIclinicalresearch/frontend-v2/ - **缓存系统**:PostgreSQL(替代Redis) **部署状态**: -- ✅ 已成功部署到SAE(2025-12-25 18:55) -- ✅ 服务运行正常(内网地址:http://172.17.173.73:3001) +- ✅ 已成功部署到SAE(2026-02-28) +- ✅ 服务运行正常(内网地址:http://172.17.197.30:3001) - ✅ 健康检查通过 **Git文件结构**: @@ -319,26 +332,26 @@ AIclinicalresearch/backend/ ### 3.4 Python微服务 -**当前部署版本**:v1.0 +**当前部署版本**:v1.2 **镜像信息**: - **仓库名称**:`python-extraction` -- **镜像版本**:`v1.0` -- **镜像大小**:1.12GB +- **镜像版本**:`v1.2` ✅(已部署) +- **镜像大小**:~1.1GB - **基础镜像**:`python:3-slim` -- **构建时间**:2025-12-24 +- **构建时间**:2026-02-27 **功能模块**: -- ✅ **PDF文本提取**:PyMuPDF 1.26.7 +- ✅ **PDF文本提取**:PyMuPDF - ✅ **Docx文档提取**:Mammoth + python-docx -- ✅ **数据清洗(DC工具)**:pandas 2.0+ / numpy 1.24+ / polars 0.19+ +- ✅ **数据清洗(DC工具)**:pandas / numpy / polars - ✅ **语言检测**:langdetect -- ❌ **Nougat OCR**:已移除(减小镜像1.5GB) +- ✅ **数据取证(RVW V2.0)**:scipy(T检验、卡方检验逆向计算) **部署状态**: -- ✅ 已部署到SAE(2025-12-24) -- ✅ 应用运行正常(2个worker进程) -- ✅ 内网访问地址:`http://172.17.173.66:8000` +- ✅ 已部署到SAE(2026-02-27) +- ✅ 应用运行正常(2个worker进程,2核4GB) +- ✅ 内网访问地址:`http://172.17.173.102:8000` - ✅ 健康检查通过 **Git文件结构**: @@ -353,6 +366,37 @@ AIclinicalresearch/extraction_service/ ## 🔄 四、部署历史记录 +### 2026-02-28(0228部署 - Node.js后端 + 前端更新 - 旧系统集成上线) + +#### 部署概览 +- **部署时间**:2026-02-28 +- **部署范围**:Node.js后端 + 前端Nginx 更新 +- **主要变更**:旧系统集成(Wrapper Bridge)上线、ASL图表/Meta分析、IIT AI对话页等 + +#### Node.js后端更新(v2.2 → v2.3) +- ✅ 新增模块:Legacy Bridge(旧系统集成认证服务) +- ✅ 新增依赖:`mysql2`(连接旧系统MySQL) +- ✅ 新增环境变量:`LEGACY_MYSQL_HOST`、`LEGACY_MYSQL_PORT`、`LEGACY_MYSQL_USER`、`LEGACY_MYSQL_DATABASE` +- ✅ 新增 API:`POST /api/v1/legacy/auth`(旧系统Token注入) +- ✅ 镜像构建推送:`backend-service:v2.3`(digest: sha256:6e25d13c...) +- ✅ SAE部署成功,内网地址变更:`172.17.197.28` → `172.17.197.30` + +#### 前端Nginx更新(v1.8 → v1.9) +- ✅ 新增模块:Legacy System iframe嵌入(研究管理 + 统计工具) +- ✅ 新增组件:`LegacySystemPage`、`ResearchManagement`、`StatisticalTools` +- ✅ 镜像构建推送:`ai-clinical_frontend-nginx:v1.9`(digest: sha256:ff3c1915...) +- ✅ SAE部署成功,内网地址变更:`172.17.197.29` → `172.17.197.31` + +#### 环境变量同步 +- ✅ `frontend-nginx-service` 的 `BACKEND_SERVICE_HOST` 更新为 `172.17.197.30` +- ℹ️ CLB 由阿里云自动更新,无需手动操作 + +#### 文档产出 +- ✅ `0228部署/01-部署完成总结.md` +- ✅ `00-阿里云SAE最新真实状态记录.md`(更新) + +--- + ### 2026-02-27(0227部署 - 数据库迁移 + R统计引擎 + Python更新) #### 部署概览 @@ -472,7 +516,7 @@ AIclinicalresearch/extraction_service/ 5. **SAE部署成功**: - ✅ 部署镜像版本:v1.2 - - ✅ 环境变量配置:`BACKEND_SERVICE_HOST=172.17.173.73`, `BACKEND_SERVICE_PORT=3001` + - ✅ 环境变量配置:`BACKEND_SERVICE_HOST=172.17.197.30`, `BACKEND_SERVICE_PORT=3001` - ✅ 容器启动正常,健康检查通过 - ✅ 新内网地址:`http://172.17.173.80:80` - ⚠️ IP地址变化:172.17.173.72 → 172.17.173.80 @@ -524,7 +568,7 @@ AIclinicalresearch/extraction_service/ - 错误配置:`PYTHON_SERVICE_URL` - 正确配置:`EXTRACTION_SERVICE_URL=http://172.17.173.66:8000` - ✅ Node.js后端重新部署,IP地址更新为:http://172.17.173.73:3001 -- ✅ 更新前端Nginx环境变量:BACKEND_SERVICE_HOST=172.17.173.73 +- ✅ 更新前端Nginx环境变量:BACKEND_SERVICE_HOST=172.17.197.30 - ✅ 配置CLB负载均衡器,获得公网访问地址:http://8.140.53.236/ - ✅ 工具C的7大功能全部测试通过 - ✅ 文献筛查模块测试通过 @@ -547,5 +591,5 @@ AIclinicalresearch/extraction_service/ --- > **提示**:本文档记录SAE服务器的最新真实状态,每次部署后必须更新! -> **最后更新**:2026-02-27 -> **当前版本**:前端v1.8 | 后端v2.1 | Python v1.2 | R统计v1.0.1 | PostgreSQL 15 +> **最后更新**:2026-02-28 +> **当前版本**:前端v1.9 | 后端v2.3 | Python v1.2 | R统计v1.0.1 | PostgreSQL 15 diff --git a/docs/05-部署文档/01-日常更新操作手册.md b/docs/05-部署文档/01-日常更新操作手册.md index f3da418a..0ca5c83e 100644 --- a/docs/05-部署文档/01-日常更新操作手册.md +++ b/docs/05-部署文档/01-日常更新操作手册.md @@ -1,7 +1,7 @@ # 日常更新操作手册 -> 版本: v2.0(合并自旧版 19-日常更新快速操作手册 + 0227 部署实战经验) -> 更新日期: 2026-02-27 +> 版本: v2.1(补充 0228 部署经验) +> 更新日期: 2026-02-28 > 适用: 日常代码更新、功能迭代、配置变更 --- @@ -27,7 +27,7 @@ docker login --username=gofeng117@163.com --password=fengzhibo117 crpi-cd5ij4pjt ## 2. Node.js 后端更新(~25 分钟) -**当前版本**: v2.2 → 下个版本: v2.3 +**当前版本**: v2.3 → 下个版本: v2.4 ### 2.1 构建 @@ -39,7 +39,7 @@ npm run build # 或: npx tsc --noCheck # 构建 Docker 镜像 -docker build -t backend-service:v2.3 . +docker build -t backend-service:v2.4 . ``` > **0227 经验**: `tsc` 不会拷贝 `.json` 配置文件,Dockerfile 中已有 `COPY src/modules/ssa/config/*.json` 等补丁步骤。如新模块有 JSON 配置文件需要确认 Dockerfile 覆盖到。 @@ -47,9 +47,9 @@ docker build -t backend-service:v2.3 . ### 2.2 推送 ```powershell -docker tag backend-service:v2.3 crpi-cd5ij4pjt65mweeo.cn-beijing.personal.cr.aliyuncs.com/ai-clinical/backend-service:v2.3 +docker tag backend-service:v2.4 crpi-cd5ij4pjt65mweeo.cn-beijing.personal.cr.aliyuncs.com/ai-clinical/backend-service:v2.4 -docker push crpi-cd5ij4pjt65mweeo.cn-beijing.personal.cr.aliyuncs.com/ai-clinical/backend-service:v2.3 +docker push crpi-cd5ij4pjt65mweeo.cn-beijing.personal.cr.aliyuncs.com/ai-clinical/backend-service:v2.4 ``` 推送约 10 分钟(~840MB),看到 `digest: sha256:...` 表示成功。 @@ -57,7 +57,7 @@ docker push crpi-cd5ij4pjt65mweeo.cn-beijing.personal.cr.aliyuncs.com/ai-clinica ### 2.3 SAE 部署 1. SAE 控制台 → `nodejs-backend-test` → 部署应用 -2. 选择镜像 `backend-service:v2.3` +2. 选择镜像 `backend-service:v2.4`(与上方构建版本一致) 3. 确认部署,等待 5-8 分钟 ### 2.4 验证 @@ -75,14 +75,14 @@ docker push crpi-cd5ij4pjt65mweeo.cn-beijing.personal.cr.aliyuncs.com/ai-clinica ## 3. 前端 Nginx 更新(~15 分钟) -**当前版本**: v1.8 → 下个版本: v1.9 +**当前版本**: v1.9 → 下个版本: v2.0 ### 3.1 构建 ```powershell cd D:\MyCursor\AIclinicalresearch\frontend-v2 -docker build -t ai-clinical_frontend-nginx:v1.9 . +docker build -t ai-clinical_frontend-nginx:v2.0 . ``` 构建约 5 分钟(含 React 编译)。 @@ -90,9 +90,9 @@ docker build -t ai-clinical_frontend-nginx:v1.9 . ### 3.2 推送 ```powershell -docker tag ai-clinical_frontend-nginx:v1.9 crpi-cd5ij4pjt65mweeo.cn-beijing.personal.cr.aliyuncs.com/ai-clinical/ai-clinical_frontend-nginx:v1.9 +docker tag ai-clinical_frontend-nginx:v2.0 crpi-cd5ij4pjt65mweeo.cn-beijing.personal.cr.aliyuncs.com/ai-clinical/ai-clinical_frontend-nginx:v2.0 -docker push crpi-cd5ij4pjt65mweeo.cn-beijing.personal.cr.aliyuncs.com/ai-clinical/ai-clinical_frontend-nginx:v1.9 +docker push crpi-cd5ij4pjt65mweeo.cn-beijing.personal.cr.aliyuncs.com/ai-clinical/ai-clinical_frontend-nginx:v2.0 ``` 推送约 2 分钟(~50MB)。 @@ -100,7 +100,7 @@ docker push crpi-cd5ij4pjt65mweeo.cn-beijing.personal.cr.aliyuncs.com/ai-clinica ### 3.3 SAE 部署 1. SAE 控制台 → `frontend-nginx-service` → 部署应用 -2. 选择镜像版本 `v1.9` +2. 选择镜像版本 `v2.0`(与上方构建版本一致) 3. **检查环境变量**: `BACKEND_SERVICE_HOST` 指向最新后端 IP ### 3.4 验证 @@ -235,6 +235,7 @@ npx prisma migrate resolve --applied □ 清零 03-待部署变更清单.md(已部署项移到历史区域) □ 如果是大版本部署,创建 MMDD部署/ 文件夹记录过程 □ 如有服务 IP 变更,检查相互依赖的环境变量是否已更新 +□ CLB 负载均衡器无需手动更新(阿里云自动处理) ``` --- diff --git a/docs/05-部署文档/0228部署/01-部署完成总结.md b/docs/05-部署文档/0228部署/01-部署完成总结.md new file mode 100644 index 00000000..eda244b4 --- /dev/null +++ b/docs/05-部署文档/0228部署/01-部署完成总结.md @@ -0,0 +1,166 @@ +# 2026年2月28日部署完成总结 + +> **部署日期**:2026-02-28 +> **部署范围**:Node.js后端 + 前端Nginx 更新(旧系统集成上线) +> **部署状态**:✅ 全部完成 +> **文档日期**:2026-02-28 + +--- + +## 部署成果一览 + +### 服务版本对比 + +| 服务 | 部署前 | 部署后 | 变更类型 | +|------|--------|--------|---------| +| Node.js后端 | v2.2 | **v2.3** | 新增旧系统集成模块 | +| 前端Nginx | v1.8 | **v1.9** | 新增旧系统嵌入前端 | + +### 内网地址变更 + +| 服务 | 部署前地址 | 部署后地址 | 状态 | +|------|-----------|-----------|------| +| Node.js后端 | `172.17.197.28:3001` | `172.17.197.30:3001` | ✅ 已变更 | +| 前端Nginx | `172.17.197.29:80` | `172.17.197.31:80` | ✅ 已变更 | + +--- + +## 一、Node.js后端更新(v2.2 → v2.3) + +### 1.1 主要变更 + +| 类别 | 变更内容 | +|------|---------| +| 新增模块 | Legacy Bridge(旧系统集成认证服务) | +| 新增依赖 | `mysql2`(连接旧系统 ECS MySQL) | +| 新增 API | `POST /api/v1/legacy/auth`(旧系统Token注入) | +| 新增服务 | `legacy-auth.service.ts`(MySQL用户查找/创建 + MD5 Token生成) | + +### 1.2 Legacy Bridge 工作原理 + +1. 新系统前端调用 `POST /api/v1/legacy/auth` +2. 后端从 JWT 中提取用户手机号 +3. 后端连接旧系统 MySQL(`xzyx_online`),通过手机号查找/创建用户 +4. 后端为该用户生成 MD5 Token,注入到 `u_user_token` 表 +5. 返回 Token + 用户信息给前端 +6. 前端构建 `token-bridge.html` URL 并在 iframe 中加载 + +### 1.3 镜像信息 + +| 项目 | 值 | +|------|---| +| ACR 仓库 | `backend-service` | +| 镜像版本 | v2.2 → **v2.3** | +| 镜像地址 | `crpi-cd5ij4pjt65mweeo.cn-beijing.personal.cr.aliyuncs.com/ai-clinical/backend-service:v2.3` | +| Digest | `sha256:6e25d13cc7e1b228638a30eda35bd4aa70e0f7d52283cf66a2ffb21bee4ea288` | +| 构建方式 | `tsc --noCheck` + Docker 多阶段构建 | + +### 1.4 新增环境变量 + +| 环境变量 | 值 | 说明 | +|---------|---|------| +| `LEGACY_MYSQL_HOST` | `8.154.22.149` | 旧系统 ECS 服务器(公网IP) | +| `LEGACY_MYSQL_PORT` | `3306` | MySQL 端口 | +| `LEGACY_MYSQL_USER` | `root` | MySQL 用户名 | +| `LEGACY_MYSQL_DATABASE` | `xzyx_online` | 旧系统数据库名 | + +### 1.5 SAE 应用配置 + +| 配置项 | 0227 部署 | 0228 部署 | +|-------|----------|----------| +| 应用名称 | `nodejs-backend-test` | `nodejs-backend-test` | +| 实例规格 | 2核4GB | 2核4GB | +| 容器端口 | 3001 | 3001 | +| 内网地址 | `172.17.197.28` | **`172.17.197.30`** | +| 镜像版本 | v2.2 | **v2.3** | + +--- + +## 二、前端Nginx更新(v1.8 → v1.9) + +### 2.1 主要变更 + +| 类别 | 变更内容 | +|------|---------| +| 新增模块 | Legacy System iframe 嵌入(研究管理 + 统计工具) | +| 新增组件 | `LegacySystemPage.tsx`、`ResearchManagement.tsx`、`StatisticalTools.tsx` | +| 路由更新 | `App.tsx` 注册 `/legacy/*` 路由 | +| 模块注册 | `moduleRegistry.ts` 更新研究管理/统计分析为 iframe embed 模式 | + +### 2.2 旧系统嵌入架构 + +``` +新系统前端 (React) + └─