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

@@ -3,9 +3,10 @@
> **文档版本:** v6.2
> **创建日期:** 2025-11-28
> **维护者:** 开发团队
> **最后更新:** 2026-02-23
> **最后更新:** 2026-02-24
> **🎉 重大里程碑:**
> - **🆕 2026-02-23ASL 工具 3 全文智能提取工作台 V2.0 开发计划完成!** Fan-out 架构 + HITL + 动态模板 + 13 条研发红线 + 分布式 Fan-out 开发指南沉淀
> - **🆕 2026-02-24ASL 工具 3 V2.0 架构升级至散装派发 + Aggregator** 9 条研发红线 + 散装派发与轮询收口任务模式指南 v1.1 沉淀
> - **2026-02-23ASL 工具 3 V2.0 开发计划完成!** HITL + 动态模板 + M1/M2/M3 三阶段 22 天
> - **🆕 2026-02-23ASL Deep Research V2.0 核心功能完成!** SSE 实时流 + 段落化思考 + 瀑布流 UI + Markdown 渲染 + 引用链接可见 + Word 导出 + 中文数据源
> - **🆕 2026-02-22SSA Phase I-IV 开发完成!** Session 黑板 + 对话层 LLM + 方法咨询 + 对话驱动分析E2E 107/107 通过
> - **2026-02-21SSA QPER 智能化主线闭环完成!** Q→P→E→R 四层架构全部开发完成,端到端 40/40 测试通过
@@ -27,9 +28,10 @@
> - **2026-01-24Protocol Agent 框架完成!** 可复用Agent框架+5阶段对话流程
> - **2026-01-22OSS 存储集成完成!** 阿里云 OSS 正式接入平台基础层
>
> **🆕 最新进展ASL 工具 3 计划完成 + V2.0 核心完成 2026-02-23**
> - 📋 **🆕 ASL 工具 3 全文智能提取工作台 V2.0 开发计划完成** — Fan-out + HITL + 动态模板v1.5 定稿6 轮架构审查13 条研发红线M1/M2/M3 三阶段 22 天)
> - 📋 **🆕 分布式 Fan-out 任务模式开发指南** — 基于 ASL 工具 3 经验沉淀7 项关键模式 + 10 项反模式 + 11 项 Code Review 检查清单
> **🆕 最新进展ASL 工具 3 架构升级 + V2.0 核心完成 2026-02-24**
> - 📋 **🆕 ASL 工具 3 V2.0 架构升级至散装派发 + Aggregator** — 废弃 Fan-out采用散装模式v2.0 定稿9 条研发红线M1/M2/M3 三阶段 22 天)
> - 📋 **🆕 散装派发与轮询收口任务模式指南 v1.1** — Postgres-Only 合规的 Level 2 批量任务处理模式API 散装 → N Worker → Aggregator 收口)
> - 📋 **分布式 Fan-out 任务模式开发指南** — Level 3 参考8 模式 + 18 反模式,已不用于 ASL 工具 3
> - ✅ **🎉 ASL Deep Research V2.0 核心功能完成** — SSE 流式架构 + 瀑布流 UI + HITL + 5 精选数据源 + Word 导出
> - ✅ **SSE 流式替代轮询** — 实时推送 AI 思考过程reasoning_content段落化日志聚合
> - ✅ **Markdown 渲染 + 引用链接可见化** — react-markdown 正确渲染报告,`[6]` 后显示完整 URL
@@ -74,7 +76,7 @@
|---------|---------|---------|---------|---------|--------|
| **AIA** | AI智能问答 | 12个智能体 + Protocol Agent全流程方案 | ⭐⭐⭐⭐⭐ | 🎉 **V3.1 MVP完整交付90%** - 一键生成+Word导出 | **P0** |
| **PKB** | 个人知识库 | RAG问答、私人文献库 | ⭐⭐⭐ | 🎉 **Dify已替换自研RAG上线95%** | P1 |
| **ASL** | AI智能文献 | 文献筛选、Deep Research、全文智能提取 | ⭐⭐⭐⭐⭐ | 🎉 **V2.0 核心完成80%+ 🆕工具3开发计划v1.5就绪** - SSE流式+瀑布流UI+HITL+Word导出+Fan-out架构+动态模板 | **P0** |
| **ASL** | AI智能文献 | 文献筛选、Deep Research、全文智能提取 | ⭐⭐⭐⭐⭐ | 🎉 **V2.0 核心完成80%+ 🆕工具3计划v2.0就绪** - SSE流式+瀑布流UI+HITL+Word导出+散装派发+Aggregator+动态模板 | **P0** |
| **DC** | 数据清洗整理 | ETL + 医学NER百万行级数据 | ⭐⭐⭐⭐⭐ | ✅ **Tool B完成 + Tool C 99%(异步架构+性能优化-99%+多指标转换+7大功能** | **P0** |
| **IIT** | IIT Manager Agent | AI驱动IIT研究助手 - 双脑架构+REDCap集成 | ⭐⭐⭐⭐⭐ | 🎉 **事件级质控V3.1完成设计100%代码60%** | **P0** |
| **SSA** | 智能统计分析 | **QPER架构** + 四层七工具 + 对话层LLM + 意图路由器 | ⭐⭐⭐⭐⭐ | 🎉 **Phase I-IV 开发完成** — QPER闭环 + Session黑板 + 意图路由 + 对话LLM + 方法咨询 + 对话驱动分析E2E 107/107 | **P1** |
@@ -103,6 +105,10 @@
│ • QueryRewriter (DeepSeek V3 查询理解) │
│ 🆕 R统计引擎ssa-r-statistics:1.0.1 Docker ✅ │
│ • plumber API | 统计护栏 | 预签名URL | 非特权用户 │
│ 🆕 异步任务模式指南体系 ✅ │
│ • Postgres-Only 异步任务处理指南Level 1 基础) │
│ • 散装派发+轮询收口指南 v1.1Level 2 批量任务)🆕│
│ • 分布式 Fan-out 指南 v1.2Level 3 高级参考) │
│ 前端Chat组件V2Ant Design X🆕 ✅ │
│ AIStreamChat | ThinkingBlock | useAIStream Hook │
└─────────────────────────────────────────────────────────┘

View File

@@ -31,6 +31,7 @@
| **日志服务** | `common/logging/` | ✅ | 结构化日志Pino |
| **缓存服务** | `common/cache/` | ✅ | 缓存抽象层(内存/Redis/Postgres |
| **异步任务** | `common/jobs/` | ✅ | 队列服务Memory/PgBoss |
| **🆕批量任务模式** | 设计模式指南 | ✅ | 散装派发 + 轮询收口Level 2 |
| **LLM网关** | `common/llm/` | ✅ | 统一LLM适配器5个模型 |
| **流式响应** | `common/streaming/` | ✅ 🆕 | OpenAI Compatible流式输出 |
| **🎉RAG引擎** | `common/rag/` | ✅ 🆕 | **完整实现pgvector+DeepSeek+Rerank** |

View File

@@ -721,6 +721,7 @@ this.boss.on('error', (err) => {
| **DC Tool C** | `parseExcelWorker.ts` | `useSessionStatus.ts` | 本指南基础 |
| **ASL 智能文献** | `screeningWorker.ts` | `useScreeningTask.ts` | [ASL模块状态](../03-业务模块/ASL-AI智能文献/00-模块当前状态与开发指南.md) |
| **DC Tool B** | `extractionWorker.ts` | - | [DC模块状态](../03-业务模块/DC-数据清洗整理/00-模块当前状态与开发指南.md) |
| **🆕ASL 工具 3** | 散装派发 + Aggregator | `useTaskStatus.ts` | [散装派发与轮询收口指南](./散装派发与轮询收口任务模式指南.md)Level 2 批量模式) |
---

View File

@@ -0,0 +1,384 @@
# 散装派发与轮询收口任务模式指南
> **版本:** v1.1优化DB 级幂等 + groupBy 进度查询 + expireInMinutes 注释)
> **创建日期:** 2026-02-24
> **定位:** Level 2 批量任务实战 Cookbook1 触发 → N 个独立 Worker → Aggregator 收口)
> **底层规范:** `Postgres-Only异步任务处理指南.md`Level 1队列命名、Payload、过期时间等强制规范
> **首个试点:** ASL 工具 3 全文智能提取工作台
> **替代方案:** `分布式Fan-out任务模式开发指南.md`Level 3适用于万级任务 + 10+ Pod当前阶段不推荐
---
## 一、适用场景判断
| 维度 | Level 1单体任务 | **Level 2散装派发本文** | Level 3Fan-out |
|------|-------------------|---------------------------|-----------------|
| **触发模式** | 1 触发 → 1 Worker → 结束 | 1 触发 → N 个独立 Worker → Aggregator 收口 | 1 触发 → Manager → N Child → Last Child Wins |
| **典型案例** | DC Tool C 解析 1 个 Excel | **ASL 工具 3 批量提取 100 篇文献** | 万级任务 + 严格实时计数 |
| **复杂度** | ⭐ | ⭐⭐ | ⭐⭐⭐⭐⭐ |
| **团队要求** | 初级 | 初中级 | 高级(需掌握 12+ 并发模式) |
**判断公式:** 如果你的任务是"1 次操作处理 N 个独立子项N 可能 > 10且团队 < 10 人",选本文的散装模式。
---
## 二、核心架构:散装派发 + 独立单兵 + 轮询收口
```
┌─ API 层 ──────────────────────────────────────┐
│ POST /tasks │
│ 1. 创建 1 个 Taskstatus: processing
│ 2. 批量创建 N 个 Resultstatus: pending
│ 3. 散装派发 N 个 pgBoss Job一次 insert
│ 4. 立即返回 taskId< 0.1 秒) │
└────────────────────────────────────────────────┘
↓ (N 个独立 Job)
┌─ Worker 层(多 Pod 各自抢单)─────────────────┐
│ 每个 Worker 只管自己的 1 篇文献: │
│ 1. 幽灵重试守卫updateMany 防覆盖已完成) │
│ 2. 执行业务逻辑MinerU + LLM
│ 3. 只更新自己的 Result 行 │
│ 4. 绝不触碰父任务 Task 表! │
│ 5. 错误分级:致命 return / 临时回退 + throw │
└────────────────────────────────────────────────┘
┌─ Aggregator全局唯一每 10 秒)─────────────┐
│ 1. 清理僵尸extracting > 30min → error │
│ 2. 聚合统计GROUP BY Result.status │
│ 3. 收口判定pending=0 且 extracting=0 │
│ → 标记 Task completed │
└────────────────────────────────────────────────┘
┌─ 前端React Query 轮询)─────────────────────┐
│ GET /tasks/:taskId/status │
│ → 实时 COUNT(Result) 聚合进度 │
│ → 无需 successCount/failedCount 冗余字段 │
└────────────────────────────────────────────────┘
```
### 为什么不用 Fan-out
| Fan-out 的代价 | 散装模式如何规避 |
|---------------|----------------|
| Last Child Wins终止器逻辑 | **Aggregator 轮询收口**,零并发竞争 |
| 原子递增 `successCount`(行锁争用) | **Worker 不碰父表**,进度由 COUNT 聚合 |
| 乐观锁与重试的互相绞杀 | **幽灵重试守卫 + Aggregator 僵尸清理**,两层配合 |
| Sweeper 清道夫 | **Aggregator 自带清理**,一个组件两个职责 |
| NOTIFY/LISTEN 跨 Pod 广播 | **纯轮询**,无需跨 Pod 通信 |
| 12+ 设计模式、19 项检查清单 | **4 个核心模式 + 8 项检查清单** |
---
## 三、数据库设计
```prisma
model AslExtractionTask {
id String @id @default(uuid())
projectId String
templateId String
idempotencyKey String? @unique // v1.1DB 级幂等(防并发重复创建)
totalCount Int // 创建后不再变更
status String @default("processing") // 仅 Aggregator 改为 completed
createdAt DateTime @default(now())
completedAt DateTime?
results AslExtractionResult[]
@@schema("asl_schema")
}
model AslExtractionResult {
id String @id @default(uuid())
taskId String
pkbDocumentId String
status String @default("pending") // pending → extracting → completed/error
extractedData Json?
errorMessage String?
task AslExtractionTask @relation(fields: [taskId], references: [id])
@@index([taskId, status]) // Aggregator 聚合查询加速
@@schema("asl_schema")
}
```
**关键设计Task 表无 `successCount`/`failedCount`。** 进度由 `COUNT(Result WHERE status=...)` 实时聚合,彻底消灭多 Worker 对同一行的写竞争。
---
## 四、4 项核心代码模式
### 模式 1API 层散装派发
```typescript
async function createTask(req, reply) {
const { projectId, templateId, documentIds, idempotencyKey } = req.body;
if (documentIds.length === 0) throw new Error('No documents selected');
// ═══════════════════════════════════════════════════════
// v1.1 DB 级幂等:利用 @unique 索引 + Prisma P2002 冲突捕获。
// 比 findFirst → if exists 的 Read-then-Write 安全 100 倍:
// 即使两个请求在同一毫秒到达,数据库唯一约束也会物理拦截第二个。
// ═══════════════════════════════════════════════════════
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' });
}
throw error;
}
const resultsData = documentIds.map(docId => ({
taskId: task.id, pkbDocumentId: docId, status: 'pending',
}));
await prisma.aslExtractionResult.createMany({ data: resultsData });
const createdResults = await prisma.aslExtractionResult.findMany({ where: { taskId: task.id } });
// 散装派发N 个独立 Job 一次入队
const jobs = createdResults.map(result => ({
name: 'asl_extract_single',
data: { resultId: result.id, pkbDocumentId: result.pkbDocumentId },
options: {
retryLimit: 3,
retryBackoff: true,
expireInMinutes: 30, // 执行超时Worker 拿到 Job 后 30min 未完成则标记 expired非排队等待超时
singletonKey: `extract-${result.id}`, // 防重复派发
},
}));
await jobQueue.insert(jobs);
return reply.send({ success: true, taskId: task.id });
}
```
**要点:**
- `singletonKey: extract-${result.id}` 防止 API 重试导致重复派发
- `idempotencyKey` + `@unique` 索引 + P2002 捕获 = **DB 物理级幂等**v1.1,替代 Read-then-Write
- `createMany` + `insert` 批量操作100 篇文献入队 < 0.1 秒
### 模式 2Worker 单兵作战(含幽灵重试守卫)
```typescript
jobQueue.work('asl_extract_single', { teamConcurrency: 10 }, async (job) => {
const { resultId, pkbDocumentId } = job.data;
// ═══════════════════════════════════════════════════════
// 幽灵重试守卫:只允许 pending → extracting。
// 如果已经是 completed/error说明是 pg-boss 误重投的幽灵,
// 直接跳过,省下一次 LLM API 费用。
// ⚠️ 此守卫依赖模式 3 Aggregator 的僵尸清理配合!
// 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 {
const data = await extractLogic(pkbDocumentId);
// 只更新自己的 Result 行,绝不碰 Task 表!
await prisma.aslExtractionResult.update({
where: { id: resultId },
data: { status: 'completed', extractedData: data },
});
} catch (error) {
if (isPermanentError(error)) {
await prisma.aslExtractionResult.update({
where: { id: resultId },
data: { status: 'error', errorMessage: error.message },
});
return { success: false, note: 'Permanent error' };
}
// 临时错误:回退状态为 pending让下次重试能通过幽灵守卫
await prisma.aslExtractionResult.update({
where: { id: resultId },
data: { status: 'pending' },
});
throw error; // pg-boss 指数退避重试
}
});
```
**要点:**
- Worker **只写自己的 Result 行**,零行锁争用
- 临时错误 throw 前回退 `status → pending`,确保重试时幽灵守卫不拦截
- `teamConcurrency: 10` 全局限流,防 OOM
### 模式 3Aggregator 轮询收口(含僵尸清理)
```typescript
// 注册为 pg-boss 定时任务(多 Pod 下只有 1 个实例执行)
await jobQueue.schedule('asl_extraction_aggregator', '*/10 * * * * *'); // 每 10 秒
jobQueue.work('asl_extraction_aggregator', async () => {
const activeTasks = await prisma.aslExtractionTask.findMany({
where: { status: 'processing' },
});
for (const task of activeTasks) {
// ═══════════════════════════════════════════════════════
// 僵尸清理extracting 超过 30 分钟 → 标记 error
// 场景Worker OOM 崩溃 → status 卡在 extracting
// → pg-boss 重试时被幽灵守卫跳过
// → 必须由 Aggregator 兜底收口
// ⚠️ 此逻辑与模式 2 的幽灵守卫是绑定关系,缺一不可!
// ═══════════════════════════════════════════════════════
await prisma.aslExtractionResult.updateMany({
where: {
taskId: task.id,
status: 'extracting',
updatedAt: { lt: new Date(Date.now() - 30 * 60 * 1000) },
},
data: {
status: 'error',
errorMessage: '[Aggregator] Extraction timeout (30min) — likely Worker crash.',
},
});
// 聚合统计有索引100 条 < 1ms
const stats = await prisma.aslExtractionResult.groupBy({
by: ['status'],
where: { taskId: task.id },
_count: true,
});
const pendingCount = stats.find(s => s.status === 'pending')?._count || 0;
const extractingCount = stats.find(s => s.status === 'extracting')?._count || 0;
// 收口:没有人在排队或干活了
if (pendingCount === 0 && extractingCount === 0) {
await prisma.aslExtractionTask.update({
where: { id: task.id },
data: { status: 'completed', completedAt: new Date() },
});
logger.info(`[Aggregator] Task ${task.id} completed`);
}
}
});
```
**要点:**
- **一个组件两个职责**:僵尸清理 + 完成收口,无需额外 Sweeper
- `groupBy` + 索引 `@@index([taskId, status])`,聚合极快
- pg-boss `schedule` 保证多 Pod 下只有 1 个实例执行,无并发问题
### 模式 4前端进度查询实时 COUNT
```typescript
async function getTaskStatus(req, reply) {
const { taskId } = req.params;
const task = await prisma.aslExtractionTask.findUnique({ where: { id: taskId } });
// v1.11x groupBy 替代 2x countDB 只扫描一次索引,查询量减半
const stats = await prisma.aslExtractionResult.groupBy({
by: ['status'],
where: { taskId },
_count: true,
});
const successCount = stats.find(s => s.status === 'completed')?._count || 0;
const failedCount = stats.find(s => s.status === 'error')?._count || 0;
return reply.send({
status: task.status,
progress: {
total: task.totalCount,
success: successCount,
failed: failedCount,
percent: Math.round(((successCount + failedCount) / task.totalCount) * 100),
},
});
}
```
---
## 五、反模式速查表
| 反模式 | 后果 | 正确做法 |
|--------|------|---------|
| Worker 更新父 Task 的 successCount | 行锁争用Lock timeout | Worker 只写自己的 Result进度由 COUNT 聚合 |
| Worker 无幽灵守卫,无条件 `extracting` | pg-boss 误重投时覆盖已完成数据,浪费 LLM 费用 | `updateMany({ where: { status: 'pending' } })` |
| 临时错误 throw 前不回退 pending | 重试被幽灵守卫拦截Aggregator 30min 后才能兜底 | throw 前 `update({ status: 'pending' })` |
| Aggregator 不清理僵尸 extracting | OOM 崩溃后 Task 永远卡在 processing | 30min 超时 → error |
| API 派发不加 singletonKey | 网络重试导致重复 Job | `singletonKey: extract-${resultId}` |
| API 幂等用 Read-then-WritefindFirst → if exists | 并发请求穿透检查,创建重复 Task | `@unique` 索引 + P2002 捕获DB 物理级幂等)|
| 进度查询发 2 次 count | 索引扫描 2 次,查询量翻倍 | 1 次 `groupBy` 聚合所有状态 |
| 误解 `expireInMinutes` 为排队超时 | 队列繁忙时正常等待的 Job 被误杀 | `expireInMinutes` = 执行超时,与排队等待无关 |
| Aggregator 用 `startedAt` 判断超时 | 误杀正在排队的大批量任务 | 用 `updatedAt`(最后活跃时间) |
| 不设 `teamConcurrency` | 1000 个 Job 同时拉起 → OOM | `teamConcurrency: 10` |
---
## 六、开发检查清单
- [ ] **Worker 独立性**Worker 是否只更新自己的 Result 行?是否完全不碰 Task 表?
- [ ] **幽灵守卫**Worker 是否用 `updateMany({ where: { status: 'pending' } })` 防覆盖?
- [ ] **临时错误回退**throw 前是否回退 `status → pending`
- [ ] **Aggregator 僵尸清理**:是否清理 extracting > 30min 的 Result
- [ ] **Aggregator 收口**:是否在 `pending=0 && extracting=0` 时标记 Task completed
- [ ] **singletonKey**Job 派发是否设置了 `singletonKey: extract-${resultId}`
- [ ] **teamConcurrency**Worker 是否设置了全局并发限制?
- [ ] **索引**Result 表是否有 `@@index([taskId, status])` 加速聚合?
- [ ] **DB 级幂等**API 是否用 `@unique(idempotencyKey)` + P2002 捕获?(禁止 Read-then-Write
- [ ] **groupBy 进度查询**:前端进度接口是否用 1 次 `groupBy` 而非多次 `count`
- [ ] **expireInMinutes 注释**Job 配置中是否注明 `expireInMinutes` = 执行超时(非排队超时)?
---
## 七、与 Fan-out 模式的演进关系
```
Level 1: Postgres-Only 单体任务现有指南DC Tool C
↓ 当 N > 10
Level 2: 散装派发 + 轮询收口本文ASL 工具 3
↓ 当日处理 > 10,000 且 Pod > 10
Level 3: 分布式 Fan-out预留指南暂不启用
```
**升级条件:** 仅当同时满足以下条件时,才考虑升级到 Level 3
1. 单次批量 > 2000 篇,且 Aggregator 10 秒轮询延迟不可接受
2. 部署 Pod > 10 个,需要实时精确计数(而非 COUNT 聚合)
3. 团队有 2+ 名熟悉分布式并发的高级开发
> **设计哲学:** 用最简单的架构解决当前问题。所有 Fan-out 的知识沉淀都在 `分布式Fan-out任务模式开发指南.md` 中保存完好,等真正需要时再启用。
---
## 八、遵守底层规范
本文所有代码模式均遵守 `Postgres-Only异步任务处理指南.md` 的强制规范:
| 规范 | 本文实现 |
|------|---------|
| 队列名用下划线 | `asl_extract_single``asl_extraction_aggregator` |
| Payload 轻量 (< 1KB) | 仅传 `{ resultId, pkbDocumentId }` |
| Worker 必须幂等 | 幽灵守卫 + 临时错误回退 pending |
| 设置过期时间 | `expireInMinutes: 30`(执行超时,非排队超时) |
| 错误分级 | 永久 return / 临时 throw |
| API 幂等 | `@unique(idempotencyKey)` + P2002 捕获DB 物理级) |
---
*本文档基于 ASL 工具 3 全文智能提取工作台的架构选型经验总结。*
*散装派发模式是 Fan-out 模式的降维替代方案,以 80% 的复杂度降低换取 95% 的功能覆盖。*
*待实战验证后升级为 v2.0。*
---
### 版本记录
| 版本 | 日期 | 变更内容 |
|------|------|---------|
| v1.0 | 2026-02-24 | 初版4 项核心模式 + 8 项检查清单 |
| v1.1 | 2026-02-24 | 优化 1API 幂等升级为 `@unique` + P2002替代 Read-then-Write优化 2进度查询 2x count → 1x groupBy优化 3`expireInMinutes` 加注释说明含义;反模式 +2检查清单 +3 |

View File

@@ -3,9 +3,10 @@
> **文档版本:** v2.1
> **创建日期:** 2025-11-21
> **维护者:** AI智能文献开发团队
> **最后更新:** 2026-02-23 🆕 **工具 3 全文智能提取工作台 V2.0 开发计划完成v1.56 轮架构审查**
> **最后更新:** 2026-02-24 🆕 **工具 3 V2.0 开发计划升级至 v2.0(散装派发 + Aggregator 架构9 条研发红线**
> **重大进展:**
> - 🆕 2026-02-23:工具 3 V2.0 开发计划 v1.5 完成Fan-out 架构 + HITL + 动态模板 + 13 条研发红线 + 5 份文档体系
> - 🆕 2026-02-24:工具 3 V2.0 架构升级Fan-out → 散装派发 + Aggregator 轮询收口,通用模式指南 v1.1 沉淀
> - 2026-02-23工具 3 V2.0 开发计划 v1.5 完成6 轮架构审查 + 5 份文档体系
> - 🆕 2026-02-23V2.0 核心功能完成SSE 流式架构 + 段落化思考日志 + 引用链接可见化
> - 🆕 2026-02-22V2.0 前后端联调完成!瀑布流 UI + Markdown 渲染 + Word 导出 + 中文数据源测试
> - 🆕 2026-02-22V2.0 开发计划确认 + Unifuncs API 网站覆盖测试完成
@@ -32,7 +33,7 @@
AI智能文献模块是一个基于大语言模型LLM的文献筛选系统用于帮助研究人员根据PICOS标准自动筛选文献。
### 当前状态
- **开发阶段**:🎉 V2.0 Deep Research 核心功能完成 + 🆕 工具 3 开发计划就绪
- **开发阶段**:🎉 V2.0 Deep Research 核心功能完成 + 🆕 工具 3 开发计划 v2.0 就绪
- **已完成功能**
- ✅ 标题摘要初筛Title & Abstract Screening- 完整流程
- ✅ 全文复筛后端Day 2-5- LLM服务 + API + Excel导出
@@ -40,7 +41,7 @@ AI智能文献模块是一个基于大语言模型LLM的文献筛选系统
-**Unifuncs API 网站覆盖测试** - 18 站点实测9 个一级可用
-**🎉 Deep Research V2.0 核心功能** — SSE 流式架构 + 瀑布流 UI + HITL + Word 导出
- **开发计划就绪(待编码)**
- 📋 **🆕 工具 3 全文智能提取工作台 V2.0** — 开发计划 v1.5 完成(6 轮架构审查13 条研发红线M1/M2/M3 三阶段,预计 22 天)
- 📋 **🆕 工具 3 全文智能提取工作台 V2.0** — 开发计划 v2.0 完成(散装派发 + Aggregator 架构9 条研发红线M1/M2/M3 三阶段,预计 22 天)
- **V2.0 已完成**
-**SSE 流式架构**:从 create_task/query_task 轮询改为 OpenAI Compatible SSE 流,实时推送 AI 思考过程
-**LLM 需求扩写**DeepSeek-V3 将粗略输入扩写为结构化检索指令书PICOS + MeSH
@@ -127,48 +128,65 @@ frontend-v2/src/modules/asl/
**通用能力指南**`docs/02-通用能力层/04-DeepResearch引擎/01-Unifuncs DeepSearch API 使用指南.md`
### 🆕 工具 3 全文智能提取工作台 V2.02026-02-23 开发计划完成,待编码)
### 🆕 工具 3 全文智能提取工作台 V2.02026-02-24 开发计划 v2.0 完成,待编码)
**功能定位:** 批量读取 PDF 全文 → 动态模板驱动 AI 结构化提取 → 人工 HITL 审核 → Excel 导出。是 ASL 证据整合 V2.0 三大工具中最复杂的一个。
**开发计划状态:** ✅ v1.5 定稿(经 6 轮架构审查 + 多轮漏洞修复
**开发计划状态:** ✅ v2.0 定稿(经 8+ 轮架构审查 + 架构转型Fan-out → 散装派发 + Aggregator
**v2.0 架构转型2026-02-24**
| 维度 | v1.5 Fan-out已废弃 | v2.0 散装派发(当前) |
|------|---------|---------|
| 派发 | Manager Job → N × Child Job | API 层直接 `insert` N 个独立 Job |
| Worker | Child Worker 修改父 Task 表(行锁争用) | Worker 只写自己的 Result零行锁 |
| 收口 | Last Child Wins最后一个翻转 Task | Aggregator 定时 `groupBy` 轮询收口 |
| 僵尸清理 | 独立 Sweeper Cron | Aggregator 兼职(一人两职) |
| 复杂度 | 13 条红线、18 项反模式 | **9 条红线,极简 Worker** |
**核心架构决策:**
| 决策 | 方案 |
|------|------|
| 异步任务 | pg-boss Fan-outManager → N × Child非单体 Worker |
| 并发控制 | `teamConcurrency` 三级限流Child:10, MinerU:2, LLM:5 |
| 幂等 | Prisma `updateMany` 乐观锁(非 Read-then-Write |
| 任务终止 | Last Child Wins最后一个 Child 翻转父任务状态) |
| 异步任务 | **散装派发 + Aggregator 轮询收口**API 直接派发 N 个 `asl_extract_single` Job |
| 并发控制 | `teamConcurrency: 10` 扁平单队列Worker 内串行调 MinerU + LLM |
| API 幂等 | `idempotencyKey @unique` + Prisma P2002 冲突捕获 |
| Worker 幂等 | `updateMany({ where: { status: 'pending' } })` 幽灵重试守卫 |
| 任务收口 | **Aggregator** 定时轮询 `groupBy``pending === 0 && extracting === 0` → completed |
| 僵尸清理 | Aggregator 兼职(`extracting > 30min` → error |
| PDF 文件来源 | 对接 PKB 个人知识库ACL 防腐层,非自建上传) |
| 表格提取 | MinerU Cloud APIVLM 模型) + OSS Clean Data 缓存 |
| 全文提取 | 直接复用 PKB `extractedText`pymupdf4llm 产物) |
| SSE 跨 Pod | PostgreSQL NOTIFY/LISTEN不引入 Redis |
| 进度查询 | 前端 React Query 轮询 + 后端 `groupBy` 实时聚合 Result 状态 |
| SSE 日志 | 本 Pod 内存事件可选增强NOTIFY/LISTEN 跨 Pod |
| Prompt 安全 | BEGIN/END 隔离 + XML 标签上下文污染防护 |
| 数据一致性 | Manager 快照 PKB 元数据到 `AslExtractionResult` |
| 数据一致性 | API 层快照 PKB 元数据到 `AslExtractionResult` |
**文档体系5 份):**
**文档体系5 + 2 份):**
| 文档 | 说明 |
|------|------|
| `08-工具3-全文智能提取工作台V2.0开发计划.md` | 架构总纲v1.5~1314 行 |
| `08-工具3-全文智能提取工作台V2.0开发计划.md` | 架构总纲v2.0 |
| `08a-工具3-M1-骨架管线冲刺清单.md` | M1 SprintWeek 15-6 天) |
| `08b-工具3-M2-HITL工作台冲刺清单.md` | M2 SprintWeek 2-38-9 天) |
| `08c-工具3-M3-动态模板引擎冲刺清单.md` | M3 SprintWeek 45-6 天) |
| `08d-工具3-代码模式与技术规范.md` | 代码 Cookbook9 章~819 行 |
| `08d-工具3-代码模式与技术规范.md` | 代码 Cookbook9 章) |
| `散装派发与轮询收口任务模式指南.md` | 🚀 **v2.0 核心参考**(通用能力层 Level 2 |
| `分布式Fan-out任务模式开发指南.md` | 历史参考Level 3已不用于本模块 |
**里程碑规划:**
| 里程碑 | 核心交付 | 时间 |
|--------|---------|------|
| M1 骨架管线 | Fan-out 全链路 + PKB ACL + 纯文本盲提 + 极简前端 | Week 1 |
| M2 HITL 工作台 | MinerU + 审核抽屉 + SSE 日志 + NOTIFY/LISTEN + Excel | Week 2-3 |
| M1 骨架管线 | 散装派发 + Aggregator 全链路 + PKB ACL + 纯文本盲提 + 极简前端 | Week 1 |
| M2 HITL 工作台 | MinerU + 审核抽屉 + SSE 日志 + Excel | Week 2-3 |
| M3 动态模板引擎 | 自定义字段 + Prompt 注入防护 + E2E 测试 | Week 4 |
**13 条研发红线**:详见架构总纲文档尾注
**9 条研发红线**:详见架构总纲文档 M1 红线表
**通用能力沉淀**`docs/02-通用能力层/分布式Fan-out任务模式开发指南.md`
**通用能力沉淀**
- 🚀 `docs/02-通用能力层/散装派发与轮询收口任务模式指南.md`v1.1Level 2 Cookbook
- 📖 `docs/02-通用能力层/分布式Fan-out任务模式开发指南.md`v1.2Level 3 参考)
### 智能文献检索 DeepSearch V1.x2026-01-18 MVP完成

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 广播
---

View File

@@ -0,0 +1,145 @@
# **🔍 深度审查与断层修复报告:工具 3 全文提取 (V2.0 散装架构版)**
**审查人:** 资深架构师
**审查对象:** 08 总纲及 08a/08b/08c/08d 拆分计划文档
**审查基准:** 《散装派发与轮询收口任务模式指南 v1.0》
**总体评估:** 战略方向极其正确,但**文档间存在严重的自相矛盾**。旧版 Fan-out 的代码Manager、原子递增、独立清道夫未被彻底清除导致架构撕裂。必须立即修复 08a、08b 和 08d 文档。
## **🚨 致命断层 1Worker 仍在修改父任务(行锁地雷未除)**
### **❌ 发现的问题 (位于 08d 代码手册 §4.3)**
在 08 总纲中您明确写道“Worker 绝不碰 Task 表,进度由 Aggregator groupBy 聚合”。
但是!在 08d 的 ExtractionChildWorker 代码示例中,依然保留了旧版 Fan-out 的致命逻辑:
// 08d 错误残留代码:
const \[\_resultUpdate, taskAfterUpdate\] \= await prisma.$transaction(\[
prisma.aslExtractionResult.update(...),
prisma.aslExtractionTask.update({ ... data: { successCount: { increment: 1 } } }) // ❌ 严重违规!
\]);
// ...
if (taskAfterUpdate.successCount \+ ... \>= totalCount) { ... } // ❌ 严重违规 (Last Child Wins 残留)
**危害:** 如果开发照抄这段代码,散装架构最核心的优势“无锁并发”将彻底丧失,依然会引发 Lock wait timeout 和死锁!
### **✅ 修复指令 (修改 08d §4.3)**
彻底删除 $transaction、删除 increment删除 Last Child Wins 判定。Worker 的代码必须极简:
// 修正后的 08d §4.3:纯粹的散装 Worker
// ... 幽灵重试守卫保持不变 ...
const extractResult \= await this.extractionService.extractOne(resultId, taskId);
// ✅ 核心:只管更新自己的 Result绝不碰 Task
await prisma.aslExtractionResult.update({
where: { id: resultId },
data: { status: 'completed', extractedData: extractResult.data, processedAt: new Date() }
});
// ✅ SSE 广播 (可选)
await broadcastLog(taskId, { source: 'system', message: \`✅ ${extractResult.filename} extracted\` });
// 结束!没有任何收口逻辑!
## **🚨 致命断层 2Manager 的幽灵依然存在**
### **❌ 发现的问题 (位于 08a M1-3 和 08d §4.2)**
* 08 总纲明确声明“消灭的组件ExtractionManagerWorker”“API 层直接散装派发 N 个独立 Job”。
* 然而08a (M1-3) 依然安排了 1 天工期去开发 ExtractionManagerWorker.ts。
* 08d (§4.2) 依然详细保留了 Manager Worker 的完整代码。
**危害:** 架构撕裂。如果 API 层发了一个 Manager JobManager 又去发 Child Job这就退回到了极其笨重的 Fan-out 模式,且徒增了一次重试崩溃的风险。
### **✅ 修复指令**
1. **修改 08a M1-3**:划掉 ExtractionManagerWorker.ts 的开发任务。
2. **修改 08d §4.2**:将此节更名为 **§4.2 API 层散装派发 (ExtractionController)**,并替换为以下代码:
// 修正后的 08d §4.2API 直接派发
async function createTask(req, reply) {
// ... DB 幂等拦截 (idempotencyKey) ...
// 1\. 获取 PKB 快照元数据并冻结到 DB (移至 API 层执行)
const pkbDocs \= await pkbBridge.getDocumentsDetail(documentIds);
const resultsData \= pkbDocs.map(doc \=\> ({
taskId: task.id,
pkbDocumentId: doc.documentId,
snapshotStorageKey: doc.storageKey,
status: 'pending'
}));
await prisma.aslExtractionResult.createMany({ data: resultsData });
const createdResults \= await prisma.aslExtractionResult.findMany({ where: { taskId: task.id } });
// 2\. 🚀 极速散装派发
const jobs \= createdResults.map(result \=\> ({
name: 'asl\_extract\_single',
data: { resultId: result.id, taskId: task.id, pkbDocumentId: result.pkbDocumentId },
options: { singletonKey: \`extract-${result.id}\`, expireInMinutes: 30 }
}));
await jobQueue.insert(jobs); // 批量压入 pg-boss
return reply.send({ taskId: task.id });
}
## **🚨 致命断层 3Sweeper(清道夫) 与 Aggregator(收口器) 未合并**
### **❌ 发现的问题 (位于 08a M1-3 和 08d §4.6)**
在《散装模式指南》中你们团队提出了一个绝妙的优化“Aggregator 兼职 Sweeper一个组件两个职责”。
但在 08a 中,依然要求单独注册 asl\_extraction\_sweeper在 08d 中也保留了独立的 Sweeper 代码。
### **✅ 修复指令 (修改 08a 和 08d)**
1. **修改 08a**:删除 Step D (Sweeper 注册)。新增任务:“开发 ExtractionAggregator.ts实现僵尸清理与轮询收口”。
2. **修改 08d**:删除 §4.6 的独立 Sweeper。补充 ExtractionAggregator 的标准代码:
// 修正后的 08d §4.6ExtractionAggregator 轮询包工头
jobQueue.work('asl\_extraction\_aggregator', async () \=\> {
const tasks \= await prisma.aslExtractionTask.findMany({ where: { status: 'processing' } });
for (const task of tasks) {
// 1\. 顺手清理僵尸 (Sweeper 职责)
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\. 聚合统计
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\. 收口
if (pending \=== 0 && extracting \=== 0\) {
await prisma.aslExtractionTask.update({ where: { id: task.id }, data: { status: 'completed' } });
}
}
});
## **⚠️ 体验断层 4M2-3 对 NOTIFY/LISTEN 的执念**
### **❌ 发现的问题 (位于 08b M2-3)**
08 总纲明确将 NOTIFY/LISTEN 跨 Pod 广播降级为“已被 V2.0 纯轮询替代/可选增强”。但在 M2 冲刺清单08b M2-3依然将 SseNotifyBridge.ts 和独立 PgClient 的开发列为了“必须做的额外任务”。
### **✅ 修复指令 (修改 08b)**
为了保证 M2 的按时交付,请将 08b 中的 **"🆕 v1.5 额外任务SSE 跨 Pod 广播"** 直接**删除**或标注为 **\[P2 可选\]**。
在散装架构下,既然进度条和跳页全靠 React Query 轮询,终端日志由于没有严重的业务关联,前期完全可以用“本 Pod 内存事件”将就,没必要在 M2 阶段去死磕复杂的 PG 独立监听连接。
## **🏁 架构师总结建议**
这个断层非常典型。因为我们前期对 Fan-out 模式进行了多达 6 轮的极限推演和打磨那些代码乐观锁、原子递增、Manager已经深入人心导致在重写 V2.0 文档时,大家潜意识里舍不得删掉它们。
**请项目经理做最后一步:**
打开 08a、08b 和 08d。凡是带了 Manager、successCount: { increment: 1 }、Last Child Wins、asl\_extraction\_sweeper 字眼的代码和任务,**全部无情删掉**,替换为上述的散装代码。
删完之后,你们的系统代码量将减少 30%,但稳定性将提升 200%!恭喜团队,你们即将拥有一套极其优雅的底层流水线,放心开干吧!

View File

@@ -0,0 +1,111 @@
# **🔍 散装与轮询架构级代码审查与优化报告**
**审查人:** 资深架构师
**审查对象:** 《散装派发与轮询收口任务模式指南 v1.0》
**总体评估:** 🌟 **极优 (Excellent)**。极简、务实、高内聚低耦合。这是我目前见过的最适合中小规模技术团队处理大批量异步任务的架构模式。
**审查结论:** 核心逻辑 100% 成立。但在 API 防抖、轮询 QPS 优化及长队列超时上存在 3 个优化空间,建议在最终编码时微调。
## **🛡️ 优化点 1API 幂等层的“伪安全” (Read-then-Write 漏洞)**
### **❌ 审查发现(模式 1API 层散装派发)**
指南中在创建任务前,写了如下防抖逻辑:
if (idempotencyKey) {
const existing \= await prisma.aslExtractionTask.findFirst({
where: { projectId, idempotencyKey },
});
if (existing) return reply.send({ success: true, taskId: existing.id });
}
const task \= await prisma.aslExtractionTask.create({ ... });
**隐患推演:** 这依然是经典的 Read-then-Write。如果用户处于极端弱网环境或鼠标连击导致两个完全相同的 HTTP 请求在**同一毫秒**到达 Node.js两个进程会同时执行 findFirst同时发现为 null然后同时执行 create。结果依然是创建了两个重复的任务并派发了 200 个 Job。
### **✅ 架构师修正方案:利用数据库唯一索引 (P2002)**
真正的幂等必须下沉到数据库物理层。在 AslExtractionTask 表中为 idempotencyKey 添加 @unique 索引,然后利用 Prisma 捕获唯一键冲突异常:
// 优化后的 模式 1
let task;
try {
task \= await prisma.aslExtractionTask.create({
data: { projectId, templateId, totalCount: docs.length, status: 'processing', idempotencyKey },
});
} catch (error) {
// Prisma 唯一键冲突错误码 P2002
if (error.code \=== 'P2002') {
const existing \= await prisma.aslExtractionTask.findUnique({ where: { idempotencyKey } });
return reply.send({ success: true, taskId: existing.id, note: 'Idempotent return' });
}
throw error;
}
*这样无论并发多高,物理上绝对不可能重复派发!*
## **⚡ 优化点 2前端轮询的“双倍 QPS”损耗**
### **❌ 审查发现(模式 4前端进度查询**
为了计算进度,您的代码同时发起了两次 count 查询:
const \[successCount, failedCount\] \= await Promise.all(\[
prisma.aslExtractionResult.count({ where: { taskId, status: 'completed' } }),
prisma.aslExtractionResult.count({ where: { taskId, status: 'error' } }),
\]);
**隐患推演:** 既然前端是每 3 秒轮询一次,如果有 10 个医生同时在线看着进度条,数据库每 3 秒就要承受 10 \* 2 \= 20 次 count 查询。这虽然用上了索引,但其实可以压缩一半。
### **✅ 架构师修正方案:复用 Aggregator 的 GROUP BY 绝技**
就像您在 Aggregator 里做的那样,一次查出所有状态分布,数据库引擎扫描一次即可:
// 优化后的 模式 4
const stats \= await prisma.aslExtractionResult.groupBy({
by: \['status'\],
where: { taskId },
\_count: true
});
const successCount \= stats.find(s \=\> s.status \=== 'completed')?.\_count || 0;
const failedCount \= stats.find(s \=\> s.status \=== 'error')?.\_count || 0;
*查询请求量直接减半,极致榨取 DB 性能。*
## **⏳ 优化点 3pg-boss 排队超时陷阱 (expireInMinutes)**
### **❌ 审查发现(模式 1Job 派发设置)**
指南中配置了:
options: {
expireInMinutes: 30, // 👈 潜在陷阱
}
**隐患推演:** 如果用户发起了 1000 篇文献的提取,而 teamConcurrency: 10。这意味这 1000 篇文献必须排队慢慢跑。假设单篇耗时 1 分钟,整个队列跑完需要 100 分钟。
如果这里的 expireInMinutes 被 pg-boss 解释为“从入队到执行完毕的总寿命TTL那么排在后面的 700 篇文献,可能会在还在排队(状态 pending就被系统强制丢弃/过期!
### **✅ 架构师修正方案:区分排队时间与执行时间**
在 pg-boss 中:
* expireInSeconds / expireInMinutes: 是指一个 Job **被 Worker 抓取后(执行中)** 的最长允许耗时(防僵尸)。
* retentionDays: 才是它允许待在队列里的总寿命。
确保开发人员理解:这里的 30 分钟是\*\*“单篇文献提取时如果卡住,最多卡 30 分钟”\*\*,而不是这 100 篇文献必须在 30 分钟内跑完。只要配置正确,即使任务排队 5 个小时也是绝对安全的。
## **🏁 架构师最终结论**
**完美放行!(Approved for Production)**
这套指南里的:
1. **幽灵重试守卫**(巧妙利用 count \=== 0 截断)
2. **僵尸清理收口合一**Aggregator 顺手做清理)
充分证明了你们团队已经彻底吃透了分布式系统的状态流转精髓。
请直接将这份架构作为底座,**去尽情修改和重构工具 3 的开发计划吧!** 期待看到这份既轻量又极速的新版计划!

View File

@@ -1006,9 +1006,7 @@
.message-bubble .markdown-content strong {
font-weight: 700;
color: #1F2937;
}
.message-bubble .markdown-content em {
}.message-bubble .markdown-content em {
font-style: italic;
color: #4B5563;
}.message-bubble .markdown-content code {
@@ -1018,4 +1016,4 @@
border-radius: 4px;
font-family: 'Monaco', 'Consolas', 'Courier New', monospace;
font-size: 0.9em;
}
}