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

921 lines
41 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 质控引擎 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