` 提取",
> 因此 LLM 返回的 `_quote` 大量引用 MinerU HTML 中的原文(如 `"410 (22.4%)"`)。
> 但旧版 `fuzzyQuoteMatch(pdfMarkdown, llmQuote)` **仅在 pymupdf4llm 文本中搜索**,
> 而 pymupdf4llm 的表格是乱码管道符(如 `"4|10 (22.4|%)"`)→ **匹配必然失败** → 满屏红色警告。
>
> **本质:** v1.3 的"上下文污染防护"和"Quote 验证"在逻辑上互相打架。越成功让 LLM 从
> MinerU 提取,Quote 误报率就越高。不修此漏洞,系统形同虚设。
> 📖 fuzzyQuoteMatch 算法见 08d-工具3-代码模式与技术规范.md §5.1
- 匹配成功(confidence ≥ 0.95):前端正常展示 Quote
- 匹配成功但 confidence 0.80-0.95:前端展示 Quote + 黄色"近似匹配"标签
- 匹配失败(confidence < 0.80):前端红色警告图标,提示人工核实
**⚠️ v1.3 加分项 2:PKB 复用感知日志**
Worker 处理每篇文献时,如果从 PKB 成功读到 `extractedText`,日志中高亮提示用户节省了重新提取的时间成本:
> 📖 PKB 复用日志见 08d-工具3-代码模式与技术规范.md §3.2
---
#### Task 2.4 — 提取任务 API
**新增端点(7 个):**
| 方法 | 路径 | 说明 |
|------|------|------|
| `POST` | `/api/v1/asl/extraction/tasks` | 创建提取任务(锁定模板 → pg-boss 入队) |
| `GET` | `/api/v1/asl/extraction/tasks/:taskId` | 获取任务元数据 + 最终状态(用于断点恢复) |
| `GET` | `/api/v1/asl/extraction/tasks/:taskId/stream` | **⚠️ SSE 端点**:实时推送进度 + 日志流 |
| `GET` | `/api/v1/asl/extraction/tasks/:taskId/results` | 获取全部提取结果(分页) |
| `GET` | `/api/v1/asl/extraction/results/:resultId` | 获取单篇提取详情 |
| `PUT` | `/api/v1/asl/extraction/results/:resultId/review` | 人工审核(approve / reject + 字段修正) |
| `GET` | `/api/v1/asl/extraction/tasks/:taskId/export` | 导出 Excel 宽表 |
**控制器:** `backend/src/modules/asl/extraction/controllers/ExtractionController.ts`
**⚠️ 审查修正:SSE 流式端点设计**
`/tasks/:taskId/stream` 使用 SSE 协议实时推送,复用 `common/streaming/` 基建:
```typescript
// SSE 事件类型(⚠️ v1.3 新增 sync 事件)
type ExtractionSSEEvent =
| { type: 'sync'; data: { processed: number; total: number; status: string; recentLogs: LogEntry[] } } // v1.3 首帧 / v1.4.1 降级: recentLogs 可能为空
| { type: 'progress'; data: { processed: number; total: number; currentFile: string } }
| { type: 'log'; data: { source: 'mineru' | 'deepseek' | 'system'; message: string; timestamp: string } }
| { type: 'complete'; data: { successCount: number; failedCount: number } }
| { type: 'error'; data: { message: string } };
```
**⚠️ v1.3 加分项 1:SSE Hydration on Connect**
> 用户刷新页面或网络断线重连后,前端建立新的 SSE 连接。此时 Worker 可能已经处理到第 5 篇,
> 但如果 SSE 只推送增量事件,前端进度条和日志区会显示空白。
SSE 端点在客户端首次连接时,立即下发一个 `sync` 事件,包含当前进度快照:
**🚨 v1.4.1 补丁 2:logBuffer 多 Pod 降级方案**
> **漏洞:** 如果 `logBuffer` 存在 Node.js 内存中(Map/Array),多 Pod 部署时用户刷新
> 被路由到另一个 Pod,`logBuffer.getRecent()` 返回空数组——日志区依然空白。
>
> **降级方案(推荐):** 不引入 Redis(违反 Postgres-Only),不存历史日志流。
> SSE 重连时仅下发进度状态,前端日志区打印一行提示即可。
> 在 v1.4 双轨制架构下,进度条由 React Query 驱动,日志流仅为视觉增强,此降级零业务影响。
> 📖 SSE 端点代码见 08d-工具3-代码模式与技术规范.md §7.2
> 📖 前端 sync 降级处理见 08d-工具3-代码模式与技术规范.md §7.4
**🚀 v2.0 SSE 简化:纯轮询为主,SSE 日志流为可选增强**
> v1.5 设计了 PostgreSQL NOTIFY/LISTEN 跨 Pod 广播方案。
> v2.0 散装模式以**纯轮询**为核心(React Query 3s 轮询 + groupBy 进度查询),
> 无需跨 Pod 实时通信即可满足业务需求。
>
> **SSE 日志流定位降级为"可选增强":**
> - M1:不做 SSE,纯轮询即可
> - M2:如果团队有余力,可接入 NOTIFY/LISTEN 做终端日志流(锦上添花)
> - 即使 SSE 完全不做,前端进度条和步骤跳转也不受任何影响(双轨制 v1.4 已保障)
> 📖 如需实现 SSE 跨 Pod 广播,参见 08d-工具3-代码模式与技术规范.md §7.6(保留为可选方案)
---
#### Task 2.5 — 升级 Excel 导出
**文件:** `backend/src/modules/asl/extraction/services/ExtractionExcelExporter.ts`
升级现有 `ExcelExporter.ts`,生成标准科研 Excel 数据宽表:
| Study_ID | NCT | treatment_name | treatment_name_quote | n_treatment | n_treatment_quote | ... | 糖尿病史比例 | 糖尿病史比例_quote |
|----------|-----|---------------|---------------------|-------------|-------------------|-----|------------|-------------------|
| Gandhi 2018 | NCT02578680 | Pembrolizumab | "Methods: ...pembro..." | 410 | "A total of 410..." | ... | 22.4% | "Table 1: ...22.4%..." |
**设计要点:**
- 每个变量字段右侧紧跟一列 `_quote` 原文
- 自定义字段追加在标准字段之后,同样带 Quote
- 仅导出 `reviewStatus = approved` 的文献
- 表头双行:第一行中文名,第二行英文 JSON Key
---
#### ~~Task 2.6 — PDF 上传接入 OSS~~(🆕 v1.2 删除)
> **已删除。** PDF 上传、OSS 存储、全文提取均由 PKB 模块承担。
> Tool 3 通过 `PkbBridgeService` 读取 PKB 的 `storageKey`(OSS 路径)和 `extractedText`(Markdown 全文),
> 无需自建上传流程。详见 Task 2.2 的流水线设计。
---
### Phase 3:前端 Step 1 — 配置模板与选择文献来源 — 预计 3 天
> **目标:** 对应产品原型图 View 1,实现模板选择、自定义字段管理、🆕 PKB 知识库对接。
#### Task 3.1 — 重构 FulltextSettings.tsx → ExtractionSetup.tsx
**布局(对应原型 5:2 双栏,v1.2 右栏改为 PKB 选择器):**
```
┌────────────────────────────────────────────────────────────────┐
│ 步骤条:[1.配置模板与选择文献] → [2.机器解析与提取] → [3.人机比对]│
├──────────────────────────────────┬─────────────────────────────┤
│ 左 3/5:模板配置 │ 右 2/5:🆕 PKB 文献来源 │
│ ┌────────────────────────────┐ │ ┌───────────────────────┐ │
│ │ 选择系统基座 │ │ │ 选择 PKB 知识库 │ │
│ │ [下拉:RCT / Cohort / QC] │ │ │ [下拉:我的知识库列表] │ │
│ ├────────────────────────────┤ │ ├───────────────────────┤ │
│ │ 基座字段展示(只读标签云) │ │ │ 知识库文献列表 │ │
│ │ 🔒 Study_ID 🔒 NCT ... │ │ │ ☑ Gandhi_2018.pdf │ │
│ ├────────────────────────────┤ │ │ ☑ Hellmann_2019.pdf │ │
│ │ 用户自定义字段插槽 │ │ │ ☐ Socinski_2018.pdf │ │
│ │ [+ 添加自定义字段] │ │ │ ── 仅显示 PDF 文档 ── │ │
│ │ ┌─────────────────────┐ │ │ │ 已选:2 篇 │ │
│ │ │ 糖尿病史比例 (%) │ │ │ └───────────────────────┘ │
│ │ │ Type: Percentage │ │ │ ┌───────────────────────┐ │
│ │ │ Prompt: "请在基线..."│ │ │ │ 💡 请先在「个人知识库」│ │
│ │ └─────────────────────┘ │ │ │ 中上传 PDF 文献 │ │
│ └────────────────────────────┘ │ └───────────────────────┘ │
├─────────────────────────────────┴─────────────────────────────┤
│ [确认模板并开始批量提取](需至少选择 1 篇 PKB 文献) │
└────────────────────────────────────────────────────────────────┘
```
**技术选型:**
- Ant Design `Select` — 基座模板下拉
- Ant Design `Tag` — 基座字段标签(锁定图标 + 灰色)
- 🆕 Ant Design `Select` — PKB 知识库下拉选择
- 🆕 Ant Design `Table` (带 Checkbox) — PKB 文献列表(仅展示 PDF 类型文档)
- Ant Design `Modal` — 添加/编辑自定义字段弹窗
- Ant Design `Steps` — 步骤条
**API 调用:**
- `GET /api/v1/asl/extraction/templates` → 加载模板列表
- `POST /api/v1/asl/projects/:projectId/template` → 克隆模板
- `PUT /api/v1/asl/projects/:projectId/template/custom-fields` → 管理自定义字段
- 🆕 `GET /api/v1/pkb/knowledge-bases` → 获取用户的 PKB 知识库列表
- 🆕 `GET /api/v1/pkb/knowledge-bases/:kbId/documents?type=pdf` → 获取知识库内 PDF 文档列表
---
#### Task 3.2 — 自定义字段管理弹窗
**组件:** `frontend-v2/src/modules/asl/components/extraction/CustomFieldModal.tsx`
对应原型中的 Modal 弹窗,表单字段:
- 字段名称(必填)
- 期望数据类型:String / Number / Percentage / Boolean(`Select`)
- AI 提取指令 Prompt(必填,`TextArea`)
支持新增 / 编辑 / 删除操作。
---
#### ~~Task 3.3 — PDF 批量上传与列表管理~~(🆕 v1.2 替换)
> **已删除 PdfUploadPanel。** 替换为以下两个组件:
#### Task 3.3a — PKB 知识库选择器
**组件:** `frontend-v2/src/modules/asl/components/extraction/PkbKnowledgeBaseSelector.tsx`
- 调用 `GET /api/v1/pkb/knowledge-bases` 获取用户的知识库列表
- 下拉选择一个知识库
- 选中后加载该知识库内的 PDF 文档列表(带 Checkbox 多选)
- 仅展示文件类型为 PDF 的文档(过滤非 PDF)
- 展示信息:文件名、上传时间、文件大小、全文提取状态(`extractedText` 是否存在)
- 底部提示:"还没有上传 PDF?请先前往「个人知识库」上传文献。" + 跳转链接
#### Task 3.3b — PKB 桥接服务(后端)— ⚠️ v1.3 ACL 防腐层
> **🚨 v1.3 排雷修正(隐患 1):** 禁止跨模块直接查 Prisma Model。
> 全系统所有模块(ASL、PKB、RVW、DC 等)之间零跨模块 import,不能开此先例。
> 改为 **ACL 防腐层**架构:PKB 暴露只读服务接口,ASL 进程内调用获取 DTO。
**PKB 侧新增(由 PKB 模块维护):**
**文件:** `backend/src/modules/pkb/services/PkbExportService.ts`
> 📖 ACL 防腐层代码见 08d-工具3-代码模式与技术规范.md §6.1 + §6.2
**设计要点:**
- ⚠️ **v1.3 ACL 防腐层**:ASL 绝不直接 `import { prisma } from ...` 查 `pkb_schema`
- PKB 模块新增 `PkbExportService`(PKB 自己的代码管自己的表),返回纯 DTO 对象
- ASL 的 `PkbBridgeService` 通过**依赖注入**拿到 `PkbExportService` 实例(进程内调用,无网络开销)
- 未来 PKB 改表结构,只需更新 `PkbExportService`,ASL 完全无感
- 仅读取 PKB 数据,不修改 PKB 数据(**单向只读依赖**)
---
### Phase 4:前端 Step 2 — 机器解析与提取 — 预计 2 天
> **目标:** 对应产品原型图 View 2,展示批量提取的实时进度和终端日志。
#### Task 4.1 — 重构 FulltextProgress.tsx → ExtractionProgress.tsx
**布局:**
```
┌──────────────────────────────────────────────────────────┐
│ ┌──────────────────────────────────────────────────┐ │
│ │ 🤖 机器静默提取中... │ │
│ │ 任务已进入 pg-boss 队列 │ │
│ │ ▓▓▓▓▓▓▓▓▓▓▓▓░░░░░░░░░░ 3/8 篇完成 │ │
│ └──────────────────────────────────────────────────┘ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ [MinerU] Extracting tables from Gandhi_2018... │ │
│ │ [MinerU] ✓ 3 tables extracted (2.1s) │ │
│ │ [DeepSeek] Building Schema: [RCT] + [1 custom] │ │
│ │ [DeepSeek] Extracting with Quote tracing... │ │
│ │ [System] 1/8 Documents processed. │ │
│ │ ... │ │
│ └──────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────┘
```
**⚠️ v1.4 终极修正:双轨制通信架构(React Query 主驱动 + SSE 日志增强)**
> **❌ v1.3 旧方案:** SSE 单轨驱动一切(进度条、步骤跳转、日志流)。
> SSE 断开时主业务流中断,需要 Hydration 机制补偿。
>
> **✅ v1.4 新方案(终极审查调和):** 双轨制分离关注点:
> - **主业务流(进度条、步骤跳转、成功/失败)**:React Query 轮询 `/tasks/:taskId`(3 秒间隔),稳健可靠
> - **视觉增强(终端日志流)**:SSE 单向通道,仅灌入 `` 组件。即使 SSE 断开,不影响主线
> - **SSE `sync` 首帧**:保留,用于快速填充日志区(比等 3 秒轮询更好的首屏体验)
| 职责 | 驱动方式 | 断开影响 |
|------|---------|---------|
| **进度条** | React Query 轮询 `GET /tasks/:taskId` | 3 秒内自动恢复 |
| **步骤跳转** | React Query 检测 `status` 字段变化 | 3 秒内自动跳转 |
| **终端日志流** | SSE `log` 事件 → `` | 仅日志流中断,无业务影响 |
| **首屏日志填充** | SSE `sync` 首帧 Hydration | 保留,快速填充 |
**技术方案:**
> 📖 双轨制通信代码见 08d-工具3-代码模式与技术规范.md §7.3 + §7.4 + §7.5
- 日志区域:深色终端风格 `` 容器,不同来源用颜色区分(`[MinerU]` 蓝色、`[DeepSeek]` 紫色、`[System]` 绿色)
- 进度条:Ant Design `Progress`,由 **React Query 轮询** 驱动(非 SSE)
- 步骤跳转:React Query 检测 `task.status` 变化自动跳转(非 SSE `complete` 事件)
**⚠️ 审查修正:断点恢复(状态水合)**
用户关闭浏览器后再次打开,前端必须能恢复到正确步骤:
> 📖 状态驱动路由见 08d-工具3-代码模式与技术规范.md §8.1
前端进入 `/extraction/:taskId` 时,首屏调用 `GET /tasks/:taskId` 读取 `status`,根据状态决定渲染哪个步骤。`processing` 状态会重建 SSE 连接从当前进度继续。
---
### Phase 5:前端 Step 3 — 人机比对与核准 — 预计 5 天
> **目标:** 对应产品原型图 View 3,实现工作台列表 + 右侧智能提取抽屉。这是**前端最复杂的部分**。
#### Task 5.1 — 重构 FulltextWorkbench.tsx → ExtractionWorkbench.tsx
**列表区域:**
| 列 | 说明 |
|----|------|
| Study ID / 标题 | 文献标识,标题可点击打开抽屉 |
| 机器解析流 | 标签显示 MinerU 表格还原 + DeepSeek 榨取状态 |
| 复核状态 | Badge:待核对(橙色脉冲) / Approved(绿色) / Rejected(红色) |
| 操作 | [复核提单] 按钮 → 打开右侧抽屉 |
**顶部:**
- 提示横幅:"机器提取完毕!共提取 N 篇文献。标记为 Approved 的数据才允许导出。"
- 右上角:[下载结构化提取结果 (Excel)] 按钮(仅当有 Approved 数据时可用)
---
#### Task 5.2 — 智能提取复核抽屉(核心 UI)
**组件:** `frontend-v2/src/modules/asl/components/extraction/ExtractionDrawer.tsx`
这是 V2.0 **交互最复杂**的组件,对应原型图中宽 700px 的右侧抽屉。
**抽屉结构:**
```
┌─ 抽屉头部 ──────────────────────────────────────────┐
│ [Pending Review] [基于所选模板提取] │
│ 论文标题(加粗) │
│ [✕ 关闭] │
├─ Quote 护栏提示条 ─────────────────────────────────────┤
│ 🛡️ 已强制开启 Quote 原文溯源 [查看源 PDF ↗] │
├─ 可滚动内容区 ────────────────────────────────────────┤
│ │
│ ┌─ 模块 1:基础元数据 ─────────────────────────────┐│
│ │ Study_ID: [Gandhi 2018] ││
│ │ NCT: [NCT02578680] ││
│ │ AI Quote: "Methods: This...NCT02578680..." ││
│ └──────────────────────────────────────────────────┘│
│ │
│ ┌─ 模块 2:基线特征 ──────────────────────────────┐ │
│ │ Intervention_N: [410] Control_N: [206] │ │
│ │ AI Quote: "A total of 410 patients..." │ │
│ │ ── 自定义字段 ──────────────────────────────── │ │
│ │ 糖尿病史比例 (%): [22.4%] [Custom Slot 标签] │ │
│ │ AI Quote: "Table 1: ...22.4%..." │ │
│ └──────────────────────────────────────────────────┘│
│ │
│ ┌─ 模块 3:RoB 2.0 ──────────────────────────────┐ │
│ │ 随机化: [Low Risk] ✅ │ │
│ │ AI Quote: "computer-generated random..." │ │
│ └──────────────────────────────────────────────────┘│
│ │
│ ┌─ 模块 4:结局指标 ─────────────────────────────┐ │
│ │ HR (OS): [0.49] 95% CI: [0.38] - [0.64] │ │
│ │ AI Quote: "hazard ratio, 0.49; 95% CI..." │ │
│ └──────────────────────────────────────────────────┘│
│ │
├─ 抽屉底部 ──────────────────────────────────────────┤
│ "请确保所有 Quote 数值已核验" │
│ [取消] [✓✓ 核准保存] │
└──────────────────────────────────────────────────────┘
```
**核心技术要点:**
1. **动态表单渲染**:根据 `extractedData` 的 JSON 结构,按模块(metadata / baseline / rob / outcomes)分组渲染 `Input` / `Select` 控件
2. **Quote 溯源区块**:每个字段下方显示 `AI Quote` 灰色面板,原文关键数字用黄色 `` 高亮
3. **Custom Slot 标识**:自定义字段旁显示 ⚡ Custom Slot 蓝色标签
4. **字段可编辑**:用户可直接修改 AI 提取的值,修改后的字段记录到 `manualOverrides`
5. **核准/驳回**:点击"核准保存"→ `PUT /api/v1/asl/extraction/results/:resultId/review` → 更新状态
**⚠️ v1.4 终极修正:HITL 死锁解套 — Quote 警告处置交互**
> 当 `fuzzyQuoteMatch` 匹配失败(confidence < 0.80)时,v1.3 仅标红警告,
> 但如果医生认为 AI 提取的值其实是对的,系统没有"放行"交互,
> 数据永远卡在 Pending 状态——**这是 HITL 死锁**。
**解锁方案:每个红色警告 Quote 旁边必须提供两个处置按钮:**
```
┌─ Quote 警告区域 ──────────────────────────────────────┐
│ ⚠️ AI Quote 匹配失败 (confidence: 0.62) │
│ AI 引用: "The median PFS was 10.3 months..." │
│ │
│ [🟢 强制认可] [✏️ 手动修改数值] │
└────────────────────────────────────────────────────────┘
```
- **[强制认可]**:消除红色警告,在 `manualOverrides` 中标记 `{ fieldName_quote_force_accepted: true }`。表示医生已人工确认该 Quote 无误,后续导出 Excel 时正常输出
- **[手动修改数值]**:聚焦到对应的 Input 框,医生直接修改提取值。旧的错误 Quote 显示~~删除线~~样式,并追加提示文字:"已转为人工干预,原文引用取消强绑定"
- 两种操作都会记录到 `manualOverrides`,审计时可追溯哪些字段经过人工干预
- **核准按钮前置校验**:所有红色警告 Quote 必须被处置(强制认可或手动修改)后,"核准保存"按钮才可点击
**⚠️ 审查修正:Drawer 性能优化**
当 `extractedData` 包含几十个字段(基座 + 自定义)时,一次性渲染所有带验证逻辑的控件和大段 Quote Markdown 会导致 Drawer 弹出卡顿。
**优化方案:**
> 📖 Collapse 懒渲染代码见 08d-工具3-代码模式与技术规范.md §8.2
- 默认仅展开"基础元数据"面板,其余折叠,用户点击展开时才渲染
- 每个 FieldGroup 组件用 `React.memo` 包裹,避免跨模块修改触发全量 Re-render
- 使用 Ant Design `Form` 的 `shouldUpdate` 精确控制字段级更新,**不引入 react-hook-form**(保持技术栈统一)
- `manualOverrides` 通过 `Form.onValuesChange` 差量追踪,仅记录用户实际修改的字段
---
#### Task 5.3 — 查看源 PDF 功能 — ⚠️ v1.3 懒签名 + 403 自动刷新
- 点击"查看源 PDF"按钮 → 通过 `PkbBridgeService.getDocumentSignedUrl(storageKey)` 获取签名 URL → 新标签页打开 PDF
- 🆕 v1.2:PDF 存储在 PKB 的 OSS 路径下(`tenants/{tenantId}/users/{userId}/pkb/{kbId}/{uuid}.pdf`),通过 `common/storage/` 生成签名 URL
**⚠️ v1.3 排雷修正(隐患 4):签名 URL 过期打断医生心流**
> 医生审核一篇论文可能耗时 30-60 分钟。如果在列表加载时预签名所有 PDF URL(1 小时有效期),
> 等医生审核到后面的文献时,前面的签名可能已过期,点击"查看源 PDF"会返回 403。
**修正方案:懒签名 + 短有效期 + 前端 403 自动刷新**
> 📖 签名 URL 代码见 08d-工具3-代码模式与技术规范.md §8.3
**要点:**
- **不在列表加载时批量预签名**,仅在用户点击"查看源 PDF"时按需生成
- 签名有效期缩短为 **10 分钟**(足够阅读当篇论文)
- 前端 `