Files
AIclinicalresearch/docs/03-业务模块/IIT Manager Agent/04-开发计划/V3.0全新开发计划/质控引擎V3.1架构升级-五级数据结构与多维报告开发计划.md
HaHafeng 0b29fe88b5 feat(iit): QC deep fix + V3.1 architecture plan + project member management
QC System Deep Fix:
- HardRuleEngine: add null tolerance + field availability pre-check (skipped status)
- SkillRunner: baseline data merge for follow-up events + field availability check
- QcReportService: record-level pass rate calculation + accurate LLM XML report
- iitBatchController: legacy log cleanup (eventId=null) + upsert RecordSummary
- seed-iit-qc-rules: null/empty string tolerance + applicableEvents config

V3.1 Architecture Design (docs only, no code changes):
- QC engine V3.1 plan: 5-level data structure (CDISC ODM) + D1-D7 dimensions
- Three-batch implementation strategy (A: foundation, B: bubbling, C: new engines)
- Architecture team review: 4 whitepapers reviewed + feedback doc + 4 critical suggestions
- CRA Agent strategy roadmap + CRA 4-tool explanation doc for clinical experts

Project Member Management:
- Cross-tenant member search and assignment (remove tenant restriction)
- IIT project detail page enhancement with tabbed layout (KB + members)
- IitProjectContext for business-side project selection
- System-KB route access control adjustment for project operators

Frontend:
- AdminLayout sidebar menu restructure
- IitLayout with project context provider
- IitMemberManagePage new component
- Business-side pages adapt to project context

Prisma:
- 2 new migrations (user-project RBAC + is_demo flag)
- Schema updates for project member management

Made-with: Cursor
2026-03-01 15:27:05 +08:00

41 KiB
Raw Blame History

质控引擎 V3.1 架构升级 — 五级数据结构与多维报告开发计划

版本V1.1(合并架构团队二次评审 4 条关键建议)
日期2026-03-01
定位:基于架构团队评审意见,在 V3.0 三级数据结构基础上升级为 CDISC ODM 五级结构,分三批次落地
前置文档

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 唯一一行,反映最新质控状态。

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 聚合。

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 上新增聚合字段:

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 层

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 增加反向语义标签

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_nameinstance_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 返回数据之前,强制标准化所有记录:

// 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 重构(统一执行入口)

将分散在 SkillRunneriitBatchControllerQcReportService 中的执行逻辑统一收归:

class QcExecutor {

  /**
   * 单记录单事件质控(实时触发 / AI 调用)
   */
  async executeSingle(
    projectId: string,
    recordId: string,
    eventId: string,
    options?: { triggeredBy: 'webhook' | 'manual' }
  ): Promise<void> {
    // 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<BatchResult> {
    // 1. RedcapAdapter 拉取全量并标准化
    // 2. 基线数据合并
    // 3. 逐 record × event 复用 executeSingle 核心逻辑
    // 4. 批量完成后触发一次聚合(而非逐条)
    // 5. 刷新 qc_reports
  }

  /**
   * 异步防抖聚合(解决冒泡并发死锁问题)
   */
  async aggregateDeferred(projectId: string): Promise<void> {
    // 详见 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 末尾追加一次
async aggregateDeferred(projectId: string): Promise<void> {
  // 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.categoryIitSkill.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 字段天然就是中文语义标签。项目初始化时自动同步:

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 的切片 QcReportServiceqc_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 一律拉取该患者的全量数据

// 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.0getRecordById() 拉全量但 merge 成扁平对象,丢失五级结构
  • 新设计V3.1exportRecords() 拉全量且 保持原始多行结构,每行 = 一个 event × form × instance

3.9 eQuery 自动闭环State Transition Hook

问题:当质控发现 Field 为 FAIL 时会生成 eQuery。但当 CRC 修正数据后Webhook 再次触发质控Field 变回 PASS——此时没有任何机制自动关闭之前的 eQuery。系统变成了"只开 Query 不销账"的半成品。

设计:在 QcExecutorupsert qc_field_status 逻辑中,比较新旧状态,触发自动闭环。

// QcExecutor 内部upsert 前先读取旧状态
async upsertFieldStatus(data: FieldStatusData): Promise<void> {
  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五级坐标
- IitQcLogformName, instanceId
- IitEqueryinstanceId
- IitFieldMappingsemanticLabel, 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 HookFAIL→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
- 新增 <event_overview> 章节
- 新增 <dimension_summary> 章节(各维度通过率)
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%。这是致命的临床逻辑错误——那些访视根本还没发生。

解法:双重过滤——字段过滤 + 时序过滤。

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<string, any>): 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 version="1.0" encoding="UTF-8"?>
<qc_context project_id="xxx" project_name="test0207" generated="2026-03-15T08:00:00Z">

  <!-- 1. 宏观统计 -->
  <summary>
    - 通过率: 92.9%14 条记录13 通过1 失败)
    - 事件覆盖: 42 个事件已质控39 通过
    - 严重问题: 1 | 警告: 2
  </summary>

  <!-- 2. 维度概览(各维度独立通过率)-->
  <dimension_summary>
    - D1 入排合规: 13/14 通过 (92.9%) — 1 条排除标准违规
    - D3 变量准确: 550/556 通过 (98.9%) — 6 条极值异常
    - D2 数据完整: --(尚未启用)
    - D5 安全性: --(尚未启用)
    - D6 方案偏离: --(尚未启用)
  </dimension_summary>

  <!-- 3. 事件维度统计 -->
  <event_overview>
    - 筛选期: 14/14 通过 (100%)
    - 基线期: 13/14 通过 (92.9%)
    - 随访1: 12/12 通过 (100%)
  </event_overview>

  <!-- 4. 严重问题详情(按受试者 × 事件 × 表单 × 实例定位)-->
  <critical_issues count="1">
    <record id="3">
      <event name="基线期">
        <form name="入排标准表" instance="1">
          1. [D1][exc_001] **排除标准检查**: 排除标准第1项 = **1**(标准: 0
        </form>
      </event>
    </record>
  </critical_issues>

  <!-- 5. 警告问题 -->
  <warnings count="2">
    <record id="7">
      <event name="随访2">
        <form name="实验室检查" instance="1">
          1. [D3][lab_003] **谷丙转氨酶(ALT)**: 当前值 **52 U/L**(正常上限: 50 U/L
        </form>
      </event>
    </record>
  </warnings>

</qc_context>

对比 V3.0

  • 新增 <dimension_summary> —— 各维度独立呈现
  • 新增 <event_overview> —— 事件级统计
  • 问题定位从 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 HookFAIL→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