diff --git a/paperforge/command_files/pf-deep.md b/paperforge/command_files/pf-deep.md new file mode 100644 index 00000000..b20bb654 --- /dev/null +++ b/paperforge/command_files/pf-deep.md @@ -0,0 +1,299 @@ +# /pf-deep + +## Purpose + +基于单篇论文的组会式精读入口。 + +1. 解析 `/pf-deep ` 中的查询词 +2. 支持 Zotero key、标题片段、DOI、PMID、关键词 +3. 优先搜索本地 Zotero 并锁定单篇论文 +4. 绑定该论文对应的: + - `/PaperForge/ocr//fulltext.md` + - `/PaperForge/ocr//meta.json` + - `//.../KEY - Title.md` +5. 在正式文献卡片中检查或创建 `## 精读` +6. 以"研究思路 + figure-by-figure"方式一次性完成精读写回 + +## CLI Equivalent + +```bash +# 准备阶段(间接) +paperforge sync # 生成 library-records 和正式笔记 +paperforge ocr # 完成 OCR 提取 +paperforge deep-reading # 查看精读队列状态 +``` + +> `/pf-deep` 是 **Agent 层命令**,无直接 CLI 等效命令。其依赖的数据由上述 CLI 命令准备。 + +## Prerequisites + +- [ ] library-record 已创建(`paperforge sync` 生成) +- [ ] `analyze: true` 已设置(在 library-record frontmatter 中) +- [ ] OCR 已完成(`ocr_status: done`) +- [ ] `fulltext.md` 存在且非空 +- [ ] 正式笔记文件存在 + +## Arguments + +| 参数 | 必需 | 说明 | +|------|------|------| +| `` | 是(queue 模式除外) | Zotero key、标题片段、DOI、PMID 或关键词 | +| `queue` | 否 | 启动批量精读队列模式 | + +### 参数说明 + +1. 如果输入看起来像 8 位 Zotero key,则直接按 key 解析。 +2. 否则先在本地 Zotero 中搜索标题/摘要。 +3. 若命中唯一结果或明显最佳结果,则直接载入。 +4. 若存在多个合理候选,则先列候选清单再让用户选。 +5. 不要强迫用户先知道 Zotero key。 + +## Example + +### 单篇精读(已知 key) + +```bash +/pf-deep XGT9Z257 +/pf-deep Predictive findings on magnetic resonance imaging +/pf-deep 10.1016/j.jse.2018.01.001 +``` + +### 批量从待精读队列启动 + +```bash +/pf-deep queue +``` + +当不提供具体 key/标题时,agent 自动执行以下流程: + +1. 运行 `paperforge deep-reading` 查看精读队列(或 `python -m paperforge deep-reading --vault {{VAULT}}` 获取 JSON 格式队列) +2. 解析输出的队列状态(`analyze=true` + `deep_reading_status != done` + `ocr_status`) +3. 按 OCR 状态分组展示: + - **就绪**:OCR 已完成,可直接精读 + - **阻塞**:OCR 未完成,需先跑 `paperforge ocr` +4. 由用户选择篇目: + - 若只有 1 篇就绪 -> 直接执行单篇精读(等同于 `/pf-deep `) + - 若多篇 -> 展示清单,用户选择后批量 spawn subagent 并行处理 + +> **注意**:`queue` 模式只扫描 `library-records`(Base 控制记录),不扫描正式卡片。只有在 Base 里勾选 `analyze=true` 的论文才会进入队列。 + +## Output + +Agent 在正式笔记中创建或更新 `## 精读` 区域,包含: + +- **Pass 1: 概览** — 一句话总览、5 Cs 快速评估、Figure 导读 +- **Pass 2: 精读还原** — Figure-by-Figure 解析、Table-by-Table 解析、关键方法补课、主要发现与新意 +- **Pass 3: 深度理解** — 假设挑战与隐藏缺陷、结论扎实性评估、Discussion 解读、个人启发、遗留问题 + +## Error Handling + +### OCR 未完成 +- **表现**:Agent 提示 `ocr_status` 不是 `done` +- **解决**:先运行 `paperforge ocr`,确认 `meta.json` 中 `ocr_status` 变为 `done` + +### 内容已存在(覆盖确认) +- **表现**:正式笔记中已存在 `## 精读` 区域且包含非占位符的实质内容 +- **处理**:Agent **必须**询问用户: + - **"追加"** — 保留现有内容,仅补充新增/空缺部分 + - **"覆盖"** — 删除现有 `## 精读` 区域,重新生成 + - **"跳过"** — 取消本次操作 +- **规则**:用户选择"追加"时保留已有内容;选择"覆盖"时先删除再重建;选择"跳过"时终止流程 + +### 未找到论文 +- **表现**:Zotero key 无效或搜索无结果 +- **解决**:确认 key 正确,或尝试用标题片段搜索 + +## Platform Notes + +### OpenCode + +- `/pf-deep` 在对话窗口直接输入 +- Agent 使用 `paperforge paths --json` 获取 Vault 路径配置 +- 多篇文章并行时使用 `Task` tool 启动 subagent,每篇独立处理 +- 需要文件系统访问权限读取 OCR 结果和写入正式笔记 + +#### Subagent Spawn 指南(多篇并行时使用) + +当需要并行处理多篇论文时,使用 Task tool 启动 subagent,每个 subagent 独立处理一篇。 + +**变量替换**: + +| 变量 | 示例值 | 获取方式 | +|------|--------|---------| +| `{{ZOTERO_KEY}}` | `Y5KQ4JQ7` | 从 library-record 或 JSON 导出中获取 | +| `{{FORMAL_NOTE}}` | `///骨科/Y5KQ4JQ7 - title.md` | 从 `paperforge paths --json` 或 library-record 中获取 | +| `{{FULLTEXT_MD}}` | `//PaperForge/ocr/Y5KQ4JQ7/fulltext.md` | 由 OCR worker 生成在 ocr 目录下 | +| `{{SCRIPT}}` | `//literature-qa/scripts/ld_deep.py` | 从 `paperforge paths --json` 获取 `ld_deep_script` 字段 | + +**Spawn 命令格式**: + +获取路径信息: +```bash +paperforge paths --json +# 返回 JSON,包含 worker_script, ld_deep_script, skill_dir 等字段 +``` + +然后使用以下格式启动 subagent: +``` +Task( + description="pf-deep {{ZOTERO_KEY}}", + prompt="加载 subagent prompt: //literature-qa/prompt_deep_subagent.md\n\n填入以下变量:\n- ZOTERO_KEY: {{ZOTERO_KEY}}\n- FORMAL_NOTE: {{FORMAL_NOTE}}\n- FULLTEXT_MD: {{FULLTEXT_MD}}\n- SCRIPT: {{SCRIPT}}", + subagent_type="general" +) +``` + +**多篇并行示例**: + +假设要对 LMD5YVLP、NDPUMMCI、Y5KQ4JQ7、UBM39DTB 四篇并行精读: + +1. 先行查询 formal-library.json 或检查文件系统,确认每篇的 FORMAL_NOTE 路径 +2. 用 Bash tool 预跑 `python {{SCRIPT}} figure-index {{FULLTEXT_MD}}` 确认 OCR 存在 +3. 四个 Task 并行启动,每篇独立 +4. 等待所有 Task 完成,收集各篇的写入行数和验证结果 + +**预检(必须)**: + +在 spawn 之前,确认: +- `{{FULLTEXT_MD}}` 存在且非空(OCR 已完成) +- `{{FORMAL_NOTE}}` 所在目录存在(note 已被 selection-sync 创建) + +如有任何一项不满足,subagent 应报错退出,不静默失败。 + +### Codex + +> **Future**:计划支持。预计通过 API 调用实现类似功能。 + +### Claude Code + +> **Future**:计划支持。预计通过工具调用或文件附件实现。 + +## 精读结构参考 + +### 执行原则 + +- `/pf-deep` 对用户来说是一次触发直接完成。 +- 内部逻辑分两步: + 1. 先生成 `## 精读` 骨架和 figure 标题位 + 2. 再补全所有空段 +- 后续再次运行时(未询问用户或用户选择追加): + - 只补空段 + - 不覆盖已有内容 + - 不覆盖用户手改内容 + +### 精读定位 + +这不是综述提取,也不是信息摘录。目标是模拟高水平博士/博士后组会讲解单篇论文的学习型精读。 + +主线必须是: + +1. 文章整体研究思路 +2. 主文 figure 逐张解析 +3. 关键方法补课 +4. 主要发现、新意、疑点与启发 + +### Supplementary 规则 + +- 默认不逐张展开 supplementary figure/table。 +- 仅在以下情况下纳入: + - 对主结论形成关键支撑 + - 补足方法可信度 + - 限制主文结论的解释范围 + - 作者在正文中明显依赖该补充材料 + +### 标准骨架 + +```md +## 精读 + +**证据边界**:区分三层信息:`论文结果`、`作者解释`、`我的理解/推断`。不要把样本内观察直接写成普遍规律,不要把相关性写成因果,不要把未进入最终模型的指标写成已被稳定验证的联合诊断结论。 + +### Pass 1: 概览 + +**一句话总览** +(待补充) + +**5 Cs 快速评估** +- **Category**(类型): +- **Context**(上下文): +- **Correctness**(合理性初判): +- **Contributions**(贡献): +- **Clarity**(清晰度): + +**Figure 导读** +- 关键主图: +- 证据转折点: +- 需要重点展开的 supplementary: +- 关键表格: + +### Pass 2: 精读还原 + +#### Figure-by-Figure 解析 +(每张 figure 下方按以下顺序填写) +- **图像定位与核心问题**:页码 + 要回答什么问题 +- **方法与结果**:方法 + 结果 +- **作者解释**:作者对该图的解读 +- **我的理解**:自己的理解(区分于作者解释) +- **在全文中的作用**:该图在整体故事线中的位置 +- **疑点 / 局限**:读图时发现的疑问(可酌情用 `> [!warning]` 突出) + +#### Table-by-Table 解析 +(如有重要表格,按同样结构展开) + +#### 关键方法补课 +- 方法 1: +- 方法 2: + +#### 主要发现与新意 +**主要发现** +- 发现 1: +- 发现 2: + +### Pass 3: 深度理解 + +#### 假设挑战与隐藏缺陷 +- 隐含假设: +- 如果放宽某个假设,结论还成立吗? +- 缺少哪些关键引用? +- 实验/分析技术的潜在问题: + +#### 哪些结论扎实,哪些仍存疑 +**较扎实** +- + +**仍存疑** +- + +#### Discussion 与 Conclusion 怎么读 +- 作者真正完成了什么: +- 哪些地方有拔高: +- 哪些地方是推测: + +#### 对我的启发 +- 研究设计上: +- figure 组织上: +- 方法组合上: +- 未来工作想法: + +#### 遗留问题 +**遗留问题** +- +``` + +### Figure 节要求 + +每个 figure 小节按以下顺序填写: + +- **图像定位与核心问题**:页码 + 要回答什么问题 +- **方法与结果**:方法 + 结果 +- **作者解释**:作者对该图的解读 +- **我的理解**:自己的理解(区分于作者解释) +- **在全文中的作用**:该图在整体故事线中的位置 +- **疑点 / 局限**:读图时发现的疑问(可酌情用 `> [!warning]` 突出) + +图像优先直接引用 `fulltext.md` 里已有的 OCR 图像链接。 + +## See Also + +- [pf-paper](pf-paper.md) — 快速摘要与问答 +- [AGENTS.md](../AGENTS.md) — 完整使用指南、架构说明、常见问题 +- [docs/COMMANDS.md](../docs/COMMANDS.md) — 命令总览与矩阵 diff --git a/paperforge/command_files/pf-ocr.md b/paperforge/command_files/pf-ocr.md new file mode 100644 index 00000000..4806ddfd --- /dev/null +++ b/paperforge/command_files/pf-ocr.md @@ -0,0 +1,142 @@ +# /pf-ocr + +## Purpose + +处理 library-records 中 `do_ocr: true` 的 PDF OCR 队列。 + +`paperforge ocr` 会自动读取 `paperforge.json` 定位 ocr 目录和 worker 脚本,运行 OCR 并自动诊断结果。 + +## CLI Equivalent + +```bash +paperforge ocr +``` + +如需使用 Python 直接调用(备选方式): + +```bash +python -m paperforge ocr --vault . +``` + +## Prerequisites + +- [ ] library-record 中 `do_ocr: true` 已设置 +- [ ] PDF 附件存在(`has_pdf: true`) +- [ ] PaddleOCR API Key 已配置(`.env` 中 `PADDLEOCR_API_TOKEN`) +- [ ] 网络连接正常(可访问 PaddleOCR 服务) +- [ ] `paperforge.json` 配置正确(路径解析无误) + +## Arguments + +| 参数 | 必需 | 说明 | +|------|------|------| +| `--diagnose` | 否 | 仅诊断配置,不上传 PDF | +| `--key ` | 否 | 仅处理指定 Zotero key 的文献 | +| `--vault ` | 否 | 指定 Vault 根目录(默认当前目录) | +| `--live` | 否 | 与 `--diagnose` 联用,执行实时 PDF 测试 | + +### 诊断模式 + +验证 OCR 配置 before queueing jobs: + +```bash +paperforge ocr --diagnose +``` + +执行完整诊断(含实时 PDF 测试): + +```bash +paperforge ocr --diagnose --live +``` + +### 诊断等级 + +| 等级 | 检查项 | 失败含义 | +|------|--------|---------| +| L1 | API token 存在性 | `PADDLEOCR_API_TOKEN` 缺失或为空 | +| L2 | URL 可达性 | 无法连接 PaddleOCR 服务 | +| L3 | API 格式验证 | 服务可达但响应格式异常 | +| L4 | 实时 PDF 往返 | 完整提交和结果获取失败 | + +> Exit code `0` = 所有检查通过。Exit code `1` = 至少一项检查失败。 + +## Example + +```bash +# 处理所有标记 do_ocr: true 的文献 +paperforge ocr + +# 仅处理指定文献 +paperforge ocr --key ABCDEFG + +# 诊断模式(不实际运行) +paperforge ocr --diagnose + +# 完整诊断(含实时测试) +paperforge ocr --diagnose --live + +# 指定 Vault 目录 +paperforge ocr --vault /path/to/vault +``` + +## Output + +OCR 完成后,每个文献生成以下文件: + +``` +/PaperForge/ocr// +├── fulltext.md # 提取的全文(含 `` 分页标记) +├── images/ # 自动切割的图表图片 +├── meta.json # OCR 元数据(含 ocr_status) +└── figure-map.json # 图表索引(自动生成) +``` + +`meta.json` 中的 `ocr_status` 字段: +- `pending` — 等待处理 +- `processing` — 正在处理 +- `done` — 完成 +- `failed` — 失败 + +## Error Handling + +### API Token 缺失(L1 失败) +- **表现**:`PADDLEOCR_API_TOKEN` 未设置或为空 +- **解决**:在 `.env` 文件中添加 `PADDLEOCR_API_TOKEN=your_token_here` + +### 服务不可达(L2 失败) +- **表现**:无法连接 PaddleOCR 服务 +- **解决**:检查网络连接,确认服务 URL 配置正确 + +### PDF 上传失败(L4 失败) +- **表现**:PDF 提交后未返回结果 +- **解决**:检查 PDF 文件是否损坏,确认文件大小未超过限制 + +### OCR 状态卡住 +- **表现**:`ocr_status` 长期显示 `processing` +- **解决**:检查 `/PaperForge/ocr//meta.json` 中的错误信息,重新设置 `do_ocr: true` 后再次运行 + +## Platform Notes + +### OpenCode + +> `/pf-ocr` 是 **CLI 命令**,Agent 层不直接提供 `/pf-ocr` 聊天命令。 +> +> 用户需要: +> 1. 在 Obsidian 中将 library-record 的 `do_ocr` 设为 `true` +> 2. 在终端运行 `paperforge ocr` +> 3. 或在 Agent 对话中要求 Agent 执行上述步骤 + +### Codex + +> **Future**:计划支持。预计通过 API 调用实现类似功能。 + +### Claude Code + +> **Future**:计划支持。预计通过工具调用实现类似功能。 + +## See Also + +- [pf-sync](pf-sync.md) — 文献同步(生成 library-records) +- [pf-deep](pf-deep.md) — 深度精读(依赖 OCR 结果) +- [AGENTS.md](../AGENTS.md) — 完整使用指南、架构说明、常见问题 +- [docs/COMMANDS.md](../docs/COMMANDS.md) — 命令总览与矩阵 diff --git a/paperforge/command_files/pf-paper.md b/paperforge/command_files/pf-paper.md new file mode 100644 index 00000000..5b5d1f51 --- /dev/null +++ b/paperforge/command_files/pf-paper.md @@ -0,0 +1,119 @@ +# /pf-paper + +## Purpose + +基于 Zotero OCR 文本的单篇论文工作台入口。 + +1. 解析 `/pf-paper ` 中的查询词 +2. 支持 Zotero key、标题片段、DOI、PMID、关键词 +3. 优先搜索本地 Zotero,解析到单篇目标论文 +4. 根据 Vault 根目录的 `paperforge.json` 加载 `/PaperForge/ocr//fulltext.md` 作为主文本 +5. 读取 `meta.json` 显示论文标题、作者、期刊、年份 +6. 进入 Q&A 模式,用中文回答用户关于该论文的问题 +7. 在当前论文上下文中,用户可再说"精读这篇文章"切换到 deep 层 + +## CLI Equivalent + +```bash +# 准备阶段(间接) +paperforge sync # 生成正式笔记和 library-records +``` + +> `/pf-paper` 是 **Agent 层命令**,无直接 CLI 等效命令。 + +## Prerequisites + +- [ ] 正式笔记已生成(`paperforge sync` 生成) +- [ ] library-record 存在(用于定位论文) +- [ ] `fulltext.md` 存在(推荐,用于基于原文回答;如不存在则基于元数据回答) + +> **注意**:与 `/pf-deep` 不同,`/pf-paper` **不强制要求** OCR 完成。没有 OCR 时基于论文元数据和公开信息回答。 + +## Arguments + +| 参数 | 必需 | 说明 | +|------|------|------| +| `` | 是 | Zotero key、标题片段、DOI、PMID 或关键词 | +| ` ...` | 否 | 可同时加载多篇论文 | + +### 解析规则 + +1. 如果输入看起来像 8 位 Zotero key,则直接按 key 解析。 +2. 否则先在本地 Zotero 中搜索标题/摘要。 +3. 若命中唯一结果或明显最佳结果,则直接载入。 +4. 若存在多个合理候选,则先列候选清单再让用户选。 +5. 不要强迫用户先知道 Zotero key。 + +## Example + +```bash +/pf-paper XGT9Z257 +/pf-paper Predictive findings on magnetic resonance imaging +/pf-paper 10.1016/j.jse.2018.01.001 +/pf-paper XGT9Z257 PQR8KLM +``` + +## Output + +加载成功后显示: + +``` +已加载论文: [title] ([year], [journal]) +Zotero Key: [key] +请基于论文原文回答问题。如信息未在论文中提及,会明确说明。 +请问有什么问题? +``` + +### 多篇论文模式 + +多个 key 时依次加载所有 `fulltext.md`,回答时说明来源: +``` +来源 [KEY1]: ... +来源 [KEY2]: ... +``` + +### 回答原则 + +- **严格基于** `fulltext.md` 中的文本内容回答 +- 引用原文时标注来源页码/章节 +- 用中文(简体中文)回答 +- 论文中未提及的内容,明确说明"论文中未提及该内容" +- 需要结合论文以外知识的问题,说明"该问题需要结合论文以外的知识回答" + +## Error Handling + +### 论文未找到 +- **表现**:Zotero key 无效或搜索无结果 +- **解决**:确认 key 正确,或尝试用标题片段搜索 + +### 多个候选结果 +- **表现**:搜索返回多个匹配的论文 +- **处理**:Agent 列出候选清单,让用户选择目标论文 + +### OCR 文件缺失 +- **表现**:`fulltext.md` 不存在 +- **处理**:Agent 基于元数据和公开信息回答,并告知用户"OCR 文本不可用,回答基于元数据" + +## Platform Notes + +### OpenCode + +- `/pf-paper` 在对话窗口直接输入 +- Agent 使用 `paperforge paths --json` 获取 Vault 路径配置 +- 单篇加载时直接读取文件内容到对话上下文 +- 多篇加载时分别读取每篇的 `fulltext.md` +- 用户可在同一上下文中说"精读这篇文章"无缝切换到 `/pf-deep` 模式 + +### Codex + +> **Future**:计划支持。预计通过 API 调用实现类似功能。 + +### Claude Code + +> **Future**:计划支持。预计通过工具调用或文件附件实现。 + +## See Also + +- [pf-deep](pf-deep.md) — 完整三阶段精读 +- [AGENTS.md](../AGENTS.md) — 完整使用指南、架构说明、常见问题 +- [docs/COMMANDS.md](../docs/COMMANDS.md) — 命令总览与矩阵 diff --git a/paperforge/command_files/pf-status.md b/paperforge/command_files/pf-status.md new file mode 100644 index 00000000..99a836f1 --- /dev/null +++ b/paperforge/command_files/pf-status.md @@ -0,0 +1,121 @@ +# /pf-status + +## Purpose + +查看 PaperForge 当前安装与运行状态。 + +`paperforge status` 会检查: + +- 安装完整性(Python 包、依赖) +- 配置文件(`paperforge.json`、`.env`) +- 路径连通性(exports、ocr、library-records、literature 目录) +- Zotero 数据目录链接状态 +- Better BibTeX 导出文件状态 + +## CLI Equivalent + +```bash +paperforge status +``` + +如需使用 Python 直接调用(备选方式): + +```bash +python -m paperforge status --vault . +``` + +更简单的方式是直接使用 `paperforge status`(见上方)。 + +## Prerequisites + +无特殊前置条件。此命令用于诊断安装问题,即使在配置不完整时也会尽量输出可用信息。 + +## Arguments + +| 参数 | 必需 | 说明 | +|------|------|------| +| `--vault ` | 否 | 指定 Vault 根目录(默认当前目录) | + +## Example + +```bash +# 检查当前目录的 Vault 状态 +paperforge status + +# 检查指定 Vault 的状态 +paperforge status --vault /path/to/vault +``` + +## Output + +典型输出示例: + +``` +PaperForge Lite v1.2 +==================== + +[安装检查] +✓ Python 包: paperforge v1.2.0 +✓ 依赖: requests, pymupdf, pillow + +[配置检查] +✓ paperforge.json: 存在且有效 +✓ .env: 存在 +✓ PADDLEOCR_API_TOKEN: 已设置 + +[路径检查] +✓ exports: /PaperForge/exports/ +✓ ocr: /PaperForge/ocr/ +✓ library-records: //library-records/ +✓ literature: // +✓ Zotero: /Zotero/ + +[数据检查] +✓ library.json: 存在,包含 150 条文献 +✓ library-records: 150 条记录 +✓ 正式笔记: 150 篇 + +状态: 一切正常 ✅ +``` + +## Error Handling + +### 配置缺失 +- **表现**:`✗ paperforge.json: 未找到` +- **解决**:运行 `paperforge doctor` 或重新执行安装步骤 + +### 路径错误 +- **表现**:`✗ Zotero: 目录不存在或不是有效链接` +- **解决**:创建 junction/symlink 到 Zotero 数据目录(见 [AGENTS.md](../AGENTS.md) 安装指南) + +### 依赖缺失 +- **表现**:`✗ 依赖: requests 未安装` +- **解决**:`pip install requests pymupdf pillow` + +### API Key 未设置 +- **表现**:`✗ PADDLEOCR_API_TOKEN: 未设置` +- **解决**:在 `.env` 文件中添加 API token + +## Platform Notes + +### OpenCode + +> `/pf-status` 是 **CLI 命令**,Agent 层不直接提供 `/pf-status` 聊天命令。 +> +> 用户可以在终端运行 `paperforge status` 检查安装状态。 +> Agent 可以在对话中指导用户执行诊断,或通过 Bash tool 代为执行并解析输出。 + +### Codex + +> **Future**:计划支持。预计通过 API 调用实现类似功能。 + +### Claude Code + +> **Future**:计划支持。预计通过工具调用实现类似功能。 + +## See Also + +- [pf-sync](pf-sync.md) — 文献同步 +- [pf-ocr](pf-ocr.md) — OCR 提取 +- [AGENTS.md](../AGENTS.md) — 完整使用指南、架构说明、常见问题 +- [docs/COMMANDS.md](../docs/COMMANDS.md) — 命令总览与矩阵 diff --git a/paperforge/command_files/pf-sync.md b/paperforge/command_files/pf-sync.md new file mode 100644 index 00000000..fdeb384c --- /dev/null +++ b/paperforge/command_files/pf-sync.md @@ -0,0 +1,157 @@ +# /pf-sync + +## Purpose + +同步 Zotero Better BibTeX JSON 导出到 library-records,并生成/更新正式文献笔记。 + +`paperforge sync` 是 `selection-sync` 和 `index-refresh` 的统一入口: + +1. **selection-sync 阶段**:检测 Zotero JSON 中的新条目,创建 library-records +2. **index-refresh 阶段**:基于 library-records 和 Zotero 元数据生成正式文献笔记 + +自动读取 `paperforge.json` 定位 exports 目录、control 目录和 literature 目录。 + +## CLI Equivalent + +```bash +paperforge sync +``` + +如需使用 Python 直接调用(备选方式): + +```bash +python -m paperforge sync --vault . +``` + +## Prerequisites + +- [ ] Zotero 已安装且 Better BibTeX 插件已启用 +- [ ] Better BibTeX 已配置自动导出 JSON +- [ ] JSON 导出文件存在(`/PaperForge/exports/library.json`) +- [ ] `paperforge.json` 配置正确(Vault 根目录下) +- [ ] 目录结构已创建(`setup.py` 会自动完成) + +## Arguments + +| 参数 | 必需 | 说明 | +|------|------|------| +| `--dry-run` | 否 | 预览变更,不实际写入文件 | +| `--domain ` | 否 | 仅同步指定领域(如 `骨科`) | +| `--selection` | 否 | 仅执行 selection-sync 阶段 | +| `--index` | 否 | 仅执行 index-refresh 阶段 | +| `--vault ` | 否 | 指定 Vault 根目录(默认当前目录) | + +### 分阶段执行 + +仅同步 Zotero 到 library-records: + +```bash +paperforge sync --selection +``` + +仅根据现有 library-records 生成正式笔记: + +```bash +paperforge sync --index +``` + +预览同步结果(不实际写入): + +```bash +paperforge sync --dry-run +``` + +按领域过滤同步: + +```bash +paperforge sync --domain 骨科 +``` + +## Example + +```bash +# 完整同步(selection + index) +paperforge sync + +# 仅创建/更新 library-records +paperforge sync --selection + +# 仅生成正式笔记 +paperforge sync --index + +# 预览模式 +paperforge sync --dry-run + +# 仅同步特定领域 +paperforge sync --domain 骨科 + +# 指定 Vault 目录 +paperforge sync --vault /path/to/vault +``` + +## Output + +### selection-sync 阶段 + +``` +[INFO] Found 5 new items +[INFO] Created library-records/骨科/XXXXXXX.md +``` + +生成文件: +- `//library-records//.md` + +### index-refresh 阶段 + +``` +[INFO] Generated 5 formal notes +[INFO] Output: //骨科/XXXXXXX - Title.md +``` + +生成文件: +- `/// - .md` + +## Error Handling + +### JSON 文件不存在 +- **表现**:`[ERROR] library.json not found` +- **解决**:检查 Better BibTeX 导出路径是否正确配置 + +### 目录权限错误 +- **表现**:`[ERROR] Permission denied` +- **解决**:检查 Vault 目录和子目录的写入权限 + +### Zotero key 重复 +- **表现**:`[WARNING] Duplicate key detected` +- **解决**:在 Zotero 中检查重复的 citation key + +### 空 JSON 导出 +- **表现**:`[INFO] Found 0 new items`(预期有文献但未检测到) +- **解决**: + 1. 确认 Zotero 中有带 citation key 的文献 + 2. 确认 Better BibTeX 已启用"Keep updated" + 3. 手动触发一次导出(Tools → Better BibTeX → Refresh BibTeX Key) + +## Platform Notes + +### OpenCode + +> `/pf-sync` 是 **CLI 命令**,Agent 层不直接提供 `/pf-sync` 聊天命令。 +> +> 用户需要在终端运行 `paperforge sync`。 +> Agent 可以在对话中指导用户执行同步步骤,或通过 Bash tool 代为执行。 + +### Codex + +> **Future**:计划支持。预计通过 API 调用实现类似功能。 + +### Claude Code + +> **Future**:计划支持。预计通过工具调用实现类似功能。 + +## See Also + +- [pf-ocr](pf-ocr.md) — OCR 提取(下一步操作) +- [pf-status](pf-status.md) — 检查系统状态 +- [AGENTS.md](../AGENTS.md) — 完整使用指南、架构说明、常见问题 +- [docs/COMMANDS.md](../docs/COMMANDS.md) — 命令总览与矩阵 diff --git a/paperforge/setup_wizard.py b/paperforge/setup_wizard.py index f7f804a1..6f3ae32d 100644 --- a/paperforge/setup_wizard.py +++ b/paperforge/setup_wizard.py @@ -1035,7 +1035,9 @@ class DeployStep(StepScreen): # Copy OpenCode command files when the target platform supports them. if getattr(self.app, "agent_key", "") == "opencode": - command_src = repo_root / "command" + command_src = wizard_dir / "command_files" + if not command_src.exists() or not command_src.is_dir(): + command_src = repo_root / "command" command_dst = vault / agent_config.get("command_dir", ".opencode/command") if command_src.exists() and command_src.is_dir(): command_dst.mkdir(parents=True, exist_ok=True) diff --git a/paperforge/worker/ocr.py b/paperforge/worker/ocr.py index 2f632d75..360246a4 100644 --- a/paperforge/worker/ocr.py +++ b/paperforge/worker/ocr.py @@ -1430,6 +1430,7 @@ def run_ocr(vault: Path, verbose: bool = False, no_progress: bool = False) -> in changed = 0 active_submitted = 0 queue_changed = False + _submitted: set[str] = set() # keys newly uploaded in this run def _do_poll(job_id: str, token_val: str) -> requests.Response: resp = requests.get(f"{job_url}/{job_id}", headers={"Authorization": f"bearer {token_val}"}, timeout=60) @@ -1598,11 +1599,67 @@ def run_ocr(vault: Path, verbose: bool = False, no_progress: bool = False) -> in meta["ocr_job_id"] = response.json()["data"]["jobId"] meta["ocr_status"] = "queued" meta["ocr_started_at"] = datetime.now(timezone.utc).isoformat() + _submitted.add(key) meta["error"] = "" queue_row["queue_status"] = "queued" write_json(paths["ocr"] / key / "meta.json", meta) changed += 1 available_slots -= 1 + # Persistent poll: wait for newly submitted jobs to complete + # Only polls items that were JUST uploaded in this run (not pre-existing queued) + freshly_queued = {r["zotero_key"] for r in ocr_queue if r.get("queue_status") == "queued" and r.get("zotero_key") in _submitted} + if freshly_queued and token: + import time as _time + max_cycles = int(os.environ.get("PAPERFORGE_POLL_MAX_CYCLES", "20")) # ~5 min at 15s intervals + for _cycle in range(max_cycles): + pending = [r for r in ocr_queue if r.get("queue_status") in ("queued", "running") and r.get("zotero_key") in _submitted] + if not pending: + break + for queue_row in progress_bar(pending, desc="Waiting OCR", disable=no_progress): + key = queue_row["zotero_key"] + meta = ensure_ocr_meta(vault, queue_row) + job_id = meta.get("ocr_job_id", "") + if not job_id: + continue + try: + response = retry_with_meta(_do_poll, paths["ocr"] / key / "meta.json", job_id, token) + payload = response.json()["data"] + state = payload["state"] + except Exception: + continue + if state == "done": + result_url = payload["resultUrl"]["jsonUrl"] + try: + result_response = requests.get(result_url, timeout=120) + result_response.raise_for_status() + lines = [l.strip() for l in result_response.text.splitlines() if l.strip()] + results = [json.loads(l)["result"] for l in lines] + page_num, md_path, json_path, fulltext_md_path = postprocess_ocr_result(vault, key, results) + meta["ocr_status"] = "done" + meta["ocr_finished_at"] = datetime.now(timezone.utc).isoformat() + meta["page_count"] = page_num + meta["markdown_path"] = md_path + meta["json_path"] = json_path + meta["fulltext_md_path"] = fulltext_md_path + meta["error"] = "" + queue_row["queue_status"] = "done" + queue_changed = True + changed += 1 + except Exception: + pass + elif state in ("error", "failed"): + meta["ocr_status"] = "error" + meta["error"] = payload.get("errorMsg", "Unknown OCR failure") + queue_row["queue_status"] = "error" + changed += 1 + else: + meta["ocr_status"] = state + queue_row["queue_status"] = state + write_json(paths["ocr"] / key / "meta.json", meta) + if pending: + _time.sleep(int(os.environ.get("PAPERFORGE_POLL_INTERVAL", "15"))) + if pending: + logger.warning("OCR poll timeout: %d jobs still pending", len(pending)) if queue_changed: ocr_queue = [row for row in ocr_queue if str(row.get("queue_status", "")).lower() != "done"] write_ocr_queue(paths, ocr_queue) diff --git a/pyproject.toml b/pyproject.toml index 50e129ea..16bd1797 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,6 +52,7 @@ paperforge = [ "skills/literature-qa/prompt_deep_subagent.md", "skills/literature-qa/chart-reading/*.md", "skills/literature-qa/chart-reading/*", + "command_files/*.md", ] [tool.pytest.ini_options] diff --git a/tests/test_ocr_preflight.py b/tests/test_ocr_preflight.py index 08947fca..181b1f02 100644 --- a/tests/test_ocr_preflight.py +++ b/tests/test_ocr_preflight.py @@ -189,10 +189,14 @@ class TestOcrPreflight: patch("paperforge.worker.ocr.write_json"), patch("builtins.open") as mock_open, patch("paperforge.worker.ocr.requests.post") as mock_post, + patch("paperforge.worker.ocr.requests.get") as mock_get, ): mock_post.return_value = MagicMock() mock_post.return_value.json.return_value = {"data": {"jobId": "123"}} mock_post.return_value.raise_for_status = lambda: None + mock_get.return_value = MagicMock() + mock_get.return_value.json.return_value = {"data": {"state": "done", "resultUrl": {"jsonUrl": ""}}} + mock_get.return_value.raise_for_status = lambda: None with patch("paperforge.worker.sync.run_selection_sync"), patch("paperforge.worker.sync.run_index_refresh"): from paperforge.worker.ocr import ( run_ocr, @@ -258,10 +262,14 @@ class TestOcrPreflight: patch("paperforge.worker.ocr.write_json"), patch("builtins.open") as mock_open, patch("paperforge.worker.ocr.requests.post") as mock_post, + patch("paperforge.worker.ocr.requests.get") as mock_get, ): mock_post.return_value = MagicMock() mock_post.return_value.json.return_value = {"data": {"jobId": "123"}} mock_post.return_value.raise_for_status = lambda: None + mock_get.return_value = MagicMock() + mock_get.return_value.json.return_value = {"data": {"state": "done", "resultUrl": {"jsonUrl": ""}}} + mock_get.return_value.raise_for_status = lambda: None with patch("paperforge.worker.sync.run_selection_sync"), patch("paperforge.worker.sync.run_index_refresh"): from paperforge.worker.ocr import ( run_ocr, diff --git a/tests/test_ocr_state_machine.py b/tests/test_ocr_state_machine.py index fa61d182..440ff234 100644 --- a/tests/test_ocr_state_machine.py +++ b/tests/test_ocr_state_machine.py @@ -127,11 +127,15 @@ do_ocr: true ): with patch("paperforge.worker.ocr.write_json") as mock_write: with patch("paperforge.worker.ocr.requests.post", mock_post): - with patch("paperforge.worker.sync.run_selection_sync"): - with patch("paperforge.worker.sync.run_index_refresh"): - from paperforge.worker.ocr import run_ocr + with patch("paperforge.worker.ocr.requests.get") as mock_get: + mock_get.return_value.json.return_value = { + "data": {"state": "done", "resultUrl": {"jsonUrl": ""}} + } + with patch("paperforge.worker.sync.run_selection_sync"): + with patch("paperforge.worker.sync.run_index_refresh"): + from paperforge.worker.ocr import run_ocr - run_ocr(vault) + run_ocr(vault) # Check requests.post was called (job submitted) assert mock_post.called, "requests.post was not called - job not submitted" @@ -142,7 +146,7 @@ do_ocr: true ] assert meta_calls, f"No meta write found for key {key}" final_meta = meta_calls[-1][0][1] - assert final_meta.get("ocr_status") == "queued", f"Expected 'queued', got {final_meta.get('ocr_status')}" + assert final_meta.get("ocr_status") == "done", f"Expected 'done' (persistent poll completes), got {final_meta.get('ocr_status')}" assert final_meta.get("ocr_job_id") == "job-123" @@ -432,10 +436,15 @@ do_ocr: true with patch("paperforge.worker.sync.run_selection_sync"): with patch("paperforge.worker.sync.run_index_refresh"): with patch("paperforge.worker.ocr.requests.post", mock_post): - with patch("paperforge.worker.ocr.requests.get", MagicMock()): - from paperforge.worker.ocr import run_ocr + with patch("paperforge.worker.ocr.requests.get") as mock_get: + mock_get.return_value.json.return_value = { + "data": {"state": "done", "resultUrl": {"jsonUrl": ""}} + } + with patch("paperforge.worker.sync.run_selection_sync"): + with patch("paperforge.worker.sync.run_index_refresh"): + from paperforge.worker.ocr import run_ocr - run_ocr(vault) + run_ocr(vault) meta_calls = [ c for c in mock_write.call_args_list if isinstance(c[0][1], dict) and c[0][1].get("zotero_key") == key