mirror of
https://github.com/lllin000/PaperForge.git
synced 2026-07-22 06:50:53 +00:00
Merge pull request #7 from LLLin000/feature/skill-refine
feat: unified release — skill redesign + deep-finalize + sync safety + dashboard v2
This commit is contained in:
commit
0c7082dcec
54 changed files with 4907 additions and 1490 deletions
459
docs/superpowers/plans/2026-05-10-dashboard-redesign.md
Normal file
459
docs/superpowers/plans/2026-05-10-dashboard-redesign.md
Normal file
|
|
@ -0,0 +1,459 @@
|
|||
# PaperForge Dashboard 重设计 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 将 PaperForge Dashboard 从 "功能展示墙" 重构为 "工作流控制台",只保留 global / collection / paper 三个视图,删除 Copy Context 类按钮和 dead deep-reading 代码。
|
||||
|
||||
**Architecture:** 3 个模式(global=系统首页, collection=批量工作台, paper=阅读辅助侧栏)。Workspace 检测用 `dirname(filePath)` 提取 zotero_key。精读内容从 formal note 正文 `## 🔍 精读` section 直接读取。Discussion 卡片做 UI 壳+空态(数据尚未生产)。Actions 由全局静态网格改为各视图内 contextual 按钮。
|
||||
|
||||
**Tech Stack:** Vanilla Obsidian DOM API (`createEl`, `addClass`, `setText`), Node.js fs/path, single-file plugin (`main.js`)
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
| 文件 | 改动类型 | 职责 |
|
||||
|------|---------|------|
|
||||
| `paperforge/plugin/main.js` | 大量修改 | 所有渲染方法、路由、actions |
|
||||
| `paperforge/plugin/src/testable.js` | 小改 | 删除 Copy Context actions |
|
||||
| `paperforge/plugin/styles.css` | 中等改动 | 新组件样式 |
|
||||
| `docs/ux-contract.md` | 更新 | workflow specification |
|
||||
|
||||
---
|
||||
|
||||
## Vault 实际数据结构(来自 D:\L\Med\test1\ 勘探)
|
||||
|
||||
```
|
||||
Workspace 路径: Resources/Literature/{domain}/{zotero_key} - {title}/
|
||||
Formal note: {workspace}/{zotero_key} - {title}.md
|
||||
Fulltext: {workspace}/fulltext.md (OCR 后的副本)
|
||||
AI dir: {workspace}/ai/ (目前为空,无 discussion.json)
|
||||
```
|
||||
|
||||
**Workspace 检测策略(用户指定):** `dirname(filePath)` → 正则 `^([A-Z0-9]{8})` 提取 zotero_key
|
||||
|
||||
**精读内容格式(来自 3EWBBTAS formal note 正文):**
|
||||
```
|
||||
## 🔍 精读
|
||||
### Pass 1: 概览
|
||||
**一句话总览**: ...
|
||||
**5 Cs 快速评估**: ...
|
||||
**Figure 导读**: ...
|
||||
### Pass 2: 精读还原
|
||||
...
|
||||
### Pass 3: 深度理解
|
||||
...
|
||||
```
|
||||
|
||||
**Discussion 现状:** 不存在。所有 `ai/` 目录为空。做 UI 壳+空态。
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 清理 testable.js — 删除 Copy Context actions
|
||||
|
||||
**Files:**
|
||||
- Modify: `paperforge/plugin/src/testable.js:172-187`
|
||||
|
||||
- [ ] **Step 1: 从 ACTIONS 数组中删除两个 Copy Context action**
|
||||
|
||||
删除 `paperforge-copy-context` 和 `paperforge-copy-collection-context` 两个 action 对象(lines 172-187)。保留其余 4 个 action(sync, ocr, doctor, repair)。注意删除后数组尾部逗号清理。
|
||||
|
||||
- [ ] **Step 2: 运行测试确认**
|
||||
|
||||
```bash
|
||||
cd paperforge/plugin && npx vitest run --reporter=verbose
|
||||
```
|
||||
|
||||
预期:所有现有测试通过(ACTIONS 数组长度从 6 变为 4)。
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 清理 main.js — 删除 dead deep-reading 代码
|
||||
|
||||
**Files:**
|
||||
- Modify: `paperforge/plugin/main.js`
|
||||
|
||||
- [ ] **Step 1: 删除 `_renderDeepStatusCard` 方法 (lines 1222-1245)**
|
||||
|
||||
- [ ] **Step 2: 删除 `_getPassCompletion` 方法 (lines 1247-1251)**
|
||||
|
||||
- [ ] **Step 3: 删除 `_renderDeepPass1Card` 方法 (lines 1253-1280)**
|
||||
|
||||
- [ ] **Step 4: 删除 `_extractPass1Content` 方法 (lines 1282-1299)**
|
||||
|
||||
- [ ] **Step 5: 删除 `_renderDeepQACard` 方法 (lines 1301-1342)**
|
||||
|
||||
- [ ] **Step 6: 更新 `_currentMode` 注释 (line 375)**
|
||||
|
||||
将 `'global' | 'paper' | 'collection' | 'deep-reading'` 改为 `'global' | 'paper' | 'collection'`
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 清理 main.js — 删除 Copy Context UI + 静态 Quick Actions 网格
|
||||
|
||||
**Files:**
|
||||
- Modify: `paperforge/plugin/main.js`
|
||||
|
||||
- [ ] **Step 1: 从 `_renderPaperMode` 删除 Copy Context 按钮 (lines 1073-1080)**
|
||||
|
||||
删除 ctxBtn 创建和事件监听。
|
||||
|
||||
- [ ] **Step 2: 从 `_buildPanel` 删除静态 Quick Actions section (lines 461-465)**
|
||||
|
||||
删除 `.paperforge-actions-section` 的创建、`this._actionsGrid` 引用、`this._renderActions()` 调用。
|
||||
|
||||
- [ ] **Step 3: 删除 `_renderActions` 方法 (lines 937-949)**
|
||||
|
||||
- [ ] **Step 4: 从 `_runAction` 删除两个 Copy Context handler (lines 1478-1526)**
|
||||
|
||||
删除 `paperforge-copy-context` 和 `paperforge-copy-collection-context` 的纯 JS clipboard 处理分支。
|
||||
|
||||
- [ ] **Step 5: 从 `_renderModeHeader` 删除 deep-reading case (lines 1703-1714)**
|
||||
|
||||
- [ ] **Step 6: 从 Plugin.onload 删除 Copy Context 命令注册 (lines 2881-2913)**
|
||||
|
||||
- [ ] **Step 7: 从 `_renderNextStepCard` 删除 ready 状态的 Copy Context fallback (lines 1195-1203)**
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 修复 mode routing — workspace path 检测
|
||||
|
||||
**Files:**
|
||||
- Modify: `paperforge/plugin/main.js:_resolveModeForFile (lines 957-978)`
|
||||
|
||||
- [ ] **Step 1: 添加 `_extractZoteroKeyFromPath(filePath)` 辅助方法**
|
||||
|
||||
在 `_resolveModeForFile` 前新增方法。逻辑:
|
||||
1. `dirname = path.dirname(filePath)`
|
||||
2. `basename = path.basename(dirname)`
|
||||
3. 正则 `^([A-Z0-9]{8})(\s*-\s*)` 提取第一个 token
|
||||
4. 返回 zotero_key 或 null
|
||||
|
||||
- [ ] **Step 2: 修改 `_resolveModeForFile`**
|
||||
|
||||
在 `.md` 分支的 `zotero_key` 检测之后,新增 fallback:如果是 `.md` 且无 frontmatter zotero_key,调用 `_extractZoteroKeyFromPath(filePath)` 检测。如果提取到 key 且在 index 中存在,返回 `{ mode: 'paper', ... }`。
|
||||
|
||||
同时在 `ext` 判断的最后(return global 之前),对任意文件类型也做一次 workspace path 检测(用于 fulltext.md 等非 formal note 文件的归属)。
|
||||
|
||||
完整逻辑:
|
||||
```js
|
||||
_resolveModeForFile(file) {
|
||||
if (!file) return { mode: 'global', filePath: null, key: null, domain: null };
|
||||
const ext = file.extension;
|
||||
const filePath = file.path;
|
||||
|
||||
if (ext === 'base') {
|
||||
return { mode: 'collection', filePath, key: null, domain: file.basename };
|
||||
}
|
||||
|
||||
if (ext === 'md') {
|
||||
const cache = this.app.metadataCache.getFileCache(file);
|
||||
const fmKey = cache && cache.frontmatter && cache.frontmatter.zotero_key;
|
||||
if (fmKey) {
|
||||
return { mode: 'paper', filePath, key: fmKey, domain: null };
|
||||
}
|
||||
}
|
||||
|
||||
// Workspace path detection for any file
|
||||
const wsKey = this._extractZoteroKeyFromPath(filePath);
|
||||
if (wsKey && this._findEntry(wsKey)) {
|
||||
return { mode: 'paper', filePath, key: wsKey, domain: null };
|
||||
}
|
||||
|
||||
return { mode: 'global', filePath, key: null, domain: null };
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 重做 Global 视图 — 系统首页
|
||||
|
||||
**Files:**
|
||||
- Modify: `paperforge/plugin/main.js:_renderGlobalMode (lines 1024-1044)`
|
||||
|
||||
- [ ] **Step 1: 重写 `_renderGlobalMode`**
|
||||
|
||||
新布局:
|
||||
```text
|
||||
[Drift Banner] (hidden by default)
|
||||
[Library Snapshot]: papers · PDFs ready · OCR done · deep-read done
|
||||
[System Status]: runtime ✓ · index ✓ · Zotero export ✓ · OCR token ✓
|
||||
[Issues Panel]: 仅有问题时显示
|
||||
[Actions]: Open Literature Hub · Sync Library
|
||||
```
|
||||
|
||||
实现:
|
||||
1. Header + version + 总体状态一行(`系统正常` / `检测到 N 个问题`)
|
||||
2. Library Snapshot: 从 `_fetchStats` 获取数据,显示 4 个 compact metric pill(papers, PDFs, OCR done, deep-read done)
|
||||
3. System Status: 4 行 status pill(runtime match/mismatch, index healthy/corrupt, Zotero export detected/missing, OCR token configured/missing)
|
||||
4. Issues Panel: 当 path_errors > 0 或 runtime mismatch 时显示,包含 `Run Doctor` / `Repair Issues` 按钮
|
||||
5. Contextual Actions: `Open Literature Hub` (+ 如果全库 queued OCR > 0 显示 `Run OCR`)
|
||||
|
||||
- [ ] **Step 2: 删除从 global 的 `_renderOcr` 引用和 OCR section DOM 构建**
|
||||
|
||||
不再在 `_renderGlobalMode` 中创建 `this._ocrSection`、`this._ocrBadge`、`this._ocrTrack`、`this._ocrCounts`。
|
||||
|
||||
- [ ] **Step 3: 调整 `_renderStats` 中的 metric cards**
|
||||
|
||||
从 "Papers / Formal Notes / Exports" 3 张卡改为只保留对用户有意义的总量。数据源改为从 `this._getCachedIndex()` 单次聚合(不再依赖 CLI dashboard --json)。
|
||||
|
||||
聚合逻辑(复用 `_fallbackFetchStats` 中的部分代码):
|
||||
- papers = items.length
|
||||
- pdfReady = items.filter(i => i.has_pdf).length
|
||||
- ocrDone = items.filter(i => i.ocr_status === 'done').length
|
||||
- deepReadDone = items.filter(i => i.deep_reading_status === 'done').length
|
||||
|
||||
---
|
||||
|
||||
### Task 6: 重做 Collection/Base 视图 — 批量工作台
|
||||
|
||||
**Files:**
|
||||
- Modify: `paperforge/plugin/main.js:_renderCollectionMode (lines 1344-1410)`
|
||||
|
||||
- [ ] **Step 1: 重写 `_renderCollectionMode`**
|
||||
|
||||
新布局:
|
||||
```text
|
||||
[Header]: 其他 · 150 papers
|
||||
[Workflow Overview]: 150 total → 148 PDF → 8 OCR → 8 analyze-ready → 1 deep-read
|
||||
[OCR Pipeline]: 待处理142 | 处理中0 | 已完成8 | 需处理0 [████████░░] [Run OCR]
|
||||
[Next Work]: 待OCR: 142 · 可进入精读: 7 · 精读完成: 1
|
||||
[Issue Summary]: 紧凑版,仅在有问题时展开详情
|
||||
[Actions]: Sync Library · Run OCR
|
||||
```
|
||||
|
||||
**单次聚合逻辑:**
|
||||
```js
|
||||
const items = domainItems;
|
||||
const total = items.length;
|
||||
const pdfReady = items.filter(i => i.has_pdf).length;
|
||||
const ocrDone = items.filter(i => i.ocr_status === 'done').length;
|
||||
const analyzeReady = items.filter(i => i.ocr_status === 'done' && i.analyze === true).length;
|
||||
const deepReadDone = items.filter(i => i.deep_reading_status === 'done').length;
|
||||
|
||||
// OCR pipeline 4-bucket aggregation
|
||||
const ocrPending = items.filter(i => ['pending','queued'].includes(i.ocr_status)).length;
|
||||
const ocrProcessing = items.filter(i => i.ocr_status === 'processing').length;
|
||||
const ocrDone2 = items.filter(i => i.ocr_status === 'done').length;
|
||||
const ocrAttention = items.filter(i => ['failed','blocked','done_incomplete','nopdf'].includes(i.ocr_status)).length;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 实现 `_renderWorkflowOverview` 辅助方法(或在 render 内部直接构建)**
|
||||
|
||||
Workflow 漏斗使用 5 个横向串联的 stage pill。
|
||||
|
||||
- [ ] **Step 3: 实现 base 内的 OCR Pipeline(复用 `_renderOcr` 的进度条逻辑)**
|
||||
|
||||
4-bucket 进度条 + count labels。需要单独的容器引用(不与 global 共享 `this._ocrSection`)。
|
||||
|
||||
- [ ] **Step 4: 实现 Issue Summary 紧凑版**
|
||||
|
||||
默认显示 "暂无阻塞问题"。有异常时显示 `N 篇 PDF 缺失 · N 篇 OCR 失败 · N 篇 Asset drift`。
|
||||
|
||||
- [ ] **Step 5: 重写 Contextual Actions**
|
||||
|
||||
base 模式 actions 区域:`Sync Library` + `Run OCR`。维护动作仅在异常时出现。
|
||||
|
||||
---
|
||||
|
||||
### Task 7: 重做 Per-paper 视图 — 阅读辅助侧栏
|
||||
|
||||
**Files:**
|
||||
- Modify: `paperforge/plugin/main.js:_renderPaperMode + 相关方法 (lines 1047-1204)`
|
||||
|
||||
- [ ] **Step 1: 重写 `_renderPaperMode`**
|
||||
|
||||
新布局:
|
||||
```text
|
||||
[Header]: title · authors · year
|
||||
[Status Strip]: PDF ✓ OCR ✓ 精读 已完成
|
||||
[文章概览]: Pass 1 一句话总览 / abstract / 空态
|
||||
[最近讨论]: 最新 session 最近 2-3 条 Q&A / 空态隐藏
|
||||
[下一步]: 精读已完成 · 可继续讨论
|
||||
[文件]: Open PDF · Open Fulltext
|
||||
[技术详情]: 默认折叠(PDF health, OCR raw status, asset state, paths)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 实现 `_renderPaperStatusStrip(container, entry)`**
|
||||
|
||||
删除 lifecycle stepper + health matrix + maturity gauge,压缩为一行 compact status pills:
|
||||
|
||||
状态判断:
|
||||
- PDF: `has_pdf` ? '✓ 可用' : '✗ 缺失'
|
||||
- OCR: `ocr_status === 'done'` ? '✓ 完成' : `['pending','queued'].includes(ocr_status)` ? '待处理' : `ocr_status === 'processing'` ? '处理中' : '✗ 异常'
|
||||
- 精读: `deep_reading_status === 'done'` ? '✓ 完成' : '待完成'
|
||||
|
||||
- [ ] **Step 3: 实现 `_renderPaperOverviewCard(container, entry)`**
|
||||
|
||||
数据源:formal note 正文(通过 `app.vault.read(noteFile)` 读取)
|
||||
|
||||
提取逻辑:
|
||||
1. 如果正文包含 `## 🔍 精读`,在 section 内找 `**一句话总览**` 或 `**文章摘要**`
|
||||
2. Fallback:显示 entry 中的 `abstract`(如果 frontmatter 有)
|
||||
3. 最终 fallback:显示 "尚未生成文章概览"
|
||||
|
||||
默认只显示短摘要(截断 200 字符),展开按钮显示更多。
|
||||
|
||||
- [ ] **Step 4: 实现 `_renderRecentDiscussionCard(container, entry)`**
|
||||
|
||||
数据源:`{workspace}/ai/discussion.json`(通过 `app.vault.adapter.read()` 读取)
|
||||
|
||||
Discussion.json 格式(来自 2Y9M3ILK 实际数据):
|
||||
```json
|
||||
{ "sessions": [{ "agent":"pf-paper", "model":"deepseek-v4-pro", "qa_pairs": [{"question": "...", "answer": "..."}] }] }
|
||||
```
|
||||
回答可能较长(500+ 中文字符)。
|
||||
|
||||
渲染:
|
||||
- 无数据时:整张卡隐藏
|
||||
- 有数据时:取 `sessions[-1]` 最近 session,展示最近 2-3 个 `qa_pairs`
|
||||
- 每条显示:`提问:完整问题` + `解答:{截断150字符}... [展开]`
|
||||
- 点击 [展开] 后显示完整回答(inline expand,不跳转)
|
||||
- 底部 "查看全部讨论 →" 链接(打开 discussion.md)
|
||||
- 截断函数:中文按字符计,英文按词计,保证不会在 Obsidian wikilink 中间截断
|
||||
|
||||
- [ ] **Step 5: 实现 `_renderPaperNextStepCard(container, entry)`**
|
||||
|
||||
简化版下一步推荐(删除 lifecycle maturity 视角):
|
||||
```
|
||||
无 PDF → "等待 PDF"
|
||||
有 PDF 未入 OCR → "可加入 OCR 队列"
|
||||
OCR pending/queued/processing → "OCR 进行中"
|
||||
OCR failed/blocked → "OCR 需要处理"
|
||||
OCR 完成 精读未完成 → "可开始精读"
|
||||
精读完成 → "已完成 · 可继续讨论"
|
||||
```
|
||||
|
||||
- [ ] **Step 6: 实现 `_renderPaperFilesRow(container, entry)`**
|
||||
|
||||
两个按钮:`Open PDF`(使用 pdf_path wikilink)、`Open Fulltext`(使用 fulltext_path)
|
||||
|
||||
- [ ] **Step 7: 实现 `_renderPaperTechnicalDetails(container, entry)`**
|
||||
|
||||
默认折叠的 section,包含:PDF health, OCR raw status, asset state, note path, fulltext path。仅在异常时展开或用户手动展开。
|
||||
|
||||
---
|
||||
|
||||
### Task 8: 更新 CSS 样式
|
||||
|
||||
**Files:**
|
||||
- Modify: `paperforge/plugin/styles.css`
|
||||
|
||||
- [ ] **Step 1: 添加新组件样式**
|
||||
|
||||
```css
|
||||
/* Status Strip — compact horizontal pills */
|
||||
.paperforge-status-strip { ... }
|
||||
.paperforge-status-pill { ... } /* pill.ok / pill.warn / pill.fail */
|
||||
|
||||
/* Paper Overview Card */
|
||||
.paperforge-paper-overview { ... }
|
||||
.paperforge-paper-overview-excerpt { ... }
|
||||
|
||||
/* Recent Discussion Card */
|
||||
.paperforge-discussion-card { ... }
|
||||
.paperforge-discussion-item { ... }
|
||||
|
||||
/* Paper Files Row */
|
||||
.paperforge-paper-files { ... }
|
||||
|
||||
/* Paper Technical Details (collapsible) */
|
||||
.paperforge-technical-details { ... }
|
||||
.paperforge-technical-details-toggle { ... }
|
||||
|
||||
/* Workflow Overview (base) */
|
||||
.paperforge-workflow-overview { ... }
|
||||
.paperforge-workflow-stage { ... }
|
||||
|
||||
/* Issue Summary (base + global) */
|
||||
.paperforge-issue-summary { ... }
|
||||
|
||||
/* Library Snapshot (global) */
|
||||
.paperforge-library-snapshot { ... }
|
||||
|
||||
/* System Status (global) */
|
||||
.paperforge-system-status { ... }
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 清理旧样式**
|
||||
|
||||
保持以下样式的兼容性(因为其他模式可能仍复用部分结构):
|
||||
- `.paperforge-header`, `.paperforge-mode-badge`
|
||||
- `.paperforge-paper-view`, `.paperforge-paper-header`, `.paperforge-paper-title`
|
||||
- `.paperforge-collection-view`
|
||||
- `.paperforge-metrics`, `.paperforge-metric-card`(global 仍用)
|
||||
- `.paperforge-ocr-section`, `.paperforge-progress-track`, `.paperforge-ocr-counts`(base 用)
|
||||
|
||||
---
|
||||
|
||||
### Task 9: 更新 UX contract 文档
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/ux-contract.md`
|
||||
|
||||
- [ ] **Step 1: 更新 Workflow 4 — Dashboard View**
|
||||
|
||||
更新 W4-S3 per-paper dashboard 描述:从 "lifecycle stepper, health matrix, next-step recommendation" 改为 "status strip, paper overview, recent discussion, next-step, files row, technical details"。
|
||||
|
||||
更新 W4-S2 添加 workflow overview columns 描述。
|
||||
|
||||
- [ ] **Step 2: 添加全局和 collection 视图描述**
|
||||
|
||||
添加 Workflow 4 新的 sub-steps 描述 global(系统首页)和 collection(批量工作台)。
|
||||
|
||||
---
|
||||
|
||||
### Task 10: 验证
|
||||
|
||||
- [ ] **Step 1: 在 Obsidian 中加载插件**
|
||||
|
||||
1. 用 Symbolic link 或直接复制插件文件到 test vault 的 `.obsidian/plugins/paperforge/`
|
||||
2. 打开 Obsidian,确认 PaperForge 图标出现在左侧 sidebar
|
||||
3. 点击图标打开 Dashboard
|
||||
|
||||
- [ ] **Step 2: 验证 mode routing**
|
||||
|
||||
- 不打开任何文件 → global mode ✓
|
||||
- 打开 `.base` 文件 → collection mode ✓
|
||||
- 打开 formal note → paper mode ✓
|
||||
- 打开 `fulltext.md` → paper mode ✓(workspace detection)
|
||||
- 打开 workshop 目录下其他文件 → paper mode ✓
|
||||
|
||||
- [ ] **Step 3: 验证 global 视图**
|
||||
|
||||
- Library snapshot 显示计数正确
|
||||
- System status 显示 runtime/index/Zotero/OCR token 状态
|
||||
- Issues 仅在有问题时出现
|
||||
- Actions 有 Open Literature Hub + Sync Library
|
||||
|
||||
- [ ] **Step 4: 验证 base 视图**
|
||||
|
||||
- Workflow overview 漏斗显示正确
|
||||
- OCR pipeline 进度条对应当前 base
|
||||
- Issue summary 紧凑显示
|
||||
- No Copy Collection Context
|
||||
|
||||
- [ ] **Step 5: 验证 paper 视图**
|
||||
|
||||
- Status strip 显示正确
|
||||
- 文章概览从 formal note 提取内容
|
||||
- Discussion 卡有数据时显示,无数据时隐藏
|
||||
- 下一步推荐文字正确
|
||||
- 技术详情默认折叠
|
||||
- No Copy Context
|
||||
|
||||
- [ ] **Step 6: 检查控制台**
|
||||
|
||||
确认无 JS 错误、无 console.error。
|
||||
|
||||
---
|
||||
|
||||
## 非目标(明确不做的)
|
||||
|
||||
1. 不做 Copy Context / Copy Collection Context
|
||||
2. 不做独立 deep-reading dashboard
|
||||
3. 不让每个 view 挂同一套 Quick Actions
|
||||
4. 不把 global 做成第二个 collection overview
|
||||
5. 不在 per-paper 保留 lifecycle stepper / health matrix / maturity gauge
|
||||
6. 不放所有健康指标全部展开
|
||||
7. 不在 dashboard 里弥补 skill 检索能力不足
|
||||
398
docs/superpowers/plans/2026-05-10-dashboard-ui-refine.md
Normal file
398
docs/superpowers/plans/2026-05-10-dashboard-ui-refine.md
Normal file
|
|
@ -0,0 +1,398 @@
|
|||
# Dashboard UI Refine Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Refine the PaperForge Obsidian dashboard so `paper`, `collection`, and `global` modes feel stable, readable, and visually intentional while fixing the current layout and interaction defects.
|
||||
|
||||
**Architecture:** Keep the existing three-mode dashboard structure in `paperforge/plugin/main.js`, but reduce refresh-related state loss and clean up the dashboard CSS into one authoritative visual system in `paperforge/plugin/styles.css`. Preserve Obsidian structural tokens for surfaces/text/borders, then layer a muted PaperForge accent system on top for badges, metrics, pills, and primary actions.
|
||||
|
||||
**Tech Stack:** Obsidian plugin DOM API, CommonJS plugin runtime, Vitest, CSS custom properties, Obsidian theme variables.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- Modify: `paperforge/plugin/main.js`
|
||||
- Dashboard leaf activation / refresh behavior
|
||||
- Paper-mode transient state preservation
|
||||
- Small DOM/class adjustments only if required by the new visual system
|
||||
- Modify: `paperforge/plugin/styles.css`
|
||||
- Consolidate duplicate dashboard CSS
|
||||
- Add stable scroll / bottom-safe-area behavior
|
||||
- Implement the `Quiet Research Desk` palette and hierarchy rules
|
||||
- Modify: `docs/ux-contract.md`
|
||||
- Update workflow contract expectations for `paper`, `collection`, and `global`
|
||||
- Test: `paperforge/plugin/tests/*.mjs`
|
||||
- Keep existing suite green
|
||||
- Add a focused regression test only if a new pure helper or testable branch is extracted
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Lock Scroll Stability and Preserve the Existing Refresh Fix
|
||||
|
||||
**Files:**
|
||||
- Modify: `paperforge/plugin/main.js`
|
||||
- Modify: `paperforge/plugin/styles.css`
|
||||
- Test: `paperforge/plugin/tests/*.mjs`
|
||||
|
||||
- [ ] **Step 1: Add a regression note to the plan execution log**
|
||||
|
||||
Record the two interaction defects at the top of your execution scratchpad / task notes to preserve focus during implementation:
|
||||
|
||||
```text
|
||||
- First discussion expand must not collapse on first click.
|
||||
- Technical-details expand must not trigger width jump from late scrollbar appearance.
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify the current automated baseline before edits**
|
||||
|
||||
Run: `npm test`
|
||||
Workdir: `paperforge/plugin`
|
||||
Expected: `40 passed` (or current equivalent with zero failures)
|
||||
|
||||
- [ ] **Step 3: Verify the existing discussion-refresh fix remains intact**
|
||||
|
||||
Do not re-implement the `active-leaf-change` guard if it is already present. Instead, confirm the current `main.js` logic still short-circuits leaf-focus-only transitions and does not get accidentally removed while refining UI code.
|
||||
|
||||
Verification target:
|
||||
|
||||
```text
|
||||
If resolved mode and file path are unchanged, active-leaf-change must not rebuild the mode tree.
|
||||
```
|
||||
|
||||
This protection must continue to preserve both:
|
||||
|
||||
```text
|
||||
- discussion expanded/collapsed state
|
||||
- technical-details expanded/collapsed state
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Stabilize the root scroll container in `styles.css`**
|
||||
|
||||
Apply bottom safe-area and stable vertical scrollbar behavior at the dashboard container level.
|
||||
|
||||
Implementation shape:
|
||||
|
||||
```css
|
||||
.paperforge-status-panel {
|
||||
overflow-y: auto;
|
||||
scrollbar-gutter: stable;
|
||||
padding: 14px 14px 56px 14px;
|
||||
}
|
||||
```
|
||||
|
||||
If `scrollbar-gutter` alone is insufficient during manual verification, add a compatible fallback without changing mode structure, for example a permanent vertical overflow reservation strategy at the same container boundary.
|
||||
|
||||
- [ ] **Step 5: Run the automated suite again**
|
||||
|
||||
Run: `npm test`
|
||||
Workdir: `paperforge/plugin`
|
||||
Expected: zero failures
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add paperforge/plugin/main.js paperforge/plugin/styles.css
|
||||
git commit -m "fix: stabilize dashboard leaf refresh and scroll layout"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Consolidate Dashboard CSS Authority
|
||||
|
||||
**Files:**
|
||||
- Modify: `paperforge/plugin/styles.css`
|
||||
- Test: `paperforge/plugin/tests/*.mjs`
|
||||
|
||||
- [ ] **Step 1: Identify the authoritative dashboard section blocks**
|
||||
|
||||
Use the existing redesign spec as the source of truth and remove overlapping duplicate selector definitions for:
|
||||
|
||||
```text
|
||||
.paperforge-section-label
|
||||
.paperforge-contextual-btn
|
||||
.paperforge-status-strip
|
||||
.paperforge-paper-overview
|
||||
.paperforge-discussion-card
|
||||
.paperforge-technical-details*
|
||||
.paperforge-workflow-overview*
|
||||
.paperforge-library-snapshot*
|
||||
.paperforge-system-status*
|
||||
.paperforge-global-actions*
|
||||
.paperforge-workflow-toggles*
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Rewrite to one authoritative visual system**
|
||||
|
||||
Prefer a single final selector block per dashboard component family. Do not stack another layer of overrides at the bottom of the file.
|
||||
|
||||
Target structure:
|
||||
|
||||
```css
|
||||
/* shared tokens */
|
||||
/* paper mode */
|
||||
/* collection mode */
|
||||
/* global mode */
|
||||
/* dark theme overrides */
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Introduce muted PaperForge accent tokens**
|
||||
|
||||
Keep surfaces/text/borders Obsidian-native, and add a restrained PaperForge tint layer.
|
||||
|
||||
Implementation shape:
|
||||
|
||||
```css
|
||||
.paperforge-status-panel {
|
||||
--pf-paper-accent: ...;
|
||||
--pf-collection-accent: ...;
|
||||
--pf-global-accent: ...;
|
||||
--pf-warm-line: ...;
|
||||
}
|
||||
```
|
||||
|
||||
Do not apply these as large full-card backgrounds.
|
||||
|
||||
- [ ] **Step 4: Run the automated suite**
|
||||
|
||||
Run: `npm test`
|
||||
Workdir: `paperforge/plugin`
|
||||
Expected: zero failures
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add paperforge/plugin/styles.css
|
||||
git commit -m "refactor: unify dashboard style system"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Rebalance Paper Mode
|
||||
|
||||
**Files:**
|
||||
- Modify: `paperforge/plugin/styles.css`
|
||||
- Modify: `paperforge/plugin/main.js` (only if a class hook is needed)
|
||||
- Test: `paperforge/plugin/tests/*.mjs`
|
||||
|
||||
- [ ] **Step 1: Raise paper-mode comfort without changing module order**
|
||||
|
||||
Keep the existing order:
|
||||
|
||||
```text
|
||||
paper header
|
||||
status/file row
|
||||
overview card
|
||||
next-step or complete row
|
||||
discussion card
|
||||
technical details
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Make technical details lighter than primary cards**
|
||||
|
||||
Use disclosure styling that reads as metadata, not as a full secondary card.
|
||||
|
||||
Implementation shape:
|
||||
|
||||
```css
|
||||
.paperforge-technical-details-toggle {
|
||||
width: 100%;
|
||||
min-height: 34px;
|
||||
}
|
||||
|
||||
.paperforge-technical-details-body {
|
||||
padding-top: 8px;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Keep file actions neutral and discussion readable**
|
||||
|
||||
Preserve neutral file-opening actions in `paper` mode; do not promote them with strong tinting. Maintain body copy at `14px` minimum and keep discussion/overview as the most legible blocks.
|
||||
|
||||
- [ ] **Step 4: Run the automated suite**
|
||||
|
||||
Run: `npm test`
|
||||
Workdir: `paperforge/plugin`
|
||||
Expected: zero failures
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add paperforge/plugin/main.js paperforge/plugin/styles.css
|
||||
git commit -m "style: rebalance paper mode for reading"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Strengthen Collection and Global Hierarchy
|
||||
|
||||
**Files:**
|
||||
- Modify: `paperforge/plugin/styles.css`
|
||||
- Modify: `paperforge/plugin/main.js` (only if action classes or wrappers need a tiny hook)
|
||||
- Test: `paperforge/plugin/tests/*.mjs`
|
||||
|
||||
- [ ] **Step 1: Scale up collection metrics and workflow stage presence**
|
||||
|
||||
Meet the measured targets from the spec:
|
||||
|
||||
```css
|
||||
.paperforge-workflow-stage {
|
||||
min-width: 72px;
|
||||
}
|
||||
|
||||
.paperforge-snapshot-pill {
|
||||
min-width: 72px;
|
||||
}
|
||||
|
||||
.paperforge-collection-actions .paperforge-contextual-btn,
|
||||
.paperforge-global-actions-row .paperforge-contextual-btn {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.paperforge-workflow-stage-value,
|
||||
.paperforge-snapshot-value {
|
||||
font-size: 22px;
|
||||
}
|
||||
```
|
||||
|
||||
Also keep `collection` / `global` labels and detail text at spec minimums:
|
||||
|
||||
```css
|
||||
.paperforge-workflow-stage-label,
|
||||
.paperforge-snapshot-label {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.paperforge-section-label {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.paperforge-status-label,
|
||||
.paperforge-status-detail,
|
||||
.paperforge-collection-count {
|
||||
font-size: 13px;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Promote the primary actions explicitly**
|
||||
|
||||
Apply stronger but muted emphasis only to:
|
||||
|
||||
```text
|
||||
global: Open Literature Hub
|
||||
collection: Run OCR
|
||||
```
|
||||
|
||||
Keep `Sync Library` secondary in both modes.
|
||||
|
||||
Enforce button size targets while doing this:
|
||||
|
||||
```css
|
||||
.paperforge-collection-actions .paperforge-contextual-btn,
|
||||
.paperforge-global-actions-row .paperforge-contextual-btn {
|
||||
min-height: 36px;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Increase global homepage presence without changing inventory/order**
|
||||
|
||||
Keep the current order:
|
||||
|
||||
```text
|
||||
library snapshot
|
||||
system status
|
||||
optional issues
|
||||
start working
|
||||
```
|
||||
|
||||
Make cards, labels, and controls read as a homepage rather than a compact utility panel.
|
||||
Keep the OCR module visually heavier than the collection action row by giving the OCR section stronger padding / surface presence than the actions beneath it.
|
||||
|
||||
- [ ] **Step 4: Run the automated suite**
|
||||
|
||||
Run: `npm test`
|
||||
Workdir: `paperforge/plugin`
|
||||
Expected: zero failures
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add paperforge/plugin/main.js paperforge/plugin/styles.css
|
||||
git commit -m "style: strengthen collection and global dashboard hierarchy"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Update UX Contract and Final Verification
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/ux-contract.md`
|
||||
- Test: `paperforge/plugin/tests/*.mjs`
|
||||
|
||||
- [ ] **Step 1: Update Workflow 4 contract entries**
|
||||
|
||||
Reflect the refined dashboard expectations in `docs/ux-contract.md`:
|
||||
|
||||
```text
|
||||
- paper mode bottom-safe-area expectation
|
||||
- no scrollbar reflow on technical-details expand
|
||||
- no first-click discussion collapse
|
||||
- preserved module order for collection mode
|
||||
- collection-mode issues remain scoped inside the collection view rather than being promoted into a separate extra module
|
||||
- preserved module order for global mode
|
||||
- light/dark theme verification expectation
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the full automated suite**
|
||||
|
||||
Run: `npm test`
|
||||
Workdir: `paperforge/plugin`
|
||||
Expected: zero failures
|
||||
|
||||
- [ ] **Step 3: Perform manual verification in Obsidian desktop**
|
||||
|
||||
Checklist:
|
||||
|
||||
```text
|
||||
- Windows light theme: paper bottom content is fully visible
|
||||
- Windows light theme: technical details expand without width jump
|
||||
- Windows light theme: first discussion expand does not flash closed
|
||||
- Windows light theme: with discussion expanded, leaf activation alone does not collapse it when resolved identity is unchanged
|
||||
- Windows light theme: with technical details expanded, leaf activation alone does not collapse it when resolved identity is unchanged
|
||||
- Windows light theme: collection feels larger and action hierarchy is obvious
|
||||
- Windows light theme: global feels like a homepage, not a utility panel
|
||||
- Windows light theme: keyboard focus is visible on collection/global action controls
|
||||
- Windows dark theme: contrast, focus visibility, and muted accents remain readable
|
||||
- Windows dark theme: keyboard focus is visible on collection/global action controls
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add docs/ux-contract.md paperforge/plugin/main.js paperforge/plugin/styles.css
|
||||
git commit -m "docs: align dashboard ux contract with refined ui"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Do not add new dashboard modes.
|
||||
- Do not change CLI/data contracts.
|
||||
- Do not introduce theme-specific palettes.
|
||||
- Prefer removing duplicate CSS over overriding it again.
|
||||
- If automated UI regression coverage requires extracting a small pure helper, keep the extraction minimal and local to dashboard behavior.
|
||||
|
||||
## Final Verification Commands
|
||||
|
||||
```bash
|
||||
cd paperforge/plugin
|
||||
npm test
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
```text
|
||||
Test Files 3 passed
|
||||
Tests 40 passed
|
||||
```
|
||||
161
docs/superpowers/plans/2026-05-10-dashboard-visual-refinement.md
Normal file
161
docs/superpowers/plans/2026-05-10-dashboard-visual-refinement.md
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
# PaperForge Dashboard Visual Refinement Plan
|
||||
|
||||
> **For agentic workers:** Use superpowers:subagent-driven-development or inline execution. Steps use checkbox (`- [ ]`) syntax.
|
||||
|
||||
**Goal:** Apply Native Light Surface Design spec — strict typography, minimal containers, Obsidian-native colors, no decorative shadows, locale-unified UI text.
|
||||
|
||||
**Architecture:** CSS-first: establish --pf-* custom properties and .pf-* utility classes on `.paperforge-status-panel`, then rewrite Sections 39-43 with constrained typography (4 sizes, 3 weights), light borders, and contextual cards only for primary content modules. JS: restructure per-paper layout to merge header/status/files rows, collapse OCR/Analyze into technical details, compact "All Set" state.
|
||||
|
||||
**Tech Stack:** Obsidian DOM API (vanilla JS), CSS with Obsidian semantic variables only.
|
||||
|
||||
**Commit style:** `type(scope): description` (English, semantic, existing pattern).
|
||||
|
||||
---
|
||||
|
||||
## Files
|
||||
- Modify: `paperforge/plugin/styles.css` (large rewrite of Sections 39-43)
|
||||
- Modify: `paperforge/plugin/main.js` (per-paper layout + locale text)
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Commit current CSS base changes
|
||||
|
||||
**Files:** styles.css (staged change on paperforge-status-panel + utility classes)
|
||||
|
||||
- [ ] Git commit the pending styles.css change
|
||||
|
||||
```bash
|
||||
git add paperforge/plugin/styles.css
|
||||
git commit -m "style(dashboard): establish CSS base --pf-* vars, typography tokens, utility classes"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Rewrite CSS Section 39 — Shared components
|
||||
|
||||
**Files:** styles.css (replace Section 39 "Shared Components")
|
||||
|
||||
Replace the existing Section 39 with:
|
||||
- `.pf-card` — only for primary content modules (no box-shadow)
|
||||
- `.pf-pill` + modifiers `.pf-pill--ok/warn/fail` — 999px radius, subtle
|
||||
- `.pf-btn-secondary` / `.pf-btn-primary` — Obsidian interactive vars
|
||||
- `.pf-complete-row` — compact green text, no card
|
||||
- `.pf-disclosure-row` + `.pf-disclosure-body` — simple text+cursor
|
||||
- `.pf-section-label` — 12px/600/uppercase/no accent border
|
||||
- `.pf-stack-8` / `.pf-stack-12` — spacing utilities
|
||||
- Remove old `.paperforge-section-label` left-border accent
|
||||
- Remove old contextual button styles (replaced by pf-btn-*)
|
||||
- Remove old card ::before elevation pseudo-elements
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Rewrite CSS Section 40 — Per-paper view
|
||||
|
||||
**Files:** styles.css (replace Section 40)
|
||||
|
||||
Apply constrained typography to per-paper components:
|
||||
- `.paperforge-paper-header`: title 16px/600, meta 13px/400, year 12px/400 (no bullet separator, use ` · `)
|
||||
- `.paperforge-status-strip`: remove borders, keep flex + gap
|
||||
- `.paperforge-status-pill`: inherit `.pf-pill` patterns (no colored backgrounds, text-only status colors)
|
||||
- `.paperforge-paper-overview`: use `.pf-card` base (no shadow, no ::before)
|
||||
- `.paperforge-discussion-card`: use `.pf-card` base (no shadow, no ::before)
|
||||
- `.paperforge-discussion-q`: 14px/600
|
||||
- `.paperforge-discussion-a`: 14px/400
|
||||
- `.paperforge-discussion-expand`: text accent only
|
||||
- `.paperforge-paper-files`: move inline with status strip
|
||||
- `.paperforge-technical-details-toggle`: use `.pf-disclosure-row` style
|
||||
- `.paperforge-technical-details-body`: use `.pf-disclosure-body` style
|
||||
- `.paperforge-workflow-toggles`: NOT a card (remove background, border, shadow). Simple flex row.
|
||||
- Dark theme overrides: remove shadow rules, keep only background/border tweaks
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Rewrite CSS Section 41 — Collection/Base
|
||||
|
||||
**Files:** styles.css (replace Section 41)
|
||||
|
||||
- `.paperforge-workflow-overview`: `.pf-card` base, remove shadow
|
||||
- `.paperforge-workflow-stage-value`: 18px/600, accent only for metric
|
||||
- `.paperforge-collection-header`: title 16px/600
|
||||
- `.paperforge-issue-summary`: light border-left accent (orange), no full red border
|
||||
- `.paperforge-collection-actions`: simple flex row
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Rewrite CSS Section 42 — Global/Home
|
||||
|
||||
**Files:** styles.css (replace Section 42)
|
||||
|
||||
- `.paperforge-library-snapshot`: `.pf-card` base, remove shadow
|
||||
- `.paperforge-system-status`: `.pf-card` base, remove shadow
|
||||
- `.paperforge-global-actions`: `.pf-card` base, remove shadow
|
||||
- `.paperforge-snapshot-value`: 18px/600
|
||||
- `.paperforge-status-row`: dot stays, label 14px
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Rewrite JS per-paper layout (merge header + status strip + file row)
|
||||
|
||||
**Files:** main.js — `_renderPaperMode` and helpers
|
||||
|
||||
Layout per spec:
|
||||
```
|
||||
[PAPER badge] title
|
||||
authors · year
|
||||
[PDF ✓] [OCR ✓] [精读 ✓] [打开 PDF] [打开全文]
|
||||
┌ 文章概览 ─────────────┐
|
||||
└──────────────────────┘
|
||||
✓ 已完成,可直接使用
|
||||
┌ 最近讨论 ─────────────┐
|
||||
└──────────────────────┘
|
||||
技术详情 ▸
|
||||
```
|
||||
|
||||
Changes:
|
||||
1. Status strip: place PDF pill + OCR pill + DeepRead pill left, Open PDF + Open Fulltext right (same flex row)
|
||||
2. No separate `paperforge-paper-files` section below — file buttons ARE in the status row
|
||||
3. Workflow toggles (OCR/Analyze checkbox): remove from standalone section, move into `_renderPaperTechnicalDetails` disclosure body
|
||||
4. Next step card: when `nextStep === 'ready'`, render compact `.pf-complete-row` instead of card
|
||||
5. Next step card: remove `'RECOMMENDED NEXT STEP'` all-caps label, use "下一步" (zh) / "Next Step" (en)
|
||||
6. Locale: all UI text use Chinese when T===zh
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Locale cleanup
|
||||
|
||||
**Files:** main.js — ensure all visible UI labels are locale-consistent
|
||||
|
||||
- "Open PDF" → `t('open_pdf')` or hardcoded 中文 `打开 PDF`
|
||||
- "Open Fulltext" → `打开全文`
|
||||
- "Copy /pf-deep Command" → `复制精读命令` or remove
|
||||
- "Run in {0}" → remove or make Chinese
|
||||
- Next-step labels: use Chinese when zh
|
||||
- Section labels: "文章概览" stays, "最近讨论" stays, "技术详情" stays
|
||||
- "Add to OCR Queue" → `加入 OCR`
|
||||
- "Remove from OCR Queue" → `移出 OCR`
|
||||
- "Analyze" label → `精读` / `标记精读`
|
||||
|
||||
Add missing i18n keys to LANG.zh and LANG.en.
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Regression verification
|
||||
|
||||
- [ ] Verify syntax: `node --check main.js`
|
||||
- [ ] Run tests: `npx vitest run` (all 40 pass)
|
||||
- [ ] Copy to test vault
|
||||
- [ ] Visual check: per-paper with 3EWBBTAS (deep-read done), 2Y9M3ILK (discussion), B29TFCL4 (no OCR)
|
||||
- [ ] Check global view layout
|
||||
- [ ] Check collection view layout
|
||||
- [ ] Check dark theme (toggle Obsidian theme to dark)
|
||||
- [ ] Check narrow sidebar width
|
||||
|
||||
---
|
||||
|
||||
## Non-goals
|
||||
- Don't add new fonts
|
||||
- Don't add decorative shadows to cards
|
||||
- Don't add colored backgrounds to status pills
|
||||
- Don't use more than 4 font sizes
|
||||
- Don't use font-weight 700
|
||||
- Don't rebuild existing working modules (only restructure layout, not logic)
|
||||
395
docs/superpowers/specs/2026-05-10-dashboard-ui-refine-design.md
Normal file
395
docs/superpowers/specs/2026-05-10-dashboard-ui-refine-design.md
Normal file
|
|
@ -0,0 +1,395 @@
|
|||
# PaperForge Dashboard UI Refine Design
|
||||
|
||||
> Approved direction: `Quiet Research Desk`
|
||||
> Tone: `Calm editorial`
|
||||
> Color posture: preserve PaperForge's own muted identity more than Obsidian theme accent, while remaining structurally theme-compatible.
|
||||
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Refine the PaperForge Obsidian dashboard so the three active modes feel stable, readable, and visually intentional.
|
||||
|
||||
This pass must remove current interaction defects, increase visual hierarchy in `global` and `collection` modes, and replace the current overly small / overly uniform surface treatment with a warmer, quieter dashboard language that still fits naturally inside Obsidian.
|
||||
|
||||
---
|
||||
|
||||
## In Scope
|
||||
|
||||
1. Fix layout and interaction defects in the current dashboard UI.
|
||||
2. Simplify and unify dashboard CSS so one visual system controls all three modes.
|
||||
3. Increase perceived weight and readability of `global` and `collection` views.
|
||||
4. Preserve `paper` mode as the calmest mode, optimized for reading and discussion.
|
||||
5. Add restrained color accents inspired by muted Morandi / Maillard / soft American editorial palettes.
|
||||
6. Keep compatibility with Obsidian themes for structure, contrast, and dark/light behavior.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
1. Adding new dashboard modes.
|
||||
2. Changing data contracts or CLI behavior.
|
||||
3. Major DOM restructuring outside the current dashboard components.
|
||||
4. Theme-specific per-theme palettes.
|
||||
5. New analytics, charts, or workflow states.
|
||||
|
||||
---
|
||||
|
||||
## Problems To Solve
|
||||
|
||||
### 1. Bottom clipping in `paper` mode
|
||||
|
||||
The dashboard scroll container ends too tightly against Obsidian's lower chrome. Expanded technical details can be visually blocked by the bottom bar.
|
||||
|
||||
### 2. Scrollbar-induced layout jump
|
||||
|
||||
Opening technical details can push content over the vertical overflow threshold, causing the right scrollbar to appear only in the expanded state. This changes content width and makes text reflow visibly.
|
||||
|
||||
### 3. First discussion expand flash
|
||||
|
||||
The first click on a discussion expand control can trigger an unnecessary mode refresh due to `active-leaf-change`, which destroys transient local UI state.
|
||||
|
||||
### 4. `collection` mode feels underweighted and empty
|
||||
|
||||
The funnel, OCR section, and actions are all visually too small and too light relative to the available vertical space. The action row feels like footer utilities instead of primary workflow controls.
|
||||
|
||||
### 5. `global` mode feels like a compressed utility panel
|
||||
|
||||
Snapshot metrics, system status, and actions do not yet read as a real homepage. Typography and card weight are too restrained.
|
||||
|
||||
### 6. Visual language is too uniform and slightly sterile
|
||||
|
||||
Nearly every module uses the same thin border, small label, and low-energy box treatment. The result is clean but fatiguing.
|
||||
|
||||
### 7. CSS authority is split across duplicated sections
|
||||
|
||||
`styles.css` contains overlapping dashboard definitions from multiple iterations. The file currently mixes the newer native-light-surface pass with later redefinitions, causing inconsistency and making further refinement brittle.
|
||||
|
||||
---
|
||||
|
||||
## Design Principles
|
||||
|
||||
### Quiet Research Desk
|
||||
|
||||
The dashboard should feel like a well-kept research desk, not an admin console.
|
||||
|
||||
- Calm, not cold
|
||||
- Warm, not decorative
|
||||
- Structured, not dense
|
||||
- Native to Obsidian, not generic web SaaS
|
||||
|
||||
### Mode personality hierarchy
|
||||
|
||||
- `paper`: quietest, most reading-oriented, least chrome
|
||||
- `collection`: strongest workflow personality, denser operational cues
|
||||
- `global`: homepage personality, strongest sense of entry and orientation
|
||||
|
||||
### Structural theme compatibility
|
||||
|
||||
The dashboard should inherit all major structural tokens from Obsidian:
|
||||
|
||||
- surfaces
|
||||
- text hierarchy
|
||||
- borders
|
||||
- hover / active interaction colors
|
||||
- dark/light behavior
|
||||
|
||||
### Selective brand tinting
|
||||
|
||||
PaperForge should keep a light house style of its own through restrained accent zones only:
|
||||
|
||||
- mode badges
|
||||
- section markers
|
||||
- emphasized numbers
|
||||
- status pills
|
||||
- light hover / focus accents
|
||||
|
||||
Accent tinting must never dominate full-card backgrounds.
|
||||
|
||||
---
|
||||
|
||||
## Color Direction
|
||||
|
||||
### Palette family
|
||||
|
||||
Use a muted editorial palette rather than a bright product palette.
|
||||
|
||||
Candidate families:
|
||||
|
||||
- `paper`: sage / dusty teal / cool gray-green
|
||||
- `collection`: terracotta / muted amber / clay brown
|
||||
- `global`: navy-gray / smoky blue / quiet brass accent
|
||||
|
||||
### Rules
|
||||
|
||||
1. No large high-saturation fills.
|
||||
2. No dependence on the user's Obsidian accent as the primary PaperForge identity.
|
||||
3. Accent color should be visible mostly in compact anchors, not whole surfaces.
|
||||
4. Dark mode uses the same family, but with contrast adjusted by theme variables.
|
||||
|
||||
### Token ownership
|
||||
|
||||
Structural tokens come from Obsidian variables:
|
||||
|
||||
- surfaces: `--background-*`
|
||||
- body and metadata text: `--text-*`
|
||||
- borders and separators: `--background-modifier-*`
|
||||
- default hover / active button surfaces: `--interactive-*`
|
||||
- default focus visibility should remain Obsidian-native unless a stronger PaperForge focus ring is required for contrast
|
||||
|
||||
PaperForge-owned tint tokens may be introduced only for:
|
||||
|
||||
- mode badges
|
||||
- section markers
|
||||
- emphasized metric numbers
|
||||
- semantic status pills
|
||||
- optional primary action emphasis
|
||||
- light card-edge or label accents in `global` and `collection`
|
||||
|
||||
PaperForge-owned tints must not replace Obsidian's base surface, body text, or default control contrast model.
|
||||
|
||||
---
|
||||
|
||||
## Visual System Changes
|
||||
|
||||
### Typography
|
||||
|
||||
Raise hierarchy contrast across all modes.
|
||||
|
||||
- Larger metric numbers in `global` and `collection`
|
||||
- Section labels become more deliberate but not louder
|
||||
- Buttons gain more presence through padding and weight, not bright fills
|
||||
- `paper` body text remains the most reading-friendly text block
|
||||
|
||||
Measured targets:
|
||||
|
||||
- `global` / `collection` metric values: minimum `22px`, target `22-24px`
|
||||
- `paper` section body copy: `14px` minimum, no reduction below current reading size
|
||||
- `global` / `collection` section labels: minimum `12px`
|
||||
- `global` / `collection` metadata/detail text: minimum `13px`
|
||||
- primary and contextual action buttons in `global` / `collection`: minimum `13px` text size
|
||||
|
||||
### Spacing
|
||||
|
||||
- Increase inner card padding in `global` and `collection`
|
||||
- Add consistent mode-level vertical rhythm
|
||||
- Add stable bottom safe area to the scroll container
|
||||
- Reduce cramped micro-gaps around pills, buttons, and disclosure areas
|
||||
|
||||
Measured targets:
|
||||
|
||||
- root dashboard bottom safe area: minimum `56px`
|
||||
- primary cards in `global` / `collection`: minimum `16px` inner padding
|
||||
- action buttons: minimum `34px` visual height
|
||||
- disclosure body top padding: minimum `8px`
|
||||
- `collection` / `global` mode section gap: minimum `16px`
|
||||
|
||||
### Surfaces
|
||||
|
||||
- Use fewer visual formulas, not more
|
||||
- Primary content modules may remain carded
|
||||
- Lightweight metadata / disclosure sections should not pretend to be full cards
|
||||
- Remove mixed old/new box-shadow and border treatments
|
||||
|
||||
### Actions
|
||||
|
||||
- `collection` and `global` actions must feel like meaningful controls, not utility links
|
||||
- Action rows should have larger targets and better anchoring in layout
|
||||
- Primary actions may use slightly stronger tinting, but still muted
|
||||
|
||||
Primary action ownership:
|
||||
|
||||
- `global`: `Open Literature Hub` is the primary navigational action; `Sync Library` is secondary
|
||||
- `collection`: `Run OCR` is the primary workflow action; `Sync Library` is secondary
|
||||
- `paper`: keep file-opening actions neutral; do not introduce a new strong primary tint in the status/file row
|
||||
|
||||
Interaction rules:
|
||||
|
||||
- focus rings must remain clearly visible in both light and dark themes
|
||||
- interactive controls must keep at least Obsidian-native focus visibility
|
||||
- tinted buttons or pills must maintain readable text contrast in both light and dark themes
|
||||
|
||||
---
|
||||
|
||||
## Mode-Specific Design
|
||||
|
||||
### Paper Mode
|
||||
|
||||
Keep `paper` mode closest to the current reading companion model.
|
||||
|
||||
Changes:
|
||||
|
||||
1. Add bottom safety padding so technical details and discussion are never cramped by the Obsidian bottom bar.
|
||||
2. Stabilize vertical scrollbar reservation to eliminate width jump when expanding sections.
|
||||
3. Preserve the current header and merged status/file row structure.
|
||||
4. Keep overview and discussion as the main content cards.
|
||||
5. Make technical details lighter and calmer than primary cards.
|
||||
6. Ensure discussion expand/collapse interaction never triggers accidental refresh-state loss.
|
||||
|
||||
Measured targets:
|
||||
|
||||
- technical-details expand/collapse must not cause horizontal text reflow from scrollbar appearance on supported desktop targets
|
||||
- local discussion expand state and technical-details disclosure state must survive leaf activation when resolved mode and file identity stay unchanged
|
||||
- paper mode may use the weakest accent density of the three modes
|
||||
|
||||
### Collection Mode
|
||||
|
||||
Make `collection` mode feel like an operational workspace.
|
||||
|
||||
Changes:
|
||||
|
||||
1. Increase funnel visual weight and number emphasis.
|
||||
2. Make OCR pipeline feel like a central module, not a thin progress strip.
|
||||
3. Enlarge action controls and treat them as workflow entry points.
|
||||
4. Reduce the sense of unused lower space by giving the mode stronger section bodies and spacing.
|
||||
5. Use warmer muted workflow accents than `paper` mode.
|
||||
|
||||
Canonical module order:
|
||||
|
||||
1. collection header
|
||||
2. workflow overview
|
||||
3. OCR pipeline
|
||||
4. collection actions
|
||||
|
||||
Measured targets:
|
||||
|
||||
- workflow stage blocks: minimum `72px` visual width
|
||||
- workflow stage labels: minimum `11px`
|
||||
- collection action buttons: minimum `36px` visual height
|
||||
- OCR module must visually read heavier than the action row beneath it
|
||||
- collection mode must preserve the canonical module order above
|
||||
|
||||
### Global Mode
|
||||
|
||||
Make `global` mode feel like the dashboard homepage.
|
||||
|
||||
Changes:
|
||||
|
||||
1. Increase snapshot card presence.
|
||||
2. Strengthen system status readability.
|
||||
3. Give the start-working area more intentional prominence.
|
||||
4. Keep issues clearly visible when present, but not alarmist.
|
||||
5. Use the calmest version of the homepage accent family so the screen feels trustworthy rather than flashy.
|
||||
|
||||
Measured targets:
|
||||
|
||||
- snapshot metric blocks: minimum `72px` visual width
|
||||
- global action buttons: minimum `36px` visual height
|
||||
- homepage modules should preserve existing inventory and order: snapshot, system status, optional issues, start working
|
||||
|
||||
---
|
||||
|
||||
## Technical Decisions
|
||||
|
||||
### 1. Single CSS authority
|
||||
|
||||
Dashboard-specific duplicated definitions in `styles.css` must be consolidated so each dashboard selector has one authoritative definition block.
|
||||
|
||||
The implementation should prefer the newer redesign architecture and remove overlapping legacy overrides instead of stacking more overrides on top.
|
||||
|
||||
### 2. Stable scroll layout
|
||||
|
||||
The root dashboard scroll container should reserve scrollbar space consistently where supported, rather than only when overflow appears.
|
||||
|
||||
Fallback rule:
|
||||
|
||||
- on desktop targets where native gutter reservation is unavailable or behaves as overlay-only, the implementation must still avoid visible width-jump during technical-details expansion through compatible fallback styling or permanent vertical overflow reservation
|
||||
|
||||
### 3. Bottom safe area
|
||||
|
||||
The main scroll container or mode content container should include explicit bottom padding sized to prevent visual collision with Obsidian's lower status area.
|
||||
|
||||
### 4. Preserve local UI state where appropriate
|
||||
|
||||
Mode refreshes should not happen when only the dashboard leaf becomes active while the resolved mode/file identity stays unchanged.
|
||||
|
||||
State preservation rules:
|
||||
|
||||
- preserve discussion expanded/collapsed state across leaf activation with unchanged identity
|
||||
- preserve technical-details disclosure expanded/collapsed state across leaf activation with unchanged identity
|
||||
- reset those states on actual mode change or file identity change
|
||||
- explicit manual refresh may reset transient state
|
||||
- file content mutation that changes the current entry data may refresh the view, but must not transiently collapse state before the refresh completes
|
||||
|
||||
### 5. Theme-aware implementation model
|
||||
|
||||
Implementation should rely on Obsidian variables for structural tokens, and use PaperForge-owned CSS custom properties for restrained accent tints.
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
### Functional
|
||||
|
||||
1. `paper` mode content is fully readable at the bottom with no visual obstruction from Obsidian chrome.
|
||||
2. Opening technical details no longer causes a first-order layout jump from scrollbar appearance.
|
||||
3. Discussion expand/collapse no longer flashes closed on first click.
|
||||
4. Existing mode routing and dashboard actions continue to work.
|
||||
5. Leaf activation alone with unchanged resolved mode/file identity does not rebuild the current mode tree.
|
||||
|
||||
### Visual
|
||||
|
||||
1. `collection` and `global` metric values are visibly larger than current live values and meet the measured targets above.
|
||||
2. `paper` remains the lowest-accent, reading-first mode.
|
||||
3. The three modes keep the same module inventory/order described in this spec while gaining differentiated emphasis.
|
||||
4. Accent color remains limited to small emphasis zones rather than full-card fills.
|
||||
5. Theme compatibility is preserved in both light and dark contexts.
|
||||
|
||||
### Accessibility / Interaction
|
||||
|
||||
1. Interactive controls retain visible keyboard focus in light and dark themes.
|
||||
2. Contextual action buttons in `global` and `collection` meet the minimum visual height targets above.
|
||||
3. Tinted pills, labels, and emphasized metrics maintain readable contrast against their backgrounds.
|
||||
4. Expanding technical details must not change content wrap width in Windows desktop verification.
|
||||
|
||||
### Code Quality
|
||||
|
||||
1. Dashboard CSS duplication is materially reduced.
|
||||
2. New styling logic is easier to reason about than the current stacked overrides.
|
||||
3. Existing plugin tests continue to pass.
|
||||
|
||||
### UX Contract Deltas Required
|
||||
|
||||
`docs/ux-contract.md` must be updated to reflect:
|
||||
|
||||
1. `paper` mode bottom-safe-area expectation so lower content is not visually obstructed.
|
||||
2. `paper` mode technical-details expansion must not introduce layout reflow from scrollbar appearance.
|
||||
3. `paper` mode discussion expand must not collapse on first click due to leaf activation.
|
||||
4. `collection` mode must preserve current module inventory and order while using larger visual hierarchy.
|
||||
5. `global` mode must preserve current module inventory and order while using larger visual hierarchy.
|
||||
6. light and dark theme verification must be mentioned in dashboard view expectations.
|
||||
|
||||
---
|
||||
|
||||
## Verification Plan
|
||||
|
||||
1. Run plugin test suite after implementation.
|
||||
2. Manually verify `paper`, `collection`, and `global` modes in Obsidian.
|
||||
3. Specifically verify:
|
||||
- paper bottom spacing
|
||||
- technical details expansion stability
|
||||
- discussion expand first-click behavior
|
||||
- perceived size and hierarchy in collection/global
|
||||
- light and dark theme legibility
|
||||
- visible keyboard focus on action controls
|
||||
4. Manual verification targets:
|
||||
- Obsidian desktop on Windows in light theme
|
||||
- Obsidian desktop on Windows in dark theme
|
||||
|
||||
---
|
||||
|
||||
## Files Expected To Change
|
||||
|
||||
- `paperforge/plugin/styles.css`
|
||||
- `paperforge/plugin/main.js`
|
||||
- `docs/ux-contract.md`
|
||||
|
||||
Possible test touch-up only if needed:
|
||||
|
||||
- `paperforge/plugin/tests/commands.test.mjs`
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
This is a refinement pass, not a dashboard rewrite.
|
||||
|
||||
The best result is a dashboard that feels more comfortable after ten minutes of use, not just prettier in a screenshot.
|
||||
431
docs/superpowers/specs/2026-05-10-skill-refine-design.md
Normal file
431
docs/superpowers/specs/2026-05-10-skill-refine-design.md
Normal file
|
|
@ -0,0 +1,431 @@
|
|||
# PaperForge Skill Refactoring: Modular Literature QA
|
||||
|
||||
**Date:** 2026-05-10
|
||||
**Branch:** `feature/skill-refine`
|
||||
**Author:** Overseer + VT-OS/OPENCODE
|
||||
|
||||
---
|
||||
|
||||
## 1. Problem Statement
|
||||
|
||||
### Current Pain Points
|
||||
|
||||
1. **No unified paper resolution.** `pf-deep` and `pf-paper` each duplicate paper-search logic in their `.md` files. When a user says "那篇关于骨再生的", agents search blindly — reading directories, grepping metadata, guessing paths.
|
||||
|
||||
2. **File organization semantically wrong.** Skill instruction files (`.md`) live in `scripts/` alongside executable code (`ld_deep.py`). No `SKILL.md` router. The `prompt_deep_subagent.md` sits orphaned at root level.
|
||||
|
||||
3. **Too many low-value skills.** `pf-sync`, `pf-ocr`, `pf-status` are thin wrappers around CLI commands (`paperforge sync/ocr/status`). They add 3 more entries to the skill list without providing agent-level value beyond what a single `bash` command does.
|
||||
|
||||
4. **No chart-reading routing clarity.** Inside `pf-deep`, the mechanism for routing to the right chart-reading guide is implicit — buried in `ld_deep.py` keyword matching. The agent skill file doesn't explain how to reference chart-reading guides.
|
||||
|
||||
### User's Core Request
|
||||
|
||||
> "用户用自然语言描述'某篇文章','某个主题','某个zoterokey',他能快速定位到这篇文章的workspace还有与其相关的各种参数,frontmatter,路径等"
|
||||
|
||||
Translation: given ANY reference to a paper (key, DOI, title fragment, author+year, or natural language description), the system must quickly resolve it to a workspace with all related parameters.
|
||||
|
||||
---
|
||||
|
||||
## 2. Design Goals
|
||||
|
||||
1. **Two-path resolution.** Python handles deterministic lookups (key, DOI, structured fields). Agent handles natural language. Python failure → Agent fallback.
|
||||
|
||||
2. **Single shared resolution module.** All sub-skills (`deep-reading`, `paper-qa`, `save-session`) consume the same `paper-resolution.md` reference + `paper_resolver.py` worker. Zero duplication.
|
||||
|
||||
3. **Compliant skill structure.** Follow the agent skills standard: `SKILL.md` as router, `references/` for detailed workflows loaded on demand, `scripts/` for executable code only.
|
||||
|
||||
4. **Token efficient.** SKILL.md under 100 lines. Sub-workflows in separate files loaded only when routed to. Python scripts executed, not loaded into context.
|
||||
|
||||
5. **Installable/distributable.** All assets under `paperforge/skills/literature-qa/` shipped with pip install. Python worker at `paperforge/worker/paper_resolver.py` also shipped.
|
||||
|
||||
---
|
||||
|
||||
## 3. Target Architecture
|
||||
|
||||
### 3.1 Directory Structure
|
||||
|
||||
```
|
||||
paperforge/skills/literature-qa/
|
||||
│
|
||||
├── SKILL.md ← Router: natural language → sub-skill routing
|
||||
│
|
||||
├── references/ ← Detailed workflows (loaded on demand)
|
||||
│ ├── deep-reading.md ← Keshav 3-pass deep reading workflow
|
||||
│ ├── paper-qa.md ← Interactive paper Q&A workflow
|
||||
│ ├── save-session.md ← Save discussion record workflow
|
||||
│ ├── paper-resolution.md ← Paper location protocol (shared by all)
|
||||
│ ├── deep-subagent.md ← Subagent prompt template for deep reading
|
||||
│ └── chart-reading/ ← 19 chart-type reading guides
|
||||
│ ├── INDEX.md
|
||||
│ ├── 条形图与误差棒.md
|
||||
│ ├── 森林图与Meta分析.md
|
||||
│ ├── 折线图与时间序列.md
|
||||
│ ├── 散点图与气泡图.md
|
||||
│ ├── ROC与PR曲线.md
|
||||
│ ├── 生存曲线.md
|
||||
│ ├── 箱式图与小提琴图.md
|
||||
│ ├── 热图与聚类图.md
|
||||
│ ├── 桑基图与弦图.md
|
||||
│ ├── 火山图与曼哈顿图.md
|
||||
│ ├── 网络图与通路图.md
|
||||
│ ├── GSEA富集图.md
|
||||
│ ├── 降维图(PCA-tSNE-UMAP).md
|
||||
│ ├── 雷达图与漏斗图.md
|
||||
│ ├── 组织学半定量图.md
|
||||
│ ├── 免疫荧光定量图.md
|
||||
│ ├── 显微照片与SEM图.md
|
||||
│ ├── Western Blot条带图.md
|
||||
│ └── 蛋白质结构图.md
|
||||
│
|
||||
└── scripts/ ← Executable code only (not loaded into context)
|
||||
└── ld_deep.py ← Deep reading engine (existing, has CLI: prepare, queue, chart-type-scan, postprocess-pass2, validate-note, etc.)
|
||||
|
||||
paperforge/worker/
|
||||
└── paper_resolver.py ← NEW: Deterministic paper lookup engine
|
||||
|
||||
tests/unit/
|
||||
└── test_paper_resolver.py ← NEW: Tests for paper resolver
|
||||
```
|
||||
|
||||
### 3.2 Files to Delete
|
||||
|
||||
| File | Reason |
|
||||
|------|--------|
|
||||
| `paperforge/skills/literature-qa/scripts/pf-sync.md` | Thin CLI wrapper; users run `paperforge sync` directly |
|
||||
| `paperforge/skills/literature-qa/scripts/pf-ocr.md` | Thin CLI wrapper; users run `paperforge ocr` directly |
|
||||
| `paperforge/skills/literature-qa/scripts/pf-status.md` | Thin CLI wrapper; users run `paperforge status` directly |
|
||||
|
||||
### 3.3 Files to Add
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `paperforge/skills/literature-qa/SKILL.md` | Main router with trigger table + paper resolution protocol |
|
||||
| `paperforge/skills/literature-qa/references/paper-resolution.md` | Shared paper location protocol |
|
||||
| `paperforge/worker/paper_resolver.py` | Deterministic lookup engine (key, DOI, structured fields) |
|
||||
| `tests/unit/test_paper_resolver.py` | Unit tests for resolver |
|
||||
|
||||
### 3.4 Files to Rewrite
|
||||
|
||||
| File (old → new) | Change |
|
||||
|---|---|
|
||||
| `scripts/pf-deep.md` → `references/deep-reading.md` | Add `disable-model-invocation: true`; remove embedded search logic; call resolver + ld_deep.py; add chart-reading routing section |
|
||||
| `scripts/pf-paper.md` → `references/paper-qa.md` | Add `disable-model-invocation: true`; remove embedded search logic; call resolver |
|
||||
| `scripts/pf-end.md` → `references/save-session.md` | Add `disable-model-invocation: true`; strip verbose explanations |
|
||||
| `prompt_deep_subagent.md` → `references/deep-subagent.md` | Minimal edits, update paths |
|
||||
|
||||
---
|
||||
|
||||
## 4. Component Design
|
||||
|
||||
### 4.1 SKILL.md — Router
|
||||
|
||||
Purpose: Single entry point. Agent reads this first. It routes to the correct reference file based on user intent.
|
||||
|
||||
```yaml
|
||||
---
|
||||
name: literature-qa
|
||||
description: 学术文献精读与问答。支持Zotero key、DOI、标题、作者/年份或自然语言定位论文。Use when user wants to deep-read, analyze, or ask questions about papers in their Zotero library. Triggered by /pf-deep, /pf-paper, /pf-end, or phrases like "精读这篇", "查一下XX文章", "读那篇关于YY的".
|
||||
---
|
||||
```
|
||||
|
||||
**Routing table (core of SKILL.md):**
|
||||
|
||||
| Trigger | User says | Load | Action |
|
||||
|---------|-----------|------|--------|
|
||||
| `/pf-deep <query>` | "精读/深度阅读/读一下 XX" | `references/deep-reading.md` | Keshav 3-pass deep reading |
|
||||
| `/pf-deep` (no args) | "精读队列/有哪些该读了" | `references/deep-reading.md` | Show deep reading queue |
|
||||
| `/pf-paper <query>` | "查/问/帮我看看 XX" | `references/paper-qa.md` | Interactive Q&A mode |
|
||||
| `/pf-end` | "保存/结束/完成讨论" | `references/save-session.md` | Persist discussion record |
|
||||
|
||||
**Paper resolution protocol (inline in SKILL.md, short version):**
|
||||
|
||||
```
|
||||
Determine input type → run exact command:
|
||||
|
||||
8-char key → python -m paperforge.worker.paper_resolver resolve-key <KEY> --vault .
|
||||
DOI → python -m paperforge.worker.paper_resolver resolve-doi <DOI> --vault .
|
||||
Author+year → python -m paperforge.worker.paper_resolver search --author "X" --year Y --vault .
|
||||
Title fragment → python -m paperforge.worker.paper_resolver search --title "X" --vault .
|
||||
Natural language → Agent reads indexes/formal-library.json, searches title/domain/year fields
|
||||
|
||||
Python returns empty → Agent fallback: grep frontmatter in formal notes directory
|
||||
Multiple matches → List candidates for user to choose
|
||||
```
|
||||
|
||||
### 4.2 paper_resolver.py — Deterministic Lookup Engine
|
||||
|
||||
A new Python module at `paperforge/worker/paper_resolver.py`.
|
||||
|
||||
**CLI interface (3 subcommands):**
|
||||
|
||||
```bash
|
||||
# Exact key lookup
|
||||
python -m paperforge.worker.paper_resolver resolve-key <KEY> --vault <PATH>
|
||||
# → {"ok": true, "key": "ABC12345", "title": "...", "formal_note": "...", "ocr_path": "...", "frontmatter": {...}}
|
||||
|
||||
# Exact DOI lookup
|
||||
python -m paperforge.worker.paper_resolver resolve-doi <DOI> --vault <PATH>
|
||||
|
||||
# Structured field search (at least one of --title, --author, --year, --domain required)
|
||||
python -m paperforge.worker.paper_resolver search --title "..." --author "..." --year 2024 --domain "骨科" --vault <PATH>
|
||||
# → {"ok": true, "matches": [{...}, ...], "count": 3}
|
||||
```
|
||||
|
||||
**--vault resolution:** Uses `paperforge.json` in vault root to locate `indexes/formal-library.json` and formal notes directory, via existing `paperforge.core.config` path utilities. Same mechanism as `paperforge sync/ocr/deep-reading`.
|
||||
|
||||
**Internal design:**
|
||||
|
||||
```python
|
||||
class PaperResolver:
|
||||
"""Deterministic paper lookup from formal-library.json index."""
|
||||
|
||||
def __init__(self, index_path: str, formal_notes_dir: str):
|
||||
# Load formal-library.json into memory
|
||||
|
||||
def resolve_key(self, key: str) -> PaperMeta | None:
|
||||
"""Exact key match. Key is 8-char alphanumeric."""
|
||||
|
||||
def resolve_doi(self, doi: str) -> PaperMeta | None:
|
||||
"""Exact DOI match from frontmatter."""
|
||||
|
||||
def search(self, title: str = None, author: str = None,
|
||||
year: int = None, domain: str = None) -> list[PaperMeta]:
|
||||
"""Multi-field search with substring matching, sorted by relevance."""
|
||||
|
||||
def get_workspace(self, key: str) -> PaperWorkspace:
|
||||
"""Given a key, return all paths and frontmatter."""
|
||||
|
||||
@dataclass
|
||||
class PaperMeta:
|
||||
key: str
|
||||
title: str
|
||||
domain: str
|
||||
year: int
|
||||
authors: str # Semi-colon separated from formal-library.json
|
||||
doi: str
|
||||
collection_path: str
|
||||
|
||||
@dataclass
|
||||
class PaperWorkspace:
|
||||
key: str
|
||||
title: str
|
||||
domain: str
|
||||
formal_note_path: str # Literature/{domain}/{key} - {title}.md
|
||||
ocr_path: str # 99_System/PaperForge/ocr/{key}/
|
||||
fulltext_path: str # 99_System/PaperForge/ocr/{key}/fulltext.md
|
||||
frontmatter: dict
|
||||
```
|
||||
|
||||
**Data source:** `formal-library.json` (already generated by `paperforge sync` in `indexes/`). This JSON contains full metadata: key, title, authors, year, doi, domain, collection_path, journal, abstract, etc.
|
||||
|
||||
**Important distinction:**
|
||||
- **Python structured search** (`resolve_key`, `resolve_doi`, `search`) operates on a subset of fields in `PaperMeta`. This handles deterministic lookups.
|
||||
- **Agent natural language search** reads the full `formal-library.json` directly (all fields available), plus can grep formal note frontmatter as fallback. This handles "那篇关于骨再生的Nature" type queries.
|
||||
|
||||
**Error handling:** Returns empty list on no match (never crashes). Agent handles the empty case via fallback.
|
||||
|
||||
### 4.3 references/deep-reading.md — Deep Reading Workflow
|
||||
|
||||
Key design decisions:
|
||||
- `disable-model-invocation: true` — never triggered automatically, only via SKILL.md routing
|
||||
- No embedded paper search logic — delegates to `paper-resolution.md`
|
||||
- Clear chart-reading routing section
|
||||
|
||||
**Chart-reading routing logic (new addition):**
|
||||
|
||||
关键原则:**Agent 负责判断图表类型,Python 只给建议,Agent 做最终决策。**
|
||||
|
||||
```markdown
|
||||
## Chart Reading Guide Routing
|
||||
|
||||
### 两步定位法
|
||||
|
||||
**Step 1: Python 给建议(快速初筛)**
|
||||
```bash
|
||||
python <skill_dir>/scripts/ld_deep.py chart-type-scan --vault . --key <KEY>
|
||||
```
|
||||
输出每个 figure 的关键词命中结果。这只是建议,Agent 不要盲信。
|
||||
|
||||
**Step 2: Agent 读 caption 做最终判断**
|
||||
对每个 figure,Agent 必须:
|
||||
1. 读该 figure 的 caption(来自 fulltext.md 或 figure-map.json)
|
||||
2. 根据 caption 内容,对照 `references/chart-reading/INDEX.md` 判断图表类型
|
||||
3. 如果 Python 建议和 Agent 判断不一致 → 以 Agent 判断为准
|
||||
4. 如果无法确定类型 → 跳过 chart guide,按通用 figure 结构分析
|
||||
|
||||
### 图表类型 → 指南文件映射
|
||||
|
||||
| 图表类型 | 指南文件 |
|
||||
|---------|---------|
|
||||
| 条形图/误差棒 | `references/chart-reading/条形图与误差棒.md` |
|
||||
| 森林图/Meta分析 | `references/chart-reading/森林图与Meta分析.md` |
|
||||
| 折线图/时间序列 | `references/chart-reading/折线图与时间序列.md` |
|
||||
| 散点图/气泡图 | `references/chart-reading/散点图与气泡图.md` |
|
||||
| ROC/PR曲线 | `references/chart-reading/ROC与PR曲线.md` |
|
||||
| 生存曲线/Kaplan-Meier | `references/chart-reading/生存曲线.md` |
|
||||
| 箱式图/小提琴图 | `references/chart-reading/箱式图与小提琴图.md` |
|
||||
| 热图/聚类图 | `references/chart-reading/热图与聚类图.md` |
|
||||
| 桑基图/弦图 | `references/chart-reading/桑基图与弦图.md` |
|
||||
| 火山图/曼哈顿图 | `references/chart-reading/火山图与曼哈顿图.md` |
|
||||
| 网络图/通路图 | `references/chart-reading/网络图与通路图.md` |
|
||||
| GSEA富集图 | `references/chart-reading/GSEA富集图.md` |
|
||||
| PCA/t-SNE/UMAP降维图 | `references/chart-reading/降维图(PCA-tSNE-UMAP).md` |
|
||||
| 雷达图/漏斗图 | `references/chart-reading/雷达图与漏斗图.md` |
|
||||
| 组织学半定量图 | `references/chart-reading/组织学半定量图.md` |
|
||||
| 免疫荧光定量图 | `references/chart-reading/免疫荧光定量图.md` |
|
||||
| 显微照片/SEM/TEM | `references/chart-reading/显微照片与SEM图.md` |
|
||||
| Western Blot条带图 | `references/chart-reading/Western Blot条带图.md` |
|
||||
| 蛋白质3D结构图 | `references/chart-reading/蛋白质结构图.md` |
|
||||
|
||||
完整索引见 `references/chart-reading/INDEX.md`。遇到边界情况优先读 INDEX.md 中的详细描述。
|
||||
```
|
||||
|
||||
**Core workflow (unchanged from current, just moved to references/):**
|
||||
1. `prepare` — check analyze status, verify OCR, generate figure-map
|
||||
2. Pass 1 Overview — one-sentence summary, 5 Cs, figure guided reading
|
||||
3. Pass 2 Reconstruction — figure-by-figure (6 sub-headings per figure), table-by-table
|
||||
4. Pass 2 Postprocess — validate figure order, sub-headings, empty blocks
|
||||
5. Pass 3 Deep Understanding — assumptions, conclusions, discussion, inspiration
|
||||
6. Final Validation — structural validation of the note
|
||||
|
||||
### 4.4 references/paper-qa.md — Interactive Q&A Workflow
|
||||
|
||||
- `disable-model-invocation: true`
|
||||
- No embedded paper search logic
|
||||
- Loads fulltext via resolved workspace
|
||||
- Q&A mode with standard answering principles (cite sources, use Chinese, flag missing info)
|
||||
- Triggers save-session.md on "save"/"done"/"end"
|
||||
|
||||
### 4.5 references/save-session.md — Save Discussion Record
|
||||
|
||||
- `disable-model-invocation: true`
|
||||
- Collects Q&A pairs from session
|
||||
- Calls existing module: `python -m paperforge.worker.discussion record <KEY> --vault . --qa-pairs '<JSON>'`
|
||||
- `paperforge/worker/discussion.py` already exists (not part of this refactor)
|
||||
- Only for paper-qa sessions (deep-reading writes directly to formal note)
|
||||
|
||||
### 4.6 references/paper-resolution.md — Shared Resolution Protocol
|
||||
|
||||
Full detailed version of the resolution protocol. Referenced by all three sub-skills.
|
||||
|
||||
```
|
||||
Contents:
|
||||
- Input type detection rules (regex patterns for key/DOI/author+year)
|
||||
- Exact commands for each type (copy-paste ready for agent)
|
||||
- Fallback procedure when Python returns empty
|
||||
- Multi-match handling (how to present candidate list)
|
||||
- Workspace structure (what paths to report after resolution)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Data Flow
|
||||
|
||||
```
|
||||
User: "/pf-deep 关于骨再生的那篇"
|
||||
│
|
||||
▼
|
||||
SKILL.md router reads trigger, routes to deep-reading.md
|
||||
│
|
||||
▼
|
||||
deep-reading.md Step 1: "Follow paper-resolution.md"
|
||||
│
|
||||
▼
|
||||
paper-resolution.md: "Natural language → Agent reads formal-library.json"
|
||||
│
|
||||
▼
|
||||
Agent reads indexes/formal-library.json, searches title/domain
|
||||
→ Found 1 match: key=ABC12345
|
||||
│
|
||||
▼
|
||||
deep-reading.md Step 2: "Run ld_deep.py prepare"
|
||||
│
|
||||
▼
|
||||
ld_deep.py prepare → checks OCR, generates figure-map, inserts scaffold
|
||||
│
|
||||
▼
|
||||
deep-reading.md Step 3: "Run chart-type-scan"
|
||||
→ Detects: bar, survival, scatter
|
||||
→ Agent reads chart-reading/条形图与误差棒.md, 生存曲线.md, 散点图与气泡图.md
|
||||
│
|
||||
▼
|
||||
Agent executes Pass 1 → Pass 2 (per figure, per chart guide) → Postprocess → Pass 3 → Validate
|
||||
│
|
||||
▼
|
||||
Result written to Literature/骨科/ABC12345 - Title.md under ## 🔍 精读
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Error Handling
|
||||
|
||||
| Scenario | Handler | Behavior |
|
||||
|----------|---------|----------|
|
||||
| Python resolver returns empty | Agent fallback | grep frontmatter in formal notes directory |
|
||||
| Both Python and Agent fail | Agent reports | "未找到匹配的论文。请确认 Zotero key、标题或搜索词是否正确。" |
|
||||
| Multiple candidates found | Agent lists | Numbered list with key, title, year, domain; user selects |
|
||||
| OCR not complete | ld_deep.py prepare | Returns error status; agent reports "OCR未完成,请先运行 paperforge ocr" |
|
||||
| Deep reading content already exists | Agent asks | "内容已存在,追加/覆盖/跳过?" |
|
||||
| Chart type unknown | Agent skips | Proceeds with figure analysis without chart guide |
|
||||
|
||||
---
|
||||
|
||||
## 7. Token Budget Analysis
|
||||
|
||||
| Component | Current Tokens (approx) | After Refactor | Savings |
|
||||
|-----------|------------------------|----------------|---------|
|
||||
| `pf-deep.md` (237 lines) | ~3000 | ~300 (references/deep-reading.md) | -2700 |
|
||||
| `pf-paper.md` (107 lines) | ~1400 | ~250 (references/paper-qa.md) | -1150 |
|
||||
| `pf-end.md` (67 lines) | ~900 | ~150 (references/save-session.md) | -750 |
|
||||
| `pf-sync.md` (85 lines) | ~1100 | 0 (deleted) | -1100 |
|
||||
| `pf-ocr.md` (102 lines) | ~1300 | 0 (deleted) | -1300 |
|
||||
| `pf-status.md` (94 lines) | ~1200 | 0 (deleted) | -1200 |
|
||||
| `SKILL.md` (new) | 0 | ~500 | +500 |
|
||||
| `paper-resolution.md` (new) | 0 | ~300 | +300 |
|
||||
| **Total in context upon trigger** | **~6900 (all loaded at once)** | **~800 (SKILL.md + one reference)** | **-6100** |
|
||||
|
||||
Key insight: After refactoring, the agent loads only SKILL.md (~500 tokens) and the one reference file for the triggered sub-skill (~150-300 tokens). Previously all 6 skill files were discoverable, and the agent might load multiple. The paper_resolver.py scripts execute via bash, consuming zero context tokens for their content.
|
||||
|
||||
---
|
||||
|
||||
## 8. Implementation Plan
|
||||
|
||||
### Phase 1: Add new modules
|
||||
1. Create `paperforge/worker/paper_resolver.py` with 3 CLI subcommands
|
||||
2. Create `tests/unit/test_paper_resolver.py`
|
||||
|
||||
### Phase 2: Rewrite skill structure
|
||||
3. Create `paperforge/skills/literature-qa/SKILL.md` (router)
|
||||
4. Create `references/paper-resolution.md` (shared protocol)
|
||||
5. Rewrite `scripts/pf-deep.md` → `references/deep-reading.md`
|
||||
6. Rewrite `scripts/pf-paper.md` → `references/paper-qa.md`
|
||||
7. Rewrite `scripts/pf-end.md` → `references/save-session.md`
|
||||
8. Move `prompt_deep_subagent.md` → `references/deep-subagent.md`
|
||||
9. Move `chart-reading/` into `references/chart-reading/`
|
||||
|
||||
### Phase 3: Cleanup
|
||||
10. Delete `scripts/pf-sync.md`
|
||||
11. Delete `scripts/pf-ocr.md`
|
||||
12. Delete `scripts/pf-status.md`
|
||||
13. Delete old copies of rewritten files: `scripts/pf-deep.md`, `scripts/pf-paper.md`, `scripts/pf-end.md`, `prompt_deep_subagent.md` (all moved to `references/`)
|
||||
|
||||
### Phase 4: Verify
|
||||
13. Run unit tests: `python -m pytest tests/unit/test_paper_resolver.py -q`
|
||||
14. Verify skill loads correctly in OpenCode
|
||||
15. Manual test: `/pf-deep ABC12345` resolves correctly
|
||||
16. Manual test: `/pf-deep 关于骨再生的那篇` routes to agent search → resolves
|
||||
|
||||
---
|
||||
|
||||
## 9. Risks and Mitigations
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|-----------|
|
||||
| `formal-library.json` index stale after sync | Document: "If paper not found, run `paperforge sync` first" |
|
||||
| Agent natural language search misses the paper | Two-layer fallback: formal-library.json → grep formal notes |
|
||||
| Skill routing confusion (SKILL.md + 3 sub-skills all visible) | Sub-skills use `disable-model-invocation: true`; only SKILL.md is auto-discoverable |
|
||||
| Chart-reading path references break after move | All paths relative to skill directory; test with `python -m paperforge.worker.paper_resolver` |
|
||||
|
||||
---
|
||||
|
||||
*Vault-Tec -- Preparing for the Future! Spec compiled by Terminal VT-OS/OPENCODE, serial VTC-2077-OC-4111.*
|
||||
|
|
@ -46,9 +46,12 @@
|
|||
|
||||
| Step ID | Trigger | Action | Measurable Outcome | Error Contract |
|
||||
|---------|---------|--------|-------------------|----------------|
|
||||
| W4-S1 | User opens Obsidian vault | Obsidian loads vault | Plugin view is registered; user navigates to `Bases/` directory and sees `.base` files rendered as interactive tables with filterable columns | Plugin not loaded; stderr in dev console shows "Failed to register PaperForge view" |
|
||||
| W4-S2 | Base view renders with workflow-gate columns | Lifecycle columns visible | Base view displays columns: `has_pdf`, `do_ocr`, `analyze`, `ocr_status`, `deep_reading_status`. Each paper appears as one row with status values. Filter UI present for `do_ocr`, `analyze`, `ocr_status` | Missing columns; all values appear as empty or "N/A" |
|
||||
| W4-S3 | Per-paper dashboard shows lifecycle stepper | Detailed view renders | Clicking a paper row reveals: lifecycle stepper (PENDING -> PROCESSING -> DONE), health matrix (pdf, ocr, metadata status), next-step recommendation badge; all components render without console errors | Lifecycle stepper missing; health matrix shows all red; recommendation badge absent |
|
||||
| W4-S1 | User opens Obsidian vault | Plugin loads and registers dashboard view | Plugin view registered; sidebar icon visible; clicking opens PaperForge dashboard panel | Plugin not loaded; console shows "Failed to register PaperForge view" |
|
||||
| W4-S2 | User opens a `.base` file | Collection mode activates | Dashboard switches to `collection` mode; preserves module order: header, workflow funnel (Total → PDF Ready → OCR Done → Deep Read), OCR pipeline scoped to current base, collection-scoped issue/health messaging only inside the collection view, then action buttons (Run OCR primary, Sync Library secondary) | Falls to global mode; missing stats; collection issues appear as a separate extra module outside the collection view |
|
||||
| W4-S3 | User opens a formal note (`.md` with `zotero_key`) | Paper mode activates | Dashboard switches to `paper` mode; preserves module order: paper header, status/file row, paper overview card, next-step or complete row, discussion card, collapsible technical details; panel keeps bottom-safe-area padding so final content is fully visible; expanding technical details does not trigger scrollbar reflow/width jump; first discussion expand does not collapse on the first click | Falls to global mode; components empty or erroring; bottom content is clipped; expanding discussion/technical details causes collapse or layout jump |
|
||||
| W4-S4 | User opens `fulltext.md` or other workspace file | Paper mode activates via workspace detection | Dashboard stays in `paper` mode for any file inside a paper workspace directory (`{key} - {title}/`). zotero_key resolved from dirname pattern | Falls to global mode |
|
||||
| W4-S5 | User opens no file or non-workspace `.md` | Global mode activates | Dashboard shows, in preserved order: library snapshot (papers / PDFs / OCR done / deep-read done), system status grid (runtime / index / Zotero export / OCR token), optional issues panel (only when anomalies exist with Run Doctor / Repair Issues), then action buttons (Open Literature Hub primary / Sync Library secondary); light and dark themes both keep muted accents, readable contrast, and visible keyboard focus on collection/global action controls | Shows loading/empty state; console errors; module order changes unexpectedly; light/dark theme contrast or focus visibility regresses |
|
||||
| W4-S6 | User toggles `do_ocr` or `analyze` checkbox | Workflow toggle writes to frontmatter | Checkbox state writes to formal note YAML frontmatter via `processFrontMatter`; Base view reflects change immediately (reads from file); dashboard auto-refreshes via `modify` event | Frontmatter not written; notice shows "Note file not found" |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -226,19 +226,35 @@ def generate_review(candidates: list[dict]) -> str:
|
|||
def extract_preserved_deep_reading(text: str) -> str:
|
||||
if not text:
|
||||
return ""
|
||||
match = re.search("^## 🔍 精读\\s*$", text, re.MULTILINE)
|
||||
# Match "## 🔍 精读" or "## 精读" (emoji is optional)
|
||||
match = re.search(r"^##\s*🔍?\s*精读\s*$", text, re.MULTILINE)
|
||||
if not match:
|
||||
return ""
|
||||
start = match.start()
|
||||
preserved = text[start:].strip()
|
||||
# Skip if section body is empty or only contains placeholder text
|
||||
body = re.sub(r"^##\s*🔍?\s*精读\s*$", "", preserved, flags=re.MULTILINE).strip()
|
||||
if not body or _is_placeholder_only(body):
|
||||
return ""
|
||||
return preserved
|
||||
|
||||
|
||||
_PLACEHOLDER_PATTERN = re.compile(r"(待补充)|\(待补充\)|\(TODO\)|\(TBD\)")
|
||||
|
||||
|
||||
def _is_placeholder_only(body: str) -> bool:
|
||||
"""Check if the deep reading body is only placeholder text (no real content)."""
|
||||
cleaned = _PLACEHOLDER_PATTERN.sub("", body).strip()
|
||||
cleaned = re.sub(r"^[-*]\s*$", "", cleaned, flags=re.MULTILINE).strip()
|
||||
return not cleaned
|
||||
|
||||
|
||||
def has_deep_reading_content(text: str) -> bool:
|
||||
preserved = extract_preserved_deep_reading(text)
|
||||
if not preserved:
|
||||
return False
|
||||
body = preserved.replace(DEEP_READING_HEADER, "").strip()
|
||||
# Strip both "## 🔍 精读" and "## 精读" header variants
|
||||
body = re.sub(r"^##\s*🔍?\s*精读\s*$", "", preserved, flags=re.MULTILINE).strip()
|
||||
if not body:
|
||||
return False
|
||||
|
||||
|
|
|
|||
|
|
@ -197,6 +197,11 @@ def build_parser() -> argparse.ArgumentParser:
|
|||
p_dr = sub.add_parser("deep-reading", help="Check deep-reading queue status")
|
||||
p_dr.add_argument("--json", action="store_true", help="Output as PFResult JSON")
|
||||
|
||||
# deep-finalize
|
||||
p_df = sub.add_parser("deep-finalize", help="Mark deep reading done and notify dashboard")
|
||||
p_df.add_argument("zotero_key", help="Zotero citation key")
|
||||
p_df.add_argument("--json", action="store_true", help="Output as PFResult JSON")
|
||||
|
||||
# repair
|
||||
p_repair = sub.add_parser("repair", help="Repair divergent literature notes")
|
||||
p_repair.add_argument("--fix", action="store_true", help="Actually apply repairs instead of dry-run")
|
||||
|
|
@ -445,6 +450,11 @@ def main(argv: list[str] | None = None) -> int:
|
|||
|
||||
return deep.run(args)
|
||||
|
||||
if args.command == "deep-finalize":
|
||||
from paperforge.commands import finalize
|
||||
|
||||
return finalize.run(args)
|
||||
|
||||
if args.command == "context":
|
||||
from paperforge.commands import context
|
||||
|
||||
|
|
|
|||
|
|
@ -83,6 +83,21 @@ Agent 在正式笔记中创建或更新 `## 精读` 区域,包含:
|
|||
- **Pass 2: 精读还原** — Figure-by-Figure 解析、Table-by-Table 解析、关键方法补课、主要发现与新意
|
||||
- **Pass 3: 深度理解** — 假设挑战与隐藏缺陷、结论扎实性评估、Discussion 解读、个人启发、遗留问题
|
||||
|
||||
## Post-Processing (必须执行)
|
||||
|
||||
精读内容全部写完后,Agent **必须**调用以下命令完成收尾:
|
||||
|
||||
```bash
|
||||
paperforge deep-finalize <zotero_key>
|
||||
```
|
||||
|
||||
该命令会:
|
||||
1. 将正式笔记 frontmatter 中的 `deep_reading_status` 设为 `done`
|
||||
2. 刷新 canonical index(写入 `formal-library.json`)
|
||||
3. Dashboard 检测到 index 变更后自动刷新 `文章概览` 卡片
|
||||
|
||||
> `deep_reading_status` 只在精读全部完成(内容填写 + 验证通过)后才标记为 `done`。不要在准备阶段或中途设置此状态。
|
||||
|
||||
## Error Handling
|
||||
|
||||
### OCR 未完成
|
||||
|
|
@ -148,6 +163,7 @@ Task(
|
|||
2. 用 Bash tool 预跑 `python {{SCRIPT}} figure-index {{FULLTEXT_MD}}` 确认 OCR 存在
|
||||
3. 四个 Task 并行启动,每篇独立
|
||||
4. 等待所有 Task 完成,收集各篇的写入行数和验证结果
|
||||
5. 对每篇已完成精读的论文,调用 `paperforge deep-finalize <zotero_key>` 标记完成并触发 Dashboard 刷新
|
||||
|
||||
**预检(必须)**:
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ _COMMAND_REGISTRY: dict[str, str] = {
|
|||
"status": "paperforge.commands.status",
|
||||
"context": "paperforge.commands.context",
|
||||
"dashboard": "paperforge.commands.dashboard",
|
||||
"finalize": "paperforge.commands.finalize",
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
95
paperforge/commands/finalize.py
Normal file
95
paperforge/commands/finalize.py
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
"""deep-finalize — mark deep reading as done and signal dashboard to refresh."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from paperforge import __version__
|
||||
from paperforge.core.result import PFResult
|
||||
|
||||
|
||||
def _update_frontmatter(text: str, key: str, value: str) -> str:
|
||||
"""Replace or insert a frontmatter field value."""
|
||||
pattern = re.compile(rf"^({re.escape(key)}:\s*)(.*?)$", re.MULTILINE)
|
||||
if pattern.search(text):
|
||||
return pattern.sub(rf"\g<1>{value}", text)
|
||||
# Insert after the first '---' separator
|
||||
fm_end = text.find("---\n", 3)
|
||||
if fm_end != -1:
|
||||
return text[:fm_end] + f"{key}: {value}\n" + text[fm_end:]
|
||||
return text
|
||||
|
||||
|
||||
def run(args: argparse.Namespace) -> int:
|
||||
"""Mark deep reading as done and refresh the canonical index.
|
||||
|
||||
Called by the AI agent at the end of /pf-deep to signal completion.
|
||||
The dashboard watches formal-library.json and will refresh on change.
|
||||
"""
|
||||
vault: Path = getattr(args, "vault_path", None)
|
||||
if vault is None:
|
||||
from paperforge.config import resolve_vault
|
||||
|
||||
vault = resolve_vault(cli_vault=getattr(args, "vault", None))
|
||||
|
||||
zotero_key: str | None = getattr(args, "zotero_key", None)
|
||||
if not zotero_key:
|
||||
print("[ERROR] zotero_key is required", file=sys.stderr)
|
||||
if getattr(args, "json", False):
|
||||
pf = PFResult(ok=False, command="deep-finalize", version=__version__, error="zotero_key is required")
|
||||
print(pf.to_json())
|
||||
return 1
|
||||
|
||||
# 1. Find and update the formal note frontmatter
|
||||
from paperforge.worker._utils import pipeline_paths
|
||||
|
||||
paths = pipeline_paths(vault)
|
||||
lit_root = paths["literature"]
|
||||
note_updated = False
|
||||
|
||||
if lit_root.exists():
|
||||
for note_file in lit_root.rglob("*.md"):
|
||||
if note_file.name in ("fulltext.md", "deep-reading.md", "discussion.md"):
|
||||
continue
|
||||
try:
|
||||
text = note_file.read_text(encoding="utf-8")
|
||||
except Exception:
|
||||
continue
|
||||
if re.search(rf'^\s*zotero_key:\s*"{re.escape(zotero_key)}"', text, re.MULTILINE):
|
||||
new_text = _update_frontmatter(text, "deep_reading_status", "done")
|
||||
note_file.write_text(new_text, encoding="utf-8")
|
||||
note_updated = True
|
||||
break
|
||||
|
||||
# 2. Refresh the canonical index entry (writes formal-library.json)
|
||||
from paperforge.worker.asset_index import refresh_index_entry
|
||||
|
||||
index_ok = refresh_index_entry(vault, zotero_key)
|
||||
|
||||
msg_parts = []
|
||||
if note_updated:
|
||||
msg_parts.append("frontmatter updated")
|
||||
else:
|
||||
msg_parts.append("frontmatter not found (index-only refresh)")
|
||||
msg_parts.append("index refreshed" if index_ok else "full index rebuild triggered")
|
||||
|
||||
message = f"[OK] deep-finalize {zotero_key}: " + ", ".join(msg_parts)
|
||||
print(message)
|
||||
|
||||
if getattr(args, "json", False):
|
||||
pf = PFResult(
|
||||
ok=True,
|
||||
command="deep-finalize",
|
||||
version=__version__,
|
||||
data={
|
||||
"zotero_key": zotero_key,
|
||||
"note_updated": note_updated,
|
||||
"index_refreshed": index_ok,
|
||||
},
|
||||
)
|
||||
print(pf.to_json())
|
||||
|
||||
return 0
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -169,22 +169,6 @@ const ACTIONS = [
|
|||
args: ["--fix", "--fix-paths"],
|
||||
okMsg: "Repair complete",
|
||||
},
|
||||
{
|
||||
id: "paperforge-copy-context",
|
||||
title: "Copy Context",
|
||||
desc: "Copy this paper\u2019s canonical index entry JSON to clipboard for AI use",
|
||||
icon: "\u2139",
|
||||
cmd: "context",
|
||||
okMsg: "Context copied",
|
||||
},
|
||||
{
|
||||
id: "paperforge-copy-collection-context",
|
||||
title: "Copy Collection Context",
|
||||
desc: "Copy canonical index entries for all visible papers to clipboard",
|
||||
icon: "\u2261",
|
||||
cmd: "context",
|
||||
okMsg: "Collection context copied",
|
||||
},
|
||||
];
|
||||
|
||||
function buildCommandArgs(action, key, filter) {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -9,8 +9,8 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|||
const { ACTIONS, buildCommandArgs, runSubprocess } = await import('../src/testable.js');
|
||||
|
||||
describe('ACTIONS', () => {
|
||||
it('has exactly 6 entries', () => {
|
||||
expect(ACTIONS).toHaveLength(6);
|
||||
it('has exactly 4 entries', () => {
|
||||
expect(ACTIONS).toHaveLength(4);
|
||||
});
|
||||
it('every entry has id, title, cmd, okMsg', () => {
|
||||
for (const a of ACTIONS) {
|
||||
|
|
@ -26,12 +26,6 @@ describe('ACTIONS', () => {
|
|||
it('repair action is enabled (no disabled flag)', () => {
|
||||
expect(ACTIONS.find(a => a.id === 'paperforge-repair')?.disabled).toBeUndefined();
|
||||
});
|
||||
it('copy-context action has no needsKey (pure JS)', () => {
|
||||
expect(ACTIONS.find(a => a.id === 'paperforge-copy-context')?.needsKey).toBeUndefined();
|
||||
});
|
||||
it('copy-collection-context action has no needsFilter (pure JS)', () => {
|
||||
expect(ACTIONS.find(a => a.id === 'paperforge-copy-collection-context')?.needsFilter).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildCommandArgs', () => {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"""Skill deployment service — single source of truth for agent skill installation and updates.
|
||||
"""Skill deployment service — single copytree for all platforms.
|
||||
|
||||
Used by both setup wizard (install) and update worker (update).
|
||||
All deployments are vault-local only.
|
||||
|
|
@ -9,253 +9,82 @@ from __future__ import annotations
|
|||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
# ── Agent Platform Configurations ──
|
||||
# Canonical source of truth. setup_wizard.py imports from here.
|
||||
|
||||
AGENT_CONFIGS = {
|
||||
"opencode": {
|
||||
"name": "OpenCode",
|
||||
"skill_dir": ".opencode/skills",
|
||||
"command_dir": ".opencode/command",
|
||||
"format": "flat_command",
|
||||
"prefix": "/",
|
||||
"config_file": None,
|
||||
},
|
||||
"claude": {
|
||||
"name": "Claude Code",
|
||||
"skill_dir": ".claude/skills",
|
||||
"format": "skill_directory",
|
||||
"prefix": "/",
|
||||
"config_file": ".claude/skills.json",
|
||||
},
|
||||
"codex": {
|
||||
"name": "Codex",
|
||||
"skill_dir": ".codex/skills",
|
||||
"format": "skill_directory",
|
||||
"prefix": "$",
|
||||
"config_file": None,
|
||||
},
|
||||
"cursor": {
|
||||
"name": "Cursor",
|
||||
"skill_dir": ".cursor/skills",
|
||||
"format": "skill_directory",
|
||||
"prefix": "/",
|
||||
"config_file": ".cursor/settings.json",
|
||||
},
|
||||
"windsurf": {
|
||||
"name": "Windsurf",
|
||||
"skill_dir": ".windsurf/skills",
|
||||
"format": "skill_directory",
|
||||
"prefix": "/",
|
||||
"config_file": None,
|
||||
},
|
||||
"github_copilot": {
|
||||
"name": "GitHub Copilot",
|
||||
"skill_dir": ".github/skills",
|
||||
"format": "skill_directory",
|
||||
"prefix": "/",
|
||||
"config_file": ".github/copilot-instructions.md",
|
||||
},
|
||||
"cline": {
|
||||
"name": "Cline",
|
||||
"skill_dir": ".clinerules",
|
||||
"format": "rules_file",
|
||||
"prefix": "/",
|
||||
"config_file": ".clinerules",
|
||||
},
|
||||
"augment": {
|
||||
"name": "Augment",
|
||||
"skill_dir": ".augment/skills",
|
||||
"format": "skill_directory",
|
||||
"prefix": "/",
|
||||
"config_file": None,
|
||||
},
|
||||
"trae": {
|
||||
"name": "Trae",
|
||||
"skill_dir": ".trae/skills",
|
||||
"format": "skill_directory",
|
||||
"prefix": "/",
|
||||
"config_file": None,
|
||||
},
|
||||
# ── Agent platform → vault-local skill directory ──
|
||||
AGENT_SKILL_DIRS: dict[str, str] = {
|
||||
"opencode": ".opencode/skills",
|
||||
"claude": ".claude/skills",
|
||||
"codex": ".codex/skills",
|
||||
"cursor": ".cursor/skills",
|
||||
"windsurf": ".windsurf/skills",
|
||||
"github_copilot": ".github/skills",
|
||||
"cline": ".clinerules",
|
||||
"augment": ".augment/skills",
|
||||
"trae": ".trae/skills",
|
||||
}
|
||||
|
||||
|
||||
def _resolve_source_root() -> Path:
|
||||
"""Resolve the paperforge package root (where skills/ lives)."""
|
||||
import paperforge
|
||||
|
||||
return Path(paperforge.__file__).parent
|
||||
|
||||
|
||||
def _substitute_vars(text: str, system_dir: str, resources_dir: str, literature_dir: str, base_dir: str, skill_dir: str, prefix: str = "/") -> str:
|
||||
for old, new in [
|
||||
("<system_dir>", system_dir),
|
||||
("<resources_dir>", resources_dir),
|
||||
("<literature_dir>", literature_dir),
|
||||
("<base_dir>", base_dir),
|
||||
("<skill_dir>", skill_dir),
|
||||
("<prefix>", prefix),
|
||||
]:
|
||||
text = text.replace(old, new)
|
||||
return text
|
||||
|
||||
|
||||
# ── Deploy helpers ──
|
||||
|
||||
def _deploy_skill_directory(vault: Path, skill_dir: str, source_root: Path, system_dir: str, resources_dir: str, literature_dir: str, base_dir: str, prefix: str = "/", overwrite: bool = False) -> list[str]:
|
||||
"""Deploy pf-* skills as independent SKILL.md directories (Claude Code, Codex, Cursor, etc.)."""
|
||||
imported = []
|
||||
src_scripts = source_root / "skills" / "literature-qa" / "scripts"
|
||||
src_charts = source_root / "skills" / "literature-qa" / "chart-reading"
|
||||
src_prompt = source_root / "skills" / "literature-qa" / "prompt_deep_subagent.md"
|
||||
|
||||
for skill_file in sorted(src_scripts.glob("pf-*.md")):
|
||||
skill_name = skill_file.stem
|
||||
skill_dst = vault / skill_dir / skill_name
|
||||
skill_dst.mkdir(parents=True, exist_ok=True)
|
||||
text = skill_file.read_text(encoding="utf-8")
|
||||
text = _substitute_vars(text, system_dir, resources_dir, literature_dir, base_dir, skill_dir, prefix)
|
||||
dst_file = skill_dst / "SKILL.md"
|
||||
if overwrite or not dst_file.exists():
|
||||
dst_file.write_text(text, encoding="utf-8")
|
||||
imported.append(skill_name)
|
||||
|
||||
# pf-deep extras: scripts, chart-reading, subagent prompt
|
||||
pf_deep_dst = vault / skill_dir / "pf-deep"
|
||||
pf_deep_dst.mkdir(parents=True, exist_ok=True)
|
||||
ld_dst = pf_deep_dst / "scripts" / "ld_deep.py"
|
||||
ld_src = src_scripts / "ld_deep.py"
|
||||
if ld_src.exists() and (overwrite or not ld_dst.exists()):
|
||||
ld_dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(ld_src, ld_dst)
|
||||
prompt_dst = pf_deep_dst / "prompt_deep_subagent.md"
|
||||
if src_prompt.exists() and (overwrite or not prompt_dst.exists()):
|
||||
shutil.copy2(src_prompt, prompt_dst)
|
||||
if src_charts.exists() and src_charts.is_dir():
|
||||
chart_dst = pf_deep_dst / "chart-reading"
|
||||
if overwrite and chart_dst.exists():
|
||||
shutil.rmtree(chart_dst)
|
||||
chart_dst.mkdir(parents=True, exist_ok=True)
|
||||
for f in src_charts.glob("*.md"):
|
||||
if overwrite or not (chart_dst / f.name).exists():
|
||||
shutil.copy2(f, chart_dst / f.name)
|
||||
|
||||
return imported
|
||||
|
||||
|
||||
def _deploy_flat_command(vault: Path, command_dir: str, source_root: Path, system_dir: str, resources_dir: str, literature_dir: str, base_dir: str, skill_dir: str, overwrite: bool = False) -> list[str]:
|
||||
"""Deploy skills in flat .md command format (OpenCode)."""
|
||||
imported = []
|
||||
command_src = source_root / "skills" / "literature-qa" / "scripts"
|
||||
command_dst = vault / command_dir
|
||||
if not (command_src.exists() and command_src.is_dir()):
|
||||
return imported
|
||||
|
||||
command_dst.mkdir(parents=True, exist_ok=True)
|
||||
for f in command_src.glob("pf-*.md"):
|
||||
text = f.read_text(encoding="utf-8")
|
||||
text = _substitute_vars(text, system_dir, resources_dir, literature_dir, base_dir, skill_dir)
|
||||
dst_file = command_dst / f.name
|
||||
if overwrite or not dst_file.exists():
|
||||
dst_file.write_text(text, encoding="utf-8")
|
||||
imported.append(f.stem)
|
||||
|
||||
return imported
|
||||
|
||||
|
||||
def _deploy_rules_file(vault: Path, skill_dir: str, source_root: Path, system_dir: str, resources_dir: str, literature_dir: str, base_dir: str, overwrite: bool = False) -> list[str]:
|
||||
"""Deploy skills as .clinerules directory (Cline)."""
|
||||
imported = []
|
||||
src_scripts = source_root / "skills" / "literature-qa" / "scripts"
|
||||
src_charts = source_root / "skills" / "literature-qa" / "chart-reading"
|
||||
src_prompt = source_root / "skills" / "literature-qa" / "prompt_deep_subagent.md"
|
||||
|
||||
pf_deep_dst = vault / skill_dir / "pf-deep"
|
||||
pf_deep_dst.mkdir(parents=True, exist_ok=True)
|
||||
ld_src = src_scripts / "ld_deep.py"
|
||||
ld_dst = pf_deep_dst / "scripts" / "ld_deep.py"
|
||||
if ld_src.exists() and (overwrite or not ld_dst.exists()):
|
||||
ld_dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(ld_src, ld_dst)
|
||||
prompt_dst = pf_deep_dst / "prompt_deep_subagent.md"
|
||||
if src_prompt.exists() and (overwrite or not prompt_dst.exists()):
|
||||
shutil.copy2(src_prompt, prompt_dst)
|
||||
if src_charts.exists() and src_charts.is_dir():
|
||||
chart_dst = pf_deep_dst / "chart-reading"
|
||||
if overwrite and chart_dst.exists():
|
||||
shutil.rmtree(chart_dst)
|
||||
chart_dst.mkdir(parents=True, exist_ok=True)
|
||||
for f in src_charts.glob("*.md"):
|
||||
if overwrite or not (chart_dst / f.name).exists():
|
||||
shutil.copy2(f, chart_dst / f.name)
|
||||
|
||||
imported.append("clinerules")
|
||||
return imported
|
||||
|
||||
|
||||
# ── Main entry point ──
|
||||
|
||||
def deploy_skills(
|
||||
vault: Path,
|
||||
agent_key: str = "opencode",
|
||||
system_dir: str = "System",
|
||||
resources_dir: str = "Resources",
|
||||
literature_dir: str = "Literature",
|
||||
base_dir: str = "Bases",
|
||||
overwrite: bool = False,
|
||||
) -> dict:
|
||||
"""Deploy skills, commands, and AGENTS.md for a given agent platform.
|
||||
"""Deploy literature-qa skill and AGENTS.md to the vault.
|
||||
|
||||
Args:
|
||||
vault: Obsidian vault root
|
||||
agent_key: Agent platform key (opencode, cursor, claude, etc.)
|
||||
overwrite: True for update (overwrite existing), False for install (skip)
|
||||
vault: Obsidian vault root.
|
||||
agent_key: Agent platform key (opencode, claude, etc.).
|
||||
overwrite: If True, overwrite existing files (used by update).
|
||||
|
||||
Returns:
|
||||
dict with 'skills', 'commands', 'agents_md', 'errors' keys
|
||||
dict with 'skill_deployed', 'agents_md', 'errors' keys.
|
||||
"""
|
||||
agent_config = AGENT_CONFIGS.get(agent_key)
|
||||
if not agent_config:
|
||||
return {"skills": [], "commands": [], "agents_md": False, "errors": [f"Unknown agent: {agent_key}"]}
|
||||
|
||||
source_root = _resolve_source_root()
|
||||
if not (source_root / "skills" / "literature-qa").exists():
|
||||
return {"skills": [], "commands": [], "agents_md": False, "errors": ["Skills source not found in package"]}
|
||||
|
||||
skill_dir = agent_config.get("skill_dir", ".opencode/skills")
|
||||
fmt = agent_config.get("format", "skill_directory")
|
||||
prefix = agent_config.get("prefix", "/")
|
||||
imported_skills: list[str] = []
|
||||
errors: list[str] = []
|
||||
|
||||
# Deploy skills by format
|
||||
try:
|
||||
if fmt == "flat_command":
|
||||
imported_skills = _deploy_flat_command(vault, agent_config["command_dir"], source_root, system_dir, resources_dir, literature_dir, base_dir, skill_dir, overwrite)
|
||||
imported_skills += _deploy_skill_directory(vault, skill_dir, source_root, system_dir, resources_dir, literature_dir, base_dir, prefix, overwrite)
|
||||
elif fmt == "rules_file":
|
||||
imported_skills = _deploy_rules_file(vault, agent_config["skill_dir"], source_root, system_dir, resources_dir, literature_dir, base_dir, overwrite)
|
||||
else:
|
||||
imported_skills = _deploy_skill_directory(vault, skill_dir, source_root, system_dir, resources_dir, literature_dir, base_dir, prefix, overwrite)
|
||||
except Exception as e:
|
||||
errors.append(f"Skill deploy failed: {e}")
|
||||
# ── Deploy literature-qa skill ──
|
||||
skill_deployed = False
|
||||
source_root = _resolve_source_root()
|
||||
src_skill = source_root / "skills" / "literature-qa"
|
||||
|
||||
# Deploy AGENTS.md
|
||||
if src_skill.exists():
|
||||
skill_dir_name = AGENT_SKILL_DIRS.get(agent_key)
|
||||
if skill_dir_name:
|
||||
dst_skill = vault / skill_dir_name / "literature-qa"
|
||||
try:
|
||||
if overwrite and dst_skill.exists():
|
||||
shutil.rmtree(dst_skill, ignore_errors=True)
|
||||
dst_skill.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copytree(src_skill, dst_skill, dirs_exist_ok=True)
|
||||
skill_deployed = True
|
||||
except Exception as e:
|
||||
errors.append(f"Skill deploy failed: {e}")
|
||||
else:
|
||||
errors.append(f"Unknown agent: {agent_key}")
|
||||
else:
|
||||
errors.append("Skills source not found in package")
|
||||
|
||||
# ── Deploy AGENTS.md ──
|
||||
agents_ok = False
|
||||
agents_src = source_root.parent / "AGENTS.md"
|
||||
if agents_src.exists():
|
||||
try:
|
||||
agents_dst = vault / "AGENTS.md"
|
||||
text = agents_src.read_text(encoding="utf-8")
|
||||
text = _substitute_vars(text, system_dir, resources_dir, literature_dir, base_dir, skill_dir, prefix)
|
||||
if overwrite and agents_dst.exists():
|
||||
agents_dst.unlink()
|
||||
if overwrite or not agents_dst.exists():
|
||||
agents_dst.write_text(text, encoding="utf-8")
|
||||
shutil.copy2(agents_src, agents_dst)
|
||||
agents_ok = True
|
||||
except Exception as e:
|
||||
errors.append(f"AGENTS.md deploy failed: {e}")
|
||||
|
||||
return {
|
||||
"skills": imported_skills,
|
||||
"commands": imported_skills,
|
||||
"skill_deployed": skill_deployed,
|
||||
"agents_md": agents_ok,
|
||||
"errors": errors,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,16 @@
|
|||
"""AgentInstaller — deploys skill files and agent configs."""
|
||||
"""AgentInstaller — deploys literature-qa skill to vault-local agent config."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from paperforge.services.skill_deploy import AGENT_SKILL_DIRS
|
||||
from paperforge.setup import SetupStepResult
|
||||
|
||||
|
||||
class AgentInstaller:
|
||||
"""Deploy agent skill files, command files, and rules."""
|
||||
"""Deploy literature-qa skill directory to vault-local agent skills path."""
|
||||
|
||||
def __init__(self, vault: Path, agent_type: str = "opencode"):
|
||||
self.vault = vault
|
||||
|
|
@ -17,19 +18,12 @@ class AgentInstaller:
|
|||
self._script_dir = Path(__file__).resolve().parent.parent
|
||||
|
||||
def _get_skills_dir(self) -> Path:
|
||||
"""Get the target skills directory based on agent type."""
|
||||
if self.agent_type == "opencode":
|
||||
base = Path.home() / ".config" / "opencode"
|
||||
elif self.agent_type == "claude":
|
||||
base = Path.home() / ".claude"
|
||||
elif self.agent_type == "codex":
|
||||
base = Path.home() / ".codex"
|
||||
else:
|
||||
base = self.vault / ".agents"
|
||||
return base / "skills"
|
||||
"""Get the vault-local target skills directory."""
|
||||
skill_dir_name = AGENT_SKILL_DIRS.get(self.agent_type, ".agents/skills")
|
||||
return self.vault / skill_dir_name
|
||||
|
||||
def deploy_skills(self) -> SetupStepResult:
|
||||
"""Deploy literature-qa skill directory to agent config."""
|
||||
"""Deploy literature-qa skill as a single directory."""
|
||||
source_skills = self._script_dir / "skills" / "literature-qa"
|
||||
if not source_skills.exists():
|
||||
return SetupStepResult(
|
||||
|
|
@ -43,11 +37,7 @@ class AgentInstaller:
|
|||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
if source_skills.is_dir():
|
||||
shutil.copytree(source_skills, target_dir, dirs_exist_ok=True)
|
||||
else:
|
||||
shutil.copy2(source_skills, target_dir)
|
||||
|
||||
shutil.copytree(source_skills, target_dir, dirs_exist_ok=True)
|
||||
return SetupStepResult(
|
||||
step="agent_installer",
|
||||
ok=True,
|
||||
|
|
@ -62,42 +52,8 @@ class AgentInstaller:
|
|||
error=str(e),
|
||||
)
|
||||
|
||||
def deploy_commands(self) -> SetupStepResult:
|
||||
"""Deploy command files to vault agent config dir."""
|
||||
source_commands = self._script_dir / "command_files"
|
||||
if not source_commands.exists():
|
||||
return SetupStepResult(
|
||||
step="agent_installer",
|
||||
ok=True,
|
||||
message="No command files to deploy",
|
||||
details={"skipped": True},
|
||||
)
|
||||
|
||||
agent_dir = self.vault / ".agents"
|
||||
target_dir = agent_dir / "command_files"
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
for f in source_commands.iterdir():
|
||||
if f.is_file():
|
||||
shutil.copy2(f, target_dir / f.name)
|
||||
|
||||
return SetupStepResult(
|
||||
step="agent_installer",
|
||||
ok=True,
|
||||
message=f"Deployed command files to {target_dir}",
|
||||
details={"source": str(source_commands), "target": str(target_dir)},
|
||||
)
|
||||
except Exception as e:
|
||||
return SetupStepResult(
|
||||
step="agent_installer",
|
||||
ok=False,
|
||||
message="Failed to deploy command files",
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
def deploy_agent_config(self) -> SetupStepResult:
|
||||
"""Deploy AGENTS.md and other agent config files."""
|
||||
"""Deploy AGENTS.md to vault root."""
|
||||
source_agents = self._script_dir.parent / "AGENTS.md"
|
||||
if not source_agents.exists():
|
||||
return SetupStepResult(
|
||||
|
|
@ -126,8 +82,4 @@ class AgentInstaller:
|
|||
|
||||
def run_all(self) -> list[SetupStepResult]:
|
||||
"""Run all deployment steps."""
|
||||
results = []
|
||||
results.append(self.deploy_skills())
|
||||
results.append(self.deploy_commands())
|
||||
results.append(self.deploy_agent_config())
|
||||
return results
|
||||
return [self.deploy_skills(), self.deploy_agent_config()]
|
||||
|
|
|
|||
62
paperforge/skills/literature-qa/SKILL.md
Normal file
62
paperforge/skills/literature-qa/SKILL.md
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
---
|
||||
name: literature-qa
|
||||
description: >
|
||||
学术文献精读与问答。Triggered by: /pf-deep /pf-paper /pf-end,
|
||||
"精读 <key/title>", "文献问答 <key/title>", "结束讨论", "保存记录",
|
||||
"精读队列". Uses Zotero key, DOI, or title to locate papers.
|
||||
license: Apache-2.0
|
||||
compatibility: all
|
||||
---
|
||||
|
||||
# Literature QA — 学术文献精读与问答
|
||||
|
||||
## 路由表
|
||||
|
||||
Agent 读到本文件后,首先根据用户意图路由到对应的 reference 文件:
|
||||
|
||||
| 用户意图 | 典型输入 | 加载文件 |
|
||||
|---------|---------|---------|
|
||||
| 三阶段精读(指定论文) | `/pf-deep <query>`, `pf-deep <query>`, "精读 XXX", "深度阅读 XXX", "带我读", "组会讲这篇", "读一下这篇" | [references/deep-reading.md](references/deep-reading.md) |
|
||||
| 三阶段精读(查看队列) | `/pf-deep`(无参数), "精读队列", "有哪些该读了" | [references/deep-reading.md](references/deep-reading.md) |
|
||||
| 论文问答 | `/pf-paper <query>`, `pf-paper <query>`, "做这篇的问答", "帮我看看 XXX", "这篇文章讲了什么", "查一下" | [references/paper-qa.md](references/paper-qa.md) |
|
||||
| 保存讨论记录 | `/pf-end`, `pf-end`, "保存", "结束讨论", "完成讨论", "保存记录" | [references/save-session.md](references/save-session.md) |
|
||||
|
||||
> **重要:** 加载 reference 文件后,严格按照该文件的流程执行。不要跳过任何步骤。
|
||||
|
||||
## 论文定位(所有路由共用,先执行)
|
||||
|
||||
详见 [references/paper-resolution.md](references/paper-resolution.md)。
|
||||
|
||||
**核心原则:所有路径操作走 Python,Agent 只管调命令、读输出。零硬编码、零平台依赖。**
|
||||
|
||||
### 快速索引
|
||||
|
||||
| 你要做什么 | 跑这个 Python 命令 |
|
||||
|-----------|-------------------|
|
||||
| 定位论文(按 key) | `python -m paperforge.worker.paper_resolver resolve-key <KEY> --vault .` |
|
||||
| 定位论文(按 DOI) | `python -m paperforge.worker.paper_resolver resolve-doi "<DOI>" --vault .` |
|
||||
| 搜索论文(字段匹配) | `python -m paperforge.worker.paper_resolver search --title "..." --author "..." --year ... --vault .` |
|
||||
| 获取 vault 路径 | `python -m paperforge.worker.paper_resolver paths --vault .` |
|
||||
|
||||
### 处理结果
|
||||
|
||||
- **Python 返回匹配:** 直接使用返回的 workspace
|
||||
- **Python 返回空:** Agent 自己搜。用 `paths` 拿到的 `literature_dir` 和 `index_path`,grep frontmatter / 读 formal-library.json
|
||||
- **自然语言输入:** Agent 自己理解语义,读 formal-library.json(`paths` 里的 `index_path`)搜索
|
||||
- **命中多篇:** 列出候选清单(key, title, year, domain, ocr_status),让用户选
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
literature-qa/
|
||||
├── SKILL.md ← 本文件(路由入口)
|
||||
├── references/
|
||||
│ ├── deep-reading.md ← 精读工作流
|
||||
│ ├── paper-qa.md ← 问答工作流
|
||||
│ ├── save-session.md ← 保存记录工作流
|
||||
│ ├── paper-resolution.md ← 论文定位详细协议
|
||||
│ ├── deep-subagent.md ← 子代理提示词模板
|
||||
│ └── chart-reading/ ← 19 个图表类型阅读指南
|
||||
└── scripts/
|
||||
└── ld_deep.py ← 精读引擎(Python)
|
||||
```
|
||||
163
paperforge/skills/literature-qa/references/deep-reading.md
Normal file
163
paperforge/skills/literature-qa/references/deep-reading.md
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
# 三阶段精读
|
||||
|
||||
Keshav 三阶段组会式精读。触发后执行以下工作流。
|
||||
|
||||
> **路径说明:** 本文件中的 `scripts/ld_deep.py` 相对于本 skill 目录。Agent 运行时 skill 目录由平台注入(通常为 `<installation_path>/paperforge/skills/literature-qa/`)。如不确定,AI 应从 SKILL.md 所在目录推断。
|
||||
|
||||
---
|
||||
|
||||
## 前置条件检查
|
||||
|
||||
执行前确认:
|
||||
- [ ] 已完成论文定位(参考 [paper-resolution.md](paper-resolution.md)),拿到 zotero_key 和 workspace
|
||||
- [ ] `analyze: true`(在 formal note frontmatter 中,resolver 返回的 workspace 里可查到)
|
||||
- [ ] `ocr_status: done`(在 resolver 返回的 workspace 里可查到)
|
||||
|
||||
如果前置条件不满足,告知用户并停止。
|
||||
|
||||
---
|
||||
|
||||
## 执行流程
|
||||
|
||||
### Step 1: Prepare(机械操作,跑脚本)
|
||||
|
||||
```bash
|
||||
python scripts/ld_deep.py prepare --key <ZOTERO_KEY>
|
||||
```
|
||||
|
||||
返回 JSON 解析:
|
||||
- `status: "ok"` → 记下 `figure_map`、`chart_type_map`、`formal_note`、`fulltext_md`、`figures`、`tables` 路径和数量 → 继续
|
||||
- `status: "error"` → 报告 `message` 给用户,停止
|
||||
|
||||
读 formal note 确认 `## 🔍 精读` 骨架已插入。
|
||||
|
||||
---
|
||||
|
||||
### Step 2: Pass 1 — 概览
|
||||
|
||||
只填 `### Pass 1: 概览` 区域。不碰 Pass 2/3。
|
||||
|
||||
**填写内容:**
|
||||
|
||||
- **一句话总览**:论文类型 + 核心发现,一句话。
|
||||
- **5 Cs 快速评估**:
|
||||
- **Category**(类型):RCT / 队列研究 / 病例对照 / 综述 / 基础研究 / ...
|
||||
- **Context**(上下文):该领域当前共识,本文要解决什么问题
|
||||
- **Correctness**(合理性初判):初步直觉,逻辑是否有明显漏洞
|
||||
- **Contributions**(贡献):1-3 条
|
||||
- **Clarity**(清晰度):写作质量,图表可读性
|
||||
- **Figure 导读**(基于 fulltext.md 浏览各图 caption):
|
||||
- 关键主图:列出并一句话概括每个主图要证明什么
|
||||
- 证据转折点:哪个 figure 是叙事的关键转折
|
||||
- 需要重点展开的 supplementary:如果有
|
||||
- 关键表格:列出
|
||||
|
||||
填完立即保存 formal note。
|
||||
|
||||
---
|
||||
|
||||
### Step 3: Pass 2 — 精读还原
|
||||
|
||||
填 `### Pass 2: 精读还原` 区域。**按 figure 顺序逐个处理。**
|
||||
|
||||
#### 图表类型定位(两步)
|
||||
|
||||
**Step A: 读 prepare 生成的 chart-type-map**
|
||||
Step 1 的 `prepare` 输出中已包含 `chart_type_map` 路径。读该文件,获取每个 figure 的关键词命中结果。这只是建议。
|
||||
|
||||
**Step B: Agent 读 caption 做最终判断**
|
||||
|
||||
对每个 figure:
|
||||
1. 读该 figure 的 caption(来自 prepare 返回的 `fulltext_md` 或 `figure_map`)
|
||||
2. 根据 caption 内容,对照 [chart-reading/INDEX.md](chart-reading/INDEX.md) 判断图表类型
|
||||
3. chart-type-map 建议和 Agent 判断不一致 → 以 Agent 判断为准
|
||||
4. 无法确定类型 → 跳过 chart guide,按通用 figure 结构分析
|
||||
5. 确定类型后,读对应的 chart-reading 指南(如 `chart-reading/条形图与误差棒.md`),按指南中的检查清单分析
|
||||
|
||||
#### 每张 Figure 的子标题(固定,不可少)
|
||||
|
||||
按以下格式填入 formal note 中该 figure 的 callout block:
|
||||
|
||||
```
|
||||
**图像定位与核心问题**:页码 + 要回答什么问题
|
||||
**方法与结果**:实验设计/数据来源/技术手段。核心数据、趋势、对比。
|
||||
**图表质量审查**:按 chart-reading 指南检查坐标轴、单位、误差棒、统计标注等。
|
||||
**作者解释**:作者在正文中对该图的解读
|
||||
**我的理解**:自己的理解(区分于作者解释)
|
||||
**疑点/局限**:读图时发现的疑问,用 `> [!warning]` 突出
|
||||
```
|
||||
|
||||
#### 每张 Table 的子标题
|
||||
|
||||
```
|
||||
回答什么问题、关键字段/分组、主要结果、我的理解、疑点/局限
|
||||
```
|
||||
|
||||
#### 每张 figure 填完立即保存,再处理下一张。
|
||||
|
||||
#### 所有 figure/table 处理完后,填:
|
||||
|
||||
**关键方法补课**:简要解释不熟悉的实验技术(1-2 项即可)
|
||||
|
||||
**主要发现与新意**:
|
||||
- 发现 1:...(来源:Figure X)
|
||||
- 发现 2:...(来源:Figure Y / Table Z)
|
||||
|
||||
保存。
|
||||
|
||||
---
|
||||
|
||||
### Step 4: Postprocess(跑校验脚本,修正问题)
|
||||
|
||||
```bash
|
||||
python scripts/ld_deep.py postprocess-pass2 <formal_note_path> --figures <N> --format text
|
||||
```
|
||||
|
||||
- 输出 `OK` → 继续
|
||||
- 输出错误 → 按错误提示修正(包含行号),修正后重新跑
|
||||
- 最多 3 轮修正。3 轮后仍失败 → 报告剩余错误给用户
|
||||
|
||||
---
|
||||
|
||||
### Step 5: Pass 3 — 深度理解
|
||||
|
||||
填 `### Pass 3: 深度理解` 区域。基于 Pass 1/2 已写的内容。
|
||||
|
||||
**填写内容:**
|
||||
|
||||
- **假设挑战与隐藏缺陷**:隐含假设;如果放宽某个假设结论还成立吗;缺少哪些关键引用;实验/分析技术潜在问题
|
||||
- **哪些结论扎实,哪些仍存疑**:
|
||||
- **较扎实**:...
|
||||
- **仍存疑**:...(用 `> [!warning]`)
|
||||
- **Discussion 与 Conclusion 怎么读**:作者真正完成了什么;哪些地方有拔高;哪些是推测
|
||||
- **对我的启发**:研究设计上;figure 组织上;方法组合上;未来工作想法
|
||||
- **遗留问题**:...(用 `> [!question]`)
|
||||
|
||||
保存。
|
||||
|
||||
---
|
||||
|
||||
### Step 6: Final Validation
|
||||
|
||||
```bash
|
||||
python scripts/ld_deep.py validate-note <formal_note_path> --fulltext <fulltext_path>
|
||||
```
|
||||
|
||||
- 输出 `OK` → 告知用户精读完成
|
||||
- 输出错误 → 修正缺失项,不报告成功直到通过
|
||||
|
||||
---
|
||||
|
||||
## Callout 格式规则
|
||||
|
||||
- `> [!important]`:每个 main finding
|
||||
- `> [!warning]`:疑问、局限、证据边界、仍存疑条目
|
||||
- `> [!question]`:遗留问题
|
||||
- **间距:** 相邻 callout block 之间必须有空行,否则 Obsidian 会合并
|
||||
- 正确:`> [!important] A\n\n> [!important] B`
|
||||
- 错误:`> [!important] A\n> [!important] B`
|
||||
|
||||
## Supplementary 规则
|
||||
|
||||
- 默认不逐张展开 supplementary figure/table
|
||||
- 仅在以下情况纳入:对主结论形成关键支撑、补足方法可信度、限制主文结论解释范围、作者在正文中明显依赖该补充材料
|
||||
61
paperforge/skills/literature-qa/references/paper-qa.md
Normal file
61
paperforge/skills/literature-qa/references/paper-qa.md
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
# 论文问答
|
||||
|
||||
交互式论文 Q&A 工作台。不强制要求 OCR,但 OCR 完成后回答更准确。
|
||||
|
||||
---
|
||||
|
||||
## 前置条件
|
||||
|
||||
- [ ] 已完成论文定位(参考 [paper-resolution.md](paper-resolution.md)),拿到 zotero_key 和 workspace
|
||||
- [ ] OCR 完成(推荐但非强制)
|
||||
|
||||
---
|
||||
|
||||
## 执行流程
|
||||
|
||||
### Step 1: 加载论文
|
||||
|
||||
1. 确认 workspace 路径
|
||||
2. 读 `fulltext.md`(如果存在)作为主要回答依据
|
||||
3. 读 formal note frontmatter 获取元数据(标题、作者、期刊、年份)
|
||||
4. 如果 fulltext.md 不存在,告知用户 "OCR 文本不可用,回答将基于元数据和公开信息"
|
||||
|
||||
### Step 2: 显示论文信息
|
||||
|
||||
```
|
||||
已加载论文: [title] ([year], [journal])
|
||||
作者: [authors]
|
||||
Zotero Key: [key]
|
||||
领域: [domain]
|
||||
OCR 状态: [done / 不可用]
|
||||
结束对话时说 "保存" 即可保存讨论。
|
||||
请问有什么问题?
|
||||
```
|
||||
|
||||
### Step 3: 进入 Q&A 模式
|
||||
|
||||
- 等待用户提问
|
||||
- 每次回答后等待下一个问题
|
||||
- 持续到用户说 "保存"、"结束"、"完成" 等关键词
|
||||
|
||||
---
|
||||
|
||||
## 回答原则
|
||||
|
||||
- **严格基于** fulltext.md 中的文本内容回答
|
||||
- 引用原文时标注来源页码/章节(如 "第 3 页,Methods 部分")
|
||||
- 用中文(简体中文)回答
|
||||
- 论文中未提及的内容,明确说明 "论文中未提及该内容"
|
||||
- 需要结合论文以外知识的问题,说明 "该问题需要结合论文以外的知识"
|
||||
|
||||
---
|
||||
|
||||
## 切换模式
|
||||
|
||||
用户在当前对话中可以说 "精读这篇文章" 切换到 deep-reading 模式。此时加载 [deep-reading.md](deep-reading.md) 执行精读流程。
|
||||
|
||||
---
|
||||
|
||||
## 保存记录
|
||||
|
||||
用户说 "保存"、"结束"、"完成"、"保存讨论" 时,加载 [save-session.md](save-session.md) 执行保存。不要自动保存。
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
# 论文定位协议
|
||||
|
||||
本文件定义如何将用户输入解析为论文 workspace。所有子流程公用。
|
||||
|
||||
## 核心原则
|
||||
|
||||
1. **Python 做确定性查找。** key、DOI、标题片段、作者+年份。
|
||||
2. **Agent 做理解和兜底。** 自然语言、Python 无结果时的 fallback 搜索。
|
||||
3. **路径从 `paths` 获取,不硬编码。**
|
||||
|
||||
---
|
||||
|
||||
## 通用命令
|
||||
|
||||
| 操作 | 命令 |
|
||||
|------|------|
|
||||
| 获取 vault 路径 | `python -m paperforge.worker.paper_resolver paths --vault .` |
|
||||
| 按 key 查 | `python -m paperforge.worker.paper_resolver resolve-key <KEY> --vault .` |
|
||||
| 按 DOI 查 | `python -m paperforge.worker.paper_resolver resolve-doi "<DOI>" --vault .` |
|
||||
| 按字段搜 | `python -m paperforge.worker.paper_resolver search --title "..." --author "..." --year ... --domain "..." --vault .` |
|
||||
|
||||
---
|
||||
|
||||
## 输入类型判断
|
||||
|
||||
### 类型 1: Zotero Key(8位字母数字组合)
|
||||
|
||||
```
|
||||
python -m paperforge.worker.paper_resolver resolve-key <KEY> --vault .
|
||||
```
|
||||
|
||||
返回 JSON 含 `key`, `title`, `domain`, `formal_note_path`, `ocr_path`, `fulltext_path`, `ocr_status` 等。所有路径由 `paperforge.json` 配置决定。
|
||||
|
||||
### 类型 2: DOI(以 `10.` 开头,可能带 URL 前缀)
|
||||
|
||||
```
|
||||
python -m paperforge.worker.paper_resolver resolve-doi "<DOI>" --vault .
|
||||
```
|
||||
|
||||
返回格式同类型 1。
|
||||
|
||||
### 类型 3: 标题片段
|
||||
|
||||
```
|
||||
python -m paperforge.worker.paper_resolver search --title "..." --vault .
|
||||
```
|
||||
|
||||
返回 `{"matches": [...], "count": N}`。
|
||||
|
||||
### 类型 4: 作者 + 年份
|
||||
|
||||
```
|
||||
python -m paperforge.worker.paper_resolver search --author "Smith" --year 2024 --vault .
|
||||
```
|
||||
|
||||
### 类型 5: 自然语言("关于骨再生的那篇")
|
||||
|
||||
Agent 自己处理:
|
||||
1. 跑 `paths` 获取 `index_path`
|
||||
2. 读 `index_path` 指向的 `formal-library.json`
|
||||
3. 在 `title`、`domain`、`journal`、`abstract` 中搜匹配
|
||||
4. 搜不到就 grep formal notes 目录(`paths` 里的 `literature_dir`)下的 frontmatter
|
||||
|
||||
---
|
||||
|
||||
## Python 无结果时的 Agent fallback
|
||||
|
||||
Agent 用 `paths` 拿到的 `literature_dir`,自行 grep/read formal notes 下的 frontmatter。
|
||||
|
||||
## 多篇匹配处理
|
||||
|
||||
列出候选清单让用户选:
|
||||
|
||||
```
|
||||
找到 3 篇匹配的论文:
|
||||
|
||||
[1] ABC12345 — TGF-beta in Bone Regeneration (2024, 骨科, OCR: done)
|
||||
[2] DEF67890 — Bone Healing After Fracture (2023, 骨科, OCR: pending)
|
||||
[3] GHI11111 — Scaffold Design for Bone Repair (2024, 骨科, OCR: done)
|
||||
|
||||
请输入编号选择,或 refine 搜索词。
|
||||
```
|
||||
|
||||
## Fallback 顺序
|
||||
|
||||
```
|
||||
输入
|
||||
│
|
||||
├── 像 key/DOI/标题/作者年份?
|
||||
│ └── Python paper_resolver → 有/无结果 → Agent 兜底
|
||||
│
|
||||
└── 自然语言?
|
||||
└── Agent 读 formal-library.json → 搜 → 有/无
|
||||
```
|
||||
55
paperforge/skills/literature-qa/references/save-session.md
Normal file
55
paperforge/skills/literature-qa/references/save-session.md
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
# 保存讨论记录
|
||||
|
||||
将 paper-qa 会话中的 Q&A 记录持久化到论文工作区。
|
||||
|
||||
---
|
||||
|
||||
## 触发条件
|
||||
|
||||
- 用户显式说 "保存"、"保存记录"、"结束"、"完成讨论"、"save"
|
||||
- 或显式输入 `/pf-end`
|
||||
- 不要自动触发
|
||||
|
||||
---
|
||||
|
||||
## 执行
|
||||
|
||||
### Step 1: 收集 Q&A 对
|
||||
|
||||
汇总本次 paper-qa 会话中所有 Q&A,序列化为 JSON 数组:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"question": "用户的问题",
|
||||
"answer": "Agent 的回答",
|
||||
"source": "user_question",
|
||||
"timestamp": "2026-05-10T12:00:00+08:00"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
`source` 为 `"user_question"`(用户提问)或 `"agent_analysis"`(Agent 主动分析)。
|
||||
|
||||
### Step 2: 调用 discussion 模块
|
||||
|
||||
```bash
|
||||
python -m paperforge.worker.discussion record <ZOTERO_KEY> \
|
||||
--vault . \
|
||||
--agent pf-paper \
|
||||
--model "<CURRENT_MODEL>" \
|
||||
--qa-pairs '<JSON_ARRAY>'
|
||||
```
|
||||
|
||||
### Step 3: 确认结果
|
||||
|
||||
CLI 返回 `{"status": "ok", ...}` → 告知用户记录已保存。
|
||||
|
||||
返回 `{"status": "error"}` → 记录错误,重试一次。仍失败则告知用户。
|
||||
|
||||
---
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 仅 paper-qa 会话需要记录。deep-reading 的内容直接写入 formal note,不需要通过本文件。
|
||||
- 如果无法从 formal-library.json 找到论文 domain/title,记录失败不应影响用户使用。
|
||||
|
|
@ -1589,11 +1589,11 @@ def main() -> int:
|
|||
full_validate_parser.add_argument("--tables", help="Comma-separated selected table numbers to validate")
|
||||
|
||||
queue_parser = subparsers.add_parser("queue", help="List papers awaiting deep reading from canonical index")
|
||||
queue_parser.add_argument("--vault", type=Path, required=True, help="Path to the vault root")
|
||||
queue_parser.add_argument("--vault", type=Path, default=None, help="Path to the vault root (auto-detected if omitted)")
|
||||
queue_parser.add_argument("--format", choices=["json", "table"], default="json", help="Output format")
|
||||
|
||||
prepare_parser = subparsers.add_parser("prepare", help="Auto-detect and prepare a paper for deep reading")
|
||||
prepare_parser.add_argument("--vault", type=Path, required=True, help="Path to the vault root")
|
||||
prepare_parser.add_argument("--vault", type=Path, default=None, help="Path to the vault root (auto-detected if omitted)")
|
||||
prepare_parser.add_argument("--key", required=True, help="Zotero key")
|
||||
prepare_parser.add_argument("--force", action="store_true", help="Re-run even if deep_reading_status=done")
|
||||
prepare_parser.add_argument("--format", choices=["json", "table"], default="json", help="Output format")
|
||||
|
|
@ -1618,6 +1618,11 @@ def main() -> int:
|
|||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.command in ("prepare", "queue"):
|
||||
if args.vault is None:
|
||||
from paperforge.config import resolve_vault as _resolve_vault
|
||||
args.vault = _resolve_vault(Path.cwd())
|
||||
|
||||
if args.command == "prepare":
|
||||
result = prepare_deep_reading(args.vault, args.key, force=args.force)
|
||||
if args.format == "json":
|
||||
|
|
|
|||
|
|
@ -1,237 +0,0 @@
|
|||
---
|
||||
name: pf-deep
|
||||
description: Complete three-pass deep reading of an academic paper (Keshav method). Requires OCR fulltext. Searches by Zotero key, title, DOI, or PMID.
|
||||
allowed-tools: [Read, Bash, Edit]
|
||||
---
|
||||
|
||||
# <prefix>pf-deep
|
||||
|
||||
## Purpose
|
||||
|
||||
基于单篇论文的组会式精读入口。
|
||||
|
||||
1. 解析 `<prefix>pf-deep <query>` 中的查询词
|
||||
2. 支持 Zotero key、标题片段、DOI、PMID、关键词
|
||||
3. 优先搜索本地 Zotero 并锁定单篇论文
|
||||
4. 绑定该论文对应的:
|
||||
- `<system_dir>/PaperForge/ocr/<KEY>/fulltext.md`
|
||||
- `<system_dir>/PaperForge/ocr/<KEY>/meta.json`
|
||||
- `<resources_dir>/<literature_dir>/.../KEY - Title.md`
|
||||
5. 在正式文献卡片中检查或创建 `## 精读`
|
||||
6. 以"研究思路 + figure-by-figure"方式一次性完成精读写回
|
||||
|
||||
## CLI Equivalent
|
||||
|
||||
```bash
|
||||
# 准备阶段(间接)
|
||||
python .opencode/skills/pf-deep/scripts/ld_deep.py prepare --vault "<VAULT_PATH>" --key <ZOTERO_KEY>
|
||||
# 返回 JSON:{status, formal_note, fulltext_md, figures, tables}
|
||||
```
|
||||
|
||||
> `<prefix>pf-deep` 是 **Agent 层命令**,通过 Python 代码自动检测论文状态,无需先行 CLI 准备。
|
||||
|
||||
## Detection(自动检测,无需手动 sync)
|
||||
|
||||
启动时,Agent 执行以下 Python 检测命令,代码会自动判断是否需要精读:
|
||||
|
||||
```bash
|
||||
python .opencode/skills/pf-deep/scripts/ld_deep.py prepare --vault "<VAULT_PATH>" --key <ZOTERO_KEY>
|
||||
```
|
||||
|
||||
返回 JSON:
|
||||
- `status: "ok"` → 就绪,可以开始精读
|
||||
- `status: "error"` → 被阻塞(`message` 说明原因:analyze=false / OCR 未完成 / 未找到论文)
|
||||
|
||||
Agent 根据返回的 `status` 决定是否进入精读流程,不自行读取 frontmatter。
|
||||
|
||||
**队列模式**(无参数时自动检测):Agent 运行:
|
||||
```bash
|
||||
python .opencode/skills/pf-deep/scripts/ld_deep.py queue --vault "<VAULT_PATH>"
|
||||
```
|
||||
代码自动扫描 canonical index 中 `analyze=true` 且 `deep_reading_status=pending` 的论文,按 OCR 状态分组。
|
||||
|
||||
## Arguments
|
||||
|
||||
| 参数 | 必需 | 说明 |
|
||||
|------|------|------|
|
||||
| `<query>` | 是(queue 模式除外) | Zotero key、标题片段、DOI、PMID 或关键词 |
|
||||
| `queue` | 否 | 启动批量精读队列模式 |
|
||||
|
||||
### 参数说明
|
||||
|
||||
1. 如果输入看起来像 8 位 Zotero key,则直接按 key 解析。
|
||||
2. 否则先在本地 Zotero 中搜索标题/摘要。
|
||||
3. 若命中唯一结果或明显最佳结果,则直接载入。
|
||||
4. 若存在多个合理候选,则先列候选清单再让用户选。
|
||||
5. 不要强迫用户先知道 Zotero key。
|
||||
|
||||
## Example
|
||||
|
||||
### 单篇精读(已知 key)
|
||||
|
||||
```bash
|
||||
<prefix>pf-deep XGT9Z257
|
||||
<prefix>pf-deep Predictive findings on magnetic resonance imaging
|
||||
<prefix>pf-deep 10.1016/j.jse.2018.01.001
|
||||
```
|
||||
|
||||
### 无参数:自动检测队列
|
||||
|
||||
```bash
|
||||
<prefix>pf-deep
|
||||
```
|
||||
|
||||
当不提供具体 key/标题时,agent 自动检测精读队列:
|
||||
|
||||
1. 运行 `python .opencode/skills/pf-deep/scripts/ld_deep.py queue --vault "<VAULT_PATH>"` 扫描队列
|
||||
2. 解析输出的 JSON,按 OCR 状态分组展示
|
||||
3. 由用户选择篇目
|
||||
|
||||
无需先跑 `paperforge sync`。
|
||||
|
||||
## 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 正确,或尝试用标题片段搜索
|
||||
|
||||
## 精读结构参考
|
||||
|
||||
### 执行原则
|
||||
|
||||
- `<prefix>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 下方按以下顺序填写)
|
||||
- **图像定位与核心问题**:页码 + 要回答什么问题
|
||||
- **方法与结果**:方法 + 结果
|
||||
- **作者解释**:作者对该图的解读
|
||||
- **我的理解**:自己的理解(区分于作者解释)
|
||||
- **在全文中的作用**:该图在整体故事线中的位置
|
||||
- **疑点 / 局限**:读图时发现的疑问
|
||||
|
||||
#### Table-by-Table 解析
|
||||
(如有重要表格,按同样结构展开)
|
||||
|
||||
#### 关键方法补课
|
||||
- 方法 1:
|
||||
- 方法 2:
|
||||
|
||||
#### 主要发现与新意
|
||||
**主要发现**
|
||||
- 发现 1:
|
||||
- 发现 2:
|
||||
|
||||
### Pass 3: 深度理解
|
||||
|
||||
#### 假设挑战与隐藏缺陷
|
||||
- 隐含假设:
|
||||
- 如果放宽某个假设,结论还成立吗?
|
||||
- 缺少哪些关键引用?
|
||||
- 实验/分析技术的潜在问题:
|
||||
|
||||
#### 哪些结论扎实,哪些仍存疑
|
||||
**较扎实**
|
||||
-
|
||||
|
||||
**仍存疑**
|
||||
-
|
||||
|
||||
#### Discussion 与 Conclusion 怎么读
|
||||
- 作者真正完成了什么:
|
||||
- 哪些地方有拔高:
|
||||
- 哪些地方是推测:
|
||||
|
||||
#### 对我的启发
|
||||
- 研究设计上:
|
||||
- figure 组织上:
|
||||
- 方法组合上:
|
||||
- 未来工作想法:
|
||||
|
||||
#### 遗留问题
|
||||
**遗留问题**
|
||||
-
|
||||
```
|
||||
|
||||
### Figure 节要求
|
||||
|
||||
每个 figure 小节按以下顺序填写:
|
||||
|
||||
- **图像定位与核心问题**:页码 + 要回答什么问题
|
||||
- **方法与结果**:方法 + 结果
|
||||
- **作者解释**:作者对该图的解读
|
||||
- **我的理解**:自己的理解(区分于作者解释)
|
||||
- **在全文中的作用**:该图在整体故事线中的位置
|
||||
- **疑点 / 局限**:读图时发现的疑问(可酌情用 `> [!warning]` 突出)
|
||||
|
||||
## See Also
|
||||
|
||||
- [pf-paper](pf-paper.md) — 快速摘要与问答
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
---
|
||||
name: pf-end
|
||||
description: Save the paper Q&A discussion record. Triggered when user says "保存" "结束" "save" or types $pf-end. Summarizes all Q&A pairs and writes them to the paper's ai/discussion directory via the discussion module.
|
||||
allowed-tools: [Read, Bash]
|
||||
---
|
||||
|
||||
# <prefix>pf-end
|
||||
|
||||
## Purpose
|
||||
|
||||
结束当前论文对话并保存讨论记录。需要用户**显式要求**时才执行,不自动触发。
|
||||
|
||||
1. 汇总本次对话中所有 Q&A 对
|
||||
2. 通过 `discussion.record_session()` 写入论文工作区的 `ai/` 目录
|
||||
3. 告知用户记录已保存
|
||||
|
||||
## Trigger
|
||||
|
||||
用户说以下任一关键词时执行(全平台通用):
|
||||
- `保存` / `记录` / `保存记录`
|
||||
- `结束` / `完成`
|
||||
- `save discussion` / `save` / `done`
|
||||
|
||||
也可以显式指定 key:
|
||||
- `{prefix}pf-end <zotero_key>`(OpenCode)
|
||||
- `保存 <zotero_key>`(全平台)
|
||||
|
||||
如果未指定 key,则使用当前已加载的论文 key。
|
||||
|
||||
## Save Format
|
||||
|
||||
将会话期间的所有 Q&A 序列化为 JSON 数组:
|
||||
|
||||
```json
|
||||
{
|
||||
"question": "用户的问题",
|
||||
"answer": "Agent 的回答",
|
||||
"source": "user_question",
|
||||
"timestamp": "2026-05-06T12:00:00+08:00"
|
||||
}
|
||||
```
|
||||
|
||||
`source` 为 `"user_question"`(用户提问)或 `"agent_analysis"`(Agent 主动分析)。
|
||||
|
||||
## Command
|
||||
|
||||
```bash
|
||||
python -m paperforge.worker.discussion record <ZOTERO_KEY> \
|
||||
--vault "<VAULT_PATH>" \
|
||||
--agent pf-paper \
|
||||
--model "<MODEL_NAME>" \
|
||||
--qa-pairs '<JSON_ARRAY>'
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
CLI 返回:
|
||||
```json
|
||||
{"status": "ok", "json_path": "Literature/{domain}/{key} - {title}/ai/discussion.json", "md_path": "..."}
|
||||
```
|
||||
|
||||
如果 `status` 为 `"error"`,记录错误信息并重试,不要跳过。
|
||||
|
||||
## See Also
|
||||
|
||||
- [pf-paper](pf-paper.md) — 论文 Q&A 工作台
|
||||
- [pf-deep](pf-deep.md) — 完整三阶段精读
|
||||
|
|
@ -1,102 +0,0 @@
|
|||
---
|
||||
name: pf-ocr
|
||||
description: Process the PDF OCR queue for formal notes marked do_ocr: true. Uploads PDFs to PaddleOCR API and extracts fulltext with figures.
|
||||
allowed-tools: [Bash]
|
||||
---
|
||||
|
||||
# /pf-ocr
|
||||
|
||||
## Purpose
|
||||
|
||||
处理正式笔记 frontmatter 中 `do_ocr: true` 的 PDF OCR 队列。
|
||||
|
||||
`paperforge ocr` 会自动读取 `paperforge.json` 定位 ocr 目录和 worker 脚本,运行 OCR 并自动诊断结果。
|
||||
|
||||
## CLI Equivalent
|
||||
|
||||
```bash
|
||||
paperforge ocr
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [ ] formal note frontmatter 中 `do_ocr: true` 已设置
|
||||
- [ ] PDF 附件存在(`has_pdf: true`)
|
||||
- [ ] PaddleOCR API Key 已配置(`.env` 中 `PADDLEOCR_API_TOKEN`)
|
||||
- [ ] 网络连接正常(可访问 PaddleOCR 服务)
|
||||
|
||||
## Arguments
|
||||
|
||||
| 参数 | 必需 | 说明 |
|
||||
|------|------|------|
|
||||
| `--diagnose` | 否 | 仅诊断配置,不上传 PDF |
|
||||
| `--key <KEY>` | 否 | 仅处理指定 Zotero key 的文献 |
|
||||
| `--vault <PATH>` | 否 | 指定 Vault 根目录(默认当前目录) |
|
||||
| `--live` | 否 | 与 `--diagnose` 联用,执行实时 PDF 测试 |
|
||||
|
||||
### 诊断等级
|
||||
|
||||
| 等级 | 检查项 | 失败含义 |
|
||||
|------|--------|---------|
|
||||
| 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 完成后,每个文献生成以下文件:
|
||||
|
||||
```
|
||||
<system_dir>/PaperForge/ocr/<key>/
|
||||
├── fulltext.md # 提取的全文(含 <!-- page N --> 分页标记)
|
||||
├── 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` 文件中添加
|
||||
|
||||
### 服务不可达(L2 失败)
|
||||
- 无法连接 PaddleOCR → 检查网络连接
|
||||
|
||||
### PDF 上传失败(L4 失败)
|
||||
- PDF 提交后未返回结果 → 检查 PDF 文件是否损坏
|
||||
|
||||
### OCR 状态卡住
|
||||
- `ocr_status` 长期显示 `processing` → 重新设置 `do_ocr: true` 后再次运行
|
||||
|
||||
## See Also
|
||||
|
||||
- [pf-sync](pf-sync.md) — 文献同步(生成正式笔记)
|
||||
- [pf-deep](pf-deep.md) — 深度精读(依赖 OCR 结果)
|
||||
|
|
@ -1,107 +0,0 @@
|
|||
---
|
||||
name: pf-paper
|
||||
description: Quick paper Q&A workbench. Load a paper by Zotero key, title, DOI, or PMID and answer questions. Does not require OCR.
|
||||
allowed-tools: [Read, Bash]
|
||||
---
|
||||
|
||||
# <prefix>pf-paper
|
||||
|
||||
## Purpose
|
||||
|
||||
基于 Zotero OCR 文本的单篇论文工作台入口。
|
||||
|
||||
1. 解析 `<prefix>pf-paper <query>` 中的查询词
|
||||
2. 支持 Zotero key、标题片段、DOI、PMID、关键词
|
||||
3. 优先搜索本地 Zotero,解析到单篇目标论文
|
||||
4. 根据 Vault 根目录的 `paperforge.json` 加载 `<system_dir>/PaperForge/ocr/<KEY>/fulltext.md` 作为主文本
|
||||
5. 读取 `meta.json` 显示论文标题、作者、期刊、年份
|
||||
6. 进入 Q&A 模式,用中文回答用户关于该论文的问题
|
||||
7. 在当前论文上下文中,用户可再说"精读这篇文章"切换到 deep 层
|
||||
|
||||
## CLI Equivalent
|
||||
|
||||
```bash
|
||||
# 准备阶段(间接)
|
||||
paperforge sync # 生成正式笔记
|
||||
```
|
||||
|
||||
> `<prefix>pf-paper` 是 **Agent 层命令**,无直接 CLI 等效命令。
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [ ] 正式笔记已生成(`paperforge sync` 生成)
|
||||
- [ ] `fulltext.md` 存在(推荐,用于基于原文回答;如不存在则基于元数据回答)
|
||||
- [ ] discussion.py 模块可用(`python -m paperforge.worker.discussion --help` 可执行)
|
||||
|
||||
> **注意**:与 `/pf-deep` 不同,`<prefix>pf-paper` **不强制要求** OCR 完成。没有 OCR 时基于论文元数据和公开信息回答。
|
||||
|
||||
## Arguments
|
||||
|
||||
| 参数 | 必需 | 说明 |
|
||||
|------|------|------|
|
||||
| `<query>` | 是 | Zotero key、标题片段、DOI、PMID 或关键词 |
|
||||
| `<query2> ...` | 否 | 可同时加载多篇论文 |
|
||||
|
||||
### 解析规则
|
||||
|
||||
1. 如果输入看起来像 8 位 Zotero key,则直接按 key 解析。
|
||||
2. 否则先在本地 Zotero 中搜索标题/摘要。
|
||||
3. 若命中唯一结果或明显最佳结果,则直接载入。
|
||||
4. 若存在多个合理候选,则先列候选清单再让用户选。
|
||||
5. 不要强迫用户先知道 Zotero key。
|
||||
|
||||
## Example
|
||||
|
||||
```bash
|
||||
<prefix>pf-paper XGT9Z257
|
||||
<prefix>pf-paper Predictive findings on magnetic resonance imaging
|
||||
<prefix>pf-paper 10.1016/j.jse.2018.01.001
|
||||
<prefix>pf-paper XGT9Z257 PQR8KLM
|
||||
```
|
||||
|
||||
## Output
|
||||
|
||||
加载成功后显示:
|
||||
|
||||
```
|
||||
已加载论文: [title] ([year], [journal])
|
||||
Zotero Key: [key]
|
||||
结束对话时说"保存记录"即可保存讨论。
|
||||
请问有什么问题?
|
||||
```
|
||||
|
||||
### 回答原则
|
||||
|
||||
- **严格基于** `fulltext.md` 中的文本内容回答
|
||||
- 引用原文时标注来源页码/章节
|
||||
- 用中文(简体中文)回答
|
||||
- 论文中未提及的内容,明确说明"论文中未提及该内容"
|
||||
- 需要结合论文以外知识的问题,说明"该问题需要结合论文以外的知识回答"
|
||||
|
||||
## Error Handling
|
||||
|
||||
### 论文未找到
|
||||
- **表现**:Zotero key 无效或搜索无结果
|
||||
- **解决**:确认 key 正确,或尝试用标题片段搜索
|
||||
|
||||
### 多个候选结果
|
||||
- **表现**:搜索返回多个匹配的论文
|
||||
- **处理**:Agent 列出候选清单,让用户选择目标论文
|
||||
|
||||
### OCR 文件缺失
|
||||
- **表现**:`fulltext.md` 不存在
|
||||
- **处理**:Agent 基于元数据和公开信息回答,并告知用户"OCR 文本不可用,回答基于元数据"
|
||||
|
||||
## Saving Discussion Record
|
||||
|
||||
当用户说"保存"、"结束"、"完成"等关键词时(或显式输入 `{prefix}pf-end <key>`),必须执行记录保存。具体步骤见 [pf-end](pf-end.md)。
|
||||
|
||||
### 注意事项
|
||||
|
||||
- 仅 `/pf-paper` Q&A 需要记录。`/pf-deep` 不记录(精读内容已写入正式笔记)。
|
||||
- 如果无法从 formal-library.json 找到论文 domain/title,记录失败不应影响用户使用。
|
||||
|
||||
## See Also
|
||||
|
||||
- [pf-end](pf-end.md) — 结束对话并保存记录
|
||||
- [pf-deep](pf-deep.md) — 完整三阶段精读
|
||||
|
|
@ -1,94 +0,0 @@
|
|||
---
|
||||
name: pf-status
|
||||
description: Check PaperForge installation and runtime status. Verifies configuration, paths, and data integrity.
|
||||
allowed-tools: [Bash]
|
||||
---
|
||||
|
||||
# /pf-status
|
||||
|
||||
## Purpose
|
||||
|
||||
查看 PaperForge 当前安装与运行状态。
|
||||
|
||||
`paperforge status` 会检查:
|
||||
|
||||
- 安装完整性(Python 包、依赖)
|
||||
- 配置文件(`paperforge.json`、`.env`)
|
||||
- 路径连通性(exports、ocr、literature 目录)
|
||||
- Zotero 数据目录链接状态
|
||||
- Better BibTeX 导出文件状态
|
||||
|
||||
## CLI Equivalent
|
||||
|
||||
```bash
|
||||
paperforge status
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
无特殊前置条件。此命令用于诊断安装问题,即使在配置不完整时也会尽量输出可用信息。
|
||||
|
||||
## Arguments
|
||||
|
||||
| 参数 | 必需 | 说明 |
|
||||
|------|------|------|
|
||||
| `--vault <PATH>` | 否 | 指定 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: <system_dir>/PaperForge/exports/
|
||||
✓ ocr: <system_dir>/PaperForge/ocr/
|
||||
✓ literature: <resources_dir>/<literature_dir>/
|
||||
✓ Zotero: <system_dir>/Zotero/
|
||||
|
||||
[数据检查]
|
||||
✓ library.json: 存在,包含 150 条文献
|
||||
✓ 正式笔记: 150 篇
|
||||
|
||||
状态: 一切正常 ✅
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### 配置缺失
|
||||
- `✗ paperforge.json: 未找到` → 运行 `paperforge doctor` 或重新执行安装
|
||||
|
||||
### 路径错误
|
||||
- `✗ Zotero: 目录不存在或不是有效链接` → 创建 junction/symlink 到 Zotero 数据目录
|
||||
|
||||
### 依赖缺失
|
||||
- `✗ 依赖: requests 未安装` → `pip install requests pymupdf pillow`
|
||||
|
||||
### API Key 未设置
|
||||
- `✗ PADDLEOCR_API_TOKEN: 未设置` → 在 `.env` 文件中添加 API token
|
||||
|
||||
## See Also
|
||||
|
||||
- [pf-sync](pf-sync.md) — 文献同步
|
||||
- [pf-ocr](pf-ocr.md) — OCR 提取
|
||||
|
|
@ -1,85 +0,0 @@
|
|||
---
|
||||
name: pf-sync
|
||||
description: Sync Zotero Better BibTeX JSON export and generate/update formal literature notes.
|
||||
allowed-tools: [Bash]
|
||||
---
|
||||
|
||||
# /pf-sync
|
||||
|
||||
## Purpose
|
||||
|
||||
同步 Zotero Better BibTeX JSON 导出并生成/更新正式文献笔记。
|
||||
|
||||
`paperforge sync` 读取 Zotero JSON 中的新条目,直接生成正式文献笔记。
|
||||
|
||||
自动读取 `paperforge.json` 定位 exports 目录和 literature 目录。
|
||||
|
||||
## CLI Equivalent
|
||||
|
||||
```bash
|
||||
paperforge sync
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [ ] Zotero 已安装且 Better BibTeX 插件已启用
|
||||
- [ ] Better BibTeX 已配置自动导出 JSON
|
||||
- [ ] JSON 导出文件存在(`<system_dir>/PaperForge/exports/library.json`)
|
||||
- [ ] `paperforge.json` 配置正确(Vault 根目录下)
|
||||
|
||||
## Arguments
|
||||
|
||||
| 参数 | 必需 | 说明 |
|
||||
|------|------|------|
|
||||
| `--dry-run` | 否 | 预览变更,不实际写入文件 |
|
||||
| `--domain <DOMAIN>` | 否 | 仅同步指定领域(如 `骨科`) |
|
||||
| `--selection` | 否 | (已废弃)仅保留以兼容旧版 |
|
||||
| `--index` | 否 | (已废弃)仅保留以兼容旧版 |
|
||||
| `--vault <PATH>` | 否 | 指定 Vault 根目录(默认当前目录) |
|
||||
|
||||
### 选项
|
||||
|
||||
```bash
|
||||
paperforge sync --dry-run # 预览同步结果
|
||||
paperforge sync --domain 骨科 # 按领域过滤同步
|
||||
```
|
||||
|
||||
## Example
|
||||
|
||||
```bash
|
||||
# 同步 Zotero 并生成正式笔记
|
||||
paperforge sync
|
||||
|
||||
# 预览模式
|
||||
paperforge sync --dry-run
|
||||
|
||||
# 仅同步特定领域
|
||||
paperforge sync --domain 骨科
|
||||
|
||||
# 指定 Vault 目录
|
||||
paperforge sync --vault /path/to/vault
|
||||
```
|
||||
|
||||
## Output
|
||||
|
||||
```
|
||||
[INFO] Found 5 new items
|
||||
[INFO] Created 骨科/XXXXXXX.md
|
||||
[INFO] Generated 5 formal notes
|
||||
[INFO] Output: <resources_dir>/<literature_dir>/骨科/XXXXXXX - Title.md
|
||||
```
|
||||
|
||||
生成文件:`<resources_dir>/<literature_dir>/<domain>/<key> - <Title>.md`
|
||||
|
||||
## Error Handling
|
||||
|
||||
### JSON 文件不存在
|
||||
- `[ERROR] library.json not found` → 检查 Better BibTeX 导出路径
|
||||
|
||||
### 空 JSON 导出
|
||||
- `[INFO] Found 0 new items` → 确认 Zotero 中有带 citation key 的文献,Better BibTeX 已启用"Keep updated"
|
||||
|
||||
## See Also
|
||||
|
||||
- [pf-ocr](pf-ocr.md) — OCR 提取(下一步操作)
|
||||
- [pf-status](pf-status.md) — 检查系统状态
|
||||
|
|
@ -262,13 +262,22 @@ def _build_entry(item: dict, vault: Path, paths: dict, domain: str, zotero_dir:
|
|||
write_json(meta_path, meta)
|
||||
title_slug = slugify_filename(item["title"])
|
||||
note_path = paths["literature"] / domain / f"{key} - {title_slug}.md"
|
||||
|
||||
# --- Freeze slug: reuse existing workspace if it exists under a different slug ---
|
||||
workspace_dir = paths["literature"] / domain / f"{key} - {title_slug}"
|
||||
if not workspace_dir.exists():
|
||||
for candidate in (paths["literature"] / domain).glob(f"{key} - *"):
|
||||
if candidate.is_dir():
|
||||
workspace_dir = candidate
|
||||
title_slug = workspace_dir.name.split(" - ", 1)[1] if " - " in workspace_dir.name else title_slug
|
||||
break
|
||||
|
||||
if note_path.parent.exists():
|
||||
for stale_note in note_path.parent.glob(f"{key} - *.md"):
|
||||
if stale_note != note_path:
|
||||
stale_note.unlink()
|
||||
|
||||
# Workspace paths (Phase 26: flat-to-workspace migration)
|
||||
workspace_dir = paths["literature"] / domain / f"{key} - {title_slug}"
|
||||
main_note_path = workspace_dir / f"{key} - {title_slug}.md"
|
||||
deep_reading_file = workspace_dir / "deep-reading.md"
|
||||
target_fulltext = workspace_dir / "fulltext.md"
|
||||
|
|
@ -363,10 +372,24 @@ def _build_entry(item: dict, vault: Path, paths: dict, domain: str, zotero_dir:
|
|||
entry["maturity"] = compute_maturity(entry)
|
||||
entry["next_step"] = compute_next_step(entry)
|
||||
|
||||
# --- Workspace: always create and write to workspace path (Phase 38: no flat fallback) ---
|
||||
existing_text = main_note_path.read_text(encoding="utf-8") if main_note_path.exists() else ""
|
||||
|
||||
main_note_path.write_text(frontmatter_note(entry, existing_text), encoding="utf-8")
|
||||
# Slug already frozen above — for existing notes, update frontmatter only (preserve body)
|
||||
if main_note_path.exists():
|
||||
text = main_note_path.read_text(encoding="utf-8")
|
||||
fm_close = text.find("---\n", 4) # closing --- after opening ---
|
||||
if fm_close != -1:
|
||||
body = text[fm_close + 4:] # everything after frontmatter
|
||||
new_full = frontmatter_note(entry, "")
|
||||
new_fm_close = new_full.find("---\n", 4)
|
||||
if new_fm_close != -1:
|
||||
new_fm = new_full[: new_fm_close + 4] # new frontmatter block with closing ---\n
|
||||
main_note_path.write_text(new_fm + body, encoding="utf-8")
|
||||
else:
|
||||
main_note_path.write_text(new_full, encoding="utf-8")
|
||||
else:
|
||||
main_note_path.write_text(frontmatter_note(entry, text), encoding="utf-8")
|
||||
else:
|
||||
existing_text = note_path.read_text(encoding="utf-8") if note_path.exists() else ""
|
||||
main_note_path.write_text(frontmatter_note(entry, existing_text), encoding="utf-8")
|
||||
|
||||
# Write per-workspace paper-meta.json (Phase 37: internal state outside frontmatter)
|
||||
write_paper_meta(workspace_dir, entry, paperforge_version=PAPERFORGE_VERSION)
|
||||
|
|
|
|||
384
paperforge/worker/paper_resolver.py
Normal file
384
paperforge/worker/paper_resolver.py
Normal file
|
|
@ -0,0 +1,384 @@
|
|||
"""Deterministic paper lookup engine.
|
||||
|
||||
Resolves papers by key, DOI, or structured field search from formal-library.json.
|
||||
Natural language queries are handled by the Agent — this module handles only
|
||||
deterministic lookups.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class PaperMeta:
|
||||
"""Lightweight paper metadata for structured search results."""
|
||||
|
||||
key: str
|
||||
title: str
|
||||
domain: str
|
||||
year: str = ""
|
||||
authors: str = ""
|
||||
doi: str = ""
|
||||
journal: str = ""
|
||||
collection_path: str = ""
|
||||
lifecycle: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class PaperWorkspace:
|
||||
"""Full workspace paths and metadata for a resolved paper."""
|
||||
|
||||
key: str
|
||||
title: str
|
||||
domain: str
|
||||
formal_note_path: str
|
||||
ocr_path: str
|
||||
fulltext_path: str
|
||||
frontmatter: dict = field(default_factory=dict)
|
||||
year: str = ""
|
||||
authors: str = ""
|
||||
doi: str = ""
|
||||
journal: str = ""
|
||||
ocr_status: str = ""
|
||||
deep_reading_status: str = ""
|
||||
analyze: bool = False
|
||||
do_ocr: bool = False
|
||||
has_pdf: bool = False
|
||||
|
||||
|
||||
class PaperResolver:
|
||||
"""Deterministic paper lookup from formal-library.json index."""
|
||||
|
||||
def __init__(self, vault: Path):
|
||||
self._vault = vault
|
||||
self._paths: dict[str, Path] = {}
|
||||
self._items: list[dict] = []
|
||||
self._loaded = False
|
||||
|
||||
def _ensure_loaded(self):
|
||||
if self._loaded:
|
||||
return
|
||||
try:
|
||||
from paperforge.config import paperforge_paths
|
||||
|
||||
self._paths = paperforge_paths(self._vault)
|
||||
except Exception:
|
||||
self._paths = {}
|
||||
try:
|
||||
from paperforge.worker.asset_index import read_index
|
||||
|
||||
data = read_index(self._vault)
|
||||
if isinstance(data, dict):
|
||||
self._items = data.get("items", [])
|
||||
except Exception:
|
||||
self._items = []
|
||||
self._loaded = True
|
||||
|
||||
def resolve_key(self, key: str) -> Optional[PaperWorkspace]:
|
||||
"""Exact match on zotero_key."""
|
||||
self._ensure_loaded()
|
||||
for entry in self._items:
|
||||
if entry.get("zotero_key", "").strip().upper() == key.strip().upper():
|
||||
return self._build_workspace(entry)
|
||||
return None
|
||||
|
||||
def resolve_doi(self, doi: str) -> Optional[PaperWorkspace]:
|
||||
"""Exact match on DOI (case-insensitive, normalized)."""
|
||||
self._ensure_loaded()
|
||||
normalized = self._normalize_doi(doi)
|
||||
for entry in self._items:
|
||||
entry_doi = self._normalize_doi(entry.get("doi", ""))
|
||||
if entry_doi and entry_doi == normalized:
|
||||
return self._build_workspace(entry)
|
||||
return None
|
||||
|
||||
def search(
|
||||
self,
|
||||
title: Optional[str] = None,
|
||||
author: Optional[str] = None,
|
||||
year: Optional[int | str] = None,
|
||||
domain: Optional[str] = None,
|
||||
limit: int = 20,
|
||||
) -> list[PaperWorkspace]:
|
||||
"""Multi-field search with substring matching, sorted by relevance score.
|
||||
|
||||
All supplied fields must match (AND logic). Each field contributes to
|
||||
a relevance score for ranking.
|
||||
"""
|
||||
self._ensure_loaded()
|
||||
scored: list[tuple[int, PaperWorkspace]] = []
|
||||
|
||||
title_lower = title.strip().lower() if title else None
|
||||
author_lower = author.strip().lower() if author else None
|
||||
year_str = str(year).strip() if year is not None else None
|
||||
domain_lower = domain.strip().lower() if domain else None
|
||||
|
||||
for entry in self._items:
|
||||
score = 0
|
||||
entry_title = (entry.get("title") or "").lower()
|
||||
entry_authors = _flatten_authors(entry.get("authors", "")).lower()
|
||||
entry_year = str(entry.get("year", ""))
|
||||
entry_domain = (entry.get("domain") or "").lower()
|
||||
|
||||
if title_lower:
|
||||
if title_lower == entry_title:
|
||||
score += 100
|
||||
elif title_lower in entry_title:
|
||||
score += 30
|
||||
else:
|
||||
continue
|
||||
if author_lower:
|
||||
if author_lower in entry_authors:
|
||||
score += 50
|
||||
else:
|
||||
continue
|
||||
if year_str:
|
||||
if year_str == entry_year:
|
||||
score += 40
|
||||
else:
|
||||
continue
|
||||
if domain_lower:
|
||||
if domain_lower == entry_domain:
|
||||
score += 20
|
||||
elif domain_lower in entry_domain:
|
||||
score += 10
|
||||
else:
|
||||
continue
|
||||
|
||||
if score > 0 or not any([title_lower, author_lower, year_str, domain_lower]):
|
||||
scored.append((score, self._build_workspace(entry)))
|
||||
|
||||
scored.sort(key=lambda x: x[0], reverse=True)
|
||||
return [ws for _, ws in scored[:limit]]
|
||||
|
||||
def _build_workspace(self, entry: dict) -> PaperWorkspace:
|
||||
"""Build a PaperWorkspace from an index entry dict."""
|
||||
key = entry.get("zotero_key", "")
|
||||
title = entry.get("title", "")
|
||||
domain = entry.get("domain", "")
|
||||
note_path = entry.get("note_path", "")
|
||||
|
||||
ocr_base = _resolve_ocr_base(self._paths, key)
|
||||
fulltext_path = entry.get("fulltext_path", "")
|
||||
if not fulltext_path and ocr_base:
|
||||
fulltext_path = str(ocr_base / "fulltext.md")
|
||||
|
||||
frontmatter = {}
|
||||
if note_path:
|
||||
try:
|
||||
note_full = self._vault / note_path
|
||||
if note_full.exists():
|
||||
from paperforge.adapters.obsidian_frontmatter import read_frontmatter_dict
|
||||
|
||||
frontmatter = read_frontmatter_dict(note_full.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
authors_raw = entry.get("authors", "")
|
||||
if isinstance(authors_raw, list):
|
||||
authors_str = "; ".join(authors_raw)
|
||||
else:
|
||||
authors_str = str(authors_raw)
|
||||
|
||||
return PaperWorkspace(
|
||||
key=key,
|
||||
title=title,
|
||||
domain=domain,
|
||||
formal_note_path=note_path,
|
||||
ocr_path=str(ocr_base) if ocr_base else "",
|
||||
fulltext_path=fulltext_path,
|
||||
frontmatter=frontmatter,
|
||||
year=str(entry.get("year", "")),
|
||||
authors=authors_str,
|
||||
doi=entry.get("doi", ""),
|
||||
journal=entry.get("journal", ""),
|
||||
ocr_status=entry.get("ocr_status", ""),
|
||||
deep_reading_status=entry.get("deep_reading_status", ""),
|
||||
analyze=bool(entry.get("analyze", False)),
|
||||
do_ocr=bool(entry.get("do_ocr", False)),
|
||||
has_pdf=bool(entry.get("has_pdf", False)),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_doi(doi: str) -> str:
|
||||
"""Normalize DOI to lowercase, strip URL prefixes."""
|
||||
s = doi.strip().lower()
|
||||
s = re.sub(r"^https?://(dx\.)?doi\.org/", "", s)
|
||||
return s
|
||||
|
||||
|
||||
def _resolve_ocr_base(paths: dict[str, Path], key: str) -> Optional[Path]:
|
||||
"""Get the OCR directory for a given zotero key."""
|
||||
ocr_dir = paths.get("ocr")
|
||||
if ocr_dir and key:
|
||||
candidate = ocr_dir / key
|
||||
if candidate.exists():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def _flatten_authors(authors) -> str:
|
||||
"""Convert authors (list or str) to a single searchable string."""
|
||||
if isinstance(authors, list):
|
||||
return "; ".join(authors)
|
||||
return str(authors)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _ok_result(command: str, data: dict) -> str:
|
||||
"""Produce a PFResult-compatible JSON string to stdout."""
|
||||
from paperforge import __version__
|
||||
|
||||
result = {
|
||||
"ok": True,
|
||||
"command": command,
|
||||
"version": __version__,
|
||||
"data": data,
|
||||
"error": None,
|
||||
}
|
||||
return json.dumps(result, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def _err_result(command: str, message: str) -> str:
|
||||
from paperforge import __version__
|
||||
|
||||
result = {
|
||||
"ok": False,
|
||||
"command": command,
|
||||
"version": __version__,
|
||||
"data": None,
|
||||
"error": {"code": "INTERNAL_ERROR", "message": message},
|
||||
}
|
||||
return json.dumps(result, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def _workspace_to_dict(ws: PaperWorkspace) -> dict:
|
||||
return {
|
||||
"key": ws.key,
|
||||
"title": ws.title,
|
||||
"domain": ws.domain,
|
||||
"year": ws.year,
|
||||
"authors": ws.authors,
|
||||
"doi": ws.doi,
|
||||
"journal": ws.journal,
|
||||
"formal_note_path": ws.formal_note_path,
|
||||
"ocr_path": ws.ocr_path,
|
||||
"fulltext_path": ws.fulltext_path,
|
||||
"ocr_status": ws.ocr_status,
|
||||
"deep_reading_status": ws.deep_reading_status,
|
||||
"analyze": ws.analyze,
|
||||
"do_ocr": ws.do_ocr,
|
||||
"has_pdf": ws.has_pdf,
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="PaperForge paper resolver")
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
rk = sub.add_parser("resolve-key")
|
||||
rk.add_argument("key", help="Zotero key (8-char alphanumeric)")
|
||||
rk.add_argument("--vault", required=True, help="Path to vault root")
|
||||
|
||||
rd = sub.add_parser("resolve-doi")
|
||||
rd.add_argument("doi", help="DOI string")
|
||||
rd.add_argument("--vault", required=True, help="Path to vault root")
|
||||
|
||||
sr = sub.add_parser("search")
|
||||
sr.add_argument("--title", default=None)
|
||||
sr.add_argument("--author", default=None)
|
||||
sr.add_argument("--year", default=None)
|
||||
sr.add_argument("--domain", default=None)
|
||||
sr.add_argument("--limit", type=int, default=20)
|
||||
sr.add_argument("--vault", required=True, help="Path to vault root")
|
||||
|
||||
pt = sub.add_parser("paths")
|
||||
pt.add_argument("--vault", required=True, help="Path to vault root")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
vault = Path(args.vault).resolve()
|
||||
if not vault.exists():
|
||||
print(_err_result(args.command, f"Vault not found: {args.vault}"), file=sys.stdout)
|
||||
sys.exit(1)
|
||||
|
||||
resolver = PaperResolver(vault)
|
||||
|
||||
if args.command == "resolve-key":
|
||||
ws = resolver.resolve_key(args.key)
|
||||
if ws:
|
||||
print(_ok_result("resolve-key", {"match": _workspace_to_dict(ws)}), file=sys.stdout)
|
||||
else:
|
||||
print(
|
||||
json.dumps({
|
||||
"ok": True,
|
||||
"command": "resolve-key",
|
||||
"data": {"match": None, "message": f"No paper found with key: {args.key}"},
|
||||
"error": None,
|
||||
}, ensure_ascii=False, indent=2),
|
||||
file=sys.stdout,
|
||||
)
|
||||
|
||||
elif args.command == "resolve-doi":
|
||||
ws = resolver.resolve_doi(args.doi)
|
||||
if ws:
|
||||
print(_ok_result("resolve-doi", {"match": _workspace_to_dict(ws)}), file=sys.stdout)
|
||||
else:
|
||||
print(
|
||||
json.dumps({
|
||||
"ok": True,
|
||||
"command": "resolve-doi",
|
||||
"data": {"match": None, "message": f"No paper found with DOI: {args.doi}"},
|
||||
"error": None,
|
||||
}, ensure_ascii=False, indent=2),
|
||||
file=sys.stdout,
|
||||
)
|
||||
|
||||
elif args.command == "search":
|
||||
results = resolver.search(
|
||||
title=args.title,
|
||||
author=args.author,
|
||||
year=args.year,
|
||||
domain=args.domain,
|
||||
limit=args.limit,
|
||||
)
|
||||
print(
|
||||
_ok_result(
|
||||
"search",
|
||||
{
|
||||
"matches": [_workspace_to_dict(ws) for ws in results],
|
||||
"count": len(results),
|
||||
},
|
||||
),
|
||||
file=sys.stdout,
|
||||
)
|
||||
|
||||
elif args.command == "paths":
|
||||
resolver._ensure_loaded()
|
||||
paths_data = {
|
||||
"vault_root": str(resolver._vault),
|
||||
"index_path": str(resolver._paths.get("index", "")),
|
||||
"literature_dir": str(resolver._paths.get("literature", "")),
|
||||
"ocr_dir": str(resolver._paths.get("ocr", "")),
|
||||
}
|
||||
print(_ok_result("paths", paths_data), file=sys.stdout)
|
||||
|
||||
except Exception as exc:
|
||||
print(_err_result(args.command, str(exc)), file=sys.stdout)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -34,31 +34,6 @@ def _sync_obsidian_plugin(vault: Path) -> None:
|
|||
_pf_utils.install_obsidian_plugin(vault)
|
||||
|
||||
|
||||
def _deploy_skills(vault: Path) -> None:
|
||||
"""Copy the literature-qa skill from the package to the vault's agent dir."""
|
||||
import paperforge
|
||||
|
||||
src = Path(paperforge.__file__).parent / "skills" / "literature-qa"
|
||||
if not src.exists():
|
||||
logger.debug("Skills source not found: %s", src)
|
||||
return
|
||||
|
||||
raw = read_paperforge_json(vault)
|
||||
agent = raw.get("agent_platform", "opencode")
|
||||
agent_dirs = {
|
||||
"opencode": ".opencode/skills/literature-qa",
|
||||
"claude": ".claude/skills/literature-qa",
|
||||
"cursor": ".cursor/skills/literature-qa",
|
||||
"copilot": ".github/skills/literature-qa",
|
||||
"windsurf": ".windsurf/skills/literature-qa",
|
||||
"codex": ".codex/skills/literature-qa",
|
||||
}
|
||||
target_rel = agent_dirs.get(agent, agent_dirs["opencode"])
|
||||
dst = vault / target_rel
|
||||
shutil.copytree(src, dst, dirs_exist_ok=True)
|
||||
logger.info("Skills deployed: %s -> %s", src, dst)
|
||||
|
||||
|
||||
def protected_paths(vault: Path) -> set[str]:
|
||||
cfg = load_vault_config(vault)
|
||||
pf = f"{cfg['system_dir']}/PaperForge"
|
||||
|
|
@ -269,20 +244,10 @@ def _deploy_all_skills(vault: Path) -> None:
|
|||
from paperforge.config import load_vault_config
|
||||
|
||||
config = load_vault_config(vault)
|
||||
# Agent platform is a user preference, not a system default.
|
||||
# Fall back to opencode if not configured.
|
||||
agent_key = config.get("agent_platform") or "opencode"
|
||||
result = deploy_skills(
|
||||
vault=vault,
|
||||
agent_key=agent_key,
|
||||
system_dir=config.get("system_dir", "System"),
|
||||
resources_dir=config.get("resources_dir", "Resources"),
|
||||
literature_dir=config.get("literature_dir", "Literature"),
|
||||
base_dir=config.get("base_dir", "Bases"),
|
||||
overwrite=True,
|
||||
)
|
||||
if result["skills"]:
|
||||
logger.info("已部署 %d 个 skill: %s", len(result["skills"]), ", ".join(result["skills"]))
|
||||
result = deploy_skills(vault=vault, agent_key=agent_key, overwrite=True)
|
||||
if result["skill_deployed"]:
|
||||
logger.info("已部署 literature-qa skill")
|
||||
if result["agents_md"]:
|
||||
logger.info("已更新 AGENTS.md")
|
||||
for err in result.get("errors", []):
|
||||
|
|
|
|||
361
tests/unit/test_paper_resolver.py
Normal file
361
tests/unit/test_paper_resolver.py
Normal file
|
|
@ -0,0 +1,361 @@
|
|||
"""Tests for paperforge.worker.paper_resolver."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from paperforge.worker.paper_resolver import PaperResolver, PaperWorkspace
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_index_entry(
|
||||
key="ABC12345",
|
||||
title="Test Paper Title",
|
||||
domain="骨科",
|
||||
year=2024,
|
||||
authors="Smith J; Doe J",
|
||||
doi="10.1234/test.2024",
|
||||
journal="Journal of Testing",
|
||||
note_path=None,
|
||||
ocr_status="done",
|
||||
deep_reading_status="pending",
|
||||
analyze=True,
|
||||
do_ocr=True,
|
||||
has_pdf=True,
|
||||
**overrides,
|
||||
):
|
||||
if note_path is None:
|
||||
note_path = f"Literature/{domain}/{key} - {title}.md"
|
||||
entry = {
|
||||
"zotero_key": key,
|
||||
"title": title,
|
||||
"domain": domain,
|
||||
"year": year,
|
||||
"authors": authors.split("; "),
|
||||
"doi": doi,
|
||||
"journal": journal,
|
||||
"note_path": note_path,
|
||||
"ocr_status": ocr_status,
|
||||
"deep_reading_status": deep_reading_status,
|
||||
"analyze": analyze,
|
||||
"do_ocr": do_ocr,
|
||||
"has_pdf": has_pdf,
|
||||
"fulltext_path": "",
|
||||
"collection_path": "",
|
||||
"first_author": authors.split("; ")[0] if authors else "",
|
||||
"abstract": "This is a test abstract.",
|
||||
"impact_factor": "5.0",
|
||||
"pmid": "",
|
||||
"collections": [],
|
||||
"collection_tags": [],
|
||||
"collection_group": [],
|
||||
"pdf_path": "",
|
||||
"ocr_job_id": "",
|
||||
"ocr_md_path": "",
|
||||
"ocr_json_path": "",
|
||||
"deep_reading_md_path": "",
|
||||
"paper_root": "",
|
||||
"main_note_path": "",
|
||||
"deep_reading_path": "",
|
||||
"ai_path": "",
|
||||
"lifecycle": "fulltext_ready",
|
||||
"health": {},
|
||||
"maturity": {},
|
||||
"next_step": "/pf-deep",
|
||||
}
|
||||
entry.update(overrides)
|
||||
return entry
|
||||
|
||||
|
||||
def _make_index_envelope(items):
|
||||
return {"schema_version": "2", "generated_at": "2026-01-01T00:00:00", "paper_count": len(items), "items": items}
|
||||
|
||||
|
||||
def _write_index(path: Path, envelope: dict):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(envelope, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
|
||||
def _write_vault_config(vault: Path):
|
||||
(vault / "paperforge.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"vault_config": {
|
||||
"system_dir": "99_System",
|
||||
"resources_dir": "03_Resources",
|
||||
"literature_dir": "Literature",
|
||||
}
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Patch PaperResolver to bypass real file system dependencies
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def resolver_with_index(tmp_path):
|
||||
"""Create a PaperResolver preloaded with a test index."""
|
||||
vault = tmp_path / "test_vault"
|
||||
vault.mkdir(parents=True)
|
||||
_write_vault_config(vault)
|
||||
|
||||
# Create index path structure
|
||||
indexes_dir = vault / "99_System" / "PaperForge" / "indexes"
|
||||
indexes_dir.mkdir(parents=True)
|
||||
|
||||
items = [
|
||||
_make_index_entry(key="ABC12345", title="TGF-beta in Bone Regeneration", domain="骨科", year=2024, authors="Smith J; Lee K", doi="10.1234/tgf.2024"),
|
||||
_make_index_entry(key="DEF67890", title="MRI Predictive Findings in Rotator Cuff", domain="运动医学", year=2023, authors="Chen W; Wang L", doi="10.5678/mri.2023"),
|
||||
_make_index_entry(key="GHI11111", title="Another Bone Study", domain="骨科", year=2024, authors="Smith J; Zhang Y", doi="10.9999/bone.2024"),
|
||||
]
|
||||
_write_index(indexes_dir / "formal-library.json", _make_index_envelope(items))
|
||||
|
||||
# Create literature dirs and minimal formal notes
|
||||
for item in items:
|
||||
note_dir = vault / Path(item["note_path"]).parent
|
||||
note_dir.mkdir(parents=True, exist_ok=True)
|
||||
frontmatter = f"""---
|
||||
zotero_key: "{item['zotero_key']}"
|
||||
domain: "{item['domain']}"
|
||||
title: "{item['title']}"
|
||||
year: {item['year']}
|
||||
doi: "{item['doi']}"
|
||||
ocr_status: "{item['ocr_status']}"
|
||||
deep_reading_status: "{item['deep_reading_status']}"
|
||||
analyze: {str(item['analyze']).lower()}
|
||||
do_ocr: {str(item['do_ocr']).lower()}
|
||||
has_pdf: {str(item['has_pdf']).lower()}
|
||||
---
|
||||
"""
|
||||
(vault / item["note_path"]).write_text(frontmatter, encoding="utf-8")
|
||||
|
||||
# Create OCR dir for ABC12345
|
||||
ocr_dir = vault / "99_System" / "PaperForge" / "ocr" / "ABC12345"
|
||||
ocr_dir.mkdir(parents=True)
|
||||
(ocr_dir / "fulltext.md").write_text("# Fulltext\nTest content.", encoding="utf-8")
|
||||
|
||||
resolver = PaperResolver(vault)
|
||||
resolver._ensure_loaded()
|
||||
return resolver
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def empty_resolver(tmp_path):
|
||||
"""Create a PaperResolver with no index data."""
|
||||
vault = tmp_path / "empty_vault"
|
||||
vault.mkdir(parents=True)
|
||||
_write_vault_config(vault)
|
||||
return PaperResolver(vault)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# resolve_key tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestResolveKey:
|
||||
def test_exact_match(self, resolver_with_index):
|
||||
ws = resolver_with_index.resolve_key("ABC12345")
|
||||
assert ws is not None
|
||||
assert ws.key == "ABC12345"
|
||||
assert ws.title == "TGF-beta in Bone Regeneration"
|
||||
assert ws.domain == "骨科"
|
||||
assert ws.year == "2024"
|
||||
|
||||
def test_case_insensitive(self, resolver_with_index):
|
||||
ws = resolver_with_index.resolve_key("abc12345")
|
||||
assert ws is not None
|
||||
assert ws.key == "ABC12345"
|
||||
|
||||
def test_no_match(self, resolver_with_index):
|
||||
ws = resolver_with_index.resolve_key("ZZZZZZZZ")
|
||||
assert ws is None
|
||||
|
||||
def test_empty_key(self, resolver_with_index):
|
||||
ws = resolver_with_index.resolve_key("")
|
||||
assert ws is None
|
||||
|
||||
def test_whitespace_trimmed(self, resolver_with_index):
|
||||
ws = resolver_with_index.resolve_key(" ABC12345 ")
|
||||
assert ws is not None
|
||||
assert ws.key == "ABC12345"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# resolve_doi tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestResolveDoi:
|
||||
def test_exact_match(self, resolver_with_index):
|
||||
ws = resolver_with_index.resolve_doi("10.1234/tgf.2024")
|
||||
assert ws is not None
|
||||
assert ws.key == "ABC12345"
|
||||
|
||||
def test_case_insensitive(self, resolver_with_index):
|
||||
ws = resolver_with_index.resolve_doi("10.1234/TGF.2024")
|
||||
assert ws is not None
|
||||
assert ws.key == "ABC12345"
|
||||
|
||||
def test_with_url_prefix(self, resolver_with_index):
|
||||
ws = resolver_with_index.resolve_doi("https://doi.org/10.1234/tgf.2024")
|
||||
assert ws is not None
|
||||
assert ws.key == "ABC12345"
|
||||
|
||||
def test_with_dx_prefix(self, resolver_with_index):
|
||||
ws = resolver_with_index.resolve_doi("http://dx.doi.org/10.1234/tgf.2024")
|
||||
assert ws is not None
|
||||
assert ws.key == "ABC12345"
|
||||
|
||||
def test_no_match(self, resolver_with_index):
|
||||
ws = resolver_with_index.resolve_doi("10.0000/nonexistent")
|
||||
assert ws is None
|
||||
|
||||
def test_empty_doi_entries_handled(self, resolver_with_index):
|
||||
items = resolver_with_index._items
|
||||
for item in items:
|
||||
if item["zotero_key"] == "GHI11111":
|
||||
break
|
||||
ws = resolver_with_index.resolve_doi("10.9999/bone.2024")
|
||||
assert ws is not None
|
||||
assert ws.key == "GHI11111"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# search tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSearch:
|
||||
def test_search_by_title_exact(self, resolver_with_index):
|
||||
results = resolver_with_index.search(title="TGF-beta in Bone Regeneration")
|
||||
assert len(results) == 1
|
||||
assert results[0].key == "ABC12345"
|
||||
|
||||
def test_search_by_title_substring(self, resolver_with_index):
|
||||
results = resolver_with_index.search(title="Bone")
|
||||
assert len(results) == 2 # ABC12345 + GHI11111
|
||||
keys = {r.key for r in results}
|
||||
assert "ABC12345" in keys
|
||||
assert "GHI11111" in keys
|
||||
|
||||
def test_search_by_author(self, resolver_with_index):
|
||||
results = resolver_with_index.search(author="Smith")
|
||||
assert len(results) == 2 # ABC12345 + GHI11111
|
||||
|
||||
def test_search_by_year(self, resolver_with_index):
|
||||
results = resolver_with_index.search(year=2023)
|
||||
assert len(results) == 1
|
||||
assert results[0].key == "DEF67890"
|
||||
|
||||
def test_search_by_domain(self, resolver_with_index):
|
||||
results = resolver_with_index.search(domain="运动医学")
|
||||
assert len(results) == 1
|
||||
assert results[0].key == "DEF67890"
|
||||
|
||||
def test_search_combined_and_logic(self, resolver_with_index):
|
||||
results = resolver_with_index.search(title="Bone", author="Smith", year=2024)
|
||||
assert len(results) == 2 # Both ABC and GHI match all three
|
||||
|
||||
def test_search_no_match_combined(self, resolver_with_index):
|
||||
results = resolver_with_index.search(title="Bone", year=2023)
|
||||
assert len(results) == 0 # No bone papers in 2023
|
||||
|
||||
def test_search_ranked_by_relevance(self, resolver_with_index):
|
||||
results = resolver_with_index.search(title="Bone")
|
||||
assert len(results) >= 1
|
||||
# Exact match (TGF-beta in Bone Regeneration) should rank higher
|
||||
# than substring match (Another Bone Study)
|
||||
assert results[0].key in ("ABC12345", "GHI11111")
|
||||
|
||||
def test_search_returns_workspace_fields(self, resolver_with_index):
|
||||
results = resolver_with_index.search(title="TGF-beta")
|
||||
ws = results[0]
|
||||
assert isinstance(ws, PaperWorkspace)
|
||||
assert ws.key == "ABC12345"
|
||||
assert ws.formal_note_path
|
||||
assert ws.frontmatter
|
||||
assert ws.frontmatter["zotero_key"] == "ABC12345"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PaperWorkspace tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPaperWorkspace:
|
||||
def test_has_all_fields(self, resolver_with_index):
|
||||
ws = resolver_with_index.resolve_key("ABC12345")
|
||||
assert ws is not None
|
||||
assert ws.key == "ABC12345"
|
||||
assert ws.title
|
||||
assert ws.domain
|
||||
assert ws.year
|
||||
assert ws.authors
|
||||
assert ws.doi
|
||||
assert ws.journal
|
||||
assert ws.formal_note_path
|
||||
assert ws.ocr_status == "done"
|
||||
assert ws.deep_reading_status == "pending"
|
||||
assert ws.analyze is True
|
||||
assert ws.do_ocr is True
|
||||
assert ws.has_pdf is True
|
||||
|
||||
def test_ocr_path_when_ocr_dir_exists(self, resolver_with_index):
|
||||
ws = resolver_with_index.resolve_key("ABC12345")
|
||||
assert ws is not None
|
||||
assert ws.ocr_path
|
||||
assert "ABC12345" in ws.ocr_path
|
||||
|
||||
def test_frontmatter_loaded(self, resolver_with_index):
|
||||
ws = resolver_with_index.resolve_key("ABC12345")
|
||||
assert ws is not None
|
||||
fm = ws.frontmatter
|
||||
assert fm["zotero_key"] == "ABC12345"
|
||||
assert fm["domain"] == "骨科"
|
||||
assert fm["title"] == "TGF-beta in Bone Regeneration"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Edge cases
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEdgeCases:
|
||||
def test_empty_index(self, empty_resolver):
|
||||
empty_resolver._ensure_loaded()
|
||||
assert empty_resolver.resolve_key("ABC12345") is None
|
||||
assert empty_resolver.resolve_doi("10.1234/test") is None
|
||||
assert empty_resolver.search(title="anything") == []
|
||||
|
||||
def test_authors_as_list(self, resolver_with_index):
|
||||
ws = resolver_with_index.resolve_key("ABC12345")
|
||||
assert ws is not None
|
||||
assert "Smith" in ws.authors
|
||||
assert "Lee" in ws.authors
|
||||
|
||||
def test_missing_optional_fields(self, resolver_with_index):
|
||||
items = resolver_with_index._items
|
||||
extra = _make_index_entry(
|
||||
key="XXX00000",
|
||||
title="Minimal Paper",
|
||||
domain="测试",
|
||||
year="",
|
||||
authors="",
|
||||
doi="",
|
||||
note_path="Literature/测试/XXX00000 - Minimal Paper.md",
|
||||
)
|
||||
items.append(extra)
|
||||
ws = resolver_with_index.resolve_key("XXX00000")
|
||||
assert ws is not None
|
||||
assert ws.year == ""
|
||||
assert ws.doi == ""
|
||||
Loading…
Reference in a new issue