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
This commit is contained in:
2026-03-01 15:27:05 +08:00
parent c3f7d54fdf
commit 0b29fe88b5
61 changed files with 6877 additions and 524 deletions

View File

@@ -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.projects1 条记录)
id: test0102-pd-study | name: test0207 | status: active
-- iit_schema.user_mappings1 条记录)
system_user_id: FengZhiBo | wecom_user_id: FengZhiBo | role: PI
-- public.users1 条记录)
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` | 修改 | 创建时选择租户 |

View File

@@ -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<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 末尾追加一次
```
```typescript
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.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<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五级坐标 | | |
| | - `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 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%。这是**致命的临床逻辑错误**——那些访视根本还没发生。
**解法**:双重过滤——字段过滤 + 时序过滤。
```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<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
<?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

View File

@@ -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 DETData Entry TriggerWebhook 接收端,但:
- 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_reportsLLM 友好报告 + 人类可读报告)
```
---
## 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_check5 分钟防抖)
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<void> {
// 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<BatchResult> {
// 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<void> {
// 事件级:从 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
<qc_context project_id="xxx" project_name="test0207">
<summary>
- 通过率: 92.9%14 条记录13 条通过1 条失败)
- 事件覆盖: 42 个事件已质控39 个通过
- 严重问题: 1 | 警告: 0
</summary>
<critical_issues>
<record id="3">
<event name="基线期">
1. [exc_001] **排除标准检查**: 当前值 **1** (标准: 0)
</event>
</record>
</critical_issues>
<!-- 新增:事件维度统计 -->
<event_overview>
- 筛选期: 14/14 通过 (100%)
- 基线期: 13/14 通过 (92.9%)
- 随访1: 12/12 通过 (100%) <!-- 2条记录尚未到达随访1 -->
</event_overview>
</qc_context>
```
---
## 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 准确运行的根基。