feat: rebuild speed-up with parallel rebuild, threaded asset cropping, maintenance refactor, embed progress, CLI docs

This commit is contained in:
LLLin000 2026-07-05 19:43:07 +08:00
parent 380f935f83
commit c34ebbce81
10 changed files with 1832 additions and 453 deletions

View file

@ -10,89 +10,285 @@
|-----------|---------|------|---------|
| `/pf-sync` | `paperforge sync` | 同步 Zotero生成正式笔记 | BBT JSON 导出 |
| `/pf-ocr` | `paperforge ocr` | PDF OCR 文本与图表提取 | `do_ocr: true` |
| `/pf-status` | `paperforge status` | 查看系统状态 | 配置完成 |
| `/pf-deep <key>` | `paperforge deep-reading` | 三阶段精读 | OCR done + `analyze: true` |
| `/pf-paper <key>` | — | 文献问答 | 正式笔记存在 |
| `/pf-status` | `paperforge status` / `paperforge runtime-health` | 查看系统状态 | 配置完成 |
| `/pf-deep <key>` | `paperforge deep-reading` (队列) | 三阶段精读 (Agent 层) | OCR done + `analyze: true` |
| `/pf-paper <key>` | — | 文献问答 (Agent 层) | 正式笔记存在 |
| `/pf-end <key>` | — | 结束精读/问答 Session (Agent 层) | 精读或问答进行中 |
| `/pf-log-reading` | `paperforge reading-log --write <key>` | 记录阅读笔记 | 精读进行中 |
| `/pf-log-session` | `paperforge project-log --write` | 总结会话决策 | 会话结束 |
---
## CLI 命令
### `paperforge sync`
### `paperforge paths`
```bash
paperforge sync # 完整同步
paperforge sync --dry-run # 预览
paperforge sync --rebuild-index # 强制重建索引
paperforge sync --json # JSON 输出
```
### `paperforge ocr`
```bash
paperforge ocr # 处理队列
paperforge ocr --key ABCDEFG # 处理指定文献
paperforge ocr --diagnose # 诊断模式
paperforge ocr --json # JSON 输出
paperforge paths # 打印 vault 路径清单
paperforge paths --json # JSON 格式输出
```
### `paperforge status`
```bash
paperforge status # 完整状态
paperforge status --json # JSON 输出
paperforge status # 完整状态检查
paperforge status --json # JSON 输出
```
### `paperforge doctor`
### `paperforge sync`
```bash
paperforge doctor # 验证安装配置
paperforge doctor --json # JSON 输出
paperforge sync # 完整同步
paperforge sync --dry-run # 预览
paperforge sync --selection # 仅 selection-sync
paperforge sync --index # 仅 index-refresh
paperforge sync --rebuild-index # 强制重建索引
paperforge sync --prune # 预览清理孤儿笔记 (dry-run)
paperforge sync --prune-force # 执行清理
paperforge sync --json # JSON 输出
```
### `paperforge repair`
### `paperforge selection-sync`
```bash
paperforge repair # 扫描分歧dry-run
paperforge repair --fix # 修复
paperforge repair --fix-paths # 修复 PDF 路径
paperforge repair --json # JSON 输出
paperforge selection-sync # 同步 Zotero 选择到文献记录 (向后兼容)
```
### `paperforge index-refresh`
```bash
paperforge index-refresh # 刷新正式文献笔记 (向后兼容)
```
### `paperforge deep-reading`
```bash
paperforge deep-reading # 查看精读队列
paperforge deep-reading --verbose # 含修复指令
paperforge deep-reading # 查精读队列状态
paperforge deep-reading --json # JSON 输出
```
### `paperforge deep-finalize`
```bash
paperforge deep-finalize <KEY> # 标记精读完成并通知 Dashboard
paperforge deep-finalize <KEY> --json
```
### `paperforge repair`
```bash
paperforge repair # 扫描分歧 (dry-run)
paperforge repair --fix # 修复
paperforge repair --fix-paths # 修复 PDF 路径
paperforge repair --json # JSON 输出
```
### `paperforge ocr`
```bash
paperforge ocr # 处理队列 (旧式)
paperforge ocr --key <KEY> # 处理指定文献
paperforge ocr --diagnose # 诊断模式
paperforge ocr --json # JSON 输出
paperforge ocr run # 运行 OCR 队列 (新式)
paperforge ocr doctor # 诊断 OCR 配置和连接
paperforge ocr doctor --live # 运行真实 PDF 测试 (L4)
paperforge ocr redo # 重新运行所有标记 ocr_redo 的文献
paperforge ocr redo <KEY> [KEY...] # 重新运行指定文献
paperforge ocr redo --dry-run # 预览待重做的文献
paperforge ocr list # 列出所有 OCR 维护状态
paperforge ocr list --json # JSON 输出
paperforge ocr list --manifest # 输出 key→sha256 清单
paperforge ocr list --keys <KEY>... # 仅指定 key 的信息
paperforge ocr rebuild # 从已有 raw blocks 重建 OCR 产物
paperforge ocr rebuild <KEY>... # 重建指定文献
paperforge ocr rebuild --all # 重建全部
paperforge ocr rebuild --status done # 按状态过滤
paperforge ocr rebuild --dry-run # 预览
paperforge ocr rebuild --resume # 跳过已有检查点的文献
```
### `paperforge context`
```bash
paperforge context <KEY> # 单篇文献的 AI context 包 (JSON)
paperforge context --domain 骨科 # 按 domain 过滤 (JSON 数组)
paperforge context --collection "path/to/coll" # 按 collection 过滤
paperforge context --all # 输出全部 canonical index
```
### `paperforge dashboard`
```bash
paperforge dashboard # 聚合统计和 Dashboard 权限
paperforge dashboard --json # JSON 输出
```
### `paperforge embed`
```bash
paperforge embed build # 构建向量索引
paperforge embed build --resume # 续建
paperforge embed status # 查看状态
paperforge embed stop # 停止构建
paperforge embed build # 构建向量索引
paperforge embed build --resume # 续建
paperforge embed build --force # 强制重建
paperforge embed build --json # JSON 输出
paperforge embed status # 查看向量 DB 状态
paperforge embed status --json # JSON 输出
paperforge embed stop # 停止构建
paperforge embed stop --json # JSON 输出
```
### `paperforge retrieve`
```bash
paperforge retrieve "<query>" # 跨 OCR 全文语义检索
paperforge retrieve "PEMF" --limit 10 --json
paperforge retrieve "75 Hz" --no-expand
```
### `paperforge query-plan`
```bash
paperforge query-plan "<query>" --intent discover # 文献发现
paperforge query-plan "<query>" --intent content # 内容检索
paperforge query-plan "<query>" --intent known-paper # 已知论文定位
paperforge query-plan "<query>" --intent discover --json
```
### `paperforge prune`
```bash
paperforge prune # 预览删除孤儿产物 (dry-run)
paperforge prune --force # 实际删除
paperforge prune <KEY> [KEY...] # 仅处理指定 key
paperforge prune --json # JSON 输出
```
### `paperforge memory`
```bash
paperforge memory build # 构建 memory DB
paperforge memory status # 查看状态
paperforge memory build # 构建 memory DB
paperforge memory build --json # JSON 输出
paperforge memory status # 查看 memory DB 状态
paperforge memory status --json # JSON 输出
```
### `paperforge search`
```bash
paperforge search "<query>" # 元数据全文搜索
paperforge search "<query>" --json
paperforge search "PEMF" --domain 骨科 --ocr done --year-from 2020
paperforge search "PEMF" --deep done --lifecycle fulltext_ready
paperforge search "PEMF" --next-step ocr --limit 10
```
### `paperforge paper-status`
```bash
paperforge paper-status <query> # 查找文献状态 (key/DOI/title/alias)
paperforge paper-status "XGT9Z257" --json
```
### `paperforge paper-context`
```bash
paperforge paper-context <key> # 文献完整上下文
paperforge paper-context "XGT9Z257" --json
```
### `paperforge reading-log`
```bash
paperforge reading-log --write <KEY> --section "Discussion P12" --excerpt "..." --usage "..." # 写阅读笔记
paperforge reading-log --render # 渲染 reading-log.md
paperforge reading-log --lookup <KEY> # 查某文献的阅读笔记
paperforge reading-log --since 2026-01-01 --limit 100 --output notes.md # 导出
paperforge reading-log --validate reading-log.md
paperforge reading-log --import reading-log.md
paperforge reading-log --correct <ID> --correction "..." --reason "..." # 修正笔记
paperforge reading-log --json
```
### `paperforge project-log`
```bash
paperforge project-log --write --project "ProjectX" --payload '{"hours":2,"status":"progress"}' # 写工作日志
paperforge project-log --list --project "ProjectX"
paperforge project-log --render --project "ProjectX"
paperforge project-log --project "ProjectX" --json
```
### `paperforge agent-context`
```bash
paperforge agent-context # 生成 Agent 引导上下文
paperforge agent-context --json # JSON 输出
```
### `paperforge runtime-health`
```bash
paperforge runtime-health --json
paperforge runtime-health # 检查 memory layer 运行时健康
paperforge runtime-health --json # JSON 输出
```
### `paperforge base-refresh`
```bash
paperforge base-refresh # 刷新 Obsidian Base 视图
paperforge base-refresh --force # 强制全量重建
```
### `paperforge doctor`
```bash
paperforge doctor # 验证安装配置
paperforge doctor --json # JSON 输出
```
### `paperforge update`
```bash
paperforge update # 更新到最新版本
```
### `paperforge setup`
```bash
paperforge setup # 交互式设置
paperforge setup --headless # 非交互式 (AI 代理)
paperforge setup --agent opencode # 指定 AI Agent 平台
paperforge setup --paddleocr-key "KEY" --paddleocr-url "URL"
paperforge setup --skip-checks # 跳过环境检查
paperforge setup --modular # 使用模块化设置 (v2.1+)
```
### `paperforge paper-lookup`
```bash
paperforge paper-lookup "<query>" # L4 网关: 定位文献
paperforge paper-lookup "PEMF" --json --limit 10
```
### `paperforge content-discovery`
```bash
paperforge content-discovery "<query>" # L4 网关: 内容发现
paperforge content-discovery "骨科 生物力学" --json --limit 10
```
### `paperforge paper-navigation`
```bash
paperforge paper-navigation "<query>" # L4 网关: 文献结构导航
paperforge paper-navigation "XGT9Z257" --json
```
### `paperforge scoped-fetch`
```bash
paperforge scoped-fetch "<query>" # L4 网关: 范围受限获取
paperforge scoped-fetch "PEMF 75 Hz treatment protocol" --json --limit 10
```
---
@ -130,3 +326,4 @@ $env:PAPERFORGE_REAL_OCR_VAULT="D:\path\to\Literature-hub"; $env:PAPERFORGE_REAL
```
不设置环境变量时,真实文献测试自动 SKIP。

View file

@ -0,0 +1,464 @@
# Rebuild 加速与批量改进 — 实现计划
> Branch: `feat/rebuild-speed`
> Base: `master`
> Spec: `docs/superpowers/specs/2026-07-05-rebuild-speed-and-batch-improvements.md`
> Review: 8.8/10 — 可以进入实现
## 总览
5 个 Wave文件不重叠可并行。
| Wave | 文件 | 改动 | 复杂度 |
|------|------|------|--------|
| 1 | `ocr_rebuild.py` + `commands/ocr.py` + `cli.py` | 阶段化 + 跨论文并行 + checkpoint 重构 | 高 |
| 2 | `ocr_objects.py` | 资产裁剪并行(复用现有坐标逻辑,不重写) | 中 |
| 3 | `ocr_maintenance.py` | Display 封装(用真实字段) | 低 |
| 4 | `commands/embed.py` | Embed 人类进度(保留机器协议) | 低 |
| 5 | `docs/COMMANDS.md` | CLI 文档补齐(从 parser 核对) | 低 |
---
## Wave 1: 阶段化 + 跨论文并行
### 1.1 前置
```python
import logging
logger = logging.getLogger(__name__)
```
### 1.2 改写 `run_derived_rebuild_for_keys()`
**签名:**
```python
def run_derived_rebuild_for_keys(
vault: Path,
keys: list[str],
progress_bar=None,
checkpoint_dir: Path | None = None,
parallel: int = 4,
) -> dict:
```
**函数体checkpoint 过滤在入口统一执行,串行/并行路径共享一处过滤逻辑。**
```python
def run_derived_rebuild_for_keys(vault, keys, progress_bar=None,
checkpoint_dir=None, parallel=4):
# 入口统一过滤 checkpoint串行/并行路径共享
if checkpoint_dir:
checkpoint_dir.mkdir(parents=True, exist_ok=True)
keys = _filter_completed_keys(keys, checkpoint_dir)
if parallel and len(keys) > 1:
return _run_parallel_rebuild(vault, keys, parallel, checkpoint_dir)
# 串行路径
rebuilt_count = 0
keys_iter = progress_bar(keys, desc="OCR rebuild") if progress_bar else keys
for key in keys_iter:
result = _rebuild_one_paper(vault, key)
if result["ok"]:
rebuilt_count += 1
if checkpoint_dir:
_write_done_marker(checkpoint_dir, key)
return {"rebuild_count": rebuilt_count}
```
### 1.3 提取 `_rebuild_one_paper()`module-level
**约束module-levelWindows/spawn pickle-safe。不写成内部闭包。**
**import 策略:保持重 imports 在阶段函数内部。** 只把 `logging`, `json`, `Path`, `datetime`, `concurrent.futures` 等 stdlib 提到模块顶部。fitz/figure/render/health 等不动。
```python
def _rebuild_one_paper(vault: Path, key: str) -> dict:
try:
ocr_root = pipeline_paths(vault)["ocr"]
artifacts = artifact_paths_for_root(ocr_root, key)
paper_root = artifacts.paper_root
if not paper_root.exists():
return {"key": key, "ok": False, "error": "paper root missing", "rebuild_count": 0}
if not artifacts.blocks_raw.exists():
return {"key": key, "ok": False, "error": "raw blocks missing", "rebuild_count": 0}
raw_blocks = list(read_jsonl(artifacts.blocks_raw))
meta = read_json(artifacts.meta_json) if artifacts.meta_json.exists() else {}
# Phase 15
...
return {"key": key, "ok": True, "rebuild_count": 1}
except Exception as e:
logger.exception(f"Rebuild failed for {key}: {e}")
return {"key": key, "ok": False, "error": str(e), "rebuild_count": 0}
```
#### Phase 1: `_phase_read_and_span()`
读 raw blocks → backfill 去重 → span backfill有效跳过→ PDF 行提取 → source metadata enrich
Returns: `(raw_blocks, meta, source_pdf_path, source_meta, page_pdf_lines, span_meta_patch)`
#### Phase 2: `_phase_structure_and_metadata()`
`build_structured_blocks()``write_role_span_profiles()` → metadata resolve → write
Returns: `(structured, doc_structure, resolved)`
#### Phase 3: `_phase_figures_tables_objects()`
**必须保护以下顺序(`apply_object_writebacks` 必须在 `write_figure_inventory` 前):**
```text
1. build_figure_inventory()
2. write_back_figure_roles()
3. residual_author_bio_pass() + post_ref_bio_cleanup()
4. synthesize_reader_figures()
5. build_table_inventory()
6. resolve_media_asset_conflicts()
7. attach_ownership_conflicts()
8. apply_object_writebacks() ← 这个顺序不能动
9. write_figure_inventory()
10. write_back_table_roles()
11. write_table_inventory()
12. write_structured_blocks_jsonl()
13. extract_and_write_objects()
```
#### Phase 4: `_phase_render_and_health()`
`render_fulltext_markdown()` → health → decision log → write
#### Phase 5: `_phase_index_and_meta()`
role index → structure tree → version flags → render outputs → meta.json
### 1.4 `_run_parallel_rebuild()`
```python
def _run_parallel_rebuild(vault, keys, workers, checkpoint_dir=None):
from concurrent.futures import ProcessPoolExecutor, as_completed
import sys, time, logging
logger = logging.getLogger(__name__)
with ProcessPoolExecutor(max_workers=workers) as pool:
futures = {pool.submit(_rebuild_one_paper, vault, k): k for k in keys}
rebuilt_count = 0
errors = []
start = time.monotonic()
for i, f in enumerate(as_completed(futures), 1):
key = futures[f]
try:
result = f.result()
except Exception as exc:
logger.error(f"Parallel worker failed for {key}: {exc}")
errors.append({"key": key, "ok": False, "error": repr(exc), "rebuild_count": 0})
continue
if result["ok"]:
rebuilt_count += 1
if checkpoint_dir:
_write_done_marker(checkpoint_dir, key)
else:
errors.append(result)
print(f"[{i}/{len(keys)}] {key} — total {time.monotonic()-start:.1f}s",
file=sys.stderr, flush=True)
return {"rebuild_count": rebuilt_count, "errors": errors}
```
### 1.5 Checkpoint 辅助函数
```python
def _write_done_marker(checkpoint_dir: Path, key: str):
(checkpoint_dir / f".done.{key}").write_text("", encoding="utf-8")
def _filter_completed_keys(keys: list[str], checkpoint_dir: Path) -> list[str]:
done = set()
if checkpoint_dir.exists():
for f in checkpoint_dir.iterdir():
if f.name.startswith(".done."):
done.add(f.name[len(".done."):])
return [k for k in keys if k not in done]
```
### 1.6 CLI 接口
**`cli.py` parser**
```python
group = parser.add_mutually_exclusive_group()
group.add_argument("--parallel", type=int, nargs="?", const=4, default=4)
group.add_argument("--no-parallel", action="store_true")
```
**normalize**
```python
parallel_workers = 0 if args.no_parallel else max(1, int(args.parallel or 4))
```
**`commands/ocr.py` dispatch**
```python
result = run_derived_rebuild_for_keys(
vault, keys,
checkpoint_dir=checkpoint_dir,
parallel=parallel_workers,
progress_bar=progress_bar,
)
```
---
## Wave 2: 资产裁剪并行
### 2.1 设计约束
**不能重写坐标转换。** 当前 `_crop_asset_from_pdf()` 已经正确处理了 OCR bbox → PDF rect 缩放和 rotation。Wave 2 只负责并发调度,不重写裁剪算法。
```text
Object tasks should call existing _crop_asset_from_pdf() without shared pdf_doc_provider.
Preserve OCR bbox -> PDF rect conversion and rotation handling.
```
### 2.2 重构方案
```text
1. Serial: _build_object_tasks() — 创建目录、清理旧文件、构建 task list、预分配稳定 ID
2. Task executors:
_write_figure_object_task(task) → _crop_asset_from_pdf(pdf_doc=None, pdf_doc_provider=None)
_write_table_object_task(task) → _crop_asset_from_pdf(pdf_doc=None, pdf_doc_provider=None)
_write_orphan_object_task(task) → _crop_asset_from_pdf(pdf_doc=None, pdf_doc_provider=None)
3. Each task opens its own fitz.Document (not shared)
4. Parallel: ThreadPoolExecutor over tasks
```
### 2.3 调用方式
```python
was_cropped = _crop_asset_from_pdf(
pdf_path=pdf_path,
page_num=page,
bbox=crop_bbox,
dst=asset_path,
page_width=page_width,
page_height=page_height,
page_cache_dir=threadsafe_page_cache_dir,
pdf_doc=None,
pdf_doc_provider=None,
rotation_deg=rotation_deg,
)
```
### 2.4 Worker 数
```python
max_workers = min(2, os.cpu_count() or 4)
```
### 2.5 Thread-safe page cache
**接入点:** thread-safe helper 必须接入 `_crop_asset_from_pdf()` 内部,替换当前 page cache 分支。不能只写一个新函数但不被调用。
**Recommended** 在 `_crop_asset_from_pdf()` 内部,将 page cache 写入替换为以下线程安全版本:
```python
_RENDER_LOCKS: dict[tuple[str, int], threading.Lock] = {}
_RENDER_LOCKS_GUARD = threading.Lock()
def _get_page_lock(cache_dir: Path, page_num: int) -> threading.Lock:
key = (str(cache_dir.resolve()), page_num)
with _RENDER_LOCKS_GUARD:
if key not in _RENDER_LOCKS:
_RENDER_LOCKS[key] = threading.Lock()
return _RENDER_LOCKS[key]
```
**key 用 `(cache_dir, page_num)` 而不仅是 `page_num`**,防止同一进程中不同 cache 目录的 page 冲突。
**备选:** 如果接入成本高,并行 task 传 `page_cache_dir=None` 禁用共享缓存,代价是每次从头渲染。
### 2.6 Orphan 处理
串行预分配 ID再并行裁剪
```python
orphan_tasks = []
for i, orphan in enumerate(unmatched_assets):
orphan_id = f"orphan_{page:03d}_{i:03d}"
orphan_tasks.append((orphan, orphans_asset_dir / f"{orphan_id}.jpg",
figures_render_dir / f"{orphan_id}.md"))
```
---
## Wave 3: Maintenance 显示封装
### 3.1 当前真实字段
当前 `OCRMaintenanceRow` 字段:`status`, `health`, `version`, `can_redo`, `can_rebuild`, `error_stage`, `error_summary`, `degraded_reasons`, ...
当前 `_compute_display_fields()` 签名:
```python
def _compute_display_fields(
status: str, health_overall: str, version: str,
can_redo: bool, can_rebuild: bool,
error_stage: str = "", error_summary: str = "",
degraded_reasons: list[str] | None = None,
) -> dict:
```
### 3.2 改写 `OCRMaintenanceRow`
```python
@dataclass
class OCRMaintenanceRow:
... # 现有字段不变
def __post_init__(self):
df = self.compute_display_fields(
status=self.status, health_overall=self.health,
version=self.version, can_redo=self.can_redo,
can_rebuild=self.can_rebuild, error_stage=self.error_stage,
error_summary=self.error_summary,
degraded_reasons=self.degraded_reasons,
)
self.display_action = df["display_action"]
self.display_label = df["display_label"]
self.display_label_key = df.get("display_label_key", "")
self.display_reason = df["display_reason"]
self.display_reason_key = df.get("display_reason_key", "")
self.display_group = df["display_group"]
self.display_severity = df["display_severity"]
self.visible_in_maintenance = df["visible_in_maintenance"]
self.show_in_base = df["show_in_base"]
@staticmethod
def compute_display_fields(...) -> dict:
"""原 _compute_display_fields() 逻辑移入此处。"""
...
```
### 3.3 修改 `compute_maintenance_manifest()`
```python
df = OCRMaintenanceRow.compute_display_fields(
status=status, health_overall=health_overall, version=version,
can_redo=can_redo, can_rebuild=can_rebuild,
error_stage=_error_stage(meta), error_summary=_error_summary(meta),
degraded_reasons=health.get("degraded_reasons", []) or [],
)
```
### 3.4 删除或保留外部函数
- **推荐:** 保留兼容 wrapper `_compute_display_fields = OCRMaintenanceRow.compute_display_fields`
- 或:直接删除,同步更新 `tests/test_ocr_maintenance.py` 中所有 import 和调用
### 3.5 测试更新
```python
# tests/test_ocr_maintenance.py
# 原: from paperforge.worker.ocr_maintenance import _compute_display_fields
# 改: from paperforge.worker.ocr_maintenance import OCRMaintenanceRow
# OCRMaintenanceRow.compute_display_fields(...)
```
---
## Wave 4: Embed 人类进度
### 4.1 当前状态
已有 stdout 机器协议:
```python
print(f"EMBED_START:{total}", flush=True)
for i, entry in enumerate(papers):
...
print(f"EMBED_PROGRESS:{i}:{total}:{key}:{count}", flush=True)
print("EMBED_DONE", flush=True)
```
### 4.2 叠加 tqdm
**只包 iterable不改变现有 `i += 1` 语义。** 当前 `i` 只在真正 embed 的 paper 递增(跳过 resume 已存在的)。
```python
from paperforge.worker._progress import progress_bar
use_tqdm = not getattr(args, "json", False)
papers_iter = progress_bar(papers, desc="Embedding", disable=not use_tqdm)
print(f"EMBED_START:{total}", flush=True)
i = 0
for entry in papers_iter:
key = entry["key"]
if resume and _already_embedded(...):
continue
chunks = chunker.chunk(entry["fulltext"])
count = embed_paper(vault, key, chunks)
i += 1
print(f"EMBED_PROGRESS:{i}:{total}:{key}:{count}", flush=True)
print("EMBED_DONE", flush=True)
```
### 4.3 不变
- `embedding/builder.py` 不动
- `EMBED_*` stdout 协议完全保留
- 插件端不受影响
---
## Wave 5: CLI 文档补齐
### 5.1 从 `build_parser()` 逐项核对
运行 `python paperforge/cli.py --help` 获取完整命令树,逐项填入。
| 命令 | 用途 | 示例 |
|------|------|------|
| `ocr` | 运行 OCR | `paperforge ocr --key KEY` |
| `ocr rebuild` | 重新生成 derived 产物 | `paperforge ocr rebuild [KEY...] [--all] [--status done_degraded] [--parallel 4]` |
| `sync` | 同步 Zotero → Obsidian | `paperforge sync` |
| `content-discovery` | 全文内容检索 | `paperforge content-discovery <query>` |
| `scoped-fetch` | 按查询获取全文块 | `paperforge scoped-fetch <query> [--limit 5]` |
| `paper-navigation` | 论文结构导航 | `paperforge paper-navigation <query>` |
| `paper-lookup` | 模糊查找论文 | `paperforge paper-lookup <query>` |
| `reading-log` | 阅读日志操作 | `paperforge reading-log --lookup/--write/--render/--validate/--import` |
| `project-log` | 项目日志操作 | `paperforge project-log --write/--list/--render/--project/--payload` |
| `paper-status` | 论文状态摘要 | `paperforge paper-status <query>` |
| `agent-context` | Agent 上下文 | `paperforge agent-context <query>` |
| `runtime-health` | 运行时健康检查 | `paperforge runtime-health` |
| `embed build` | 构建向量索引 | `paperforge embed build [--force] [--resume]` |
| `embed status` | 向量索引状态 | `paperforge embed status` |
| `embed stop` | 停止向量构建 | `paperforge embed stop` |
| `status` | 全局状态 | `paperforge status` |
| `search` | 检索 | `paperforge search <query>` |
**所有示例必须从 `build_parser()` 实际参数核对,不要手写猜测。**
---
## 测试计划
| Wave | 测试 | 覆盖 |
|------|------|------|
| 1 | `_rebuild_one_paper()` | 成功、paper root 缺失、raw blocks 缺失 |
| 1 | `_run_parallel_rebuild()` | 3 papers → count=31/3 fail → count=2+1 error |
| 1 | `_filter_completed_keys()` | 有标记 → 过滤 |
| 1 | 已有 `test_ocr_rebuild` | 21 个全部通过(串行不退化) |
| 2 | `_write_figure_object_task()` | 坐标正确性(复用 `_crop_asset_from_pdf` |
| 2 | `_get_page_lock()` 线程安全 | 并发同一页 → 不竞态 |
| 2 | 整体并行 | 3 figures → 全部产出 |
| 3 | 已有 `test_ocr_maintenance` | 28 个全部通过(行为不退化) |
| 3 | `OCRMaintenanceRow.compute_display_fields()` | 替换旧 `_compute_display_fields` import |
| 4 | stdout 抓取 | `EMBED_START / EMBED_PROGRESS / EMBED_DONE` 仍然存在 |
| 5 | `test_command_docs.py` | 更新(如果存在) |
Wave 1+2 并行 → review → Wave 3+4+5 并行 → review → 全量测试 → 合入 master。
重点审核Wave 2 坐标安全、Wave 3 字段匹配、Wave 4 机器协议保留。

View file

@ -0,0 +1,453 @@
# Rebuild 加速与批量改进设计
> 设计讨论2026-07-05
> 状态:设计稿(已审核,待实现)
> 审核结论7.6/10 — A1/P0#1 先做P0#2 和 embed 进度已按审核意见重写
## 设计目标
本设计解决一个核心问题和三个附带问题:
**核心问题**rebuild 全串行单线程逐个跑CPU 空闲、I/O 空闲。大规模批量 rebuild50+ 篇)耗时过长。
**附带问题**
1. `run_derived_rebuild_for_keys()` 是 300 行内联编排,不可测试、不可插桩
2. rebuild 缺少并行完成进度embed 已有 stdout 机器进度协议(`EMBED_START/EMBED_PROGRESS/EMBED_DONE`),但缺少面向终端用户的 human-readable tqdm
3. CLI 文档覆盖不全,用户不知道有哪些命令可用
4. Maintenance 显示逻辑在数据类外散布
## 范围
### 目标内
1. **跨论文并行**P0#1`ProcessPoolExecutor` 驱动,默认 4 进程
2. **资产裁剪并行**P0#2`ThreadPoolExecutor` 驱动,默认 2 线程/进程
3. **Rebuild 阶段化**A1把 300 行内联编排拆成 `_rebuild_one_paper()` + 5 个阶段函数
4. **Rebuild 进度**:并行模式 `print [i/N] key — t秒` 到 stderr串行保持 tqdm
5. **Embed 进度**`commands/embed.py` 中在 stderr 叠加 tqdm保留 `EMBED_START/EMBED_PROGRESS/EMBED_DONE` 机器协议
6. **Maintenance 显示封装**A4`compute_display_fields()` 转为静态方法,`__post_init__` 和 `compute_maintenance_manifest()` 共享
7. **CLI 文档补齐**B1`docs/COMMANDS.md` 覆盖全部 30+ 命令,名称从 `build_parser()` 逐项核对
### 非目标
- Figure passes 内部并行pass 间依赖收益不高scope 太大)
- JSONL 增量写入ponytail 标记但实际不是瓶颈)
- Section-aware chunkingB3shipping 后做)
- Gateway 连接管理器A2代码味而非性能问题
- CLI 导入体操A3值不值得做不确定
- LanceBackend 实现/删除A5你说了放放
## 设计决策
### 1. 跨论文并行P0#1
#### 1.1 Worker 数
默认为 4不随 `os.cpu_count()` 变化。用户通过 `--parallel N` 自定义。
理由rebuild 是 CPU + I/O 混合负载4 进程 SSD I/O 争用可控。
#### 1.2 CLI 接口
```python
group = parser.add_mutually_exclusive_group()
group.add_argument("--parallel", type=int, nargs="?", const=4, default=4)
group.add_argument("--no-parallel", action="store_true")
```
**必须 normalize**argparse 不会自动把 `--no-parallel``--parallel` 关联。如果只看 `args.parallel``--no-parallel` 时它仍然是默认值 `4`
```python
parallel_workers = 0 if args.no_parallel else max(1, int(args.parallel or 4))
run_derived_rebuild_for_keys(..., parallel=parallel_workers)
```
| CLI | `args.parallel` | `args.no_parallel` | 实际 workers |
|-----|-----------------|-------------------|-------------|
| 默认 | 4 | False | 4 |
| `--parallel` | 4 | False | 4 |
| `--parallel 2` | 2 | False | 2 |
| `--no-parallel` | 4 | True | 0 / serial |
`parallel=0``parallel=False` 走串行路径。
except Exception as e:
# 注意ocr_rebuild.py 当前没有 logger需要补
import logging
logger = logging.getLogger(__name__)
logger.exception(f"Rebuild failed for {key}: {e}")
return {"key": key, "ok": False, "error": str(e), "rebuild_count": 0}
def _run_parallel_rebuild(vault, keys, workers, ...) -> dict:
from concurrent.futures import ProcessPoolExecutor, as_completed
import sys
import time
import logging
logger = logging.getLogger(__name__)
# checkpoint主进程先扫描 .done.* 过滤已完成 keys
cp_dir = checkpoint_dir
if cp_dir:
cp_dir.mkdir(parents=True, exist_ok=True)
keys = _filter_completed_keys(keys, cp_dir)
with ProcessPoolExecutor(max_workers=workers) as pool:
futures = {pool.submit(_rebuild_one_paper, vault, k): k for k in keys}
rebuilt_count = 0
errors = []
start = time.monotonic()
for i, f in enumerate(as_completed(futures), 1):
key = futures[f]
try:
result = f.result() # 业务异常已被 worker 内部捕获
except Exception as exc:
# 来自子进程 crash / unpicklable / OOM / BrokenProcessPool / KeyboardInterrupt
logger.error(f"Parallel rebuild worker failed for {key}: {exc}")
errors.append({"key": key, "ok": False, "error": repr(exc), "rebuild_count": 0})
continue
if result["ok"]:
rebuilt_count += 1
if cp_dir:
_write_done_marker(cp_dir, key)
else:
errors.append(result)
total_elapsed = time.monotonic() - start
print(f"[{i}/{len(keys)}] {key} — total {total_elapsed:.1f}s",
file=sys.stderr, flush=True)
return {"rebuild_count": rebuilt_count, "errors": errors}
```
#### 1.4 Checkpoint 适配
并行时用 **独立目录**,不重用旧 JSON checkpoint 文件路径。
```python
# 旧vault / "System" / "PaperForge" / ".ocr_rebuild_checkpoint.json"
# 新vault / "System" / "PaperForge" / ".ocr_rebuild_checkpoint" / (目录)
# 主进程写标记worker 只返回 result主进程确认 ok 后写 .done.<key>
def _write_done_marker(checkpoint_dir: Path, key: str):
(checkpoint_dir / f".done.{key}").write_text("", encoding="utf-8")
# 主进程侧:扫描过滤
def _filter_completed_keys(keys: list[str], checkpoint_dir: Path) -> list[str]:
done = set()
if checkpoint_dir.exists():
for f in checkpoint_dir.iterdir():
if f.name.startswith(".done."):
done.add(f.name[len(".done."):])
return [k for k in keys if k not in done]
```
CLI resume 逻辑同步改为读 `.done.*` 文件,不再读旧 JSON。
#### 1.5 日志
`logging.getLogger(__name__)`
```python
import logging
logger = logging.getLogger(__name__)
```
并行模式下子进程 stderr 通过 ProcessPoolExecutor 管道自动合并到主进程,无需特殊处理。
#### 1.6 进度输出到 stderr
```python
print(f"[{i}/{len(keys)}] {key} — total {total_elapsed:.1f}s",
file=sys.stderr, flush=True)
```
避免污染 `--json` 输出。
### 2. 资产裁剪并行P0#2
审核意见:**不能共享 fitz.Document 并宣称线程安全。** 本方案已重写。
#### 2.1 Worker 数
`min(2, os.cpu_count())`。不暴露 CLI。
#### 2.2 核心约束
1. **不能共享 fitz.Document**:避免多线程并发访问同一个 PDF 文件的竞态
2. **页面缓存写入用临时文件 + atomic replace**:避免读到半成品缓存
3. **Orphan ID 串行预分配**:在提交 futures 之前确定所有输出路径
#### 2.3 实现方式
```python
def extract_and_write_objects(
pdf_path, figure_inventory, table_inventory,
asset_root, render_root, ...,
):
import os
from concurrent.futures import ThreadPoolExecutor, as_completed
max_workers = min(2, os.cpu_count() or 4)
# 串行阶段:目录创建、清理旧文件 → 安全,不做线程中
_prepare_directories(...)
# 串行预构建 task list确定所有稳定 ID
tasks: list[Callable] = []
for match in figure_inventory.get("matched_figures", []):
fig_id = _resolve_figure_id(match) # 稳定 ID
asset_path = figures_asset_dir / f"{fig_id}.jpg"
render_path = figures_render_dir / f"{fig_id}.md"
tasks.append(
lambda m=match: _crop_one_figure(m, pdf_path, asset_path, render_path, ...)
)
for table in table_inventory.get("tables", []):
tbl_id = _resolve_table_id(table) # 稳定 ID
asset_path = tables_asset_dir / f"{tbl_id}.jpg"
render_path = tables_render_dir / f"{tbl_id}.md"
tasks.append(
lambda t=table: _crop_one_table(t, pdf_path, asset_path, render_path, ...)
)
# 并行裁剪:每个 task 自己打开 fitz.Document
with ThreadPoolExecutor(max_workers=max_workers) as pool:
futures = [pool.submit(t) for t in tasks]
for f in as_completed(futures):
f.result() # 让异常透出
```
#### 2.4 页面缓存 atomic write
```python
_RENDER_LOCKS: dict[int, threading.Lock] = {}
def _render_page_cached(doc_path: Path, page_num: int, cache_dir: Path) -> Path | None:
"""线程安全页面渲染:每个 page 独立锁 + 临时文件 atomic replace。"""
lock = _RENDER_LOCKS.setdefault(page_num, threading.Lock())
cache_path = cache_dir / f"page_{page_num:03d}.jpg"
if cache_path.exists():
return cache_path
with lock:
# double-check: 另一个线程可能已经写完
if cache_path.exists():
return cache_path
import fitz
doc = fitz.open(str(doc_path))
try:
page = doc[page_num - 1]
pix = page.get_pixmap(dpi=200)
# 写临时文件 → atomic replace
tmp = cache_path.with_suffix(".tmp.jpg")
pix.save(str(tmp))
tmp.replace(cache_path)
return cache_path
finally:
doc.close()
```
#### 2.5 每个 crop task 打开自己的 PDF
```python
def _crop_one_figure(match, pdf_path, asset_path, render_path, ...):
import fitz
doc = fitz.open(str(pdf_path))
try:
page = doc[page_num - 1]
pix = page.get_pixmap(clip=bbox, dpi=200)
tmp = asset_path.with_suffix(".tmp.jpg")
pix.save(str(tmp))
tmp.replace(asset_path)
finally:
doc.close()
md = render_figure_object_markdown(...)
md_tmp = render_path.with_suffix(".tmp.md")
md_tmp.write_text(md)
md_tmp.replace(render_path)
return True
```
### 3. Rebuild 阶段化A1
#### 3.1 拆函数
`_rebuild_one_paper()` 内部拆成 5 个阶段函数:
```python
# Phase 1: Span
meta = _phase_span(raw_blocks, artifacts, meta, vault, key, source_pdf_path)
page_pdf_lines = extract_pdf_lines_normalized(source_pdf_path)
# Phase 2: Structure + Metadata
structured, doc_structure, resolved = _phase_structure(
raw_blocks, source_meta, artifacts, vault, key
)
# Phase 3: Figures + Tables + Objects
figure_inventory, table_inventory = _phase_figures_tables(
structured, page_pdf_lines, source_pdf_path, artifacts
)
# Phase 4: Render + Health
markdown = _phase_render(structured, resolved, figure_inventory, table_inventory,
doc_structure, reader_payload, meta)
# Phase 5: Index + Meta
_phase_index_and_meta(structured, resolved, markdown, meta, meta_patches,
artifacts, paper_root, vault, key)
```
#### 3.2 必须保护的顺序
**审核意见:不能重排 figure/table 管道顺序。** 以下顺序是代码注释明确要求的:
```text
1. build_figure_inventory()
2. write_back_figure_roles()
3. residual_author_bio_pass() + post_ref_bio_cleanup()
4. synthesize_reader_figures()
5. build_table_inventory()
6. resolve_media_asset_conflicts()
7. attach_ownership_conflicts()
8. apply_object_writebacks() ← 必须在 write_figure_inventory 之前
9. write_figure_inventory()
10. write_back_table_roles()
11. write_table_inventory()
12. write_structured_blocks_jsonl()
13. extract_and_write_objects()
```
### 4. Rebuild 进度
- 串行模式:现有 `progress_bar`tqdm不变
- 并行模式:`as_completed` + `print(f"[{i}/{n}] {key} — total {elapsed:.1f}s", file=sys.stderr)`
- `--json` 时禁用人类进度
### 5. Embed 进度
**审核意见:原来 spec 写错位置。** 当前 embed build 循环不在 `embedding/builder.py`,在 `commands/embed.py`
当前已有机器进度协议(`EMBED_START / EMBED_PROGRESS / EMBED_DONE`)到 stdout插件端依赖它们。
正确方案:
```python
# commands/embed.py — 在现有机器协议之上叠加人类进度
from paperforge.worker._progress import progress_bar
def run_embed(vault, papers, ...):
total = len(papers)
print(f"EMBED_START:{total}", flush=True) # 保留机器协议
for i, paper in enumerate(progress_bar(papers, desc="Embedding", disable=args.json)):
key = paper["key"]
chunks = chunker.chunk(paper["fulltext"])
count = embed_paper(vault, key, chunks)
print(f"EMBED_PROGRESS:{i+1}:{total}:{key}:{count}", flush=True) # 保留机器协议
print("EMBED_DONE", flush=True) # 保留机器协议
```
改动范围:
- `commands/embed.py`:加 tqdm 到 stderr`--json` 开关控制
- `embedding/builder.py`:不动
- 机器输出协议:不变
### 6. Maintenance 显示封装A4
**审核意见:不能直接删除 `_compute_display_fields()``compute_maintenance_manifest()` 也在用它。**
正确方案:
```python
@dataclass
class OCRMaintenanceRow:
...
def __post_init__(self):
df = self.compute_display_fields(...)
self.display_action = df["display_action"]
self.display_label = df["display_label"]
self.display_severity = df["display_severity"]
self.display_group = df["display_group"]
self.visible_in_maintenance = df["visible_in_maintenance"]
self.show_in_base = df["show_in_base"]
@staticmethod
def compute_display_fields(...) -> dict:
"""和 compute_maintenance_manifest() 共享的静态方法。"""
... # 现有 10-clause if-elif 逻辑
```
改动:
- `compute_display_fields()` 转为 `@staticmethod`,签名不变
- `OCRMaintenanceRow.__post_init__()` 调用 `compute_display_fields()`
- `compute_maintenance_manifest()` 也调用 `OCRMaintenanceRow.compute_display_fields()`
- 外部独立函数 `_compute_display_fields()` 删除
说明:本次只封装 display fields。`fulltext_drift_state` 等字段保持构造后赋值,不在此次改动范围内。
### 7. CLI 文档补齐B1
**审核意见:命令名必须从实际 `build_parser()` 逐项核对。** 以下是当前实际命令名(非猜测):
| 命令 | 实际 CLI 示例 | 文档状态 |
|------|-------------|---------|
| 运行 OCR | `paperforge ocr --key KEY` | 已有 |
| 重建 derived | `paperforge ocr rebuild [KEY...] [--all] [--status ...] [--parallel N]` | 需补 |
| 同步 | `paperforge sync` | 已有 |
| 状态 | `paperforge status` | 已有 |
| 检索 | `paperforge search <query>` | 已有 |
| 内容发现 | `paperforge content-discovery <query>` | 需补 |
| 范围获取 | `paperforge scoped-fetch <paper>` | 需补 |
| 论文导航 | `paperforge paper-navigation <query>` | 需补 |
| 论文查找 | `paperforge paper-lookup <query>` | 需补 |
| 阅读日志 | `paperforge reading-log --lookup/--write/--render/--validate/--import` | 需补 |
| 项目日志 | `paperforge project-log --write/--list/--render/--project/--payload` | 需补 |
| 论文状态 | `paperforge paper-status <query>` | 需补 |
| Agent 上下文 | `paperforge agent-context <query>` | 需补 |
| 运行时健康 | `paperforge runtime-health` | 需补 |
| 嵌入构建 | `paperforge embed build [--force] [--resume]` | 需补 |
| 嵌入状态 | `paperforge embed status` | 需补 |
| 停止嵌入 | `paperforge embed stop` | 需补 |
| 说明 | 以上 CLI 示例必须从 `build_parser()` 逐项核对,不要手写猜测命令名 | — |
文档格式:每命令一行用途 + 实际 CLI 示例(从 parser real_defaults 验证)。
## 非功能约束
| 属性 | 目标 |
|------|------|
| 向后兼容 | 串行路径行为 0 变化 |
| 并行下性能 | 40 篇 paper ≥ 2x 串行(**目标,非保证**;无 profiler 数据) |
| 内存 | 默认 4 workers 应在笔记本内存可承受范围内;后续用 40-paper benchmark 实测后考虑提高默认值 |
| 错误隔离 | 单篇失败不阻止其他论文;主进程 try/except f.result() 兜底 |
| 测试 | 每阶段函数独立测试 + 串行/并行路径 smoke test |
## 设计决策明细
| # | 决策 | 理由 |
|---|------|------|
| D1 | `max_workers=4` 固定值 | I/O 争用受限,非纯 CPU 密集 |
| D2 | 并行默认开 | 用户预期 "rebuild 就是快的" |
| D3 | CLI `--parallel`/`--no-parallel` 互斥组 | 语义最清晰 |
| D4 | `_rebuild_one_paper()` 返回 dict业务异常已内部捕获 | 隔离异常传递 |
| D5 | **主进程仍 try/except f.result()** | 子进程 crash/unpicklable/OOM 无法内部拦截 |
| D6 | 独立 checkpoint_dir + `.done.<key>` 标记 | 零库依赖,零竞态 |
| D7 | 每个 crop task 打开自己的 fitz.Document | **审核后修正**:避免共享 PDF 的线程安全风险 |
| D8 | 页面缓存用 per-page 锁 + atomic replace | **审核后修正**:避免读到半成品缓存 |
| D9 | orphan/table ID 串行预分配后再并行 | 避免并行递增计数器 |
| D10 | rebuild 和 embed 进度各自独立策略 | rebuild 用 as_completed 打印embed 保持机器协议 + tqdm |
| D11 | `compute_display_fields()` 转为 `@staticmethod` | 同时被 `__post_init__``compute_maintenance_manifest()` 调用 |
| D12 | CLI 文档从 `build_parser()` 逐项核对 | 避免手写错误命令名 |
| D13 | embed 进度改 `commands/embed.py`,不动 `embedding/builder.py` | **审核后修正**:真正 build loop 在 commands 层 |
| D14 | 保留 `EMBED_START/EMBED_PROGRESS/EMBED_DONE` stdout 协议 | 插件端依赖此协议 |

View file

@ -249,6 +249,11 @@ def build_parser() -> argparse.ArgumentParser:
rebuild_parser.add_argument("--status", metavar="STATUS", help="Filter by OCR status (done, done_degraded, failed)")
rebuild_parser.add_argument("--dry-run", action="store_true", help="List papers that would be rebuilt without executing")
rebuild_parser.add_argument("--resume", action="store_true", help="Skip papers already in checkpoint")
parallel_group = rebuild_parser.add_mutually_exclusive_group()
parallel_group.add_argument("--parallel", type=int, nargs="?", const=4, default=4, metavar="N",
help="Number of parallel workers (default: 4)")
parallel_group.add_argument("--no-parallel", dest="parallel", action="store_const", const=0,
help="Disable parallel processing (serial)")
# context (Phase 26: traceable AI context packs)
p_context = sub.add_parser("context", help="Generate traceable AI context pack for paper(s)")

View file

@ -20,6 +20,7 @@ from paperforge.embedding.preflight import _preflight_check
from paperforge.memory.chunker import chunk_fulltext
from paperforge.memory.state_snapshot import write_vector_runtime
from paperforge.worker.asset_index import read_index
from paperforge.worker._progress import progress_bar
def run(args: argparse.Namespace) -> int:
@ -183,7 +184,8 @@ def run(args: argparse.Namespace) -> int:
)
i = 0
for entry in done_papers:
papers_iter = progress_bar(done_papers, desc="Embedding", disable=args.json)
for entry in papers_iter:
key = entry.get("zotero_key")
fulltext_rel = entry.get("fulltext_path", "")
if not fulltext_rel:

View file

@ -323,6 +323,7 @@ def _run_ocr_rebuild(
status_filter: str | None = None,
dry_run: bool = False,
resume: bool = False,
parallel_workers: int = 4,
) -> int:
"""Rebuild OCR-derived artifacts from existing raw blocks."""
from paperforge.worker.ocr_maintenance import collect_maintenance_rows
@ -345,11 +346,10 @@ def _run_ocr_rebuild(
print("No papers matched for rebuild.")
return 0
# Resume: skip keys already in checkpoint
cp = vault / "System" / "PaperForge" / ".ocr_rebuild_checkpoint.json"
if resume and cp.exists():
import json
done = set(json.loads(cp.read_text(encoding="utf-8")))
# Resume: skip keys already in checkpoint (.done.* markers)
cp_dir = vault / "System" / "PaperForge" / ".ocr_rebuild_checkpoint"
if resume and cp_dir.exists():
done = {p.name.removeprefix(".done.") for p in cp_dir.glob(".done.*")}
skipped = [k for k in keys if k in done]
keys = [k for k in keys if k not in done]
if skipped:
@ -366,7 +366,12 @@ def _run_ocr_rebuild(
return 0
from paperforge.worker._progress import progress_bar
result = run_derived_rebuild_for_keys(vault, keys, progress_bar=progress_bar, checkpoint=cp)
result = run_derived_rebuild_for_keys(
vault, keys,
progress_bar=progress_bar,
checkpoint_dir=cp_dir if resume else None,
parallel=parallel_workers,
)
count = result.get("rebuild_count", 0)
print(f"Done. Rebuilt {count} paper(s).")
return 0
@ -417,6 +422,7 @@ def run(args: argparse.Namespace) -> int:
)
if ocr_action == "rebuild":
parallel_workers = 0 if getattr(args, "no_parallel", False) else max(1, int(getattr(args, "parallel", 4) or 4))
return _run_ocr_rebuild(
vault,
keys=getattr(args, "keys", None) or None,
@ -424,6 +430,7 @@ def run(args: argparse.Namespace) -> int:
status_filter=getattr(args, "status", None),
dry_run=getattr(args, "dry_run", False),
resume=getattr(args, "resume", False),
parallel_workers=parallel_workers,
)
if key:

View file

@ -46,6 +46,97 @@ class OCRMaintenanceRow:
fulltext_drift_state: str = "UNKNOWN"
fulltext_drift_reason: str = ""
show_in_base: bool = True
def __post_init__(self) -> None:
df = self.compute_display_fields(
status=self.status, health_overall=self.health,
version=self.version, can_redo=self.can_redo,
can_rebuild=self.can_rebuild, error_stage=self.error_stage,
error_summary=self.error_summary,
degraded_reasons=self.degraded_reasons,
)
for k, v in df.items():
setattr(self, k, v)
@staticmethod
def compute_display_fields(
status: str,
health_overall: str,
version: str,
can_redo: bool,
can_rebuild: bool,
error_stage: str = "",
error_summary: str = "",
degraded_reasons: list[str] | None = None,
) -> dict:
"""Map raw OCR state to display fields for the maintenance tab."""
is_degraded = health_overall in ("yellow", "red") or status == "done_degraded"
if status in ("pending",):
return dict(display_action="none", display_label="等待处理", display_label_key="",
display_reason="", display_reason_key="",
display_group="hidden", display_severity="normal",
visible_in_maintenance=False, show_in_base=True)
if status in ("running", "queued", "processing"):
return dict(display_action="none", display_label="处理中", display_label_key="",
display_reason="", display_reason_key="",
display_group="hidden", display_severity="normal",
visible_in_maintenance=False, show_in_base=True)
if status in ("failed", "error", "fatal_error", "done_incomplete", "retryable_error") and can_redo:
return dict(display_action="retry_ocr", display_label="重试 OCR",
display_label_key="maintenance_action_retry_ocr",
display_reason="上次处理未完成,可以重新尝试",
display_reason_key="maintenance_reason_retry",
display_group="retry", display_severity="actionable",
visible_in_maintenance=True, show_in_base=True)
if version == "v1" and can_redo:
return dict(display_action="upgrade_legacy", display_label="升级旧结果",
display_label_key="maintenance_action_upgrade_legacy",
display_reason="旧版本结果仍然可用,升级后可获得更好的章节、图表和问答效果",
display_reason_key="maintenance_reason_legacy",
display_group="legacy_optional", display_severity="optional",
visible_in_maintenance=True, show_in_base=True)
if is_degraded and can_rebuild:
return dict(display_action="rebuild_result", display_label="重建结果",
display_label_key="",
display_reason="已有OCR数据可重建获得更稳定的结果",
display_reason_key="",
display_group="rebuild", display_severity="actionable",
visible_in_maintenance=True, show_in_base=True)
if is_degraded and not can_rebuild and can_redo:
return dict(display_action="retry_ocr", display_label="重试 OCR",
display_label_key="",
display_reason="降级结果无法重建可重新OCR",
display_reason_key="",
display_group="retry", display_severity="actionable",
visible_in_maintenance=True, show_in_base=True)
if status == "nopdf":
return dict(display_action="add_pdf", display_label="补充 PDF",
display_label_key="",
display_reason="请去 Zotero 添加 PDF 文件",
display_reason_key="",
display_group="external_action", display_severity="external",
visible_in_maintenance=False, show_in_base=True)
if status == "blocked":
return dict(display_action="configure_ocr", display_label="配置 OCR",
display_label_key="",
display_reason="请配置 PaddleOCR API Token",
display_reason_key="",
display_group="external_action", display_severity="external",
visible_in_maintenance=False, show_in_base=True)
if status == "done" and not is_degraded:
return dict(display_action="none", display_label="已完成", display_label_key="",
display_reason="", display_reason_key="",
display_group="hidden", display_severity="normal",
visible_in_maintenance=False, show_in_base=True)
if not can_redo and not can_rebuild:
return dict(display_action="none", display_label="已完成", display_label_key="",
display_reason="", display_reason_key="",
display_group="hidden", display_severity="normal",
visible_in_maintenance=False, show_in_base=False)
return dict(display_action="none", display_label="已完成", display_label_key="",
display_reason="", display_reason_key="",
display_group="hidden", display_severity="normal",
visible_in_maintenance=False, show_in_base=True)
def to_dict(self) -> dict:
return {
@ -183,85 +274,7 @@ def _recommended_action(meta: dict, has_raw: bool, has_source_meta: bool) -> str
return ""
def _compute_display_fields(
status: str,
health_overall: str,
version: str,
can_redo: bool,
can_rebuild: bool,
error_stage: str = "",
error_summary: str = "",
degraded_reasons: list[str] | None = None,
) -> dict:
"""Map raw OCR state to display fields for the maintenance tab."""
is_degraded = health_overall in ("yellow", "red") or status == "done_degraded"
if status in ("pending",):
return dict(display_action="none", display_label="等待处理", display_label_key="",
display_reason="", display_reason_key="",
display_group="hidden", display_severity="normal",
visible_in_maintenance=False, show_in_base=True)
if status in ("running", "queued", "processing"):
return dict(display_action="none", display_label="处理中", display_label_key="",
display_reason="", display_reason_key="",
display_group="hidden", display_severity="normal",
visible_in_maintenance=False, show_in_base=True)
if status in ("failed", "error", "fatal_error", "done_incomplete", "retryable_error") and can_redo:
return dict(display_action="retry_ocr", display_label="重试 OCR",
display_label_key="maintenance_action_retry_ocr",
display_reason="上次处理未完成,可以重新尝试",
display_reason_key="maintenance_reason_retry",
display_group="retry", display_severity="actionable",
visible_in_maintenance=True, show_in_base=True)
if version == "v1" and can_redo:
return dict(display_action="upgrade_legacy", display_label="升级旧结果",
display_label_key="maintenance_action_upgrade_legacy",
display_reason="旧版本结果仍然可用,升级后可获得更好的章节、图表和问答效果",
display_reason_key="maintenance_reason_legacy",
display_group="legacy_optional", display_severity="optional",
visible_in_maintenance=True, show_in_base=True)
if is_degraded and can_rebuild:
return dict(display_action="rebuild_result", display_label="重建结果",
display_label_key="",
display_reason="已有OCR数据可重建获得更稳定的结果",
display_reason_key="",
display_group="rebuild", display_severity="actionable",
visible_in_maintenance=True, show_in_base=True)
if is_degraded and not can_rebuild and can_redo:
return dict(display_action="retry_ocr", display_label="重试 OCR",
display_label_key="",
display_reason="降级结果无法重建可重新OCR",
display_reason_key="",
display_group="retry", display_severity="actionable",
visible_in_maintenance=True, show_in_base=True)
if status == "nopdf":
return dict(display_action="add_pdf", display_label="补充 PDF",
display_label_key="",
display_reason="请去 Zotero 添加 PDF 文件",
display_reason_key="",
display_group="external_action", display_severity="external",
visible_in_maintenance=False, show_in_base=True)
if status == "blocked":
return dict(display_action="configure_ocr", display_label="配置 OCR",
display_label_key="",
display_reason="请配置 PaddleOCR API Token",
display_reason_key="",
display_group="external_action", display_severity="external",
visible_in_maintenance=False, show_in_base=True)
if status == "done" and not is_degraded:
return dict(display_action="none", display_label="已完成", display_label_key="",
display_reason="", display_reason_key="",
display_group="hidden", display_severity="normal",
visible_in_maintenance=False, show_in_base=True)
if not can_redo and not can_rebuild:
return dict(display_action="none", display_label="已完成", display_label_key="",
display_reason="", display_reason_key="",
display_group="hidden", display_severity="normal",
visible_in_maintenance=False, show_in_base=False)
return dict(display_action="none", display_label="已完成", display_label_key="",
display_reason="", display_reason_key="",
display_group="hidden", display_severity="normal",
visible_in_maintenance=False, show_in_base=True)
_compute_display_fields = OCRMaintenanceRow.compute_display_fields
def compute_maintenance_manifest(vault: Path) -> dict[str, str]:
@ -299,7 +312,7 @@ def compute_maintenance_manifest(vault: Path) -> dict[str, str]:
rec_action = _recommended_action(meta, has_raw, has_source_meta)
# Compute display fields for hash
df = _compute_display_fields(
df = OCRMaintenanceRow.compute_display_fields(
status=status, health_overall=health_overall,
version=version, can_redo=can_redo, can_rebuild=can_rebuild,
error_stage=_error_stage(meta),

View file

@ -1,14 +1,29 @@
from __future__ import annotations
import contextlib
import os
import re
import threading
from collections.abc import Callable
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
from typing import Any
from paperforge.worker.ocr_math import normalize_ocr_math_text
_RENDER_LOCKS: dict[tuple[str, int], threading.Lock] = {}
_RENDER_LOCKS_LOCK = threading.Lock()
def _get_render_lock(page_cache_dir: Path, page_num: int) -> threading.Lock:
key = (str(page_cache_dir), page_num)
with _RENDER_LOCKS_LOCK:
if key not in _RENDER_LOCKS:
_RENDER_LOCKS[key] = threading.Lock()
return _RENDER_LOCKS[key]
def render_figure_object_markdown(figure: dict[str, Any]) -> str:
caption = figure.get("caption", "")
image_relpath = figure.get("image_relpath", "")
@ -101,8 +116,6 @@ def _crop_asset_from_pdf(
rotation_deg: int = 0,
) -> bool:
if dst.exists():
with contextlib.suppress(Exception):
dst.unlink()
@ -134,26 +147,35 @@ def _crop_asset_from_pdf(
try:
if page_width > 0 and page_height > 0 and page_cache_dir is not None and not rotation_deg:
try:
from paperforge.worker.ocr import crop_block_asset, render_pdf_page_cached
except ImportError:
return False
try:
page_image_path = page_cache_dir / f"page_{page_num:03d}.jpg"
rendered = render_pdf_page_cached(
doc,
page_num,
target_width=page_width,
target_height=page_height,
destination=page_image_path,
)
if not rendered:
lock = _get_render_lock(page_cache_dir, page_num)
with lock:
cached_page_image = _find_cached_page_image(page_cache_dir, page_num)
if cached_page_image is not None:
try:
from paperforge.worker.ocr import crop_block_asset
except ImportError:
return False
ok = crop_block_asset(cached_page_image, [int(v) for v in bbox], dst)
return ok
try:
from paperforge.worker.ocr import crop_block_asset, render_pdf_page_cached
except ImportError:
return False
try:
page_image_path = page_cache_dir / f"page_{page_num:03d}.jpg"
rendered = render_pdf_page_cached(
doc,
page_num,
target_width=page_width,
target_height=page_height,
destination=page_image_path,
)
if not rendered:
return False
ok = crop_block_asset(rendered, [int(v) for v in bbox], dst)
return ok
except Exception:
return False
ok = crop_block_asset(rendered, [int(v) for v in bbox], dst)
return ok
except Exception:
return False
try:
import fitz
@ -190,6 +212,163 @@ def _crop_asset_from_pdf(
doc.close()
def _write_figure_object_task(
data: dict[str, Any],
*,
pdf_path: Path | None,
page_cache_dir: Path | None,
asset_dir: Path,
render_dir: Path,
) -> None:
"""Extract crop and write markdown for one figure/cluster object."""
fig_id = data["fig_id"]
page = data["page"]
page_width = data["page_width"]
page_height = data["page_height"]
crop_bbox = data["crop_bbox"]
rotation_deg = data.get("rotation_deg", 0)
caption = data.get("caption", "")
asset_path_rel = data["asset_path_rel"]
asset_path_abs = asset_dir / f"{fig_id}.jpg"
was_cropped = False
if crop_bbox and all(v > 0 for v in crop_bbox):
was_cropped = _crop_asset_from_pdf(
pdf_path,
page,
crop_bbox,
asset_path_abs,
page_width=page_width,
page_height=page_height,
page_cache_dir=page_cache_dir,
pdf_doc=None,
pdf_doc_provider=None,
rotation_deg=rotation_deg,
)
if not was_cropped:
for asset_info in data.get("matched_assets", []):
bbox = asset_info.get("bbox", [0, 0, 0, 0])
if pdf_path and bbox and all(v > 0 for v in bbox):
if _crop_asset_from_pdf(
pdf_path,
page,
bbox,
asset_path_abs,
page_width=page_width,
page_height=page_height,
page_cache_dir=page_cache_dir,
pdf_doc=None,
pdf_doc_provider=None,
rotation_deg=rotation_deg,
):
was_cropped = True
break
md = render_figure_object_markdown(
{
"figure_id": fig_id,
"page": page,
"caption": caption,
"image_relpath": asset_path_rel,
"confidence": data.get("confidence", 0.5),
"was_cropped": was_cropped,
}
)
_write_object_markdown(md, render_dir / f"{fig_id}.md")
def _write_table_object_task(
data: dict[str, Any],
*,
pdf_path: Path | None,
page_cache_dir: Path | None,
asset_dir: Path,
render_dir: Path,
) -> None:
"""Extract crop and write markdown for one table object."""
tbl_id = data["tbl_id"]
page = data["page"]
page_width = data["page_width"]
page_height = data["page_height"]
crop_bbox = data["crop_bbox"]
rotation_deg = data.get("rotation_deg", 0)
caption = data.get("caption", "")
asset_path_rel = data["asset_path_rel"]
asset_path_abs = asset_dir / f"{tbl_id}.jpg"
was_cropped = False
if data.get("has_asset") and pdf_path and crop_bbox and all(v > 0 for v in crop_bbox):
was_cropped = _crop_asset_from_pdf(
pdf_path,
page,
crop_bbox,
asset_path_abs,
page_width=page_width,
page_height=page_height,
page_cache_dir=page_cache_dir,
pdf_doc=None,
pdf_doc_provider=None,
rotation_deg=rotation_deg,
)
md = render_table_object_markdown(
{
"table_id": tbl_id,
"page": page,
"caption": caption,
"image_relpath": asset_path_rel,
"confidence": 0.85 if was_cropped else 0.4,
"formal_table_number": data.get("formal_table_number"),
"note_texts": data.get("note_texts", []),
"note_match_reason": data.get("note_match_reason", ""),
}
)
_write_object_markdown(md, render_dir / f"{tbl_id}.md")
def _write_orphan_object_task(
data: dict[str, Any],
*,
pdf_path: Path | None,
page_cache_dir: Path | None,
asset_dir: Path,
render_dir: Path,
) -> None:
"""Extract crop and write markdown for one orphan object."""
orphan_id = data["orphan_id"]
page = data["page"]
page_width = data["page_width"]
page_height = data["page_height"]
bbox = data["bbox"]
asset_path_rel = data["asset_path_rel"]
asset_path_abs = asset_dir / f"{orphan_id}.jpg"
if pdf_path and bbox and all(v > 0 for v in bbox):
_crop_asset_from_pdf(
pdf_path,
page,
bbox,
asset_path_abs,
page_width=page_width,
page_height=page_height,
page_cache_dir=page_cache_dir,
pdf_doc=None,
pdf_doc_provider=None,
)
md = render_figure_object_markdown(
{
"figure_id": orphan_id,
"page": page,
"caption": "",
"image_relpath": asset_path_rel,
"confidence": 0.3,
}
)
_write_object_markdown(md, render_dir / f"{orphan_id}.md")
def extract_and_write_objects(
pdf_path: Path | None,
figure_inventory: dict[str, Any],
@ -238,260 +417,194 @@ def extract_and_write_objects(
def _page_dims(page_num: int) -> tuple[int, int]:
return page_dimensions_by_page.get(page_num, (0, 0))
shared_pdf_doc: Any | None = None
shared_pdf_open_attempted = False
# ---- Serial phase: build task list with stable IDs ----
tasks: list[tuple[str, dict[str, Any]]] = []
def _get_shared_pdf_doc() -> Any | None:
nonlocal shared_pdf_doc, shared_pdf_open_attempted
if shared_pdf_doc is not None:
return shared_pdf_doc
if shared_pdf_open_attempted:
return None
if pdf_path is None or not pdf_path.exists():
shared_pdf_open_attempted = True
return None
shared_pdf_open_attempted = True
try:
import fitz
shared_pdf_doc = fitz.open(str(pdf_path))
except Exception:
return None
return shared_pdf_doc
# Matched figures
for i, match in enumerate(figure_inventory.get("matched_figures", [])):
fig_id = match.get("figure_id", f"figure_{i + 1:03d}")
caption_text = match.get("text", "")
page = match.get("page", 0)
page_width, page_height = _page_dims(page)
rotation_deg = int(match.get("rotation_correction_deg", 0) or 0)
try:
# Process matched figures
for i, match in enumerate(figure_inventory.get("matched_figures", [])):
fig_id = match.get("figure_id", f"figure_{i + 1:03d}")
caption_text = match.get("text", "")
page = match.get("page", 0)
page_width, page_height = _page_dims(page)
asset_path_rel = f"assets/figures/{fig_id}.jpg"
asset_path_abs = figures_asset_dir / f"{fig_id}.jpg"
rotation_deg = int(match.get("rotation_correction_deg", 0) or 0)
# Build crop bbox: cluster_bbox, else union of matched_assets,
# expanded to include owned figure_inner_text blocks (same page)
crop_bbox = match.get("cluster_bbox") or [0, 0, 0, 0]
if not (len(crop_bbox) == 4 and all(v > 0 for v in crop_bbox)):
asset_bboxes = [
a.get("bbox", [0, 0, 0, 0])
for a in match.get("matched_assets", [])
if len(a.get("bbox") or []) >= 4 and all(v > 0 for v in a.get("bbox", [0, 0, 0, 0]))
crop_bbox = match.get("cluster_bbox") or [0, 0, 0, 0]
if not (len(crop_bbox) == 4 and all(v > 0 for v in crop_bbox)):
asset_bboxes = [
a.get("bbox", [0, 0, 0, 0])
for a in match.get("matched_assets", [])
if len(a.get("bbox") or []) >= 4 and all(v > 0 for v in a.get("bbox", [0, 0, 0, 0]))
]
if asset_bboxes:
crop_bbox = [
min(b[0] for b in asset_bboxes),
min(b[1] for b in asset_bboxes),
max(b[2] for b in asset_bboxes),
max(b[3] for b in asset_bboxes),
]
if asset_bboxes:
crop_bbox = [
min(b[0] for b in asset_bboxes),
min(b[1] for b in asset_bboxes),
max(b[2] for b in asset_bboxes),
max(b[3] for b in asset_bboxes),
]
if structured_blocks and len(crop_bbox) == 4 and all(v > 0 for v in crop_bbox):
for blk in structured_blocks:
if blk.get("role") != "figure_inner_text":
continue
if str(blk.get("_object_owner_id", "")) != str(fig_id):
continue
if int(blk.get("page", 0) or 0) != int(page or 0):
continue
bb = blk.get("bbox") or [0, 0, 0, 0]
if len(bb) < 4 or not all(v > 0 for v in bb):
continue
crop_bbox = [
min(crop_bbox[0], bb[0]),
min(crop_bbox[1], bb[1]),
max(crop_bbox[2], bb[2]),
max(crop_bbox[3], bb[3]),
]
if structured_blocks and len(crop_bbox) == 4 and all(v > 0 for v in crop_bbox):
for blk in structured_blocks:
if blk.get("role") != "figure_inner_text":
continue
if str(blk.get("_object_owner_id", "")) != str(fig_id):
continue
if int(blk.get("page", 0) or 0) != int(page or 0):
continue
bb = blk.get("bbox") or [0, 0, 0, 0]
if len(bb) < 4 or not all(v > 0 for v in bb):
continue
crop_bbox = [
min(crop_bbox[0], bb[0]),
min(crop_bbox[1], bb[1]),
max(crop_bbox[2], bb[2]),
max(crop_bbox[3], bb[3]),
]
was_cropped = False
if len(crop_bbox) == 4 and all(v > 0 for v in crop_bbox):
was_cropped = _crop_asset_from_pdf(
pdf_path,
page,
crop_bbox,
asset_path_abs,
page_width=page_width,
page_height=page_height,
tasks.append((
"figure",
{
"fig_id": fig_id,
"page": page,
"page_width": page_width,
"page_height": page_height,
"crop_bbox": crop_bbox,
"rotation_deg": rotation_deg,
"matched_assets": match.get("matched_assets", []),
"caption": caption_text,
"asset_path_rel": f"assets/figures/{fig_id}.jpg",
"confidence": match.get("confidence", 0.5),
},
))
# Unresolved figure clusters
for i, cluster in enumerate(figure_inventory.get("unresolved_clusters", [])):
cluster_id = cluster.get("cluster_id") or f"unresolved_cluster_{i + 1:03d}"
page = cluster.get("page", 0)
page_width, page_height = _page_dims(page)
bbox = cluster.get("cluster_bbox", [0, 0, 0, 0])
tasks.append((
"figure",
{
"fig_id": cluster_id,
"page": page,
"page_width": page_width,
"page_height": page_height,
"crop_bbox": bbox,
"rotation_deg": 0,
"matched_assets": [],
"caption": "",
"asset_path_rel": f"assets/figures/{cluster_id}.jpg",
"confidence": 0.45,
},
))
# Pre-allocate orphan IDs (stable ordering across figure + table orphans)
num_figure_orphans = len(figure_inventory.get("unmatched_assets", []))
num_table_orphans = len(table_inventory.get("unmatched_assets", []))
orphan_index = 0
# Figure unmatched assets as orphans
for asset in figure_inventory.get("unmatched_assets", []):
orphan_index += 1
orphan_id = f"orphan_{orphan_index:03d}"
page = asset.get("page", 0)
page_width, page_height = _page_dims(page)
bbox = asset.get("bbox", [0, 0, 0, 0])
tasks.append((
"orphan",
{
"orphan_id": orphan_id,
"page": page,
"page_width": page_width,
"page_height": page_height,
"bbox": bbox,
"asset_path_rel": f"assets/orphans/{orphan_id}.jpg",
},
))
# Tables
for i, table in enumerate(table_inventory.get("tables", [])):
tbl_id = f"table_{i + 1:03d}"
caption_text = table.get("caption_text", "")
page = table.get("page", 0)
page_width, page_height = _page_dims(page)
asset_bbox = table.get("asset_bbox", [0, 0, 0, 0])
crop_bbox = table.get("render_bbox") or asset_bbox
rotation_deg = table.get("render_rotation_deg", 0) or 0
tasks.append((
"table",
{
"tbl_id": tbl_id,
"page": page,
"page_width": page_width,
"page_height": page_height,
"crop_bbox": crop_bbox,
"rotation_deg": rotation_deg,
"caption": caption_text,
"has_asset": table.get("has_asset", False),
"asset_path_rel": f"assets/tables/{tbl_id}.jpg",
"formal_table_number": table.get("formal_table_number") or table.get("table_number"),
"note_texts": table.get("note_texts", []),
"note_match_reason": table.get("note_match_reason", ""),
},
))
# Table unmatched assets as orphans (continue from figure orphan index)
for asset in table_inventory.get("unmatched_assets", []):
orphan_index += 1
orphan_id = f"orphan_{orphan_index:03d}"
page = asset.get("page", 0)
page_width, page_height = _page_dims(page)
bbox = asset.get("bbox", [0, 0, 0, 0])
tasks.append((
"orphan",
{
"orphan_id": orphan_id,
"page": page,
"page_width": page_width,
"page_height": page_height,
"bbox": bbox,
"asset_path_rel": f"assets/orphans/{orphan_id}.jpg",
},
))
# ---- Parallel phase: dispatch crops and markdown writes ----
max_workers = min(2, os.cpu_count() or 4)
with ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = []
for task_type, data in tasks:
if task_type == "figure":
futures.append(executor.submit(
_write_figure_object_task,
data,
pdf_path=pdf_path,
page_cache_dir=page_cache_dir,
pdf_doc_provider=_get_shared_pdf_doc,
rotation_deg=rotation_deg,
)
if not was_cropped:
for asset_info in match.get("matched_assets", []):
bbox = asset_info.get("bbox", [0, 0, 0, 0])
if (
pdf_path
and bbox
and all(v > 0 for v in bbox)
and _crop_asset_from_pdf(
pdf_path,
page,
bbox,
asset_path_abs,
page_width=page_width,
page_height=page_height,
page_cache_dir=page_cache_dir,
pdf_doc_provider=_get_shared_pdf_doc,
rotation_deg=rotation_deg,
)
):
was_cropped = True
break
md = render_figure_object_markdown(
{
"figure_id": fig_id,
"page": page,
"caption": caption_text,
"image_relpath": asset_path_rel,
"confidence": match.get("confidence", 0.5),
"was_cropped": was_cropped,
}
)
_write_object_markdown(md, figures_render_dir / f"{fig_id}.md")
# Process unresolved figure clusters (multi-panel without reliable legend)
for i, cluster in enumerate(figure_inventory.get("unresolved_clusters", [])):
cluster_id = cluster.get("cluster_id") or f"unresolved_cluster_{i + 1:03d}"
page = cluster.get("page", 0)
bbox = cluster.get("cluster_bbox", [0, 0, 0, 0])
page_width, page_height = _page_dims(page)
asset_path_rel = f"assets/figures/{cluster_id}.jpg"
asset_path_abs = figures_asset_dir / f"{cluster_id}.jpg"
if pdf_path and bbox and all(v > 0 for v in bbox):
_crop_asset_from_pdf(
pdf_path,
page,
bbox,
asset_path_abs,
page_width=page_width,
page_height=page_height,
asset_dir=figures_asset_dir,
render_dir=figures_render_dir,
))
elif task_type == "table":
futures.append(executor.submit(
_write_table_object_task,
data,
pdf_path=pdf_path,
page_cache_dir=page_cache_dir,
pdf_doc_provider=_get_shared_pdf_doc,
)
md = render_figure_object_markdown(
{
"figure_id": cluster_id,
"page": page,
"caption": "",
"image_relpath": asset_path_rel,
"confidence": 0.45,
}
)
_write_object_markdown(md, figures_render_dir / f"{cluster_id}.md")
# Process unmatched assets as orphans
orphan_count = 0
for asset in figure_inventory.get("unmatched_assets", []):
orphan_count += 1
orphan_id = f"orphan_{orphan_count:03d}"
bbox = asset.get("bbox", [0, 0, 0, 0])
page = asset.get("page", 0)
page_width, page_height = _page_dims(page)
asset_path_rel = f"assets/orphans/{orphan_id}.jpg"
asset_path_abs = orphans_asset_dir / f"{orphan_id}.jpg"
if pdf_path and bbox and all(v > 0 for v in bbox):
_crop_asset_from_pdf(
pdf_path,
page,
bbox,
asset_path_abs,
page_width=page_width,
page_height=page_height,
asset_dir=tables_asset_dir,
render_dir=tables_render_dir,
))
elif task_type == "orphan":
futures.append(executor.submit(
_write_orphan_object_task,
data,
pdf_path=pdf_path,
page_cache_dir=page_cache_dir,
pdf_doc_provider=_get_shared_pdf_doc,
)
asset_dir=orphans_asset_dir,
render_dir=figures_render_dir,
))
md = render_figure_object_markdown(
{
"figure_id": orphan_id,
"page": page,
"caption": "",
"image_relpath": asset_path_rel,
"confidence": 0.3,
}
)
_write_object_markdown(md, figures_render_dir / f"{orphan_id}.md")
# Process tables
for i, table in enumerate(table_inventory.get("tables", [])):
tbl_id = f"table_{i + 1:03d}"
caption_text = table.get("caption_text", "")
page = table.get("page", 0)
page_width, page_height = _page_dims(page)
asset_bbox = table.get("asset_bbox", [0, 0, 0, 0])
asset_path_rel = f"assets/tables/{tbl_id}.jpg"
asset_path_abs = tables_asset_dir / f"{tbl_id}.jpg"
was_cropped = False
# Use render_bbox + render_rotation_deg for rotated tables
_crop_bbox = table.get("render_bbox") or asset_bbox
_rot_deg = table.get("render_rotation_deg", 0) or 0
if table.get("has_asset") and pdf_path and _crop_bbox and all(v > 0 for v in _crop_bbox):
was_cropped = _crop_asset_from_pdf(
pdf_path,
page,
_crop_bbox,
asset_path_abs,
page_width=page_width,
page_height=page_height,
page_cache_dir=page_cache_dir,
pdf_doc_provider=_get_shared_pdf_doc,
rotation_deg=_rot_deg,
)
md = render_table_object_markdown(
{
"table_id": tbl_id,
"page": page,
"caption": caption_text,
"image_relpath": asset_path_rel,
"confidence": 0.85 if was_cropped else 0.4,
"formal_table_number": table.get("formal_table_number") or table.get("table_number"),
"note_texts": table.get("note_texts", []),
"note_match_reason": table.get("note_match_reason", ""),
}
)
_write_object_markdown(md, tables_render_dir / f"{tbl_id}.md")
# Process unmatched table assets as orphans
for asset in table_inventory.get("unmatched_assets", []):
orphan_count += 1
orphan_id = f"orphan_{orphan_count:03d}"
bbox = asset.get("bbox", [0, 0, 0, 0])
page = asset.get("page", 0)
page_width, page_height = _page_dims(page)
asset_path_rel = f"assets/orphans/{orphan_id}.jpg"
asset_path_abs = orphans_asset_dir / f"{orphan_id}.jpg"
if pdf_path and bbox and all(v > 0 for v in bbox):
_crop_asset_from_pdf(
pdf_path,
page,
bbox,
asset_path_abs,
page_width=page_width,
page_height=page_height,
page_cache_dir=page_cache_dir,
pdf_doc_provider=_get_shared_pdf_doc,
)
md = render_figure_object_markdown(
{
"figure_id": orphan_id,
"page": page,
"caption": "",
"image_relpath": asset_path_rel,
"confidence": 0.3,
}
)
_write_object_markdown(md, figures_render_dir / f"{orphan_id}.md")
finally:
if shared_pdf_doc is not None:
with contextlib.suppress(Exception):
shared_pdf_doc.close()
for future in as_completed(futures):
future.result()

View file

@ -2,10 +2,13 @@ from __future__ import annotations
import datetime
import json
import logging
from pathlib import Path
from paperforge.core.io import read_json, write_json
logger = logging.getLogger(__name__)
CURRENT_SPAN_BACKFILL_VERSION = "2026-07-01.1"
CURRENT_SPAN_VISUAL_CONTAINER_VERSION = "2026-06-26.6"
MIN_SPAN_BACKFILL_COVERAGE = 0.90
@ -116,37 +119,53 @@ def select_papers_for_derived_rebuild(papers: list[dict]) -> list[str]:
return [p["zotero_key"] for p in papers if p.get("derived_stale") and not p.get("raw_upgradable")]
def run_derived_rebuild_for_keys(vault: Path, keys: list[str], progress_bar=None, checkpoint: Path | None = None) -> dict:
"""Run derived-layer rebuild for the given paper keys without raw OCR rerun.
Rebuilds: structured blocks, metadata, figure/table inventories, objects,
render outputs, and health from stored raw blocks only.
If checkpoint is provided, appends each rebuilt key so interrupted runs
can skip completed work via --resume.
def _filter_completed_keys(checkpoint_dir: Path | None, keys: list[str]) -> list[str]:
"""Return keys that do not have a .done.<key> marker in checkpoint_dir."""
if not checkpoint_dir:
return keys
cp = Path(checkpoint_dir)
if not cp.exists():
return keys
done = {p.name.removeprefix(".done.") for p in cp.glob(".done.*")}
return [k for k in keys if k not in done]
def _write_done_marker(checkpoint_dir: Path | None, key: str) -> None:
"""Write a completion marker for a successfully rebuilt paper."""
if not checkpoint_dir:
return
(Path(checkpoint_dir) / f".done.{key}").touch()
def _rebuild_one_paper(vault: Path, key: str) -> dict:
"""Rebuild derived artifacts for a single paper. Module-level for pickle.
Returns dict with status ('ok', 'skipped') and details.
"""
from paperforge.worker._utils import pipeline_paths, read_jsonl
from paperforge.worker.ocr import validate_ocr_meta
from paperforge.worker.ocr_artifacts import artifact_paths_for_root
ocr_root = pipeline_paths(vault)["ocr"]
rebuilt_count = 0
artifacts = artifact_paths_for_root(ocr_root, key)
paper_root = artifacts.paper_root
keys_iter = progress_bar(keys, desc="OCR rebuild") if progress_bar else keys
for key in keys_iter:
artifacts = artifact_paths_for_root(ocr_root, key)
paper_root = artifacts.paper_root
if not paper_root.exists():
continue
if not paper_root.exists():
return {"key": key, "status": "skipped", "reason": "no_paper_dir"}
if not artifacts.blocks_raw.exists():
return {"key": key, "status": "skipped", "reason": "no_raw_blocks"}
# Read stored raw blocks
if not artifacts.blocks_raw.exists():
continue
all_raw_blocks = list(read_jsonl(artifacts.blocks_raw))
all_raw_blocks = list(read_jsonl(artifacts.blocks_raw))
ocr_meta = read_json(artifacts.meta_json) if artifacts.meta_json.exists() else {}
# ── Phase 1: clean raw blocks and span backfill ──
def _phase1_span_backfill() -> dict:
"""Reject overlapping fallback blocks, backfill span_metadata from PDF.
Returns (span_meta_patch, source_pdf_path). Modifies all_raw_blocks in place."""
nonlocal all_raw_blocks
# Reject pdf_text_layer_fallback blocks whose text overlaps >=80% with
# a non-backfill block on the same page. These are column fragments
# the PDF text layer spilled during the initial OCR backfill.
from paperforge.worker.ocr_pdf_spans import (
_BACKFILL_OVERLAP_REJECT_THRESHOLD,
_backfill_coverage_in_existing,
@ -175,10 +194,7 @@ def run_derived_rebuild_for_keys(vault: Path, keys: list[str], progress_bar=None
block["_ocr_raw_error_type"] = "backfill_overlaps_existing_text_block"
block["_text_source"] = "pdf_text_layer_fallback_rejected"
# Backfill span_metadata from source PDF
ocr_meta = read_json(artifacts.meta_json) if artifacts.meta_json.exists() else {}
source_pdf_path = _resolve_source_pdf_for_rebuild(vault, key, ocr_meta)
span_meta_patch: dict[str, object] = {}
covered_count, eligible_count, coverage = _compute_span_backfill_coverage(all_raw_blocks)
@ -223,17 +239,19 @@ def run_derived_rebuild_for_keys(vault: Path, keys: list[str], progress_bar=None
status="rerun_backfill",
)
# Extract normalized PDF rawdict lines for asset-internal figure number recovery
return {"span_meta_patch": span_meta_patch, "source_pdf_path": source_pdf_path}
# ── Phase 2: PDF lines, enrich meta, build structured blocks ──
def _phase2_build_structured(source_pdf_path: Path | None) -> dict:
"""Extract PDF lines, enrich source metadata, build structured blocks.
Returns {structured, doc_structure, resolved, source_meta, page_pdf_lines_by_page}."""
from paperforge.worker.ocr_pdf_spans import extract_pdf_lines_normalized
page_pdf_lines_by_page = extract_pdf_lines_normalized(source_pdf_path)
# Read source metadata. If legacy/old OCR papers are missing canonical
# bibliographic metadata, enrich source_metadata.json from the formal
# Literature-hub note frontmatter before rebuilding OCR-derived layers.
_enrich_meta_from_paper_note(vault, key, artifacts.source_metadata)
source_meta = read_json(artifacts.source_metadata) if artifacts.source_metadata.exists() else {}
# Rebuild structured blocks
from paperforge.worker.ocr_blocks import build_structured_blocks, write_structured_blocks_jsonl
structured, doc_structure = build_structured_blocks(
@ -241,12 +259,11 @@ def run_derived_rebuild_for_keys(vault: Path, keys: list[str], progress_bar=None
source_metadata=source_meta,
structure_output_dir=artifacts.blocks_structured.parent,
)
# Write role-level span profiles
from paperforge.worker.ocr_profiles import write_role_span_profiles
write_role_span_profiles(structured, artifacts.blocks_structured.parent)
# Rebuild resolved metadata
from paperforge.worker.ocr_metadata import (
extract_frontmatter_candidates_from_blocks,
resolve_metadata,
@ -264,7 +281,21 @@ def run_derived_rebuild_for_keys(vault: Path, keys: list[str], progress_bar=None
)
write_resolved_metadata(metadata_dir / "resolved_metadata.json", resolved)
# Rebuild figure inventory
return {
"structured": structured,
"doc_structure": doc_structure,
"resolved": resolved,
"source_meta": source_meta,
"page_pdf_lines_by_page": page_pdf_lines_by_page,
}
# ── Phase 3: figure/table inventories, bio passes, writebacks ──
def _phase3_figure_tables(
structured: list[dict],
page_pdf_lines_by_page: dict[int, list[dict]],
source_meta: dict,
) -> dict:
"""Build figure and table inventories, run bio passes, resolve conflicts."""
from paperforge.worker.ocr_figures import (
build_figure_inventory,
write_back_figure_roles,
@ -274,7 +305,6 @@ def run_derived_rebuild_for_keys(vault: Path, keys: list[str], progress_bar=None
figure_inventory = build_figure_inventory(structured, page_pdf_lines_by_page=page_pdf_lines_by_page)
write_back_figure_roles(figure_inventory, structured)
# Author bio passes (Pass B + Pass C)
from paperforge.worker.ocr_bio import (
residual_author_bio_pass,
post_ref_bio_cleanup,
@ -289,7 +319,7 @@ def run_derived_rebuild_for_keys(vault: Path, keys: list[str], progress_bar=None
if ref_start_page is not None:
post_ref_bio_cleanup(figure_inventory, structured, ref_start_page=ref_start_page)
prune_figure_inventory_after_bio(figure_inventory)
# Rebuild reader figures
from paperforge.worker.ocr_figure_reader import synthesize_reader_figures
reader_payload = synthesize_reader_figures(figure_inventory, structured_blocks=structured)
@ -297,7 +327,6 @@ def run_derived_rebuild_for_keys(vault: Path, keys: list[str], progress_bar=None
reader_figures_dir.mkdir(parents=True, exist_ok=True)
write_json(reader_figures_dir / "reader_figures.json", reader_payload)
# Rebuild table inventory
from paperforge.worker.ocr_tables import build_table_inventory, write_back_table_roles, write_table_inventory
table_inventory = build_table_inventory(structured)
@ -306,8 +335,6 @@ def run_derived_rebuild_for_keys(vault: Path, keys: list[str], progress_bar=None
resolve_media_asset_conflicts(figure_inventory, table_inventory)
attach_ownership_conflicts(figure_inventory, table_inventory)
# Apply object writeback seam (ownership evidence, contained/side-adjacent text, consumed-block contract)
# Must run BEFORE write_figure_inventory so claims land in the persisted inventory.
from paperforge.worker.ocr_object_writeback import apply_object_writebacks
apply_object_writebacks(
@ -320,15 +347,32 @@ def run_derived_rebuild_for_keys(vault: Path, keys: list[str], progress_bar=None
write_back_table_roles(table_inventory, structured)
write_table_inventory(artifacts.blocks_structured.parent / "table_inventory.json", table_inventory)
# Re-persist structured blocks with writeback roles (table_html, figure_asset)
# ponytail: writes entire list again; if throughput matters, write only changed blocks
write_structured_blocks_jsonl(artifacts.blocks_structured, structured)
from paperforge.worker.ocr_blocks import write_structured_blocks_jsonl as _write_structured_blocks_jsonl
_write_structured_blocks_jsonl(artifacts.blocks_structured, structured)
# Rebuild object artifacts
return {
"figure_inventory": figure_inventory,
"table_inventory": table_inventory,
"reader_payload": reader_payload,
}
# ── Phase 4: objects, render, health ──
def _phase4_render_health(
structured: list[dict],
resolved: dict,
figure_inventory: dict,
table_inventory: dict,
reader_payload: dict,
doc_structure: dict,
ocr_meta: dict,
source_pdf_path: Path | None,
) -> str:
"""Extract object artifacts, render fulltext markdown, build health report.
Returns markdown string."""
from paperforge.worker.ocr_objects import extract_and_write_objects
ocr_meta = read_json(artifacts.meta_json) if artifacts.meta_json.exists() else {}
source_pdf_path = Path(ocr_meta.get("source_pdf", "")) if ocr_meta.get("source_pdf") else None
_source_pdf_path = Path(ocr_meta.get("source_pdf", "")) if ocr_meta.get("source_pdf") else None
page_dimensions_by_page: dict[int, tuple[int, int]] = {}
for block in structured:
page = int(block.get("page", 0) or 0)
@ -338,7 +382,7 @@ def run_derived_rebuild_for_keys(vault: Path, keys: list[str], progress_bar=None
page_dimensions_by_page[page] = (width, height)
extract_and_write_objects(
pdf_path=source_pdf_path,
pdf_path=_source_pdf_path,
figure_inventory=figure_inventory,
table_inventory=table_inventory,
asset_root=paper_root / "assets",
@ -347,7 +391,6 @@ def run_derived_rebuild_for_keys(vault: Path, keys: list[str], progress_bar=None
structured_blocks=structured,
)
# Rebuild render output
from paperforge.worker.ocr_render import render_fulltext_markdown, write_render_outputs
rebuild_page_count = ocr_meta.get("page_count", 0) or 0
@ -364,7 +407,6 @@ def run_derived_rebuild_for_keys(vault: Path, keys: list[str], progress_bar=None
reader_payload=reader_payload,
)
# Rebuild health
from paperforge.worker.ocr_health import build_ocr_health, build_ocr_raw_integrity_health, write_ocr_health
health_report = build_ocr_health(
@ -380,12 +422,20 @@ def run_derived_rebuild_for_keys(vault: Path, keys: list[str], progress_bar=None
health_report["ocr_raw_integrity"] = build_ocr_raw_integrity_health(all_raw_blocks)
write_ocr_health(paper_root / "health", health_report)
# Persist decision log
from paperforge.worker.ocr_decisions import collect_decisions, write_decision_log
write_decision_log(paper_root / "health" / "decision_log.jsonl", collect_decisions(structured))
# Rebuild role index
return markdown
# ── Phase 5: indexes, version flags, write meta ──
def _phase5_finalize(
resolved: dict,
structured: list[dict],
markdown: str,
span_meta_patch: dict,
) -> None:
"""Rebuild indexes, apply version flags, write meta.json."""
from paperforge.worker.ocr_index import build_role_indexes, write_role_index
role_indexes = build_role_indexes(
@ -393,20 +443,19 @@ def run_derived_rebuild_for_keys(vault: Path, keys: list[str], progress_bar=None
resolved_metadata=resolved,
)
write_role_index(paper_root / "index", role_indexes)
# Rebuild structure tree
from paperforge.retrieval.structure_tree import build_structure_tree, write_structure_tree
structure_tree = build_structure_tree(structured)
write_structure_tree(paper_root / "index", structure_tree)
# Update version state in meta.json
meta = ocr_meta
meta.update(span_meta_patch)
meta = _apply_post_rebuild_version_flags(meta)
# Rebuild regenerated the derived outputs; validate from a clean
# optimistic status instead of short-circuiting on a stale
# done_incomplete value from a previous render.
meta["ocr_status"] = "done"
from paperforge.worker.ocr_render import write_render_outputs
meta = write_render_outputs(
render_root=paper_root / "render",
user_fulltext=artifacts.compat_fulltext,
@ -414,21 +463,97 @@ def run_derived_rebuild_for_keys(vault: Path, keys: list[str], progress_bar=None
meta=meta,
rebuild_increment=True,
)
# Re-validate and clear stale errors (e.g. page marker mismatch from pre-fix render)
paths_dict = {"ocr": pipeline_paths(vault)["ocr"]}
_status, _err = validate_ocr_meta(paths_dict, meta)
meta["ocr_status"] = _status
meta["error"] = _err if _err else ""
write_json(artifacts.meta_json, meta)
rebuilt_count += 1
if checkpoint:
done = []
if checkpoint.exists():
done = json.loads(checkpoint.read_text(encoding="utf-8"))
done.append(key)
checkpoint.write_text(json.dumps(done, ensure_ascii=False), encoding="utf-8")
# ── Execute phases ──
phase1_result = _phase1_span_backfill()
span_meta_patch = phase1_result["span_meta_patch"]
source_pdf_path = phase1_result["source_pdf_path"]
phase2_result = _phase2_build_structured(source_pdf_path)
structured = phase2_result["structured"]
doc_structure = phase2_result["doc_structure"]
resolved = phase2_result["resolved"]
source_meta = phase2_result["source_meta"]
page_pdf_lines_by_page = phase2_result["page_pdf_lines_by_page"]
phase3_result = _phase3_figure_tables(structured, page_pdf_lines_by_page, source_meta)
figure_inventory = phase3_result["figure_inventory"]
table_inventory = phase3_result["table_inventory"]
reader_payload = phase3_result["reader_payload"]
markdown = _phase4_render_health(
structured, resolved, figure_inventory, table_inventory,
reader_payload, doc_structure, ocr_meta, source_pdf_path,
)
_phase5_finalize(resolved, structured, markdown, span_meta_patch)
return {"key": key, "status": "ok"}
def _run_parallel_rebuild(vault: Path, keys: list[str], workers: int, checkpoint_dir: Path | None) -> list[dict]:
"""Run rebuild in parallel using a process pool."""
from concurrent.futures import ProcessPoolExecutor, as_completed
results: list[dict] = []
with ProcessPoolExecutor(max_workers=workers) as executor:
futures = {executor.submit(_rebuild_one_paper, vault, k): k for k in keys}
for future in as_completed(futures):
key = futures[future]
try:
result = future.result()
if result.get("status") == "ok":
_write_done_marker(checkpoint_dir, key)
results.append(result)
except Exception as e:
results.append({"key": key, "status": "failed", "error": str(e)})
return results
def run_derived_rebuild_for_keys(
vault: Path,
keys: list[str],
progress_bar=None,
checkpoint_dir: Path | None = None,
parallel: int = 4,
) -> dict:
"""Run derived-layer rebuild for the given paper keys without raw OCR rerun.
Rebuilds: structured blocks, metadata, figure/table inventories, objects,
render outputs, and health from stored raw blocks only.
If checkpoint_dir is provided, .done.<key> marker files track progress so
interrupted runs can skip completed work via --resume.
Args:
vault: Vault root path.
keys: Paper keys to rebuild.
progress_bar: Optional progress bar wrapper (tqdm-style).
checkpoint_dir: Directory for .done.<key> completion markers.
parallel: Number of parallel workers (0 = serial). Default 4.
"""
keys = _filter_completed_keys(checkpoint_dir, keys)
if not keys:
return {"rebuild_count": 0}
workers = int(parallel) if parallel else 0
if workers > 0 and len(keys) > 1:
results = _run_parallel_rebuild(vault, keys, workers, checkpoint_dir)
return {"rebuild_count": sum(1 for r in results if r.get("status") == "ok")}
rebuilt_count = 0
keys_iter = progress_bar(keys, desc="OCR rebuild") if progress_bar else keys
for key in keys_iter:
result = _rebuild_one_paper(vault, key)
if result.get("status") == "ok":
rebuilt_count += 1
_write_done_marker(checkpoint_dir, key)
return {"rebuild_count": rebuilt_count}

View file

@ -18,8 +18,8 @@ def _call(status: str, health: str = "-", version: str = "-",
can_redo: bool = False, can_rebuild: bool = False,
error_stage: str = "", error_summary: str = "",
degraded_reasons: list[str] | None = None) -> dict:
from paperforge.worker.ocr_maintenance import _compute_display_fields
return _compute_display_fields(
from paperforge.worker.ocr_maintenance import OCRMaintenanceRow
return OCRMaintenanceRow.compute_display_fields(
status=status, health_overall=health, version=version,
can_redo=can_redo, can_rebuild=can_rebuild,
error_stage=error_stage, error_summary=error_summary,