docs(asl): Upgrade Tool 3 architecture from Fan-out to Scatter+Aggregator (v2.0)

Architecture transformation:
- Replace Fan-out (Manager->Child->Last Child Wins) with Scatter+Aggregator pattern
- API layer directly dispatches N independent jobs (no Manager)
- Worker only writes its own Result row, never touches Task table (zero row-lock)
- Aggregator polls groupBy for completion + zombie cleanup (replaces Sweeper)
- Reduce red lines from 13 to 9, eliminate distributed complexity

Documents updated (10 files):
- 08-Tool3 main architecture doc: v2.0 rewrite (schema, Task 2.3/2.4, red lines, risks)
- 08d-Code patterns: rewrite sections 4.1-4.6 (API dispatch, SingleWorker, Aggregator)
- 08a-M1 sprint: rewrite M1-3 core (Worker+Aggregator), red lines, acceptance criteria
- 08b-M2 sprint: simplify SSE (NOTIFY/LISTEN downgraded to P2 optional)
- 08c-M3 sprint: milestone table wording update
- New: Scatter+Polling Aggregator pattern guide v1.1 (Level 2 cookbook)
- New: V2.0 architecture deep review and gap-fix report
- Updated: ASL module status, system status, capability layer index

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-02-24 22:11:09 +08:00
parent 85fda830c2
commit 371fa53956
13 changed files with 1163 additions and 597 deletions

View File

@@ -1,93 +1,54 @@
# 工具 3全文智能提取工作台 V2.0 开发计划
> **版本:** v1.5(多 Pod SSE 通信 + PKB 数据一致性快照
> **版本:** v2.0架构转型Fan-out → 散装派发 + Aggregator 轮询收口
> **创建日期:** 2026-02-22
> **更新日期:** 2026-02-23v1.1 → v1.2 PKB → v1.3 排雷 → v1.4 终极 → v1.4.1 逻辑补丁 → v1.4.2 致命修复 → v1.5 跨实例+快照
> **架构审查** `docs/03-业务模块/ASL-AI智能文献/06-技术文档/工具3架构审查与研发改进建议.md`
> **深度排雷:** `docs/03-业务模块/ASL-AI智能文献/06-技术文档/工具3开发计划深度审查与排雷指南.md`
> **终极审查:** `docs/03-业务模块/ASL-AI智能文献/06-技术文档/工具3终极架构审查与研发规范.md`
> **更新日期:** 2026-02-24v1.1→v1.5 见历史补丁 → **v2.0 架构转型**
> **异步任务指南** `docs/02-通用能力层/散装派发与轮询收口任务模式指南.md`Level 2**本模块遵循**
> **Postgres-Only 指南:** `docs/02-通用能力层/Postgres-Only异步任务处理指南.md`Level 1 底层规范)
> **PKB 模块:** `docs/03-业务模块/PKB-个人知识库/00-模块当前状态与开发指南.md`
> **Postgres-Only 指南:** `docs/02-通用能力层/Postgres-Only异步任务处理指南.md`
> **产品原型:** `docs/03-业务模块/ASL-AI智能文献/00-系统设计/证据整合V2.0/工具3 全文提取产品原型图V2.html`
> **数据字典:** `docs/03-业务模块/ASL-AI智能文献/00-系统设计/证据整合V2.0/ASL 工具 3 全文提取数据字典与规范.md`
> **模板规范:** `docs/03-业务模块/ASL-AI智能文献/00-系统设计/证据整合V2.0/ASL 工具 3 提取模板管理规范.md`
> **OSS 规范:** `docs/04-开发规范/11-OSS存储开发规范.md`
> **OSS 规范:** `docs/04-开发规范/11-OSS存储开发规范.md`
> **架构审查(历史):** `docs/03-业务模块/ASL-AI智能文献/06-技术文档/` 下 3 份审查文档v1.1-v1.5 期间产出,部分结论已被 v2.0 架构转型替代)
>
> ### v1.5 补丁:多 Pod SSE 通信 + PKB 数据一致性快照
> ### 🚀 v2.0 架构转型Fan-out → 散装派发 + Aggregator 轮询收口
>
> | # | 问题 | 严重度 | 修正方案 | 影响章节 | 归属里程碑 |
> |---|------|--------|---------|---------|-----------|
> | 1 | SSE `sseEmitter.emit()` 基于内存 EventEmitter用户连 Pod A 但 Worker 跑在 Pod B → 实时日志零推送 | **关键** | PostgreSQL `NOTIFY/LISTEN` 跨实例广播Worker 端 `NOTIFY asl_sse_channel`API 端每 Pod 启动时 `LISTEN` 常驻连接,收到后检查本机是否有该 taskId 的 SSE 客户端并推送 | Task 2.4 | M2 |
> | 2 | 提取任务可能持续 50 分钟,期间用户在 PKB 删除/修改文档 → Child Worker 找不到 `storageKey` 崩溃 | **重要** | Manager 派发时一次性快照 `storageKey` + `filename` 到 `AslExtractionResult`Child Worker 从自身记录读取而非运行时回查 PKB | Task 1.1, Task 2.3 | M1 |
> **转型决策依据:** v1.1-v1.5 期间累计发现 12+ 个 Fan-out 分布式 BugLast Child Wins 终点丢失、
> 乐观锁与重试绞杀、原子递增行锁争用、Sweeper 误杀、NOTIFY/LISTEN 复杂性等),
> 经团队评估Fan-out 的运维复杂度远超创业团队承受能力。
> 详见 `散装派发与轮询收口任务模式指南.md` 的成本-收益分析。
>
> ### v1.4.2 补丁3 项致命缺陷修复 + 队列命名合规
> | 维度 | v1.5 Fan-out已废弃 | v2.0 散装派发(当前) |
> |------|----------------------|---------------------|
> | **调度模型** | API → Manager Job → N Child Job → Last Child Wins | API 直接散装派发 N 个独立 Job → Aggregator 轮询收口 |
> | **任务完成判定** | 最后一个 Child 原子递增后翻转 Task status | Aggregator 定时 `groupBy` 聚合,`pending=0 && extracting=0` → completed |
> | **进度统计** | Task 表 `successCount`/`failedCount` 冗余字段 | 实时 `groupBy COUNT(Result.status)`Task 表无计数字段 |
> | **僵尸清理** | 独立 Sweeper 定时任务 | Aggregator 自带僵尸清理(一个组件两个职责) |
> | **跨 Pod 通信** | NOTIFY/LISTEN 广播 SSE 日志 | 纯轮询,无需跨 Pod 通信SSE 日志为可选增强) |
> | **消灭的组件** | — | ~~ExtractionManagerWorker~~、~~Sweeper~~、~~NOTIFY/LISTEN Bridge~~ |
> | **消灭的 Bug 类** | — | ~~Last Child Wins 终点丢失~~、~~原子递增行锁争用~~、~~三级嵌套子队列 OOM~~ |
> | **设计模式数** | 12+ | 4 个核心模式 |
> | **研发红线数** | 13 条 | 9 条 |
>
> | # | 致命缺陷 | 严重度 | 补丁方案 | 影响章节 |
> |---|---------|--------|---------|---------|
> | 1 | Fan-out 终点丢失:没有人把 `AslExtractionTask.status` 从 `processing` 更新为 `completed`,前端永远卡在 Step 2 | **致命** | "Last Child Wins" 模式Child 原子递增后判断 `successCount + failedCount === totalCount`,最后一个 Child 负责翻转状态 | Task 2.3 |
> | 2 | 伪幂等 Read-then-Write 反模式:`findUnique` 检查状态后再操作,并发 retry 穿透导致双倍 MinerU/LLM 调用 + 计数双递增 | **致命** | 替换为 Prisma `updateMany({ where: { status: 'pending' } })` 乐观锁,原子抢占 `extracting` 状态 | Task 2.3 |
> | 3 | 队列名称使用点号(`asl.extraction.child`违反《Postgres-Only 指南》§4.1 红线 | **严重** | 全局替换为下划线格式:`asl_extraction_manager`、`asl_extraction_child`、`asl_mineru_extract`、`asl_llm_extract` | 全局 |
> | — | `executionLogs` 注释歧义 | 低 | Schema 注释清理v1.1 已决定不存日志,注释改为更明确的措辞 | Task 1.1 |
> **保留的 v1.x 成果(不受架构转型影响):**
> - v1.2 PKB 对接方案ACL 防腐层)
> - v1.3 XML 隔离 Prompt、fuzzyQuoteMatch 搜索池扩容、签名 URL 懒加载
> - v1.4 双轨制通信React Query 主驱 + SSE 日志增强、MinerU 缓存、HITL 解锁、错误分级
> - v1.5 PKB 数据一致性快照(移至 API 层执行)
>
> ### v1.4.1 补丁3 项隐蔽逻辑漏洞修复
> <details>
> <summary>📜 v1.1 - v1.5 历史补丁记录(已被 v2.0 架构转型部分替代,点击展开)</summary>
>
> | # | 漏洞 | 严重度 | 补丁方案 | 影响章节 |
> |---|------|--------|---------|---------|
> | 1 | fuzzyQuoteMatch 搜索 pymupdf4llm 文本,遗漏 MinerU 表格来源的 Quote → 满屏误报红色警告 | **致命** | 搜索池扩容:剥离 MinerU HTML 标签后与 Markdown 拼接再匹配 | Task 2.3 |
> | 2 | logBuffer 内存存储在多 Pod 下为空SSE Hydration 首帧 recentLogs 为空数组 | 中等 | 降级方案:不存历史日志,重连时仅下发进度 + 打印"监控已重连"提示 | Task 2.4 |
> | 3 | `asl_extraction_child` 无 teamConcurrency 限制1000 篇文献 → 1000 个挂起闭包 → Node.js OOM | **严重** | Child 队列加 `teamConcurrency: 10`,其余在 PostgreSQL 中排队 | Task 2.3 |
> v1.5: 多 Pod SSE NOTIFY/LISTEN + PKB 快照 → **SSE 部分已被 v2.0 纯轮询替代PKB 快照保留但移至 API 层**
> v1.4.2: Last Child Wins + 乐观锁 + 队列命名 → **Last Child Wins 和原子递增已被 Aggregator 替代,乐观锁(幽灵重试守卫)和队列命名保留**
> v1.4.1: fuzzyQuoteMatch 搜索池 + logBuffer 降级 + teamConcurrency → **全部保留**
> v1.4: P-Queue→teamConcurrency + 原子递增 + 错误分级 + MinerU 缓存 + HITL + 双轨制 + 计算卸载 → **原子递增已废弃,其余全部保留**
> v1.3: PKB ACL + XML 隔离 + Fan-out 扇出 + 签名 URL + SSE Hydration + PKB 复用日志 → **Fan-out 扇出已被散装模式替代,其余全部保留**
> v1.2: PKB 对接 → **全部保留**
> v1.1: 10 项架构审查修正 → **全部保留**
>
> ### v1.4 变更:终极架构审查 7 项修正v1.4 定稿)
>
> | # | 终极审查问题 | 修正措施 | 影响章节 |
> |---|------------|---------|---------|
> | 1 | P-Queue 单机限流在多 Pod 下失效 | 废弃 P-Queue改用 pg-boss `teamConcurrency` 全局队列限流 | Task 2.3 |
> | 2 | Fan-out 并发回写父任务 Race Condition | Prisma 原子递增 `{ increment: 1 }` + 事务保障 | Task 2.3 |
> | 3 | 永久错误被 pg-boss 盲目重试 3 次 | 异常分级路由:致命错误 return success 停止重试 | Task 2.3 |
> | 4 | MinerU 重复解析同一 PDF 浪费算力 | MinerU Clean Data OSS 缓存Cache-Aside 模式) | Task 2.2 |
> | 5 | Quote 标红后医生无路可走HITL 死锁) | [强制认可] + [手动修改数值] 双按钮解锁 | Task 5.2 |
> | 6 | SSE 断开导致主业务流中断 | 双轨制React Query 轮询驱动主流 + SSE 仅驱动日志流 | Task 4.1 |
> | 7 | 红线 2 计算卸载约束未显式标注 | Node.js 禁碰 pymupdf4llm/MinerU 解析,必须路由到外部服务 | Task 2.2 |
>
> ### v1.3 变更:深度排雷 6 项修正 + Postgres-Only 安全规范v1.3
>
> | # | 排雷问题 | 修正措施 | 影响章节 |
> |---|---------|---------|---------|
> | 1 | 跨 Schema 直查打破模块边界 | PKB 暴露 `PkbExportService`ACL 防腐层ASL 进程内调用获取 DTO | Task 3.3b, 七.4 |
> | 2 | 双引擎上下文污染致 LLM 幻觉 | `<FULL_TEXT>` + `<HIGH_FIDELITY_TABLES>` XML 隔离 + 表格优先级指令 | Task 2.1, 2.2 |
> | 3 | pg-boss 单 Job 粗粒度致崩溃重做 | Fan-out 扇出模式:父任务派发 N 个子任务(单篇粒度) | Task 2.3, 2.4 |
> | 4 | 签名 URL 1 小时过期打断医生心流 | 懒签名10 分钟有效期)+ 前端 403 自动刷新 | Task 5.3 |
> | 5 | SSE 重连后进度条空白 | SSE Hydration on Connect连接时下发 `sync` 初始事件 | Task 4.1, 2.4 |
> | 6 | PKB 复用无用户感知 | Worker 日志高亮 `[System] Fast-path: Reused from PKB` | Task 2.3 |
> | — | Postgres-Only 安全规范 | 幂等 upsert + Payload 轻量 + 合理过期时间(强制遵守) | Task 2.3, 全局 |
>
> ### v1.2 变更:对接 PKB 个人知识库(取消自建 PDF 上传)
>
> **决策依据:** PKB 模块已完整集成 OSS 存储(`storage.upload()` + `generatePkbStorageKey()`
> + `getDocumentSignedUrl()`),且上传后自动通过 pymupdf4llm 提取 Markdown 全文
> 存入 `extractedText` 字段。Tool 3 无需重复造轮子。
>
> | 删除项 | 原计划内容 | 替代方案 |
> |--------|-----------|---------|
> | ~~Task 2.6~~ | PDF 上传接入 OSS | PKB 已完成,直接读取 |
> | ~~Task 3.3~~ | PdfUploadPanel 组件 | 改为 PkbKnowledgeBaseSelector |
> | ~~前端 Upload.Dragger~~ | 拖拽上传 PDF | 选择 PKB 知识库 + 勾选文献 |
> | ~~pymupdf4llm 运行时~~ | 每篇 PDF 提取全文 | 直接读 PKB `extractedText`(已有) |
>
> ### 架构审查修正记录v1.1
>
> | # | 审查问题 | 修正措施 | 影响章节 |
> |---|---------|---------|---------|
> | 1 | 任务进度管理违背 Postgres-Only 规范 | AslExtractionTask 仅存业务元数据,实时进度交 pg-boss/CheckpointService | Task 1.1 |
> | 2 | Step 2 日志推送退回轮询 | 改用 SSE 流式推送,复用 `common/streaming/` | Task 4.1 |
> | 3 | MinerU 批量并发无控制 | ~~P-Queue 漏斗~~ → v1.4 改 pg-boss `teamConcurrency` | Task 2.3 |
> | 4 | Quote 子串匹配过于理想化 | fuzzyQuoteMatchUnicode 标准化 + Levenshtein ≤5% | Task 2.3, 七.3 |
> | 5 | JSONB 字段无节制膨胀 | MinerU HTML 表格压缩存 OSSDB 仅存 ossKey | Task 1.1 |
> | 6 | 自定义 Prompt 无注入防护 | 指令隔离护栏 BEGIN/END + 防逃逸声明 | Task 2.1 |
> | 7 | 断点恢复缺失 | 状态驱动路由,首屏读 status 决定渲染步骤 | Task 4.1, 5.1 |
> | 8 | Drawer 大表单渲染卡顿 | Collapse 折叠懒渲染 + Ant Design Form shouldUpdate | Task 5.2 |
> | 9 | 排期过于乐观 | Sprint 1 跑通主干 → Sprint 2 叠加高级特性 | 六 |
> | 10 | 缺乏 E2E 测试 | 新增 Playwright E2E 测试节点 | Task 6.3 |
> </details>
---
@@ -224,26 +185,19 @@ model AslProjectTemplate {
}
// 提取任务(业务元数据映射表)
// ⚠️ 架构审查修正:本表仅存储业务元数据和任务完成后的聚合统计
// 实时进度由 pg-boss + CheckpointService 托管;日志流为 SSE 瞬时推送(阅后即焚),
// 不存入任何数据库字段。Schema 中无 executionLogs 字段v1.4.2 确认删除设计正确)
// 🆕 v1.2:新增 pkbKnowledgeBaseId文献来源从 PKB 知识库获取
// 🚀 v2.0 散装模式Task 表无 successCount/failedCount进度由 COUNT(Result) 实时聚合
// Worker 绝不写 Task 表,仅 Aggregator 在收口时更新 status。
// 日志流为 SSE 瞬时推送(可选增强),不存入数据库。
model AslExtractionTask {
id String @id @default(uuid())
projectId String
templateId String
pkbKnowledgeBaseId String? // 🆕 PKB 知识库 ID文献 PDF 来源)
jobId String? @unique // pg-boss job ID用于关联实时进度
pkbKnowledgeBaseId String? // PKB 知识库 ID文献 PDF 来源)
idempotencyKey String? @unique // 🚀 v2.0 DB 级幂等(@unique + P2002 捕获,防并发重复创建)
modelName String @default("deepseek-v3")
totalCount Int @default(0) // 待提取文献总数(创建时写入)
// 以下字段仅在任务完成后由 Worker 回写
status String @default("pending") // pending | processing | completed | failed
successCount Int @default(0)
failedCount Int @default(0)
totalTokens Int @default(0)
totalCost Decimal @default(0)
startedAt DateTime?
totalCount Int @default(0) // 待提取文献总数(创建时写入,不再变更
status String @default("processing") // processing | completed | failed仅 Aggregator 变更)
createdAt DateTime @default(now())
completedAt DateTime?
errorMessage String?
@@ -255,21 +209,21 @@ model AslExtractionTask {
}
// 单篇文献的提取结果
// 🆕 v1.2:新增 pkbDocumentId支持从 PKB 文档直接读取 extractedText 和 storageKey
// 🚀 v2.0Worker 只写自己的 Result 行,绝不碰 Task 表。
// Aggregator 通过 groupBy 聚合 Result.status 来判定 Task 是否完成。
model AslExtractionResult {
id String @id @default(uuid())
taskId String
projectId String
literatureId String? // 来自工具 1/2 的 AslLiterature可选
pkbDocumentId String? // 🆕 来自 PKB 的文档 ID与 literatureId 二选一)
pkbDocumentId String? // 来自 PKB 的文档 ID与 literatureId 二选一)
// 🆕 v1.5PKB 数据一致性快照
// Manager 派发时冻结Child Worker 直接使用,无需运行时回查 PKB
// 防止提取进行中 PKB 侧删除/修改文档导致任务崩溃
snapshotStorageKey String? // 快照:OSS 存储路径(派发时从 PKB 冻结)
snapshotFilename String? // 快照:文件名(派发时从 PKB 冻结)
// PKB 数据一致性快照v1.5 设计v2.0 移至 API 层执行)
// API 创建任务时一次性冻结,Worker 直接使用,无需运行时回查 PKB
snapshotStorageKey String? // 快照OSS 存储路径
snapshotFilename String? // 快照:文件名
status String @default("pending") // pending | extracting | extracted | approved | rejected | error
status String @default("pending") // pending | extracting | completed | error
// MinerU 表格提取
// ⚠️ 架构审查修正MinerU HTML 表格可能非常庞大(几 MB
@@ -303,6 +257,7 @@ model AslExtractionResult {
literature AslLiterature? @relation(fields: [literatureId], references: [id])
// pkbDocumentId 为跨 schema 引用,不建 Prisma relation通过 PkbBridgeService 查询
@@index([taskId, status]) // 🚀 v2.0Aggregator groupBy 聚合加速
@@schema("asl_schema")
}
```
@@ -483,88 +438,78 @@ model AslLiterature {
---
#### Task 2.3 — 智能提取服务(核心)— ⚠️ v1.3 Fan-out 扇出模式 + Postgres-Only 安全规范
#### Task 2.3 — 智能提取服务(核心)— 🚀 v2.0 散装派发 + Aggregator 轮询收口
**文件:** `backend/src/modules/asl/extraction/services/ExtractionService.ts`
**Worker** `backend/src/modules/asl/extraction/services/ExtractionWorker.ts`
**Worker** `backend/src/modules/asl/extraction/services/ExtractionSingleWorker.ts`
**Aggregator** `backend/src/modules/asl/extraction/services/ExtractionAggregator.ts`
> **🚨 v1.3 排雷修正(隐患 3** 原计划将 N 篇文献打包在一个 `pg-boss` Job 中处理
> 如果第 80 篇崩溃,前 79 篇的 `CheckpointService` 数据无法独立恢复pg-boss 整体 retry 会从头跑)
> 改为 **Fan-out 扇出模式**Manager Job → N 个 Child Job单篇粒度每个 Child 独立 retry
>
> **同时强制遵守 `Postgres-Only异步任务处理指南` 的 6 项安全规范。**
> **🚀 v2.0 架构转型:** 废弃 Fan-out 扇出模式Manager → N Child → Last Child Wins
> 改用**散装派发 + Aggregator 轮询收口**模式
> API 层直接创建 N 个独立 JobWorker 只管自己的 Result 行Aggregator 定时收口
> 详见 `散装派发与轮询收口任务模式指南.md`。
**Fan-out 架构图:**
**散装派发架构图:**
```
┌──────────────────────────────────────────────────────────┐
│ POST /api/v1/asl/extraction/tasks
→ 创建 AslExtractionTask (DB)
→ pg-boss.send('asl_extraction_manager', { taskId })
┌──────────────────────────────────────────────────┐
│ Manager Job (asl_extraction_manager) │
│ 1. 读取任务关联的 N 篇文献
│ │ 2. ⚠️ v1.5 批量快照 PKB 元数据 → 冻结到 │ │
AslExtractionResult.snapshotStorageKey/Filename │ │
│ │ 3. 为每篇文献 dispatch Child Job │ │
│ pg-boss.send('asl_extraction_child', {
│ taskId, resultId, pkbDocumentId │
│ }) │
│ 4. 派发完毕后 Manager 退出Fire-and-forget
└──────────────────────────────────────────────────┘
↓ (N 个)
┌──────────────────────────────────────────────────┐
│ Child Job (asl_extraction_child) × N │
│ │ 1. ⚠️ v1.4.2 乐观锁抢占 pending → extracting │ │
│ │ 2. 读 PKB extractedText零成本+ 用快照字段 │ │
│ │ snapshotStorageKey 访问 OSS防 PKB 删除) │ │
│ 3. 派发 asl_mineru_extract 子队列teamConc: 2
│ 4. 组装 Prompt → LLM 调用 → fuzzyQuoteMatch
│ 5. 事务: upsert Result + 原子递增父任务计数
│ 6. ⚠️ v1.4.2 "Last Child Wins":判断完成数 =
│ │ totalCount → 翻转 Task status = completed │ │
│ │ 7. SSE 推送进度日志 │ │
│ │ ⚡ 致命错误 return success 停止重试 │ │
│ ⚡ 临时错误 throw → pg-boss 指数退避自动 retry
└──────────────────────────────────────────────────┘
└──────────────────────────────────────────────────────────┘
┌─ API 层 ──────────────────────────────────────┐
│ POST /api/v1/asl/extraction/tasks │
1. DB 级幂等idempotencyKey @unique + P2002
2. 创建 Taskstatus: processing
3. 批量创建 N 个 Resultstatus: pending
4. PKB 快照冻结snapshotStorageKey/Filename
5. 散装派发 N 个 pgBoss Job一次 insert
6. 立即返回 taskId< 0.1 秒)
└────────────────────────────────────────────────┘
↓ (N 个独立 Job)
┌─ Worker 层(多 Pod 各自抢单)─────────────────┐
每个 Worker 只管自己的 1 篇文献:
1. 幽灵重试守卫updateMany where: pending
2. 读 PKB extractedText + snapshotStorageKey
3. MinerU 表格提取(含 OSS 缓存
4. 组装 Prompt → LLM 调用 → fuzzyQuoteMatch
5. 只更新自己的 Result 行
6. 绝不触碰父任务 Task 表!
7. 错误分级:致命 return / 临时回退 + throw
└────────────────────────────────────────────────┘
┌─ Aggregator全局唯一每 10 秒)─────────────┐
1. 清理僵尸extracting > 30min → error
2. 聚合统计groupBy Result.status
3. 收口判定pending=0 且 extracting=0
→ 标记 Task completed
└────────────────────────────────────────────────┘
┌─ 前端React Query 轮询)─────────────────────┐
GET /tasks/:taskId/status
→ 实时 groupBy 聚合进度
│ → 无需 successCount/failedCount 冗余字段 │
└────────────────────────────────────────────────┘
```
> 📖 代码模式见 08d-工具3-代码模式与技术规范.md §4.1
> 📖 Manager/Child Worker 代码见 08d §4.2 + §4.3
> 📖 代码模式见 08d-工具3-代码模式与技术规范.md §4
**⚠️ 强制遵守Postgres-Only 异步任务处理指南安全规范**
| 规范 | 要求 | 本模块实现 |
|------|------|-----------|
| **幂等性** | Worker 必须容忍 pg-boss 重投at-least-once | ⚠️ v1.4.2 改为 `updateMany({ where: { status: 'pending' } })` 乐观锁原子抢占,替代 Read-then-Write 反模式 |
| **Payload 轻量** | Job data 不超过数 KB禁止塞 PDF 正文 | 仅传 `{ taskId, resultId, pkbDocumentId }`,不超过 200 bytes。快照数据存在 `AslExtractionResult` 表中v1.5),不塞 Job |
| **过期时间** | 必须设置 `expireInMinutes`,防止僵尸 Job | Manager: 60minChild: 30min |
| **错误分级** | 区分"可重试"和"永久失败" | 429/5xx → retrypg-boss 指数退避4xx/解析错误 → 标记 error不 retry |
| **死信处理** | 超过 retryLimit 的 Job 进入 DLQ | pg-boss 内置 `onFail` handler 标记该篇为 `error` |
| **进度追踪** | 不在 Job data 中存大量进度 | 进度统一走 `CheckpointService`Job data 仅含 ID 引用 |
| **幂等性** | Worker 必须容忍 pg-boss 重投at-least-once | 幽灵重试守卫 `updateMany({ where: { status: 'pending' } })`,只允许 pending→extracting |
| **API 幂等** | 防止网络重试创建重复 Task | `idempotencyKey @unique` + P2002 捕获DB 物理级幂等) |
| **Payload 轻量** | Job data 不超过数 KB禁止塞 PDF 正文 | 仅传 `{ resultId, pkbDocumentId }`,不超过 200 bytes |
| **过期时间** | 必须设置 `expireInMinutes`,防止僵尸 Job | `expireInMinutes: 30`(执行超时,非排队等待超时) |
| **错误分级** | 区分"可重试"和"永久失败" | 致命错误 return停止重试/ 临时错误回退 pending + throw指数退避 |
| **死信处理** | 超过 retryLimit 的 Job 进入 DLQ | pg-boss 内置 `onFail` + Aggregator 僵尸清理双保险 |
| **Worker 独立性** | Worker 只写自己的行 | **绝不碰 Task 表**,进度由 Aggregator groupBy 聚合 |
**⚠️ v1.4 终极修正:废弃 P-Queue改用 pg-boss `teamConcurrency` 全局限流**
**并发控制:**
> **❌ 旧方案(已废弃):** `ExtractionService` 内部使用 `P-Queue({ concurrency: 2 })` 控制 MinerU 并发。
> 在多实例K8s Pods部署下每个 Pod 各自有一个 P-Queue实际全局并发 = 2 × Pod 数,
> MinerU API 瞬间 429 熔断 + 重试风暴。
>
> **✅ 新方案:** 把并发控制权交还给数据库。将 MinerU 解析拆为独立 pg-boss 子队列,
> 配置 `teamConcurrency`——这是 PostgreSQL 级全局锁,跨所有 Node.js 实例生效。
```
asl_extract_single (teamConcurrency: 10) ← 全局限流,防 OOM
```
> 📖 Worker 注册与三级限流见 08d-工具3-代码模式与技术规范.md §4.4
> **v1.4.2 三级限流架构(队列名已合规):**
> ```
> asl_extraction_child (teamConcurrency: 10) ← 背压阀门,防 OOM
> └─ asl_mineru_extract (teamConcurrency: 2) ← 昂贵 API 保护
> └─ asl_llm_extract (teamConcurrency: 5) ← LLM 并发保护
> ```
> 全部基于 PostgreSQL 行锁实现全局并发控制,跨所有 Node.js 实例生效。
> P-Queue 是进程内信号量,多实例下形同虚设——已废弃。
> 🚀 v2.0 简化:废弃三级嵌套子队列(`asl_mineru_extract`、`asl_llm_extract`
> 统一为单一队列 `asl_extract_single`MinerU 和 LLM 调用在 Worker 内部串行执行。
> `teamConcurrency: 10` 已足够控制全局并发10 个 Worker × 1 MinerU/LLM = 最多 10 并发外部调用)。
**LLM 调用策略:**
- 主模型DeepSeek-V3128K 上下文成本低JSON 输出能力强)
@@ -649,28 +594,18 @@ SSE 端点在客户端首次连接时,立即下发一个 `sync` 事件,包
> 📖 前端 sync 降级处理见 08d-工具3-代码模式与技术规范.md §7.4
Worker 中通过 `CheckpointService` 更新进度SSE 端点监听 checkpoint 变化并推送给前端。
**🚀 v2.0 SSE 简化纯轮询为主SSE 日志流为可选增强**
**🆕 v1.5SSE 跨实例实时日志通信 — PostgreSQL NOTIFY/LISTEN**
> **物理限制:** `sseEmitter.emit()` 基于内存 EventEmitter用户连 Pod A但 Worker 跑在 Pod B
> Pod B 触发 emitPod A 的 SSE 连接收不到任何实时日志。v1.4.1 仅解决了历史日志logBuffer 降级),
> **实时日志流本身** 在多 Pod 下仍然是断裂的。
> v1.5 设计了 PostgreSQL NOTIFY/LISTEN 跨 Pod 广播方案。
> v2.0 散装模式以**纯轮询**为核心React Query 3s 轮询 + groupBy 进度查询),
> 无需跨 Pod 实时通信即可满足业务需求
>
> **解决方案:** 使用 PostgreSQL `NOTIFY/LISTEN` 实现跨实例广播Postgres-Only 合规,不引入 Redis
> **SSE 日志流定位降级为"可选增强"**
> - M1不做 SSE纯轮询即可
> - M2如果团队有余力可接入 NOTIFY/LISTEN 做终端日志流(锦上添花)
> - 即使 SSE 完全不做,前端进度条和步骤跳转也不受任何影响(双轨制 v1.4 已保障)
| 角色 | 职责 |
|------|------|
| **Worker 发送端Pod B** | Child Worker 在生成日志时执行 `NOTIFY asl_sse_channel, '{taskId, type, data}'` |
| **API 接收端(所有 Pod** | Pod 启动时建立一条**独立的长连接**(不从连接池借),执行 `LISTEN asl_sse_channel`,收到消息后检查本机是否有该 `taskId` 的 SSE 客户端,有则推送 |
**关键约束:**
- NOTIFY payload 上限 **8000 bytes**(日志消息绰绰有余)
- LISTEN 连接必须**独立于连接池**(归还连接后 LISTEN 失效)
- NOTIFY 是 fire-and-forget无持久化、无重放与 v1.4 双轨制定位完全吻合——日志流本身就是"阅后即焚"的视觉增强
- **归属 M2**M1 不做 SSE 日志流)
> 📖 NOTIFY/LISTEN 代码模式见 08d-工具3-代码模式与技术规范.md §7.6
> 📖 如需实现 SSE 跨 Pod 广播,参见 08d-工具3-代码模式与技术规范.md §7.6(保留为可选方案)
---
@@ -1073,11 +1008,11 @@ backend/src/modules/asl/
│ │ ├── DynamicPromptBuilder.ts # 动态 Prompt 组装XML 隔离 + 防注入护栏)
│ │ ├── PdfProcessingPipeline.ts # MinerU 表格增强 + PKB 文本复用
│ │ ├── PkbBridgeService.ts # PKB 桥接服务(调用 PkbExportService ACL
│ │ ├── ExtractionService.ts # 核心提取服务teamConcurrency 全局限流)
│ │ ├── ExtractionService.ts # 核心提取服务
│ │ ├── ExtractionValidator.ts # 提取结果验证fuzzyQuoteMatch
│ │ ├── ExtractionExcelExporter.ts # Excel 宽表导出
│ │ ├── ExtractionManagerWorker.ts # 🆕 v1.3 Fan-out Manager派发 N 个 Child
│ │ └── ExtractionChildWorker.ts # 🆕 v1.3 Fan-out Child单篇粒度提取
│ │ ├── ExtractionSingleWorker.ts # 🚀 v2.0 独立 Worker单篇粒度只写自己的 Result
│ │ └── ExtractionAggregator.ts # 🚀 v2.0 轮询收口(僵尸清理 + 完成判定
│ ├── prompts/
│ │ ├── system_prompt_v2.md # V2 系统提示词
│ │ └── extraction_schema/ # 各模板的 JSON Schema
@@ -1150,7 +1085,7 @@ frontend-v2/src/modules/asl/
| 里程碑 | 文档 | 时间 | 核心交付 |
|--------|------|------|---------|
| **M1 骨架管线** | `08a-工具3-M1-骨架管线冲刺清单.md` | Week 15-6 天) | Fan-out 全链路 + PKB ACL + 纯文本盲提 + 极简前端 |
| **M1 骨架管线** | `08a-工具3-M1-骨架管线冲刺清单.md` | Week 15-6 天) | 散装派发 + Aggregator 全链路 + PKB ACL + 纯文本盲提 + 极简前端 |
| **M2 HITL 工作台** | `08b-工具3-M2-HITL工作台冲刺清单.md` | Week 2-38-9 天) | MinerU + 审核抽屉 + SSE 日志 + Excel 导出 |
| **M3 动态模板引擎** | `08c-工具3-M3-动态模板引擎冲刺清单.md` | Week 45-6 天) | 自定义字段 + 安全护栏 + E2E 封版 |
| **合计** | | **~22 天** | 每周五可独立 Demo |
@@ -1251,22 +1186,15 @@ fileUrl = await storage.upload(storageKey, file); // ✅ 已集成 storage.uplo
| 🆕 v1.3 跨模块耦合蔓延 | 未来 PKB 改表导致 ASL 崩溃 | ACL 防腐层PkbExportService 返回 DTOASL 不 import PKB 内部类型 |
| 🆕 v1.3 签名 URL 过期 403 | 医生审核中途无法查看 PDF | 懒签名10 分钟)+ 前端 403 自动刷新 |
| 🆕 v1.3 SSE 重连后进度空白 | 用户刷新页面看到空白进度条 | SSE Hydration on Connect首帧 `sync` 事件下发当前快照 |
| 🆕 v1.3 pg-boss 单 Job 崩溃全重做 | N 篇文献因 1 篇崩溃全部重来 | Fan-out 扇出模式:每篇独立 Child Job独立 retry |
| 🆕 v1.3 pg-boss retry 产生重复数据 | 同一篇文献被提取多次 | ⚠️ v1.4.2 改为 `updateMany` 乐观锁抢占 + singletonKey 防重复派发 |
| 🆕 v1.4 P-Queue 多实例并发翻倍 | K8s 3 Pod → 实际并发 6 导致 429 | 废弃 P-Queue改 pg-boss `teamConcurrency`DB 级全局锁 |
| 🆕 v1.4 Fan-out 并发回写计数丢失 | 100 子任务并发 +1 产生 Race Condition | Prisma 原子递增 `{ increment: 1 }` + 事务保障 |
| 🆕 v1.4 永久错误被盲目重试 3 次 | PKB 文件删除 → pg-boss 白重试 | 错误分级路由:致命错误 return success 停止重试 |
| 🆕 v1.4 MinerU 重复解析浪费算力 | retry / 跨任务同 PDF 重复调用 | MinerU Clean Data OSS 缓存Cache-Aside |
| 🆕 v1.4 Quote 标红后 HITL 死锁 | 医生无法放行正确但低置信度的提取 | [强制认可] + [手动修改数值] 双按钮解锁 |
| 🆕 v1.4 SSE 断开中断主业务流 | 进度条停滞、步骤跳转失效 | 双轨制React Query 轮询驱动主流 + SSE 仅驱动日志 |
| 🚨 v1.4.1 Quote 验证搜索范围遗漏 MinerU | LLM 引用 MinerU 表格文本但验证仅搜索 pymupdf4llm → 满屏红色误报 | `buildQuoteSearchScope()` 拼接 MinerU 纯文本后再匹配 |
| 🚨 v1.4.1 logBuffer 多 Pod 内存不共享 | SSE 重连被路由到其他 Pod → 日志区空白 | 降级方案:不存历史日志,重连时打印"监控已重连"提示 |
| 🚨 v1.4.1 Child Worker 无并发限制 OOM | 1000 篇文献 → 1000 个挂起闭包 → V8 堆溢出 | `teamConcurrency: 10` 背压限流,其余在 PostgreSQL 排队 |
| 🚨 v1.4.2 Fan-out 终点丢失 | Manager 退出后无人翻转 Task status → 前端永远卡在 Step 2 processing | "Last Child Wins"Child 原子递增后判断 `successCount + failedCount >= totalCount` 则翻转 `completed` |
| 🚨 v1.4.2 伪幂等 Read-then-Write 穿透 | 并发 retry 同时 `findUnique` 读到 pending → 双倍 MinerU/LLM 调用 + 计数双递增 | `updateMany({ where: { status: 'pending' } })` 乐观锁原子抢占,`count=0` 直接退出 |
| 🚨 v1.4.2 队列名称点号违规 | pg-boss 底层将点号识别为 Schema 分隔符,可能路由截断 | 全局替换为下划线格式:`asl_extraction_manager``asl_extraction_child``asl_mineru_extract``asl_llm_extract` |
| 🆕 v1.5 SSE 实时日志跨 Pod 断裂 | 用户连 Pod AWorker 跑 Pod B`sseEmitter.emit()` 基于内存 EventEmitter → Pod A 零日志 | PostgreSQL `NOTIFY/LISTEN` 跨实例广播Pod 启动时建立独立长连接 `LISTEN`Worker 端 `NOTIFY`(归属 M2 |
| 🆕 v1.5 PKB 数据提取中被删改 | 50 分钟提取窗口内用户在 PKB 删除 PDF → Child Worker 找不到 `storageKey` 批量崩溃 | Manager 派发时快照 `storageKey` + `filename``AslExtractionResult`Child 从自身记录读取(归属 M1 |
| 🚀 v2.0 pg-boss retry 产生重复处理 | 同一篇文献被提取多次,浪费 LLM 费用 | 幽灵重试守卫 `updateMany({ where: { status: 'pending' } })`,幽灵 retry 直接跳过 |
| 🚀 v2.0 Worker OOM 崩溃后 Result 卡 extracting | Aggregator 前该 Result 永远不收口 | Aggregator 每 10 秒扫描 `extracting > 30min` → 强制标记 error |
| 🚀 v2.0 API 重试创建重复 Task | 浏览器重试 / 网络超时重发 | `idempotencyKey @unique` + P2002 捕获DB 物理级幂等 |
| v1.4 永久错误被盲目重试 3 次 | PKB 文件删除 → pg-boss 白重试 | 错误分级路由:致命错误 return 停止重试 |
| v1.4 MinerU 重复解析浪费算力 | retry / 跨任务同 PDF 重复调用 | MinerU Clean Data OSS 缓存Cache-Aside |
| v1.4 Quote 标红后 HITL 死锁 | 医生无法放行正确但低置信度的提取 | [强制认可] + [手动修改数值] 双按钮解锁 |
| v1.4 SSE 断开中断主业务流 | 进度条停滞、步骤跳转失效 | 双轨制React Query 轮询驱动主流 + SSE 仅驱动日志 |
| v1.4.1 Quote 验证搜索范围遗漏 MinerU | LLM 引用 MinerU 表格文本但验证仅搜索 pymupdf4llm → 满屏红色误报 | `buildQuoteSearchScope()` 拼接 MinerU 纯文本后再匹配 |
| v1.5 PKB 数据提取中被删改 | 50 分钟提取窗口内用户在 PKB 删除 PDF → Worker 找不到 `storageKey` 批量崩溃 | API 创建任务时快照 `storageKey` + `filename``AslExtractionResult`Worker 从自身记录读取 |
---
@@ -1286,7 +1214,8 @@ fileUrl = await storage.upload(storageKey, file); // ✅ 已集成 storage.uplo
| **架构审查与改进建议** | `docs/03-业务模块/ASL-AI智能文献/06-技术文档/工具3架构审查与研发改进建议.md` |
| **🆕 深度审查与排雷指南** | `docs/03-业务模块/ASL-AI智能文献/06-技术文档/工具3开发计划深度审查与排雷指南.md` |
| **🆕 终极架构审查与研发规范** | `docs/03-业务模块/ASL-AI智能文献/06-技术文档/工具3终极架构审查与研发规范.md` |
| **🆕 Postgres-Only 异步任务处理指南** | `docs/02-通用能力层/Postgres-Only异步任务处理指南.md` |
| **🚀 散装派发与轮询收口指南** | `docs/02-通用能力层/散装派发与轮询收口任务模式指南.md`**v2.0 核心参考** |
| **Postgres-Only 异步任务处理指南** | `docs/02-通用能力层/Postgres-Only异步任务处理指南.md` |
| **🆕 PKB 个人知识库状态** | `docs/03-业务模块/PKB-个人知识库/00-模块当前状态与开发指南.md` |
| **🆕 M1 骨架管线冲刺清单** | `docs/03-业务模块/ASL-AI智能文献/04-开发计划/08a-工具3-M1-骨架管线冲刺清单.md` |
| **🆕 M2 HITL 工作台冲刺清单** | `docs/03-业务模块/ASL-AI智能文献/04-开发计划/08b-工具3-M2-HITL工作台冲刺清单.md` |
@@ -1296,19 +1225,15 @@ fileUrl = await storage.upload(storageKey, file); // ✅ 已集成 storage.uplo
---
*本文档为架构总纲v1.5)。排期与任务分配已拆分为 M1/M2/M3 三个独立冲刺清单(见第六章)。*
*本文档为架构总纲v2.0 散装派发 + Aggregator 轮询收口)。排期与任务分配已拆分为 M1/M2/M3 三个独立冲刺清单(见第六章)。*
*M1 启动前全员必须确认以下研发红线:*
*1. **Payload 轻量**pg-boss Job Data 仅传 ID禁止塞 PDF 正文或 extractedText*
*2. **计算卸载**Node.js 禁碰 pymupdf4llm/MinerU 解析,必须路由到 Python 微服务或 Cloud API*
*3. **过期时间**Child Job `expireInMinutes: 30`Manager Job `expireInMinutes: 60`*
*4. **三级限流**Child `teamConcurrency: 10` → MinerU `teamConcurrency: 2` → LLM `teamConcurrency: 5`(全部 pg-boss 全局锁)*
*5. **原子递增**Fan-out Child 完成时必须用 Prisma `{ increment: 1 }` 原子更新父任务计数*
*6. **错误分级**:致命错误 return success 停止重试,临时错误 throw 让 pg-boss 自动退避*
*7. **ACL 防腐层**ASL 绝不直接 import PKB 内部类型,统一通过 `PkbExportService` 获取 DTO*
*8. **Quote 搜索池**`fuzzyQuoteMatch` 必须在 pymupdf4llm 全文 **+ MinerU 纯文本** 中匹配,禁止仅搜索 Markdown*
*9. **Last Child Wins**Fan-out 模式中最后一个完成的 Child无论成功或失败必须翻转父任务 `status = completed`,否则前端永远卡在 Step 2*
*10. **乐观锁抢占**Child Worker 禁止使用 `findUnique → if check` 模式Read-then-Write必须用 `updateMany({ where: { status: 'pending' } })` 原子锁*
*11. **队列命名下划线**:所有 pg-boss 队列名使用下划线(`asl_extraction_child`禁止点号遵守《Postgres-Only 指南》§4.1*
*12. **PKB 快照冻结**Manager 派发 Child Job 前必须将 `storageKey` + `filename` 快照到 `AslExtractionResult`Child Worker 从自身记录读取,禁止运行时回查 PKBv1.5*
*13. **SSE 跨 Pod 广播**Worker 日志推送必须经 PostgreSQL `NOTIFY`,禁止依赖内存 EventEmitter 跨实例通信v1.5M2 实施)*
*1. **Worker 独立性**Worker 只更新自己的 Result 行,**绝不碰 Task 表**,进度由 Aggregator groupBy 聚合*
*2. **幽灵重试守卫**Worker 禁止 `findUnique → if check`Read-then-Write必须用 `updateMany({ where: { status: 'pending' } })` 原子锁*
*3. **临时错误回退**:临时错误 throw 前必须 `update({ status: 'pending' })` 释放幽灵守卫*
*4. **API DB 级幂等**`idempotencyKey @unique` + P2002 捕获,禁止 Read-then-Write 幂等*
*5. **Payload 轻量**pg-boss Job Data 仅传 `{ resultId, pkbDocumentId }`,禁止塞 PDF 正文*
*6. **计算卸载**Node.js 禁碰 pymupdf4llm/MinerU 解析,必须路由到 Python 微服务或 Cloud API*
*7. **错误分级**:致命错误 return 停止重试,临时错误回退 pending + throw 让 pg-boss 自动退避*
*8. **ACL 防腐层**ASL 绝不直接 import PKB 内部类型,统一通过 `PkbExportService` 获取 DTO*
*9. **PKB 快照冻结**API 创建任务时必须将 `storageKey` + `filename` 快照到 `AslExtractionResult`Worker 从自身记录读取,禁止运行时回查 PKB*

View File

@@ -3,8 +3,9 @@
> **所属:** 工具 3 全文智能提取工作台 V2.0
> **架构总纲:** `08-工具3-全文智能提取工作台V2.0开发计划.md`
> **代码手册:** `08d-工具3-代码模式与技术规范.md`(所有代码模式均在此手册中,开发时按需查阅)
> **建议时间:** Week 15.5-6.5 天,含 v1.6 Sweeper 清道夫 0.5 天)
> **核心目标:** 证明 "PKB 拿数据 → Fan-out 分发 → LLM 盲提 → 数据落库 → 前端到 completed" 这条管线是通的。
> **建议时间:** Week 15-6 天)
> **核心目标:** 证明 "API 散装派发 → Worker 单兵提取 → Aggregator 收口 → 前端轮询到 completed" 这条管线是通的。
> **异步模式指南:** `散装派发与轮询收口任务模式指南.md`Level 2 Cookbook
---
@@ -28,9 +29,10 @@
**验收标准:**
- [ ] `npx prisma migrate deploy` 成功
- [ ] `npx prisma db seed` 后数据库有 3 套模板记录
- [ ] `AslExtractionTask``pkbKnowledgeBaseId` 字段
- [ ] `AslExtractionResult``snapshotStorageKey` + `snapshotFilename` 快照字段v1.5
- [ ] `AslExtractionResult``pkbDocumentId` 字段、`status``extracting` 状态值
- [ ] `AslExtractionTask``pkbKnowledgeBaseId` + `idempotencyKey @unique` 字段(无 `successCount/failedCount`
- [ ] `AslExtractionResult``snapshotStorageKey` + `snapshotFilename` 快照字段
- [ ] `AslExtractionResult``pkbDocumentId` 字段、`status``pending | extracting | completed | error`
- [ ] `AslExtractionResult``@@index([taskId, status])` 复合索引Aggregator 性能保障)
> 📖 Schema 详情见架构总纲 Task 1.1
@@ -40,8 +42,11 @@
**做什么:**
- `TemplateController.ts`GET 模板列表、GET 模板详情、POST 克隆到项目
- `ExtractionController.ts`POST 创建任务、GET 任务状态(**React Query 轮询用**、GET 结果列表
- 创建任务时:锁定模板 → 批量创建 `AslExtractionResult`status=pending`pgBoss.send('asl_extraction_manager', { taskId })`
- `ExtractionController.ts`
- POST 创建任务(含 DB 幂等 `idempotencyKey @unique` + P2002
- GET 任务状态(`groupBy` 聚合 Result 状态,驱动 React Query 轮询)
- GET 结果列表
- 🚀 **创建任务 = API 层散装派发(无 Manager**:锁定模板 → PKB 快照冻结 → `createMany` Result → `jobQueue.insert` N 个 `asl_extract_single` Job
**不做什么:**
- 不做自定义字段 CRUD APIM3
@@ -49,15 +54,17 @@
- 不做 Excel 导出M2
**验收标准:**
- [ ] `POST /api/v1/asl/extraction/tasks` 创建任务并入队
- [ ] `GET /api/v1/asl/extraction/tasks/:taskId` 返回 `status``successCount``totalCount`
- [ ] `POST /api/v1/asl/extraction/tasks` 创建任务并散装派发 N 个 Job
- [ ] 重复 `idempotencyKey` 请求返回已有 taskId幂等验证
- [ ] `GET /api/v1/asl/extraction/tasks/:taskId` 返回 `groupBy` 聚合进度completedCount / errorCount / pendingCount / extractingCount
- [ ] `GET /api/v1/asl/extraction/tasks/:taskId/results` 返回提取结果列表
> 📖 端点完整列表见架构总纲 Task 1.3 + Task 2.4
> 📖 端点完整列表见架构总纲 Task 1.3 + Task 2.4
> 📖 API 散装派发代码见 08d §4.2
---
### M1-3PKB ACL 防腐层 + Fan-out 调度核心2 天)⚠️ 本里程碑最关键
### M1-3PKB ACL 防腐层 + 散装 Worker + Aggregator2 天)⚠️ 本里程碑最关键
**做什么(按顺序):**
@@ -68,44 +75,46 @@
**Step B — ASL 侧桥接0.5 天):**
- `PkbBridgeService.ts`:调用 `PkbExportService`,代理所有 PKB 数据访问
**Step C — Fan-out Manager + Child Worker1 天)⚠️ 核心战役:**
- `ExtractionManagerWorker.ts`读取任务 → 🆕 **v1.6 空集合守卫**`results.length === 0` → 直接 completed → ⚠️ v1.5 批量快照 PKB 元数据(`snapshotStorageKey` + `snapshotFilename`)冻结到 `AslExtractionResult` → 为每篇文献 `pgBoss.send('asl_extraction_child', ...)` → 退出Fire-and-forget
- `ExtractionChildWorker.ts` 完整逻辑:
1. **乐观锁抢占**`updateMany({ where: { status: 'pending' }, data: { status: 'extracting' } })`
**Step C — ExtractionSingleWorker0.5 天)⚠️ 核心战役:**
- `ExtractionSingleWorker.ts` 完整逻辑
1. **幽灵重试守卫**`updateMany({ where: { status: 'pending' }, data: { status: 'extracting' } })`
2. **纯文本降级提取**:从 PKB 读 `extractedText` + 写死 RCT Schema → 调用 DeepSeek
3. **原子递增**:事务内 `update Result + increment Task counts`
4. **Last Child Wins**`successCount + failedCount >= totalCount` → 翻转 `status = completed`
5. **错误分级路由**:致命错误 return / 🆕 **v1.6 临时错误 throw 前释放乐观锁(回退 status → pending**
3. **只更新自己的 Result**`prisma.aslExtractionResult.update({ status: 'completed', extractedData })`
4. **绝不碰 Task 表**(无 `$transaction`、无 `increment`、无 Last Child Wins
5. **错误分级路由**:致命错误 → 标 error + return临时错误 → 回退 `pending` + throw
**Step D — 🆕 Sweeper 清道夫注册0.5 天v1.6 新增**
- `asl_extraction_sweeper`pg-boss 定时任务,每 10 分钟扫描 `processing``updatedAt > 2h` 的任务,强制标记 `failed`
- 使用 `updatedAt`(最后活跃时间)判断卡死,禁止用 `startedAt`(防误杀健康的超大批量任务
**Step D — ExtractionAggregator0.5 天**
- `ExtractionAggregator.ts`pg-boss schedule 每 2 分钟执行
- 一人兼两职:**僵尸清理**`extracting > 30min` → error+ **收口**`pending === 0 && extracting === 0` → completed
- 使用 `groupBy` 一次查询统计所有状态
**Worker 注册(遵守队列命名规范):**
**Worker + Aggregator 注册(遵守队列命名规范):**
```
jobQueue.work('asl_extraction_child', { teamConcurrency: 10 }, handler)
jobQueue.work('asl_extract_single', { teamConcurrency: 10 }, handler)
await jobQueue.schedule('asl_extraction_aggregator', '*/2 * * * *')
await jobQueue.work('asl_extraction_aggregator', aggregatorHandler)
```
**M1 阶段简化:不注册 `asl_mineru_extract` 子队列M2 才接 MinerU**
**M1 阶段简化:Worker 内部串行调 LLM接 MinerUM2 再接)。**
**验收标准:**
- [ ] PkbExportService 能返回知识库列表和文档详情DTO
- [ ] Manager 派发`AslExtractionResult.snapshotStorageKey``snapshotFilename` 已填充(v1.5 快照验证)
- [ ] 手动删除 PKB 文档记录后,Child Worker 仍能通过 `snapshotStorageKey` 从 OSS 获取 PDFv1.5 一致性验证)
- [ ] Manager 能为 N 篇文献派发 N 个 Child Job
- [ ] Child Worker 乐观锁正确:并发重试不会双倍处理
- [ ] Child Worker 原子递增10 篇并发提取后 `successCount = 10`
- [ ] Last Child Wins最后一个 Child 翻转 Task status = completed
- [ ] API 创建任务`AslExtractionResult.snapshotStorageKey``snapshotFilename` 已填充(PKB 快照验证)
- [ ] 手动删除 PKB 文档记录后Worker 仍能通过 `snapshotStorageKey` 从 OSS 获取 PDF一致性验证
- [ ] API 为 N 篇文献散装派发 N 个 `asl_extract_single` Job
- [ ] Worker 幽灵守卫正确:并发重试不会双倍处理`lock.count === 0` 跳过)
- [ ] Worker 只写 Result 行Task 表零更新(确认无行锁争用)
- [ ] Aggregator 每 2 分钟轮询:`pending === 0 && extracting === 0` → Task `completed`
- [ ] Aggregator 僵尸清理:手动将 Result 卡在 `extracting` 超 30 分钟 → 被标为 `error`
- [ ] 致命错误PKB 文档不存在)→ 该篇标 error + 不重试 + 不阻塞其他篇
- [ ] 临时错误429→ pg-boss 指数退避重试
- [ ] 🆕 临时错误 throw 前回退 `status → pending`:模拟 429 重试后乐观锁仍能抢占成功v1.6 乐观锁释放验证)
- [ ] 🆕 Manager 空集合守卫:`results.length === 0` 时 Task 直接标记 `completed`v1.6 边界验证
- [ ] 🆕 Sweeper 清道夫已注册:`asl_extraction_sweeper` 定时任务在 pg-boss 中可查到v1.6
- [ ] 🆕 Sweeper 判定条件为 `updatedAt > 2h`,而非 `startedAt`v1.6 防误杀验证)
- [ ] 临时错误429 回退 `pending` + pg-boss 指数退避重试
- [ ] 临时错误回退后重试成功:模拟 429 → 重试 → 幽灵守卫通过 → 提取成功
- [ ] 空文献边界:`documentIds = []` → API 直接拒绝400 Bad Request
> 📖 Fan-out 架构图、Worker 代码模式、研发红线见架构总纲 Task 2.3
> 📖 散装架构图、Worker 代码模式见架构总纲 Task 2.3
> 📖 ACL 防腐层设计见架构总纲 Task 3.3b
> 📖 Sweeper、乐观锁释放、空集合守卫代码见 08d §4.2 / §4.3 / §4.6
> 📖 Worker / Aggregator 代码见 08d §4.3 / §4.6
> 📖 散装模式指南见 `散装派发与轮询收口任务模式指南.md`
---
@@ -152,29 +161,27 @@ jobQueue.work('asl_extraction_child', { teamConcurrency: 10 }, handler)
| # | 红线 | 违反后果 |
|---|------|---------|
| 1 | 队列名用下划线(`asl_extraction_child`),禁止点号 | pg-boss 路由截断 |
| 2 | Child Worker `updateMany` 乐观锁,禁止 `findUnique → if` | 并发穿透,算力翻倍 |
| 3 | Last Child Wins 终止器,成功和失败路径都要检查 | Task 永远卡在 processing |
| 4 | `teamConcurrency: 10`,禁止无限拉取 Child Job | Node.js OOM |
| 5 | Job Payload 仅传 ID< 200 bytes禁止塞 PDF 正文 | pg-boss 阻塞 |
| 6 | ACL 防腐层ASL 不 import PKB 内部类型 | 模块耦合蔓延 |
| 7 | Manager 必须快照 `snapshotStorageKey` + `snapshotFilename`Child 禁止运行时回查 PKB 获取 storageKeyv1.5 | 提取中 PKB 删文档 → 批量崩溃 |
| 8 | 🆕 临时错误 `throw` 前必须 `update({ status: 'pending' })` 释放乐观锁v1.6 | 重试时被"幂等跳过"计数永远缺一票Task 永久卡死 |
| 9 | 🆕 Manager 必须检查 `results.length === 0` 并直接 completedv1.6 | 空文献 → 无 Child → Last Child Wins 死锁 |
| 10 | 🆕 必须注册 `asl_extraction_sweeper` 清道夫(`updatedAt > 2h`,禁止用 `startedAt`v1.6 | 进程 OOM/SIGKILL 后 Task 永久挂起 |
| 1 | 队列名用下划线(`asl_extract_single`),禁止点号 | pg-boss 路由截断 |
| 2 | **Worker 绝不碰 Task 表**,只写自己的 Result 行 | 行锁争用 / 死锁 |
| 3 | Worker 用 `updateMany({ status: 'pending' })` 幽灵守卫,禁止 `findUnique → if` | 并发穿透LLM 费用白烧 |
| 4 | 临时错误 `throw` 前必须 `update({ status: 'pending' })` 回退 | 重试被幽灵守卫误跳过 |
| 5 | `teamConcurrency: 10`,禁止无限拉取 Job | Node.js OOM |
| 6 | Job Payload 仅传 ID< 200 bytes禁止塞 PDF 正文 | pg-boss 阻塞 |
| 7 | ACL 防腐层ASL 不 import PKB 内部类型 | 模块耦合蔓延 |
| 8 | API 层快照 `snapshotStorageKey` + `snapshotFilename`Worker 禁止运行时回查 PKB | PKB 删文档 → 批量崩溃 |
| 9 | API 创建任务用 `idempotencyKey @unique` + P2002禁止 Read-then-Write | 并发穿透创建重复任务 |
---
## M1 结束时的状态
```
✅ Prisma 表 + 3 套 Seed 模板
✅ Prisma 表 + 3 套 Seed 模板(含 idempotencyKey @unique
✅ PKB ACL 防腐层 → PkbExportService + PkbBridgeService
Fan-out 全链路Manager → N × Child → Last Child Wins → completed
乐观锁 + 原子递增 + 错误分级路由 — 所有并发 Bug 已验证
🆕 Sweeper 清道夫注册v1.6 防 OOM/SIGKILL 卡死)
🆕 乐观锁释放 + 空集合守卫 + pg_notify 参数化v1.6 全量代码级同步)
✅ 前端三步走:选模板/选文献 → 轮询进度 → 极简结果列表
散装派发全链路API 散装 → N × Worker → Aggregator 收口 → completed
幽灵守卫 + 错误分级路由 + Aggregator 僵尸清理 — 所有并发 Bug 已验证
API 层 DB 幂等 + PKB 快照冻结
前端三步走:选模板/选文献 → 轮询进度groupBy 聚合)→ 极简结果列表
❌ 无 MinerU纯文本降级
❌ 无 SSE 日志流
❌ 无审核抽屉
@@ -182,4 +189,4 @@ jobQueue.work('asl_extraction_child', { teamConcurrency: 10 }, handler)
❌ 无 Excel 导出
```
> **M1 的核心价值:** 所有分布式 Bug并发死锁、幂等穿透、终点丢失、背压 OOM在第一周就被逼出来。M2 加特性时地基是稳的
> **M1 的核心价值:** 散装架构天然消除了 Fan-out 的行锁争用、Last Child Wins 终点丢失等分布式 Bug。Worker 逻辑极简只写自己Aggregator 定时收口,第一周就能稳定跑通全链路

View File

@@ -3,7 +3,7 @@
> **所属:** 工具 3 全文智能提取工作台 V2.0
> **架构总纲:** `08-工具3-全文智能提取工作台V2.0开发计划.md`
> **代码手册:** `08d-工具3-代码模式与技术规范.md`(所有代码模式均在此手册中,开发时按需查阅)
> **前置依赖:** M1 全部完成(Fan-out 管线已验证、PKB ACL 已通、纯文本提取可跑通)
> **前置依赖:** M1 全部完成(散装管线 + Aggregator 已验证、PKB ACL 已通、纯文本提取可跑通)
> **建议时间:** Week 2-38-9 天)
> **核心目标:** 接入 MinerU 视觉大模型提升表格准确率,完成前端最复杂的 HITL 审核抽屉,交付一个"完全可用"的 V1 产品。
@@ -21,19 +21,18 @@
**做什么:**
- `PdfProcessingPipeline.ts` 升级M1 的纯文本降级 → 完整双引擎流水线
- 从 PKB `storageKey` 下载 PDF Buffer → 调用 MinerU Cloud API → 返回结构化 HTML 表格
- 从 PKB `snapshotStorageKey` 下载 PDF Buffer → 调用 MinerU Cloud API → 返回结构化 HTML 表格
- **MinerU Clean Data OSS 缓存**Cache-Aside调用前先检查 `pkb/{kbId}/{docId}_mineru_clean.html`,命中则 <1 秒返回
- 注册 `asl_mineru_extract` 子队列`teamConcurrency: 2`
- Child Worker 内部通过 `pgBoss.send('asl_mineru_extract', ...)` 派发 MinerU 子任务
- Worker 内部串行 `await mineruClient.extractTables()`(无需独立子队列`teamConcurrency: 10` 即为 MinerU 隐式并发上限
**不做什么:**
- 不改 Fan-out 架构M1 已稳定)
- 不改散装架构M1 已稳定)
- 不做动态 PromptM3继续用写死的 RCT Schema
**验收标准:**
- [ ] MinerU 返回 HTML 表格,含 `<table>` + `colspan/rowspan`
- [ ] OSS 缓存命中时跳过 MinerU 调用(日志可见 "Cache hit"
- [ ] `asl_mineru_extract` 队列 `teamConcurrency: 2` 生效3 Pod 环境下全局最多 2 个并行
- [ ] `teamConcurrency: 10` 全局并发控制生效MinerU 调用受此限制
- [ ] MinerU 超时(>3min自动降级到纯文本
> 📖 缓存代码模式见架构总纲 Task 2.2
@@ -68,30 +67,30 @@
**做什么:**
- `ExtractionController.ts` 新增 SSE 端点 `GET /tasks/:taskId/stream`
- SSE 事件类型:`sync`(首帧)、`progress``log``complete``error`
- SSE 事件类型:`sync`(首帧)、`log``error`
- **首帧 sync 降级方案**`recentLogs: []`(不依赖内存 logBuffer前端检测到空日志时打印 "--- 监控已重新连接 ---"
- `ProcessingTerminal.tsx` 组件深色终端风格来源颜色区分MinerU 蓝 / DeepSeek 紫 / System 绿)
- `useExtractionLogs.ts` Hook仅驱动日志区不影响主业务流
- SSE 使用**本 Pod 内存事件**即可Worker 和 API 在同一 Pod 时日志实时可达)
**M1 已完成的不动:**
- `useTaskStatus.ts`React Query 轮询)继续驱动进度条和步骤跳转
- `useTaskStatus.ts`React Query 轮询 + groupBy 进度)继续驱动进度条和步骤跳转
- `complete` 检测完全由 React Query 轮询到 `status === 'completed'` 触发,不依赖 SSE
**[P2 可选] SSE 跨 Pod 广播 — NOTIFY/LISTEN**
> 散装架构下进度条由 React Query 轮询驱动SSE 仅为日志增强。
> 多 Pod 部署后若日志体验不佳,可后续实施 `SseNotifyBridge.ts`(代码见 08d §7.6)。
> M2 阶段不强制实施。
**验收标准:**
- [ ] SSE 连接后立即收到 `sync` 首帧
- [ ] 日志实时打字机效果(`[MinerU]``[DeepSeek]``[System]` 分色)
- [ ] SSE 断开后进度条不受影响React Query 继续轮询)
- [ ] 多 Pod 环境下 SSE 重连到其他 Pod → 显示 "监控已重新连接" 提示
- [ ] **🆕 v1.5 NOTIFY/LISTEN 跨 Pod 实时日志:** Worker 在 Pod B 提取 → Pod A 的 SSE 客户端能实时收到日志
**🆕 v1.5 额外任务SSE 跨 Pod 广播 — NOTIFY/LISTEN含在 M2-3 工期内):**
- `SseNotifyBridge.ts`Pod 启动时创建独立 PgClient 长连接(不从连接池借),执行 `LISTEN asl_sse_channel`
- 收到 NOTIFY 后检查本机是否有该 `taskId` 的 SSE 客户端,有则推送,无则静默忽略
- `ExtractionChildWorker` 中替代 `sseEmitter.emit()`:改用 `prisma.$executeRawUnsafe('NOTIFY asl_sse_channel, ...')`
- `complete` 事件同样走 NOTIFY 广播,确保"Last Child Wins"翻转后所有 Pod 收到
- [ ] 多 Pod 环境下 SSE 重连到其他 Pod → 显示 "监控已重新连接" 提示(本 Pod 无历史日志时)
> 📖 双轨制架构见架构总纲 Task 4.1
> 📖 SSE Hydration 降级见架构总纲 Task 2.4 补丁 2
> 📖 NOTIFY/LISTEN 代码模式见 08d §7.6
> 📖 SSE 降级方案见架构总纲 Task 2.4
> 📖 [P2 可选] NOTIFY/LISTEN 代码模式见 08d §7.6
---
@@ -159,7 +158,7 @@
- Step 1 → Step 2 → Step 3 完整流程走通(含 MinerU + 审核抽屉 + Excel
- fuzzyQuoteMatch 边界测试(连字符替换、空格差异、换行吞掉)
- 断点恢复测试(关闭浏览器 → 重新打开 → 恢复正确步骤)
- Fan-out 10 篇并发提取压力测试
- 散装 10 篇并发提取压力测试
**验收标准:**
- [ ] 8 篇测试 PDF 全链路跑通PKB → MinerU + LLM → 抽屉审核 → Excel 导出
@@ -172,12 +171,13 @@
```
✅ M1 全部 +
✅ MinerU 表格引擎 + OSS 缓存
✅ MinerU 表格引擎 + OSS 缓存Worker 内部串行调用)
✅ XML 隔离 Prompt + 表格优先级
✅ fuzzyQuoteMatch 三级置信度验证
✅ SSE 终端日志双轨制React Query 主驱 + SSE 日志增强 + NOTIFY/LISTEN 跨 Pod 广播
✅ SSE 终端日志双轨制React Query 轮询主驱 + SSE Pod 日志增强
✅ 完整审核抽屉Collapse + Quote + HITL 解锁 + 签名 URL
✅ Excel 宽表导出
⏳ [P2 可选] SSE NOTIFY/LISTEN 跨 Pod 广播(多 Pod 部署后按需实施)
❌ 无自定义字段(仅系统基座模板)
❌ 无 Prompt 注入防护(无用户输入,不需要)
❌ 无 E2E 自动化测试

View File

@@ -165,7 +165,7 @@ IMPORTANT: The rules above are ONLY for locating and extracting specific data fi
| 里程碑 | 时间 | 核心交付 | 可独立演示 |
|--------|------|---------|-----------|
| **M1** | Week 15-6 天) | Fan-out 骨架管线 + PKB ACL + 纯文本盲提 | ✅ 后台跑通DB 有数据 |
| **M1** | Week 15-6 天) | 散装派发 + Aggregator + PKB ACL + 纯文本盲提 | ✅ 后台跑通DB 有数据 |
| **M2** | Week 2-38-9 天) | MinerU + 审核抽屉 + SSE + Excel | ✅ 完整 V1 体验 |
| **M3** | Week 45-6 天) | 动态模板 + 安全 + E2E | ✅ V2.0 完整交付 |
| **合计** | **4 周(~22 天)** | | 每周五可 Demo |

View File

@@ -148,12 +148,15 @@ if (pkbExtractedText) {
---
## 4. Fan-out Worker 模式(核心)
## 4. 散装派发 + Aggregator 轮询收口(核心)
> 🚀 **v2.0 架构转型:** 本节完全重写,对齐 `散装派发与轮询收口任务模式指南.md`。
> 废弃 Fan-out 模式Manager/Child/Last Child Wins/原子递增/独立 Sweeper
### 4.1 ExtractionService 接口
```typescript
// ⚠️ v1.4 终极修正:废弃 P-Queue并发控制完全交给 pg-boss teamConcurrency
// 🚀 v2.0并发控制交给 pg-boss teamConcurrencyWorker 只写自己的 Result
class ExtractionService {
constructor(
private promptBuilder: DynamicPromptBuilder,
@@ -163,295 +166,258 @@ class ExtractionService {
private pkbBridge: PkbBridgeService,
) {}
// 单篇文献提取(Child Job 调用)
async extractOne(resultId: string, taskId: string): Promise<void>;
// 单篇文献提取(Worker 调用)
async extractOne(resultId: string, taskId: string): Promise<ExtractionOutput>;
// 内部流程(单篇粒度):
// 1. 加载项目模板 → 组装 Schema
// 2. 从 PKB 读取 extractedText零成本用 snapshotStorageKey 访问 OSS防 PKB 删除v1.5
// 3. ⚠️ v1.4: 通过 snapshotStorageKey → OSS 缓存检查 → MinerU 子队列teamConcurrency 全局限流
// 2. 从 PKB 读取 extractedText零成本用 snapshotStorageKey 访问 OSS防 PKB 删除)
// 3. MinerU 表格提取(含 OSS 缓存 Cache-Aside
// 4. 组装 PromptXML 隔离区 + 防注入护栏)→ LLM 调用
// 5. 解析 JSON → fuzzyQuoteMatch 验证
// 6. ⚠️ 事务内 upsert Result + 原子递增父任务计数(防 Race Condition
// 7. SSE 推送进度日志
// 6. 返回 ExtractionOutputWorker 负责写 Result本方法不写 DB
}
```
### 4.2 ExtractionManagerWorkerFire-and-forget
### 4.2 API 层散装派发ExtractionController
> 🚀 v2.0**删除 ExtractionManagerWorker**。API 层直接创建 Result + 快照 PKB 元数据 + 散装派发 Job。
```typescript
// Manager Worker — Fire-and-forget派发后立即退出
// ⚠️ v1.5:派发前一次性快照 PKB 元数据,防止提取中 PKB 侧删改导致崩溃
class ExtractionManagerWorker {
async handle(job: { data: { taskId: string } }) {
const task = await prisma.aslExtractionTask.findUnique({ where: { id: job.data.taskId } });
const results = await prisma.aslExtractionResult.findMany({ where: { taskId: task.id } });
// ═══════════════════════════════════════════════════════════
// 🚨 v1.6 空集合边界守卫
// 如果文献被全部删除或过滤后 results 为空,无 Child 被派发,
// Last Child Wins 永远不触发Task 永远卡在 processing。
// Manager 必须自己充当"收口人"直接完成任务。
// ═══════════════════════════════════════════════════════════
if (results.length === 0) {
await prisma.aslExtractionTask.update({
where: { id: task.id },
data: { status: 'completed', completedAt: new Date() },
});
await broadcastLog(task.id, { source: 'system', message: '⚠️ No documents to extract, task auto-completed.' });
return;
// ExtractionController.ts — POST /api/v1/asl/extraction/tasks
async function createTask(req, reply) {
const { projectId, templateId, documentIds, idempotencyKey } = req.body;
if (documentIds.length === 0) throw new Error('No documents selected');
// ═══════════════════════════════════════════════════════
// DB 级幂等:@unique 索引 + P2002 冲突捕获
// ═══════════════════════════════════════════════════════
let task;
try {
task = await prisma.aslExtractionTask.create({
data: { projectId, templateId, totalCount: documentIds.length, status: 'processing', idempotencyKey },
});
} catch (error) {
if (error.code === 'P2002' && idempotencyKey) {
const existing = await prisma.aslExtractionTask.findFirst({ where: { idempotencyKey } });
return reply.send({ success: true, taskId: existing.id, note: 'Idempotent return' });
}
// ═══════════════════════════════════════════════════════════
// ⚠️ v1.5 PKB 数据一致性快照
// 提取任务可能持续 50 分钟,期间用户可能在 PKB 删除/修改文档。
// 一次性批量读取 PKB 元数据并冻结到 AslExtractionResult
// Child Worker 从自身记录读取 snapshotStorageKey/snapshotFilename
// 不再运行时回查 PKB即使 PKB 删了记录OSS 文件通常仍在。
// ═══════════════════════════════════════════════════════════
const pkbDocIds = results.map(r => r.pkbDocumentId).filter(Boolean);
const pkbDocs = await Promise.all(
pkbDocIds.map(id => this.pkbBridge.getDocumentDetail(id))
);
const pkbDocMap = new Map(pkbDocs.map(d => [d.documentId, d]));
// 批量快照写入
await prisma.$transaction(
results.map(result => {
const doc = pkbDocMap.get(result.pkbDocumentId);
return prisma.aslExtractionResult.update({
where: { id: result.id },
data: {
snapshotStorageKey: doc?.storageKey ?? null,
snapshotFilename: doc?.filename ?? null,
}
});
})
);
// Fan-out为每篇文献派发 Child Job
for (const result of results) {
await pgBoss.send('asl_extraction_child', {
taskId: task.id,
resultId: result.id,
pkbDocumentId: result.pkbDocumentId,
}, {
retryLimit: 3,
retryDelay: 10, // 10 秒后重试
retryBackoff: true, // 指数退避
expireInMinutes: 30,
singletonKey: `extract-${result.id}`, // 幂等键,防止重复派发
});
}
// Manager 派发完毕后直接退出,不等待 Child 完成
// 任务状态翻转由 "Last Child Wins" 机制在 Child Worker 中完成
throw error;
}
// ═══════════════════════════════════════════════════════
// PKB 快照冻结(从旧 Manager 移至 API 层)
// 提取可能持续 50 分钟,期间用户可能在 PKB 删除/修改文档
// ═══════════════════════════════════════════════════════
const pkbDocs = await Promise.all(
documentIds.map(id => pkbBridge.getDocumentDetail(id))
);
const resultsData = pkbDocs.map(doc => ({
taskId: task.id,
projectId,
pkbDocumentId: doc.documentId,
snapshotStorageKey: doc.storageKey,
snapshotFilename: doc.filename,
status: 'pending',
}));
await prisma.aslExtractionResult.createMany({ data: resultsData });
const createdResults = await prisma.aslExtractionResult.findMany({ where: { taskId: task.id } });
// ═══════════════════════════════════════════════════════
// 散装派发N 个独立 Job 一次入队(无 Manager 中转)
// ═══════════════════════════════════════════════════════
const jobs = createdResults.map(result => ({
name: 'asl_extract_single',
data: { resultId: result.id, taskId: task.id, pkbDocumentId: result.pkbDocumentId },
options: {
retryLimit: 3,
retryBackoff: true,
expireInMinutes: 30, // 执行超时,非排队等待超时
singletonKey: `extract-${result.id}`,
},
}));
await jobQueue.insert(jobs);
return reply.send({ success: true, taskId: task.id });
}
```
### 4.3 ExtractionChildWorker乐观锁 + Last Child Wins + 错误分级)
**要点:**
- `idempotencyKey @unique` + P2002 = DB 物理级幂等
- PKB 快照冻结在 API 层完成(无需 Manager
- `singletonKey: extract-${result.id}` 防重复派发
- `jobQueue.insert(jobs)` 批量入队100 篇 < 0.1 秒
### 4.3 ExtractionSingleWorker幽灵守卫 + 错误分级)
> 🚀 v2.0**删除 $transaction、删除 increment、删除 Last Child Wins**。
> Worker 只管更新自己的 Result 行,绝不碰 Task 表。收口由 Aggregator§4.6)负责。
```typescript
// Child Worker — ⚠️ v1.4.2 终极修正:乐观锁 + 原子递增 + Last Child Wins + 错误分级路由
class ExtractionChildWorker {
async handle(job: { data: { taskId: string; resultId: string; pkbDocumentId: string } }) {
const { taskId, resultId, pkbDocumentId } = job.data;
// 🚀 v2.0 散装 Worker — 只管自己的 Result绝不碰 Task 表
class ExtractionSingleWorker {
async handle(job: { data: { resultId: string; taskId: string; pkbDocumentId: string } }) {
const { resultId, taskId, pkbDocumentId } = job.data;
// ═══════════════════════════════════════════════════════
// 幽灵重试守卫:只允许 pending → extracting
// OOM 崩溃 → status 卡在 extracting → 重试被跳过
// → Aggregator 30 分钟后将 extracting 标为 error 兜底
// ═══════════════════════════════════════════════════════
const lock = await prisma.aslExtractionResult.updateMany({
where: { id: resultId, status: 'pending' },
data: { status: 'extracting' },
});
if (lock.count === 0) {
return { success: true, note: 'Phantom retry skipped' };
}
try {
// ═══════════════════════════════════════════════════════════
// ⚠️ v1.4.2 补丁 2乐观锁抢占替代 Read-then-Write 反模式)
// 利用 updateMany 的 WHERE 条件充当原子锁:
// 只有 status='pending' 的行才允许被更新为 'extracting'
// 并发重试时第二个 Worker 会得到 count=0直接退出
// ═══════════════════════════════════════════════════════════
const lock = await prisma.aslExtractionResult.updateMany({
where: { id: resultId, status: 'pending' },
data: { status: 'extracting' },
const data = await this.extractionService.extractOne(resultId, taskId);
// ✅ 只更新自己的 Result 行,绝不碰 Task 表!
await prisma.aslExtractionResult.update({
where: { id: resultId },
data: { status: 'completed', extractedData: data, processedAt: new Date() },
});
if (lock.count === 0) {
// 已被其他 Worker 抢占或已完成,幂等跳过
return { success: true, note: 'Idempotent skip: already processing or completed' };
}
// 执行提取(此时该行已被本 Worker 独占为 'extracting'
const extractResult = await this.extractionService.extractOne(resultId, taskId);
// ═══════════════════════════════════════════════════════════
// ⚠️ v1.4.2 补丁 1 + v1.4 原子递增:
// 事务内更新 Result 状态 + 原子递增父任务计数
// 返回更新后的 Task用于 "Last Child Wins" 判断
// ═══════════════════════════════════════════════════════════
const [_resultUpdate, taskAfterUpdate] = await prisma.$transaction([
prisma.aslExtractionResult.update({
where: { id: resultId },
data: { status: 'completed', extractedData: extractResult.data, processedAt: new Date() }
}),
prisma.aslExtractionTask.update({
where: { id: taskId },
data: {
successCount: { increment: 1 },
totalTokens: { increment: extractResult.tokens },
totalCost: { increment: extractResult.cost },
}
}),
]);
// 🚨 v1.6SSE 推送日志(跨 Pod 广播,替代原 sseEmitter.emit
await broadcastLog(taskId, { source: 'system', message: `${extractResult.filename} extracted` });
// ═══════════════════════════════════════════════════════════
// ⚠️ v1.4.2 补丁 1"Last Child Wins" 终止器
// 最后一个完成(成功或失败)的 Child 负责将父任务翻转为 completed
// 这是 Fan-out 模式的关键收口逻辑——没有它Task 永远卡在 processing
// ═══════════════════════════════════════════════════════════
if (taskAfterUpdate.successCount + taskAfterUpdate.failedCount >= taskAfterUpdate.totalCount) {
await prisma.aslExtractionTask.update({
where: { id: taskId },
data: { status: 'completed', completedAt: new Date() },
});
await broadcastLog(taskId, { source: 'system', type: 'complete', message: '🎉 All documents extracted.' });
}
} catch (error) {
// ⚠️ v1.4 错误分级路由:区分"致命错误"和"临时错误"
if (error instanceof PkbDocumentNotFoundError || error.name === 'PdfCorruptedError') {
// 致命错误:标记业务状态为 error + 原子递增 failedCount
const taskAfterFail = await prisma.$transaction(async (tx) => {
await tx.aslExtractionResult.update({
where: { id: resultId },
data: { status: 'error', errorMessage: error.message }
});
return tx.aslExtractionTask.update({
where: { id: taskId },
data: { failedCount: { increment: 1 } }
});
if (isPermanentError(error)) {
// 致命错误:标记 error不重试
await prisma.aslExtractionResult.update({
where: { id: resultId },
data: { status: 'error', errorMessage: error.message },
});
// ⚠️ v1.4.2 "Last Child Wins":失败的 Child 也要检查是否是最后一个
if (taskAfterFail.successCount + taskAfterFail.failedCount >= taskAfterFail.totalCount) {
await prisma.aslExtractionTask.update({
where: { id: taskId },
data: { status: 'completed', completedAt: new Date() },
});
await broadcastLog(taskId, { source: 'system', type: 'complete', message: '🎉 All documents extracted.' });
}
return { success: false, reason: 'Permanent failure, aborted retry.' };
return { success: false, note: 'Permanent error' };
}
// ═══════════════════════════════════════════════════════════
// 🚨 v1.6 补丁:临时错误 throw 前必须释放乐观锁!
// 原因:上方 updateMany 已将 status 改为 'extracting'。
// 如果裸 throwpg-boss 重试时乐观锁 where: { status: 'pending' }
// 返回 count=0 → 误判"幂等跳过" → 计数永远少一票 → Last Child Wins 永远不触发。
// ═══════════════════════════════════════════════════════════
// 临时错误:回退 status → pending让下次重试能通过幽灵守卫
await prisma.aslExtractionResult.update({
where: { id: resultId },
data: { status: 'pending' },
});
throw error; // pg-boss 指数退避重试
}
}
}
// 临时错误 (429/网络抖动)throw → pg-boss 自动指数退避重试
throw error;
function isPermanentError(error: any): boolean {
return error instanceof PkbDocumentNotFoundError
|| error.name === 'PdfCorruptedError'
|| (error.status && error.status >= 400 && error.status < 500 && error.status !== 429);
}
```
**与旧版 Fan-out Child Worker 的关键区别:**
-~~`prisma.$transaction([...Result, ...Task])`~~ → ✅ 只 `prisma.aslExtractionResult.update()`
-~~`successCount: { increment: 1 }`~~ → ✅ Worker 不碰 Task 表
-~~Last Child Wins 终止器~~ → ✅ Aggregator 定时收口§4.6
- ✅ 幽灵守卫 + 临时错误回退 pending 保留不变
### 4.4 Worker + Aggregator 注册(扁平单队列)
> 🚀 v2.0:废弃三级嵌套队列,改为**单一扁平队列** `asl_extract_single`。
> MinerU / LLM 调用由 Worker 内部 `await` 串行完成,`teamConcurrency` 即为总并发上限。
```typescript
// ═══════════════════════════════════════════════════════
// 单一 Worker 队列:全局最多 10 篇文献并行提取
// ═══════════════════════════════════════════════════════
jobQueue.work('asl_extract_single', { teamConcurrency: 10 }, async (job) => {
await extractionSingleWorker.handle(job);
});
// ═══════════════════════════════════════════════════════
// Aggregator 定时收口:每 2 分钟扫描(代码见 §4.6
// ═══════════════════════════════════════════════════════
await jobQueue.schedule('asl_extraction_aggregator', '*/2 * * * *');
await jobQueue.work('asl_extraction_aggregator', aggregatorHandler);
```
> **扁平架构对比:**
> ```
> 旧 Fan-out三级嵌套已废弃
> asl_extraction_manager → asl_extraction_child(10) → asl_mineru_extract(2) + asl_llm_extract(5)
>
> 新散装(扁平一级):
> asl_extract_single (teamConcurrency: 10) ← Worker 内部串行调 MinerU + LLM
> asl_extraction_aggregator (schedule: */2 * * * *) ← 定时收口 + 僵尸清理
> ```
> `teamConcurrency: 10` 即为 MinerU + LLM 的隐式总并发上限,无需额外子队列。
### 4.5 Postgres-Only 安全规范速查
| 规范 | 要求 | v2.0 散装模式实现 |
|------|------|-----------|
| **API 幂等** | 防止重复创建任务 | `idempotencyKey @unique` + P2002 冲突捕获 |
| **Worker 幂等** | 容忍 pg-boss 重投 | `updateMany({ where: { status: 'pending' } })` 幽灵守卫 |
| **Worker 独立** | Worker 绝不碰 Task 表 | 只写自己的 Result 行Aggregator 负责收口 |
| **Payload 轻量** | Job data 不超过数 KB | 仅传 `{ resultId, taskId, pkbDocumentId }`< 200 bytes |
| **过期时间** | 必须设置 `expireInMinutes` | `expireInMinutes: 30`(执行超时,非排队等待超时) |
| **错误分级** | 区分"可重试"和"永久失败" | 临时错误 → 回退 pending + throw致命错误 → 标 error + return |
| **死信处理** | 超过 retryLimit 的 Job | Aggregator 兼职 Sweeper`extracting` 超 30min → error |
| **进度查询** | 不在 Task 存冗余计数 | 前端 `groupBy` 实时聚合 Result 状态 |
### 4.6 ExtractionAggregator — 轮询收口 + 僵尸清理
> 🚀 v2.0**Aggregator 兼职 Sweeper一个组件两个职责。**
> 取代旧版独立的 `asl_extraction_sweeper` + Fan-out `Last Child Wins` 收口逻辑。
```typescript
// ExtractionAggregator.ts — pg-boss schedule 每 2 分钟执行一次
async function aggregatorHandler() {
const tasks = await prisma.aslExtractionTask.findMany({
where: { status: 'processing' },
});
for (const task of tasks) {
// ═══════════════════════════════════════════════════════
// 职责 1僵尸清理Sweeper 兼职)
// extracting 超过 30 分钟 → 大概率 Worker 进程崩溃OOM/SIGKILL
// ═══════════════════════════════════════════════════════
await prisma.aslExtractionResult.updateMany({
where: {
taskId: task.id,
status: 'extracting',
updatedAt: { lt: new Date(Date.now() - 30 * 60 * 1000) },
},
data: { status: 'error', errorMessage: '[Aggregator] Timeout, likely worker crash.' },
});
// ═══════════════════════════════════════════════════════
// 职责 2聚合统计 — groupBy 一次查询
// ═══════════════════════════════════════════════════════
const stats = await prisma.aslExtractionResult.groupBy({
by: ['status'],
where: { taskId: task.id },
_count: true,
});
const pending = stats.find(s => s.status === 'pending')?._count ?? 0;
const extracting = stats.find(s => s.status === 'extracting')?._count ?? 0;
// ═══════════════════════════════════════════════════════
// 职责 3收口 — pending 和 extracting 都清零即完成
// ═══════════════════════════════════════════════════════
if (pending === 0 && extracting === 0) {
await prisma.aslExtractionTask.update({
where: { id: task.id },
data: { status: 'completed', completedAt: new Date() },
});
logger.info(`[Aggregator] Task ${task.id} completed`);
}
}
}
```
### 4.4 Worker 注册(三级限流 + 队列命名合规)
**Aggregator 与旧 Sweeper / Last Child Wins 的对比:**
```typescript
// ⚠️ v1.4.2 补丁 3队列名称全部使用下划线遵守《Postgres-Only 指南》§4.1 红线)
// 点号(.)在 pg-boss 底层解析中可能被识别为 Schema 分隔符,导致路由截断异常
jobQueue.work('asl_extraction_child', { teamConcurrency: 10 }, async (job) => {
// 全局最多 10 个文献同时在 Node.js 内存中处理
// 其余在 PostgreSQL 中排队(零内存占用)
await extractionChildWorker.handle(job);
});
// MinerU 子队列:全局仅允许 2 个并行(跨所有 Pod
jobQueue.work('asl_mineru_extract', { teamConcurrency: 2 }, async (job) => {
const { storageKey, kbId, docId } = job.data;
return await pdfPipeline.extractTables(storageKey, kbId, docId); // 含 OSS 缓存
});
// LLM 子队列:全局仅允许 5 个并行
jobQueue.work('asl_llm_extract', { teamConcurrency: 5 }, async (job) => {
const { resultId, taskId, prompt } = job.data;
return await llmGateway.call(prompt);
});
// Child Worker 内部调用方式(不再使用 P-Queue
class ExtractionChildWorker {
async extractWithMinerU(storageKey: string, kbId: string, docId: string) {
const jobId = await pgBoss.send('asl_mineru_extract', { storageKey, kbId, docId });
return await pgBoss.getJobResult(jobId);
}
}
```
> **三级限流架构:**
> ```
> asl_extraction_child (teamConcurrency: 10) ← 背压阀门,防 OOM
> └─ asl_mineru_extract (teamConcurrency: 2) ← 昂贵 API 保护
> └─ asl_llm_extract (teamConcurrency: 5) ← LLM 并发保护
> ```
> 全部基于 PostgreSQL 行锁实现全局并发控制,跨所有 Node.js 实例生效。
### 4.5 Postgres-Only 安全规范速查
| 规范 | 要求 | 本模块实现 |
|------|------|-----------|
| **幂等性** | Worker 必须容忍 pg-boss 重投at-least-once | ⚠️ v1.4.2 `updateMany({ where: { status: 'pending' } })` 乐观锁原子抢占 |
| **Payload 轻量** | Job data 不超过数 KB禁止塞 PDF 正文 | 仅传 `{ taskId, resultId, pkbDocumentId }`,不超过 200 bytes |
| **过期时间** | 必须设置 `expireInMinutes`,防止僵尸 Job | Manager: 60minChild: 30min |
| **错误分级** | 区分"可重试"和"永久失败" | 429/5xx → retrypg-boss 指数退避4xx/解析错误 → 标记 error不 retry |
| **死信处理** | 超过 retryLimit 的 Job 进入 DLQ | pg-boss 内置 `onFail` handler 标记该篇为 `error` |
| **进度追踪** | 不在 Job data 中存大量进度 | 进度统一走 `CheckpointService`Job data 仅含 ID 引用 |
### 🆕 4.6 Sweeper 清道夫 — 进程硬崩溃兜底v1.6
> **Fan-out 指南 v1.2 强制要求:** 单兵 Worker 无法处理自身猝死OOM/SIGKILL
> 必须有系统级外部定时任务兜底。否则父任务可能永远卡在 `processing`。
```typescript
// ===== 工具 3 专属清道夫(模块启动时注册) =====
async function aslExtractionSweeper() {
const stuckTasks = await prisma.aslExtractionTask.findMany({
where: {
status: 'processing',
// 🚨 使用 updatedAt最后活跃时间而非 startedAt
// 500 篇文献正常排队可能需要 3+ 小时,用 startedAt 会误杀健康任务。
// 只要 Child 还在完成并递增计数updatedAt 就会持续刷新。
updatedAt: { lt: new Date(Date.now() - 2 * 60 * 60 * 1000) },
},
});
for (const task of stuckTasks) {
await prisma.aslExtractionTask.update({
where: { id: task.id },
data: {
status: 'failed',
errorMessage: '[Sweeper] No progress for 2h — likely Child Worker OOM/SIGKILL. Force-closed.',
completedAt: new Date(),
},
});
// 广播失败事件,确保前端 SSE 能感知
await broadcastLog(task.id, {
source: 'system',
type: 'complete',
message: '❌ [Sweeper] Task force-closed after 2h inactivity.',
});
logger.warn(`[Sweeper] Force-closed stuck task ${task.id} (no progress for 2h)`);
}
}
// 注册为 pg-boss 定时任务(每 10 分钟扫描一次)
await jobQueue.schedule('asl_extraction_sweeper', '*/10 * * * *');
await jobQueue.work('asl_extraction_sweeper', aslExtractionSweeper);
```
> **关键:** Sweeper 判断"卡死"基于 `updatedAt` 而非 `startedAt`,避免误杀正在排队的超大批量任务。
| 旧方案(已废弃) | 新方案 Aggregator |
|---|---|
| Last Child Wins 收口 + 独立 Sweeper 清道夫 | Aggregator 一人兼两职 |
| Worker 必须 `$transaction` 递增 Task 计数 | Worker 只写 Result零行锁争用 |
| Sweeper 用 `updatedAt > 2h` 判断卡死 | Aggregator 用 `extracting > 30min` 清僵尸 |
| 两个独立组件需分别注册 | 一个 `pg-boss schedule` 搞定 |
---
@@ -658,34 +624,38 @@ function useExtractionLogs(taskId: string) {
```typescript
// Step 2 页面组件:双轨制组合
function ExtractionProgress({ taskId }: { taskId: string }) {
const { data: task } = useTaskStatus(taskId); // 主驱动:轮询
const { logs } = useExtractionLogs(taskId); // 增强SSE 日志
// 进度条由 React Query 驱动(稳健)
const percent = task ? Math.round((task.successCount + task.failedCount) / task.totalCount * 100) : 0;
// 完成检测由 React Query 驱动(不依赖 SSE complete 事件)
// 🚀 v2.0:进度 API 返回 groupBy 聚合结果(无 successCount/failedCount 冗余字段)
const { data: progress } = useTaskStatus(taskId); // 主驱动:轮询
const { logs } = useExtractionLogs(taskId); // 增强SSE 日志
const processed = (progress?.completedCount ?? 0) + (progress?.errorCount ?? 0);
const percent = progress ? Math.round(processed / progress.totalCount * 100) : 0;
useEffect(() => {
if (task?.status === 'completed' || task?.status === 'failed') {
if (progress?.status === 'completed' || progress?.status === 'failed') {
navigate(`/asl/extraction/workbench/${taskId}`);
}
}, [task?.status]);
}, [progress?.status]);
return (
<>
<Progress percent={percent} />
<ProcessingTerminal logs={logs} /> {/* SSE 驱动,纯视觉 */}
<ProcessingTerminal logs={logs} />
</>
);
}
```
> **双轨制分工:** React Query 轮询驱动进度条和步骤跳转稳健可靠SSE 仅灌日志流给 ProcessingTerminal视觉增强断开无影响
> **v2.0 变化:** 进度 API 返回 `groupBy` 聚合的 `completedCount` / `errorCount` / `extractingCount` / `pendingCount`,不再依赖 Task 表冗余计数字段。
### 7.6 SSE 跨 Pod 广播 — PostgreSQL NOTIFY/LISTENv1.5M2 实施)
### 7.6 [P2 可选] SSE 跨 Pod 广播 — PostgreSQL NOTIFY/LISTEN
> ⚠️ **v2.0 降级说明:** 散装架构下,进度条和步骤跳转完全由 React Query 轮询驱动§7.5
> SSE 日志流仅为视觉增强。跨 Pod 广播为 **P2 可选增强**M1/M2 前期可用本 Pod 内存事件替代。
>
> **物理限制:** `sseEmitter.emit()` 基于内存 EventEmitter用户连 Pod A、Worker 跑 Pod B → Pod A 零日志。
> 使用 PostgreSQL `NOTIFY/LISTEN` 实现 Postgres-Only 合规的跨实例广播(不引入 Redis
> 如需实现跨 Pod 日志广播,可使用以下 PostgreSQL `NOTIFY/LISTEN` 方案
```typescript
// ===== Worker 发送端ExtractionChildWorker 内部) =====
@@ -757,8 +727,8 @@ class SseNotifyBridge {
- NOTIFY payload 物理上限 **~8000 bytes** → 发送前必须截断至 **7000 bytes**v1.6 强制规范)
- **禁止 `$executeRawUnsafe` + 字符串拼接!** 必须使用 `$executeRaw` Tagged Template + `pg_notify()`v1.6 强制规范)
- LISTEN 连接必须**独立于 Prisma 连接池**PgClient 单独创建)
- NOTIFY 是 fire-and-forget无持久化完美匹配 v1.4 双轨制定位
- `complete` 事件仍走 NOTIFY 广播,确保"Last Child Wins"翻转状态后所有 Pod 的 SSE 客户端都能收到
- NOTIFY 是 fire-and-forget无持久化完美匹配双轨制定位SSE 断开不影响业务)
- v2.0 下 `complete` 事件由前端轮询到 `status === 'completed'` 触发,不再强依赖 SSE 广播
---