File size: 29,290 Bytes
00b2f48
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
# pdfsys-mnbvc · Roadmap

> 优化方案与实施计划 · v0.1 · 2026-04-17
>
> 本文档把 [`PRD.md`](./PRD.md) 描述的目标转化为**带优先级、带工作量、带验收标准**的可执行任务池。PRD 回答"我们要做什么",ROADMAP 回答"按什么顺序做、怎么做、做完怎么验证"。

---

## 0 · 摘要

**一句话**:设计文档与架构框架一流,工程基础设施缺失严重,6 个 stage 只落地了 1.5 个。

**冲刺计划**:以 2 周"可协作化"冲刺(P0)作为一切后续工作的前提,再用 4 周打磨性能与可靠性(P1),最后 10–16 周补齐 6-stage 闭环(P2)。P3 是 PB 级规模化与生态,作为长期背景项。

---

## 1 · 现状评分卡

| 维度 | 状态 | 评分 |
|---|---|---|
| 设计文档(PRD) | 441 行,取舍清晰 | 9/10 |
| 架构分包 | 7 个 workspace 包,边界合理 | 8/10 |
| 核心契约(`pdfsys-core`) | frozen dataclass + 零依赖 + 原子写 | 9/10 |
| MVP 闭环(Router→MuPDF→Scorer) | 跑通 OmniDocBench-100 | 7/10 |
| **测试** | **零测试文件,零 CI** | **0/10** |
| **依赖管理** | 无 lock 文件,依赖无上界 | 2/10 |
| **Observability** | 无 logging,无 metrics | 2/10 |
| 实现完成度 | 2180 行,4/7 包是 stub | 3/10 |
| Demo & 贡献者体验 | Gradio + Cursor rules 完善 | 8/10 |

**关键风险**:当前状态下 1 人可 hack 前进;**任何超过 3 人的协作会立刻失控**——没有测试保护 parity、没有 CI、没有 lock 文件,第一次依赖升级就会毒化路由器。

---

## 2 · 优化全景

```
┌──────────────────────────────────────────────────────────────────┐
│  P0  工程基础(2 周,阻塞一切后续)                                │
│  ├─ 1.1 测试框架 pytest + 关键单测                                 │
│  ├─ 1.2 代码质量 ruff + mypy + pre-commit                         │
│  ├─ 1.3 GitHub Actions CI                                         │
│  ├─ 1.4 uv.lock 入库 + 依赖上界                                    │
│  └─ 1.5 Parity harness(router 回归守门)                          │
├──────────────────────────────────────────────────────────────────┤
│  P1  性能与可靠性(4 周)                                          │
│  ├─ 2.1 Router 热路径优化(49 ms → 10 ms)                         │
│  ├─ 2.2 Quality scorer 批量推理                                    │
│  ├─ 2.3 structlog 日志系统                                         │
│  ├─ 2.4 Prometheus metrics 导出                                    │
│  └─ 2.5 错误分类 + quarantine 桶                                   │
├──────────────────────────────────────────────────────────────────┤
│  P2  功能补全(8-12 周,按 PRD roadmap)                           │
│  ├─ 3.1 Layout analyser(PP-DocLayoutV3 ONNX INT8)                │
│  ├─ 3.2 Pipeline parser(RapidOCR 简单版式)                       │
│  ├─ 3.3 Stage-B router(layout-cache 驱动)                        │
│  ├─ 3.4 VLM parser(MinerU 2.5 + LMDeploy)                        │
│  ├─ 3.5 Stage-3 后处理                                             │
│  ├─ 3.6 Stage-4 质量 / PII / MinHash 去重                          │
│  └─ 3.7 Stage-5 Parquet 打包                                       │
├──────────────────────────────────────────────────────────────────┤
│  P3  规模化与生态(3-6 个月)                                      │
│  ├─ 4.1 datatrove 编排集成                                         │
│  ├─ 4.2 Slurm / K8s runner                                         │
│  ├─ 4.3 对象存储后端(S3 / OSS / MinIO)                           │
│  ├─ 4.4 中文 EduScore 训练                                         │
│  └─ 4.5 竖排古籍 LoRA                                              │
└──────────────────────────────────────────────────────────────────┘
```

---

## 3 · P0 工程基础(Week 1-2)

### 3.1 测试框架 · pytest

**目标**:2 周内 `pdfsys-core` ≥ 90% / `pdfsys-router` ≥ 60% / `pdfsys-parser-mupdf` ≥ 60% 覆盖率。

**为什么优先**`.cursor/rules/01-architecture-invariants.mdc` 里 7 条不变式(BBox 归一化、frozen dataclass、原子写、schema 同构等)**全部可单测验证**。没有测试,"不要违反不变式"只是一句空话。

**交付物结构**```
tests/
├── conftest.py                         # 共享 fixtures
├── fixtures/pdfs/                      # 5-10 个跨类型 PDF(< 100 KB/file,入库)
├── unit/
│   ├── core/
│   │   ├── test_bbox.py               # BBox 边界、转换、非法值
│   │   ├── test_serde.py              # to_dict/from_dict roundtrip
│   │   ├── test_cache.py              # LayoutCache 原子写 + 崩溃恢复
│   │   └── test_types.py              # Backend / RegionType 枚举稳定性
│   ├── router/
│   │   ├── test_classifier_smoke.py   # classify() 不 raise 任何畸形输入
│   │   ├── test_feature_shape.py      # 输出必须 124 列,列名锁定
│   │   └── test_error_taxonomy.py     # encrypted/corrupt/empty 错误分类
│   ├── parser_mupdf/
│   │   ├── test_extract_basic.py      # 正常 PDF 段落抽取
│   │   ├── test_bbox_normalized.py    # 所有 bbox ∈ [0, 1]
│   │   └── test_corrupted_pdf.py      # 坏 PDF 不 crash
│   └── bench/
│       └── test_loop_never_raises.py  # 坏 PDF 进去,JSONL 行出来
├── contract/
│   ├── test_extracted_doc_schema.py   # 所有 parser 输出同构
│   └── test_cursor_rules_valid.py     # .mdc frontmatter 合法
└── integration/
    └── test_bench_smoke.py            # python -m pdfsys_bench --limit 3
```

**关键样例**```python
# tests/unit/core/test_bbox.py
import pytest
from pdfsys_core import BBox

class TestBBoxInvariants:
    @pytest.mark.parametrize("x0,y0,x1,y1", [
        (-0.1, 0, 0.5, 0.5),   # 负坐标
        (0, 0, 1.1, 0.5),      # 超过 1
        (0.5, 0, 0.3, 0.5),    # x1 < x0
        (0, 0, 0, 0),          # 零面积
    ])
    def test_rejects_invalid(self, x0, y0, x1, y1):
        with pytest.raises(ValueError):
            BBox(x0=x0, y0=y0, x1=x1, y1=y1)

    def test_to_pixels_roundtrip(self):
        box = BBox(0.1, 0.2, 0.9, 0.8)
        assert box.to_pixels(1000, 500) == (100, 100, 900, 400)
```

```python
# tests/unit/router/test_feature_shape.py
EXPECTED_COLUMNS = 124

def test_feature_vector_has_124_columns(sample_pdf):
    router = Router()
    decision = router.classify(sample_pdf)
    assert not decision.error
    assert len(decision.features) == EXPECTED_COLUMNS, (
        f"Feature vector drifted from 124 to {len(decision.features)}. "
        "If intentional, retrain XGBoost weights."
    )
```

**实施步骤**1. `uv add --group dev pytest pytest-cov pytest-xdist hypothesis`
2.`pyproject.toml``[tool.pytest.ini_options]``[tool.coverage.run]`
3. `conftest.py` 提供 `sample_pdf` / `encrypted_pdf` / `corrupted_pdf` fixture
4. 按上表顺序写测试(每天 1 个子目录)
5.`Makefile``scripts/test.sh``uv run pytest -n auto tests/`

**验收**:CI 跑通全部测试 < 2 分钟;三包覆盖率达标。

**工作量**:1 人 · 10 天

---

### 3.2 代码质量 · ruff + mypy + pre-commit

**目标**:零 ruff 错误、`pdfsys-core` 零 mypy 错误、commit 前自动拦截。

**根 `pyproject.toml` 新增**```toml
[tool.ruff]
target-version = "py311"
line-length = 100
src = ["packages/pdfsys-core/src", "packages/pdfsys-router/src",
       "packages/pdfsys-parser-mupdf/src", "packages/pdfsys-bench/src",
       "demo"]

[tool.ruff.lint]
select = ["E", "F", "W", "I", "B", "UP", "SIM", "PLC0415", "BLE001", "RET", "ARG"]
ignore = ["E501"]
per-file-ignores = { "packages/pdfsys-bench/**" = ["BLE001"] }

[tool.mypy]
python_version = "3.11"
strict = true
exclude = ["^packages/pdfsys-parser-(pipeline|vlm)/", "^packages/pdfsys-layout-analyser/"]

[[tool.mypy.overrides]]
module = ["pymupdf.*", "xgboost.*", "gradio.*"]
ignore_missing_imports = true
```

**`.pre-commit-config.yaml`**```yaml
repos:
  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.6.9
    hooks:
      - id: ruff
        args: [--fix]
      - id: ruff-format
  - repo: https://github.com/pre-commit/mirrors-mypy
    rev: v1.11.2
    hooks:
      - id: mypy
        files: ^packages/pdfsys-core/
  - repo: local
    hooks:
      - id: no-committed-weights
        name: Reject committed model weights
        entry: bash -c '! git diff --cached --name-only | grep -E "\.(ubj|safetensors|pt|bin)$"'
        language: system
        pass_filenames: false
      - id: validate-cursor-rules
        name: Validate .cursor/rules YAML frontmatter
        entry: python scripts/validate_rules.py
        language: system
        files: ^\.cursor/rules/.*\.mdc$
```

**实施步骤**1. `uv add --group dev ruff mypy pre-commit`
2. 写上面两个配置
3. `uv run ruff check --fix .` + `uv run ruff format .` 修现存问题
4. `uv run mypy packages/pdfsys-core` 直到零错
5. `pre-commit install` 追加到 `scripts/setup_cursor.sh`
6.`03-doc-sync.mdc` 里提到的 `scripts/validate_rules.py` 落地

**验收**`pre-commit run --all-files` 全绿。

**工作量**:1 人 · 3 天

---

### 3.3 GitHub Actions CI

**`.github/workflows/ci.yml`**```yaml
name: CI
on:
  pull_request:
  push:
    branches: [main]

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: astral-sh/setup-uv@v3
        with: { version: "0.4.x", enable-cache: true }
      - run: uv sync --frozen
      - run: uv run ruff check .
      - run: uv run ruff format --check .
      - run: uv run mypy packages/pdfsys-core

  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        python: ["3.11", "3.12"]
    steps:
      - uses: actions/checkout@v4
      - uses: astral-sh/setup-uv@v3
        with: { python-version: "${{ matrix.python }}" }
      - run: uv sync --frozen
      - run: uv run python -m pdfsys_router.download_weights
      - run: uv run pytest -n auto --cov --cov-report=xml tests/
      - uses: codecov/codecov-action@v4
        if: matrix.python == '3.11'

  parity:
    runs-on: ubuntu-latest
    if: contains(github.event.pull_request.changed_files, 'feature_extractor.py')
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 2 }
      - uses: astral-sh/setup-uv@v3
      - run: uv sync --frozen
      - run: uv run python -m pdfsys_router.download_weights
      - run: bash scripts/check_parity.sh origin/main HEAD
```

**实施步骤**1. 写上面 workflow
2. 可选:`.github/workflows/preview-hf-space.yml` PR 自动部署预览 Space
3. GitHub Settings → Branches 把 `main` 设为 protected、必须通过 CI

**验收**:PR 打开 3 分钟内看到 ✅ × 3。

**工作量**:1 人 · 1 天

---

### 3.4 uv.lock 入库 + 依赖上界

**当前痛点**- `.gitignore:14``uv.lock` 排除了(反模式,lock 文件必须入库)
- 所有依赖只有下界:`pymupdf>=1.24` 明天升级到 2.0 会被自动拉进来

**修复**1.`.gitignore` 移除 `uv.lock`
2. 给所有依赖加上界(保守策略 major+1):

```toml
# packages/pdfsys-router/pyproject.toml
dependencies = [
    "pdfsys-core",
    "pymupdf>=1.24,<2.0",
    "xgboost>=2.0,<3.0",
    "scikit-learn>=1.3,<2.0",
    "pandas>=2.0,<3.0",
    "numpy>=1.26,<3.0",
]
```

3. `uv lock && git add uv.lock`
4. CI 用 `uv sync --frozen`(见 §3.3)

**工作量**:0.5 天

---

### 3.5 Parity Harness

**背景**`.cursor/rules/21-router-parity.mdc` 已描述 parity 验证流程,但**缺可执行脚本****`scripts/check_parity.sh`**:

```bash
#!/usr/bin/env bash
# Verify router ocr_prob drift between two refs.
# Usage: bash scripts/check_parity.sh <baseline_ref> <candidate_ref>
set -euo pipefail

BASELINE="${1:-origin/main}"
CANDIDATE="${2:-HEAD}"
SAMPLE_DIR="${PARITY_SAMPLE_DIR:-tests/fixtures/pdfs}"
EPSILON="${PARITY_EPSILON:-1e-6}"
WORK_DIR="$(mktemp -d)"
trap 'rm -rf "$WORK_DIR"' EXIT

run_bench() {
    local ref="$1" out="$2"
    git worktree add "$WORK_DIR/$ref" "$ref"
    (cd "$WORK_DIR/$ref" && uv sync --frozen --quiet \
       && uv run python -m pdfsys_router.download_weights >/dev/null \
       && uv run python -m pdfsys_bench --pdf-dir "$SAMPLE_DIR" --out "$out" --no-quality)
    git worktree remove --force "$WORK_DIR/$ref"
}

run_bench "$BASELINE"  "$WORK_DIR/baseline.jsonl"
run_bench "$CANDIDATE" "$WORK_DIR/candidate.jsonl"

uv run python scripts/parity_diff.py \
    "$WORK_DIR/baseline.jsonl" "$WORK_DIR/candidate.jsonl" \
    --epsilon "$EPSILON"
```

**`scripts/parity_diff.py`**:接收两个 JSONL、逐 PDF 对比 `ocr_prob`、漂移超阈值 exit 非零。

**工作量**:1 天

---

## 4 · P1 性能与可靠性(Week 3-6)

### 4.1 Router 热路径优化

**现状**:49 ms/PDF(PRD 目标 ≤10 ms)。跑 1 PB 语料 ≈ 浪费 10+ 小时 CPU。

**优化点**(先 profile 后改,要求 P0 测试先到位):

#### (a) 去掉 pandas DataFrame 构造

```python
# ❌ 现状 (packages/pdfsys-router/src/pdfsys_router/xgb_model.py)
df = pd.DataFrame([features])
names = getattr(self.model, "feature_names_in_", None)
if names is not None:
    df = df.reindex(columns=list(names), fill_value=0)
probs = self.model.predict_proba(df)

# ✅ 优化:缓存列序 + numpy array
class XgbRouterModel:
    def __init__(self, path):
        self._feature_order: list[str] | None = None

    def predict_proba(self, features: dict[str, float]) -> float:
        if self._feature_order is None:
            self._feature_order = list(self.model.feature_names_in_)
        arr = np.fromiter(
            (features.get(k, 0.0) for k in self._feature_order),
            dtype=np.float32, count=len(self._feature_order),
        ).reshape(1, -1)
        return float(self.model.predict_proba(arr)[0, 1])
```

预估:~15 ms → ~2 ms。

#### (b) PyMuPDF 文本读取去重

`_get_garbled_text_per_page` 对每页 `get_text()`,后续 `compute_features_per_chunk` 对采样页再读一次——同一页读两次。
优化:读所有采样页文本时就缓存 `page → text` 字典,复用。预估 ~25 ms → ~12 ms。

#### (c) 早 return

`is_encrypted` / `needs_pass` / `len(doc) == 0` 这类硬错误应在特征提取前 short-circuit。

**验收**:Parity harness 验证 `|diff(ocr_prob)| < 1e-6`;OmniDocBench-100 上 p50 ≤ 10 ms。

**工作量**:2-3 天

---

### 4.2 Quality scorer 批量推理

**现状**:单条 3.6 s;10 万文档 ≈ 100 小时。

**改动**:`OcrQualityScorer.score_many` 从循环改成真正 batch:

```python
def score_many(self, texts: list[str], batch_size: int = 8) -> list[QualityScore]:
    self._ensure_loaded()
    torch = self._torch
    results: list[QualityScore] = []
    for i in range(0, len(texts), batch_size):
        batch = [t[:self.max_chars] or " " for t in texts[i:i + batch_size]]
        enc = self._tokenizer(
            batch, return_tensors="pt", truncation=True,
            max_length=self.max_tokens, padding=True,
        ).to(self._device)
        with torch.inference_mode():
            logits = self._model(**enc).logits.squeeze(-1)
        for j, text in enumerate(batch):
            score = max(0.0, min(3.0, float(logits[j].item())))
            results.append(QualityScore(
                score=score,
                num_chars=len(text),
                num_tokens=int(enc["attention_mask"][j].sum()),
                model=self.model_name,
            ))
    return results
```

**配套**`pdfsys_bench.loop.run_loop` 改成"先全部 extract → 批量 score → 展回 JSONL",保持输出顺序。

**验收**:batch=8 相比 batch=1 吞吐 ≥ 3×;单样本数值差 `< 1e-3`**工作量**:3 天

---

### 4.3 structlog 日志系统

**现状**:全仓 `print(...)` × 12 处;无级别、无结构。

**方案**`pdfsys-core` 之外的包引入 `structlog`(core 保持零依赖):

```python
# packages/pdfsys-router/src/pdfsys_router/_log.py
import structlog
log = structlog.get_logger("pdfsys.router")

# 使用:
log.info("classified", backend=decision.backend.value,
         ocr_prob=decision.ocr_prob, pdf=str(path),
         num_pages=decision.num_pages)
```

生产用 `JSONRenderer()`(便于 Grafana/ELK 摄入),dev 用 `ConsoleRenderer()`**工作量**:2 天

---

### 4.4 Prometheus metrics

**最小实现**```python
# packages/pdfsys-bench/src/pdfsys_bench/_metrics.py
from prometheus_client import Counter, Histogram, start_http_server

router_decisions = Counter("pdfsys_router_decisions_total",
                           "Router decisions by backend", ["backend"])
router_latency = Histogram("pdfsys_router_duration_seconds",
                           "Router classification latency",
                           buckets=[0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0])
extract_failures = Counter("pdfsys_extract_failures_total",
                           "Extraction failures", ["backend", "error_class"])

def enable_metrics_endpoint(port: int = 9000) -> None:
    start_http_server(port)
```

`pdfsys-bench` CLI 新增 `--metrics-port` flag。

**工作量**:2 天

---

### 4.5 错误分类 + quarantine 桶

**现状**:失败写 `extract_error: "classify_failed: X"` 自由字符串,无法聚合。

**方案**`pdfsys-core` 新增 `errors.py````python
from enum import Enum

class ErrorClass(str, Enum):
    OPEN_FAILED = "open_failed"
    ENCRYPTED = "encrypted"
    EMPTY = "empty"
    CORRUPTED_STREAM = "corrupted_stream"
    FEATURE_EXTRACTION_FAILED = "feature_extraction_failed"
    MODEL_INFERENCE_FAILED = "model_inference_failed"
    OOM = "oom"
    UNKNOWN = "unknown"
```

`RouterDecision.error_class: ErrorClass` 替代自由字符串。Bench 按 class 聚合计数。

Quarantine 桶:`out/quarantine/<error_class>/<sha256>.json` 保留失败记录(路径 + error + 完整特征向量,**不保留 PDF**),离线分析用。

**工作量**:3 天

---

## 5 · P2 功能补全(Week 7-16)

### 依赖 DAG

```
Layout Analyser (3.1) ──┬──► Pipeline Parser (3.2) ──┐
                        │                             │
                        └──► VLM Parser     (3.4) ────┼──► Stage-3 (3.5) ──► Stage-4 (3.6) ──► Stage-5 (3.7)

                        ┌──► Stage-B Router (3.3) ─────┘

                  (reads LayoutCache)
```

### 5.1 Layout Analyser · P2-1

**选型**:PP-DocLayoutV3 ONNX INT8(CPU ~50 ms/页),未来可接 docling-layout-heron。

**交付物**```
packages/pdfsys-layout-analyser/src/pdfsys_layout_analyser/
├── __init__.py
├── analyser.py              # LayoutAnalyser 主类
├── runners/
│   ├── pp_doclayoutv3.py    # ONNX runtime 驱动
│   └── heuristic.py         # bbox 列数聚类 fallback
├── render.py                # PDF 页 → PNG(DPI 可调)
└── postprocess.py           # 阅读顺序 + 跨栏合并
```

**API**```python
class LayoutAnalyser:
    def __init__(self, config: LayoutConfig = LayoutConfig()): ...
    def analyse(self, pdf_path: str | Path) -> LayoutDocument: ...
    def analyse_with_cache(
        self, pdf_path: str | Path, cache: LayoutCache
    ) -> LayoutDocument: ...   # idempotent
```

**验收**- OmniDocBench-100 上 mAP ≥ 0.85
- CPU INT8 吞吐 ≥ 20 页/s/core
- `LayoutDocument` 能被 `LayoutCache.save/load` 完整 roundtrip
- 空 / 加密 / 损坏 PDF 全部不 crash

**工作量**:1 人 · 10 天

---

### 5.2 Pipeline Parser · P2-2

**选型**:RapidOCR(PaddleOCR ONNX 前向,无 Paddle 依赖)。

**交付物**```
packages/pdfsys-parser-pipeline/src/pdfsys_parser_pipeline/
├── extract.py              # extract_doc / extract_doc_bytes
├── ocr_engine.py           # RapidOCR wrapper (lazy load)
├── region_processor.py     # 按 RegionType 派发
├── image_cropper.py        # bbox → image crop
└── markdown_emitter.py     # region + OCR → Segment
```

**核心逻辑**```python
def extract_doc(pdf_path, *, layout_cache: LayoutCache) -> ExtractedDoc:
    layout = layout_cache.load_or_compute(pdf_path, analyser)
    segments = []
    for page in layout.pages:
        for region in page.regions:
            img = crop_region_from_pdf(pdf_path, page.index, region.bbox)
            text = ocr_engine.recognise(img, region.type)
            segments.append(Segment(
                index=len(segments),
                backend=Backend.PIPELINE,
                page_index=page.index,
                type=region.type,
                content=text,
                bbox=region.bbox,
                source_region_id=region.region_id,
            ))
    return ExtractedDoc(
        sha256=sha256_of_file(pdf_path),
        backend=Backend.PIPELINE,
        segments=tuple(segments),
        markdown=merge_segments_to_markdown(tuple(segments)),
        stats={"page_count": len(layout.pages)},
    )
```

**验收**- OmniDocBench 扫描件子集中文字符 F1 ≥ 0.90
- 输出 schema 与 `parser-mupdf` 同构(`tests/contract/test_extracted_doc_schema.py` 保护)
- CPU 吞吐 ≥ 5 页/s/core

**工作量**:1 人 · 12 天

---

### 5.3 Stage-B Router · P2-3

把当前 4 行 stub `decider.py` 做实:

```python
def decide_complex_vs_simple(
    layout: LayoutDocument, config: RouterConfig
) -> Backend:
    if not config.vlm_enabled:
        return Backend.PIPELINE
    if layout.has_complex_content:
        return Backend.VLM
    return Backend.PIPELINE
```

`Router._route()``ocr_prob ≥ threshold` 时先查 `LayoutCache`,命中 → 调 `decide_complex_vs_simple`;未命中 → 返回 `DEFERRED`**工作量**:2 天

---

### 5.4 VLM Parser · P2-4

**选型**(PRD §4.4):生产用 LMDeploy 驱动 MinerU 2.5-Pro 1.2B。

**交付物**```
packages/pdfsys-parser-vlm/src/pdfsys_parser_vlm/
├── extract.py
├── engines/
│   ├── mineru.py           # LMDeploy wrapper
│   └── paddleocr_vl.py     # 备选
├── batching.py             # dynamic batching
├── rendering.py            # 高 DPI 页面渲染
└── fallback.py             # OOM 降 batch 重试
```

**关键约束**- Worker 常驻模型(单例懒加载)
- `max_batch_size=16, max_seq=8192`(PRD §4.4)
- 超长页:单页 > 8192 tokens 按 bbox 聚类切两块
- 单页 OOM 自动降 batch 重试 ≤ 2 次后写 quarantine(见 §4.5)

**工作量**:1 人 · 15 天(含 LMDeploy 调通)

---

### 5.5 Stage-3 后处理

独立成新包 `packages/pdfsys-postproc/````
├── reading_order.py       # 跨页合并、脚注挂回正文、双栏交错修正
├── paragraph_merge.py     # 折行还原 + 中文断句
├── formula_norm.py        # KaTeX 语法校验,失败转 image placeholder
├── table_norm.py          # HTML↔Markdown 双格式,行列校验
└── unicode_norm.py        # NFC + 全半角统一 + 零宽字符清理
```

**工作量**:1 人 · 10 天

---

### 5.6 Stage-4 质量 / PII / MinHash 去重

独立成 `packages/pdfsys-quality/`,复用 `datatrove` 的 MinHash block(PRD §4.6.5):

```
├── lang_id.py         # GlotLID 段落级语种识别
├── heuristic.py       # 重复 n-gram、非 CJK 比例、行长方差
├── edu_score.py       # 中文 EduScore (fastText → DeBERTa-v3-tiny)
├── pii.py             # 正则 + NER 兜底
└── dedup/
    ├── exact.py       # md5 内容精确去重
    └── minhash.py     # datatrove MinHash LSH wrapper
```

**工作量**:2 人 · 3 周(MinHash 跨 shard 需全局 shuffle,最复杂)

---

### 5.7 Stage-5 Parquet 打包

独立成 `packages/pdfsys-output/`- Parquet 分片 ~1 GB/shard,zstd 压缩
- 分桶路径:`v1/lang=zh/source=arxiv/qb=high/shard-NNNNN.parquet`
- JSONL 镜像 + Markdown 抽样存档(每 shard 0.1%)

**工作量**:1 人 · 5 天

---

## 6 · P3 规模化与生态(3-6 个月)

| 项 | 说明 | 工作量 |
|---|---|---|
| **datatrove 集成** | 把现有 stage 包成 `datatrove.Block`,原生 Slurm 后端 | 2-3 周 |
| **Slurm / K8s runner** | 新包 `pdfsys-runner`,支持 shard checkpoint + 反压 | 3-4 周 |
| **对象存储后端** | `pdfsys-core` 抽象 `FSBackend` 协议,支持 `file://` / `s3://` / `oss://` / `minio://` | 1-2 周 |
| **中文 EduScore 训练** | fastText → DeBERTa-v3-tiny 分类器 + 数据标注 | 4-6 周(含标注) |
| **竖排古籍 LoRA** | MinerU 2.5 针对性 LoRA 微调 | 4-6 周(GPU 密集) |

---

## 7 · 里程碑时间线

| 里程碑 | 周 | 标志 |
|---|---|---|
| **M1 · 可协作化** | 2 | CI 绿灯;覆盖率达标;lock 文件入库;parity harness 守门 |
| **M2 · 生产级核心** | 6 | Router p50 ≤ 10 ms;scorer 3× 吞吐;统一 log+metrics;错误可聚合 |
| **M3 · 6-stage 打通** | 16 | 10 GB 数据集端到端跑完;三种 backend 同构 schema |
| **M4 · PB 就绪** | 24 | datatrove + Slurm runner;对象存储后端;TCO 估算入库 |
| **M5 · v0.1 数据集** | 32 | 首个 1 TB 级对外可发布数据集 + 评测报告 |

---

## 8 · Quick Wins · 两周内可立即启动

如果只能挑最高 ROI 的 5 件事立刻做:

1. **写 15 个 core / router / parser-mupdf 单测** — 2 天 · 把不变式变成机器可验证
2. **配 ruff + pre-commit** — 0.5 天 · 新 PR 质量底线立起来
3. **写 `.github/workflows/ci.yml`** — 0.5 天 · 反馈从"review 时"提前到"push 时"
4. **`uv.lock` 入库 + 依赖加上界** — 0.5 天 · 依赖不会突然不一样
5. **`scripts/check_parity.sh` + 10 个样本 PDF 入 fixtures** — 2 天 · router 改动自动守门

合计 **5-6 个工作日**,换来"可协作化"的全部前提。强烈建议以这作为第一冲刺。

---

## 9 · 风险与"不做的事"

### 必须克制的诱惑

- ❌ **不要在 P0 之前碰 stub 实现**——没有测试和 parity harness 保护,任何功能添加都是技术债的利息
- ❌ **不要替换 PyMuPDF**——它在中文场景的工程成熟度是第一梯队,换 pdfminer/PyPDF2 会立刻倒退
- ❌ **不要引入 LangChain / LlamaIndex**——这是数据处理 pipeline,不是 RAG 应用
- ❌ **不要在 `pdfsys-core` 引入 pydantic**——现有 `dataclass(frozen=True, slots=True)` + `serde.py` 够用,换 pydantic 破坏零依赖不变式

### 长期风险对应策略

| 风险 | 对应 |
|---|---|
| MinerU 2.5 新版许可变化 | PaddleOCR-VL 保持热备,`pdfsys-parser-vlm` 做成 engine 抽象 |
| PyMuPDF AGPL 限制 | 评估 pikepdf / pdfplumber 作为退路(低优先级) |
| PB 级对象存储成本失控 | P0 阶段写 `scripts/tco.py` 估算 |
| 中文 PII 召回不足 | NER 模型兜底,保留审计表便于事后补救 |

---

## 10 · 如何跟踪进度

- **短期(P0-P1)**:GitHub Projects / Milestones。每个子项一 issue,带验收标准。
- **中期(P2)**:每个 stage 落地时开一个"tracking issue"聚合子 PR,`CHANGELOG.md` 按 SemVer 更新。
- **长期(P3)**:PRD §10 的 P0/P1/P2/P3 roadmap 每月复盘一次,本文档 v0.N 同步迭代。

进度状态在根 `README.md` §What's implemented 表里维护——按 `.cursor/rules/03-doc-sync.mdc` 的映射表,任何 Stage 状态从 ❌→✅ 都必须同步该表。

---

## 附录 · 总量一览

| 阶段 | 周期 | 核心交付 | 人力 |
|---|---|---|---|
| **P0 工程基础** | 2 周 | pytest + ruff + CI + lock + parity | 1 人 |
| **P1 性能/可靠性** | 4 周 | router 5×、scorer 3×、log/metrics | 1-2 人 |
| **P2 功能补全** | 10-12 周 | 6 stage 闭环 | 2-3 人 |
| **P3 规模化** | 3-6 月 | datatrove + Slurm + PB 级运行 | 3-4 人 |

从 0 到"PB 级准备"约 24 周,累计约 20-30 人周。与 PRD §6 的资源预算 "100 × A100 + 32 节点 CPU 墙钟 ~2 个月"相匹配——**先把工具链造好,再把大算力接上**。