diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1bb8b6ea..0dccc57d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,7 +52,13 @@ jobs: - name: Run unit tests shell: bash run: | - python -m pytest tests/unit/ \ + python -m pytest tests/ \ + --ignore=tests/sandbox \ + --ignore=tests/cli \ + --ignore=tests/e2e \ + --ignore=tests/journey \ + --ignore=tests/chaos \ + --ignore=tests/audit \ -v --tb=short --timeout=60 # --------------------------------------------------------------------------- diff --git a/.opencode/skills/paperforge/SKILL.md b/.opencode/skills/paperforge/SKILL.md deleted file mode 100644 index d26e759c..00000000 --- a/.opencode/skills/paperforge/SKILL.md +++ /dev/null @@ -1,191 +0,0 @@ ---- -name: paperforge -description: > - Research Memory Runtime — 文献搜索、精读、问答、阅读笔记、 - 工作记录、方法论提取。Triggered by: - pf-deep pf-paper pf-sync pf-ocr pf-status, - "精读" "找文献" "搜文献" "文献问答" "读一下" "看看这篇" - "讨论" "记录阅读" "记录工作" "总结会话" "提取方法论" - "记一下" "保存这次" "找证据" "找75 Hz" "找支持" "collection" "库里". -source: paperforge -version: 1.5.9 ---- - -# PaperForge — Research Memory Runtime - -PaperForge 将文献、阅读痕迹、工作过程、方法论和产物 -组织成可检索、可复核、可由 agent 调用的研究记忆。 - ---- - -## 1. Bootstrap — 必须先执行 - -```bash -python $SKILL_DIR/scripts/pf_bootstrap.py --vault "$VAULT" -``` - -返回 JSON。记录以下变量(所有 molecule 文件继承,不再重复声明): - -| 变量 | JSON 字段 | 用途 | -| ------------- | ----------------------- | ------------------------------ | -| `$VAULT` | `vault_root` | 所有 `--vault` 参数 | -| `$PYTHON` | `python_candidate` | 所有 `python -m paperforge` 调用 | -| `$LIT_DIR` | `paths.literature_dir` | 文献笔记根目录 | -| `$SKILL_DIR` | 平台注入 | 脚本路径 | -| `$METHODS` | `methodology_index` | 可用方法论索引 | - -如果 `ok: false`,报告 `error` 给用户,**停止。禁止自己拼路径。** - -如果 `python_verified` 为 `false` 或 `python_candidate` 为 `null`: -依次尝试 `python` 再 `python3`。全部失败则停止,提示用户在 `paperforge.json` 中设置 `python_path`。 - -bootstrap 现在也返回一个 `capabilities` 块(rg, semantic, metadata 等)。 - -## 1a. Pre-flight Checklist - -**进入任何 molecule 之前,必须完成以下检查。标记为 `[x]` 后才可前进。** - -- [ ] **bootstrap completed**:`$VAULT`、`$PYTHON`、`$LIT_DIR`、`$SKILL_DIR` 已定义 -- [ ] **version match**:`$PYTHON -m paperforge --version` 输出的版本号与 SKILL.md frontmatter 的 `version` 字段一致;不一致时提示用户运行 `paperforge update` -- [ ] **capabilities**:已读取 `capabilities` 块中的 `rg`、`metadata_search`、`paper_context`、`semantic_enabled`、`semantic_ready` -- [ ] **runtime-health passed**:`safe_read`、`safe_write` 已知,vector 状态已知 -- [ ] **intent identified**:用户意图已映射到一个顶层 research intent -- [ ] **molecule selected**:已路由到对应的 `molecules/.md` - -如果任一检查未通过,先执行对应的步骤,**不跳过**。 - ---- - -## 2. Agent Context — bootstrap 成功后执行 - -```bash -$PYTHON -m paperforge --vault "$VAULT" agent-context --json -``` - -返回 library overview、collection tree、可用命令和规则。Agent 注入为会话上下文。 - ---- - -## 3. Runtime Health — compound 启动原子 - -在 compound 启动链中,只执行一次: - -```text -bootstrap -> agent-context -> runtime-health -> route -> molecule -``` - -执行命令: - -```bash -$PYTHON -m paperforge --vault "$VAULT" runtime-health --json -``` - -检查返回 JSON: - -- `data.summary.safe_read == false`:禁止路由到 discover-papers、read-known-paper、deep-analyze-paper -- `data.summary.safe_write == false`:禁止路由到 write-reading-log-jsonl、write-project-reading-log、write-project-log -- `data.layers.vector.status != "ok"`:禁止把 semantic retrieve 当主路径,必要时退回 FTS / paper-context / fulltext -- `data.layers.*.repair_command` 存在时,优先把该命令作为修复建议返回给用户 - -一旦 runtime-health 通过,后续 molecule 继承该状态,**不要在每个 molecule 里重复跑 preflight**。 - -Dashboard 的 `System Status` 只是这个 contract 的薄展示,不是第二套真相源。 - ---- - -## 4. 意图路由 - -用户输入按以下顺序判定并路由到 molecule。 - -### A. 机械命令(不经过 research intent routing) - -| 用户说 | 动作 | -| ------------- | ---------------------------------------------------------- | -| `/pf-sync` | 执行 `paperforge sync`,解释结果 | -| `/pf-ocr` | 执行 `paperforge ocr`,解释结果 | -| `/pf-status` | 执行 `paperforge status --json` 或 `runtime-health --json` | - -### B. 研究命令别名 - -| 用户说 | 路由到的 intent | -| ----------- | ---------------------------- | -| `/pf-deep` | `deep_analyze_paper` | -| `/pf-paper` | `read_known_paper` | - -### C. 顶层研究意图(按判定顺序) - -1. **`capture_project_knowledge`** — 直接保存/归档/提取(用户说"记一下"、"保存这次"、"提取方法论") -2. **`read_known_paper`** — 用户给定了明确的 paper(key/DOI/标题) -3. **`discover_papers`** — 用户要找一批论文("找XX的文章"、"collection里有什么") -4. **`find_supporting_evidence`** — 用户要找具体证据/参数/术语("找75 Hz"、"找支持这句话的依据") -5. **`deep_analyze_paper`** — 用户明确要精读("/pf-deep") - -**判定顺序(必须严格按此顺序执行):** - -```text -0. 处理机械命令(A 节) -1. 处理别名(B 节)→ deep_analyze_paper / read_known_paper -2. 如果用户要求保存/归档/从上下文提取 → capture_project_knowledge -3. 如果用户已指向单一论文 → read_known_paper -4. 如果用户想要论文列表 → discover_papers -5. 如果用户想要证据/支持 → find_supporting_evidence -6. 如果意图无法判定 → 打开 atoms/clarify-user-intent.md - 注意:如果多个 intent 同时匹配(如"找这篇的证据然后保存"),**除非某个 intent 明显主导**,否则也走 clarify;不要硬猜。 -7. post-action:molecule 输出后,如果用户要求保存 → capture_project_knowledge -``` - -### D. Clarify 回退 - -- 不能稳定判定 intent 时,打开 `atoms/clarify-user-intent.md` -- 最多两轮 - -未知或拼错的 `/pf-*` 命令不要静默掉进 `project-engineering`,必须明确提示用户命令不存在或请澄清意图。 - ---- - -## 5. Reading-Log Safety Rule — 全局规则,所有 workflow 必须遵守 - -Reading-log 不是事实源。它记录的是**之前的关注点、解读和预期用途**。 - -当存在 prior reading-log 时: -1. 用它决定**优先复查什么**,不是用它回答用户问题 -2. 重新打开**原文/图表/表格**,核实之前的解读 -3. 确认的,说明"已回原文复核" -4. 被推翻的,创建 correction note -5. **绝对禁止**仅根据 reading-log 内容回答事实性问题 - ---- - -## 6. 全局禁止规则 - -- **禁止自行拼接文件路径**。所有路径从 bootstrap 或 paper-context 获取。 -- **禁止绕过 CLI 直接操作文件**。搜索用 `$PYTHON -m paperforge search`,不用 glob/grep 扫库。 -- **禁止在未完成 paper-context 检查前读取原文**(适用于 read-known-paper、deep-analyze-paper)。 - ---- - -## 文件结构 - -``` -paperforge/skills/paperforge/ -├── SKILL.md ← 本文件(compound:启动注入 + 路由 + 全局规则) -├── molecules/ ← 1 molecule = 1 intent 的完整执行流程 -│ ├── read-known-paper.md -│ ├── discover-papers.md -│ ├── find-supporting-evidence.md -│ ├── deep-analyze-paper.md -│ └── capture-project-knowledge.md -├── atoms/ ← 可复用子步骤 -│ ├── clarify-user-intent.md -│ ├── retrieval-routing.md -│ ├── write-reading-log-jsonl.md -│ ├── write-project-reading-log.md -│ ├── write-project-log.md -│ ├── extract-methodology-card.md -│ └── chart-reading/ -├── scripts/ -│ ├── pf_bootstrap.py -│ └── pf_deep.py -└── workflows/ - └── project-engineering.md -``` diff --git a/.opencode/skills/paperforge/atoms/.gitkeep b/.opencode/skills/paperforge/atoms/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/.opencode/skills/paperforge/atoms/chart-reading/.gitkeep b/.opencode/skills/paperforge/atoms/chart-reading/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/.opencode/skills/paperforge/atoms/chart-reading/GSEA富集图.md b/.opencode/skills/paperforge/atoms/chart-reading/GSEA富集图.md deleted file mode 100644 index f7885bcf..00000000 --- a/.opencode/skills/paperforge/atoms/chart-reading/GSEA富集图.md +++ /dev/null @@ -1,78 +0,0 @@ -# GSEA 富集图 - -## 适用场景 - -展示基因集富集分析(Gene Set Enrichment Analysis)结果,说明某一基因集合(如通路、功能类别)在排序后的基因列表中是否富集于顶端或底端。常用于转录组、蛋白质组差异表达下游分析。 - -## 读图维度 - -### 1. 排序指标与排序逻辑 - -- **基因排序依据**: - - Signal2Noise ratio?logFC?t-statistic?Correlation? - - 排序方向:高表达→低表达?病例→对照? -- **排序方向含义**: - - 左侧(顶端)= 在 Case 中高表达 / 正相关 - - 右侧(底端)= 在 Control 中高表达 / 负相关 - -### 2. 富集曲线 (Enrichment Plot) - -- **曲线形态**: - - 高峰靠左 = 基因集富集于排序列表顶端(Case-associated) - - 高峰靠右 = 富集于底端(Control-associated) - - 平坦 = 无显著富集 -- **峰值位置**: - - 峰值在哪个分位数?(ES 最大点) - - 峰值是否陡峭?(富集程度强 vs 弱) - -### 3. 核心统计量 - -- **ES (Enrichment Score)**: - - 标准化 ES (NES) 范围通常在 -3 到 +3 - - |NES| > 1 通常被认为有意义(软件默认阈值) - - 但作者使用的实际阈值是什么? -- **p-value / FDR (q-value)**: - - q < 0.05 还是 q < 0.25?(GSEA 默认用较宽松的 0.25) - - 是否经过多重检验校正? -- **Leading Edge**: - - 哪些基因贡献了主要富集信号? - - Leading edge 基因数 / 基因集总基因数 = ? - -### 4. 基因位置分布 - -- **中间条的竖线**: - - 每个竖线代表该基因集中的基因在排序列表中的位置 - - 竖线是否集中在左侧/右侧?还是均匀分布? -- **基因集大小**: - - 总基因数是否在合理范围(15-500)? - - 过小(<15)或过大(>500)的基因集结果不可靠 - -### 5. 热图条带 - -- **热图含义**: - - 通常表示所有基因的表达谱(红=高表达,蓝=低表达) - - 或表示基因与表型的相关性 -- **观察要点**: - - 热图颜色在 Leading Edge 区域是否一致? - - 是否呈现清晰的分层? - -## 常见陷阱 - -| 陷阱 | 识别方法 | 风险 | -|------|---------|------| -| **宽松FDR阈值** | q<0.25 作为显著性 | 大量假阳性通路,需要 q<0.05 | -| **基因集大小不当** | 看基因集总基因数 | 过小缺乏统计力,过大失去特异性 | -| **排序指标选择偏差** | 检查 ranking metric | 不同指标可能产生不同结果 | -| **Permutation次数不足** | nPerm < 1000 | p值不稳定,结果不可重复 | -| **多重数据库扫描** | 同时测试数百个基因集 | 即使 q-value 校正,仍可能过拟合 | - -## 关键问题模板 - -1. "GSEA 使用 q<0.25 作为阈值,如果收紧到 q<0.05,该通路是否仍然显著?" -2. "富集曲线峰值靠左,但 Leading Edge 基因只占基因集的 20%,富集信号是否主要由少数基因驱动?" -3. "排序使用的是 Signal2Noise,如果改用 logFC 或 t-statistic,该通路是否仍然富集?" -4. "该基因集包含 400+ 基因,是否过大导致失去生物学特异性?" - -## 一句话总结 - -> GSEA 图的核心是**"看排序、看曲线、看Leading Edge、防宽松阈值"**——富集曲线的峰值位置和陡峭度比单纯的 p 值更能说明问题。 diff --git a/.opencode/skills/paperforge/atoms/chart-reading/INDEX.md b/.opencode/skills/paperforge/atoms/chart-reading/INDEX.md deleted file mode 100644 index a9567465..00000000 --- a/.opencode/skills/paperforge/atoms/chart-reading/INDEX.md +++ /dev/null @@ -1,25 +0,0 @@ -# Chart-Reading Guide Index - -> 按生物医学文献中的常见度排序。每个指南包含该图类型的阅读方法、关键审查问题、以及常见陷阱。 - -| # | 图表类型 | 处理的主要图表类型 | 指南文件 | -|---|---------|-------------------|---------| -| 1 | 条形图与误差棒 | 柱状图、条形图、误差棒 | [[条形图与误差棒]] | -| 2 | 森林图与Meta分析 | 森林图、Meta分析 | [[森林图与Meta分析]] | -| 3 | 折线图与时间序列 | 折线图、时间序列图 | [[折线图与时间序列]] | -| 4 | 散点图与气泡图 | 散点图、气泡图 | [[散点图与气泡图]] | -| 5 | ROC与PR曲线 | ROC曲线、PR曲线 | [[ROC与PR曲线]] | -| 6 | 生存曲线 | Kaplan-Meier生存曲线 | [[生存曲线]] | -| 7 | 箱式图与小提琴图 | 箱式图、小提琴图 | [[箱式图与小提琴图]] | -| 8 | 热图与聚类图 | 热图、聚类热图、相关性矩阵、树状图 | [[热图与聚类图]] | -| 9 | 桑基图与弦图 | 桑基图、弦图、流程图 | [[桑基图与弦图]] | -| 10 | 火山图与曼哈顿图 | 火山图、曼哈顿图 | [[火山图与曼哈顿图]] | -| 11 | 网络图与通路图 | 网络图、通路图、富集网络 | [[网络图与通路图]] | -| 12 | GSEA富集图 | GSEA富集分析图 | [[GSEA富集图]] | -| 13 | 降维图(PCA-tSNE-UMAP) | PCA、t-SNE、UMAP降维图 | [[降维图(PCA-tSNE-UMAP)]] | -| 14 | 雷达图与漏斗图 | 雷达图、漏斗图 | [[雷达图与漏斗图]] | -| 15 | 组织学半定量图 | 组织学染色评分图 | [[组织学半定量图]] | -| 16 | 免疫荧光定量图 | 免疫荧光定量分析图 | [[免疫荧光定量图]] | -| 17 | 显微照片与SEM图 | 显微照片、SEM电镜图 | [[显微照片与SEM图]] | -| 18 | Western Blot条带图 | Western Blot条带图 | [[Western Blot条带图]] | -| 19 | 蛋白质结构图 | 蛋白质3D结构图、结构域图 | [[蛋白质结构图]] | diff --git a/.opencode/skills/paperforge/atoms/chart-reading/ROC与PR曲线.md b/.opencode/skills/paperforge/atoms/chart-reading/ROC与PR曲线.md deleted file mode 100644 index ce4023a8..00000000 --- a/.opencode/skills/paperforge/atoms/chart-reading/ROC与PR曲线.md +++ /dev/null @@ -1,66 +0,0 @@ -# ROC 与 PR 曲线阅读指南 - -## 适用场景 -- 评估分类/诊断模型的性能 -- 比较不同模型/特征的区分能力 -- 选择最优分类阈值 - ---- - -## 核心阅读维度 - -### 1. AUC / AUROC -- **数值解读**: - - 0.5 = 随机猜测(对角线) - - 0.7-0.8 = 可接受 - - 0.8-0.9 = 优秀 - - >0.9 = 杰出(但也可能是过拟合信号) -- **置信区间**:是否报告了95% CI?CI窄 = 估计精确;CI宽 = 样本小或不稳定 -- **与基线比较**:是否优于现有方法?差异是否有统计学意义?(DeLong检验) - -### 2. PR-AUC(Precision-Recall) -- **什么时候更重要**:当正负样本极度不平衡时(如罕见病诊断),PR曲线比ROC更有信息量 -- **基线**:PR曲线的基线 = 正样本比例(如正样本5%,则基线AP=0.05) -- **解读**:AP(Average Precision)> 基线多少?AP高但ROC AUC低 = 模型对正样本识别好,但假阳性多 - -### 3. 最优截断点 -- **Youden指数**:Sensitivity + Specificity - 1 的最大值点 -- **临床场景决定**: - - 筛查(如癌症早筛):优先高Sensitivity(不漏诊),容忍低Specificity - - 确诊(如手术决策):优先高Specificity(不误诊),容忍低Sensitivity -- **作者的选择**:是否根据临床需求选择了截断点?还是只用默认0.5? - -### 4. 曲线形态 -- **ROC左上角凸起**:越好(高Sensitivity + 高Specificity同时达到) -- **PR右上角凸起**:越好(高Precision + 高Recall同时达到) -- **交叉曲线**:两条曲线交叉 = 一个模型在某些阈值更好,另一个在其他阈值更好 -- **平台期**:曲线在某段平台 = 改变阈值对该区间性能影响小 - -### 5. 验证策略 -- **训练集 vs 测试集**:ROC是否在测试集上报告?(训练集ROC无意义,必定 inflate) -- **交叉验证**:是否用k-fold CV?折数多少?(k=5或10标准) -- **外部验证**:是否在独立数据集上验证?(金标准) -- **时间验证**:如果是时序数据,是否用未来数据验证? - ---- - -## 常见陷阱 - -| 陷阱 | 说明 | 如何识别 | -|------|------|---------| -| 数据泄露 | 测试集信息用于特征选择/模型调参 | 问:特征选择是否在交叉验证循环内? | -| 阈值报告偏差 | 只报告最优阈值,忽略实际应用阈值 | 看是否说明了阈值选择依据 | -| 不平衡数据用ROC | 正样本<5%时ROC AUC可能虚高 | 问:是否同时报告了PR曲线? | -| 过拟合 | 训练集AUC=0.99,测试集AUC=0.75 | 比较训练/测试性能差距 | -| 置信区间缺失 | AUC=0.85但无CI,无法判断可靠性 | 看是否报告95% CI或标准误 | - ---- - -## 批判性提问模板 - -1. "AUC是在训练集还是独立测试集上计算的?" -2. "如果数据不平衡,是否同时报告了PR-AUC?" -3. "最优截断点的选择依据是什么?是否符合临床场景?" -4. "置信区间宽度如何?样本量是否足够?" -5. "与现有方法比较时,是否用了DeLong检验?" -6. "验证策略是什么?k-fold CV?外部验证?时间验证?" diff --git a/.opencode/skills/paperforge/atoms/chart-reading/Western Blot条带图.md b/.opencode/skills/paperforge/atoms/chart-reading/Western Blot条带图.md deleted file mode 100644 index cc0f3ead..00000000 --- a/.opencode/skills/paperforge/atoms/chart-reading/Western Blot条带图.md +++ /dev/null @@ -1,76 +0,0 @@ -# Western Blot 条带图 - -## 适用场景 - -Western Blot(免疫印迹)条带的半定量分析,包括磷酸化蛋白检测、总蛋白表达、翻译后修饰(PTM)分析。是生物医学研究中蛋白层面最常用的验证方法。 - ---- - -## 核心阅读维度 - -### 1. 内参(Loading Control)选择与验证 - -| 内参类型 | 常用蛋白举例 | 适用场景 | 风险 | -| ---------------- | ------------------------- | --------------------------------- | --------------------------------- | -| 管家蛋白 | GAPDH、β-Actin、β-Tubulin | 普通全细胞裂解液 | 可能受实验条件调控,不稳定 | -| 总蛋白染色 | Ponceau S、Coomassie | 磷酸化蛋白等 PTM 研究 | 更可靠但定量精度稍低 | -| 上样量控制 | BCA/Braford 定量后等量上样 | 所有WB | 只能控制总蛋白量,不能控制转膜效率 | - -**关键检查点**: -- 管家蛋白是否在所有实验条件下表达稳定?(处理组 vs 对照组) -- 是否验证了内参在线性范围内?(做了稀释曲线) -- 磷酸化蛋白是否用了总蛋白或胞浆/胞核分馏的内参,而非全细胞管家蛋白? - -### 2. 条带完整性检查 - -- **裁剪规范**:同一膜上是否清晰标注了裁剪分界线?(磷酸化条带和总蛋白条带来自同一膜的不同抗体剥离/重孵育是常规做法,但必须标注) -- **非相邻泳道拼接**:同一膜上不相邻泳道被拼接到一起时,是否有明确分界线/空格标注 -- **曝光均匀性**:整张膜的背景是否均匀?边缘泳道是否与中间泳道曝光一致? - -### 3. 定量归一化方式 - -``` -归一化值 = (目标条带强度 - 背景) / (内参条带强度 - 背景) -``` - -- **必须确认**:归一化是 Target / Loading Control,还是先归一化到对照组再计算 fold change? -- **内参一致性**:内参条带强度在所有泳道中是否相近(CV<15%)?如果差异过大,内参本身不可靠 - -### 4. 线性范围与饱和检测 - -- **饱和信号**:条带纯白(像素值=255)= 饱和,数据不可用 -- **线性范围**:信号强度应与上样量成比例;过饱和导致定量偏低 -- **检测方法**:是否做了浓度梯度(dilution series)验证线性范围? -- **多张膜比较**:如果目标条带和内参来自不同膜,是否有交叉参照(bridging)? - -### 5. 背景扣除与 ROI 设置 - -- **背景region**:使用膜上无蛋白区域的平均强度,而非纯白背景 -- **ROI一致性**:所有条带使用相同大小的矩形/自由形状选区 -- **局部背景**:每个条带附近扣除各自的局部背景,而非全膜平均背景 - ---- - -## 常见陷阱 - -| 陷阱 | 说明 | 如何识别 | -| ---------------------- | -------------------------------------------- | -------------------------------------- | -| **内参不稳定** | 管家蛋白在不同处理组间表达差异显著 | 检查内参条带灰度是否在所有泳道中相近 | -| **饱和条带定量** | 曝光过曝导致条带变白,灰度值被截断 | 检查条带是否纯白、是否有光晕 | -| **膜面不均导致假差异** | 转膜时泳道边缘效应造成中间vs边缘信号差异 | 检查同一膜不同区域的信号是否一致 | -| **条带裁剪误导** | 同一膜不同区域拼接到一起,假装是不同条件 | 寻找膜上泳道间距/背景的不自然断裂 | -| **过曝掩盖阴性** | 某处理组信号强到过曝,无法与真正阴性区分 | 检查所有条带是否都在线性范围内 | -| **多重比较未校正** | 3个以上处理组的WB,用独立t检验而不用ANOVA | 检查统计方法部分 | - ---- - -## 关键问题模板 - -1. "内参(管家蛋白)的表达是否真的稳定?有没有独立验证其在实验条件下的稳定性?" -2. "所有条带是否都在相机/底物的线性动态范围内?有没有饱和条带被纳入定量?" -3. "归一化方法是 Target/内参 还是其他?不同膜之间如何校正?" -4. "条带是否来自同一张膜的同一次转印?是否有拼接?是否有非相邻泳道的裁剪?" - -## 一句话总结 - -> WB 条带的核心是**"查内参、防饱和、验线性、看裁剪"**——条带灰度只是数字,背后的内参稳定性、线性范围和膜面均匀性才是定量可信度的根基。 diff --git a/.opencode/skills/paperforge/atoms/chart-reading/免疫荧光定量图.md b/.opencode/skills/paperforge/atoms/chart-reading/免疫荧光定量图.md deleted file mode 100644 index 6759b4ed..00000000 --- a/.opencode/skills/paperforge/atoms/chart-reading/免疫荧光定量图.md +++ /dev/null @@ -1,69 +0,0 @@ -# 免疫荧光定量图 - -## 适用场景 - -免疫荧光(IF)、共聚焦显微镜、定量荧光显微图像分析。常见于蛋白定位、共定位分析、细胞计数、荧光强度定量。是细胞生物学和分子病理学的核心可视化手段。 - ---- - -## 核心阅读维度 - -### 1. 共定位系数(Colocalization Coefficients) - -| 系数名称 | 公式/含义 | 取值范围 | 适用场景 | -| ------------------------- | ------------------------------------------ | -------- | ------------------------------------- | -| **Pearson 相关系数 (rP)** | 协方差标准化,衡量线性相关 | -1 到 +1 | 两通道信号密度相近时最可靠 | -| **Manders M1 / M2** | 通道1/通道2 中共定位像素占总像素的比例 | 0 到 1 | 两通道信号强度差异大时使用 | -| **Manders Overlap (R)** | 实际重叠与最大可能重叠之比 | 0 到 1 | 通用共定位指标 | -| **Li's ICQ** | 基于像素强度相关性的离散度量化 | -0.5 到 +0.5 | 介于 Pearson 和 Manders 之间 | - -**关键解释**: -- M1 = 通道1 中与通道2 共定位的信号 / 通道1 总信号 -- M2 = 通道2 中与通道1 共定位的信号 / 通道2 总信号 -- M1 ≠ M2 时,说明两蛋白的表达量不同但空间重叠 - -### 2. 图像采集质量控制 - -- **Sequential scanning**:双通道或多通道必须用顺序扫描(而非同时激发),以消除串扰(cross-talk) -- **光路校准**:两种荧光通道的发射光谱是否有重叠?是否有 spectral bleed-through? -- **ROI 选择**:使用 Lasso 工具选择包含荧光信号的区域,排除无荧光背景 -- **阈值设定**:是否使用了自动或手动的强度阈值排除背景噪声? - -### 3. 荧光强度定量 - -- **平均荧光强度**(Mean Intensity):总荧光强度 / 细胞数或面积 -- **阳性率**(% Positive):荧光强度超过阈值的细胞占总细胞数的比例 -- **比值法**:如 p-FOXO3a / Total FOXO3a,需要双通道染色 -- **细胞计数**:DAPI 计数总细胞,特定标记阳性细胞 = 阳性率 - -### 4. 伪彩色与展示规范 - -- 伪彩色(Fire、LUT)是否正确标注了颜色条? -- 不同通道的对比度/亮度调整是否分别进行?(不能对单通道单独调亮) -- 图像是否经过去噪、反卷积等处理?处理参数是否报告? - ---- - -## 常见陷阱 - -| 陷阱 | 说明 | 如何识别 | -| -------------------- | ---------------------------------------------- | ---------------------------------- | -| **串扰未消除** | 两通道同时激发导致光谱重叠,虚假共定位 | 检查是否 sequential scan | -| **阈值主观设定** | 不同图像用不同阈值,导致假阳性/阴性 | 阈值是否在所有图像中一致?是否有自动算法? | -| **内参不稳定** | 用 GAPDH 或 Actin 做荧光定量内参 | 荧光定量通常不用内参,应报告总蛋白或细胞数 | -| **ROI 选区偏倚** | 只选荧光强的区域,排除弱信号 | 检查是否全视野分析还是选择性分析 | -| **过度处理图像** | 去噪/反卷积过度导致信号失真 | 要求原始未处理图像(raw data) | -| **噪声被当信号** | 弱阳性但未设阈值,背景噪声被计入荧光强度 | 检查信噪比和阈值设定 | - ---- - -## 关键问题模板 - -1. "共定位分析用的是 Pearson 相关系数还是 Manders 系数?两个系数的物理含义分别是什么?" -2. "图像采集是否用了 sequential scanning 以消除通道间串扰?" -3. "荧光强度定量是用了哪种指标(平均强度、峰值强度、阳性率)?如何排除背景噪声?" -4. "是否有光谱重叠(bleed-through)问题导致假性共定位?是否做了单通道对照确认?" - -## 一句话总结 - -> 免疫荧光的核心是**"溯光源、看共定位、防串扰、查阈值"**——荧光图像的美观不等于定量可靠,串扰和阈值是最容易被忽视的两大杀手。 diff --git a/.opencode/skills/paperforge/atoms/chart-reading/折线图与时间序列.md b/.opencode/skills/paperforge/atoms/chart-reading/折线图与时间序列.md deleted file mode 100644 index cb39599b..00000000 --- a/.opencode/skills/paperforge/atoms/chart-reading/折线图与时间序列.md +++ /dev/null @@ -1,84 +0,0 @@ -# 折线图与时间序列 - -## 适用场景 - -展示随时间变化的连续数据,如药物释放曲线、浓度-时间曲线、动力学监测、累积响应。常见于药物递送、组织工程、药代动力学实验。 - ---- - -## 核心阅读维度 - -### 1. 释放模型识别 - -| 模型 | 方程 | 特征 | 适用场景 | -| ------------ | --------------------------------------- | --------------------------------- | ------------------------------- | -| 零级(Zero-order) | Qt = Q0 + K0t | 直线;释放速率与浓度无关 | 渗透泵、控释制剂 | -| 一级(First-order) | Ln(Q0 - Qt) = LnQ0 - K1t | 指数衰减;速率与剩余量成正比 | 水溶性药物从多孔基质 | -| Higuchi | Qt = KH × t^0.5 | 平方根时间关系;扩散控制 | 从矩阵扩散的药物 | -| Korsmeyer-Peppas | Mt/M∞ = K × t^n | 幂律模型;n 决定释放机制 | 聚合物水凝胶、纤维、薄膜 | -| Weibull | Mt/M∞ = 1 - exp[-(t-Ti)/λ]^m | S 型曲线;Ti=延迟时间,λ=尺度参数 | 复杂释放机制 | - -### 2. Korsmeyer-Peppas 释放指数 n 的解释(圆柱形体系) - -| n 值 | 释放机制 | -| ----------------- | ------------------------------- | -| n < 0.45 | Fick 扩散(纯扩散) | -| 0.45 < n < 0.89 | 异常传输(扩散+溶胀协同) | -| n = 0.45 | 菲克扩散(Case I) | -| 0.45 < n < 0.89 | 非菲克扩散(异常传输) | -| n > 0.89 | Case-II 传输(溶胀控制) | - -> 注意:上述 n 值仅对圆柱形几何(如片剂、纤维)有效。不同几何形状的临界值不同。 - -### 3. 突释效应(Burst Release) - -- **定义**:初始阶段药物快速释放,通常在 t=0 附近出现 -- **评估**:突释比例 = (M1 / M∞) × 100%,其中 M1 是第一个时间点的释放量 -- **问题**:突释过大(>30%)可能导致初期血药浓度过高,或导致药物浪费 -- **追问**:是否有突释?突释比例是多少?作者是否解释原因(表面药物、孔道效应)? - -### 4. 平台期与平衡 - -- **平台期识别**:曲线趋于平坦时,释放达到平衡或耗竭 -- **关键问题**: - - 平台期的释放百分比是多少?(60%?80%?) - - 是否达到 100%?(未达到 = 残留药物可能被困在基质中) - - 平台期持续多久?(长期缓释 vs 短效) - -### 5. 模型拟合质量 - -- **R² 拟合优度**:高 R²(>0.95)不代表模型正确,只能说明拟合紧密 -- **AIC/BIC 模型比较**:不同模型比较时用 AIC 或 BIC,不只用 R² -- **残差分析**:残差是否随机分布?(系统性残差 = 模型错误) -- **外推风险**:不要用模型外推至实验未覆盖的时间范围 - -### 6. 数据呈现完整性 - -- **原始数据点**:是否每个时间点都标注了独立样本(n=?)? -- **误差棒类型**:SD(离散度)vs SEM(均值精度)vs CI -- **时间点密度**:初期时间点是否足够密集?(突释阶段通常需要更密集的采样) - ---- - -## 常见陷阱 - -| 陷阱 | 说明 | 如何识别 | -| ------------------ | ------------------------------------------ | ---------------------------------- | -| **模型选择偏倚** | 用 R²最高的模型,但该模型物理意义不符 | 检查作者是否报告了多个模型及 AIC/BIC | -| **突释未量化** | 忽略初始快速释放阶段,只报告稳态释放 | 看 t=0 或第一个时间点的释放比例 | -| **假零级** | 曲线看似直线但实际是一级+扩散的叠加 | 检查不同浓度起始点的释放曲线是否平行 | -| **时间范围不充分** | 采样时间不足以判断是否达到平台期 | 观察曲线末端是否明显趋于平坦 | -| **误差棒类型混淆** | SEM 被标注为 SD,制造"精确"假象 | 方法或图注中是否注明误差棒类型 | - ---- - -## 关键问题模板 - -1. "释放曲线符合哪种模型?n(Korsmeyer-Peppas 指数)或 K( Higuchi 常数)的物理意义是什么?" -2. "是否存在突释效应?突释比例是多少?是否会影响临床疗效或安全性?" -3. "释放是否达到平台期?残余药物比例是多少?这对体内药效持续时间有什么暗示?" -4. "模型拟合的 R² 是否只是表面好看——残差是否有系统性偏差?换用其他模型是否更合理?" - -## 一句话总结 - -> 折线图的核心是**"辨模型、看突释、查平台、验残差"**——药物释放不是看曲线"漂亮",而是看机制是否自洽、平台是否有意义。 diff --git a/.opencode/skills/paperforge/atoms/chart-reading/散点图与气泡图.md b/.opencode/skills/paperforge/atoms/chart-reading/散点图与气泡图.md deleted file mode 100644 index eedd2c40..00000000 --- a/.opencode/skills/paperforge/atoms/chart-reading/散点图与气泡图.md +++ /dev/null @@ -1,87 +0,0 @@ -# 散点图与气泡图 - -## 适用场景 - -展示两个连续变量之间的关系,或增加第三个维度(气泡大小)展示多变量关联。常见于相关性分析、聚类结果可视化、剂量-反应关系。 - -## 读图维度 - -### 1. 坐标轴与变量定义 - -- **X/Y轴分别代表什么变量?** - - 是否为连续变量?单位是什么? - - 是否存在对数转换(如 log10、log2)? - - 轴范围是否被截断(truncated axis)? -- **变量关系类型**: - - 自变量→因变量(实验设计) - - 双向关联(观察性研究) - - 时间序列(X轴为时间) - -### 2. 数据点分布特征 - -- **整体趋势**: - - 线性 / 非线性 / 无明显趋势 - - 正相关 / 负相关 / U型 / 倒U型 -- **离散程度**: - - 点的密集区域在哪里? - - 是否存在明显分层(如病例 vs 对照)? -- **异常值**: - - 远离主群体的点是否被标注? - - 异常值是真实数据还是测量误差? - -### 3. 分组与着色 - -- **颜色/形状编码**: - - 不同颜色代表什么分组?(疾病亚型、处理组、时间窗) - - 是否使用渐变色表示连续变量? -- **分组间差异**: - - 各组是否呈现不同的分布模式? - - 是否存在组间重叠? - -### 4. 气泡图专属维度 - -- **气泡大小代表什么?** - - 样本量?统计显著性?效应量? - - 大小比例是否线性? -- **三维信息整合**: - - X/Y/Size 三个维度是否讲述一致的故事? - - 是否存在大样本量但效应小的" dominant "气泡? - -### 5. 辅助元素 - -- **回归线/趋势线**: - - 是否添加了拟合线?什么模型?(线性、LOESS、多项式) - - 置信区间(阴影区域)是否显示? -- **参考线**: - - 是否有阈值线(如 cutoff、基线)? - - 象限划分线(如 mean±SD)? - -### 6. 统计标注 - -- **相关系数**: - - Pearson r / Spearman ρ 值是多少? - - p-value 或 q-value 是否标注? -- **样本量**: - - n = ? 是否每组的 n 都足够? - -## 常见陷阱 - -| 陷阱 | 识别方法 | 风险 | -|------|---------|------| -| **过度绘制** (Overplotting) | 点过多重叠成"实心团" | 掩盖真实分布,看不出密度差异 | -| **假相关** | X和Y实际受第三方变量Z驱动 | 将伴随关系误读为因果关系 | -| **轴截断误导** | Y轴不从0开始 | 放大微弱差异,制造虚假趋势 | -| **气泡大小误导** | 面积 vs 半径编码混淆 | 读者对大小的感知被扭曲 | -| **选择性标注** | 只标注"有趣"的点 | 确认偏误,忽略不支持假设的数据 | -| **伪Replication** | 一个患者多个时间点作为独立点 | 违反独立性假设,虚增样本量 | - -## 关键问题模板 - -1. "散点图显示 X 和 Y 的相关系数为 r=0.XX,但散点分布呈漏斗形/曲线形,线性 Pearson 相关是否合适?" -2. "图中标注的离群点是否被作者排除?排除标准是什么?是否影响结论?" -3. "气泡大小代表样本量,最大的气泡是否主导了视觉印象,而小样本研究的结果可能更不稳定?" -4. "如果按第三个变量分层(如性别、年龄组),相关性是否仍然成立?" - -## 一句话总结 - -> 散点图的核心是**"看趋势、看离散、看分组、防过绘"**——确认变量关系形态,警惕视觉假象和伪相关。 diff --git a/.opencode/skills/paperforge/atoms/chart-reading/显微照片与SEM图.md b/.opencode/skills/paperforge/atoms/chart-reading/显微照片与SEM图.md deleted file mode 100644 index 98a0757e..00000000 --- a/.opencode/skills/paperforge/atoms/chart-reading/显微照片与SEM图.md +++ /dev/null @@ -1,81 +0,0 @@ -# SEM / TEM 图像 - -## 适用场景 - -扫描电子显微镜(SEM)和透射电子显微镜(TEM)获取的材料形貌、纳米结构、孔径分布、纤维形态等图像。常见于生物材料、组织工程支架、纳米药物递送、植入物表面改性研究。 - ---- - -## 核心阅读维度 - -### 1. 标尺(Scale Bar)与放大倍数 - -- **标尺必须存在**:所有publication-quality的显微图像必须包含标尺 -- **标尺长度选择**:标尺长度应约为图像宽度的 5-10%,过小难以辨认,过大遮挡视野 -- **放大倍数标注**:通常以 "scale bar = X μm" 或 "mag = 50,000×" 形式标注 -- **实际尺寸计算**:用标尺测量而非依赖仪器显示的放大倍数(仪器显示的放大倍数可能有5-10%误差) - -**SEM 放大倍数误差**:SEM 放大倍数通常有 5-10% 的系统误差,ISO 16700 标准规定了校准方法 -**TEM 放大倍数误差**:TEM 放大倍数误差可达 5-10%,需用衍射光栅或晶格标准品校准 - -### 2. 代表性图像原则 - -- **多视野原则**:是否展示了同一样本的多个视野(至少 3 个)? -- **统计报告**:图像数量 n=?(如 "n=5 independent images") -- **正负对照**:是否有代表性图像和阴性/异常图像的并列展示? - -### 3. 图像处理与增强 - -| 处理类型 | 可接受场景 | 不可接受场景 | -| ------------------- | ----------------------------------- | ----------------------------------- | -| 亮度/对比度调整 | 全图统一调整 | 只调整局部区域 | -| 伪彩色 | 假色SEM(用于高亮特定结构) | 掩盖真实对比度差异 | -| 图像拼接 | 明确标注拼接线 | 隐藏拼接痕迹 | -| 锐化/降噪 | 降噪不损失真实结构细节 | 锐化过度产生人工边缘 | - -### 4. SEM 特有检查项 - -- **荷电效应(Charging)**:图像中白色条纹/区域 = 荷电,信号失真 -- **焦深(DOF)**:高倍SEM焦深浅,图像是否清晰取决于是否在焦平面 -- **倾斜角度**:如果样本经过倾斜处理,实际形貌可能被扭曲 -- **加速电压**:5-20 kV 影响穿透深度和信号类型(SE vs BSE) - -### 5. TEM 特有检查项 - -- **电子衍射标定**:晶格条纹是否与标准数据库匹配? -- **样品厚度**:过厚样品导致对比度下降、晶格模糊 -- **辐照损伤**:生物样品在高电子剂量下可能受损(尤其 cryo-TEM) -- **光阑选择**:明场 vs 暗场,是否正确使用 - -### 6. 定量分析规范 - -- **测量工具**:ImageJ / Fiji 是标准工具,必须报告软件版本 -- **样本量**:至少测量 200 个颗粒/结构(ISO 13322-1:2004) -- **统计报告**:报告均值 ± SD / 中位数(IQR),并说明测量的结构数量 n -- **校准验证**:测量前是否用标准品(如 NIST SRM 8820)对仪器进行了校准验证 - ---- - -## 常见陷阱 - -| 陷阱 | 说明 | 如何识别 | -| ------------------ | ------------------------------------------ | ---------------------------------- | -| **标尺不准确** | 仪器显示放大倍数与实际不符 | 用标尺实际测量并与标注尺寸对比 | -| **单视野代表全貌** | 只展示一个最"好看"的图像 | 是否有 n≥3 的多视野重复 | -| **荷电当成结构** | 白色条纹区域是荷电假象,不是真实样品结构 | 检查图像背景是否均匀 | -| **图像过度处理** | 对比度/锐化过度,引入人工结构 | 要求原始未处理图像(TIFF) | -| **TEM 样品过厚** | 过厚样品导致晶格模糊、对比度差 | 图像整体发暗、晶格条纹不清晰 | -| **尺寸测量不报告n**| 只说"粒径约 50nm"但不报告测量了多少颗粒 | 方法中是否说明了统计的 n | - ---- - -## 关键问题模板 - -1. "标尺的实际长度是多少?是否与标注一致?仪器校准是否使用了 NIST 可追溯标准(如 SRM 8820)?" -2. "每张图像来自几个独立视野(n=?)?是否有多个样本/制备条件的重复?" -3. "图像是否经过处理(如对比度调整、伪彩色)?处理是否全图统一,还是只处理了局部?" -4. "SEM 图像中是否存在荷电效应(白色条纹/区域)?TEM 图像中样品厚度是否足以获得清晰晶格?" - -## 一句话总结 - -> 显微图像的核心是**"验标尺、查多野、审处理、核校准"**——漂亮的图像不等于可靠的数据,标尺准确性、样本代表性和仪器校准是三个生命线。 diff --git a/.opencode/skills/paperforge/atoms/chart-reading/条形图与误差棒.md b/.opencode/skills/paperforge/atoms/chart-reading/条形图与误差棒.md deleted file mode 100644 index 1bddb6c1..00000000 --- a/.opencode/skills/paperforge/atoms/chart-reading/条形图与误差棒.md +++ /dev/null @@ -1,81 +0,0 @@ -# 条形图与误差棒 - -## 适用场景 - -比较不同组别在某个指标上的均值/频数差异,带误差棒表示不确定性。常见于组间比较、时间趋势、分类变量分布。 - -## 读图维度 - -### 1. 条形含义与基准 - -- **条形高度代表什么?** - - 均值?中位数?频数?百分比? - - 如果是均值,原始数据分布是否对称? -- **Y轴起点**: - - 是否从0开始?(条形图必须从0开始!) - - 如果截断,差异被放大了多少倍? - -### 2. 误差棒解读 - -- **误差棒类型**: - - **SD**(标准差):数据离散程度 - - **SEM**(标准误):均值估计精度 = SD/√n - - **95% CI**(置信区间):统计推断范围 - - **IQR**(四分位距):非参数离散度 -- **误差棒重叠 ≠ 无显著差异** - - SEM 重叠不代表 p > 0.05 - - 必须看正式的统计检验结果 -- **误差棒长度暗示样本量** - - 相同 SD 下,n 越大 SEM 越小 - - 极小的误差棒可能暗示 n 很大或 SD 被低估 - -### 3. 分组结构 - -- **X轴分组逻辑**: - - 单因素分组?双因素交叉? - - 组间是否存在自然顺序(如时间、剂量)? -- **对照组设置**: - - 是否有合适的对照/基线? - - 对照组是否为"健康人"还是"安慰剂"? - -### 4. 统计显著性标注 - -- **星号/字母系统**: - - * p<0.05, ** p<0.01, *** p<0.001 - - 不同字母代表组间差异显著(如 a vs b) -- **多重比较校正**: - - 是否进行了 Bonferroni / FDR 校正? - - 未校正的多重 t 检验 inflate Type I error - -### 5. 条形图变体识别 - -- **堆叠条形图 (Stacked)**: - - 各部分之和是否有意义(如 100%)? - - 比较不同组的同一子类别是否困难? -- **分组条形图 (Grouped)**: - - 每组内各条的顺序是否一致? - - 视觉负担是否过大(>5组×5条件)? -- **水平条形图**: - - 标签过长时的解决方案 - - 阅读方向改变不影响解读逻辑 - -## 常见陷阱 - -| 陷阱 | 识别方法 | 风险 | -|------|---------|------| -| **Y轴不从0开始** | 检查Y轴最小值 | 微小差异被放大数倍,误导读者 | -| **SEM冒充SD** | 看误差棒图例标注 | SEM 总是更小,制造"精确"假象 | -| **双向条形图误导** | 正负方向同时延伸 | 读者对绝对值的比较被干扰 | -| **3D条形图** | 立体效果 | 深度扭曲高度感知,毫无意义 | -| **未校正的多重比较** | 大量星号但没有校正说明 | 假阳性率飙升 | - -## 关键问题模板 - -1. "误差棒标注为 mean±SEM,但样本量 n=5,用 SEM 是否故意缩小误差视觉效果?" -2. "条形图显示 A 组比 B 组高 30%,但 Y 轴从 50 开始而非 0,实际差异是否被夸大?" -3. "进行了 10 组两两比较,但 p 值未校正,标注的显著性是否可信?" -4. "堆叠条形图中,上层类别的变化是否被下层基线变化所掩盖?" - -## 一句话总结 - -> 条形图的核心是**"看基准、看误差、看校正、防截断"**——误差棒类型决定解读方式,Y轴起点决定差异真实度。 diff --git a/.opencode/skills/paperforge/atoms/chart-reading/桑基图与弦图.md b/.opencode/skills/paperforge/atoms/chart-reading/桑基图与弦图.md deleted file mode 100644 index 7ac58793..00000000 --- a/.opencode/skills/paperforge/atoms/chart-reading/桑基图与弦图.md +++ /dev/null @@ -1,88 +0,0 @@ -# 桑基图与弦图 - -## 适用场景 - -**桑基图 (Sankey Diagram)**:展示流量/数量的流向和分配,常见于患者诊疗路径、多组学数据整合、能量/物质流动、临床试验病例流转。 - -**弦图 (Chord Diagram)**:展示双向关系或配对关联的密度,常见于细胞互作、基因共表达模块间的共享基因、不同亚群间的迁移/转换关系。 - -## 读图维度 - -### 桑基图 - -#### 1. 节点与层级 - -- **节点代表什么?** - - 分类状态(如疾病分期、治疗阶段、分子亚型) - - 时间节点(如基线→3月→6月→12月) - - 数据类型(如基因组→转录组→蛋白组) -- **层级逻辑**: - - 从左到右是否有自然的时序或因果顺序? - - 是否可以逆流?(如治疗后复发回到前期状态) - -#### 2. 流量与宽度 - -- **线条宽度**: - - 与流量/数量成正比 - - 视觉上线条宽度是否可准确比较? -- **流量守恒**: - - 进入节点的总流量是否等于流出总流量? - - 如有损失(如失访、死亡),是否标注了"流失"路径? - -#### 3. 路径追踪 - -- **主要路径**: - - 最粗的线代表什么主流向? - - 是否存在多条并行的重要路径? -- **罕见路径**: - - 极细的线条是否代表真实的小比例事件? - - 还是被视觉压缩到几乎看不见? - -### 弦图 - -#### 1. 环形节点 - -- **圆周上的节点**: - - 代表什么实体?(细胞类型、基因模块、样本组) - - 节点弧长是否代表大小?(如细胞数量、模块基因数) -- **颜色编码**: - - 节点颜色是否代表分组/类别? - - 弦的颜色是源节点色、目标节点色、还是混合色? - -#### 2. 弦的解读 - -- **弦的粗细**: - - 代表两节点间关系强度(如配体-受体对数、共享基因数) - - 弦越粗 = 相互作用越强 -- **弦的方向**: - - 是否有方向性?(如从细胞A到细胞B的分泌关系) - - 还是纯无向关联? - -#### 3. 矩阵关系 - -- **邻接矩阵 vs 弦图**: - - 弦图是邻接矩阵的可视化 - - 如果弦图过于复杂,看原始矩阵是否更清晰? -- **自环 (Self-loop)**: - - 节点连接到自身的弦代表什么?(自分泌、模块内调控) - -## 常见陷阱 - -| 陷阱 | 识别方法 | 风险 | -|------|---------|------| -| **桑基图层级过多** | >4 层 | 线条交叉严重,无法追踪路径 | -| **弦图节点过多** | >20 个节点 | 视觉混乱,弦完全重叠 | -| **桑基流量未标注比例** | 只有绝对数 | 无法判断占比,需要同时看百分比 | -| **弦图过度解读方向** | 无方向数据强行加箭头 | 误导为因果关系 | -| **桑基隐藏关键流失** | 未显示失访/死亡分支 | 幸存者偏误,夸大疗效 | - -## 关键问题模板 - -1. "桑基图显示从诊断到治疗有 30% 的病例"流失",这些病例的临床特征是否与继续治疗的病例不同?是否存在选择偏倚?" -2. "弦图显示细胞类型 A 和 B 之间有大量互作,但这是基于单一数据集的预测,是否有独立的细胞实验验证?" -3. "桑基图的最粗路径是否代表了作者希望强调的"理想路径",而较细的路径实际上也很重要但被视觉弱化了?" -4. "弦图中模块 1 和模块 2 的共享基因有 50 个,但两个模块各自的基因数分别是 200 和 300,Jaccard 重叠度其实只有 10%,弦的粗细是否放大了关联感?" - -## 一句话总结 - -> 桑基图的核心是**"追路径、看守恒、防流失"**,弦图的核心是**"看强度、防混乱、验证方向性"**——流量可视化的真实度取决于未显示部分的透明度。 diff --git a/.opencode/skills/paperforge/atoms/chart-reading/森林图与Meta分析.md b/.opencode/skills/paperforge/atoms/chart-reading/森林图与Meta分析.md deleted file mode 100644 index 02d3b754..00000000 --- a/.opencode/skills/paperforge/atoms/chart-reading/森林图与Meta分析.md +++ /dev/null @@ -1,81 +0,0 @@ -# 森林图与 Meta 分析 - -## 适用场景 - -展示多个独立研究(或亚组分析)的效应量估计及其置信区间,底部汇总合并效应量。是 Meta 分析的标准可视化,也用于亚组分析、敏感性分析、多变量回归结果展示。 - -## 读图维度 - -### 1. 研究/亚组排列 - -- **左侧列标签**: - - 每个方块代表什么?(单个研究、亚组、或总体合并) - - 研究排列顺序:按发表时间?按效应量大小?按权重? -- **研究特征**: - - 每个研究的样本量(n)? - - 研究设计类型(RCT vs 观察性)? - - 随访时间、干预剂量等关键特征? - -### 2. 效应量与置信区间 - -- **效应量指标**: - - HR(风险比)、OR(比值比)、RR(相对风险)、MD(均值差)、SMD(标准化均值差) - - 效应量 = 1(或 0)线代表什么?(无效应线) -- **置信区间 (CI)**: - - 95% CI 是否跨越无效线?→ 该研究/亚组不显著 - - CI 宽度反映精度:宽 = 样本小/异质性大;窄 = 样本大/精度高 -- **菱形(汇总估计)**: - - 中心点 = 合并效应量 - - 宽度 = 95% CI - - 菱形是否跨越无效线?→ 总体是否显著 - -### 3. 权重分配 - -- **方块大小**: - - 通常与权重(Weight %)成正比 - - 大样本研究 = 大方块 = 对合并结果影响大 -- **权重合理性**: - - 固定效应模型 vs 随机效应模型权重不同 - - 是否有个别研究权重过高(>50%)? - -### 4. 异质性评估 - -- **I² 统计量**: - - 0-25%:低异质性;25-50%:中等;50-75%:高;>75%:很高 - - I² 高 → 研究间差异大,合并需谨慎 -- **Q 检验 (Cochran's Q)**: - - p < 0.05 表示存在显著异质性 -- **Tau²**: - - 随机效应模型中的异质性方差 -- **异质性可视化**: - - CI 是否广泛重叠?(重叠多 = 异质性低) - - 是否存在方向相反的研究? - -### 5. 发表偏倚线索 - -- **图形不对称性**: - - 小样本阴性结果是否缺失? - - 大样本研究是否聚集在真实效应附近? -- **Egger 检验 / 漏斗图**: - - 森林图本身不能检测发表偏倚,需配合漏斗图 - -## 常见陷阱 - -| 陷阱 | 识别方法 | 风险 | -|------|---------|------| -| **忽略异质性** | I² > 50% 仍做合并 | 合并结果无意义,亚组差异被掩盖 | -| **固定效应误用** | 高异质性用固定效应 | 低估标准误,假阳性 | -| **亚组分析过多** | 大量亚组,每组仅2-3个研究 | 假阳性,交互作用虚假 | -| **生态学谬误** | 用研究层面特征解释个体层面 | 因果推断层次混乱 | -| **合并不可比研究** | 不同设计、不同人群、不同干预 | Garbage in, garbage out | - -## 关键问题模板 - -1. "森林图显示 I² = 78%,作者仍报告合并 HR=0.85 (95%CI 0.75-0.96),高异质性下这个合并值是否还有临床意义?" -2. "最大权重的研究贡献了 60% 的权重,如果排除该研究,合并结果是否逆转?" -3. "亚组分析显示男性获益 (HR 0.7) 而女性不获益 (HR 1.0),交互检验 p=0.03 还是 p=0.2?差异是否真实?" -4. "所有小样本研究的 CI 都跨 1,只有最大样本的研究显示显著,是否存在小样本偏倚或发表偏倚?" - -## 一句话总结 - -> 森林图的核心是**"看方块、看CI、看异质、看权重"**——单个研究的精确度和总体异质性比合并点估计更重要。 diff --git a/.opencode/skills/paperforge/atoms/chart-reading/火山图与曼哈顿图.md b/.opencode/skills/paperforge/atoms/chart-reading/火山图与曼哈顿图.md deleted file mode 100644 index 93ccfe33..00000000 --- a/.opencode/skills/paperforge/atoms/chart-reading/火山图与曼哈顿图.md +++ /dev/null @@ -1,62 +0,0 @@ -# 火山图与曼哈顿图阅读指南 - -## 适用场景 -- 展示大规模差异分析结果(如RNA-seq、GWAS、蛋白质组学) -- 同时展示统计显著性和效应量 -- 识别top hits(关键基因/SNP/蛋白质) - ---- - -## 核心阅读维度 - -### 1. 阈值线 -- **显著性阈值**:通常是水平线(如 p=0.05, p=0.01 或 FDR q=0.05) - - 注意:如果用了-log10(p),阈值线是水平的;如果用原始p值,是垂直的 - - **多重检验校正**:如果是组学数据(测了上万个基因),必须用FDR(Benjamini-Hochberg)或Bonferroni校正,不能用raw p-value -- **效应量阈值**:通常是垂直线(如 |log2FC| > 1 或 > 0.5) - - 小效应量(|log2FC| < 0.5)即使统计显著,生物学意义可能有限 - -### 2. 象限分布 -- **左上/右上象限**:高显著性 + 大效应量 = 最有生物学意义的hits -- **中间区域**:不显著或效应量小 = 可忽略 -- **对称性**:上调和下调的数量是否对称?不对称可能提示技术偏倚(如文库制备偏差) - -### 3. Top Hits -- **标注的基因/位点**:作者特别标注的点是否有生物学合理性? -- **已知标记**:是否与先验知识一致?(如已知癌症基因在肿瘤vs正常中差异表达) -- **新颖性**:新发现的hits是否有功能注释支持? - -### 4. 整体模式 -- **漏斗形/三角形**:理想情况,大效应量对应高显著性 -- **离散分布**:无明确模式 = 可能缺乏系统性差异 -- **批次效应信号**:如果看到按批次聚类的显著点(如所有batch1样本的某基因都高表达)= 技术artifact - -### 5. 曼哈顿图特有(GWAS) -- **基因组位置**:x轴是染色体位置,y轴是-log10(p) -- **峰(Peak)**:连续多个显著SNP = 关联区域(LD block) -- **基因注释**:峰附近是否有已知基因?功能是什么? -- **Bonferroni阈值**:通常为 5×10^-8(全基因组显著性) -- ** suggestive 阈值**:通常为 1×10^-5(提示性关联,需验证) - ---- - -## 常见陷阱 - -| 陷阱 | 说明 | 如何识别 | -|------|------|---------| -| 未校正p值 | 用raw p-value而非FDR,假阳性率高 | 看方法部分:是否提到FDR/BH校正? | -| 效应量过小 | |log2FC|<0.2却标注为"显著差异" | 看垂直阈值线位置 | -| 批次效应 | 技术批次驱动差异而非生物学 | 看是否有按批次聚类的显著点 | -| 样本量不足 | 小样本导致效应量估计不稳定 | 看误差线(如果有)或 replicate 数量 | -| 选择性标注 | 只标注支持结论的点,忽略矛盾点 | 看是否标注了所有已知关键基因? | - ---- - -## 批判性提问模板 - -1. "多重检验校正方法是什么?如果是组学数据,是否用了FDR?" -2. "效应量阈值是多少?|log2FC|>1还是>0.5?生物学意义如何?" -3. "显著性阈值是raw p-value还是校正后的q-value?" -4. "Top hits是否有先验知识支持?新发现的hits是否有功能注释?" -5. "上调和下调基因数量是否对称?如果不对称,原因是什么?" -6. "如果是曼哈顿图,峰宽是多少?是否跨越多个基因?" diff --git a/.opencode/skills/paperforge/atoms/chart-reading/热图与聚类图.md b/.opencode/skills/paperforge/atoms/chart-reading/热图与聚类图.md deleted file mode 100644 index 8cf4fbd2..00000000 --- a/.opencode/skills/paperforge/atoms/chart-reading/热图与聚类图.md +++ /dev/null @@ -1,65 +0,0 @@ -# 热图与聚类图阅读指南 - -## 适用场景 -- 展示矩阵数据(基因表达、蛋白质丰度、相关性等) -- 识别样本或特征的相似性模式 -- 展示通路/基因集的活性 - ---- - -## 核心阅读维度 - -### 1. 聚类结构(如果存在) -- **行/列聚类**:样本或基因是否形成明显分组? -- **聚类算法**:通常用层次聚类(hierarchical),距离度量(Euclidean/Pearson/correlation)和链接方法(complete/average/Ward)会影响结果 -- **稳定性**:如果改变距离度量,聚类模式是否仍然稳定?(不稳定的聚类不可靠) - -### 2. 颜色与标准化 -- **颜色梯度**: - - 红-蓝:通常表示上调-下调(表达量) - - 黄-黑-蓝:通常表示激活-中性-抑制(通路活性) - - **必须确认**:caption是否明确说明颜色含义? -- **标准化方法**: - - **Z-score**:(x - mean) / SD,突出相对变化,适合跨样本比较 - - **Log转换**:用于偏态分布(如RNA-seq count数据) - - **Percentile**:将数据映射到0-100%,丢失绝对量信息 - - **Min-Max**:映射到[0,1],对异常值敏感 - - **关键问题**:标准化方法是否适合数据类型?(如RNA-seq应该用log2 TPM/FPKM,而不是原始count) - -### 3. 注释条(Annotation Bars) -- **顶部/侧边色条**:是否标注了临床特征(如亚型、分期、治疗响应)? -- **一致性**:热图聚类模式与注释特征是否一致?(如:热图分3组,注释条也正好分3组 = 好) -- **遗漏信息**:是否有重要协变量未标注?(如批次效应、性别、年龄) - -### 4. 关键区域 -- **高亮块**:作者是否在文中特别指出了某些区域? -- **边界清晰度**:分组之间的边界是清晰还是模糊?(模糊 = 亚型定义不稳健) -- **一致性**:同一组内的样本是否表现一致?(组内方差大 = 亚型异质性高) - -### 5. 行列选择 -- **样本选择**:是否排除了某些样本?原因是什么?(如QC失败) -- **特征选择**: - - 是全部基因还是筛选后的子集? - - 筛选标准是什么?(方差top N?差异表达?已知标记?) - - **风险**:先筛选再聚类会造成"循环论证"(看起来有结构,其实是因为你选了有结构的基因) - ---- - -## 常见陷阱 - -| 陷阱 | 说明 | 如何识别 | -|------|------|---------| -| 循环论证 | 用差异基因做聚类,然后说"我们发现两个亚型" | 看方法部分:聚类用的基因是否预先筛选过? | -| 批次效应伪装 | 聚类按批次而非生物学分组 | 问:如果有批次注释条,是否与聚类一致? | -| 颜色误导 | 非对称颜色映射或断裂色阶 | 看color bar是否连续、是否以0为中心 | -| 过度解读 | 把噪声当成模式 | 问:如果随机打乱数据,聚类是否还明显? | - ---- - -## 批判性提问模板 - -1. "聚类用的基因是如何筛选的?是否预先基于差异表达筛选?" -2. "颜色标准化方法是什么?Z-score、log、还是percentile?" -3. "如果有批次效应,热图是否能区分批次和真实生物学差异?" -4. "聚类结果的稳定性如何?换用不同距离度量或聚类算法,模式是否一致?" -5. "作者声称的亚型数量(如k=3)是否有统计支持?(如gap statistic, silhouette score)" diff --git a/.opencode/skills/paperforge/atoms/chart-reading/生存曲线.md b/.opencode/skills/paperforge/atoms/chart-reading/生存曲线.md deleted file mode 100644 index ba8ad37a..00000000 --- a/.opencode/skills/paperforge/atoms/chart-reading/生存曲线.md +++ /dev/null @@ -1,75 +0,0 @@ -# 生存曲线阅读指南 - -## 适用场景 -- 时间-事件数据(time-to-event) -- 比较不同组别的生存/复发/进展时间 -- 评估预后因素的独立效应 - ---- - -## 核心阅读维度 - -### 1. 曲线形态 -- **中位生存时间(MST)**:曲线下降到50%时对应的时间。 - - 如果曲线始终>50% = 中位未达到,应报告"中位生存期未达到" - - 注意:MST是样本统计量,不是个体预测 -- **早期/晚期分离**: - - 早期分离(曲线在前6个月就分开)= 治疗/因素有快速效应 - - 晚期分离 = 效应随时间累积 - - 始终不分离 = 组间无差异 -- **平台期**:曲线在某段时间水平 = 事件风险暂时消失(如治愈) - -### 2. 删失数据(Censoring) -- **删失标记**:曲线上通常有小竖线表示删失 -- **删失比例**:如果>50%样本删失,估计不可靠(随访时间太短或失访太多) -- **信息删失 vs 随机删失**: - - 信息删失(因副作用退出)= 偏差风险 - - 随机删失 = 可接受 -- ** competing risks **:如果有竞争风险(如癌症死亡 vs 其他原因死亡),标准Kaplan-Meier会高估累积发生率 - -### 3. 统计显著性 -- **Log-rank检验**:比较两条/多条曲线的整体差异。 - - p<0.05 = 组间生存分布有差异 - - 注意:log-rank检验对晚期差异敏感,对早期差异不敏感 -- **Hazard Ratio(HR)**: - - HR>1 = 暴露组风险高(如死亡更快) - - HR<1 = 暴露组风险低(如死亡更慢) - - HR=1 = 无差异 - - **必须看95% CI**:如果CI包含1,即使HR≠1也不显著 -- **Breslow检验 / Tarone-Ware**:如果对早期差异更感兴趣,log-rank可能不够敏感 - -### 4. 样本量与统计效力 -- **每组事件数(number of events)**: - - 总事件数 < 10×变量数 = Cox回归过拟合风险 - - 例如:10个协变量,需要至少100个事件 -- **最小随访时间**:是否足够长?(如5年生存率,但中位随访仅2年 = 不可信) - -### 5. Cox回归假设 -- **比例风险假设(PH assumption)**:HR在不同时间点是否恒定? - - 检验方法:Schoenfeld残差、log-log图 - - 如果违反:需要分层Cox、时变系数或加速失效模型(AFT) -- **线性假设**:连续协变量的对数风险是否线性? - - 可用martingale残差检验 - ---- - -## 常见陷阱 - -| 陷阱 | 说明 | 如何识别 | -|------|------|---------| -| immortal time bias | 从治疗后开始算生存,但治疗前有"免疫"时间 | 看时间0的定义是否合理 | -| lead-time bias | 筛查提前发现疾病,造成"生存期延长"假象 | 问:是否报告了 lead-time 校正? | -| overfitting in Cox | 变量数 > 事件数/10 | 看事件数和变量数的比例 | -| 忽略竞争风险 | 竞争事件存在时,KM曲线高估累积发生率 | 问:是否用了Fine-Gray竞争风险模型? | -| 中位生存未标注 | 曲线始终>50%,但作者说"中位生存X月" | 这是不可能的,应质疑 | - ---- - -## 批判性提问模板 - -1. "中位随访时间是多少?是否足够观察主要终点?" -2. "删失比例是多少?删失原因是否随机?" -3. "HR的95% CI是否包含1?如果包含,差异不显著" -4. "是否检验了比例风险假设?如果违反,用了什么替代方法?" -5. "如果存在竞争风险,是否用了适当的统计方法?" -6. "Cox回归的变量数/事件数比例是否合理?" diff --git a/.opencode/skills/paperforge/atoms/chart-reading/箱式图与小提琴图.md b/.opencode/skills/paperforge/atoms/chart-reading/箱式图与小提琴图.md deleted file mode 100644 index 1e2d4205..00000000 --- a/.opencode/skills/paperforge/atoms/chart-reading/箱式图与小提琴图.md +++ /dev/null @@ -1,58 +0,0 @@ -# 箱式图与小提琴图阅读指南 - -## 适用场景 -- 展示连续变量在不同分组间的分布 -- 比较多组间的中位数/均值差异 -- 识别异常值和数据质量 - ---- - -## 核心阅读维度 - -### 1. 集中趋势 -- **中位数线位置**:组间中位数差异是否明显? -- **均值标记**:如果标注了均值(通常用菱形或叉号),与中位数是否接近?(接近=对称分布,远离=偏态) - -### 2. 离散程度 -- **IQR(四分位距)**:箱体高度代表50%数据的范围。IQR大 = 组内异质性高 -- ** whisker 长度**:通常延伸至 1.5×IQR 或最值。Whisker长 = 分布尾部重 -- **小提琴图宽度**:某值处越宽 = 该值附近数据密度越高 - -### 3. 异常值 -- **Outlier 标记**:通常是箱外的点。 - - 判断:是生物学真实差异还是技术错误? - - 样本量小(n<10)时,每个点都很重要,不要轻易忽略 -- **样本量影响**:n<5时箱式图不可靠,应该看原始散点 - -### 4. 统计显著性 -- **p值标注**:* p<0.05, ** p<0.01, *** p<0.001 -- **检验方法**: - - 两组:t-test(正态)或 Mann-Whitney U(非正态) - - 多组:ANOVA(正态)或 Kruskal-Wallis(非正态) - - 如果数据明显偏态,用参数检验(t/ANOVA)是**错误**的 -- **多重比较校正**:如果做多次两两比较,是否用了 Bonferroni / FDR 校正? - -### 5. 分组与对照 -- **对照组设置**:是否有适当的对照?(如正常组织 vs 病变组织) -- **组间样本量平衡**:如果一组 n=50 另一组 n=5,差异可能不可靠 -- **配对设计**:如果是配对样本(如治疗前后),应该用配对检验,而非独立检验 - ---- - -## 常见陷阱 - -| 陷阱 | 说明 | 如何识别 | -|------|------|---------| -| Y轴截断 | 截断Y轴放大微小差异 | 检查Y轴是否从0开始(比值类)或是否有断裂标记 | -| 不标n值 | 无法判断统计效力 | 看caption或方法部分是否有样本量说明 | -| 用均值±SEM代替箱式图 | SEM小,掩盖真实离散度 | SEM = SD/√n,永远比SD小,是"美化"手段 | -| 忽略正态性假设 | t-test用于极端偏态数据 | 看是否报告了Shapiro-Wilk检验或使用非参数检验 | - ---- - -## 批判性提问模板 - -1. "组间差异的效应量(effect size)是多少? Cohen's d > 0.5 才算中等效应" -2. "异常值是否影响了中位数? 去掉异常值后结论是否改变?" -3. "如果改用非参数检验,p值是否仍然显著?" -4. "样本量是否足够检测到这个效应量?"(可用G*Power反推) diff --git a/.opencode/skills/paperforge/atoms/chart-reading/组织学半定量图.md b/.opencode/skills/paperforge/atoms/chart-reading/组织学半定量图.md deleted file mode 100644 index 356b1423..00000000 --- a/.opencode/skills/paperforge/atoms/chart-reading/组织学半定量图.md +++ /dev/null @@ -1,80 +0,0 @@ -# 组织学半定量图 - -## 适用场景 - -关节软骨、骨、肌肉、皮肤等组织的染色评估(H&E、Safranin O、Masson trichrome、番红O-固绿等),以及基于评分系统的半定量组织学分析。常见于骨科、再生医学、创伤修复研究。 - ---- - -## 核心阅读维度 - -### 1. 评分系统识别与适用范围 - -| 评分系统 | 分项数 | 满分 | 适用场景 | 特点 | -| -------------- | ------ | ------ | ----------------------------- | --------------------------------- | -| **O'Driscoll** | 8 | 24 | 动物软骨修复 | 评估结构、细胞、基质、矿化等 | -| **Modified O'Driscoll** | 8 | 24+ | 软骨修复 | 加入hyaline cartilage比例 | -| **ICRS II** | 14 | 每项100mm VAS | 人/动物软骨 | 14个独立评分项,可视化模拟评分尺 | -| **Mankin** | 4-5 | 14 | 骨关节炎分级 | 结构、细胞、Safranin O、潮线 | -| **OARSI** | 多维度 | 层级制 | 人膝关节骨关节炎组织学 | 更适合退行性病变 | -| **Pineda** | 4 | 12 | 软骨修复 | 简单快速 | - -### 2. 评分者间一致性(Inter-rater Reliability) - -- **评估方法**:组内相关系数(ICC)或 Kappa 统计量 -- **ICC 阈值**:ICC > 0.75 = 良好,0.60-0.75 = 中等,< 0.60 = 差 -- **盲法要求**:评分者必须不知道样本分组(治疗组 vs 对照组) -- **一致性人数**:至少 2-3 名经过培训的评分者 - -**关键问题**: -- 论文是否报告了评分者间一致性? -- 评分者是否盲于实验分组? -- 如果评分差异过大(如 >2 分),如何处理?(共识评分?平均?排除离群值?) - -### 3. ROI(Region of Interest)选择 - -- **评分位置**:是在修复区中心?还是包含修复区+周围正常软骨? -- **切片位置**:是否使用最佳切片(无折叠、无撕裂)? -- **染色一致性**:同批次染色各组切片条件是否相同? - -### 4. 软骨特异性染色评估要点 - -| 染色类型 | 评估内容 | 关键指标 | -| ------------------ | ------------------------------- | --------------------------------- | -| **Safranin O / Fast Green** | 蛋白聚糖(PG)含量 | 染色强度(0-3 或 0-4 分制);与正常软骨对比 | -| **H&E** | 细胞形态、结构完整性 | 细胞数、细胞簇、核形态 | -| **Masson Trichrome** | 胶原沉积与纤维化程度 | 蓝绿色胶原 vs 红色肌肉/细胞质 | -| **番红O(Safranin O)** | PG 分布 | 深层/浅层分布是否与正常软骨相似 | -| **IHC(COL2A1等)** | 目标蛋白表达定位与强度 | 阳性染色区域百分比 | - -### 5. 评分细节审查 - -- **分项分数**还是**总分**?分项分数是否都有报告? -- **正常软骨对照**:是否同时评分了正常对照组(Sham)作为基准? -- **时间点设计**:多个时间点(4w / 12w / 24w)的评分是否用同一标准? - ---- - -## 常见陷阱 - -| 陷阱 | 说明 | 如何识别 | -| ------------------ | ------------------------------------------ | ---------------------------------- | -| **评分者非盲** | 评分者知道分组信息,导致评分偏倚 | 方法中是否注明"blinded evaluation" | -| **评分者间一致性差**| ICC < 0.6,但作者仍报告组间差异显著 | 检查是否报告了 ICC 或 κ 值 | -| **ROI 选取不统一** | 不同样本选取的评分区域大小不一致 | 方法中是否明确了 ROI 标准 | -| **总分掩盖分项差异**| 两组总分相近但分项模式完全不同 | 检查分项分数而非只看总分 | -| **缺乏正常对照** | 没有正常软骨/假手术组作为基准 | 是否设置了 baseline 对照 | -| **组织伪影当作数据**| 切片折叠、气泡、染色假象被纳入评分 | 图像质量是否足以区分真实信号和伪影 | - ---- - -## 关键问题模板 - -1. "用的是哪个评分系统(O'Driscoll / ICRS II / Mankin)?该系统的分项评分是否都有报告,还是只报告了总分?" -2. "评分是否采用了盲法?评分者间一致性(ICC/κ)是多少?是否在报告前就确认了一致性?" -3. "ROI 的选择标准是什么?是固定位置还是基于修复区中心?不同样本的评分区域是否可比较?" -4. "是否存在评分过高/过低的离群样本?作者如何处理离群值?" - -## 一句话总结 - -> 组织学评分的核心是**"查评分系统、验盲法执行、核ROI一致、看完总分看分项"**——半定量评分的主观性决定了盲法和一致性报告是可信度的基础。 diff --git a/.opencode/skills/paperforge/atoms/chart-reading/网络图与通路图.md b/.opencode/skills/paperforge/atoms/chart-reading/网络图与通路图.md deleted file mode 100644 index 2fa62850..00000000 --- a/.opencode/skills/paperforge/atoms/chart-reading/网络图与通路图.md +++ /dev/null @@ -1,78 +0,0 @@ -# 网络图与通路图 - -## 适用场景 - -展示分子相互作用网络(PPI)、信号通路级联、基因调控关系、或共表达网络。常见于系统生物学、多组学整合分析、药物靶点预测。 - -## 读图维度 - -### 1. 网络拓扑结构 - -- **节点 (Node)**: - - 代表什么?(基因、蛋白、代谢物、疾病、药物) - - 节点大小/颜色编码什么?(表达量、重要性、分类、差异显著性) - - 关键节点(Hub)是否被标注? -- **边 (Edge)**: - - 代表什么关系?(物理相互作用、调控、共表达、预测关联) - - 边的粗细/颜色编码什么?(置信度、强度、关系类型) - - 是否有方向性?(箭头表示调控方向) - -### 2. 网络布局算法 - -- **布局方式**: - - Force-directed(力导向):相关节点聚集,无关节点分离 - - Circular(环形):强调层次结构 - - Hierarchical(层次):展示级联信号 -- **布局选择影响**: - - 不同算法可能强调不同的网络特征 - - 聚集的模块是否代表真实功能单元? - -### 3. 模块与聚类 - -- **模块检测**: - - 是否有明显的社区/模块结构? - - 模块用什么算法识别?(MCL、Louvain、WGCNA) - - 模块内节点是否有共享功能? -- **通路注释**: - - 节点是否标注了通路归属?(如 KEGG、GO、Reactome) - - 同一通路的节点是否在网络中聚集? - -### 4. 中心性指标 - -- **Hub 基因/蛋白**: - - Degree(连接数)最高的节点是谁? - - Betweenness(中介中心性)高的节点 = 信息流通的关键瓶颈 - - Closeness(接近中心性)高的节点 = 网络核心 -- **Hub 的生物学意义**: - - 是否是已知的"明星分子"? - - 是否是潜在的药物靶点? - -### 5. 多组学整合层 - -- **多层网络**: - - 不同颜色/形状的节点是否代表不同组学层面?(基因、蛋白、代谢物) - - 层间连接(如基因→蛋白)是否可靠? -- **整合可信度**: - - 跨层关联是否有实验验证? - - 还是纯计算预测? - -## 常见陷阱 - -| 陷阱 | 识别方法 | 风险 | -|------|---------|------| -| ** hairball 效应** | 节点>100,边极度密集 | 无法解读,视觉噪声 | -| **预测边混入** | 未区分实验验证 vs 预测 | 假阳性关联污染网络 | -| **Hub 偏倚** | 只关注已知明星分子 | 忽视真正有功能的新节点 | -| **布局artifact** | 节点聚集仅因布局算法 | 被误读为生物学模块 | -| **过度解读方向性** | 蛋白相互作用标为"激活" | PPI 本身不携带方向信息 | - -## 关键问题模板 - -1. "网络图中包含 200+ 节点和 1000+ 边,是否进行了阈值过滤?未显示的边是否可能改变模块结构?" -2. "标注的 Hub 节点 Degree 最高,但 Betweenness 低,它是否是真正的功能核心还是仅仅连接了很多边缘节点?" -3. "网络中的边有 30% 来自 STRING 预测(非实验验证),如果仅保留实验验证的边,网络结构是否崩溃?" -4. "通路图显示 A 激活 B 激活 C,但这是基于文献挖掘的通路模板,本研究的数据是否支持这种级联关系?" - -## 一句话总结 - -> 网络图的核心是**"看节点、看模块、看Hub、防 hairball"**——网络的可读性和边的可信度比节点的数量更重要。 diff --git a/.opencode/skills/paperforge/atoms/chart-reading/蛋白质结构图.md b/.opencode/skills/paperforge/atoms/chart-reading/蛋白质结构图.md deleted file mode 100644 index ebe4f79a..00000000 --- a/.opencode/skills/paperforge/atoms/chart-reading/蛋白质结构图.md +++ /dev/null @@ -1,79 +0,0 @@ -# 蛋白质结构图 - -## 适用场景 - -展示蛋白质三维结构、结构域组织、配体结合位点、突变位置、或蛋白-蛋白/蛋白-配体相互作用界面。常见于结构生物学、药物设计、突变功能预测。 - -## 读图维度 - -### 1. 表示方式与分辨率 - -- **结构表示法**: - - **Cartoon/Ribbon**:展示二级结构(α-螺旋、β-折叠、无规卷曲) - - **Surface**:展示表面电势、疏水性、可及性 - - **Stick/Ball-and-stick**:展示小分子配体、关键残基侧链 - - **Sphere**:展示金属离子、水分子 -- **分辨率 (Resolution)**: - - X-ray: < 2.5Å 为高质量,2.5-3.5Å 中等,>3.5Å 较低 - - Cryo-EM: < 3.0Å 原子级,3-5Å 可看清二级结构 - - 低分辨率结构的侧链位置不可靠 - -### 2. 结构域与功能域 - -- **结构域划分**: - - 是否标注了已知的功能域?(如激酶域、DNA结合域) - - 不同颜色是否代表不同结构域? -- **无序区域 (IDR)**: - - N端或C端是否有未解析区域(虚线表示)? - - 无序区域是否有功能重要性? - -### 3. 突变与修饰位点 - -- **突变标注**: - - 突变位点在结构上的位置?(表面 vs 核心) - - 是否参与相互作用界面? - - 是保守位点还是可变位点?(看序列比对) -- **翻译后修饰**: - - 磷酸化、泛素化、乙酰化位点是否标注? - - 修饰位点是否暴露在表面? - -### 4. 相互作用界面 - -- **蛋白-蛋白相互作用 (PPI)**: - - 界面面积多大?(>1500Ų 为典型强相互作用) - - 界面是否涉及疏水核心 + 极性边缘? - - 关键残基是否通过 Stick 显示? -- **蛋白-配体相互作用**: - - 配体结合口袋位置?(正构 vs 别构) - - 氢键、疏水作用、π-π堆积是否标注? - - 结合亲和力 (Kd/Ki) 是否与结构互补性一致? - -### 5. 动态性与构象变化 - -- **静态结构局限**: - - 单张结构图是否能代表动态构象系综? - - 是否有 NMR 系综或 MD 模拟补充? -- **构象变化**: - - 是否叠加了多种构象(如开放/关闭状态)? - - 变化发生在哪个结构域? - -## 常见陷阱 - -| 陷阱 | 识别方法 | 风险 | -|------|---------|------| -| **低分辨率过度解读** | Resolution > 4Å | 侧链位置不准确,界面分析不可靠 | -| **同源建模未标注** | 未说明是实验结构还是预测 | 将预测结构等同于实验验证 | -| **静态冻结动态** | 仅展示单一构象 | 忽视蛋白质天然动态性 | -| **修饰位点埋藏** | 标注的修饰位点在结构核心 | 修饰酶无法接触,标注可能错误 | -| **人工构建域** | 截短体或融合蛋白结构 | 不代表全长蛋白的真实状态 | - -## 关键问题模板 - -1. "该蛋白质结构分辨率为 3.8Å,作者声称突变破坏了氢键网络,但在这个分辨率下侧链取向是否足够精确以支持这一结论?" -2. "结构图显示配体结合在口袋 A,但生物化学实验显示主要活性来自口袋 B,晶体结构是否捕获了非生理相关的结合模式?" -3. "图中标注的磷酸化位点 Ser123 在结构上位于β-折叠核心内部,激酶如何接触这个位点?是否只在未折叠状态下修饰?" -4. "这是同源建模结构(基于 30% 序列同源的模板),而非实验解析结构,模型质量评分(如 QMEAN、MolProbity)是否在可接受范围?" - -## 一句话总结 - -> 蛋白质结构图的核心是**"看分辨率、看界面、看动态、防过度解读"**——结构是功能的快照,不是功能的全部。 diff --git a/.opencode/skills/paperforge/atoms/chart-reading/降维图(PCA-tSNE-UMAP).md b/.opencode/skills/paperforge/atoms/chart-reading/降维图(PCA-tSNE-UMAP).md deleted file mode 100644 index 797bd8ea..00000000 --- a/.opencode/skills/paperforge/atoms/chart-reading/降维图(PCA-tSNE-UMAP).md +++ /dev/null @@ -1,65 +0,0 @@ -# 降维图(PCA / t-SNE / UMAP)阅读指南 - -## 适用场景 -- 高维数据的可视化(RNA-seq、质谱、影像特征等) -- 样本相似性/异质性的初步探索 -- 批次效应检测 - ---- - -## 核心阅读维度 - -### 1. 方差解释率(PCA特有) -- **PC1/PC2百分比**:前两维解释了多少总方差? - - >50%:降维充分,可信 - - 20-50%:中等,可能丢失信息 - - <20%:降维不充分,两维不足以代表数据结构 -- **碎石图(Scree Plot)**:如果有,看拐点在哪里。拐点后的PC贡献小,可忽略 - -### 2. 分组分离度 -- **视觉分离**:不同颜色/形状的组是否明显分开? -- **重叠程度**:重叠多 = 组间差异小,或降维丢失了区分信息 -- **边界清晰度**:边界是清晰还是模糊?(模糊 = 组间有过渡状态) - -### 3. 批次效应检测 -- **批次标记**:如果有按批次着色,看同一批次样本是否聚集? - - 是 = 存在批次效应,需要校正(如ComBat) - - 否 = 批次效应小,可喜 -- **与生物学分组的关系**:批次聚集和生物学分组哪个更强? - - 如果批次 > 生物学 = 严重问题,结论不可信 - -### 4. 离群样本 -- **远离主群的点**:是否总是同一个样本? -- **原因**:技术错误(如测序深度低)还是真实生物学异常? -- **处理**:作者是否排除了离群点?排除标准是什么? - -### 5. 降维方法差异 - -| 方法 | 优点 | 缺点 | 适用场景 | -|------|------|------|---------| -| PCA | 线性、可解释、有载荷 | 只能捕捉线性关系 | 初步探索、批次检测 | -| t-SNE | 非线性、局部结构好 | 随机、全局结构差、perplexity敏感 | 亚型识别、局部聚类 | -| UMAP | 非线性、速度快、全局+局部 | 超参数敏感(n_neighbors, min_dist) | 大规模数据、单细胞 | - -**关键问题**:作者选择该方法的理由是否充分?(如:单细胞数据用UMAP合理,但普通RNA-seq用t-SNE可能过度) - ---- - -## 常见陷阱 - -| 陷阱 | 说明 | 如何识别 | -|------|------|---------| -| Perplexity选择不当 | t-SNE的perplexity影响聚类外观 | 方法部分是否说明perplexity值?通常5-50 | -| UMAP过聚合 | min_dist太小导致所有点粘在一起 | 看是否有独立的小簇,还是一个大 blob | -| 忽略方差解释率 | 只展示PC1/PC2但解释率<10% | 看caption或正文是否报告了% | -| 循环论证 | 用聚类结果定义组,然后在降维图上展示 | 问:颜色标注的组是如何定义的?是否独立于降维? | - ---- - -## 批判性提问模板 - -1. "PC1/PC2 解释了多少方差?如果<30%,二维投影是否充分?" -2. "分组颜色是基于独立标签(如临床诊断)还是聚类结果?" -3. "是否存在批次效应?批次效应是否大于生物学效应?" -4. "离群样本是否被排除?排除标准是什么?" -5. "如果用PCA而不是t-SNE/UMAP,分离度是否仍然明显?"(检验非线性方法的必要性) diff --git a/.opencode/skills/paperforge/atoms/chart-reading/雷达图与漏斗图.md b/.opencode/skills/paperforge/atoms/chart-reading/雷达图与漏斗图.md deleted file mode 100644 index 3c5cf32a..00000000 --- a/.opencode/skills/paperforge/atoms/chart-reading/雷达图与漏斗图.md +++ /dev/null @@ -1,84 +0,0 @@ -# 雷达图与漏斗图 - -## 适用场景 - -**雷达图**:多维度比较(如不同样本的多指标特征画像),常见于多参数免疫分型、药物多靶点评估、患者多维特征比较。 - -**漏斗图**:Meta分析中检测发表偏倚,或展示临床流程中各阶段的病例流失(如从筛查到入组到完成随访)。 - -## 读图维度 - -### 雷达图 - -#### 1. 维度定义与尺度 - -- **每个轴代表什么指标?** - - 指标是否独立?(避免高度相关的指标重复) - - 指标是否经过标准化?(否则量纲大的指标会主导图形) - - 轴的方向:是否所有轴都是"越大越好"?(注意反向指标) - -#### 2. 多边形形态解读 - -- **整体面积**: - - 面积越大 = 综合得分越高(在标准化前提下) - - 但面积受维度数量影响(维度越多面积越容易大) -- **形状特征**: - - 某一轴特别突出 = 该维度是主要特征 - - 接近正多边形 = 各维度均衡 - - 明显不对称 = 存在优势/劣势维度 - -#### 3. 组间比较 - -- **多边形重叠**: - - 完全重叠 = 两组特征几乎相同 - - 某一方向明显分离 = 该维度是主要区分特征 -- **交叉情况**: - - A组在维度1上高于B组,但在维度2上低于B组 = 权衡关系 - -### 漏斗图 (Funnel Plot) - -#### 1. 坐标轴含义 - -- **X轴**:效应量(如 logHR、logOR、SMD) -- **Y轴**:精度指标(通常是标准误 SE 的倒数,即 1/SE) - - 上方 = 大样本、高精度研究 - - 下方 = 小样本、低精度研究 - -#### 2. 对称性判断 - -- **理想情况**: - - 呈倒漏斗形,大样本研究聚集在顶部中间 - - 小样本研究在底部对称分布 -- **发表偏倚信号**: - - 底部左侧或右侧缺失 = 小样本阴性结果未发表 - - 图形明显不对称 = 可能存在发表偏倚 - -#### 3. 统计检验 - -- **Egger 检验**: - - p < 0.05 提示存在发表偏倚 - - 但检验力低(研究数 < 10 时不稳定) -- **Trim and Fill**: - - 估算需要多少"缺失"研究才能使漏斗对称 - - 调整后的合并效应量是否仍显著? - -## 常见陷阱 - -| 陷阱 | 识别方法 | 风险 | -|------|---------|------| -| **雷达图维度过多** | >8 个轴 | 可读性极差,图形混乱 | -| **雷达图未标准化** | 不同轴量纲差异巨大 | 大数值轴完全压制小数值轴 | -| **漏斗图研究过少** | n < 10 | 无法判断对称性,检验无功效 | -| **混淆两种漏斗图** | Meta漏斗 vs 临床流程漏斗 | 解读方式完全不同 | -| **假对称性** | 异质性高导致分散 | 异质性大时漏斗图本就不应集中 | - -## 关键问题模板 - -1. "雷达图有 12 个维度,是否有维度压缩或主成分分析支持这些维度的独立性?" -2. "漏斗图底部右侧明显缺失小样本阴性研究,如果进行 Trim and Fill 校正,合并效应量是否仍具有临床意义?" -3. "雷达图中样本 A 的面积大于样本 B,但 A 在关键疗效指标上反而低于 B,面积大小是否被非关键指标主导?" -4. "漏斗图显示不对称,但 I² = 85%(高异质性),这种分散是发表偏倚还是真实异质性导致?" - -## 一句话总结 - -> 雷达图看**"多维度均衡与失衡"**,漏斗图看**"小样本是否对称缺失"**——两者都警惕过度简化和维度误导。 diff --git a/.opencode/skills/paperforge/atoms/clarify-user-intent.md b/.opencode/skills/paperforge/atoms/clarify-user-intent.md deleted file mode 100644 index 286ff76f..00000000 --- a/.opencode/skills/paperforge/atoms/clarify-user-intent.md +++ /dev/null @@ -1,41 +0,0 @@ -# clarify-user-intent - -Clarifies ambiguous user intent when SKILL.md cannot confidently route. - -## Trigger conditions - -- Input is too short or vague -- Input matches multiple top-level intents -- Required object (paper key/DOI/title) is missing -- User says "这篇" but no unique paper is locked in context - -## Interaction rules - -1. Explain briefly what PaperForge can do -2. Present options matching the 5 research intents + mechanical commands -3. Let user choose or provide more details -4. Maximum 2 rounds of clarification; after that, report inability to route - -Two-round limit: 最多两轮。两轮后仍无法确定,告知用户无法路由。 - -## Fixed question pattern (Chinese) - -``` -我可以帮你做这几类事: - -1. 找某篇文章 -2. 找一批相关论文 -3. 找支持某个观点/参数/术语的证据 -4. 精读一篇文章 -5. 记录到项目阅读笔记 / 保存 - -你现在更想做哪一种?如果你已经有 paper key / DOI / 标题,也可以直接发给我。 -``` - -## Output - -Returns one: `clarified_intent` matched to the compound's top-level intents. - -## Two-round limit rule - -最多两轮。两轮后仍无法确定,告知用户无法路由。 diff --git a/.opencode/skills/paperforge/atoms/extract-methodology-card.md b/.opencode/skills/paperforge/atoms/extract-methodology-card.md deleted file mode 100644 index df7770b2..00000000 --- a/.opencode/skills/paperforge/atoms/extract-methodology-card.md +++ /dev/null @@ -1,108 +0,0 @@ -# extract-methodology-card - -从项目日志中提取可复用的方法论,生成方法论卡片。 - ---- - -## 前置条件 - -- bootstrap 已完成(有 `$VAULT`、`$PYTHON`) -- 已知 project 名称 -- 目标项目有至少一条 `type: "session_summary"` 或 `type: "note"` 的日志 - ---- - -## 步骤 - -### Step 1: 读取项目日志 - -```bash -$PYTHON -m paperforge --vault "$VAULT" project-log --list --project "" --json -``` - -从返回的日志条目中筛选包含可复用方法论的内容(`reusable` 字段非空的条目)。 - -### Step 2: 识别可复用模式 - -逐条分析日志,找出: -- 重复出现的解决策略 -- 用户纠正的常见错误 -- 被多次验证有效的流程 - -### Step 3: 读取方法论卡片模板 - -从 `references/method-card-template.md` 读取模板: - -```bash -cat "$VAULT/System/PaperForge/references/method-card-template.md" -``` - -### Step 4: 填充模板生成卡片 - -按模板格式填充: - -```markdown ---- -id: -tags: [, ] -source_project: -status: active ---- - -# <卡片标题(简短可搜索)> - -## Use when - - -## Procedure -1. <步骤 1> -2. <步骤 2> -3. <步骤 3> - -## Watch-outs -- <常见陷阱> -- <注意事项> - -## Example -来自 `` 项目的具体例子(附 project-log 来源) ---- -``` - -### Step 5: 确认写入 - -``` -即将创建方法论卡片到 System/PaperForge/methodology/archive/.md: - -标题: dc-parameter-audit -来源: 综述写作 -步骤: - 1. 列出所有参数窗断言 - 2. 逐句追溯文献来源 - 3. 区分"文献说了什么"和"我推断什么" - -确认写入?(y/n) -``` - -### Step 6: 写入 - -```bash -cat > "$VAULT/System/PaperForge/methodology/archive/.md" << 'EOF' -<卡片内容> -EOF -``` - -写入后确认文件已创建。 - ---- - -## 禁止 - -- 不要在没有 project-log 的情况下凭空创建卡片 -- 不要创建单次使用的方法——必须是可跨项目复用的模式 -- 不要使用与已有卡片重复的 id - ---- - -## 参考 - -模板文件:`references/method-card-template.md` diff --git a/.opencode/skills/paperforge/atoms/method-card-template.md b/.opencode/skills/paperforge/atoms/method-card-template.md deleted file mode 100644 index 0ae4addf..00000000 --- a/.opencode/skills/paperforge/atoms/method-card-template.md +++ /dev/null @@ -1,39 +0,0 @@ -# Method Card Template - -复制此模板创建新的方法论卡片。 - ---- - - - - -# <标题:简短、可搜索> - -## Use when - - -## Procedure - - -1. <步骤 1> -2. <步骤 2> -3. <步骤 3> - -## Watch-outs - - -- <注意 1> -- <注意 2> - -## Example - - ---- - diff --git a/.opencode/skills/paperforge/atoms/retrieval-routing.md b/.opencode/skills/paperforge/atoms/retrieval-routing.md deleted file mode 100644 index a78a6c52..00000000 --- a/.opencode/skills/paperforge/atoms/retrieval-routing.md +++ /dev/null @@ -1,150 +0,0 @@ -# retrieval-routing - -Route between vector and memory retrievers based on query type. - -## 1. Authority principles - -- **bootstrap** provides convenience capability fields only; it is not the runtime truth source -- **runtime-health** is the runtime truth source; always check it before making retrieval decisions -- **Semantic/vector retrieval is optional and supplementary** -- metadata and fulltext search are the primary retrieval path - -## 2. Retrieval ladders - -### Ladder A -- Paper Discovery (multi-arm strategy for discover-papers) - -**Prerequisite: check `retrieve` availability** - -Before any discovery arm, check if semantic/vector search is available: - -```bash -$PYTHON -m paperforge --vault "$VAULT" embed status --json -``` - -If `data.db_exists == true` and `data.chunk_count > 0`, `retrieve` is usable. -Otherwise, skip Arm 1 and Arm 3 below. - -**Strategy: choose discovery method based on user query type** - -| User says... | Use arms | -|---------------------------------------|----------------------------------| -| Specific technical term, method, parameter ("bipolar pulses", "galvanotaxis chamber") | **Arm 1** (fulltext) + **Arm 2** (metadata) in parallel | -| Author name + year, topic title | **Arm 2** (metadata) + optionally **Arm 1** | -| "collection X里有什么" / "collection里有什么" | **Arm 3** (collection inventory) | -| Vague / broad topic | **Arm 1** first, then **Arm 2** to supplement | - -#### Arm 1 — Fulltext semantic search (if `retrieve` available) - -```bash -$PYTHON -m paperforge --vault "$VAULT" retrieve "" --json --limit 30 -``` - -- Searches OCR fulltext chunks via vector embedding -- Catches concepts that appear only in Methods/Results/Discussion (not in title/abstract) -- Returns up to 30 chunk hits across papers; each hit includes `paper_id`, `section`, page -- If `ok: false` → skip this arm, proceed with Arm 2 - -#### Arm 2 — Metadata FTS search (always available) - -```bash -$PYTHON -m paperforge --vault "$VAULT" search "" --json --limit 30 - [--domain ""] \ - [--year-from ] [--year-to ] \ - [--ocr done|pending] \ - [--lifecycle ] -``` - -- Uses FTS5 on title, abstract, authors, journal, domain, collection_path -- Fast, always works -- Limit 30 by default; user can request more - -#### Arm 3 — Collection/domain inventory (full list, no truncation) - -When the user wants to know what's in a collection or domain: - -```bash -$PYTHON -m paperforge --vault "$VAULT" context --collection "" --json -$PYTHON -m paperforge --vault "$VAULT" context --domain "" --json -``` - -- Returns **every** paper in the collection/domain (no truncation) -- `context --collection` does prefix match on collection path -- `context --domain` does exact match on domain field -- If the list is very large (50+), summarize by year/author and ask user if they want to narrow - -#### Result deduplication - -- Collect all results from used arms into a single list -- Deduplicate by `zotero_key` (keep first occurrence) -- If `Arm 1` was used: prefer the Arm 1 entry (has fulltext match signal) -- After dedup, enrich top hits with `paper-context` - -#### Large result set handling - -- If any arm returns > 20 results, tell the user the total count -- Offer options: increase limit, narrow by year/domain/author, or show top N -- The `search` default limit is 20; `retrieve` default is 5 (increase to 30 for discovery) -- `context --collection` already returns all — no pagination needed - -#### Fallback: when `retrieve` is unavailable - -If `embed status` shows no vector index, skip Arm 1 entirely: - -> Semantic fulltext search unavailable (vector index not built). Falling back to metadata search only. Run `paperforge embed build` to enable fulltext discovery. - -Then proceed with Arm 2 only (or Arm 3 if collection/domain query). - -### Ladder B -- Evidence Retrieval with rg - -1. Generate metadata candidates via `paperforge search` -2. Narrow to papers with OCR/fulltext available (check runtime-health or paper-context) -3. Run `rg` over resolved fulltext set to locate exact evidence -4. Verify top hits with `paperforge paper-context` and local snippet reads - -### Ladder C -- Evidence Retrieval without rg - -1. Same metadata generation as Ladder B -2. Fallback to `grep` / `findstr` / system search -3. If agent environment supports, try installing rg (not assumed by default) -4. If no fulltext search tool is available, degrade to metadata-only evidence - -### Ladder D -- Semantic Candidate Expansion (if `semantic_enabled` && `semantic_ready`) - -1. Use `paperforge retrieve ` to expand candidate set -2. Never treat semantic hits as final evidence -3. Verify every semantic hit with rg / fulltext / paper-context before use - -## 3. Fallback behavior when no OCR/fulltext exists - -When runtime-health indicates no papers have OCR or fulltext available: - -> Exact evidence verification is limited -- degrading to metadata-level support - -Present a candidate paper list only; no snippet verification is performed. - -## 4. Recommended limits - -| Phase | Default limit | Why | -|-------|---------------|-----| -| Discovery — `search` | 30 | Catch papers beyond top 20 | -| Discovery — `retrieve` | 30 | Cover fulltext body matches | -| Discovery — `context --collection` | no limit | Must list everything | -| Enrichment — `paper-context` | top 10 | Enough for user to decide | -| Evidence snippets | top 5 | Deep verification is expensive | - -## 5. Semantic degradation rule - -```text -Retrieve availability check (run before any discovery): - paperforge embed status --json → db_exists == true && chunk_count > 0 - -If retrieve is NOT available (no vector index): -- Skip all retrieve calls in every molecule -- Fall back to metadata-only strategy (search + context --collection) -- Inform user: "Fulltext semantic search unavailable. Run 'paperforge embed build' to enable body-text discovery." - -If retrieve IS available: -- Use as primary discovery arm for technical/method queries -- Every semantic hit should be verified by paper-context before presenting as evidence -- Retrieve is a discovery tool, not a verification tool -``` - diff --git a/.opencode/skills/paperforge/atoms/write-project-log.md b/.opencode/skills/paperforge/atoms/write-project-log.md deleted file mode 100644 index 3796aa7a..00000000 --- a/.opencode/skills/paperforge/atoms/write-project-log.md +++ /dev/null @@ -1,131 +0,0 @@ -# write-project-log - -记录会话/项目日志到 `project-log.jsonl`,包含决策、弯路、待办等。 - ---- - -## 前置条件 - -- bootstrap 已完成(有 `$VAULT`、`$PYTHON`) -- 已知 project 名称 -- 对话上下文中已有会话内容可回顾 - ---- - -## Schema - -```json -{ - "id": "plog_20260519_001", - "project": "综述写作", - "date": "2026-05-19", - "type": "session_summary", - "title": "DC 段参数窗审计", - "decisions": ["做了 X,因为 Y"], - "detours": [ - { - "wrong": "错误方向", - "correction": "用户如何纠正", - "resolution": "最终方案" - } - ], - "reusable": ["可复用的方法论或教训"], - "todos": [ - {"content": "待办事项", "done": false} - ], - "related_papers": ["ABC12345"], - "tags": ["DC", "参数窗", "审计"], - "agent": "opencode" -} -``` - -| 字段 | 必填 | 说明 | -|------|------|------| -| `id` | 是 | 自动生成 `plog_YYYYMMDD_NNN` | -| `project` | 是 | 项目名 | -| `date` | 是 | YYYY-MM-DD | -| `type` | 是 | `session_summary` / `decision` / `correction` / `milestone` / `note` | -| `title` | 是 | 简短标题 | -| `decisions` | 否 | 核心决策列表 | -| `detours` | 否 | 弯路与修正记录 | -| `reusable` | 否 | 可复用方法论 | -| `todos` | 否 | 待办事项 | -| `related_papers` | 否 | 相关 Zotero keys | -| `tags` | 否 | 分类标签 | -| `agent` | 否 | 记录者 | - ---- - -## 步骤 - -### Step 1: 确定 project 和 type - -从上下文获取。如果用户未指定 project,询问。 - -type 参考: - -| type | 使用场景 | -|------|---------| -| `session_summary` | 会话结束时的总结 | -| `decision` | 单独记录一个重要决策 | -| `correction` | 用户纠正了某个方向 | -| `milestone` | 项目里程碑 | -| `note` | 一般研究笔记 | - -### Step 2: 回顾本次会话 - -提取以下内容: -- **做了什么**(核心决策及其原因) -- **用户纠正了什么**(弯路与修正) -- **有什么可复用的发现** -- **待办事项** - -### Step 3: 展示确认 - -``` -即将记录: - 日期: 2026-05-19 - 类型: session_summary - 标题: DC 段参数窗审计完成 - 决策: - - 限定参数窗为 100Hz-1kHz - 弯路: - - 把推断当文献事实 → 用户要求逐句审计 → 5 处修正 - 可复用: - - 写完必须逐句过 source - -确认写入?(y/n) -``` - -### Step 4: 写入 - -```bash -$PYTHON -m paperforge --vault "$VAULT" project-log --write \ - --project "" \ - --payload '' -``` - -payload 为完整 JSON 对象(单行序列化)。 - -返回 `ok: true` → 写入成功;`ok: false` → 报错重试。 - -### Step 5: 确认渲染 - -```bash -$PYTHON -m paperforge --vault "$VAULT" project-log --render --project "" -``` - -输出到 `Resources/Projects//project-log.md`。 - ---- - -## 禁止 - -- 不要在用户确认前写入 -- 不要只写"做了什么"而没有"弯路"和"可复用"部分 - ---- - -## 参考 - -详情请看本 atom 顶部的确认模板部分。 diff --git a/.opencode/skills/paperforge/atoms/write-project-reading-log.md b/.opencode/skills/paperforge/atoms/write-project-reading-log.md deleted file mode 100644 index fdf29558..00000000 --- a/.opencode/skills/paperforge/atoms/write-project-reading-log.md +++ /dev/null @@ -1,91 +0,0 @@ -# write-project-reading-log - -将叙述性证据直接写入 `Resources/Projects//reading-log.md`。 - -**定位:丰富内容层**——完整段落、写作素材、按 claim 组织的叙述性记录。 - ---- - -## 核心原则 - -**由 Agent 直接写入 markdown,不从 JSONL 渲染,不自动生成。** - -这是给你(人类作者)看的文档,不是给机器解析的数据。因此应该用自然段落写作,按论点和 claim 组织,而非逐条粘贴 JSON。 - ---- - -## 前置条件 - -- bootstrap 已完成(有 `$VAULT`、`$PYTHON`) -- 已知 `project` 名称 -- 目标文件路径:`$VAULT/Resources/Projects//reading-log.md` - ---- - -## 步骤 - -### Step 1: 确认项目 - -从上下文获取 project 名称。如果未指定,询问用户。 - -### Step 2: 组织叙述内容 - -按 claim/论点组织写作素材,而非按论文。每段包含: - -- **Claim 标题**(`##` 级别) -- **来源论文**(标题 + 年份 + 作者) -- **关键证据**(原文引用 + 你的解读) -- **与其他证据的关系**(支持/矛盾/补充) - -### Step 3: 展示确认 - -``` -即将写入 Resources/Projects/综述写作/reading-log.md: - -# PEMF 对 GAG 合成的影响 - -## PEMF 促进软骨基质合成 -Smith 2024 在体外软骨细胞实验中报告,PEMF 暴露后 GAG 含量增加 40%(原文:...)。 -这与 Jones 2023 的结果一致...... - -确认写入?(y/n) -``` - -### Step 4: 写入文件 - -使用 `write` 工具追加到目标文件: - -```text -目标路径:$VAULT/Resources/Projects//reading-log.md -写入模式:追加 -内容:按 claim 组织的 markdown 段落 -``` - -如果文件不存在,先创建空文件,再追加内容。 - -### Step 5: 确认写入成功 - -读取目标文件最后几行,确认内容已正确追加。 - ---- - -## 与 JSONL 的关系 - -| | JSONL | Project reading-log | -|---|-------|-------------------| -| 粒度 | 单句/单点 | 段落/claim 级 | -| 格式 | JSON 对象 | Markdown 叙述 | -| 使用者 | 机器搜索/渲染 | 人类阅读/写作 | -| 生成方式 | atom 写 | Agent 直接写 | -| 频率 | 高频 | 低频(仅在材料累积到可成段时) | - -两者互补——JSONL 是索引,project reading-log 是内容。 - ---- - -## 禁止 - -- 不要从 JSONL 自动渲染——这是人工写作区域 -- 不要只粘贴 JSON——这是给人看的文档 -- 不要用单句代替完整段落 -- 不要在用户确认前写入 diff --git a/.opencode/skills/paperforge/atoms/write-reading-log-jsonl.md b/.opencode/skills/paperforge/atoms/write-reading-log-jsonl.md deleted file mode 100644 index 07f2e9ab..00000000 --- a/.opencode/skills/paperforge/atoms/write-reading-log-jsonl.md +++ /dev/null @@ -1,108 +0,0 @@ -# write-reading-log-jsonl - -向 `reading-log.jsonl` 追加单条结构化阅读条目。 - -**定位:结构化索引层**——简短、可搜索、机器可解析。每条条文对应论文中的一句引用/发现。 - ---- - -## 前置条件 - -- bootstrap 已完成(有 `$VAULT`、`$PYTHON`) -- 已知 `paper_id`(zotero_key) -- 上下文中有待记录的 excerpt 和相关信息 - ---- - -## Schema - -```json -{ - "id": "rln_20260519_001", - "paper_id": "ABC12345", - "project": "综述写作", - "section": "Results Fig.3", - "excerpt": "原文关键句(逐字引用)", - "context": "包含 excerpt 的完整段落", - "usage": "这个信息在写作中的用途", - "note": "注意事项 / 待核查", - "tags": ["PEMF", "dose-response"], - "verified": false -} -``` - -| 字段 | 必填 | 说明 | -|------|------|------| -| `id` | 是 | 自动生成 `rln_YYYYMMDD_NNN` | -| `paper_id` | 是 | Zotero key(8位大写) | -| `project` | 否 | 关联的研究项目 | -| `section` | 是 | 文献位置 | -| `excerpt` | 是 | 逐字引用原文 | -| `context` | 是 | 完整段落供复核定位 | -| `usage` | 是 | 在写作中的用途 | -| `note` | 否 | 待核查事项 | -| `tags` | 否 | 分类标签 | -| `verified` | 否 | 默认 false | - ---- - -## 步骤 - -### Step 1: 收集必填字段 - -从对话上下文提取:`paper_id`、`section`、`excerpt`、`context`、`usage`。 - -### Step 2: 生成 id - -格式:`rln__<3位序号>`(序号从上下文已存在的条数推断) - -### Step 3: 展示确认 - -``` -即将记录: - 文献: ABC12345 | Smith 2024 - 位置: Results Fig.3 - 原文: "..." - 用途: 支撑 PEMF 基质合成的论证 - 项目: 综述写作 - 标签: PEMF, GAG - -确认写入?(y/n) -``` - -### Step 4: 写入 - -```bash -$PYTHON -m paperforge --vault "$VAULT" reading-log --write \ - --section "
" \ - --excerpt "" \ - --context "" \ - --usage "" \ - --note "" \ - --project "" \ - --tags "," -``` - -返回 `ok: true` → 写入成功;`ok: false` → 报错重试一次。 - -### Step 5: 触发渲染 - -```bash -$PYTHON -m paperforge --vault "$VAULT" reading-log --render --project "" -``` - -输出到 `Resources/Projects//reading-log.md`。 - ---- - -## 禁止 - -- 不要在用户确认前写入 -- `excerpt` 必须是原文逐字引用,不能是推断或改写 -- `context` 不能为空 - ---- - -## 参考 - -详情请看本 atom 顶部的确认模板部分。 diff --git a/.opencode/skills/paperforge/molecules/.gitkeep b/.opencode/skills/paperforge/molecules/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/.opencode/skills/paperforge/molecules/capture-project-knowledge.md b/.opencode/skills/paperforge/molecules/capture-project-knowledge.md deleted file mode 100644 index 0d72835e..00000000 --- a/.opencode/skills/paperforge/molecules/capture-project-knowledge.md +++ /dev/null @@ -1,86 +0,0 @@ -# capture-project-knowledge - -捕获和持久化从阅读中获得的知识到项目记忆。 - -## Pre-flight Checklist - -- [ ] SKILL.md Section 1a Pre-flight 全部通过 -- [ ] `$VAULT`、`$PYTHON` 已从 bootstrap 获取 -- [ ] 待保存的内容已就绪(证据/讨论/方法论) -- [ ] intent 已确定为 `capture_project_knowledge` - - 或作为 post-action(在其他 molecule 输出后用户要求保存) - ---- - -## 触发模式 - -### 1. 直意模式(Direct intent) - -用户直接说"记一下"、"保存"、"记录这条"——不管从哪个上下文来,都路由到这里。 - -### 2. 后置模式(Post-action) - -在其他分子(`read-known-paper`、`find-supporting-evidence`)产出结果后,用户要求保存其中一部分内容。 - ---- - -## 捕获类型 - -| 类型 | 描述 | 对应 Atom | -|------|------|-----------| -| **Lightweight JSONL** | 单条结构化阅读笔记,可搜索、可机器解析 | `atoms/write-reading-log-jsonl.md` | -| **Rich reading-log** | 叙述性段落,直接写入项目 markdown,写作素材 | `atoms/write-project-reading-log.md` | -| **Session/project log** | 会话总结、决策记录、弯路修正、待办事项 | `atoms/write-project-log.md` | -| **Methodology card** | 从项目日志中提取可复用的方法论 | `atoms/extract-methodology-card.md` | - ---- - -## 步骤 - -### Step 1: 确定用户意图 - -从对话上下文中判断用户要保存哪类内容: - -- 用户提到单条论文片段 / 引用 → **Lightweight JSONL**(最快、最轻量) -- 用户说"写一段总结" / "记录到这个项目" → **Rich reading-log** -- 用户说"记一下今天的进展" / "记录决策" → **Session/project log** -- 用户说"这个方法值得复用" / "提取方法论" → **Methodology card** -- 不确定时:列出四个选项让用户选 - -### Step 2: 调用对应 Atom - -| 意图 | Atom | -|------|------| -| 单条阅读笔记 | `atoms/write-reading-log-jsonl.md` | -| 项目阅读记录 | `atoms/write-project-reading-log.md` | -| 会话/项目日志 | `atoms/write-project-log.md` | -| 方法论提取 | `atoms/extract-methodology-card.md` | - -### Step 3: 写入前确认 - -**必须**在写入前以交互方式展示给用户确认。 - -格式参考对应 atom(`atoms/write-reading-log-jsonl.md`、`atoms/write-project-reading-log.md`、`atoms/write-project-log.md`)中的确认模板。 - -### Step 4: 写入后反馈 - -确认写入成功后,告知用户写入位置和主要内容摘要。 - ---- - -## 过渡路由 - -| 来源 | 路由方式 | -|------|---------| -| `read-known-paper` 保存讨论 | 用户说"保存" → Step 1 | -| `find-supporting-evidence` 保存证据 | 用户选择证据保存 → Step 1 | -| 用户直接说"记一下" | 直意触发 → Step 1 | - ---- - -## 禁止 - -- 不要在用户未要求时自动保存内容 -- 不要绕过确认步骤直接写入 -- 不要用 project-reading-log 替代 lightweight JSONL(它们是不同粒度的记录) diff --git a/.opencode/skills/paperforge/molecules/deep-analyze-paper.md b/.opencode/skills/paperforge/molecules/deep-analyze-paper.md deleted file mode 100644 index 17b7ad8a..00000000 --- a/.opencode/skills/paperforge/molecules/deep-analyze-paper.md +++ /dev/null @@ -1,195 +0,0 @@ -# deep-analyze-paper - -> [!warning] Safety Rules -> - **禁止主动加 `--force`** — 只有用户明确要求重读时才能用 -> - **禁止猜测 `--figures N`** — N 必须来自 Step 1 prepare 输出的实际数字 -> - **禁止重复跑 `prepare`** — prepare 只跑一次。跑了两次以上 → 检查 note 结构是否被破坏 -> - 不要在 Pass 1 完成前碰 Pass 2/3 -> - 不要把推断写成文献事实——区分"作者说了 X"和"我推断 Y" -> - 不要跨 figure 写综合判断(Pass 2 逐图,Pass 3 才做综合) -> - validate 失败超过 3 轮 → 执行自查清单,不要盲目重试 - -Keshav 三阶段精读。在 formal note 中写入结构化的 `## 精读` 区域。 - ---- - -## Pre-flight Checklist - -- [ ] SKILL.md Section 1a Pre-flight 全部通过 -- [ ] `$VAULT`、`$PYTHON` 已从 bootstrap 获取 -- [ ] 已确定唯一 paper(zotero_key) -- [ ] OCR status 为 `done`(否则无法精读) -- [ ] intent 已确定为 `deep_analyze_paper` - ---- - -### Step 0: paper-context(必须) - -```bash -$PYTHON -m paperforge --vault "$VAULT" paper-context --json -``` - -检查返回 JSON: -- `ok: false` → 报告 `error.message`,停止 -- `data.paper.ocr_status != "done"` → "OCR 未完成,请先运行 paperforge ocr",停止 -- `data.paper.analyze != true` → "analyze 未开启,请在 formal note frontmatter 中设为 true",停止 - -**检查 prior_notes:** -- 如果存在 `data.prior_notes`,逐条看 `verified` 字段 -- `verified: false` 的条目记入 recheck_targets,精读时必须回原文复核这些位置 -- `verified: true` 的条目可以信任,但标注"之前已验证" - -**记录关键路径:** -- `data.paper.note_path`(formal note 路径) -- `data.paper.fulltext_path`(fulltext 路径) -- 记下 `recheck_targets` 列表 - ---- - -## 执行流程 - -### Step 1: Prepare(跑脚本) - -```bash -$PYTHON "$SKILL_DIR/scripts/pf_deep.py" prepare --key --vault "$VAULT" -``` - -> [!warning] `--force` 禁止在精读过程中使用。`--force` 会清除已有精读内容重新生成骨架,仅在用户明确要求重读时才能用。任何时候都不要主动加 `--force`。 - -解析返回 JSON: -- `status: "ok"` → **记下 `figures`(数字)、`tables`(数字)、`figure_map`、`chart_type_map`、`formal_note`、`fulltext_md` 路径** -- `status: "warn"` + `deep_reading_status: done` → 告知用户"该文献已精读过",确认是否重读 -- `status: "error"` → 报告 `message`,停止 - -**把 `figures` 数量记在当前上下文**——Step 4 要用。 - -读 formal note,确认 `## 精读` 骨架已插入。 - ---- - -### Step 2: Pass 1 — 概览 - -只填 `### Pass 1: 概览`。不碰 Pass 2/3。 - -填写内容必须来自原文,不可推断: - -- **一句话总览**:论文类型 + 核心发现,一句话 -- **5 Cs 快速评估**: - - Category(RCT / 队列 / 综述 / 基础研究等) - - Context(领域共识,本文要解决什么) - - Correctness(初步直觉,逻辑有否明显漏洞) - - Contributions(1-3 条) - - Clarity(写作质量,图表可读性) -- **Figure 导读**(基于 fulltext 浏览各图 caption): - - 关键主图:列出,一句话概括要证明什么 - - 证据转折点:哪个 figure 是叙事关键转折 - - 需要重点展开的 supplementary - - 关键表格 - -填完立即保存。 - ---- - -### Step 3: Pass 2 — 精读还原 - -填 `### Pass 2: 精读还原`。**按 figure 顺序逐个处理**。 - -每处理完一个 figure 立即保存。 - -#### 图表类型定位(两步) - -**A: 读 chart-type-map**(prepare 输出中包含该路径)。这是关键词命中建议。 - -**B: Agent 读 caption 做最终判断** -1. 读该 figure 的 caption(来自 fulltext) -2. 打开 `atoms/chart-reading/INDEX.md`,对照 caption 内容判断图表类型 -3. chart-type-map 建议和 Agent 判断不一致时 → 以 Agent 判断为准 -4. 无法确定类型 → 跳过 chart guide,按通用结构分析 -5. 确定类型 → 读对应 chart-reading 指南,按指南中的检查清单分析 - -#### 每张 Figure 的子标题(固定,不可跳过) - -``` -**图像定位与核心问题**:页码 + 要回答什么问题 -**方法与结果**:实验设计 / 数据来源 / 技术手段;核心数据、趋势、对比 -**图表质量审查**:按 chart-reading 指南检查坐标轴、单位、误差棒、统计标注 -**作者解释**:作者在正文中对该图的解读 -**我的理解**:自己的理解(必须与作者解释做明显区分) -**疑点/局限**:用 `> [!warning]` 突出 -``` - -#### 每张 Table 的子标题(简化版) - -``` -回答什么问题、关键字段/分组、主要结果、我的理解、疑点/局限 -``` - -#### 所有 figure/table 处理完后 - -**关键方法补课**:简要解释不熟悉的实验技术(1-2 项) - -**主要发现与新意**: -- 发现 1:...(来源:Figure X) -- 发现 2:...(来源:Table Y) -- 每条发现必须标注来源(Figure 编号或正文段落) - ---- - -### Step 4: Postprocess(跑校验,修正问题) - -**`N` = Step 1 prepare 输出中的 `figures` 值,不要自己猜。** - -```bash -$PYTHON "$SKILL_DIR/scripts/pf_deep.py" postprocess-pass2 "" --figures -``` - -- 输出 `OK` → 继续 Step 5 -- 输出错误列表(含行号)→ 按提示修正,修正后重新跑 -- 最多 3 轮修正。3 轮后仍失败 → 报告剩余错误给用户 - ---- - -### Step 5: Pass 3 — 深度理解 - -填 `### Pass 3: 深度理解`。基于 Pass 1/2 已写内容。 - -- **假设挑战与隐藏缺陷**:隐含假设;放宽假设后结论还成立吗;缺少的关键引用;实验/分析技术潜在问题 -- **哪些结论扎实,哪些仍存疑**: - - 较扎实:... - - 仍存疑:...(用 `> [!warning]`) -- **Discussion 与 Conclusion 怎么读**:作者实际完成了什么;哪些有拔高;哪些是推测 -- **对我的启发**:研究设计、figure 组织、方法组合、未来工作 -- **遗留问题**:...(用 `> [!question]`) - ---- - -### Step 6: Final Validation - -```bash -$PYTHON "$SKILL_DIR/scripts/pf_deep.py" validate-note "" --fulltext "" -``` - -- 输出 `OK` → 告知用户精读完成 -- 输出错误 → 修正缺失项,直到通过 - -**如果 validate 反复失败(超过 3 轮)→ 先自查:** -1. 检查 note 结构:`## 🔍 精读` 骨架是否完整?是否被多次 `prepare` 破坏? -2. 检查 `Pass 2` 中每个 figure 的子标题是否完整(6 个子标题全部存在?) -3. 检查 Pass 1 和 Pass 2 之间是否有内容错位 -4. 检查 `--figures N` 是否和 paper 实际图片数一致(读 fulltext caption 核实) -5. 以上都确认无误还失败 → 报告全部错误给用户,不要继续重试 - -### Post-action: 保存/归档 - -如果用户要求保存精读成果到项目知识库,跳转至 `capture-project-knowledge.md`。 - ---- - -## Callout 格式规则 - -- `> [!important]` — 每个 main finding -- `> [!warning]` — 疑问、局限、证据边界、仍存疑条目 -- `> [!question]` — 遗留问题 -- **相邻 callout 之间必须有空行**(否则 Obsidian 合并): - - 正确:`> [!important] A\n\n> [!important] B` - - 错误:`> [!important] A\n> [!important] B` diff --git a/.opencode/skills/paperforge/molecules/discover-papers.md b/.opencode/skills/paperforge/molecules/discover-papers.md deleted file mode 100644 index 40e5ac05..00000000 --- a/.opencode/skills/paperforge/molecules/discover-papers.md +++ /dev/null @@ -1,159 +0,0 @@ -# discover-papers - -从文献库中发现和检索论文,返回候选清单(candidate list)。 - ---- - -## Pre-flight Checklist - -进入此 molecule 前,确认以下检查已完成。每项完成后标记 `[x]`: - -- [ ] SKILL.md Section 1a Pre-flight 全部通过 -- [ ] `$VAULT`、`$PYTHON`、`$LIT_DIR` 已从 bootstrap 获取 -- [ ] `capabilities` 已读取(至少确认 `metadata_search` 可用) -- [ ] intent 已确定为 `discover_papers` - ---- - -## 步骤 - -### Step 1: 解析用户搜索意图 - -提取以下信息(缺什么就问用户): -- **搜索词**:关键词、作者名、年份 -- **范围**:domain(如"骨科")、不指定=全库 -- **过滤条件**:OCR 状态、年份范围(`--year-from`/`--year-to`)、lifecycle - -### Step 2: 多臂搜索策略 - -打开 `atoms/retrieval-routing.md` 参照 Ladder A。 - -**先检查 `retrieve` 是否可用:** - -```bash -$PYTHON -m paperforge --vault "$VAULT" embed status --json -``` - -取 `data.db_exists` 和 `data.chunk_count`。仅当 `db_exists == true && chunk_count > 0` 时 `retrieve` 可用。 - -**根据用户意图选择搜索臂:** - -| 用户说... | 执行臂 | -|--------------------------------------|-------------------------------------| -| 技术术语/方法/参数("bipolar pulses"、 "galvanotaxis") | 先用 **Arm 1**(全文语义)再 **Arm 2**(元数据补充) | -| 作者+年份、主题关键词 | **Arm 2**(元数据)为主,可选 Arm 1 | -| "collection X里有什么" / "库里有什么" | **Arm 3** (collection 列举) | -| 宽泛主题 | **Arm 1** 先搜,**Arm 2** 补充 | - -#### Arm 1 — 全文语义搜索(仅当 `retrieve` 可用) - -```bash -$PYTHON -m paperforge --vault "$VAULT" retrieve "" --json --limit 30 -``` - -- 搜索 OCR 全文块的向量嵌入,能匹配正文 Methods/Results/Discussion 中的概念 -- 返回 JSON:`data.chunks[]` 包含 `paper_id`(即 `zotero_key`)、`section`、`page_number`、`chunk_text` -- 从 chunks 中提取唯一 paper_id 列表作为候选论文集合 -- 如果 `ok: false` → 跳过此臂 - -#### Arm 2 — 元数据 FTS 搜索(始终可用) - -```bash -$PYTHON -m paperforge --vault "$VAULT" search "" --json --limit 30 \ - [--domain ""] \ - [--year-from ] [--year-to ] \ - [--ocr ] \ - [--lifecycle ] -``` - -- FTS5 搜索标题、摘要、作者、期刊、domain、collection 路径 -- 返回 JSON:`data.matches[]` 包含 `zotero_key`、`title`、`year`、`first_author` 等 - -#### Arm 3 — Collection/Domain 列举(完整列表,不截断) - -```bash -$PYTHON -m paperforge --vault "$VAULT" context --collection "" --json -$PYTHON -m paperforge --vault "$VAULT" context --domain "" --json -``` - -- 返回 collection 或 domain 下**所有**论文(无 limit 截断) -- `context --collection` 按 collection 路径前缀匹配 -- `context --domain` 按 domain 字段精确匹配 -- 如果列表超过 50 篇,按年份/作者分组摘要后问用户是否缩小范围 - -#### 结果去重 - -- 合并所有臂的结果 -- 按 `zotero_key` 去重(保留首次出现) -- 如果同时用了 Arm 1+2:优先保留 Arm 1 的结果(包含全文命中信号) -- 去重后进入 Step 3 富化 - -#### 大规模结果集处理 - -- 如果任何一臂返回超过 20 篇,告知用户总数 -- 主动提供选项:加 limit、按年份/domain/作者缩小、只展示前 N 篇 -- 不要跳过或隐瞒大量结果的存在 - -#### 当 `retrieve` 不可用时的降级 - -如果 `embed status` 显示无向量索引: - -> 语义全文搜索不可用(向量索引未构建)。已降级到元数据搜索。运行 `paperforge embed build` 后可开启正文发现。 - -然后只运行 Arm 2(或 Arm 3 如果是 collection/domain 查询)。 - -### Step 3: Top-hit 富化(`paperforge paper-context`) - -对候选列表中的 **前 10 篇**(如果超过 10 篇,只富化前 10),调 `paper-context` 获取详细状态: - -```bash -$PYTHON -m paperforge --vault "$VAULT" paper-context --json -``` - -目的:拿到 `ocr_status`、`prior_notes` 数量、`analyze` 状态,帮助用户判断哪些可以直接读。 - -如果列表超过 10 篇,在展示时告知用户总数和只富化了前 N 篇。 - -### Step 4: 展示候选清单 - -格式(每条一行): - -``` -找到 N 篇匹配 ""(来自 [全文语义/元数据/collection列举]): - -[1] ABC12345 | Smith 2024 | 论文标题 | 骨科 | OCR: done | 精读: pending | 阅读笔记: 3 -[2] DEF67890 | Jones 2023 | 论文标题 | 骨科 | OCR: done | 精读: done | 阅读笔记: 0 -[3] GHI11111 | Wang 2022 | 论文标题 | 骨科 | OCR: pending | | 阅读笔记: 0 - -(共 N 篇,仅展示前 M 篇。如需查看更多,请说"更多"或指定年份/关键词缩小范围) -``` - -关键字段:`zotero_key`、`first_author`、`year`、`title`、`domain`、`ocr_status`、`deep_reading_status` - -**来源标注**:如果使用了多臂搜索,在顶部标注命中来自哪个搜索臂(`全文语义` / `元数据` / `collection列举`)。 - -### Step 5: 等用户选择后续操作 - -展示候选后不要自己决定下一步。等用户说: -- "读一下 [1]" → 路由到 `read-known-paper.md` -- "精读 [2]" → 路由到 `deep-analyze-paper.md` -- "换个关键词" / "refine" → 回到 Step 1 -- "不找了" → 结束 - ---- - -## 过渡路由 - -| 用户动作 | 路由目标 | -|---------|---------| -| 用户选了一篇论文 | `read-known-paper.md` | -| 用户想重新搜索、缩小范围 | 返回 Step 1(refine) | -| 用户想精读(deep read) | `deep-analyze-paper.md` | - ---- - -## 禁止 - -- 不要在搜索结果中替用户决定读哪篇 -- 不要在搜索阶段读全文 -- 不要对 0 结果硬猜路径 diff --git a/.opencode/skills/paperforge/molecules/find-supporting-evidence.md b/.opencode/skills/paperforge/molecules/find-supporting-evidence.md deleted file mode 100644 index 83f8a8c9..00000000 --- a/.opencode/skills/paperforge/molecules/find-supporting-evidence.md +++ /dev/null @@ -1,119 +0,0 @@ -# find-supporting-evidence - -为特定论点或问题查找文献中的证据支持。 - ---- - -## Pre-flight Checklist - -进入此 molecule 前,确认以下检查已完成: - -- [ ] SKILL.md Section 1a Pre-flight 全部通过 -- [ ] `$VAULT`、`$PYTHON`、`$LIT_DIR` 已从 bootstrap 获取 -- [ ] `capabilities` 已读取(关键:`rg`、`semantic_enabled`、`semantic_ready`) -- [ ] `atoms/retrieval-routing.md` 已就绪(提供检索梯级选择) -- [ ] intent 已确定为 `find_supporting_evidence` - ---- - -## 步骤 - -### Step 1: 解析用户证据需求 - -提取以下信息(缺什么就问用户): -- **论点/问题**:用户需要支持的具体主张或疑问 -- **范围限制**:是否限定特定 domain、作者、年份 -- **证据类型**:统计结果、方法引用、临床发现、机制解释等 - -### Step 2: 检索梯级选择(`atoms/retrieval-routing.md`) - -**先检查 `retrieve` 是否可用:** - -```bash -$PYTHON -m paperforge --vault "$VAULT" embed status --json -``` - -仅当 `data.db_exists == true && data.chunk_count > 0` 时 `retrieve` 可用。 - -根据运行时状态,从以下路径中选择: - -1. **Ladder B1**(首选,当 `retrieve` 可用)-- 语义全文快速定位 - - 直接调 `paperforge retrieve --json --limit 30` 获取语义匹配的全文块 - - `retrieve` 返回的 chunks 已包含 `section`、`page_number`、`chunk_text`、`paper_id` - - 按论文分组组织结果 → 直接进入 Step 3 展示 - - 如需精确定位验证,再用 `rg` / `grep` 在论文全文中确认 - -2. **Ladder B2**(备选,无 `retrieve` 但有 `rg`)-- 元数据→全文 grep - - 先用 `paperforge search` 生成元数据候选集 - - 用 `runtime-health` 或 `paper-context` 筛选有 OCR/全文的论文 - - 在解析后的全文中运行 `rg` 定位匹配片段 - -3. **Ladder C**(回退)-- 无 `rg` 时用 `grep`/`findstr` - - 同上流程但用系统搜索工具代替 rg - -4. **Ladder D**(补充)-- 当 Ladder B1 命中太少时,用 `search` 做元数据补充 - - 取 `retrieve` 结果 + `search` 结果的并集 - - 去重后展示更完整的证据列表 - -5. **元数据降级** -- 当没有任何论文有 OCR/全文时,只出候选论文列表,不做片段验证 - -### Step 3: 展示分组证据命中(grouped evidence hits with snippets) - -格式: - -``` -找到 N 条与 "<论点>" 相关的证据: - -=== Smith 2024 (zotero_key: ABC12345) === -[1] 第 5 页 · 方法部分 - 匹配片段:"...we used a randomized controlled trial..." - 上下文:在讨论实验设计时作者描述了... - -=== Jones 2023 (zotero_key: DEF67890) === -[2] 第 12 页 · 讨论部分 - 匹配片段:"...our findings align with previous meta-analyses..." - 上下文:作者比较本研究与已有综述的一致性... - -(共 N 条,来自 M 篇论文) -``` - -每项包含: -- 论文标识(`zotero_key`、标题) -- 章节或页码引用 -- 匹配片段 -- 简短上下文 - -### Step 4: 等用户选择后续操作 - -- "看 [1] 的详情" → 路由到 `read-known-paper.md` -- "保存 [1]" / "记录这条证据" → 路由到 `capture-project-knowledge.md` -- "换个关键词" → 回到 Step 1 -- "够了" → 结束 - ---- - -## 过渡路由 - -| 用户动作 | 路由目标 | -|---------|---------| -| 用户想查看论文详情 | `read-known-paper.md` | -| 用户想保存证据到项目知识 | `capture-project-knowledge.md` | -| 用户想重新搜索 | 回到 Step 1 | - ---- - -## 元数据降级 - -当 `runtime-health` 显示没有任何论文有 OCR 或全文可用时: - -> 精确证据验证受限 -- 降级到元数据级支持 - -仅输出候选论文列表(不含片段验证),告知用户当前无法做全文级证据检索。 - ---- - -## 禁止 - -- 不要在没有 OCR/全文的情况下虚构引用位置或片段 -- 不要在没有 `rg`/`grep` 验证的情况下把语义检索结果当最终证据 -- 不要在用户未要求时自动保存证据 diff --git a/.opencode/skills/paperforge/molecules/read-known-paper.md b/.opencode/skills/paperforge/molecules/read-known-paper.md deleted file mode 100644 index d3fc3248..00000000 --- a/.opencode/skills/paperforge/molecules/read-known-paper.md +++ /dev/null @@ -1,119 +0,0 @@ -# read-known-paper - -> [!warning] Safety Rules -> - 不要捏造论文未提及的内容 -> - 不要把推断写成论文事实——严格区分"文献说了什么"和"我推断什么" -> - 引用原文时标注来源页码/章节 -> - 论文未提及的内容明确说明"论文中未提及" - -交互式文献问答。不强制要求 OCR,但 OCR 完成后回答更准确。 - ---- - -## Pre-flight Checklist - -进入此 molecule 前,确认以下检查已完成: - -- [ ] SKILL.md Section 1a Pre-flight 全部通过 -- [ ] `$VAULT`、`$PYTHON` 已从 bootstrap 获取 -- [ ] 已确定唯一 paper(zotero_key / DOI / 标题) -- [ ] intent 已确定为 `read_known_paper` - ---- - -## 步骤 - -### Step 1: 定位论文 - -用户可能给 zotero_key、DOI、标题片段、作者+年份。按以下方式查找: - -**如果用户给的是 zotero_key:** - -```bash -$PYTHON -m paperforge --vault "$VAULT" paper-context --json -``` - -返回 JSON 包含 paper 元数据、OCR 状态、prior_notes 等。 - -**如果用户给的是 DOI、标题片段、作者+年份,先解析成候选:** - -```bash -$PYTHON -m paperforge --vault "$VAULT" paper-status "" --json -``` - -如果 `paper-status` 直接唯一命中,取返回的 `zotero_key` 再调 `paper-context`。 - -**paper-status 无法唯一定位时的备选:** - -```bash -$PYTHON -m paperforge --vault "$VAULT" search "" --json --limit 5 -``` - -如果多候选,列出让用户选;用户选定后,再用选中的 `zotero_key` 调 `paper-context`。 -如果无结果,告知用户并停止。 - -### Step 2: 加载文献内容 - -1. 从 paper-context 或 formal note frontmatter 获取:标题、作者、期刊、年份、domain -2. 读 `fulltext.md`(如果 OCR done)作为主要回答依据 -3. 如果 fulltext 不存在:"OCR 文本不可用,回答将基于元数据和公开信息" - -### Step 3: 展示论文信息 + 进入 Q&A - -``` -已加载: (<year>, <journal>) -作者: <authors> | Key: <zotero_key> | 领域: <domain> -OCR: done / 不可用 -结束对话时说"保存"即可保存讨论。 -请问有什么问题? -``` - -### Step 4: Q&A 循环 - -- 等待用户提问 -- 每次回答后等待下一个问题 -- 持续到用户说"保存"、"结束"、"完成" - -**回答原则:** -- 严格基于 fulltext.md 中的文本内容 -- 引用原文时标注来源页码/章节 -- 论文未提及的内容明确说明"论文中未提及" -- 区分"文献说了什么"和"我推断什么" - -### Step 5: 保存讨论 - -用户说"保存"、"结束"、"完成"时执行。 - -**收集 Q&A 对**,序列化为 JSON 数组: - -```json -[ - { - "question": "用户的问题", - "answer": "Agent 的回答", - "source": "user_question", - "timestamp": "2026-05-14T12:00:00+08:00" - } -] -``` - -`source`: `"user_question"`(用户提问)或 `"agent_analysis"`(Agent 主动分析)。 - -**调 discussion 模块:** - -```bash -$PYTHON -m paperforge.worker.discussion record <zotero_key> \ - --vault "$VAULT" \ - --agent pf-paper \ - --model "<current_model>" \ - --qa-pairs '<JSON_ARRAY>' -``` - -- 返回 `ok` → 告知用户已保存 -- 返回 `error` → 重试一次,仍失败则告知用户 - -> [!important] 不要自动保存。仅用户明确要求时执行。 - -### Post-action: 保存/归档 - -如果用户要求保存讨论内容到项目知识库,跳转至 `capture-project-knowledge.md`。 diff --git a/.opencode/skills/paperforge/scripts/pf_bootstrap.py b/.opencode/skills/paperforge/scripts/pf_bootstrap.py deleted file mode 100644 index 85dec80e..00000000 --- a/.opencode/skills/paperforge/scripts/pf_bootstrap.py +++ /dev/null @@ -1,302 +0,0 @@ -"""PaperForge bootstrap — single entry point for agent to discover vault state. - -No dependencies. Runs on ANY Python. Just reads paperforge.json + filesystem. - -Usage: - python pf_bootstrap.py # auto-discover vault from CWD - python pf_bootstrap.py --vault <path> - -Output (JSON to stdout): - { - "ok": true, - "vault_root": "D:\\...", - "paths": { - "literature_dir": "D:\\...\\Resources\\Literature", - "index_path": "D:\\...\\System\\PaperForge\\indexes\\formal-library.json", - "ocr_dir": "D:\\...\\System\\PaperForge\\ocr", - "exports_dir": "D:\\...\\System\\PaperForge\\exports" - }, - "domains": ["domain1", "domain2"], - "index_summary": {"domain1": 120, "domain2": 80}, - "python_candidate": "D:\\...\\python.exe", - "methodology_index": [ - {"id": "parameter-window-audit", "description": "比较多个研究的参数和剂量反应"}, - ... - ] - } - -If anything fails: ok=false, error explains why. -""" - -from __future__ import annotations - -import json -import subprocess -import sys -from pathlib import Path - -try: - import paperforge - SKILL_VERSION = paperforge.__version__ -except Exception: - SKILL_VERSION = "unknown" - - -def _find_paperforge_json(start: Path) -> Path | None: - current = start.resolve() - for _ in range(10): - candidate = current / "paperforge.json" - if candidate.exists(): - return candidate - parent = current.parent - if parent == current: - break - current = parent - return None - - -def _read_pf_config(pf_json: Path) -> dict: - with open(pf_json, encoding="utf-8") as f: - return json.load(f) - - -def _find_python_with_paperforge(vault: Path, pf_cfg: dict) -> tuple[str | None, bool]: - """Find a Python executable. Returns (candidate, verified_has_paperforge).""" - candidates = [] - - # 1. Explicit python_path in config - if pf_cfg.get("python_path"): - candidates.append(Path(pf_cfg["python_path"])) - - # 2. Common venv locations inside vault - venv_names = [".venv", ".paperforge-test-venv", "venv"] - exe_paths = ["Scripts/python.exe", "bin/python3"] - for vn in venv_names: - for ep in exe_paths: - p = vault / vn / ep - if p.exists(): - candidates.append(p) - - for candidate in candidates: - try: - result = subprocess.run( - [str(candidate), "-m", "paperforge", "--version"], - capture_output=True, text=True, timeout=10, - encoding="utf-8", errors="replace", - ) - if result.returncode == 0 and "paperforge" in result.stdout.lower(): - return (str(candidate), True) - except Exception: - continue - - # Fallback: check system python/python3 only (no paperforge verification) - for fallback in ["python", "python3"]: - try: - result = subprocess.run( - [fallback, "--version"], - capture_output=True, text=True, timeout=10, - encoding="utf-8", errors="replace", - ) - if result.returncode == 0: - return (fallback, False) - except Exception: - continue - - return (None, False) - - -def _scan_methodology_archive(pf_root: Path) -> list[dict]: - """Scan methodology archive directory for available method cards.""" - archive_dir = pf_root / "methodology" / "archive" - if not archive_dir.exists(): - return [] - - methods = [] - for f in sorted(archive_dir.glob("*.md")): - try: - text = f.read_text(encoding="utf-8") - # Extract first heading as title, first paragraph after "Use when" as description - title = "" - description = "" - in_use_when = False - for line in text.split("\n"): - stripped = line.strip() - if stripped.startswith("# ") and not title: - title = stripped.lstrip("# ").strip() - elif stripped.startswith("## Use when"): - in_use_when = True - elif in_use_when and stripped and not stripped.startswith("#"): - description = stripped - in_use_when = False - methods.append({ - "id": f.stem, - "title": title or f.stem, - "description": description or "(no description)", - }) - except Exception: - continue - return methods - - -DEFAULTS = { - "system_dir": "System", - "resources_dir": "Resources", - "literature_dir": "Literature", - "control_dir": "LiteratureControl", - "base_dir": "Bases", -} - - -def resolve_cfg(raw: dict) -> dict: - """Resolve config with vault_config nested support and legacy flat keys.""" - cfg = DEFAULTS.copy() - nested = raw.get("vault_config", {}) - if isinstance(nested, dict): - cfg.update({k: v for k, v in nested.items() if v}) - cfg.update({k: raw[k] for k in DEFAULTS if raw.get(k)}) - return cfg - - -def main() -> None: - import argparse - p = argparse.ArgumentParser(description="PaperForge bootstrap") - p.add_argument("--vault", default=None, help="Vault root path (auto-detect if omitted)") - args = p.parse_args() - - result: dict = {"ok": False} - - # --- 1. Find vault --- - if args.vault: - vault = Path(args.vault).resolve() - pf_json = vault / "paperforge.json" - if not pf_json.exists(): - result["error"] = f"paperforge.json not found at {vault}" - json.dump(result, sys.stdout, ensure_ascii=False, indent=2) - sys.exit(0) - else: - pf_json = _find_paperforge_json(Path.cwd()) - if pf_json is None: - result["error"] = "paperforge.json not found from CWD upward. Set --vault." - json.dump(result, sys.stdout, ensure_ascii=False, indent=2) - sys.exit(0) - vault = pf_json.parent - - result["vault_root"] = str(vault) - - # --- 2. Read config --- - try: - cfg = _read_pf_config(pf_json) - except Exception as e: - result["error"] = f"Cannot read paperforge.json: {e}" - json.dump(result, sys.stdout, ensure_ascii=False, indent=2) - sys.exit(0) - - cfg = resolve_cfg(cfg) - system_dir = cfg.get("system_dir", "System") - resources_dir = cfg.get("resources_dir", "Resources") - literature_dir = cfg.get("literature_dir", "Literature") - - # --- 3. Build paths from config --- - pf_root = vault / system_dir / "PaperForge" - - paths = { - "literature_dir": str(vault / resources_dir / literature_dir), - "index_path": str(pf_root / "indexes" / "formal-library.json"), - "ocr_dir": str(pf_root / "ocr"), - "exports_dir": str(pf_root / "exports"), - } - result["paths"] = paths - - # --- 4. List domains --- - lit_dir = Path(paths["literature_dir"]) - domains = sorted( - [d.name for d in lit_dir.iterdir() if d.is_dir()] - ) if lit_dir.exists() else [] - result["domains"] = domains - - # --- 5. Index summary --- - index_path = Path(paths["index_path"]) - index_summary: dict[str, int] = {} - if index_path.exists(): - try: - data = json.loads(index_path.read_text(encoding="utf-8")) - items = data.get("items", []) - if isinstance(items, dict): - items = items.values() - for item in items: - d = item.get("domain", "unknown") - index_summary[d] = index_summary.get(d, 0) + 1 - except Exception: - pass - result["index_summary"] = index_summary - - # --- 6. Find Python that has paperforge (best effort) --- - py_candidate, py_verified = _find_python_with_paperforge(vault, cfg) - if py_candidate: - result["python_candidate"] = py_candidate - result["python_verified"] = py_verified - else: - result["python_candidate"] = "python" - result["python_verified"] = False - - # --- 7. Memory layer state --- - memory_layer = {"available": False, "paper_count": 0, "fts_search": False, "vector_search": False} - idx_path = Path(paths["index_path"]) - dc_json = vault / ".obsidian" / "plugins" / "paperforge" / "data.json" - if idx_path.exists(): - try: - with open(idx_path, encoding="utf-8") as f: - data = json.load(f) - items = data.get("items", []) if isinstance(data, dict) else data - memory_layer["paper_count"] = len(items) - memory_layer["available"] = True - memory_layer["fts_search"] = True - except: - pass - plugin_data = None - if dc_json.exists(): - try: - with open(dc_json, encoding="utf-8") as f: - plugin_data = json.load(f) - vector_enabled = plugin_data.get("features", {}).get("vector_db", False) - memory_layer["vector_search"] = vector_enabled - except: - pass - result["memory_layer"] = memory_layer - - # --- 8. Scan methodology archive --- - result["methodology_index"] = _scan_methodology_archive(pf_root) - - # --- 9. Capability probes --- - rg_available = False - try: - rg_result = subprocess.run( - ["rg", "--version"], - capture_output=True, text=True, timeout=10, - encoding="utf-8", errors="replace", - ) - rg_available = rg_result.returncode == 0 - except Exception: - pass - - semantic_enabled = plugin_data.get("features", {}).get("vector_db", False) if plugin_data else False - - result["capabilities"] = { - "rg": rg_available, - "metadata_search": True, - "paper_context": True, - # Duplicates memory_layer.vector_search by design — bootstrap provides - # convenience capabilities without the indirection. runtime-health is authoritative. - "semantic_enabled": semantic_enabled, - # Placeholder: same as semantic_enabled until a separate readiness probe - # (e.g. runtime-health ping) is implemented. - "semantic_ready": semantic_enabled, - } - - result["ok"] = True - result["skill_version"] = SKILL_VERSION - json.dump(result, sys.stdout, ensure_ascii=False, indent=2) - - -if __name__ == "__main__": - main() diff --git a/.opencode/skills/paperforge/scripts/pf_deep.py b/.opencode/skills/paperforge/scripts/pf_deep.py deleted file mode 100644 index 99c2d0f3..00000000 --- a/.opencode/skills/paperforge/scripts/pf_deep.py +++ /dev/null @@ -1,1790 +0,0 @@ -#!/usr/bin/env python3 -"""Helpers for /pf-deep literature sessions.""" - -from __future__ import annotations - -import argparse -import json -import logging -import re -from collections.abc import Iterable -from dataclasses import dataclass, field -from pathlib import Path -from typing import TypeVar - -from paperforge.worker._utils import get_analyze_queue - -logger = logging.getLogger(__name__) - - -def _load_vault_config(vault: Path) -> dict: - """Load vault directory configuration — delegates to shared resolver. - - Preserves the public name for legacy callers. - """ - from paperforge.config import load_vault_config as _shared_load_vault_config - - return _shared_load_vault_config(vault) - - -def _paperforge_paths(vault: Path) -> dict[str, Path]: - """Build PaperForge path inventory for /pf-deep — delegates to shared resolver. - - Returns ocr, literature keys matching shared resolver output. - """ - from paperforge.config import paperforge_paths as _shared_paperforge_paths - - shared = _shared_paperforge_paths(vault) - return { - "ocr": shared["ocr"], - "literature": shared["literature"], - } - - -def _get_ocr_root(vault: Path) -> Path: - """Get OCR root path dynamically from config.""" - return _paperforge_paths(vault)["ocr"] - - -STUDY_HEADER = "## 🔍 精读" -FIGURE_SECTION_HEADER = "#### Figure-by-Figure 解析" -TABLE_SECTION_HEADER = "#### Table-by-Table 解析" - -FIGURE_SUBHEADINGS = [ - "图像定位与核心问题", - "方法与结果", - "图表质量审查", - "作者解释", - "我的理解", - "疑点 / 局限", -] - -TABLE_SUBHEADINGS = [ - "这张表在回答什么问题", - "关键字段 / 分组", - "主要结果", - "我的理解", - "在全文中的作用", - "疑点 / 局限", -] - - -@dataclass -class FigureEntry: - number: str - image_id: str - title: str - image_link: str - page: int | None - caption: str - is_supplementary: bool - additional_images: list[dict] = field(default_factory=list) - - -@dataclass -class TableEntry: - number: str - image_link: str - page: int | None - image_id: str = "" - - -def validate_extraction_completeness( - figures: list[FigureEntry], - tables: list[TableEntry], - fulltext: str, -) -> list[str]: - """Check if figure/table extraction is complete and flag issues. - - Returns a list of warning messages for the agent. - """ - warnings: list[str] = [] - - # Check for unmapped figures (number="?") - unmapped_figures = [f for f in figures if f.number == "?"] - if unmapped_figures: - warnings.append( - f"[!WARNING] {len(unmapped_figures)} 个 figure 未从 figure-map 解析到编号," - f"已标记为 '?'。Agent 需手动核对并补充真实编号(如 Fig 1, Fig 2...)。" - ) - - # Check for low figure count (Nature/Science papers typically have 4-8 figures) - main_figures = [f for f in figures if not f.is_supplementary] - if len(main_figures) < 2: - warnings.append( - f"[!WARNING] 仅提取到 {len(main_figures)} 个主图,数量异常偏低。" - f"请检查 OCR 质量或手动从 PDF 补充缺失的 figure。" - ) - - # Check for tables that might have been missed - # Look for table captions in fulltext that don't have corresponding images - table_caption_pattern = re.compile( - r"(?:Table|Extended Data Table|Supplementary Table)\s+(\d+)", - re.IGNORECASE, - ) - caption_numbers = set() - for match in table_caption_pattern.finditer(fulltext): - caption_numbers.add(match.group(1)) - - extracted_numbers = {t.number for t in tables} - missing_tables = caption_numbers - extracted_numbers - if missing_tables: - warnings.append( - f"[!WARNING] 检测到 {len(missing_tables)} 个表格引用未匹配到图像:" - f"Table {', '.join(sorted(missing_tables))}。" - f"Agent 需手动从 PDF 提取这些表格。" - ) - - return warnings - - -EVIDENCE_GUARDRAIL = ( - "**证据边界**:区分三层信息:`论文结果`、`作者解释`、`我的理解/推断`。" - "不要把样本内观察直接写成普遍规律,不要把相关性写成因果,不要把未进入最终模型的指标写成已被稳定验证的联合诊断结论。" -) - -REQUIRED_SECTIONS = [ - "**一句话总览**", - "**证据边界**", - "**Figure 导读**", - "**主要发现**", - "**较扎实**", - "**仍存疑**", - "**遗留问题**", -] - - -def extract_figures_from_fulltext(fulltext: str, figure_map: dict | None = None) -> list[FigureEntry]: - """Extract ALL image blocks from OCR fulltext markdown. - - Priority: use figure_map (from build_figure_map) if available for accurate - numbering and captions. Fallback to raw OCR image blocks with '?' numbering. - - Returns all images with their page number and image basename. - The agent reads fulltext to identify which are real figures and their numbers, - then passes image basenames to ensure-scaffold via --figures. - """ - figures: list[FigureEntry] = [] - pending_page: int | None = None - pending_image: str | None = None - - page_pattern = re.compile(r"<!-- page (\d+) -->") - image_pattern = re.compile(r"!\[\[(.+?)\]\]") - - # Build lookup from figure_map if available - map_lookup: dict[str, dict] = {} - if figure_map: - for entry in figure_map.get("figures", []): - map_lookup[entry.get("image_id", "")] = entry - for entry in figure_map.get("supplementary_figures", []): - map_lookup[entry.get("image_id", "")] = entry - - for raw_line in fulltext.splitlines(): - line = raw_line.strip() - - page_match = page_pattern.match(line) - if page_match: - pending_page = int(page_match.group(1)) - pending_image = None - continue - - image_match = image_pattern.match(line) - if image_match: - pending_image = image_match.group(1) - image_id = Path(pending_image).stem - - # Try figure_map first for accurate metadata - mapped = map_lookup.get(image_id) - if mapped: - figures.append( - FigureEntry( - number=mapped.get("number", "?"), - image_id=image_id, - title=mapped.get("caption", "")[:80] + "..." - if len(mapped.get("caption", "")) > 80 - else mapped.get("caption", "[Figure]"), - image_link=pending_image, - page=mapped.get("page", pending_page), - caption=mapped.get("caption", ""), - is_supplementary=mapped.get("type", "") == "supplementary_figure", - additional_images=mapped.get("additional_images", []), - ) - ) - else: - figures.append( - FigureEntry( - number="?", - image_id=image_id, - title="[OCR图像块]", - image_link=pending_image, - page=pending_page, - caption="", - is_supplementary=False, - ) - ) - continue - - return figures - - -def build_figure_plan( - figures: Iterable[FigureEntry], - important_supplementary: set[str] | None = None, -) -> list[FigureEntry]: - """Keep all main figures and only requested supplementary figures.""" - important_supplementary = important_supplementary or set() - planned: list[FigureEntry] = [] - for figure in figures: - if not figure.is_supplementary or figure.number in important_supplementary: - planned.append(figure) - return planned - - -T = TypeVar("T") - -def select_entries_by_numbers(entries: Iterable[T], numbers: set[str], attr: str = "number") -> list[T]: - """Select entries by their number field while preserving order.""" - if not numbers: - return [] - selected: list[T] = [] - for entry in entries: - if getattr(entry, attr) in numbers: - selected.append(entry) - return selected - - -def extract_tables_from_fulltext(fulltext: str, figure_map: dict | None = None) -> list[TableEntry]: - """Extract OCR table blocks from fulltext markdown. - - Supports multiple naming conventions: - - page_XXX_table_YYYY... (raw OCR) - - extended_data_table_N... (extended data) - - supplementary_table_N... (supplementary) - - Priority: use figure_map if available for accurate numbering. - """ - tables: list[TableEntry] = [] - pending_page: int | None = None - - page_pattern = re.compile(r"<!-- page (\d+) -->") - # Relaxed pattern: match any image path containing "table" in the filename - table_pattern = re.compile(r"!\[\[(.*table[^\]]*\.\w+)\]\]", re.IGNORECASE) - - # Build lookup from figure_map if available - map_lookup: dict[str, dict] = {} - if figure_map: - for entry in figure_map.get("tables", []): - map_lookup[entry.get("image_id", "")] = entry - for entry in figure_map.get("supplementary_tables", []): - map_lookup[entry.get("image_id", "")] = entry - - for raw_line in fulltext.splitlines(): - line = raw_line.strip() - if not line: - continue - - page_match = page_pattern.match(line) - if page_match: - pending_page = int(page_match.group(1)) - continue - - table_match = table_pattern.match(line) - if table_match: - image_link = table_match.group(1) - image_id = Path(image_link).stem - - # Try figure_map first - mapped = map_lookup.get(image_id) - if mapped: - tables.append( - TableEntry( - number=mapped.get("number", str(len(tables) + 1)), - image_link=image_link, - page=mapped.get("page", pending_page), - ) - ) - else: - # Fallback: cannot determine real table number from filename - # The number in filename (e.g. 75 in page_024_table_75...) is an OCR internal ID, NOT the table number - tables.append(TableEntry(number="?", image_link=image_link, page=pending_page)) - - return tables - - -# --------------------------------------------------------------------------- -# Chart Type Detection: per-figure subfigure type scanning -# --------------------------------------------------------------------------- - -CHART_TYPE_KEYWORDS: dict[str, list[str]] = { - "条形图与误差棒": [ - "bar", - "histogram", - "column", - "quantification", - "quantitative", - "relative expression", - "mRNA expression", - "protein level", - "BMD", - "BV/TV", - "Tb.N", - "Tb.Sp", - "intensity", - "score", - "ratio", - "percentage", - "proportion", - "fold change", - ], - "折线图与时间序列": [ - "curve", - "time", - "kinetics", - "release", - "cyclic", - "stability", - "degradation", - "profile", - "over time", - "day", - "week", - "duration", - "period", - "cycle", - "repeated", - ], - "热图与聚类图": [ - "heatmap", - "heat map", - "clustering", - "cluster", - "dendrogram", - "hierarchical", - "correlation matrix", - "expression matrix", - ], - "火山图与曼哈顿图": [ - "volcano", - "volcano plot", - "manhattan", - "-log10", - "log2fc", - "fold change", - "differential", - "DEG", - "DEM", - "upregulated", - "downregulated", - ], - "免疫荧光定量图": [ - "immunofluorescence", - "immunofluorescent", - "confocal", - "fluorescence", - "staining", - "3D reconstruction", - "overlay", - "DAPI", - " Alexa ", - "FITC", - "TRITC", - "Cy3", - "Cy5", - "SOX9", - "COL2A1", - "Piezo1", - "positive cells", - "mean intensity", - "fluorescent", - ], - "组织学半定量图": [ - "H&E", - "hematoxylin", - "eosin", - "Safranin", - "Fast Green", - "Masson", - "trichrome", - "histology", - "histological", - "tissue section", - "slide", - "stain", - "morphology", - "O'Driscoll", - "ICRS", - "Mankin", - "OARSI", - "Pineda", - ], - "桑基图与弦图": [ - "chord", - "Sankey", - "correlation network", - "interaction network", - "signaling network", - "proximity", - "correlation of metabolic", - "Spearman", - "Pearson correlation", - ], - "雷达图与漏斗图": [ - "radar", - "funnel", - "spider", - "web chart", - "polar", - ], - "GSEA富集图": [ - "GSEA", - "MSEA", - "enrichment", - "enrichment plot", - "enrichment score", - "NES", - "leading edge", - "pathway enrichment", - "metabolic set", - ], - "箱式图与小提琴图": [ - "box", - "boxplot", - "box plot", - "violin", - "whisker", - "quartile", - "median", - "IQR", - "outlier", - ], - "散点图与气泡图": [ - "scatter", - "bubble", - "dot plot", - "correlation plot", - "regression", - "linear fit", - "R²", - "correlation coefficient", - ], - "Western Blot条带图": [ - "Western", - "blot", - "WB", - "gel electrophoresis", - "SDS-PAGE", - "band", - "molecular weight", - "kDa", - ], - "显微照片与SEM图": [ - "SEM", - "TEM", - "microscopy", - "micrograph", - "morphology", - "surface", - "topography", - "nanostructure", - "porous", - "FE-SEM", - "scanning electron", - "transmission electron", - ], - "降维图(PCA-tSNE-UMAP)": [ - "PCA", - "t-SNE", - "tSNE", - "UMAP", - "MDS", - "dimensionality reduction", - "principal component", - "clustering plot", - "embedding", - ], - "网络图与通路图": [ - "pathway", - "network", - "signaling pathway", - "KEGG", - "Reactome", - "protein-protein interaction", - "PPI", - "regulatory network", - ], - "蛋白质结构图": [ - "protein structure", - "crystallography", - "NMR structure", - "AlphaFold", - "3D structure", - "homology modeling", - "docking", - ], - "森林图与Meta分析": [ - "forest plot", - "meta-analysis", - "meta analysis", - "pooled effect", - "heterogeneity", - "I²", - "Egger", - "funnel plot", - ], - "ROC与PR曲线": [ - "ROC", - "AUC", - "receiver operating", - "sensitivity", - "specificity", - "PR curve", - "precision-recall", - "diagnostic accuracy", - ], - "生存曲线": [ - "survival", - "Kaplan-Meier", - "KM curve", - "log-rank", - "hazard ratio", - "HR", - "OS", - "PFS", - "DFS", - "mortality", - ], -} - - -def detect_chart_types(caption: str) -> list[str]: - """Detect which chart-reading guides are relevant for a figure caption. - - Returns a deduplicated list of chart type names (Chinese) that match - keywords found in the caption. Matching is case-insensitive. - """ - caption_lower = caption.lower() - matched: list[str] = [] - for chart_type, keywords in CHART_TYPE_KEYWORDS.items(): - for kw in keywords: - if kw.lower() in caption_lower: - matched.append(chart_type) - break # one match per chart type is enough - return matched - - -def build_chart_type_map(figure_map: dict) -> dict: - """Build a per-figure chart-type reference map from figure-map.json. - - Output format: - { - "zotero_key": "...", - "figures": [ - { - "number": "1", - "label": "Figure 1", - "detected_chart_types": ["雷达图与漏斗图"], - "recommended_guides": ["chart-reading/雷达图与漏斗图.md"] - }, - ... - ], - "tables": [...] - } - """ - result: dict = { - "zotero_key": figure_map.get("zotero_key", ""), - "figures": [], - "tables": [], - } - - for fig in figure_map.get("figures", []): - caption = fig.get("caption", "") - types = detect_chart_types(caption) - entry = { - "number": fig.get("number", "?"), - "label": fig.get("label", ""), - "caption_preview": caption[:200] + "..." if len(caption) > 200 else caption, - "detected_chart_types": types, - "recommended_guides": [f"chart-reading/{t}.md" for t in types], - } - result["figures"].append(entry) - - for tbl in figure_map.get("tables", []): - caption = tbl.get("caption", "") - types = detect_chart_types(caption) - entry = { - "number": tbl.get("number", "?"), - "label": tbl.get("label", ""), - "caption_preview": caption[:200] + "..." if len(caption) > 200 else caption, - "detected_chart_types": types, - "recommended_guides": [f"chart-reading/{t}.md" for t in types], - } - result["tables"].append(entry) - - return result - - -CAPTION_PATTERNS = { - "main_figure": re.compile( - r"^(?:Figure|Fig\.?)\s*(\d+[a-zA-Z]?)(?:\s*[\.:|\-]?\s*)(.*?)$", - re.IGNORECASE, - ), - "supplementary_figure": re.compile( - r"^(?:Supplementary\s+(?:Figure|Fig\.?)\s*|Extended\s+Data\s+(?:Figure|Fig\.?)\s*|Suppl?\.?\s*(?:Figure|Fig\.?)\s*)(S?\d+[a-zA-Z]?)(?:\s*[\.:|\-]?\s*)(.*?)$", - re.IGNORECASE, - ), - "main_table": re.compile( - r"^(?:Table)\s*(\d+[a-zA-Z]?)(?:\s*[\.:|\-]?\s*)(.*?)$", - re.IGNORECASE, - ), - "supplementary_table": re.compile( - r"^(?:Supplementary\s+Table\s*|Suppl?\.?\s*Table\s*|Extended\s+Data\s+Table\s*)(S?\d+[a-zA-Z]?)(?:\s*[\.:|\-]?\s*)(.*?)$", - re.IGNORECASE, - ), -} - - -def build_figure_map(fulltext: str, zotero_key: str = "") -> dict: - """Build a caption-driven inventory of figures and tables. - - Scans fulltext.md line-by-line, detects formal captions, and pairs each - caption with the nearest image block within a window of adjacent pages. - """ - lines = fulltext.splitlines() - page_pattern = re.compile(r"<!-- page (\d+) -->") - image_pattern = re.compile(r"!\[\[(.+?)\]\]") - - # First pass: collect all images with their page and line index - all_images: list[dict] = [] - current_page: int | None = None - - for idx, raw_line in enumerate(lines): - line = raw_line.strip() - if not line: - continue - - page_match = page_pattern.match(line) - if page_match: - current_page = int(page_match.group(1)) - continue - - image_match = image_pattern.match(line) - if image_match: - all_images.append( - { - "link": image_match.group(1), - "id": Path(image_match.group(1)).stem, - "line_idx": idx, - "page": current_page, - } - ) - - # Second pass: match captions to nearest images - entries: list[dict] = [] - current_page = None - - for idx, raw_line in enumerate(lines): - line = raw_line.strip() - if not line: - continue - - page_match = page_pattern.match(line) - if page_match: - current_page = int(page_match.group(1)) - continue - - # Try each caption pattern - for entry_type, pattern in CAPTION_PATTERNS.items(): - m = pattern.match(line) - if not m: - continue - number = m.group(1) - caption_text = m.group(2).strip() if len(m.groups()) > 1 else "" - - # Find nearest image within window of adjacent pages (current ± 2 pages) - # Filter: figure captions → exclude table images; table captions → prefer table images - best_image = None - min_distance = float("inf") - is_table_type = entry_type in ("main_table", "supplementary_table") - - for img in all_images: - img_name_lower = img.get("link", "").lower() - img_is_table = "table" in img_name_lower - - # Figure captions: skip images that are clearly table screenshots - if not is_table_type and img_is_table: - continue - - if current_page is not None and img["page"] is not None: - page_diff = abs(img["page"] - current_page) - if page_diff <= 2: - distance = abs(img["line_idx"] - idx) - # Table captions: boost score for table images (+ priority) - score = distance - 100 if (is_table_type and img_is_table) else distance - if score < min_distance: - min_distance = score - best_image = img - - # Find additional images on the same page near the best image - additional_images = [] - if best_image: - for img in all_images: - if img is best_image: - continue - if img["page"] == best_image["page"]: - # Check if this image is adjacent to best_image (within reasonable line distance) - line_diff = abs(img["line_idx"] - best_image["line_idx"]) - if line_diff <= 5: # Within 5 lines - additional_images.append({"id": img["id"], "link": img["link"]}) - - entries.append( - { - "number": number, - "label": line.split(".")[0] if "." in line else line.split("|")[0].strip(), - "page": current_page, - "type": entry_type, - "caption": caption_text, - "image_link": best_image["link"] if best_image else None, - "image_id": best_image["id"] if best_image else None, - "additional_images": additional_images, - } - ) - break # one caption per line - - # Deduplicate by (type, number) keeping first - seen: set[tuple[str, str]] = set() - deduped: list[dict] = [] - for e in entries: - key = (e["type"], e["number"]) - if key not in seen: - seen.add(key) - deduped.append(e) - - return { - "zotero_key": zotero_key, - "generated_at": "", - "figures": [e for e in deduped if e["type"] == "main_figure"], - "tables": [e for e in deduped if e["type"] == "main_table"], - "supplementary_figures": [e for e in deduped if e["type"] == "supplementary_figure"], - "supplementary_tables": [e for e in deduped if e["type"] == "supplementary_table"], - } - - -def find_note_by_zotero_key(workspace_root: Path, zotero_key: str) -> Path | None: - """Resolve the formal literature note from the configured literature directory.""" - literature_root = _paperforge_paths(workspace_root)["literature"] - if not literature_root.exists(): - return None - - frontmatter_pattern = re.compile(rf'^\s*zotero_key:\s*"?{re.escape(zotero_key)}"?\s*$', re.MULTILINE) - for note_path in literature_root.rglob("*.md"): - try: - text = note_path.read_text(encoding="utf-8") - except UnicodeDecodeError: - text = note_path.read_text(encoding="utf-8", errors="ignore") - if frontmatter_pattern.search(text): - return note_path - return None - - -def render_study_scaffold(figures: Iterable[FigureEntry], tables: Iterable[TableEntry] | None = None) -> str: - """Render the Keshav three-pass deep-reading scaffold.""" - figure_list = list(figures) - table_list = list(tables or []) - - # Build figure blocks using the standard renderer for consistency - figure_blocks = [render_figure_block(fig) for fig in figure_list] - figure_section = ( - "\n\n".join(figure_blocks) if figure_blocks else "##### Figure 待补充\n\n- 暂未从 OCR 中解析到可用主图。" - ) - - # Build table blocks using the standard renderer for consistency - table_blocks = [render_table_block(table) for table in table_list] - table_section = "\n\n".join(table_blocks) if table_blocks else "- 暂未从 OCR 中解析到需单独展开的表格。" - - # Build completeness check prompt - figure_count = len(figure_list) - table_count = len(table_list) - completeness_prompt = ( - f"> [!warning] 图表完整性自查\n" - f"> 以下内容由脚本自动提取,请人工核对是否完整:\n" - f"> - **Figures**:提取到 {figure_count} 个图像块(含主图及补充材料图)\n" - f"> - **Tables**:提取到 {table_count} 个表格块\n" - f"> - **核对方法**:对照论文原文,检查是否所有 Figure / Table / Extended Data Figure / Supplementary Table 均已包含\n" - f"> - **如有遗漏**:请在下方直接补充对应的 figure/table 解析块,格式与现有块保持一致" - ) - - return ( - f"{STUDY_HEADER}\n\n" - f"{completeness_prompt}\n\n" - f"{EVIDENCE_GUARDRAIL}\n\n" - "### Pass 1: 概览\n\n" - "**一句话总览**\n" - "(待补充)\n\n" - "**5 Cs 快速评估**\n" - "- **Category**(类型):\n" - "- **Context**(上下文):\n" - "- **Correctness**(合理性初判):\n" - "- **Contributions**(贡献):\n" - "- **Clarity**(清晰度):\n\n" - "**Figure 导读**\n" - "- 关键主图:\n" - "- 证据转折点:\n" - "- 需要重点展开的 supplementary:\n" - "- 关键表格:\n\n" - "### Pass 2: 精读还原\n\n" - f"{FIGURE_SECTION_HEADER}\n\n" - f"{figure_section}\n\n" - f"{TABLE_SECTION_HEADER}\n\n" - f"{table_section}\n\n" - "#### 关键方法补课\n" - "- 方法 1:\n" - "- 方法 2:\n\n" - "#### 主要发现与新意\n" - "**主要发现**\n" - "- 发现 1:\n" - "- 发现 2:\n\n" - "### Pass 3: 深度理解\n\n" - "#### 假设挑战与隐藏缺陷\n" - "- 隐含假设:\n" - "- 如果放宽某个假设,结论还成立吗?\n" - "- 缺少哪些关键引用?\n" - "- 实验/分析技术的潜在问题:\n\n" - "#### 哪些结论扎实,哪些仍存疑\n" - "**较扎实**\n" - "- \n\n" - "**仍存疑**\n" - "- \n\n" - "#### Discussion 与 Conclusion 怎么读\n" - "- 作者真正完成了什么:\n" - "- 哪些地方有拔高:\n" - "- 哪些地方是推测:\n\n" - "#### 对我的启发\n" - "- 研究设计上:\n" - "- figure 组织上:\n" - "- 方法组合上:\n" - "- 未来工作想法:\n\n" - "#### 遗留问题\n" - "**遗留问题**\n" - "- \n" - ) - - -def render_figure_block(figure: FigureEntry) -> str: - lines = [ - f"> [!note]- Figure {figure.number}:{figure.title}", - f"> ![[{figure.image_link}]]", - ] - for add_img in figure.additional_images: - lines.append(f"> ![[{add_img['link']}]]") - lines.append(">") - for heading in FIGURE_SUBHEADINGS: - lines.append(f"> **{heading}**") - lines.append(">") - return "\n".join(lines) + "\n\n" - - -def render_table_block(table: TableEntry) -> str: - lines = [ - f"> [!note]- Table {table.number}", - f"> ![[{table.image_link}]]", - ">", - ] - for heading in TABLE_SUBHEADINGS: - lines.append(f"> **{heading}**") - lines.append(">") - return "\n".join(lines) + "\n\n" - - -def validate_selected_blocks( - note_text: str, figures: Iterable[FigureEntry], tables: Iterable[TableEntry] | None = None -) -> list[str]: - """Return missing selected figure/table embeds that must be repaired before generation.""" - missing: list[str] = [] - for figure in figures: - # Use prefix matching for heading to accommodate custom titles - heading_prefix = f"> [!note]- Figure {figure.number}:" - embed = f"![[{figure.image_link}]]" - # Check if any line starts with the heading prefix and embed exists - has_heading = any(line.strip().startswith(heading_prefix) for line in note_text.splitlines()) - if not has_heading or embed not in note_text: - missing.append(f"Figure {figure.number}") - - for table in tables or []: - heading_prefix = f"> [!note]- Table {table.number}" - embed = f"![[{table.image_link}]]" - has_heading = any(line.strip().startswith(heading_prefix) for line in note_text.splitlines()) - if not has_heading or embed not in note_text: - missing.append(f"Table {table.number}") - - return missing - - -def postprocess_pass2(note_text: str, figure_count: int) -> list[dict]: - """Validate Pass 2 figure blocks in a written deep-reading note. - - Args: - note_text: Full text of the formal note after AI wrote Pass 2. - figure_count: Expected number of figures from the skeleton. - - Returns: - List of error dicts with keys: type, severity, figure, line, message. - """ - errors: list[dict] = [] - lines = note_text.splitlines() - - # --- Collect figure callout positions --- - fig_pattern = re.compile(r'^>\s*\[!note\]-\s*Figure\s+(\d+)') - figure_entries: list[tuple[int, int]] = [] - for i, line in enumerate(lines, 1): - m = fig_pattern.match(line) - if m: - figure_entries.append((int(m.group(1)), i)) - - # Check 1 — Order - for j in range(1, len(figure_entries)): - prev_num, prev_line = figure_entries[j - 1] - curr_num, _curr_line = figure_entries[j] - if curr_num <= prev_num: - errors.append({ - "type": "order", - "severity": "error", - "figure": str(prev_num), - "line": prev_line, - "message": f"Figure {prev_num} appears before Figure {curr_num} (line {prev_line})", - }) - - # Check 5 — Duplicates - seen: dict[int, list[int]] = {} - for fig_num, line_num in figure_entries: - seen.setdefault(fig_num, []).append(line_num) - for fig_num, line_nums in seen.items(): - if len(line_nums) > 1: - errors.append({ - "type": "duplicate", - "severity": "error", - "figure": str(fig_num), - "line": line_nums[1], - "message": f"Figure {fig_num} appears at lines {line_nums[0]} and {line_nums[1]}", - }) - - # Check 6 — Missing - present_numbers = {fn for fn, _ in figure_entries} - for fn in range(1, figure_count + 1): - if fn not in present_numbers: - errors.append({ - "type": "missing", - "severity": "error", - "figure": str(fn), - "line": 0, - "message": f"Figure {fn} is missing from the note", - }) - - # Check 7 — Extra - for fn, ln in figure_entries: - if fn > figure_count: - errors.append({ - "type": "extra", - "severity": "error", - "figure": str(fn), - "line": ln, - "message": f"Figure {fn} not in skeleton (found at line {ln})", - }) - - # --- Build figure block ranges --- - figure_ranges: list[tuple[int, int, int]] = [] - for idx, (fig_num, start_line) in enumerate(figure_entries): - if idx + 1 < len(figure_entries): - end_line = figure_entries[idx + 1][1] - else: - end_line = len(lines) + 1 - figure_ranges.append((fig_num, start_line, end_line)) - - # Check 4 — Missing sub-headings - subheading_set = set(FIGURE_SUBHEADINGS) - for fig_num, start_line, end_line in figure_ranges: - block_lines = lines[start_line - 1:end_line - 1] - found_headings: set[str] = set() - for ll in block_lines: - stripped = ll.strip() - for h in subheading_set: - if stripped == f"> **{h}**": - found_headings.add(h) - for h in FIGURE_SUBHEADINGS: - if h not in found_headings: - errors.append({ - "type": "missing_subheading", - "severity": "error", - "figure": str(fig_num), - "line": start_line, - "message": f"Figure {fig_num}: sub-heading '{h}' not found", - }) - - # Check 3 — Empty blocks - for fig_num, start_line, end_line in figure_ranges: - block_lines = lines[start_line - 1:end_line - 1] - sub_indices: list[int] = [] - for bi, ll in enumerate(block_lines): - stripped = ll.strip() - for h in FIGURE_SUBHEADINGS: - if stripped == f"> **{h}**": - sub_indices.append(bi) - break - for si in range(len(sub_indices) - 1): - curr_idx = sub_indices[si] - next_idx = sub_indices[si + 1] - gap = block_lines[curr_idx + 1:next_idx] - gap_content = [l for l in gap if l.strip() not in ("", ">")] - if not gap_content: - heading_line = block_lines[curr_idx] - heading_text = heading_line.strip().replace("> **", "").replace("**", "") - errors.append({ - "type": "empty_block", - "severity": "warning", - "figure": str(fig_num), - "line": start_line + curr_idx, - "message": f"Figure {fig_num}: sub-heading '{heading_text}' has no content", - }) - - # Check 2 — Image bounds: detect stray ![[ outside callout blocks - for i, line in enumerate(lines, 1): - if "![" not in line: - continue - # A line that does not start with ">" cannot be inside a callout - if not line.strip().startswith(">"): - img_match = re.search(r'!\[\[([^\]]+)\]\]', line) - img_ref = img_match.group(0) if img_match else "unknown" - errors.append({ - "type": "image_bounds", - "severity": "error", - "line": i, - "message": f"Stray image at line {i}: {img_ref}", - }) - continue - inside_callout = False - for _fig_num, bl_start, bl_end in figure_ranges: - if bl_start <= i < bl_end: - inside_callout = True - break - if not inside_callout: - img_match = re.search(r'!\[\[([^\]]+)\]\]', line) - img_ref = img_match.group(0) if img_match else "unknown" - errors.append({ - "type": "image_bounds", - "severity": "error", - "line": i, - "message": f"Stray image at line {i}: {img_ref}", - }) - - return errors - - -def validate_callout_structure(note_text: str, figures: Iterable[FigureEntry]) -> list[str]: - """Check that the note keeps the required small set of section markers.""" - missing: list[str] = [] - for marker in REQUIRED_SECTIONS: - if marker not in note_text: - missing.append(marker) - - for figure in figures: - figure_heading_prefix = f"> [!note]- Figure {figure.number}:" - # Find the line that starts with this prefix - figure_idx = -1 - for _i, line in enumerate(note_text.splitlines()): - if line.strip().startswith(figure_heading_prefix): - figure_idx = note_text.find(line) - break - if figure_idx != -1: - # Find the next callout or heading after this figure block - figure_block = note_text[figure_idx:] - next_callout = figure_block.find("\n> [!note]-", 1) - next_heading = figure_block.find("\n##### ", 1) - next_section = figure_block.find("\n#### ", 1) - end_positions = [p for p in [next_callout, next_heading, next_section] if p != -1] - if end_positions: - figure_block = figure_block[: min(end_positions)] - required_local = [ - "**作者解释**", - "**我的理解**", - "**疑点 / 局限**", - ] - for marker in required_local: - if marker not in figure_block: - missing.append(f"{figure_heading_prefix}::{marker}") - - return missing - - -def validate_callout_spacing(note_text: str) -> list[str]: - """Check that consecutive callout blocks are separated by blank lines. - - In Obsidian, a callout block starts with `> [!type]` and continues as long - as subsequent lines start with `>`. An empty line or a non-`>` line ends - the block. If another `> [!type]` appears before the block is properly - ended, Obsidian merges them into a single callout instead of rendering - them as separate blocks. - """ - issues: list[str] = [] - lines = note_text.splitlines() - in_callout_block = False - - for idx, line in enumerate(lines): - stripped = line.strip() - is_callout_start = stripped.startswith("> [!") - is_callout_continuation = stripped.startswith("> ") or stripped == ">" - - if is_callout_start: - if in_callout_block: - issues.append( - f"Line {idx + 1}: Callout '{stripped[:60]}...' appears inside " - f"an ongoing callout block without a blank line separator." - ) - in_callout_block = True - elif not is_callout_continuation and stripped: - # Non-blank, non-callout line ends any callout block - in_callout_block = False - elif not stripped: - # Blank line ends the callout block - in_callout_block = False - - return issues - - -def validate_scaffold_residue(note_text: str) -> list[str]: - """Check for leftover scaffold template text that should have been removed. - - After the subagent fills the note, certain auto-generated instructional - callouts (e.g. "图表完整性自查") are no longer needed and should be - deleted. Their presence indicates incomplete cleanup. - """ - issues: list[str] = [] - residue_markers = [ - "图表完整性自查", - "以下内容由脚本自动提取", - "如有遗漏:请在下方直接补充", - ] - lines = note_text.splitlines() - for idx, line in enumerate(lines): - for marker in residue_markers: - if marker in line: - issues.append( - f"Line {idx + 1}: Scaffold residue detected: '{marker}'. " - "Remove this auto-generated instruction after verification." - ) - break # One issue per line is enough - return issues - - -def validate_redundant_headings(note_text: str) -> list[str]: - """Check for redundant heading + bold text pairs like: - - #### 遗留问题 - **遗留问题** - - The bold line duplicates the heading and should be removed. - """ - issues: list[str] = [] - lines = note_text.splitlines() - for idx in range(len(lines) - 1): - current = lines[idx].strip() - next_line = lines[idx + 1].strip() - # Match #### Heading followed by **Heading** - if current.startswith("#### ") and next_line.startswith("**") and next_line.endswith("**"): - heading_text = current[5:].strip() - bold_text = next_line[2:-2].strip() - if heading_text == bold_text: - issues.append( - f"Line {idx + 2}: Redundant bold text '{next_line}' " - f"duplicates heading '{current}'. Remove the bold line." - ) - return issues - - -def validate_deep_note( - note_text: str, - figures: Iterable[FigureEntry], - tables: Iterable[TableEntry] | None = None, -) -> list[str]: - """Run the full structural validation for a generated deep-reading note.""" - issues: list[str] = [] - - if STUDY_HEADER not in note_text: - issues.append(STUDY_HEADER) - if FIGURE_SECTION_HEADER not in note_text: - issues.append(FIGURE_SECTION_HEADER) - if TABLE_SECTION_HEADER not in note_text: - issues.append(TABLE_SECTION_HEADER) - - issues.extend(validate_selected_blocks(note_text, figures, tables)) - issues.extend(validate_callout_structure(note_text, figures)) - issues.extend(validate_callout_spacing(note_text)) - - issues.extend(validate_scaffold_residue(note_text)) - issues.extend(validate_redundant_headings(note_text)) - - required_headings = [ - "### Pass 1: 概览", - "### Pass 2: 精读还原", - "### Pass 3: 深度理解", - "#### Figure-by-Figure 解析", - "#### Table-by-Table 解析", - "#### 关键方法补课", - "#### 主要发现与新意", - "#### 假设挑战与隐藏缺陷", - "#### 哪些结论扎实,哪些仍存疑", - "#### Discussion 与 Conclusion 怎么读", - "#### 对我的启发", - "#### 遗留问题", - ] - for heading in required_headings: - if heading not in note_text: - issues.append(heading) - - return issues - - -def _ensure_section_with_blocks(note_text: str, header: str, blocks: list[str]) -> str: - if not blocks: - return note_text - - updated = note_text - if header not in updated: - updated = updated.rstrip() + f"\n\n{header}\n\n" - - for block in blocks: - heading = block.splitlines()[0] - if heading not in updated: - insertion_point = updated.find(header) - if insertion_point == -1: - updated = updated.rstrip() + f"\n\n{header}\n\n{block}\n" - else: - header_end = updated.find("\n", insertion_point) - if header_end == -1: - header_end = len(updated) - insert_at = header_end + 1 - updated = updated[:insert_at] + "\n" + block + "\n\n" + updated[insert_at:] - - return updated - - -def ensure_study_section( - note_text: str, figures: Iterable[FigureEntry], tables: Iterable[TableEntry] | None = None -) -> str: - """Append the study scaffold if it does not yet exist.""" - figure_list = list(figures) - table_list = list(tables or []) - if STUDY_HEADER in note_text: - updated = _ensure_section_with_blocks( - note_text, FIGURE_SECTION_HEADER, [render_figure_block(fig) for fig in figure_list] - ) - updated = _ensure_section_with_blocks( - updated, TABLE_SECTION_HEADER, [render_table_block(table) for table in table_list] - ) - return updated - - stripped = note_text.rstrip() - scaffold = render_study_scaffold(figure_list, table_list) - if stripped: - return f"{stripped}\n\n{scaffold}\n" - return f"{scaffold}\n" - - -def _read_json(path: Path) -> dict: - if not path.exists(): - return {} - try: - return json.loads(path.read_text(encoding="utf-8")) - except (json.JSONDecodeError, UnicodeDecodeError): - return {} - - -def prepare_deep_reading(vault: Path, zotero_key: str, force: bool = False) -> dict: - """Automate all mechanical pre-reading steps for /pf-deep. - - Returns a dict with: - - status: "ok" | "error" - - message: human-readable summary - - formal_note: path to the formal literature note - - fulltext_md: path to OCR fulltext - - figure_map: path to figure-map.json - - chart_type_map: path to chart-type-map.json - - figures: list of extracted figures - - tables: list of extracted tables - - chart_recommendations: list of recommended chart-reading guides - """ - result: dict = { - "status": "error", - "message": "", - "zotero_key": zotero_key, - "formal_note": None, - "fulltext_md": None, - "figure_map": None, - "chart_type_map": None, - "figures": [], - "tables": [], - "chart_recommendations": [], - } - - paths = _paperforge_paths(vault) - literature_root = paths["literature"] - ocr_root = paths["ocr"] - - # 0. Refresh canonical index entry so upstream frontmatter changes are reflected - try: - from paperforge.worker.asset_index import refresh_index_entry - - refresh_index_entry(vault, zotero_key) - except Exception: - pass - - # 1. Look up from canonical index (fast, no file scanning) - domain: str | None = None - title: str = "" - try: - from paperforge.worker.asset_index import read_index - - index = read_index(vault) - if index: - items = index.get("items") if isinstance(index, dict) else index - for entry in items: - if entry.get("zotero_key") == zotero_key: - domain = entry.get("domain") - title = entry.get("title", "") - if not entry.get("analyze"): - result["message"] = ( - f"[ERROR] analyze=False for {zotero_key}. " - "Set analyze: true in formal note frontmatter first." - ) - return result - if entry.get("deep_reading_status") == "done" and not force: - result["message"] = ( - "[WARN] deep_reading_status already 'done'. Use --force to re-run." - ) - return result - break - except Exception: - pass - - # 2. Fallback: search formal notes by zotero_key pattern - if domain is None: - if literature_root.exists(): - for candidate in literature_root.rglob(f"*.md"): - try: - text = candidate.read_text(encoding="utf-8") - key_match = re.search(rf'^zotero_key:\s*"?{re.escape(zotero_key)}"?', text, re.MULTILINE) - if key_match: - domain_match = re.search(r"^domain:\s*(.+)$", text, re.MULTILINE) - domain = domain_match.group(1).strip() if domain_match else candidate.parent.name - from paperforge.adapters.obsidian_frontmatter import read_frontmatter_bool - - if not read_frontmatter_bool(candidate, "analyze"): - result["message"] = ( - f"[ERROR] analyze=False for {zotero_key} in {candidate}. " - "Set analyze: true first." - ) - return result - break - except Exception: - continue - - if domain is None: - result["message"] = f"[ERROR] Paper not found for zotero_key={zotero_key}. Run paperforge sync first." - return result - - # 3. Find formal note by domain + key - formal_note: Path | None = None - if literature_root.exists() and domain: - domain_dir = literature_root / domain - if domain_dir.exists(): - for candidate in domain_dir.glob("*.md"): - if candidate.name == f"{zotero_key}.md" or candidate.name.startswith(f"{zotero_key} -"): - formal_note = candidate - break - if formal_note is None: - for candidate in domain_dir.glob("*.md"): - try: - text = candidate.read_text(encoding="utf-8") - if re.search(rf'^zotero_key:\s*"?{re.escape(zotero_key)}"?', text, re.MULTILINE): - formal_note = candidate - break - except Exception: - continue - - if formal_note is None: - for candidate in literature_root.rglob("*.md"): - try: - text = candidate.read_text(encoding="utf-8") - if re.search(rf'^zotero_key:\s*"?{re.escape(zotero_key)}"?', text, re.MULTILINE): - formal_note = candidate - break - except Exception: - continue - - if formal_note is None: - result["message"] = f"[ERROR] Formal note not found for {zotero_key}. Run paperforge sync first." - return result - - result["formal_note"] = str(formal_note) - - # 4. Check OCR / fulltext availability - ocr_dir = ocr_root / zotero_key - fulltext_md = ocr_dir / "fulltext.md" - meta_path = ocr_dir / "meta.json" - - if not fulltext_md.exists(): - result["message"] = f"[ERROR] OCR fulltext not found: {fulltext_md}. Run paperforge ocr first." - return result - - ocr_status = "pending" - if meta_path.exists(): - meta = _read_json(meta_path) - ocr_status = str(meta.get("ocr_status", "pending")).strip().lower() - - if ocr_status != "done": - result["message"] = f"[ERROR] OCR status='{ocr_status}', not 'done'. Wait for OCR or check meta.json." - return result - - result["fulltext_md"] = str(fulltext_md) - - # Save original note content for rollback - original_note_text = formal_note.read_text(encoding="utf-8") - - # Track files for rollback - written_paths: list[Path] = [] - - try: - # 6. Run figure-map - figure_map_path = ocr_dir / "figure-map.json" - fulltext_text = fulltext_md.read_text(encoding="utf-8") - figure_map = build_figure_map(fulltext_text, zotero_key=zotero_key) - figure_map["generated_at"] = ( - __import__("datetime").datetime.now(__import__("datetime").timezone.utc).isoformat() - ) - figure_map_path.parent.mkdir(parents=True, exist_ok=True) - figure_map_path.write_text(json.dumps(figure_map, ensure_ascii=False, indent=2), encoding="utf-8") - written_paths.append(figure_map_path) - result["figure_map"] = str(figure_map_path) - - # 7. Run chart-type-scan - chart_type_map_path = ocr_dir / "chart-type-map.json" - chart_type_result = build_chart_type_map(figure_map) - chart_type_map_path.write_text(json.dumps(chart_type_result, ensure_ascii=False, indent=2), encoding="utf-8") - written_paths.append(chart_type_map_path) - result["chart_type_map"] = str(chart_type_map_path) - - # Collect recommendations - all_guides: set[str] = set() - for fig in chart_type_result.get("figures", []): - for guide in fig.get("recommended_guides", []): - all_guides.add(guide) - for tbl in chart_type_result.get("tables", []): - for guide in tbl.get("recommended_guides", []): - all_guides.add(guide) - result["chart_recommendations"] = sorted(all_guides) - - # 8. Extract figures/tables and run ensure-scaffold - figure_candidates = extract_figures_from_fulltext(fulltext_text, figure_map) - table_candidates = extract_tables_from_fulltext(fulltext_text, figure_map) - planned_figures = build_figure_plan(figure_candidates) - planned_tables = table_candidates # Include all tables by default - - note_text = formal_note.read_text(encoding="utf-8") - updated = ensure_study_section(note_text, planned_figures, planned_tables) - formal_note.write_text(updated, encoding="utf-8") - - skeleton_errors = validate_callout_structure(updated, planned_figures) - if skeleton_errors: - logger.warning("Skeleton validation found %d issues: %s", len(skeleton_errors), "; ".join(skeleton_errors[:3])) - - result["figures"] = [ - {"number": f.number, "image_id": f.image_id, "page": f.page, "title": f.title} for f in planned_figures - ] - result["tables"] = [{"number": t.number, "page": t.page, "image_link": t.image_link} for t in planned_tables] - - result["status"] = "ok" - result["message"] = ( - f"[OK] Prepared {zotero_key}\n" - f" Formal note: {formal_note}\n" - f" Fulltext: {fulltext_md}\n" - f" Figures: {len(planned_figures)} | Tables: {len(planned_tables)}\n" - f" Chart guides: {len(all_guides)} recommended" - ) - return result - - except Exception as exc: - # Rollback: delete partial files and restore note - for path in written_paths: - if path.exists(): - path.unlink() - formal_note.write_text(original_note_text, encoding="utf-8") - result["message"] = f"[ERROR] Prepare failed for {zotero_key}: {exc}. Rolled back changes." - return result - - -def scan_deep_reading_queue(vault: Path) -> list[dict]: - """Scan canonical index for analyze=true + deep_reading_status!=done entries. - - Thin wrapper around get_analyze_queue() in _utils.py. - """ - all_records = get_analyze_queue(vault) - # Filter: only records that still need deep reading - queue = [r for r in all_records if r["deep_reading_status"] != "done"] - # Sort: OCR completed first, then by domain, then by key - queue.sort( - key=lambda row: ( - 0 if row["ocr_status"] == "done" else 1, - row["domain"], - row["zotero_key"], - ) - ) - return queue - - -import contextlib -import sys - -# Fix Windows console encoding for Unicode output -if sys.platform == "win32": - try: - sys.stdout.reconfigure(encoding="utf-8") - except AttributeError: - # Python < 3.7 fallback - import codecs - - sys.stdout = codecs.getwriter("utf-8")(sys.stdout.buffer) - - -def main() -> int: - parser = argparse.ArgumentParser(description="Helpers for /pf-deep note scaffolding") - subparsers = parser.add_subparsers(dest="command", required=True) - - figure_parser = subparsers.add_parser("figure-index", help="Extract figures from fulltext markdown") - figure_parser.add_argument("fulltext", type=Path) - - scaffold_parser = subparsers.add_parser("ensure-scaffold", help="Append a deep-reading scaffold to a note") - scaffold_parser.add_argument("note", type=Path) - scaffold_parser.add_argument( - "--fulltext", type=Path, help="Optional fulltext markdown used to build figure headings" - ) - scaffold_parser.add_argument("--figures", help="Comma-separated selected figure numbers to insert") - scaffold_parser.add_argument("--tables", help="Comma-separated selected table numbers to insert") - - validate_parser = subparsers.add_parser( - "validate-selected", help="Validate selected figure/table embeds are present in the note" - ) - validate_parser.add_argument("note", type=Path) - validate_parser.add_argument( - "--fulltext", type=Path, required=True, help="OCR fulltext markdown used to resolve figure/table embeds" - ) - validate_parser.add_argument("--figures", help="Comma-separated selected figure numbers to validate") - validate_parser.add_argument("--tables", help="Comma-separated selected table numbers to validate") - - full_validate_parser = subparsers.add_parser( - "validate-note", help="Run the full structural validation for a deep-reading note" - ) - full_validate_parser.add_argument("note", type=Path) - full_validate_parser.add_argument( - "--fulltext", type=Path, required=True, help="OCR fulltext markdown used to resolve figure/table embeds" - ) - full_validate_parser.add_argument("--figures", help="Comma-separated selected figure numbers to validate") - 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, 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, 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") - - map_parser = subparsers.add_parser("figure-map", help="Build caption-driven figure/table map from OCR fulltext") - map_parser.add_argument("fulltext", type=Path, help="OCR fulltext markdown path") - map_parser.add_argument("--key", default="", help="Zotero key for output metadata") - map_parser.add_argument("--out", type=Path, help="Optional output JSON path (default: stdout)") - - chart_type_parser = subparsers.add_parser( - "chart-type-scan", help="Scan figure captions for chart types and recommend chart-reading guides" - ) - chart_type_parser.add_argument( - "figure_map", type=Path, help="Path to figure-map.json generated by figure-map command" - ) - chart_type_parser.add_argument("--out", type=Path, help="Optional output JSON path (default: stdout)") - - pp_parser = subparsers.add_parser("postprocess-pass2", help="Validate Pass 2 figure blocks after AI writing") - pp_parser.add_argument("note", type=Path, help="Path to the formal note") - pp_parser.add_argument("--figures", type=int, required=True, help="Expected number of figures") - pp_parser.add_argument("--format", choices=["json", "text"], default="text", help="Output format") - - 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": - print(json.dumps(result, ensure_ascii=False, indent=2)) - else: - print(result.get("message", "Unknown result")) - return 0 if result["status"] == "ok" else 1 - - if args.command == "postprocess-pass2": - note_text = args.note.read_text(encoding="utf-8") - errors = postprocess_pass2(note_text, args.figures) - if args.format == "json": - print(json.dumps(errors, ensure_ascii=False, indent=2)) - else: - if not errors: - print("OK: Pass 2 structure is clean") - else: - print(f"Found {len(errors)} issues:") - for e in errors: - print(f" [{e['severity']}] {e['message']}") - return 1 if errors else 0 - - if args.command == "figure-index": - fulltext = args.fulltext.read_text(encoding="utf-8") - for figure in extract_figures_from_fulltext(fulltext): - print( - f"Figure {figure.number}\tid={figure.image_id}\tpage={figure.page}\timage={figure.image_link}\ttitle={figure.title}" - ) - return 0 - - if args.command == "ensure-scaffold": - note_text = args.note.read_text(encoding="utf-8") - figures: list[FigureEntry] = [] - tables: list[TableEntry] = [] - if args.fulltext: - fulltext = args.fulltext.read_text(encoding="utf-8") - # Auto-load figure-map.json from same directory as fulltext if available - figure_map = None - figure_map_path = args.fulltext.parent / "figure-map.json" - if figure_map_path.exists(): - with contextlib.suppress(json.JSONDecodeError, UnicodeDecodeError): - figure_map = json.loads(figure_map_path.read_text(encoding="utf-8")) - figure_candidates = extract_figures_from_fulltext(fulltext, figure_map) - table_candidates = extract_tables_from_fulltext(fulltext, figure_map) - selected_figures = {part.strip() for part in (args.figures or "").split(",") if part.strip()} - selected_tables = {part.strip() for part in (args.tables or "").split(",") if part.strip()} - figures = ( - select_entries_by_numbers(figure_candidates, selected_figures, attr="image_id") - if selected_figures - else build_figure_plan(figure_candidates) - ) - tables = select_entries_by_numbers(table_candidates, selected_tables) if selected_tables else [] - updated = ensure_study_section(note_text, figures, tables) - args.note.write_text(updated, encoding="utf-8") - return 0 - - if args.command == "validate-selected": - note_text = args.note.read_text(encoding="utf-8") - fulltext = args.fulltext.read_text(encoding="utf-8") - figure_candidates = extract_figures_from_fulltext(fulltext) - table_candidates = extract_tables_from_fulltext(fulltext) - selected_figures = {part.strip() for part in (args.figures or "").split(",") if part.strip()} - selected_tables = {part.strip() for part in (args.tables or "").split(",") if part.strip()} - figures = ( - select_entries_by_numbers(figure_candidates, selected_figures, attr="image_id") if selected_figures else [] - ) - tables = select_entries_by_numbers(table_candidates, selected_tables) if selected_tables else [] - missing = validate_selected_blocks(note_text, figures, tables) - if missing: - for item in missing: - print(item) - return 1 - print("OK") - return 0 - - if args.command == "validate-note": - note_text = args.note.read_text(encoding="utf-8") - fulltext = args.fulltext.read_text(encoding="utf-8") - # Auto-load figure-map.json from same directory as fulltext if available - figure_map = None - figure_map_path = args.fulltext.parent / "figure-map.json" - if figure_map_path.exists(): - with contextlib.suppress(json.JSONDecodeError, UnicodeDecodeError): - figure_map = json.loads(figure_map_path.read_text(encoding="utf-8")) - figure_candidates = extract_figures_from_fulltext(fulltext, figure_map) - table_candidates = extract_tables_from_fulltext(fulltext) - selected_figures = {part.strip() for part in (args.figures or "").split(",") if part.strip()} - selected_tables = {part.strip() for part in (args.tables or "").split(",") if part.strip()} - figures = ( - select_entries_by_numbers(figure_candidates, selected_figures, attr="image_id") - if selected_figures - else build_figure_plan(figure_candidates) - ) - tables = select_entries_by_numbers(table_candidates, selected_tables) if selected_tables else [] - issues = validate_deep_note(note_text, figures, tables) - if issues: - for item in issues: - print(item) - return 1 - print("OK") - return 0 - - if args.command == "queue": - queue = scan_deep_reading_queue(args.vault) - if args.format == "json": - # Convert Path objects to strings for JSON serialization - serializable = [] - for row in queue: - r = dict(row) - if hasattr(r.get("note_path"), "__fspath__"): - r["note_path"] = str(r["note_path"]) - serializable.append(r) - print(json.dumps(serializable, ensure_ascii=False, indent=2)) - else: - ready = [row for row in queue if row["ocr_status"] == "done"] - blocked = [row for row in queue if row["ocr_status"] != "done"] - print(f"# 待精读队列 ({len(queue)} 篇)") - print() - if ready: - print(f"## 就绪 ({len(ready)} 篇) — OCR 完成") - print() - for row in ready: - print(f"- `{row['zotero_key']}` | {row['domain']} | {row['title']}") - print() - if blocked: - print(f"## 阻塞 ({len(blocked)} 篇) — 等待 OCR") - print() - for row in blocked: - print(f"- `{row['zotero_key']}` | {row['domain']} | {row['title']} | OCR: {row['ocr_status']}") - print() - if not ready and not blocked: - print("暂无待精读论文。") - return 0 - - if args.command == "figure-map": - fulltext = args.fulltext.read_text(encoding="utf-8") - result = build_figure_map(fulltext, zotero_key=args.key) - result["generated_at"] = __import__("datetime").datetime.now(__import__("datetime").timezone.utc).isoformat() - if args.out: - args.out.parent.mkdir(parents=True, exist_ok=True) - args.out.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8") - print(f"figure-map: wrote {args.out}") - else: - print(json.dumps(result, ensure_ascii=False, indent=2)) - return 0 - - if args.command == "chart-type-scan": - figure_map = _read_json(args.figure_map) - if not figure_map: - print("ERROR: Could not read figure-map.json or file is empty", file=__import__("sys").stderr) - return 1 - result = build_chart_type_map(figure_map) - if args.out: - args.out.parent.mkdir(parents=True, exist_ok=True) - args.out.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8") - print(f"chart-type-scan: wrote {args.out}") - else: - print(json.dumps(result, ensure_ascii=False, indent=2)) - return 0 - - return 1 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/.opencode/skills/paperforge/workflows/project-engineering.md b/.opencode/skills/paperforge/workflows/project-engineering.md deleted file mode 100644 index 85a269f6..00000000 --- a/.opencode/skills/paperforge/workflows/project-engineering.md +++ /dev/null @@ -1,26 +0,0 @@ -# Project Engineering - -When user asks about PaperForge codebase issues (branch, code review, feature, -dashboard, memory layer, user feedback, errors, installation, Git, Zotero, -BetterBibTeX, OCR, plugin): - -1. Read `AGENTS.md` and `README.md` for architecture context -2. Use `git log --oneline` and `git diff` to understand recent changes -3. Search codebase with grep/glob as needed -4. Run diagnostics: `python -m paperforge doctor` (if applicable) -5. Present findings and recommend fixes - -Do NOT modify code without user confirmation. - -## Review Dimensions - -When auditing branches, code, or user-reported issues, check: - -1. **Source of truth clarity:** Is data stored in one canonical location? -2. **Derived index rebuildability:** Can SQLite be rebuilt from JSONL? -3. **Agent routing stability:** Will the skill router pick the right workflow? -4. **Obsidian file integrity:** Are .md files still readable with valid frontmatter? -5. **User flow length:** Has the number of manual steps decreased? -6. **Cross-platform safety:** Paths use `/`, Python detection works on Win/Mac/Linux, Git is accessible. -7. **Data loss risk:** Does any operation silently drop records? -8. **Deprecation hygiene:** Are old functions properly wrapped or removed? diff --git a/docs/superpowers/plans/2026-05-20-pdf-annotation-layer-pdfjs-internal.md b/docs/superpowers/plans/2026-05-20-pdf-annotation-layer-pdfjs-internal.md deleted file mode 100644 index f348cf33..00000000 --- a/docs/superpowers/plans/2026-05-20-pdf-annotation-layer-pdfjs-internal.md +++ /dev/null @@ -1,450 +0,0 @@ -# PaperForge PDF Annotation Layer PDF.js Internal Route 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:** Replace the current DOM-only PDF annotation overlay with a PDF.js-internal, viewport-aligned overlay implementation that renders imported Zotero annotations visibly and correctly inside Obsidian's native PDF viewer. - -**Architecture:** Keep the proven Python import/cache pipeline unchanged. Rewrite the plugin-side overlay path so that it discovers PDF.js page internals, mounts one overlay per `pageView.div`, aligns each layer with `window.pdfjsLib.setLayerDimensions(...)`, and repaints through page render events instead of generic DOM mutation retries. - -**Tech Stack:** Python 3.10+, SQLite cache, Obsidian plugin CommonJS (`main.js`), PDF.js runtime internals, optional `monkey-around`, Vitest, pytest - ---- - -## File Map - -### Python Files - -- Modify: `paperforge/annotation/cache.py` — keep cache contract stable; preserve optional page size metadata but do not expand scope further unless strictly needed for plugin fallback - -### Plugin Runtime Files - -- Modify: `paperforge/plugin/main.js` — replace DOM-only overlay flow with PDF.js internal route -- Modify: `paperforge/plugin/src/testable.js` — add pure helpers for viewport math and internal guard logic -- Modify: `paperforge/plugin/styles.css` — simplify overlay visuals after alignment is fixed; keep only necessary visible-state styles - -### Tests - -- Modify: `paperforge/plugin/tests/runtime.test.mjs` — internal guard logic, viewport percentage helpers, page grouping helpers -- Modify: `paperforge/plugin/tests/commands.test.mjs` — only if annotation command bridge arguments change -- Modify: `paperforge/plugin/tests/errors.test.mjs` — soft-disable behavior when PDF internals unavailable - -### Reference Inputs - -- Read: `docs/superpowers/specs/2026-05-20-pdf-annotation-layer-pdfjs-internal-design.md` -- Read: `patch-plan/plugin-overlay-adapter.md` -- Read: `reports/04-obsidian-pdf-overlay-feasibility.md` - ---- - -## Task 1: Freeze and Test the Current Data Contract - -**Files:** -- Modify: `paperforge/plugin/tests/runtime.test.mjs` -- Reference: `paperforge/annotation/cache.py` - -- [ ] **Step 1: Write failing tests for the plugin-side annotation cache assumptions** - -Cover: - -- annotation objects use full field names (`type`, `position`, `page_index`, `zotero_attachment_key`) -- viewport renderer can consume imported Zotero-style `position.rects` -- cache grouping by `page_index` stays zero-based - -- [ ] **Step 2: Run the focused plugin runtime tests to verify failure** - -Run: `cd paperforge/plugin && npx vitest run tests/runtime.test.mjs` - -Expected: FAIL for the new assertions. - -- [ ] **Step 3: Implement the minimal testable helpers needed by the new renderer** - -Add pure helpers in `src/testable.js` for: - -- `getAnnotationPosition(ann)` -- `getAnnotationRectsFromPosition(position)` -- `normalizePdfRectToViewportPercent(rect, viewBox)` -- `groupAnnotationsByPageIndex(annotations)` - -- [ ] **Step 4: Re-run the focused plugin runtime tests** - -Run: `cd paperforge/plugin && npx vitest run tests/runtime.test.mjs` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add paperforge/plugin/src/testable.js paperforge/plugin/tests/runtime.test.mjs -git commit -m "test: lock annotation cache and viewport helper contracts" -``` - ---- - -## Task 2: Add Internal PDF.js Discovery Guards - -**Files:** -- Modify: `paperforge/plugin/main.js` -- Modify: `paperforge/plugin/src/testable.js` -- Modify: `paperforge/plugin/tests/errors.test.mjs` - -- [ ] **Step 1: Write failing tests for internal availability checks** - -Cover: - -- `window.pdfjsLib.setLayerDimensions` detection -- internal PDF view shape guard -- graceful disablement when page internals are missing - -- [ ] **Step 2: Run the focused error/runtime tests to verify failure** - -Run: `cd paperforge/plugin && npx vitest run tests/errors.test.mjs tests/runtime.test.mjs` - -Expected: FAIL for the new internal guard assertions. - -- [ ] **Step 3: Implement minimal runtime guards** - -Add helpers for: - -- `hasPdfJsLayerAlignment(windowObj)` -- `resolvePdfInternalHandle(view)` -- `canRenderPdfInternalOverlay(handle)` - -In `main.js`, emit one concise disablement log instead of continuing with broken rendering. - -- [ ] **Step 4: Re-run the focused tests** - -Run: `cd paperforge/plugin && npx vitest run tests/errors.test.mjs tests/runtime.test.mjs` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add paperforge/plugin/main.js paperforge/plugin/src/testable.js paperforge/plugin/tests/errors.test.mjs paperforge/plugin/tests/runtime.test.mjs -git commit -m "feat: add PDF.js internal overlay guards" -``` - ---- - -## Task 3: Replace MutationObserver Rendering with PDF Viewer Event Hooks - -**Files:** -- Modify: `paperforge/plugin/main.js` -- Modify: `paperforge/plugin/tests/runtime.test.mjs` - -- [ ] **Step 1: Write failing tests for page-render event wiring decisions** - -Cover: - -- first file load fetches annotations once -- page repaint requests are page-scoped -- generic subtree mutation is no longer the primary repaint driver - -- [ ] **Step 2: Run the focused runtime tests to verify failure** - -Run: `cd paperforge/plugin && npx vitest run tests/runtime.test.mjs` - -Expected: FAIL for the new page event wiring assertions. - -- [ ] **Step 3: Implement minimal event-driven lifecycle** - -In `main.js`: - -- patch the native PDF view load path to capture the internal viewer handle -- subscribe to whichever of these are available from the runtime shape: - - `pagerendered` - - `textlayerrendered` - - `annotationlayerrendered` - - `scalechanged` -- keep MutationObserver only as a narrowly scoped fallback if an event path is unavailable - -- [ ] **Step 4: Re-run the focused runtime tests** - -Run: `cd paperforge/plugin && npx vitest run tests/runtime.test.mjs` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add paperforge/plugin/main.js paperforge/plugin/tests/runtime.test.mjs -git commit -m "feat: drive overlay repaint from PDF viewer events" -``` - ---- - -## Task 4: Add PageView-Aligned Layer Management - -**Files:** -- Modify: `paperforge/plugin/main.js` -- Modify: `paperforge/plugin/styles.css` -- Modify: `paperforge/plugin/tests/runtime.test.mjs` - -- [ ] **Step 1: Write failing tests for page layer creation/alignment helpers** - -Cover: - -- one layer per page -- layer mounted on `pageView.div` -- `setLayerDimensions(layer, viewport)` called when available -- page clear only removes PaperForge-owned rects - -- [ ] **Step 2: Run the focused runtime tests to verify failure** - -Run: `cd paperforge/plugin && npx vitest run tests/runtime.test.mjs` - -Expected: FAIL for the new layer-management assertions. - -- [ ] **Step 3: Implement minimal layer manager behavior** - -In `main.js` add logic equivalent to: - -- `getOrCreateOverlayLayer(pageView)` -- `alignOverlayLayer(layer, pageView.viewport)` -- `clearOverlayPage(pageNumber)` - -Simplify CSS so the layer behaves like a PDF++-style aligned layer instead of a guessed absolute box. - -- [ ] **Step 4: Re-run the focused runtime tests** - -Run: `cd paperforge/plugin && npx vitest run tests/runtime.test.mjs` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add paperforge/plugin/main.js paperforge/plugin/styles.css paperforge/plugin/tests/runtime.test.mjs -git commit -m "feat: align overlay layers to PDF.js page viewports" -``` - ---- - -## Task 5: Rewrite Rect Rendering to Use Viewport ViewBox - -**Files:** -- Modify: `paperforge/plugin/main.js` -- Modify: `paperforge/plugin/src/testable.js` -- Modify: `paperforge/plugin/tests/runtime.test.mjs` - -- [ ] **Step 1: Write failing tests for viewport-based rect normalization** - -Cover: - -- input rect format is `[left, bottom, right, top]` -- Y mirroring uses viewport/viewBox coordinates -- output percentages are derived from `viewBox`, not guessed page sizes - -- [ ] **Step 2: Run the focused runtime tests to verify failure** - -Run: `cd paperforge/plugin && npx vitest run tests/runtime.test.mjs` - -Expected: FAIL for the new normalization assertions. - -- [ ] **Step 3: Implement minimal viewport-based renderer** - -In `main.js`: - -- stop using hardcoded letter/A4 fallback as the primary mapping path -- normalize each rect against `pageView.viewport.viewBox` -- preserve readonly state, popover hooks, and local action metadata - -In `src/testable.js`, expose the pure geometry transform for tests. - -- [ ] **Step 4: Re-run the focused runtime tests** - -Run: `cd paperforge/plugin && npx vitest run tests/runtime.test.mjs` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add paperforge/plugin/main.js paperforge/plugin/src/testable.js paperforge/plugin/tests/runtime.test.mjs -git commit -m "feat: render annotation rects from PDF.js viewport coordinates" -``` - ---- - -## Task 6: Reconnect Popovers and Local Actions on the New Layer - -**Files:** -- Modify: `paperforge/plugin/main.js` -- Modify: `paperforge/plugin/styles.css` -- Modify: `paperforge/plugin/tests/errors.test.mjs` - -- [ ] **Step 1: Write failing tests for local interaction behavior on aligned layers** - -Cover: - -- click target still opens popover -- readonly imported annotations stay readonly -- local annotations still expose delete/edit path - -- [ ] **Step 2: Run the focused tests to verify failure** - -Run: `cd paperforge/plugin && npx vitest run tests/errors.test.mjs tests/runtime.test.mjs` - -Expected: FAIL for the new interaction assertions. - -- [ ] **Step 3: Implement the minimal interaction reconnect** - -Ensure the new viewport-aligned layer still attaches: - -- click handlers -- hover handlers -- popover placement -- local delete/edit actions - -- [ ] **Step 4: Re-run the focused tests** - -Run: `cd paperforge/plugin && npx vitest run tests/errors.test.mjs tests/runtime.test.mjs` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add paperforge/plugin/main.js paperforge/plugin/styles.css paperforge/plugin/tests/errors.test.mjs paperforge/plugin/tests/runtime.test.mjs -git commit -m "feat: reconnect popovers and local actions on aligned overlay layers" -``` - ---- - -## Task 7: Preserve or Soft-Disable Selection-to-Create - -**Files:** -- Modify: `paperforge/plugin/main.js` -- Modify: `paperforge/plugin/tests/runtime.test.mjs` - -- [ ] **Step 1: Write failing tests for selection create behavior under the new lifecycle** - -Cover one of two explicitly acceptable outcomes: - -- selection-create still works after text layer readiness, or -- selection-create is soft-disabled behind a clear console/user message pending follow-up - -- [ ] **Step 2: Run the focused runtime tests to verify failure** - -Run: `cd paperforge/plugin && npx vitest run tests/runtime.test.mjs` - -Expected: FAIL for the new selection-path assertions. - -- [ ] **Step 3: Implement the minimal supported outcome** - -Preferred: - -- bind selection-create only after page text-layer readiness on the internal page route - -Fallback if runtime shape prevents safe support in this phase: - -- disable selection-create temporarily with a documented message and follow-up note in the spec/plan - -- [ ] **Step 4: Re-run the focused runtime tests** - -Run: `cd paperforge/plugin && npx vitest run tests/runtime.test.mjs` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add paperforge/plugin/main.js paperforge/plugin/tests/runtime.test.mjs -git commit -m "feat: stabilize selection create for internal PDF overlay route" -``` - ---- - -## Task 8: Clean Up Temporary Debug Instrumentation - -**Files:** -- Modify: `paperforge/plugin/main.js` -- Modify: `paperforge/plugin/styles.css` -- Modify: `paperforge/plugin/tests/errors.test.mjs` - -- [ ] **Step 1: Write failing tests for production logging expectations** - -Cover: - -- plugin keeps concise lifecycle logs only -- no `visual-probe`, `hitTest`, `pageBox`, `layerBox`, `rectBox` debug spam in the stable path - -- [ ] **Step 2: Run the focused tests to verify failure** - -Run: `cd paperforge/plugin && npx vitest run tests/errors.test.mjs tests/runtime.test.mjs` - -Expected: FAIL because the temporary debugging code is still present. - -- [ ] **Step 3: Remove or gate temporary diagnostics** - -Keep only concise logs such as: - -- overlay enabled/disabled -- annotation count fetched -- one clear internal-shape failure message when applicable - -- [ ] **Step 4: Re-run the focused tests** - -Run: `cd paperforge/plugin && npx vitest run tests/errors.test.mjs tests/runtime.test.mjs` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add paperforge/plugin/main.js paperforge/plugin/styles.css paperforge/plugin/tests/errors.test.mjs paperforge/plugin/tests/runtime.test.mjs -git commit -m "chore: remove temporary PDF overlay debug instrumentation" -``` - ---- - -## Task 9: Real-World Verification on a Zotero-Backed PDF - -**Files:** -- Modify only if verification reveals real defects - -- [ ] **Step 1: Run Python and plugin automated test slices** - -Run: - -```bash -python -m pytest tests/unit/ tests/cli/ -v --tb=short -cd paperforge/plugin && npx vitest run -``` - -Expected: PASS. - -- [ ] **Step 2: Verify against the real Literature-hub vault PDF** - -Use the existing real paper already exercised during debugging: - -- vault PDF path under `System/Zotero/storage/5AWBHQ59/...pdf` -- expected paper id `9ILH6L6W` - -Confirm: - -- imported Zotero highlights are visible on the correct lines -- zoom preserves alignment -- scrolling to later annotated pages preserves alignment -- popover opens on click - -- [ ] **Step 3: If verification fails, fix only the discovered root cause and re-run Step 1 and Step 2** - -- [ ] **Step 4: Commit final verification-backed fixes** - -```bash -git add paperforge/plugin/main.js paperforge/plugin/src/testable.js paperforge/plugin/styles.css paperforge/plugin/tests/runtime.test.mjs paperforge/plugin/tests/errors.test.mjs paperforge/annotation/cache.py -git commit -m "fix: finalize PDF.js internal annotation overlay route" -``` - ---- - -## Completion Criteria - -Do not consider this plan complete until all of the following are true: - -- the plugin no longer relies on generic page DOM geometry as its primary placement method -- layer alignment uses PDF.js viewer internals or a clearly documented fallback path -- a real imported Zotero annotation is visibly aligned on the target PDF inside Obsidian -- broad debug instrumentation has been removed from the normal path -- automated test slices pass diff --git a/docs/superpowers/specs/2026-05-20-pdf-annotation-layer-pdfjs-internal-design.md b/docs/superpowers/specs/2026-05-20-pdf-annotation-layer-pdfjs-internal-design.md deleted file mode 100644 index 127b6b09..00000000 --- a/docs/superpowers/specs/2026-05-20-pdf-annotation-layer-pdfjs-internal-design.md +++ /dev/null @@ -1,315 +0,0 @@ -# PaperForge PDF Annotation Layer: PDF.js Internal Route Design - -**Date:** 2026-05-20 -**Status:** Proposed -**Audience:** Maintainers, contributors, agentic implementers - ---- - -## 1. Summary - -PaperForge should replace its current DOM-only PDF overlay experiment with a PDF.js-internal overlay architecture modeled on PDF++. - -The critical design change is: - -1. Stop treating Obsidian's native PDF viewer as a generic DOM tree. -2. Patch the viewer lifecycle to obtain PDF.js page objects (`pageView`, `viewport`). -3. Mount annotation layers on `pageView.div`. -4. Align each layer with `window.pdfjsLib.setLayerDimensions(layer, pageView.viewport)`. -5. Render annotation rects in the same viewport coordinate space used by PDF.js. - -This design keeps the existing Python-side import/cache model, but replaces the plugin-side rendering path. - ---- - -## 2. Why A New Design Is Needed - -The first overlay implementation proved that PaperForge can: - -- import real Zotero annotations from `zotero.sqlite` -- normalize them correctly into `annotations.db` -- expose them to the plugin through `annotation-cache.json` -- compute real screen-space rect DOM nodes - -However, real-world debugging against a live paper showed that the current rendering strategy is still wrong. - -### 2.1 Proven Facts From Debugging - -For the real Zotero annotation `LJ8FR3BS`: - -- Zotero SQLite, `annotations.db`, and `annotation-cache.json` all agree on: - - `parent_key = 5AWBHQ59` - - `type = highlight` - - `color = #aaaaaa` - - `pageIndex = 0` - - `rects = [[61.137, 323.189, 291.762, 331.788], ...]` -- The plugin creates real overlay rect DOM nodes. -- The overlay rect has a valid `getBoundingClientRect()`. -- `document.elementFromPoint()` at the rect center returns the overlay node itself (`SELF`). - -So the failure is not: - -- SQLite parsing -- import normalization -- JSON cache shape -- missing DOM nodes -- trivial `z-index` loss - -### 2.2 Actual Failure Mode - -The failure is architectural: - -- the plugin currently renders into a generic absolute-positioned layer inferred from `.page`, `canvas`, and related DOM boxes -- PDF++ does not do that -- PDF++ renders through PDF.js page internals and explicitly aligns the layer with `setLayerDimensions(..., viewport)` - -The DOM-only route is therefore considered disproven for PaperForge. - ---- - -## 3. Product Decision - -### Chosen Plugin-Side Route - -- **Keep:** Python import pipeline, `annotations.db`, CLI/cache bridge -- **Replace:** DOM-only overlay placement logic -- **Adopt:** PDF.js internal page/viewport aligned rendering - -### Why - -This is the smallest change that addresses the proven root cause without reopening the Python architecture. - ---- - -## 4. Goals - -### 4.1 Primary Goals - -1. Render imported Zotero annotations visibly and reliably inside Obsidian's native PDF viewer. -2. Use the same page viewport coordinate system as PDF.js. -3. Re-render overlays from PDF viewer lifecycle events instead of generic DOM mutation heuristics. -4. Preserve existing local annotation create/edit/delete capabilities where possible. - -### 4.2 Secondary Goals - -1. Remove noisy debug-only rendering paths from the plugin. -2. Reduce overlay re-render churn during scroll/zoom. -3. Keep the current `annotation-cache.json` read path intact. - -### 4.3 Non-Goals - -1. No rewrite of the Python annotation import layer. -2. No custom embedded PDF reader. -3. No Zotero write-back in this phase. -4. No attempt to support every Obsidian version; private PDF internals remain a managed compatibility risk. - ---- - -## 5. Design Principles - -1. **Python data path stays stable.** The rendering rewrite should not disturb the proven SQLite import/cache pipeline. -2. **Render in PDF.js coordinate space, not guessed DOM space.** -3. **Use native viewer lifecycle events, not broad MutationObserver retries.** -4. **Patch the smallest internal surface that exposes `pageView` and `viewport`.** -5. **Fail soft.** If PDF internals are unavailable, the plugin should disable overlays for that session rather than degrade the whole viewer. - ---- - -## 6. Research Basis - -### 6.1 PDF++ Reference Pattern - -PDF++ creates its layer like this conceptually: - -```ts -const pageDiv = pageView.div; -let layer = pageDiv.querySelector('div.pdf-plus-backlink-highlight-layer'); -if (!layer) { - layer = pageDiv.createDiv('pdf-plus-backlink-highlight-layer'); - window.pdfjsLib.setLayerDimensions(layer, pageView.viewport); -} -``` - -It also renders rectangles using the viewport's `viewBox`, not guessed page DOM dimensions. - -### 6.2 Current PaperForge Gap - -PaperForge currently: - -- appends `.pf-annotation-overlay` directly to `.page` -- infers layer geometry from visible DOM boxes -- derives coordinates from cached page sizes and percentages -- uses MutationObserver as a coarse refresh trigger - -This is close enough to create DOM nodes, but not close enough to guarantee native viewer alignment. - ---- - -## 7. Proposed Architecture - -## 7.1 System Overview - -```text -annotation-cache.json - ↓ -Obsidian plugin file→paper resolution - ↓ -PDF view internal patch - ↓ -PDF.js pageView / viewport discovery - ↓ -viewport-aligned overlay layer - ↓ -annotation rect rendering + local actions -``` - -## 7.2 Internal Patch Boundary - -The plugin should patch the native PDF viewer only far enough to gain access to: - -- current PDF file path -- `pdfViewer` -- `pdfViewer.getPageView(pageNumberIndex)` -- PDF.js event bus events such as: - - `pagerendered` - - `textlayerrendered` - - `annotationlayerrendered` - - `scalechanged` - -## 7.3 Layer Alignment - -For each annotated page: - -1. Get `pageView`. -2. Get or create `.pf-annotation-overlay` on `pageView.div`. -3. Call `window.pdfjsLib.setLayerDimensions(layer, pageView.viewport)`. -4. Render rects as percentages of `pageView.viewport.viewBox`. - -## 7.4 Coordinate Mapping - -Input rects remain Zotero/PDF coordinates: - -```text -[left, bottom, right, top] -``` - -Rendering should: - -1. mirror Y into viewport display space -2. normalize against `viewport.viewBox` -3. set CSS percentages relative to the aligned overlay layer - -This removes the need to guess page dimensions from: - -- canvas pixels -- DOM boxes -- hardcoded letter/A4 fallbacks - -## 7.5 Event-Driven Refresh - -Replace broad mutation-driven rerenders with page/viewer events: - -- initial file load → fetch annotations once -- `pagerendered` / `textlayerrendered` → ensure page layer exists, repaint that page only -- `scalechanged` → clear/rebuild visible layers -- annotation create/delete/update → invalidate local page cache and repaint affected page only - ---- - -## 8. Data Contracts - -## 8.1 Cache Contract - -The existing `annotation-cache.json` contract remains valid. - -Required fields per annotation: - -- `id` -- `paper_id` -- `zotero_key` -- `zotero_attachment_key` -- `type` -- `page_index` -- `selected_text` -- `comment` -- `color` -- `sort_index` -- `position.rects` -- `is_readonly` - -`page_width/page_height` may remain in the cache as optional debug metadata, but the plugin should no longer depend on them for final placement when `viewport` is available. - -## 8.2 Plugin Runtime State - -The plugin should maintain: - -- current `pdfPath` -- current `paperId` -- current annotations grouped by `page_index` -- per-page overlay layer references -- per-page rendered annotation ids for cheap repaint decisions - ---- - -## 9. Module/File Strategy - -Respect current repo reality: - -- `paperforge/plugin/main.js` remains the runtime entry point -- `paperforge/plugin/src/testable.js` remains the home for pure helpers - -Recommended logical slices inside `main.js`: - -1. PDF internal discovery helpers -2. overlay layer manager -3. viewport-based rect renderer -4. event registration / teardown -5. selection-to-create and popover actions - -No forced multi-file runtime split is required for this phase. - ---- - -## 10. Risks - -### Risk 1: Obsidian PDF internals differ across versions - -Mitigation: - -- add version/shape guards around internal access -- log a single concise disablement message -- avoid crashing plugin startup - -### Risk 2: Cannot reliably obtain `pageView` from current patch point - -Mitigation: - -- probe multiple internal access paths already documented by PDF++ research -- patch at file-load and render-event boundaries -- fall back to disabling overlays rather than continuing with DOM-only placement - -### Risk 3: Selection-create path depends on text layer timing - -Mitigation: - -- register selection handlers only after page text layer render readiness -- keep current create command bridge unchanged until overlay rendering is stable - ---- - -## 11. Acceptance Criteria - -The phase is complete when all of the following are true on a real Zotero-backed PDF in Obsidian: - -1. Imported annotations appear visibly on the correct page and over the correct text region. -2. Overlays remain aligned after zoom changes. -3. Overlays remain aligned after scrolling to newly rendered pages. -4. Console no longer depends on broad debug spam to understand rendering state. -5. The plugin no longer depends on guessed letter/A4 sizing for primary placement. -6. Existing local create/delete behavior still works, or is explicitly soft-disabled with a documented reason. - ---- - -## 12. Rollout Note - -This design supersedes only the plugin-side rendering path of the earlier 2026-05-20 annotation-layer design. The Python-side architecture, database boundary, and cache contract remain in force. diff --git a/experiments/obsidian-overlay-prototype/README.md b/experiments/obsidian-overlay-prototype/README.md deleted file mode 100644 index 6a23517a..00000000 --- a/experiments/obsidian-overlay-prototype/README.md +++ /dev/null @@ -1,22 +0,0 @@ -# Obsidian PDF Overlay Prototype - -> Placeholder for overlay prototype code. -> -> After Phase 2 implementation, this directory will contain: -> - `patch-pdf-viewer.ts` — monkey-around patches for PDFViewerChild -> - `overlay-layer.ts` — overlay DOM management -> - `rect-renderer.ts` — rectangle rendering with coordinates -> - `selection-handler.ts` — text selection detection -> - `popover.ts` — annotation edit popover -> - `annotation-fetcher.ts` — execFile Python bridge -> - `types.ts` — shared interfaces - -## Reference - -PDF++ source: https://github.com/RyotaUshio/obsidian-pdf-plus - -Core techniques: -- `around()` from `monkey-around` library for prototype patching -- `window.pdfjsLib.setLayerDimensions()` for coordinate alignment -- Percentage-based CSS positioning for zoom-independence -- EventBus: `textlayerrendered`, `pagerendered`, `scalechanged` diff --git a/experiments/sample_zotero_annotations.json b/experiments/sample_zotero_annotations.json deleted file mode 100644 index 13a8277f..00000000 --- a/experiments/sample_zotero_annotations.json +++ /dev/null @@ -1,126 +0,0 @@ -{ - "ok": true, - "probe": { - "zotero_db": "/Users/researcher/Zotero/zotero.sqlite", - "tables_found": [ - "itemAnnotations", - "itemAttachments", - "itemCreators", - "itemData", - "itemDataValues", - "itemNotes", - "itemRelations", - "itemTags", - "itemTypeCreatorTypes", - "itemTypeFields", - "itemTypeFieldsCombined", - "itemTypes", - "items", - "tags", - "creators", - "collections", - "collectionItems", - "deletedItems", - "fulltextWords", - "groupItems", - "groups", - "libraries", - "relations", - "settings", - "syncCache", - "syncDeleteLog", - "syncQueue", - "version" - ], - "annotation_count": 3, - "schema_version_detected": "itemAnnotations v125+" - }, - "annotations": [ - { - "libraryID": 1, - "annotationKey": "ABC12345", - "annotationTypeInt": 1, - "annotationType": "highlight", - "attachmentKey": "PDFE7890", - "parentItemKey": "PAPER1234", - "parentItemTitle": "Deep Learning for Biomedical Image Analysis", - "isExternal": false, - "selectedText": "Deep learning approaches have demonstrated remarkable performance across various biomedical imaging tasks, including segmentation, classification, and detection.", - "comment": "Key finding for introduction section", - "color": "#ffd400", - "pageLabel": "3", - "sortIndex": "00002|000000|00000", - "position": { - "pageIndex": 2, - "rects": [ - [72.0, 520.0, 540.0, 536.0], - [72.0, 504.0, 540.0, 520.0], - [72.0, 488.0, 540.0, 504.0] - ] - }, - "tags": ["deep_learning", "review"], - "authorName": "", - "dateAdded": "2025-11-15 10:30:00", - "dateModified": "2025-11-15 10:35:00", - "version": 5, - "attachment_path": "storage:PDFE7890/paper.pdf", - "attachment_link_mode": 0 - }, - { - "libraryID": 1, - "annotationKey": "DEF67890", - "annotationTypeInt": 2, - "annotationType": "note", - "attachmentKey": "PDFE7890", - "parentItemKey": "PAPER1234", - "parentItemTitle": "Deep Learning for Biomedical Image Analysis", - "isExternal": false, - "selectedText": "", - "comment": "<p>This figure shows the U-Net architecture that became the de facto standard for medical image segmentation. Note the skip connections that preserve spatial information across the contracting path.</p>", - "color": "#2ea8e5", - "pageLabel": "7", - "sortIndex": "00006|000000|00000", - "position": { - "pageIndex": 6, - "rects": [ - [420.0, 360.0, 540.0, 400.0] - ] - }, - "tags": ["architecture", "u-net"], - "authorName": "", - "dateAdded": "2025-11-15 14:00:00", - "dateModified": "2025-11-16 09:15:00", - "version": 7, - "attachment_path": "storage:PDFE7890/paper.pdf", - "attachment_link_mode": 0 - }, - { - "libraryID": 1, - "annotationKey": "GHI12345", - "annotationTypeInt": 5, - "annotationType": "underline", - "attachmentKey": "PDFE7890", - "parentItemKey": "PAPER1234", - "parentItemTitle": "Deep Learning for Biomedical Image Analysis", - "isExternal": false, - "selectedText": "The primary limitation of current approaches is the requirement for large annotated datasets.", - "comment": "Research gap — mention in related work", - "color": "#ff6666", - "pageLabel": "12", - "sortIndex": "00011|000000|00000", - "position": { - "pageIndex": 11, - "rects": [ - [72.0, 480.0, 540.0, 496.0] - ] - }, - "tags": ["research_gap"], - "authorName": "", - "dateAdded": "2025-11-16 11:00:00", - "dateModified": "2025-11-16 11:00:00", - "version": 8, - "attachment_path": "storage:PDFE7890/paper.pdf", - "attachment_link_mode": 0 - } - ] -} diff --git a/experiments/zotero_annotation_probe.py b/experiments/zotero_annotation_probe.py deleted file mode 100644 index 2a0a700a..00000000 --- a/experiments/zotero_annotation_probe.py +++ /dev/null @@ -1,242 +0,0 @@ -#!/usr/bin/env python3 -""" -Zotero Annotation Probe — read-only SQLite parser for Zotero annotations. - -Usage: - python experiments/zotero_annotation_probe.py --zotero-db "<path-to-zotero.sqlite>" [--limit 20] - -Outputs unified JSON annotations to stdout. Never writes to Zotero SQLite. -""" - -from __future__ import annotations - -import argparse -import json -import os -import shutil -import sqlite3 -import sys -import tempfile -import uuid -from datetime import datetime, timezone -from pathlib import Path - - -ANNOTATION_TYPE_MAP = { - 1: "highlight", - 2: "note", - 3: "image", - 4: "ink", - 5: "underline", - 6: "text", -} - - -def copy_db_to_temp(zotero_db: Path) -> Path: - """Copy zotero.sqlite to a temp file to avoid locks and inconsistent reads.""" - tmp = Path(tempfile.mkdtemp()) / "zotero_copy.sqlite" - shutil.copy2(str(zotero_db), str(tmp)) - return tmp - - -def open_readonly(db_path: Path) -> sqlite3.Connection: - uri = "file:" + db_path.as_posix() + "?mode=ro" - conn = sqlite3.connect(uri, uri=True) - conn.row_factory = sqlite3.Row - return conn - - -def probe_schema(conn: sqlite3.Connection) -> dict: - """Return detected table schemas for verification.""" - tables = {} - cursor = conn.execute( - "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name" - ) - for row in cursor.fetchall(): - tables[row["name"]] = True - return tables - - -def fetch_annotations(conn: sqlite3.Connection, limit: int = 20) -> list[dict]: - """Fetch annotations and their parent attachment info.""" - query = """ - SELECT - ia.itemID, - ia.parentItemID, - ia.type AS annotation_type, - ia.authorName, - ia.text AS annotation_text, - ia.comment AS annotation_comment, - ia.color AS annotation_color, - ia.pageLabel, - ia.sortIndex, - ia.position AS position_json, - ia.isExternal, - - i.key AS annotation_key, - i.libraryID, - i.dateAdded, - i.dateModified, - i.version, - - att.key AS attachment_key, - ia_att.path AS attachment_path, - ia_att.linkMode AS attachment_link_mode, - ia_att.contentType AS attachment_content_type, - - parent.key AS parent_item_key - FROM itemAnnotations ia - JOIN items i ON i.itemID = ia.itemID - LEFT JOIN itemAttachments ia_att ON ia_att.itemID = ia.parentItemID - LEFT JOIN items att ON att.itemID = ia.parentItemID - LEFT JOIN items parent ON parent.itemID = ia_att.parentItemID - LIMIT ? - """ - rows = conn.execute(query, (limit,)).fetchall() - - results = [] - for row in rows: - ann_type_int = row["annotation_type"] - ann_type_str = ANNOTATION_TYPE_MAP.get(ann_type_int, f"unknown_{ann_type_int}") - - position = {} - raw_pos = row["position_json"] - if raw_pos: - try: - position = json.loads(raw_pos) - except json.JSONDecodeError: - position = {"_parse_error": True, "_raw_preview": raw_pos[:200]} - - # Fetch tags for this annotation - tags = [] - try: - tag_rows = conn.execute( - """SELECT t.name - FROM itemTags it - JOIN tags t ON t.tagID = it.tagID - WHERE it.itemID = ?""", - (row["itemID"],), - ).fetchall() - tags = [t["name"] for t in tag_rows] - except Exception: - pass - - entry = { - "libraryID": row["libraryID"], - "annotationKey": row["annotation_key"], - "annotationTypeInt": ann_type_int, - "annotationType": ann_type_str, - "attachmentKey": row["attachment_key"], - "parentItemKey": row["parent_item_key"], - "isExternal": bool(row["isExternal"]), - "selectedText": row["annotation_text"] or "", - "comment": row["annotation_comment"] or "", - "color": row["annotation_color"] or "#ffd400", - "pageLabel": row["pageLabel"] or "", - "sortIndex": row["sortIndex"] or "", - "position": position, - "tags": tags, - "authorName": row["authorName"] or "", - "dateAdded": row["dateAdded"] or "", - "dateModified": row["dateModified"] or "", - "version": row["version"] or 0, - "attachment_path": row["attachment_path"] or "", - "attachment_link_mode": row["attachment_link_mode"], - } - - results.append(entry) - - return results - - -def main(): - parser = argparse.ArgumentParser( - description="Read-only Zotero annotation probe" - ) - parser.add_argument( - "--zotero-db", - required=True, - help="Path to zotero.sqlite", - ) - parser.add_argument( - "--limit", - type=int, - default=20, - help="Max annotations to return", - ) - parser.add_argument( - "--copy-db", - action="store_true", - default=True, - help="Copy DB to temp before reading (default: True)", - ) - parser.add_argument( - "--no-copy", - action="store_true", - help="Skip DB copy (read directly, risky if Zotero is running)", - ) - args = parser.parse_args() - - zotero_db = Path(args.zotero_db).expanduser().resolve() - if not zotero_db.exists(): - print(json.dumps({ - "ok": False, - "error": f"File not found: {zotero_db}", - }, ensure_ascii=False)) - sys.exit(1) - - probe_path = zotero_db - cleanup_path = None - - if not args.no_copy: - try: - probe_path = copy_db_to_temp(zotero_db) - cleanup_path = probe_path.parent - except Exception as e: - print(json.dumps({ - "ok": False, - "error": f"Failed to copy DB: {e}", - }, ensure_ascii=False)) - sys.exit(1) - - try: - conn = open_readonly(probe_path) - - tables = probe_schema(conn) - if "itemAnnotations" not in tables: - print(json.dumps({ - "ok": False, - "error": "itemAnnotations table not found — not a valid Zotero SQLite?", - "detected_tables": list(tables.keys()), - }, ensure_ascii=False)) - sys.exit(1) - - annotations = fetch_annotations(conn, limit=args.limit) - - output = { - "ok": True, - "probe": { - "zotero_db": str(zotero_db), - "tables_found": list(tables.keys()), - "annotation_count": len(annotations), - "schema_version_detected": "itemAnnotations v125+", - }, - "annotations": annotations, - } - - print(json.dumps(output, ensure_ascii=False, indent=2)) - - except sqlite3.DatabaseError as e: - print(json.dumps({ - "ok": False, - "error": f"SQLite error: {e}", - }, ensure_ascii=False)) - sys.exit(1) - finally: - conn.close() - if cleanup_path and cleanup_path.exists(): - shutil.rmtree(cleanup_path, ignore_errors=True) - - -if __name__ == "__main__": - main() diff --git a/paperforge/annotation/__init__.py b/paperforge/annotation/__init__.py deleted file mode 100644 index 6ed06580..00000000 --- a/paperforge/annotation/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -"""PaperForge Annotation Layer — read-only Zotero annotation import, local caching, and Obsidian overlay.""" - -from __future__ import annotations diff --git a/paperforge/annotation/cache.py b/paperforge/annotation/cache.py deleted file mode 100644 index 7da546c2..00000000 --- a/paperforge/annotation/cache.py +++ /dev/null @@ -1,142 +0,0 @@ -"""Annotation JSON cache for Obsidian plugin (no sql.js required). - -Writes a flat JSON file that the plugin reads directly via fs.readFileSync, -following the same snapshot pattern as memory-runtime-state.json. -""" - -from __future__ import annotations - -import json -import sqlite3 -from datetime import datetime, timezone -from pathlib import Path -from typing import Any - -try: - from pypdf import PdfReader -except ImportError: - PdfReader = None - - -def _now() -> str: - return datetime.now(timezone.utc).isoformat() - - -CACHE_FILENAME = "annotation-cache.json" - -CACHE_QUERY = """ -SELECT - id, paper_id, zotero_key, zotero_attachment_key, - type, page_index, page_label, - selected_text, comment, color, sort_index, - tags_json, position_json, sync_state, is_readonly -FROM annotations -WHERE deleted_at IS NULL -ORDER BY paper_id, sort_index -""" - - -def _row_to_dict(row: sqlite3.Row) -> dict[str, Any]: - pos = row["position_json"] - if isinstance(pos, str): - try: - pos = json.loads(pos) - except (json.JSONDecodeError, TypeError): - pos = None - tags = row["tags_json"] - if isinstance(tags, str): - try: - tags = json.loads(tags) - except (json.JSONDecodeError, TypeError): - tags = [] - return { - "id": row["id"], - "paper_id": row["paper_id"], - "zotero_key": row["zotero_key"] or "", - "zotero_attachment_key": row["zotero_attachment_key"] or "", - "type": row["type"], - "page_index": row["page_index"], - "page_label": row["page_label"] or "", - "selected_text": row["selected_text"] or "", - "comment": row["comment"] or "", - "color": row["color"] or "#ffd400", - "sort_index": row["sort_index"] or "", - "tags": tags, - "position": pos, - "sync_state": row["sync_state"], - "is_readonly": bool(row["is_readonly"]), - } - - -def _load_pdf_page_sizes(vault_path: Path, attachment_key: str) -> dict[int, tuple[float, float]]: - if not attachment_key: - return {} - from paperforge.config import paperforge_paths - - storage_dir = paperforge_paths(vault_path)["zotero_dir"] / "storage" / attachment_key - if not storage_dir.exists(): - return {} - pdf_files = sorted(storage_dir.glob("*.pdf")) - if not pdf_files: - return {} - if PdfReader is None: - return {} - try: - reader = PdfReader(str(pdf_files[0])) - except Exception: - return {} - sizes: dict[int, tuple[float, float]] = {} - for idx, page in enumerate(reader.pages): - try: - box = page.mediabox - sizes[idx] = (float(box.width), float(box.height)) - except Exception: - continue - return sizes - - -def build_cache(conn: sqlite3.Connection, vault_path: Path | str) -> dict[str, Any]: - """Read all non-deleted annotations and group by paper_id.""" - vault = Path(str(vault_path)) - conn.row_factory = sqlite3.Row - rows = conn.execute(CACHE_QUERY).fetchall() - by_paper: dict[str, list[dict[str, Any]]] = {} - attachment_page_sizes: dict[str, dict[int, tuple[float, float]]] = {} - for r in rows: - d = _row_to_dict(r) - attachment_key = d["zotero_attachment_key"] - if attachment_key and attachment_key not in attachment_page_sizes: - attachment_page_sizes[attachment_key] = _load_pdf_page_sizes(vault, attachment_key) - page_sizes = attachment_page_sizes.get(attachment_key, {}) - page_size = page_sizes.get(int(d.get("page_index") or 0)) - if page_size: - d["page_width"] = page_size[0] - d["page_height"] = page_size[1] - pid = d["paper_id"] - if pid not in by_paper: - by_paper[pid] = [] - by_paper[pid].append(d) - return { - "v": 1, - "ts": _now(), - "total": len(rows), - "papers": list(by_paper.keys()), - "by_paper": by_paper, - } - - -def write_cache(conn: sqlite3.Connection, vault_path: Path | str) -> str | None: - """Write annotation cache JSON to the vault's indexes directory. - - Returns the cache file path on success, None if the directory is missing. - """ - vault = Path(str(vault_path)) - from paperforge.config import paperforge_paths - - indexes_dir = paperforge_paths(vault)["index"].parent - if not indexes_dir.exists(): - return None - cache_path = indexes_dir / CACHE_FILENAME - data = build_cache(conn, vault) - cache_path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8") - return str(cache_path) diff --git a/paperforge/annotation/db.py b/paperforge/annotation/db.py deleted file mode 100644 index 259e2220..00000000 --- a/paperforge/annotation/db.py +++ /dev/null @@ -1,45 +0,0 @@ -"""Annotation DB path resolution and connection helpers. - -Follows the same pattern as paperforge/memory/db.py but for the independent -annotations.db, which is NOT rebuilt during memory layer operations. -""" - -from __future__ import annotations - -import sqlite3 -from pathlib import Path - -from paperforge.config import paperforge_paths - - -def get_annotations_db_path(vault: Path) -> Path: - """Return the absolute path to annotations.db, co-located with paperforge.db.""" - paths = paperforge_paths(vault) - index_path = paths.get("index") - if index_path is None: - msg = "paperforge_paths did not return an 'index' key; cannot determine annotations.db location" - raise FileNotFoundError(msg) - return index_path.parent / "annotations.db" - - -def get_annotations_connection(db_path: Path, read_only: bool = False) -> sqlite3.Connection: - """Open a SQLite connection to annotations.db with WAL mode. - - Args: - db_path: Path to annotations.db. - read_only: If True, open in read-only mode (for queries). - - Returns: - sqlite3.Connection with row_factory set to sqlite3.Row. - """ - if read_only: - uri = "file:" + db_path.as_posix() + "?mode=ro" - conn = sqlite3.connect(uri, uri=True) - else: - db_path.parent.mkdir(parents=True, exist_ok=True) - conn = sqlite3.connect(str(db_path)) - conn.row_factory = sqlite3.Row - if not read_only: - conn.execute("PRAGMA journal_mode=WAL;") - conn.execute("PRAGMA foreign_keys=ON;") - return conn diff --git a/paperforge/annotation/importer.py b/paperforge/annotation/importer.py deleted file mode 100644 index 759609e9..00000000 --- a/paperforge/annotation/importer.py +++ /dev/null @@ -1,219 +0,0 @@ -"""Annotation import/reconciliation logic. - -Transforms raw Zotero probe data into PaperForge's annotation schema and -reconciles with existing rows using stable upsert semantics. -""" - -from __future__ import annotations - -import json -import logging -import uuid -from datetime import datetime, timezone -from typing import Any - -logger = logging.getLogger(__name__) - -PAPERFORGE_LOCAL_SOURCES = {"paperforge"} - - -def _now() -> str: - return datetime.now(timezone.utc).isoformat() - - -def _probe_to_row( - ann: dict, - source: str, - now: str, -) -> dict[str, Any]: - """Convert a normalized probe annotation dict into a DB row.""" - paper_id = ann.get("parentItemKey", "") or ann.get("attachmentKey", "") - zotero_key = ann.get("annotationKey", "") - zotero_attachment_key = ann.get("attachmentKey", "") - pdf_path = ann.get("attachment_path", "") - version = ann.get("version", 0) - modified = ann.get("dateModified", "") - - return { - "id": zotero_key or str(uuid.uuid4()), - "paper_id": paper_id, - "zotero_library_id": ann.get("libraryID"), - "zotero_item_id": None, - "zotero_key": zotero_key, - "zotero_attachment_key": zotero_attachment_key, - "pdf_path": pdf_path, - "pdf_hash": "", - "type": ann.get("annotationType", ""), - "page_index": ann.get("position", {}).get("pageIndex"), - "page_label": ann.get("pageLabel", ""), - "selected_text": ann.get("selectedText", ""), - "comment": ann.get("comment", ""), - "color": ann.get("color", ""), - "sort_index": ann.get("sortIndex", ""), - "tags_json": json.dumps(ann.get("tags", []), ensure_ascii=False), - "position_json": json.dumps(ann.get("position", {}), ensure_ascii=False), - "selector_json": "{}", - "source": source, - "source_key": zotero_key, - "source_version": version, - "source_modified_at": modified, - "sync_state": "zotero_synced" if source != "paperforge" else "local", - "is_readonly": 1 if source != "paperforge" else 0, - "created_at": now, - "updated_at": now, - "deleted_at": None, - } - - -UPSERT_SQL = """ -INSERT INTO annotations ( - id, - paper_id, - zotero_library_id, - zotero_item_id, - zotero_key, - zotero_attachment_key, - pdf_path, - pdf_hash, - type, - page_index, - page_label, - selected_text, - comment, - color, - sort_index, - tags_json, - position_json, - selector_json, - source, - source_key, - source_version, - source_modified_at, - sync_state, - is_readonly, - created_at, - updated_at, - deleted_at -) VALUES ( - :id, - :paper_id, - :zotero_library_id, - :zotero_item_id, - :zotero_key, - :zotero_attachment_key, - :pdf_path, - :pdf_hash, - :type, - :page_index, - :page_label, - :selected_text, - :comment, - :color, - :sort_index, - :tags_json, - :position_json, - :selector_json, - :source, - :source_key, - :source_version, - :source_modified_at, - :sync_state, - :is_readonly, - :created_at, - :updated_at, - :deleted_at -) ON CONFLICT(id) DO UPDATE SET - paper_id = excluded.paper_id, - zotero_library_id = excluded.zotero_library_id, - zotero_key = excluded.zotero_key, - zotero_attachment_key = excluded.zotero_attachment_key, - pdf_path = excluded.pdf_path, - type = excluded.type, - page_index = excluded.page_index, - page_label = excluded.page_label, - selected_text = excluded.selected_text, - comment = excluded.comment, - color = excluded.color, - sort_index = excluded.sort_index, - tags_json = excluded.tags_json, - position_json = excluded.position_json, - source_version = excluded.source_version, - source_modified_at = excluded.source_modified_at, - updated_at = excluded.updated_at, - deleted_at = excluded.deleted_at -""" - - -def _import_annotations( - conn, - annotations: list[dict], - source: str, -) -> dict: - """Core import logic. Returns {imported, updated, deleted}.""" - now = _now() - stats = {"imported": 0, "updated": 0, "deleted": 0} - - incoming_keys = set() - - for ann in annotations: - row = _probe_to_row(ann, source, now) - zk = row["zotero_key"] - incoming_keys.add(zk) - - # Check if row already exists by id (zotero_key for imported annotations) - existing = conn.execute( - "SELECT source_version, sync_state FROM annotations WHERE id = ?", - (row["id"],), - ).fetchone() - - if existing is None: - conn.execute(UPSERT_SQL, row) - stats["imported"] += 1 - elif existing["source_version"] != row["source_version"]: - conn.execute(UPSERT_SQL, row) - stats["updated"] += 1 - else: - stats["updated"] += 0 # unchanged - - conn.commit() - - # Soft-delete stale rows that are no longer in the source's current set - # Only applies to zotero_db source annotations - if source != "paperforge" and incoming_keys: - stale_rows = conn.execute( - """SELECT id, sync_state FROM annotations - WHERE source = ? - AND source_key NOT IN ({}) - AND deleted_at IS NULL""".format( - ",".join("?" for _ in incoming_keys) - ), - [source, *incoming_keys], - ).fetchall() - - for stale in stale_rows: - from paperforge.annotation.service import mark_deleted - - mark_deleted(conn, stale["id"]) - stats["deleted"] += 1 - - conn.commit() - - return stats - - -def run_import( - conn, - annotations: list[dict], - source: str, -) -> dict: - """Import annotations from a probe result into annotations.db. - - Args: - conn: Writable connection to annotations.db. - annotations: List of normalized annotation dicts from probe.py. - source: Source identifier, e.g. "zotero_db". - - Returns: - Dict with keys: imported, updated, deleted. - """ - return _import_annotations(conn, annotations, source) diff --git a/paperforge/annotation/probe.py b/paperforge/annotation/probe.py deleted file mode 100644 index 204c583a..00000000 --- a/paperforge/annotation/probe.py +++ /dev/null @@ -1,177 +0,0 @@ -"""Read-only probe into Zotero SQLite for annotation data. - -This module opens Zotero's zotero.sqlite in read-only mode and extracts -normalized annotation data. It NEVER writes to any Zotero-owned file. -""" - -from __future__ import annotations - -import json -import logging -import shutil -import sqlite3 -import tempfile -from pathlib import Path - -logger = logging.getLogger(__name__) - -ANNOTATION_TYPE_MAP = { - 1: "highlight", - 2: "note", - 3: "image", - 4: "ink", - 5: "underline", - 6: "text", -} - -FETCH_ANNOTATIONS_SQL = """ -SELECT - ia.itemID, - ia.parentItemID, - ia.type AS annotation_type, - ia.authorName, - ia.text AS annotation_text, - ia.comment AS annotation_comment, - ia.color AS annotation_color, - ia.pageLabel, - ia.sortIndex, - ia.position AS position_json, - ia.isExternal, - - i.key AS annotation_key, - i.libraryID, - i.dateAdded, - i.dateModified, - i.version, - - att.key AS attachment_key, - ia_att.path AS attachment_path, - ia_att.linkMode AS attachment_link_mode, - - parent.key AS parent_item_key -FROM itemAnnotations ia -JOIN items i ON i.itemID = ia.itemID -LEFT JOIN itemAttachments ia_att ON ia_att.itemID = ia.parentItemID -LEFT JOIN items att ON att.itemID = ia.parentItemID -LEFT JOIN items parent ON parent.itemID = ia_att.parentItemID -ORDER BY ia.sortIndex -""" - -FETCH_TAGS_SQL = """ -SELECT it.itemID, t.name -FROM itemTags it -JOIN tags t ON t.tagID = it.tagID -WHERE it.itemID IN (%s) -""" - - -def copy_db_to_temp(zotero_db: Path) -> Path: - """Copy zotero.sqlite to a temp file to avoid locks and inconsistent reads. - - The caller is responsible for cleaning up the temp directory. - """ - tmp_dir = Path(tempfile.mkdtemp(prefix="pf_zotero_")) - dest = tmp_dir / "zotero_copy.sqlite" - shutil.copy2(str(zotero_db), str(dest)) - logger.info("Copied %s to %s", zotero_db, dest) - return dest - - -def open_readonly(db_path: Path) -> sqlite3.Connection: - """Open a Zotero SQLite database in read-only mode. - - Uses URI ``mode=ro`` to enforce read-only access. - """ - uri = "file:" + db_path.as_posix() + "?mode=ro" - conn = sqlite3.connect(uri, uri=True) - conn.row_factory = sqlite3.Row - return conn - - -def _fetch_tags(conn: sqlite3.Connection, item_ids: list[int]) -> dict[int, list[str]]: - """Fetch tags for the given item IDs. Returns {itemID: [tag_name, ...]}.""" - if not item_ids: - return {} - placeholders = ",".join("?" for _ in item_ids) - rows = conn.execute(FETCH_TAGS_SQL % placeholders, item_ids).fetchall() - result: dict[int, list[str]] = {iid: [] for iid in item_ids} - for row in rows: - result.setdefault(row["itemID"], []).append(row["name"]) - return result - - -def normalize_annotation_row(row: sqlite3.Row, tags: list[str]) -> dict: - """Convert a raw Zotero annotation row into a normalized dict.""" - ann_type_int = row["annotation_type"] - ann_type_str = ANNOTATION_TYPE_MAP.get(ann_type_int, f"unknown_{ann_type_int}") - - position = {} - raw_pos = row["position_json"] - if raw_pos: - try: - position = json.loads(raw_pos) - except json.JSONDecodeError: - position = {"_parse_error": True, "_raw_preview": raw_pos[:200]} - - return { - "libraryID": row["libraryID"], - "annotationKey": row["annotation_key"], - "annotationTypeInt": ann_type_int, - "annotationType": ann_type_str, - "attachmentKey": row["attachment_key"], - "parentItemKey": row["parent_item_key"], - "isExternal": bool(row["isExternal"]), - "selectedText": row["annotation_text"] or "", - "comment": row["annotation_comment"] or "", - "color": row["annotation_color"] or "#ffd400", - "pageLabel": row["pageLabel"] or "", - "sortIndex": row["sortIndex"] or "", - "position": position, - "tags": tags, - "authorName": row["authorName"] or "", - "dateAdded": row["dateAdded"] or "", - "dateModified": row["dateModified"] or "", - "version": row["version"] or 0, - "attachment_path": row["attachment_path"] or "", - "attachment_link_mode": row["attachment_link_mode"], - } - - -def fetch_annotations( - conn: sqlite3.Connection, limit: int = 100 -) -> list[dict]: - """Fetch annotations from Zotero SQLite with tags. - - Args: - conn: Read-only connection to zotero.sqlite. - limit: Maximum number of annotation items to return. - - Returns: - List of normalized annotation dicts. - """ - rows = conn.execute(FETCH_ANNOTATIONS_SQL + " LIMIT ?", (limit,)).fetchall() - if not rows: - return [] - - item_ids = [r["itemID"] for r in rows] - tags_map = _fetch_tags(conn, item_ids) - - return [normalize_annotation_row(r, tags_map.get(r["itemID"], [])) for r in rows] - - -def probe( - conn: sqlite3.Connection, - limit: int = 20, -) -> dict: - """Full probe: fetch annotations and report metadata. - - Returns a dict with ``ok``, ``probe``, and ``annotations`` keys. - """ - annotations = fetch_annotations(conn, limit=limit) - return { - "ok": True, - "probe": { - "annotation_count": len(annotations), - }, - "annotations": annotations, - } diff --git a/paperforge/annotation/schema.py b/paperforge/annotation/schema.py deleted file mode 100644 index 4f9d0b05..00000000 --- a/paperforge/annotation/schema.py +++ /dev/null @@ -1,149 +0,0 @@ -"""Annotation database schema — independent from memory layer paperforge.db. - -Schema version 1 creates: - - meta version tracking - - annotations normalized annotation table - - annotations_fts FTS5 virtual table for full-text search - - sync_queue future write-back operation queue - -This DB is never dropped during paperforge memory rebuild. -""" - -from __future__ import annotations - -import logging -import sqlite3 - -logger = logging.getLogger(__name__) - -ANNOTATIONS_SCHEMA_VERSION = 1 - -CREATE_META = """ -CREATE TABLE IF NOT EXISTS meta ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL -); -""" - -CREATE_ANNOTATIONS = """ -CREATE TABLE IF NOT EXISTS annotations ( - id TEXT PRIMARY KEY, - paper_id TEXT NOT NULL, - - zotero_library_id INTEGER, - zotero_item_id INTEGER, - zotero_key TEXT DEFAULT '', - zotero_attachment_key TEXT DEFAULT '', - - pdf_path TEXT DEFAULT '', - pdf_hash TEXT DEFAULT '', - - type TEXT NOT NULL, - page_index INTEGER, - page_label TEXT DEFAULT '', - - selected_text TEXT DEFAULT '', - comment TEXT DEFAULT '', - color TEXT DEFAULT '', - sort_index TEXT DEFAULT '', - tags_json TEXT DEFAULT '[]', - - position_json TEXT DEFAULT '{}', - selector_json TEXT DEFAULT '{}', - - source TEXT NOT NULL DEFAULT 'paperforge', - source_key TEXT DEFAULT '', - source_version INTEGER, - source_modified_at TEXT DEFAULT '', - - sync_state TEXT NOT NULL DEFAULT 'local', - is_readonly INTEGER NOT NULL DEFAULT 0, - - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - deleted_at TEXT -); -""" - -ANNOTATION_INDEXES = [ - "CREATE INDEX IF NOT EXISTS idx_annotations_paper ON annotations(paper_id);", - "CREATE INDEX IF NOT EXISTS idx_annotations_type ON annotations(type);", - "CREATE INDEX IF NOT EXISTS idx_annotations_sync_state ON annotations(sync_state);", - "CREATE INDEX IF NOT EXISTS idx_annotations_source ON annotations(source);", - "CREATE INDEX IF NOT EXISTS idx_annotations_page ON annotations(paper_id, page_index);", - "CREATE INDEX IF NOT EXISTS idx_annotations_zotero_key ON annotations(zotero_key);", - "CREATE INDEX IF NOT EXISTS idx_annotations_deleted ON annotations(deleted_at) WHERE deleted_at IS NOT NULL;", -] - -CREATE_ANNOTATIONS_FTS = """ -CREATE VIRTUAL TABLE IF NOT EXISTS annotations_fts USING fts5( - paper_id, - selected_text, - comment, - tags_json, - content='annotations', - content_rowid='rowid' -); -""" - -FTS_TRIGGERS = [ - """CREATE TRIGGER IF NOT EXISTS annotations_ai AFTER INSERT ON annotations BEGIN - INSERT INTO annotations_fts(rowid, paper_id, selected_text, comment, tags_json) - VALUES (new.rowid, new.paper_id, new.selected_text, new.comment, new.tags_json); -END;""", - """CREATE TRIGGER IF NOT EXISTS annotations_ad AFTER DELETE ON annotations BEGIN - INSERT INTO annotations_fts(annotations_fts, rowid, paper_id, selected_text, comment, tags_json) - VALUES ('delete', old.rowid, old.paper_id, old.selected_text, old.comment, old.tags_json); -END;""", - """CREATE TRIGGER IF NOT EXISTS annotations_au AFTER UPDATE ON annotations BEGIN - INSERT INTO annotations_fts(annotations_fts, rowid, paper_id, selected_text, comment, tags_json) - VALUES ('delete', old.rowid, old.paper_id, old.selected_text, old.comment, old.tags_json); - INSERT INTO annotations_fts(rowid, paper_id, selected_text, comment, tags_json) - VALUES (new.rowid, new.paper_id, new.selected_text, new.comment, new.tags_json); -END;""", -] - -CREATE_SYNC_QUEUE = """ -CREATE TABLE IF NOT EXISTS sync_queue ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - annotation_id TEXT NOT NULL, - operation TEXT NOT NULL, - payload_json TEXT NOT NULL, - retry_count INTEGER DEFAULT 0, - last_error TEXT, - created_at TEXT NOT NULL, - FOREIGN KEY (annotation_id) REFERENCES annotations(id) -); -""" - - -def ensure_schema(conn: sqlite3.Connection) -> None: - """Create tables, indexes, FTS, and triggers if they don't exist.""" - conn.execute(CREATE_META) - conn.execute(CREATE_ANNOTATIONS) - for idx_sql in ANNOTATION_INDEXES: - conn.execute(idx_sql) - conn.execute(CREATE_ANNOTATIONS_FTS) - for trigger_sql in FTS_TRIGGERS: - conn.execute(trigger_sql) - conn.execute(CREATE_SYNC_QUEUE) - - # Ensure version is recorded - version = get_schema_version(conn) - if version == 0: - conn.execute( - "INSERT OR REPLACE INTO meta (key, value) VALUES ('schema_version', ?)", - (str(ANNOTATIONS_SCHEMA_VERSION),), - ) - conn.commit() - - -def get_schema_version(conn: sqlite3.Connection) -> int: - """Read the stored schema version from meta table, or 0 if not found.""" - try: - row = conn.execute( - "SELECT value FROM meta WHERE key = 'schema_version'" - ).fetchone() - return int(row["value"]) if row else 0 - except sqlite3.OperationalError: - return 0 diff --git a/paperforge/annotation/service.py b/paperforge/annotation/service.py deleted file mode 100644 index 324a095b..00000000 --- a/paperforge/annotation/service.py +++ /dev/null @@ -1,202 +0,0 @@ -"""Annotation CRUD, search, and export operations against annotations.db. - -Part of the "lightweight embedded service" layer — pure DB operations, -no CLI or network concerns. -""" - -from __future__ import annotations - -import json -import logging -import uuid -from datetime import datetime, timezone -from typing import Any - -logger = logging.getLogger(__name__) - -# Columns that can be safely patched by users -PATCHABLE_COLUMNS = {"comment", "color", "tags_json", "selected_text"} - - -def _now() -> str: - return datetime.now(timezone.utc).isoformat() - - -# ── Mutation ── - - -def mark_deleted(conn, annotation_id: str) -> None: - """Soft-delete an annotation by id.""" - conn.execute( - "UPDATE annotations SET deleted_at = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL", - (_now(), _now(), annotation_id), - ) - - -def hard_delete(conn, annotation_id: str) -> None: - """Permanently delete an annotation by id.""" - conn.execute("DELETE FROM annotations WHERE id = ?", (annotation_id,)) - - -# ── CRUD ── - - -def create_annotation( - conn, - paper_id: str, - annotation_type: str, - page_index: int | None = None, - page_label: str = "", - selected_text: str = "", - comment: str = "", - color: str = "#ffd400", - sort_index: str = "", - position_json: dict | None = None, - tags: list[str] | None = None, -) -> dict: - """Create a new PaperForge-local annotation.""" - now = _now() - aid = str(uuid.uuid4()) - conn.execute( - """INSERT INTO annotations ( - id, paper_id, type, page_index, page_label, - selected_text, comment, color, sort_index, - tags_json, position_json, source, sync_state, - is_readonly, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'paperforge', 'local', 0, ?, ?)""", - ( - aid, - paper_id, - annotation_type, - page_index, - page_label, - selected_text, - comment, - color, - sort_index, - json.dumps(tags or [], ensure_ascii=False), - json.dumps(position_json or {}, ensure_ascii=False), - now, - now, - ), - ) - conn.commit() - return get_annotation(conn, aid) - - -def patch_annotation( - conn, - annotation_id: str, - **kwargs: Any, -) -> dict: - """Patch editable fields on an annotation. - - Raises ValueError if the annotation is readonly or not found. - """ - existing = get_annotation(conn, annotation_id) - if existing is None: - raise ValueError(f"Annotation not found: {annotation_id}") - if existing.get("is_readonly"): - raise ValueError(f"Cannot edit readonly annotation: {annotation_id}") - - updates = {k: v for k, v in kwargs.items() if k in PATCHABLE_COLUMNS and v is not None} - if not updates: - return existing - - updates["updated_at"] = _now() - set_clause = ", ".join(f"{k} = ?" for k in updates) - values = list(updates.values()) + [annotation_id] - conn.execute(f"UPDATE annotations SET {set_clause} WHERE id = ?", values) - conn.commit() - return get_annotation(conn, annotation_id) - - -def delete_annotation(conn, annotation_id: str) -> None: - """Soft-delete an annotation by id. Raises ValueError if not found.""" - existing = get_annotation(conn, annotation_id) - if existing is None: - raise ValueError(f"Annotation not found: {annotation_id}") - mark_deleted(conn, annotation_id) - conn.commit() - - -# ── Queries ── - - -def get_annotation(conn, annotation_id: str) -> dict | None: - """Fetch a single annotation by id. Returns None if not found.""" - row = conn.execute( - "SELECT * FROM annotations WHERE id = ?", (annotation_id,) - ).fetchone() - return dict(row) if row else None - - -def list_annotations( - conn, - paper_id: str = "", - page_index: int | None = None, - annotation_type: str = "", - limit: int = 100, -) -> list[dict]: - """List annotations, optionally filtered.""" - clauses = ["deleted_at IS NULL"] - params: list[Any] = [] - - if paper_id: - clauses.append("paper_id = ?") - params.append(paper_id) - if page_index is not None: - clauses.append("page_index = ?") - params.append(page_index) - if annotation_type: - clauses.append("type = ?") - params.append(annotation_type) - - where = " AND ".join(clauses) - rows = conn.execute( - f"SELECT * FROM annotations WHERE {where} ORDER BY sort_index LIMIT ?", - [*params, limit], - ).fetchall() - return [dict(r) for r in rows] - - -# ── Export ── - - -def export_annotations_json( - conn, - paper_id: str = "", - limit: int = 1000, -) -> str: - """Export annotations for a paper as a JSON string.""" - anns = list_annotations(conn, paper_id=paper_id, limit=limit) - - def _clean(a: dict) -> dict: - return {k: v for k, v in a.items() if k not in ("id",)} - - return json.dumps([_clean(a) for a in anns], ensure_ascii=False, indent=2) - - -def export_annotations_markdown( - conn, - paper_id: str = "", - limit: int = 1000, -) -> str: - """Export annotations for a paper as formatted Markdown.""" - anns = list_annotations(conn, paper_id=paper_id, limit=limit) - lines = [f"# Annotations for {paper_id}\n"] - - for i, ann in enumerate(anns, 1): - lines.append(f"## {i}. {ann['type'].title()}") - if ann.get("page_label") or ann.get("page_index") is not None: - pl = ann["page_label"] or str(ann["page_index"] + 1) - lines.append(f"**Page:** {pl}") - if ann.get("selected_text"): - lines.append(f"\n> {ann['selected_text']}") - if ann.get("comment"): - lines.append(f"\n{ann['comment']}") - if ann.get("color"): - lines.append(f"\n*Color: {ann['color']}*") - lines.append("") - - return "\n".join(lines) diff --git a/paperforge/commands/annotation.py b/paperforge/commands/annotation.py deleted file mode 100644 index 6e1eea43..00000000 --- a/paperforge/commands/annotation.py +++ /dev/null @@ -1,382 +0,0 @@ -"""paperforge annotation — CLI command family for PDF annotation management.""" - -from __future__ import annotations - -import argparse -import json -import sys -from pathlib import Path - -from paperforge import __version__ as PF_VERSION -from paperforge.annotation.db import get_annotations_connection, get_annotations_db_path -from paperforge.annotation.importer import run_import -from paperforge.annotation.probe import copy_db_to_temp, fetch_annotations, open_readonly -from paperforge.annotation.schema import ensure_schema, get_schema_version -from paperforge.annotation.service import ( - create_annotation, - delete_annotation, - export_annotations_json, - export_annotations_markdown, - hard_delete, - list_annotations, - patch_annotation, -) -from paperforge.config import paperforge_paths -from paperforge.core.errors import ErrorCode -from paperforge.core.result import PFError, PFResult - - -def _db_path(vault: Path) -> Path: - return get_annotations_db_path(vault) - - -def _db_conn(vault: Path): - db_path = _db_path(vault) - conn = get_annotations_connection(db_path, read_only=False) - ensure_schema(conn) - return conn - - -def _probe_and_import( - vault: Path, - zotero_db: Path, - paper_key: str, - dry_run: bool, - no_copy: bool, -) -> dict: - """Core import pipeline: detect Zotero DB → probe → import.""" - probe_path = zotero_db - if not no_copy: - probe_path = copy_db_to_temp(zotero_db) - - probe_conn = open_readonly(probe_path) - try: - anns = fetch_annotations(probe_conn, limit=10000) - finally: - probe_conn.close() - - if paper_key: - anns = [a for a in anns if a.get("parentItemKey") == paper_key] - - if dry_run: - return {"paper_key": paper_key, "annotations_found": len(anns), "dry_run": True} - - conn = _db_conn(vault) - try: - result = run_import(conn, anns, source="zotero_db") - result["annotations_found"] = len(anns) - return result - finally: - conn.close() - - -def run(args: argparse.Namespace) -> int: - vault = args.vault_path - sub = getattr(args, "annotation_subcommand", "") - json_output = getattr(args, "json", False) - - if sub == "import": - return _cmd_import(vault, args, json_output) - elif sub == "list": - return _cmd_list(vault, args, json_output) - elif sub == "create": - return _cmd_create(vault, args, json_output) - elif sub == "patch": - return _cmd_patch(vault, args, json_output) - elif sub == "delete": - return _cmd_delete(vault, args, json_output) - elif sub == "export": - return _cmd_export(vault, args, json_output) - elif sub == "status": - return _cmd_status(vault, args, json_output) - elif sub == "cache-refresh": - return _cmd_cache_refresh(vault, args, json_output) - else: - print(f"Unknown annotation subcommand: {sub}", file=sys.stderr) - return 1 - - -def _write_cache(vault: Path) -> None: - """Open annotations.db, build JSON cache, write to indexes directory.""" - from paperforge.annotation.cache import write_cache as _wc - conn = _db_conn(vault) - try: - _wc(conn, vault) - finally: - conn.close() - - -def _emit(result: PFResult, json_output: bool) -> int: - if json_output: - print(result.to_json()) - else: - data = result.data or {} - if result.ok: - for k, v in data.items(): - if isinstance(v, list): - print(f"{k}: {len(v)}") - else: - print(f"{k}: {v}") - else: - print(f"Error: {result.error.message}", file=sys.stderr) - return 0 if result.ok else 1 - - -def _cmd_import(vault, args, json_output): - paths = paperforge_paths(vault) - zotero_dir = paths.get("zotero_dir") - zotero_db_raw = getattr(args, "zotero_db", None) or str(zotero_dir / "zotero.sqlite") - zotero_db = Path(zotero_db_raw).expanduser().resolve() - - if not zotero_db.exists(): - result = PFResult( - ok=False, command="annotation import", version=PF_VERSION, - error=PFError(code="ZOTERO_DB_NOT_FOUND", message=f"Zotero SQLite not found: {zotero_db}"), - ) - return _emit(result, json_output) - - try: - data = _probe_and_import( - vault, zotero_db, - paper_key=getattr(args, "paper", ""), - dry_run=getattr(args, "dry_run", False), - no_copy=getattr(args, "no_copy", False), - ) - if not getattr(args, "dry_run", False): - try: - _write_cache(vault) - except Exception: - pass - result = PFResult(ok=True, command="annotation import", version=PF_VERSION, data=data) - return _emit(result, json_output) - except Exception as exc: - result = PFResult( - ok=False, command="annotation import", version=PF_VERSION, - error=PFError(code=ErrorCode.INTERNAL_ERROR, message=str(exc)), - ) - return _emit(result, json_output) - - -def _resolve_paper_key(vault, pdf_path): - """Resolve a vault-relative PDF path to a paper zotero key via memory DB.""" - if not pdf_path: - return "" - import sqlite3 - - from paperforge.memory.db import get_memory_db_path - - db = get_memory_db_path(Path(str(vault))) - if not db.exists(): - return "" - try: - mem = sqlite3.connect(str(db)) - mem.row_factory = sqlite3.Row - pdf_name = Path(pdf_path).name - # Memory DB stores pdf_path as wikilink: [[path/to/file.pdf]] - # Match by filename inside wikilink, or by the raw path - for like_pattern in [f"%/{pdf_name}]", f"%\\{pdf_name}]", f"%{pdf_name}%"]: - row = mem.execute( - "SELECT zotero_key FROM papers WHERE pdf_path LIKE ? LIMIT 1", - (like_pattern,), - ).fetchone() - if row: - mem.close() - return row["zotero_key"] - # Last resort: extract storage folder key (8-char HEX) from PDF path - # e.g. System/Zotero/storage/XJSUZL8W/file.pdf -> XJSUZL8W - m = __import__("re").search(r"storage[\\/]([A-Z0-9]{8})", pdf_path, __import__("re").IGNORECASE) - if m: - row = mem.execute( - "SELECT zotero_key FROM papers WHERE pdf_path LIKE ? LIMIT 1", - (f"%{m.group(1)}%",), - ).fetchone() - if row: - mem.close() - return row["zotero_key"] - mem.close() - except Exception: - pass - return "" - - -def _cmd_list(vault, args, json_output): - paper_key = args.paper_key or "" - if not paper_key and getattr(args, "pdf_path", None): - paper_key = _resolve_paper_key(vault, args.pdf_path) - if not paper_key: - result = PFResult( - ok=False, command="annotation list", version=PF_VERSION, - error=PFError(code="PATH_NOT_FOUND", message=f"Could not resolve PDF path to paper key: {args.pdf_path}"), - ) - return _emit(result, json_output) - conn = _db_conn(vault) - try: - anns = list_annotations( - conn, - paper_id=paper_key, - page_index=getattr(args, "page", None), - annotation_type=getattr(args, "ann_type", ""), - limit=getattr(args, "limit", 100), - ) - result = PFResult(ok=True, command="annotation list", version=PF_VERSION, data={ - "paper_id": paper_key, "annotations": anns, "count": len(anns), - }) - return _emit(result, json_output) - finally: - conn.close() - - -def _cmd_create(vault, args, json_output): - paper_id = args.paper or "" - if not paper_id and getattr(args, "pdf_path", None): - paper_id = _resolve_paper_key(vault, args.pdf_path) - if not paper_id: - result = PFResult( - ok=False, command="annotation create", version=PF_VERSION, - error=PFError(code="PATH_NOT_FOUND", message=f"Could not resolve PDF path to paper key: {args.pdf_path}"), - ) - return _emit(result, json_output) - conn = _db_conn(vault) - try: - pos_str = getattr(args, "position_json", "") or "" - pos = None - if pos_str: - try: - pos = json.loads(pos_str) - except json.JSONDecodeError: - pass - ann = create_annotation( - conn, - paper_id=paper_id, - annotation_type=args.ann_type, - page_index=getattr(args, "page_index", None), - page_label=getattr(args, "page_label", ""), - selected_text=getattr(args, "selected_text", ""), - comment=getattr(args, "comment", ""), - color=getattr(args, "color", "#ffd400"), - sort_index=getattr(args, "sort_index", ""), - position_json=pos, - ) - result = PFResult(ok=True, command="annotation create", version=PF_VERSION, data=ann) - conn.commit() - if not getattr(args, "defer_cache_refresh", False): - try: - _write_cache(vault) - except Exception: - pass - return _emit(result, json_output) - except Exception as exc: - result = PFResult( - ok=False, command="annotation create", version=PF_VERSION, - error=PFError(code="CREATE_FAILED", message=str(exc)), - ) - return _emit(result, json_output) - finally: - conn.close() - - -def _cmd_patch(vault, args, json_output): - conn = _db_conn(vault) - try: - kwargs = {} - if getattr(args, "comment", None) is not None: - kwargs["comment"] = args.comment - if getattr(args, "color", None) is not None: - kwargs["color"] = args.color - ann = patch_annotation(conn, args.annotation_id, **kwargs) - result = PFResult(ok=True, command="annotation patch", version=PF_VERSION, data=ann) - conn.commit() - if not getattr(args, "defer_cache_refresh", False): - try: - _write_cache(vault) - except Exception: - pass - return _emit(result, json_output) - except Exception as exc: - result = PFResult( - ok=False, command="annotation patch", version=PF_VERSION, - error=PFError(code="PATCH_FAILED", message=str(exc)), - ) - return _emit(result, json_output) - finally: - conn.close() - - -def _cmd_delete(vault, args, json_output): - conn = _db_conn(vault) - try: - if getattr(args, "hard", False): - hard_delete(conn, args.annotation_id) - ann = {"id": args.annotation_id, "deleted": True, "hard": True} - else: - delete_annotation(conn, args.annotation_id) - ann = {"id": args.annotation_id, "deleted": True, "hard": False} - result = PFResult(ok=True, command="annotation delete", version=PF_VERSION, data=ann) - conn.commit() - if not getattr(args, "defer_cache_refresh", False): - try: - _write_cache(vault) - except Exception: - pass - return _emit(result, json_output) - except Exception as exc: - result = PFResult( - ok=False, command="annotation delete", version=PF_VERSION, - error=PFError(code="DELETE_FAILED", message=str(exc)), - ) - return _emit(result, json_output) - finally: - conn.close() - - -def _cmd_export(vault, args, json_output): - conn = _db_conn(vault) - try: - fmt = getattr(args, "format", "json") - paper_key = args.paper_key - if fmt == "markdown": - text = export_annotations_markdown(conn, paper_id=paper_key) - else: - text = export_annotations_json(conn, paper_id=paper_key) - - output_path = getattr(args, "output", None) - if output_path: - Path(output_path).write_text(text, encoding="utf-8") - result = PFResult(ok=True, command="annotation export", version=PF_VERSION, data={"path": output_path}) - else: - if json_output: - print(text) - else: - result = PFResult(ok=True, command="annotation export", version=PF_VERSION, data={"text": text}) - return _emit(result, json_output) - except Exception as exc: - result = PFResult( - ok=False, command="annotation export", version=PF_VERSION, - error=PFError(code="EXPORT_FAILED", message=str(exc)), - ) - return _emit(result, json_output) - - -def _cmd_status(vault, args, json_output): - db_path = _db_path(vault) - data = { - "db_exists": db_path.exists(), - "db_path": str(db_path), - } - if db_path.exists(): - conn = _db_conn(vault) - try: - count = conn.execute("SELECT COUNT(*) as cnt FROM annotations WHERE deleted_at IS NULL").fetchone()["cnt"] - schema_ver = get_schema_version(conn) - data["annotation_count"] = count - data["schema_version"] = schema_ver - finally: - conn.close() - result = PFResult(ok=True, command="annotation status", version=PF_VERSION, data=data) - return _emit(result, json_output) - - -def _cmd_cache_refresh(vault, args, json_output): - _write_cache(vault) - result = PFResult(ok=True, command="annotation cache-refresh", version=PF_VERSION, data={"refreshed": True}) - return _emit(result, json_output) diff --git a/paperforge/plugin/tests/errors.test.ts b/paperforge/plugin/tests/errors.test.ts index d95069c4..0b824a0d 100644 --- a/paperforge/plugin/tests/errors.test.ts +++ b/paperforge/plugin/tests/errors.test.ts @@ -5,6 +5,7 @@ import { describe, it, expect } from 'vitest'; import { classifyError, + buildRuntimeInstallCommand, parseRuntimeStatus, } from "../src/services/python-bridge"; @@ -56,6 +57,28 @@ describe('classifyError', () => { }); }); +describe('buildRuntimeInstallCommand', () => { + it('constructs correct URL with version tag', () => { + const result = buildRuntimeInstallCommand('python', '1.4.17rc3'); + expect(result.cmd).toBe('python'); + expect(result.url).toBe('git+https://github.com/LLLin000/PaperForge.git@1.4.17rc3'); + expect(result.args).toContain('-m'); + expect(result.args).toContain('pip'); + expect(result.args).toContain('install'); + expect(result.args).toContain('--upgrade'); + }); + + it('includes extraArgs when provided', () => { + const result = buildRuntimeInstallCommand('python', '1.4.17rc3', ['-3']); + expect(result.args[0]).toBe('-3'); + }); + + it('timeout is 120000', () => { + const result = buildRuntimeInstallCommand('python', '1.4.17rc3'); + expect(result.timeout).toBe(120000); + }); +}); + describe('parseRuntimeStatus', () => { it('returns ok with version on success', () => { const result = parseRuntimeStatus(null, '1.4.17rc3\n', ''); diff --git a/paperforge/plugin/tests/settings-panels.test.ts b/paperforge/plugin/tests/settings-panels.test.ts new file mode 100644 index 00000000..5c7f5174 --- /dev/null +++ b/paperforge/plugin/tests/settings-panels.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, test } from 'vitest'; + +import { getDisclosureState, toggleDisclosureState } from "../src/utils/disclosure"; + +describe('feature panel disclosure state', () => { + test('defaults vector config panel to expanded when no state exists', () => { + expect(getDisclosureState({}, 'vectorConfig', false)).toBe(false); + }); + + test('persists collapsed state in the shared panel store', () => { + const store = {}; + + expect(toggleDisclosureState(store, 'vectorConfig', false)).toBe(true); + expect(store.vectorConfig).toBe(true); + expect(getDisclosureState(store, 'vectorConfig', false)).toBe(true); + }); + + test('reopens panel from stored collapsed state', () => { + const store = { vectorConfig: true }; + + expect(toggleDisclosureState(store, 'vectorConfig', false)).toBe(false); + expect(store.vectorConfig).toBe(false); + expect(getDisclosureState(store, 'vectorConfig', false)).toBe(false); + }); +}); diff --git a/paperforge/plugin/tests/vector-ready.test.ts b/paperforge/plugin/tests/vector-ready.test.ts new file mode 100644 index 00000000..0592d61f --- /dev/null +++ b/paperforge/plugin/tests/vector-ready.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from 'vitest'; + +import { shouldRenderVectorReady } from "../src/services/memory-state"; + +describe('shouldRenderVectorReady', () => { + it('keeps vector advanced UI visible while build status text is temporarily null', () => { + expect(shouldRenderVectorReady(true, null)).toBe(true); + }); +}); diff --git a/patch-plan/annotation-schema-v3.md b/patch-plan/annotation-schema-v3.md deleted file mode 100644 index a786dc0e..00000000 --- a/patch-plan/annotation-schema-v3.md +++ /dev/null @@ -1,77 +0,0 @@ -# Annotation Schema v1 (Independent, not v3) - -> Note: While PaperForge's memory layer is at schema v2, annotation schema starts at v1 independently. - -## DB Location - -``` -<vault>/System/PaperForge/indexes/annotations.db -``` - -## Schema Version Management - -```python -ANNOTATIONS_SCHEMA_VERSION = 1 - -def get_annotations_schema_version(conn): - try: - row = conn.execute("SELECT value FROM meta WHERE key = 'schema_version'").fetchone() - return int(row["value"]) if row else 0 - except sqlite3.OperationalError: - return 0 - -def ensure_annotations_schema(conn): - stored = get_annotations_schema_version(conn) - if stored == 0: - _create_annotations_schema(conn) - conn.execute("INSERT OR REPLACE INTO meta (key, value) VALUES ('schema_version', '1')") - conn.commit() - elif stored < ANNOTATIONS_SCHEMA_VERSION: - _migrate_annotations_schema(conn, stored) -``` - -## Full DDL - -See `reports/03-paperforge-schema-design.md` for complete DDL. - -### Tables - -| Table | Purpose | -|-------|---------| -| `meta` | Schema version, last import timestamp, stats | -| `annotations` | Main annotation data | -| `annotations_fts` | FTS5 virtual table for full-text search | -| `sync_queue` | Pending write-back operations (future) | - -### Indexes - -```sql -idx_annotations_paper — paper_id -idx_annotations_type — type -idx_annotations_sync_state — sync_state -idx_annotations_source — source -idx_annotations_page — (paper_id, page_index) -idx_annotations_zotero_key — zotero_key -idx_annotations_deleted — partial index on deleted_at IS NOT NULL -``` - -## Migration Strategy (Future) - -| Version | Changes | -|---------|---------| -| 1 (MVP) | Initial schema as designed | -| 2 | Web API write-back support: add `api_response_json`, `last_push_attempt` columns | -| 3 | Group library support: add `zotero_group_id` column | - -## Cross-DB Note - -Annotations DB is independent from paperforge.db. To correlate: - -```python -# Get all annotations for a paper -def get_paper_annotations(ann_conn, paper_zotero_key): - return ann_conn.execute( - "SELECT * FROM annotations WHERE paper_id = ? AND deleted_at IS NULL ORDER BY sort_index", - (paper_zotero_key,) - ).fetchall() -``` diff --git a/patch-plan/annotation-service-api.md b/patch-plan/annotation-service-api.md deleted file mode 100644 index e440b4b8..00000000 --- a/patch-plan/annotation-service-api.md +++ /dev/null @@ -1,150 +0,0 @@ -# Annotation Service API Design - -> Python service layer for annotation operations. - -## Module: `paperforge/annotation/__init__.py` - -```python -""" -PaperForge Annotation Layer. - -Read-only Zotero SQLite parsing → annotations.db cache → CLI commands → Obsidian overlay. -""" -``` - -## Module: `paperforge/annotation/probe.py` - -```python -class ZoteroAnnotationProbe: - """Read-only probe into Zotero SQLite for annotations.""" - - def __init__(self, zotero_db_path: Path): - self.zotero_db_path = zotero_db_path - - def copy_to_temp(self) -> Path: - """Copy zotero.sqlite to temp file. Returns temp path.""" - - def open_readonly(self, db_path: Path) -> sqlite3.Connection: - """Open SQLite in read-only mode (URI mode=ro).""" - - def fetch_annotations(self, conn, paper_key: str = "", limit: int = 100) -> list[dict]: - """Fetch annotations with parent attachment info.""" - - def fetch_tags_for_annotations(self, conn, item_ids: list[int]) -> dict[int, list[str]]: - """Fetch tags for annotation items.""" - - def probe(self, limit: int = 20) -> dict: - """Full probe: schema check → fetch → return unified JSON.""" -``` - -## Module: `paperforge/annotation/db.py` - -```python -class AnnotationDB: - """Management of annotations.db.""" - - def __init__(self, vault: Path): - self.vault = vault - self.db_path = vault / "System" / "PaperForge" / "indexes" / "annotations.db" - - def get_connection(self, read_only: bool = False) -> sqlite3.Connection: - """Open connection with WAL mode.""" - - def ensure_schema(self): - """Create tables if not exist, migrate if needed.""" - - def integrity_check(self) -> bool: - """PRAGMA integrity_check.""" - - def get_stats(self) -> dict: - """Return count by type, source, sync_state.""" -``` - -## Module: `paperforge/annotation/import_annotations.py` - -```python -def import_from_zotero( - vault: Path, - zotero_db_path: Path | None = None, - paper_key: str = "", - dry_run: bool = False, -) -> dict: - """Import annotations from Zotero SQLite into annotations.db. - - Steps: - 1. Detect/copy Zotero SQLite - 2. Open read-only connection - 3. Fetch annotations (optionally filtered by paper_key) - 4. For each annotation: - a. Check if already imported (by zotero_key + source_version) - b. If new: INSERT with sync_state='zotero_synced', is_readonly=1 - c. If updated: UPDATE with new position/comment/text, bump version - d. If deleted: soft delete (set deleted_at) - 5. Commit - 6. Return import stats - """ -``` - -## Module: `paperforge/annotation/export.py` - -```python -def export_json(ann_conn, paper_key: str) -> str: - """Export annotations for a paper as pretty JSON.""" - -def export_markdown(ann_conn, paper_key: str, paper_title: str) -> str: - """Export annotations as formatted Markdown.""" -``` - -## CLI: `paperforge/commands/annotation.py` - -```python -def run(args) -> int: - """Dispatch annotation subcommands.""" - if args.annotation_action == "import": - return _cmd_import(args) - elif args.annotation_action == "list": - return _cmd_list(args) - elif args.annotation_action == "create": - return _cmd_create(args) - elif args.annotation_action == "patch": - return _cmd_patch(args) - elif args.annotation_action == "delete": - return _cmd_delete(args) - elif args.annotation_action == "export": - return _cmd_export(args) - elif args.annotation_action == "status": - return _cmd_status(args) -``` - -## JSON Output Contract - -All commands support `--json` flag. Output envelope: - -```json -{ - "ok": true, - "command": "annotation <subcommand>", - "data": { - "paper_id": "ABCD1234", - "annotations": [...], - "count": 15 - }, - "meta": { - "db_path": "/path/to/annotations.db", - "elapsed_ms": 123 - } -} -``` - -Error envelope: - -```json -{ - "ok": false, - "command": "annotation <subcommand>", - "error": { - "code": "ZOTERO_DB_NOT_FOUND", - "message": "Zotero SQLite not found at /path/to/zotero.sqlite" - } -} -``` diff --git a/patch-plan/plugin-overlay-adapter.md b/patch-plan/plugin-overlay-adapter.md deleted file mode 100644 index ec17ea1b..00000000 --- a/patch-plan/plugin-overlay-adapter.md +++ /dev/null @@ -1,396 +0,0 @@ -# Plugin Overlay Adapter Design - -> Obsidian plugin side of the annotation overlay system. - -## Module Structure - -``` -plugin/src/ -├── main.ts ← extended: add annotation commands -├── pdf-overlay/ -│ ├── patch-pdf-viewer.ts ← monkey-around patches -│ ├── overlay-layer.ts ← overlay DOM management -│ ├── rect-renderer.ts ← rect placement + colors -│ ├── selection-handler.ts ← text selection → annotation -│ ├── popover.ts ← annotation popover -│ ├── annotation-fetcher.ts ← execFile bridge -│ └── types.ts ← interfaces -└── testable.js ← extended -``` - -## Patch Sequence (in `patch-pdf-viewer.ts`) - -```typescript -import { around } from 'monkey-around'; -import { Plugin } from 'obsidian'; - -export function patchPDFViewer(plugin: Plugin): boolean { - // Step 1: Get PDFView constructor - const pdfLeaves = plugin.app.workspace.getLeavesOfType('pdf'); - if (!pdfLeaves.length) return false; - const pdfView = pdfLeaves[0].view as any; - const PDFViewerChildProto = pdfView.constructor.prototype; - - // Step 2: Patch loadFile to inject annotation overlay - plugin.register(around(PDFViewerChildProto, { - loadFile(old: Function) { - return function (this: any, file: any) { - const result = old.call(this, file); - initOverlayForFile(this, file); - return result; - }; - } - })); - - // Step 3: Patch load to register event listeners - plugin.register(around(PDFViewerChildProto, { - load(old: Function) { - return function (this: any) { - const result = old.call(this); - registerOverlayEvents(this); - return result; - }; - }, - unload(old: Function) { - return function (this: any) { - cleanupOverlay(this); - return old.call(this); - }; - } - })); - - return true; -} -``` - -## Overlay Layer Management (in `overlay-layer.ts`) - -```typescript -export class OverlayLayerManager { - private pageLayers: Map<number, HTMLElement> = new Map(); - - getOrCreateLayer(pageView: any): HTMLElement { - const pageDiv = pageView.div; - let layer = pageDiv.querySelector('div.pf-annotation-overlay') as HTMLElement; - if (!layer) { - layer = pageDiv.createDiv('pf-annotation-overlay'); - window.pdfjsLib.setLayerDimensions(layer, pageView.viewport); - } - return layer; - } - - clearPage(pageNumber: number): void { - const layer = this.pageLayers.get(pageNumber); - if (layer) layer.empty(); - } - - clearAll(): void { - this.pageLayers.forEach(layer => layer.empty()); - this.pageLayers.clear(); - } -} -``` - -## Rect Renderer (in `rect-renderer.ts`) - -```typescript -export interface AnnotationRect { - id: string; - type: 'highlight' | 'underline' | 'note'; - rect: [number, number, number, number]; // PDF coords [left, bottom, right, top] - color: string; - comment?: string; - isReadonly: boolean; -} - -export class RectRenderer { - render(rects: AnnotationRect[], pageView: any, layer: HTMLElement): void { - const viewport = pageView.viewport; - const [pageX, pageY, pageX2, pageY2] = viewport.viewBox; - const pageW = pageX2 - pageX; - const pageH = pageY2 - pageY; - - for (const ann of rects) { - const [left, bottom, right, top] = ann.rect; - const mirrored = window.pdfjsLib.Util.normalizeRect([ - left, pageY2 - bottom + pageY, - right, pageY2 - top + pageY - ]); - - const el = layer.createDiv('pf-annotation-rect'); - el.dataset.annotationId = ann.id; - el.dataset.type = ann.type; - - el.setCssStyles({ - left: `${100 * (mirrored[0] - pageX) / pageW}%`, - top: `${100 * (mirrored[1] - pageY) / pageH}%`, - width: `${100 * (mirrored[2] - mirrored[0]) / pageW}%`, - height: `${100 * (mirrored[3] - mirrored[1]) / pageH}%`, - }); - - if (ann.type === 'highlight') { - el.style.background = hexToRgba(ann.color, 0.25); - } else if (ann.type === 'underline') { - el.style.borderBottom = `2px solid ${ann.color}`; - } else if (ann.type === 'note') { - el.style.background = hexToRgba(ann.color, 0.15); - el.style.border = `1px solid ${ann.color}`; - } - - if (ann.isReadonly) { - el.classList.add('pf-annotation-readonly'); - } - } - } -} - -function hexToRgba(hex: string, alpha: number): string { - const r = parseInt(hex.slice(1, 3), 16); - const g = parseInt(hex.slice(3, 5), 16); - const b = parseInt(hex.slice(5, 7), 16); - return `rgba(${r}, ${g}, ${b}, ${alpha})`; -} -``` - -## Selection Handler (in `selection-handler.ts`) - -```typescript -export class SelectionHandler { - private pendingButton: HTMLElement | null = null; - - handleSelection(pageView: any): void { - const selection = window.getSelection(); - if (!selection || selection.isCollapsed) { - this.removePendingButton(); - return; - } - - const range = selection.getRangeAt(0); - const textLayer = pageView.textLayer; - if (!textLayer) return; - - // Determine page index and selected rects - const pageNumber = pageView.id; - const selectedText = selection.toString().trim(); - if (!selectedText) return; - - // Show floating "Add Highlight" button - this.showAddButton(pageView, range, selectedText); - } - - private async addAnnotation(pageView: any, selectedText: string, rects: number[][]): Promise<void> { - const sortIndex = this.buildSortIndex(pageView.id, 0, 0); - const position = { - pageIndex: pageView.id - 1, - rects: rects.map(r => [r[0], r[1], r[2], r[3]]), - }; - - // execFile → paperforge annotation create - const result = await createAnnotation({ - paperKey: currentPaperKey, - type: 'highlight', - pageIndex: pageView.id - 1, - selectedText, - color: '#ffd400', - position, - sortIndex, - }); - - // Optimistic add to overlay - renderSingleAnnotation(result, pageView); - } -} -``` - -## Popover (in `popover.ts`) - -```typescript -export class AnnotationPopover { - private popoverEl: HTMLElement | null = null; - - show(annotation: Annotation, rect: DOMRect, pageView: any): void { - this.dismiss(); - - this.popoverEl = pageView.div.createDiv('pf-annotation-popover'); - this.popoverEl.setCssStyles({ - position: 'absolute', - left: `${rect.left}px`, - top: `${rect.bottom + 8}px`, - }); - - this.popoverEl.createDiv('pf-popover-header', (el) => { - el.setText(annotation.type.toUpperCase()); - el.style.color = annotation.color; - }); - - if (annotation.selectedText) { - this.popoverEl.createDiv('pf-popover-text', el => el.setText(`"${annotation.selectedText.slice(0, 100)}..."`)); - } - - if (annotation.comment) { - this.popoverEl.createDiv('pf-popover-comment', el => el.setText(annotation.comment)); - } - - if (!annotation.isReadonly) { - const actions = this.popoverEl.createDiv('pf-popover-actions'); - actions.createEl('button', { text: 'Edit' }, (btn) => { - btn.onclick = () => this.enableEdit(annotation, pageView); - }); - actions.createEl('button', { text: 'Delete' }, (btn) => { - btn.onclick = () => this.deleteAnnotation(annotation.id, pageView); - }); - } else { - this.popoverEl.createDiv('pf-popover-readonly', el => { - el.setText('🔒 Zotero annotation (read-only in PaperForge)'); - }); - } - } - - dismiss(): void { - this.popoverEl?.remove(); - this.popoverEl = null; - } -} -``` - -## execFile Bridge (in `annotation-fetcher.ts`) - -```typescript -import { execFile } from 'child_process'; - -let currentPaperKey: string = ''; - -export async function fetchAnnotations(vaultPath: string, pythonExe: string, paperKey: string): Promise<Annotation[]> { - currentPaperKey = paperKey; - return runAnnotationCommand(vaultPath, pythonExe, ['list', paperKey, '--json']); -} - -export async function createAnnotation(vaultPath: string, pythonExe: string, data: CreatePayload): Promise<Annotation> { - const args = [ - 'create', - '--paper', data.paperKey, - '--type', data.type, - '--page-index', String(data.pageIndex), - '--selected-text', data.selectedText || '', - '--comment', data.comment || '', - '--color', data.color || '#ffd400', - '--position', JSON.stringify(data.position), - '--sort-index', data.sortIndex, - '--json', - ]; - return runAnnotationCommand(vaultPath, pythonExe, args); -} - -async function runAnnotationCommand(vaultPath: string, pythonExe: string, args: string[]): Promise<any> { - return new Promise((resolve, reject) => { - execFile( - pythonExe, - ['-m', 'paperforge', 'annotation', ...args], - { cwd: vaultPath, timeout: 15000 }, - (err, stdout) => { - if (err) { - reject(new Error(`Annotation command failed: ${err.message}`)); - return; - } - const result = JSON.parse(stdout); - if (!result.ok) { - reject(new Error(result.error?.message || 'Annotation command failed')); - return; - } - resolve(result.data); - } - ); - }); -} -``` - -## CSS Styles (to add to `styles.css`) - -```css -/* Annotation overlay layer */ -.pf-annotation-overlay { - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - pointer-events: none; - z-index: 10; -} - -/* Individual annotation rect */ -.pf-annotation-rect { - position: absolute; - pointer-events: auto; - cursor: pointer; - border-radius: 2px; - transition: opacity 0.15s ease; -} - -.pf-annotation-rect:hover { - opacity: 0.6; -} - -.pf-annotation-readonly { - cursor: default; -} - -.pf-annotation-readonly::after { - content: '🔒'; - position: absolute; - top: -8px; - right: -8px; - font-size: 10px; - opacity: 0.5; -} - -/* Popover */ -.pf-annotation-popover { - position: fixed; - z-index: 1000; - background: var(--background-primary); - border: 1px solid var(--background-modifier-border); - border-radius: 8px; - padding: 12px; - min-width: 200px; - max-width: 400px; - box-shadow: var(--shadow-l); - font-size: var(--font-small); -} - -.pf-popover-header { - font-weight: bold; - font-size: var(--font-smaller); - margin-bottom: 8px; -} - -.pf-popover-text { - font-style: italic; - color: var(--text-muted); - margin-bottom: 6px; - padding: 4px 8px; - border-left: 2px solid var(--background-modifier-border); -} - -.pf-popover-comment { - margin-bottom: 8px; - white-space: pre-wrap; -} - -.pf-popover-actions { - display: flex; - gap: 8px; - justify-content: flex-end; -} - -.pf-popover-readonly { - color: var(--text-faint); - font-size: var(--font-smallest); - text-align: center; - padding: 4px; -} - -/* Dark mode adjustments */ -.theme-dark .pf-annotation-rect { - opacity: 0.7; -} diff --git a/reports/00-executive-summary.md b/reports/00-executive-summary.md deleted file mode 100644 index 81cd6a53..00000000 --- a/reports/00-executive-summary.md +++ /dev/null @@ -1,112 +0,0 @@ -# Executive Summary — PaperForge PDF Annotation Layer - -> ROBCO INDUSTRIES (TM) TERMLINK REPORT -> Status: RESEARCH COMPLETE | DESIGN READY - -## The 10 Key Questions Answered - -### 1. ZotFlow 哪些设计值得 PaperForge 借鉴? - -- **AnnotationJSON schema** — ZotFlow 的 `AnnotationJSON` 接口(id, type, position, sortIndex, color, comment, text, tags 等字段)是经过 production 验证的 Zotero 兼容模型 -- **Sync conflict resolution** — 字段级别 diff + version-based optimistic locking + 三种 conflict action(keep-local / accept-remote / mark-conflict) -- **LiquidJS template pipeline** — 模板驱动的 source note 生成可直接复用 -- **Dexie/IndexedDB 缓存模式** — 在 Python 端可用 SQLite 等价实现,设计思路一致(syncStatus 追踪、全 raw payload 存储) - -### 2. ZotFlow 哪些部分太重,PaperForge 应该避免? - -- **内嵌 Zotero reader iframe** — 整个 reader/ 子模块(Zotero PDF.js + Penpal + Comlink),约 2/3 的代码量 -- **CodeMirror 6 扩展** — 可编辑区域、锁、comments、citation suggest 等 O(N) 级别的 editor 集成 -- **React UI 组件** — TreeView(react-arborist)、Activity Center、settings tab 等 -- **完整的自适应同步引擎** — 42,918 行的 sync.ts,涵盖双向 pull/push、冲突检测、批量重试等,对只读场景完全不需要 - -### 3. 本地解析 Zotero SQLite 是否足够支持只读 annotation sync? - -**完全足够。** 所有 annotation 数据存储在 `itemAnnotations` 表 + `items` 表 + `tags` 表 + `itemTags` 表,通过 standard SQL JOIN 可获取全部字段(type, text, comment, color, pageLabel, sortIndex, position JSON, tags, dateModified)。 - -Zotero 官方文档明确标注 SQLite 可以被外部工具只读提取。 - -注意点:Zotero 运行时 SQLite 有缓存层,必须先 COPY DB 到临时目录再读,避免锁和不一致。 - -### 4. Zotero annotation 到 PaperForge annotation 的字段映射 - -| Zotero 字段 | PaperForge 字段 | 说明 | -| ------------------------- | ------------------ | --------------------------------- | -| `itemAnnotations.type` | `type` | 1→highlight, 2→note, 3→image... | -| `items.key` | `zotero_key` | 8 字符 base32 key | -| `itemAnnotations.text` | `selected_text` | 仅 highlight/underline 有值 | -| `itemAnnotations.comment` | `comment` | 所有类型都可能含 comment | -| `itemAnnotations.color` | `color` | Hex 色号,如 #ffd400 | -| `itemAnnotations.pageLabel`| `page_label` | 标页码字符串 | -| `itemAnnotations.sortIndex`| `sort_index` | 格式化零填充排序索引 | -| `itemAnnotations.position` | `position_json` | 原始 JSON 字符串,含 rects/paths | -| `itemTags → tags.name` | `tags_json` | JSON 数组 | -| `items.dateModified` | `source_modified_at` | ISO 8601 | - -### 5. PaperForge 是否应该修改现有 paperforge.db schema? - -**不应该。** Annotation 是用户数据,现有 `drop_all_tables()` 会在 memory rebuild 时无差别删除。 - -**决策:独立 `annotations.db`**,与 `paperforge.db` 并列存放。两者通过 `zotero_key` 关联。rebuild memory layer 时 annotations.db 完全不受影响。 - -### 6. Annotation 表应该如何避免被 memory rebuild 删除? - -采用独立 DB + 独立 schema version 管理: - -``` -paperforge/indexes/ -├── paperforge.db ← memory layer (schema v2, 可 rebuild) -├── annotations.db ← annotation layer (schema v1, 独立管理) -├── formal-library.json ← canonical index -├── memory-runtime-state.json -└── vector-runtime-state.json -``` - -`annotations.db` 有自己的 `meta` 表记录 `schema_version`,由 annotation CLI 命令独立管理 migration。 - -### 7. Obsidian PDF overlay 第一版是否可行? - -**可行。** PDF++(2095 stars)已验证了 monkey-patching 路线的可行性。核心机制: - -1. 用 `monkey-around` 库的 `around()` 函数 patch `PDFViewerChild.prototype` -2. 在每个 `div.page` 内创建 overlay layer div -3. 用 `window.pdfjsLib.setLayerDimensions()` 对齐坐标空间 -4. 用百分比 CSS 定位实现缩放无关渲染 -5. 监听 `textlayerrendered` / `annotationlayerrendered` 事件触发重绘 - -### 8. 是否需要自建 PDF.js reader? - -**不需要** 且 **不推荐**。Obsidian 内置了 PDF.js(通过 `loadPdfJs()` 可从 plugin 访问),ZotFlow 的自建 reader iframe 是其最重的部分。PaperForge 应该直接 hook 原生 PDF viewer,仅在需要 overlay 的页面上添加 DOM 层。 - -### 9. 第一版是否应该写回 Zotero? - -**架构层面设计写回,但 v1 不实现。** 推荐 hybrid 路线: - -- **读路径**:本地 SQLite 只读解析(快、离线、零配置) -- **写路径**:通过 Zotero Web API 写回(安全、version 乐观锁、官方支持) -- **v1**:本地 SQLite 读取 + 本地 annotations.db 编辑,`sync_state` = `local` / `pending_push` -- **v2**:实现 Web API push,`sync_state` = `zotero_synced` - -schema 从第一天就包含 `sync_state`、`source`、`source_version` 等字段,为写回预留。 - -### 10. 最推荐的 MVP 开发路线是什么? - -``` -Phase 1 (1 周): DB + CLI - annotations.db schema - paperforge annotation import (Zotero SQLite read-only) - paperforge annotation list/patch/delete - 独立 schema version 管理,sync_state 预留 - -Phase 2 (1-2 周): Plugin Overlay - monkey-patch PDF viewer - render highlight/underline/note rects - selection → create annotation - click → edit popover - Zotero annotations show as read-only (lock icon) - PaperForge local annotations are editable - -Phase 3 (后续): Web API Write-back - Web API 客户端 (zotero-api-client 封装) - sync queue → pending_push → API push - 冲突检测与手动解决 -``` diff --git a/reports/01-zotflow-architecture.md b/reports/01-zotflow-architecture.md deleted file mode 100644 index a5ee0323..00000000 --- a/reports/01-zotflow-architecture.md +++ /dev/null @@ -1,113 +0,0 @@ -# ZotFlow Architecture Analysis - -> Status: COMPLETE | Repo: duanxianpi/obsidian-zotflow v1.0.9 | License: AGPL-3.0 - -## Architecture Overview - -ZotFlow 采用 **Main Thread + Web Worker + Reader Iframe** 三层架构: - -``` -Obsidian Main Thread -├── Plugin (main.ts) -│ ├── WorkerBridge (Comlink) ←→ Web Worker -│ ├── ZoteroReaderView (Penpal) ←→ Reader Iframe (zotero/reader) -│ ├── LocalReaderView (vault files) -│ ├── NoteEditorView -│ └── TreeView (React + react-arborist) -│ -├── Services (main-thread) -│ ├── ServiceLocator -│ ├── IndexService (zotero_key → file path from frontmatter) -│ ├── ViewStateService -│ ├── CitationService -│ └── EventBus / TaskMonitor / LogService / NotificationService -│ -└── Ui Components - ├── reader/view.ts / bridge.ts / local-view.ts - ├── tree-view/ (React) - ├── activity-center/ (React modal) - ├── editor/ (CodeMirror 6 extensions) - └── modals/ -``` - -``` -Web Worker (worker.ts) -├── ZoteroService (zotero-api-client wrapper) -├── SyncService (42,918 bytes — bidirectional sync engine) -├── AnnotationService (CRUD + diff) -├── LibraryService (per-library permissions) -├── AttachmentService (download + cache from API/WebDAV) -├── TemplateService (LiquidJS rendering) -├── NoteService (source note generation) -├── ConflictService (field-level diff + resolve) -├── IndexedDB (Dexie) — local cache for all Zotero data -└── PDFProcessWorker (nested PDF.js worker for export/import) -``` - -## Communication Protocols - -| Boundary | Protocol | Mechanism | -|----------|----------|-----------| -| Main ↔ Worker | Comlink | Proxy-based RPC, `Comlink.wrap<WorkerAPI>()` | -| Main ↔ Reader Iframe | Penpal | WindowMessenger, structured clone | -| Worker ↔ PDF.js Worker | postMessage | Promise-based request/response | - -## Annotation Data Flow - -### Read Path (Zotero → Plugin → Iframe) - -``` -Zotero API → Worker SyncService → IndexedDB (items table) - → Plugin.annotationService.getAnnotations() - → getAnnotationJson() (db/annotation.ts) - → AnnotationJSON[] → ZoteroReaderView - → IframeBridge.initReader({ annotations }) - → AnnotationManager.setAnnotations() → render overlays -``` - -### Write Path (Iframe → Plugin → Zotero) - -``` -User creates annotation in reader iframe - → annotationsSaved event (Penpal) - → ZoteroReaderView.handleAnnotationsSaved() - → WorkerBridge.annotation.saveAnnotations() - → AnnotationService.saveAnnotations() - → diff with existing (annotationDataDiff) - → upsert IndexedDB items (syncStatus = "created"/"updated") - → trigger source note re-render (debounced) - → Next push cycle: SyncService.pushDirtyItems() - → Zotero API (version-based optimistic locking) -``` - -## Key Technical Decisions - -### Why Web API instead of SQLite? -- Full bidirectional sync requires version management -- SQLite write would bypass Zotero's validation + sync state -- Web API supports group libraries, conflict detection - -### Local Cache -- Dexie/IndexedDB with schema migration (v1→v2→v3) -- `syncStatus` tracking per item: synced / created / updated / deleted / ignore / conflict -- Raw API response stored in `raw` field for field-level diff - -### Reader Embedding -- Git submodule to `zotero/reader` -- Built via webpack, inlined as blob URLs -- Modified Zotero reader engine with Obsidian theme integration -- Iframe sandbox restricted to `allow-scripts allow-same-origin allow-forms` - -## What PaperForge Should Borrow - -1. **AnnotationJSON schema** — well-designed, Zotero-compatible data model -2. **Conflict resolution model** — field-level diff with keep-local / accept-remote -3. **LiquidJS template pipeline** — if PaperForge wants template-driven notes -4. **SyncStatus pattern** — clean state machine for sync tracking - -## What PaperForge Should NOT Borrow - -1. **Embedded reader iframe** — too heavy, PaperForge should hook native PDF viewer -2. **CodeMirror 6 extensions** — Obsidian-specific, PaperForge doesn't do real-time editing -3. **React UI components** — PaperForge's plugin is minimal (ribbon icons + status bar) -4. **Full bidirectional sync engine** — PaperForge is read-first, write-limited diff --git a/reports/01-zotflow-dataflow.mmd b/reports/01-zotflow-dataflow.mmd deleted file mode 100644 index 4e184abe..00000000 --- a/reports/01-zotflow-dataflow.mmd +++ /dev/null @@ -1,65 +0,0 @@ -```mermaid -graph TB - subgraph "Zotero Cloud" - ZAPI[Zotero Web API] - ZStorage[Zotero File Storage] - end - - subgraph "Web Worker" - direction TB - Sync[SyncService] - Annotation[AnnotationService] - IDB[(IndexedDB\nDexie)] - Tpl[TemplateService\nLiquidJS] - Attach[AttachmentService] - PDFW[PDF Process Worker] - Conflict[ConflictService] - end - - subgraph "Main Thread (Obsidian Plugin)" - direction TB - WB[WorkerBridge\nComlink] - Plugin[ZotFlow Plugin] - RB[ReaderBridge\nPenpal] - CM6[CodeMirror 6\nExtensions] - Tree[Tree View\nReact] - Activity[Activity Center] - end - - subgraph "Reader Iframe" - direction TB - Reader[Zotero Reader Engine] - AM[Annotation Manager] - PDFJS[PDF.js] - end - - subgraph "Obsidian Vault" - SN[Source Notes\n.md] - ZF[.zf.json Sidecar\nfor local files] - end - - ZAPI -->|"REST API\nversion-based"| Sync - ZStorage -->|"File download"| Attach - Sync --> IDB - IDB --> Annotation - Annotation -->|"getAnnotations()"| WB - Annotation -->|"saveAnnotations()"| Tpl - Tpl --> SN - Attach -->|"Blob"| WB - WB <--> Plugin - Plugin --> RB - RB -->|"initReader(data)"| Reader - Reader --> PDFJS - Reader --> AM - AM -->|"annotationsSaved"| RB - AM -->|"annotationsDeleted"| RB - WB <-->|"extractExternalAnnotations"| PDFW - Plugin --> CM6 - Plugin --> Tree - Plugin --> Activity - Plugin -->|"LocalReader"| ZF - - style ZAPI fill:#f9f,stroke:#333,stroke-width:2px - style IDB fill:#bbf,stroke:#333,stroke-width:2px - style Reader fill:#bfb,stroke:#333,stroke-width:2px -``` diff --git a/reports/02-zotero-sqlite-schema.md b/reports/02-zotero-sqlite-schema.md deleted file mode 100644 index 7c5777dd..00000000 --- a/reports/02-zotero-sqlite-schema.md +++ /dev/null @@ -1,160 +0,0 @@ -# Zotero Annotation SQLite Schema Report - -> Status: COMPLETE | Source: zotero/zotero (main branch) - -## Table: itemAnnotations - -```sql -CREATE TABLE itemAnnotations ( - itemID INTEGER PRIMARY KEY, - parentItemID INT NOT NULL, - type INTEGER NOT NULL, - authorName TEXT, - text TEXT, - comment TEXT, - color TEXT, - pageLabel TEXT, - sortIndex TEXT NOT NULL, - position TEXT NOT NULL, - isExternal INT NOT NULL, - FOREIGN KEY (itemID) REFERENCES items(itemID) ON DELETE CASCADE, - FOREIGN KEY (parentItemID) REFERENCES itemAttachments(itemID) -); -CREATE INDEX itemAnnotations_parentItemID ON itemAnnotations(parentItemID); -``` - -## Annotation Type Mapping - -| Integer | String | Description | -|---------|-------------|----------------------| -| 1 | `highlight` | Text highlight | -| 2 | `note` | Sticky note | -| 3 | `image` | Image region capture | -| 4 | `ink` | Freehand drawing | -| 5 | `underline` | Text underline | -| 6 | `text` | Text box (resizable) | - -Source: `chrome/content/zotero/xpcom/annotations.js` -```javascript -Zotero.Annotations.ANNOTATION_TYPE_HIGHLIGHT = 1; -Zotero.Annotations.ANNOTATION_TYPE_NOTE = 2; -Zotero.Annotations.ANNOTATION_TYPE_IMAGE = 3; -Zotero.Annotations.ANNOTATION_TYPE_INK = 4; -Zotero.Annotations.ANNOTATION_TYPE_UNDERLINE = 5; -Zotero.Annotations.ANNOTATION_TYPE_TEXT = 6; -``` - -## Position JSON Structures - -### Highlight / Underline / Note / Text (type 1, 2, 5, 6) -```json -{ - "pageIndex": 1, - "rects": [ - [231.284, 402.126, 293.107, 410.142], - [54.222, 392.164, 293.107, 400.18] - ] -} -``` -Each rect: `[left, top, right, bottom]` in PDF points (1/72 inch), origin bottom-left. - -### Image (type 3) -```json -{ - "pageIndex": 123, - "rects": [[314.4, 412.8, 556.2, 609.6]], - "width": 400, - "height": 200 -} -``` - -### Ink (type 4) -```json -{ - "pageIndex": 1, - "width": 2, - "paths": [ - [x0, y0, x1, y1, x2, y2, ...], - [x3, y3, x4, y4, ...] - ] -} -``` - -## Key Fields - -| Column | Type | Notes | -|-------------------|----------|-----------------------------------------------| -| `text` | TEXT | Extracted text for highlight/underline only | -| `comment` | TEXT | User's annotation note (may contain HTML) | -| `color` | TEXT | Hex color, e.g. `#ffd400` (default yellow) | -| `pageLabel` | TEXT | Page label string, e.g. "15", "XVI" | -| `sortIndex` | TEXT | Zero-padded: `"<page:05d>|<rect:06d>|<char:05d>"` | -| `position` | TEXT | JSON string (can be up to 65KB before split) | -| `isExternal` | INT | 1 = embedded PDF annotation (read-only) | - -## Relationship Traversal - -```sql --- Get all annotations for a paper (top-level item) -SELECT - ia.itemID, - ia.type, - ia.text, - ia.comment, - ia.color, - ia.pageLabel, - ia.sortIndex, - ia.position, - ia.isExternal, - i.key AS annotation_key, - i.dateModified, - i.libraryID, - att.path AS attachment_path, - att.key AS attachment_key, - att.linkMode AS attachment_link_mode -FROM items paper -JOIN items att ON att.parentItemID = paper.itemID - AND att.itemTypeID = (SELECT itemTypeID FROM itemTypes WHERE typeName = 'attachment') -JOIN itemAnnotations ia ON ia.parentItemID = att.itemID -JOIN items i ON i.itemID = ia.itemID -WHERE paper.key = ?; - --- Get tags for annotations -SELECT - i.key AS annotation_key, - t.name AS tag_name -FROM items i -JOIN itemTags it ON it.itemID = i.itemID -JOIN tags t ON t.tagID = it.tagID -WHERE i.key IN (?); -``` - -## Annotation Color Presets - -Source: `zotero/reader/src/common/defines.js` -```javascript -const ANNOTATION_COLORS = [ - ['yellow', '#ffd400'], - ['red', '#ff6666'], - ['green', '#5fb236'], - ['blue', '#2ea8e5'], - ['purple', '#a28ae5'], - ['magenta', '#e56eee'], - ['orange', '#f19837'], - ['gray', '#aaaaaa'], -]; -``` - -## Position Size Limit - -`ANNOTATION_POSITION_MAX_SIZE = 65000` bytes. Annotations whose serialized `position` JSON exceeds this are split into multiple annotation items (by rect or path segment). - -## Important: Read-Only Access Only - -Zotero documentation explicitly warns: -> "access to the SQLite database should be done only in a read-only manner. Modifying the database while Zotero is running can easily result in a corrupted database." - -PaperForge must: -1. COPY `zotero.sqlite` to a temp path before reading (if Zotero is running) -2. Never write to any Zotero SQLite table -3. Never assume schema stability across Zotero versions diff --git a/reports/03-paperforge-schema-design.md b/reports/03-paperforge-schema-design.md deleted file mode 100644 index 3f47bce3..00000000 --- a/reports/03-paperforge-schema-design.md +++ /dev/null @@ -1,190 +0,0 @@ -# PaperForge Annotation Schema Integration Design - -> Status: DESIGN COMPLETE | Schema Version: v1 (independent) - -## Decision: Independent annotations.db - -Annotation data will live in a **separate SQLite database** (`annotations.db`) co-located with `paperforge.db`: - -``` -<vault>/System/PaperForge/indexes/ -├── paperforge.db ← Memory Layer (rebuildable) -├── annotations.db ← Annotation Layer (never dropped) -├── formal-library.json -└── runtime-state*.json -``` - -### Rationale - -| Concern | Solution | -|---------|----------| -| `drop_all_tables()` would destroy user data | Separate DB is immune to rebuild | -| Schema version coupling | Independent schema version management | -| Rebuild frequency | Memory rebuild is frequent, annotations should persist | -| Backup granularity | Users may want to backup annotations independently | -| Concurrent access | Annotations can be written while memory is being rebuilt | - -## Schema: annotations.db - -```sql --- Schema version management -CREATE TABLE IF NOT EXISTS meta ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL -); - --- Main annotation table -CREATE TABLE IF NOT EXISTS annotations ( - id TEXT PRIMARY KEY, -- UUID for local, zotero_key for imported - - -- Paper association (via zotero_key) - paper_id TEXT NOT NULL, - - -- Zotero source tracking - zotero_library_id INTEGER, - zotero_item_id INTEGER, - zotero_key TEXT DEFAULT '', - zotero_attachment_key TEXT DEFAULT '', - - -- PDF tracking - pdf_path TEXT DEFAULT '', - pdf_hash TEXT DEFAULT '', - - -- Annotation data - type TEXT NOT NULL, -- highlight|underline|note|image|ink|text - page_index INTEGER, -- 0-based - page_label TEXT DEFAULT '', - - selected_text TEXT DEFAULT '', - comment TEXT DEFAULT '', - color TEXT DEFAULT '', -- hex: #ffd400 - sort_index TEXT DEFAULT '', - - -- Position data - position_json TEXT DEFAULT '{}', - selector_json TEXT DEFAULT '{}', -- for EPUB/web annotations (future) - - -- Tags - tags_json TEXT DEFAULT '[]', - - -- Sync state (from Zotero or local) - source TEXT NOT NULL DEFAULT 'paperforge', -- paperforge|zotero_db|pdf_embedded|imported_json - source_key TEXT DEFAULT '', -- original key in source system - source_version INTEGER, -- Zotero version number - source_modified_at TEXT DEFAULT '', -- ISO 8601 from Zotero - - sync_state TEXT NOT NULL DEFAULT 'local', -- local|zotero_synced|zotero_remote_changed|local_modified|conflict|pending_push - is_readonly INTEGER NOT NULL DEFAULT 0, -- 1 = Zotero-sourced, not editable - - -- Metadata - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - deleted_at TEXT, -- soft delete - - FOREIGN KEY (paper_id) REFERENCES papers(zotero_key) -- conceptual, not enforced cross-DB -); - --- Indexes -CREATE INDEX IF NOT EXISTS idx_annotations_paper ON annotations(paper_id); -CREATE INDEX IF NOT EXISTS idx_annotations_type ON annotations(type); -CREATE INDEX IF NOT EXISTS idx_annotations_sync_state ON annotations(sync_state); -CREATE INDEX IF NOT EXISTS idx_annotations_source ON annotations(source); -CREATE INDEX IF NOT EXISTS idx_annotations_page ON annotations(paper_id, page_index); -CREATE INDEX IF NOT EXISTS idx_annotations_zotero_key ON annotations(zotero_key); -CREATE INDEX IF NOT EXISTS idx_annotations_deleted ON annotations(deleted_at) WHERE deleted_at IS NOT NULL; - --- FTS5 full-text search on annotation text and comments -CREATE VIRTUAL TABLE IF NOT EXISTS annotations_fts USING fts5( - paper_id, - selected_text, - comment, - tags_json, - content='annotations', - content_rowid='rowid' -); - --- Triggers to keep FTS in sync -CREATE TRIGGER IF NOT EXISTS annotations_ai AFTER INSERT ON annotations BEGIN - INSERT INTO annotations_fts(rowid, paper_id, selected_text, comment, tags_json) - VALUES (new.rowid, new.paper_id, new.selected_text, new.comment, new.tags_json); -END; - -CREATE TRIGGER IF NOT EXISTS annotations_ad AFTER DELETE ON annotations BEGIN - INSERT INTO annotations_fts(annotations_fts, rowid, paper_id, selected_text, comment, tags_json) - VALUES ('delete', old.rowid, old.paper_id, old.selected_text, old.comment, old.tags_json); -END; - -CREATE TRIGGER IF NOT EXISTS annotations_au AFTER UPDATE ON annotations BEGIN - INSERT INTO annotations_fts(annotations_fts, rowid, paper_id, selected_text, comment, tags_json) - VALUES ('delete', old.rowid, old.paper_id, old.selected_text, old.comment, old.tags_json); - INSERT INTO annotations_fts(rowid, paper_id, selected_text, comment, tags_json) - VALUES (new.rowid, new.paper_id, new.selected_text, new.comment, new.tags_json); -END; - --- Sync queue for pending write-back operations -CREATE TABLE IF NOT EXISTS sync_queue ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - annotation_id TEXT NOT NULL, - operation TEXT NOT NULL, -- create|update|delete - payload_json TEXT NOT NULL, - retry_count INTEGER DEFAULT 0, - last_error TEXT, - created_at TEXT NOT NULL, - FOREIGN KEY (annotation_id) REFERENCES annotations(id) -); - -CREATE INDEX IF NOT EXISTS idx_sync_queue_pending ON sync_queue(annotation_id, operation) - WHERE retry_count < 3; -``` - -## Sync State Machine - -``` - ┌─────────────┐ - │ local │ ← PaperForge-native annotation (no Zotero source) - └──────┬──────┘ - │ - ┌──────▼──────┐ - │ pending_push│ ← local edit pending Zotero API push - └──────┬──────┘ - │ - ┌──────▼──────┐ - │zotero_synced│ ← successfully pushed to Zotero - └──────┬──────┘ - │ - ┌────────────┼────────────┐ - │ │ │ - ┌────────▼───┐ ┌──────▼──────┐ ┌──▼─────────┐ - │local_modif.│ │remote_change│ │ conflict │ - │ (push) │ │ (re-pull) │ │ (manual fix)│ - └────────────┘ └─────────────┘ └─────────────┘ -``` - -## How to Avoid Rebuild Conflict - -```python -# In memory/builder.py build_from_index(): -# DO NOT drop annotations.db tables -# Only operate on paperforge.db tables - -ANNOTATIONS_DB = "annotations.db" # managed separately - -def build_from_index(vault: Path) -> dict: - # ... existing code touches paperforge.db only ... - # annotations.db is never dropped -``` - -## Cross-DB Join Pattern - -Since annotations and papers are in separate databases, queries that need both do a two-step lookup: - -```python -# Step 1: Get paper -paper = lookup_paper(conn_paperforge, query) - -# Step 2: Get annotations for that paper -annotations = conn_annotations.execute( - "SELECT * FROM annotations WHERE paper_id = ? AND deleted_at IS NULL ORDER BY sort_index", - (paper["zotero_key"],) -).fetchall() -``` diff --git a/reports/04-obsidian-pdf-overlay-feasibility.md b/reports/04-obsidian-pdf-overlay-feasibility.md deleted file mode 100644 index 3ba3423f..00000000 --- a/reports/04-obsidian-pdf-overlay-feasibility.md +++ /dev/null @@ -1,213 +0,0 @@ -# Obsidian PDF Overlay Feasibility Analysis - -> Status: FEASIBLE | Reference: PDF++ (RyotaUshio/obsidian-pdf-plus) - -## Verdict - -**Technically feasible** via monkey-patching Obsidian's private PDF viewer internals. PDF++ (2095 stars, 254 releases) has proven this approach works. However, there is **no public Obsidian API** for PDF viewing — every hook is reverse-engineered. - -## The Alternative: Why Not Build A Custom PDF View? - -| Approach | Pros | Cons | -|----------|------|------| -| Monkey-patch native viewer | Seamless UX, reuse native features | Fragile, requires version-gating | -| Custom view type + loadPdfJs() | Stable API, full control | User loses native PDF features, UX fragmentation | - -**User chose: monkey-patch native viewer.** - -## How PDF++ Patches the PDF Viewer - -### Core Patcher: `monkey-around` - -```typescript -import { around } from 'monkey-around'; - -export const patchPDFInternals = (plugin: Plugin): boolean => { - plugin.register(around(PDFViewerChild.prototype, { - load(old) { - return function () { - // Plugin initialization here - old.call(this); - }; - }, - loadFile(old) { - return function (file: TFile) { - old.call(this, file); - // Create visualizer, register event listeners - }; - } - })); -}; -``` - -### Patch Chain - -``` -patchWorkspace() → WorkspaceLeaf openLinkText -patchPDFView() → PDFView.prototype (getState, setState, onLoadFile) -patchPDFInternals() → PDFViewerComponent, PDFViewerChild, ObsidianViewer -patchPDFInternalFromPDFEmbed() → PDF embeds -patchBacklink() → backlink pane -``` - -## DOM Structure of Obsidian's PDF Viewer - -``` -div.pdf-viewer-container - div.pdf-viewer - div.page[data-page-number="1"] - canvas.canvasWrapper - canvas ← rendered PDF canvas - div.textLayer - span.textLayerNode[data-idx] - div.annotationLayer - section[data-annotation-id] - div.custom-overlay-layer ← PaperForge injects here - div.annotation-highlight - div.page[data-page-number="2"]... -``` - -## Key Obsidian Internal Classes - -| Class | How to Access | -|-------|---------------| -| `PDFView` | `leaf.view` on 'pdf' type leaf | -| `PDFViewerComponent` | `pdfView.viewer` | -| `PDFViewerChild` | `viewerComponent.child` | -| `ObsidianViewer` | `child.pdfViewer` | -| `PDFPageView` | `pdfViewer.getPageView(n)` | -| `EventBus` | `child.pdfViewer.eventBus` | - -All obtained via: -```typescript -const pdfLeaves = app.workspace.getLeavesOfType('pdf'); -const pdfView = pdfLeaves[0].view as any; -``` - -## Events to Listen To - -| Event | When to Rerender | -|-------|------------------| -| `textlayerrendered` | Page text layer ready, safe to render text-based annotations | -| `annotationlayerrendered` | Page annotation layer ready | -| `pagerendered` | Page canvas rendered | -| `pagechanging` | Current page changed | -| `scalechanged` | Zoom level changed | -| `pagesloaded` | All pages loaded | - -```typescript -child.pdfViewer.eventBus.on('textlayerrendered', (data) => { - const pageView = data.source; // PDFPageView - renderAnnotationsForPage(pageView, annotationsForPage(pageView.id)); -}); -``` - -## Coordinate Transformation - -### PDF → Overlay Position -```typescript -function placeAnnotationRect(rect: number[], pageView: PDFPageView) { - const viewport = pageView.viewport; - const [left, bottom, right, top] = rect; - - // Mirror Y axis (PDF origin is bottom-left, screen is top-left) - const mirrored = window.pdfjsLib.Util.normalizeRect([ - left, - viewport.viewBox[3] - bottom + viewport.viewBox[1], - right, - viewport.viewBox[3] - top + viewport.viewBox[1] - ]); - - // Create overlay layer per page - const pageDiv = pageView.div; - let layer = pageDiv.querySelector('div.pf-annotation-overlay'); - if (!layer) { - layer = pageDiv.createDiv('pf-annotation-overlay'); - window.pdfjsLib.setLayerDimensions(layer, pageView.viewport); - } - - // Place rect with percentage positioning (zoom-independent) - const pageW = viewport.viewBox[2] - viewport.viewBox[0]; - const pageH = viewport.viewBox[3] - viewport.viewBox[1]; - const el = createDiv('pf-annotation-rect'); - el.setCssStyles({ - left: `${100 * (mirrored[0] - viewport.viewBox[0]) / pageW}%`, - top: `${100 * (mirrored[1] - viewport.viewBox[1]) / pageH}%`, - width: `${100 * (mirrored[2] - mirrored[0]) / pageW}%`, - height: `${100 * (mirrored[3] - mirrored[1]) / pageH}%`, - background: `rgba(255, 212, 0, 0.25)`, - }); - layer.appendChild(el); -} -``` - -## Selection → Annotation - -```typescript -function getSelectionRect(pageView: PDFPageView): Rect[] | null { - const selection = window.getSelection(); - if (!selection || selection.isCollapsed) return null; - - const textLayer = pageView.textLayer; - if (!textLayer) return null; - - // Iterate through text layer nodes to find selected range - // Extract PDF coordinates from text content items - // Returns array of [left, top, right, bottom] rects -} -``` - -## Dark Mode Handling - -```css -.theme-light .pf-annotation-highlight { - background: rgba(255, 212, 0, 0.25); -} -.theme-dark .pf-annotation-highlight { - background: rgba(255, 200, 0, 0.2); -} - -.theme-light .pf-annotation-underline { - border-bottom: 2px solid #ff6666; -} -.theme-dark .pf-annotation-underline { - border-bottom: 2px solid #ff8888; -} -``` - -Listen for theme changes: -```typescript -app.workspace.on('css-change', () => { - const isDark = document.body.classList.contains('theme-dark'); - // Re-render with appropriate colors -}); -``` - -## Getting Current PDF Path - -```typescript -const activeLeaf = app.workspace.activeLeaf; -if (activeLeaf?.view?.file?.extension === 'pdf') { - const pdfFile: TFile = activeLeaf.view.file; - const path = pdfFile.path; // e.g. "Resources/Literature/domain/paper.pdf" -} -``` - -## Risk Assessment - -| Risk | Mitigation | -|------|------------| -| Obsidian version breakage | `requireApiVersion()` checks; version-gate patches | -| PDF.js version changes | PDF++ handles v1.7.7 vs v1.8.0 diffs (ObsidianViewer class vs factory) | -| Conflicts with other plugins | Document known incompatibilities; avoid patching same methods | -| Performance with 500+ annotations | Cache rects per annotation ID; debounce rerender; requestAnimationFrame | -| Mobile support | Check `Platform.isDesktopApp`; degrade gracefully | - -## Recommended Libraries - -| Library | Purpose | -|---------|---------| -| `monkey-around` | Prototype patching (PDF++ proven) | -| `obsidian` (built-in) | Plugin API, TFile, Workspace, loadPdfJs() | - -No additional PDF.js bundling needed — Obsidian provides it natively. diff --git a/reports/05-mvp-implementation-plan.md b/reports/05-mvp-implementation-plan.md deleted file mode 100644 index a9880370..00000000 --- a/reports/05-mvp-implementation-plan.md +++ /dev/null @@ -1,249 +0,0 @@ -# MVP Implementation Plan - -> 2-3 weeks total | Phase 1: DB+CLI (1 week) | Phase 2: Plugin Overlay (1-2 weeks) - -## File Structure - -### New Python Modules - -``` -paperforge/ -├── annotation/ ← NEW: annotation package -│ ├── __init__.py -│ ├── probe.py ← Zotero SQLite read-only parser -│ ├── db.py ← annotations.db connection + schema -│ ├── schema.py ← annotations.db DDL + migration -│ ├── import_annotations.py ← import pipeline (Zotero → annotations.db) -│ └── export.py ← JSON / annotated PDF export -├── commands/ -│ ├── annotation.py ← `paperforge annotation` CLI -│ └── ... -├── memory/ -│ └── ... ← unchanged (paperforge.db untouched) -└── services/ - └── sync_service.py ← unchanged -``` - -### New TS Modules (Obsidian Plugin) - -``` -paperforge/plugin/src/ -├── main.ts ← patched: add ribbon/cmd for annotation overlay -├── pdf-overlay/ -│ ├── patch-pdf-viewer.ts ← monkey-around patches -│ ├── overlay-layer.ts ← overlay DOM management per page -│ ├── rect-renderer.ts ← rect placement + color theming -│ ├── selection-handler.ts ← text selection → annotation -│ ├── popover.ts ← click annotation → edit comment/color -│ ├── annotation-fetcher.ts ← execFile → paperforge annotation list -│ └── types.ts ← shared interfaces -└── testable.js ← extend -``` - -## New CLI Commands - -```bash -# Import annotations from Zotero SQLite -paperforge annotation import - [--zotero-db PATH] # override auto-detected Zotero data dir - [--copy-db] # copy zotero.sqlite to temp before reading - [--paper KEY] # specific paper only - [--dry-run] # preview without writing - [--json] - -# List annotations for a paper -paperforge annotation list - <paper_key> # zotero_key of the paper - [--page N] # filter by page - [--type TYPE] # filter by type - [--json] - [--limit N] - -# Create a local annotation -paperforge annotation create - --paper KEY - --type TYPE - --page-index N - [--page-label TEXT] - [--selected-text TEXT] - [--comment TEXT] - [--color HEX] - [--position JSON] - [--sort-index TEXT] - [--json] - -# Update annotation -paperforge annotation patch - <annotation_id> - [--comment TEXT] - [--color HEX] - [--json] - -# Soft delete annotation -paperforge annotation delete - <annotation_id> - [--hard] # permanent delete - [--json] - -# Export annotations -paperforge annotation export - <paper_key> - [--format json|markdown] # default: json - [--output PATH] - [--json] - -# Check annotation DB status -paperforge annotation status - [--json] -``` - -## New DB Table (annotations.db) - -See `reports/03-paperforge-schema-design.md` for full schema. - -Key decisions: -- `sync_state` includes `pending_push` from day 1, even though v1 won't push -- `sync_queue` table is pre-defined for future Web API push -- FTS5 on `selected_text` + `comment` + `tags_json` -- Soft delete via `deleted_at` - -## API Routes (for Plugin execFile interaction) - -``` -→ paperforge annotation list <key> --json -← { - "ok": true, - "data": { - "paper_id": "ABCD1234", - "annotations": [ - { id, type, page_index, selected_text, comment, color, position_json, ... } - ], - "count": 15 - } -} - -→ paperforge annotation create --paper KEY --type highlight --page-index 3 --json -← { - "ok": true, - "data": { "id": "uuid-here", ... } -} -``` - -## Plugin ↔ Python Interaction - -```typescript -// In annotation-fetcher.ts -import { execFile } from 'child_process'; - -async function fetchAnnotations(paperKey: string): Promise<Annotation[]> { - const result = await runSubprocess( - pythonExe, - ['-m', 'paperforge', 'annotation', 'list', paperKey, '--json'], - vaultPath, - 30000 - ); - return JSON.parse(result.stdout).data.annotations; -} - -async function createAnnotation(paperKey: string, data: CreateAnnotationPayload): Promise<Annotation> { - const args = [ - '-m', 'paperforge', 'annotation', 'create', - '--paper', paperKey, - '--type', data.type, - '--page-index', String(data.pageIndex), - '--selected-text', data.selectedText || '', - '--comment', data.comment || '', - '--color', data.color || '#ffd400', - '--position', JSON.stringify(data.position), - '--sort-index', data.sortIndex, - '--json' - ]; - const result = await runSubprocess(pythonExe, args, vaultPath, 10000); - return JSON.parse(result.stdout).data; -} -``` - -## UI Interaction Flow - -``` -1. User opens PDF in Obsidian (native PDF viewer) -2. Plugin patches PDFViewerChild.loadFile() → fetches annotations from annotations.db -3. Plugin renders overlay rects on each page -4. User sees highlights/underlines/notes on PDF - -User creates annotation: - 1. Select text in PDF (native text selection works) - 2. Plugin detects selection, shows "Add highlight" floating button - 3. User clicks → execFile → paperforge annotation create - 4. Plugin receives new annotation → adds to overlay immediately (optimistic) - -User views annotation: - 1. Hover over highlight → tooltip shows comment - 2. Click → popover shows full annotation details - 3. If is_readonly=1 → show lock icon, no edit buttons - 4. If is_readonly=0 → show edit/delete buttons - -User edits annotation: - 1. Click edit in popover → inline textarea - 2. Save → execFile → paperforge annotation patch - 3. Plugin updates overlay - -Zotero annotations: - - Imported with is_readonly=1, sync_state='zotero_synced' - - Displayed with lock icon - - First version: user reads them in PDF, cannot edit -``` - -## Not In MVP - -| Feature | Reason | -|---------|--------| -| Zotero Web API write-back | Requires API key management, rate limiting, conflict UI | -| PDF file export with embedded annotations | Complex PDF manipulation, post-MVP | -| Ink annotation editing | Zotero's ink rendering is complex | -| EPUB annotation | Different position model (CSS selector) | -| Group library support | Zotero SQLite has different path resolution for groups | -| Multi-device sync | Out of scope for first version | -| Template-driven annotation notes | ZotFlow's feature, good but not required | - -## Testing Strategy - -### Python Tests -```bash -# Unit tests for annotation DB -python -m pytest tests/unit/test_annotation_db.py -v - -# Integration test: probe Zotero SQLite -python experiments/zotero_annotation_probe.py --zotero-db fixtures/test_zotero.sqlite --limit 10 - -# CLI tests -python -m pytest tests/cli/test_annotation_commands.py -v -``` - -### JS Tests -```bash -# Unit tests for overlay components (no Obsidian dependency) -cd paperforge/plugin && npx vitest run - -# Test: annotation-fetcher mock responses -# Test: coordinate transformation -# Test: rect placement logic -``` - -### Fixtures -```bash -fixtures/ -├── test_zotero.sqlite ← minimal Zotero SQLite with 1 paper + annotations -├── sample_annotations.json ← known-good annotation JSON -└── sample_annotation_probe.json ← expected probe output -``` - -## Risk & Mitigation - -| Risk | Likelihood | Impact | Mitigation | -|------|-----------|--------|------------| -| Zotero SQLite schema changes | Low | High | Schema version check; fail gracefully with clear error | -| Zotero running locks DB | Medium | Medium | Copy to temp before reading; detect lock errors | -| Obsidian PDF viewer internal API changes | High | Medium | version-gate patches; plugin auto-disables on major version mismatch | -| Monkey-patching conflicts with PDF++/other plugins | Medium | Low | Document conflicts; PaperForge checks for other patchers | -| Large annotations (500+) performance | Low | Medium | Rects merged per page; debounced rerender; cache computed positions | diff --git a/reports/06-risk-and-license-review.md b/reports/06-risk-and-license-review.md deleted file mode 100644 index ddf5a21d..00000000 --- a/reports/06-risk-and-license-review.md +++ /dev/null @@ -1,65 +0,0 @@ -# Risk & License Review - -## License Compatibility - -| Component | License | Usage | Compatible? | -|-----------|---------|-------|-------------| -| Zotero | AGPL-3.0 | SQLite schema reference, annotations.js implementation patterns | ✅ Read-only reference, no code copied | -| ZotFlow | AGPL-3.0 | Architecture inspiration, not code | ✅ Clean-room design | -| PDF++ | MIT | Overlay technique reference | ✅ MIT allows study/implementation | -| Obsidian | Proprietary | Plugin development | ✅ Plugin API is public | -| PaperForge | (Check LICENSE file) | This project | N/A | - -## Technical Risks - -### Risk 1: Zotero SQLite Schema Instability -- **Impact**: Schema changes could break annotation import -- **Mitigation**: Version check on Zotero schema; probe script validates detected schema; clear error message if schema is unknown -- **Detection**: Compare detected table columns against known schema version - -### Risk 2: Obsidian PDF Viewer Internal API Breakage -- **Impact**: Monkey-patching breaks after Obsidian update -- **Mitigation**: - - `requireApiVersion()` checks before applying patches - - Graceful degradation (no crash if patch fails) - - Each patch independently try-caught - - Alert user if annotation overlay cannot initialize -- **Historical context**: PDF++ has survived 254 releases across 2 years, proving the approach is viable - -### Risk 3: Concurrent Zotero SQLite Access -- **Impact**: Reading zotero.sqlite while Zotero is running may get stale data or SQLITE_BUSY -- **Mitigation**: - - Default behavior: copy zotero.sqlite to temp directory before reading - - SQLITE_BUSY detection with retry - - Clear warning in documentation about closing Zotero before import - -### Risk 4: Performance with Large Annotation Sets -- **Impact**: 500+ annotations on a single paper could cause slow overlay rendering -- **Mitigation**: - - Rects merged per page, not per annotation - - Only render annotations for visible pages - - Debounce rerender during scroll/zoom - - Cache computed overlay DOM elements - -### Risk 5: Plugin Conflict with PDF++ -- **Impact**: Both plugins patch the same PDF viewer methods -- **Detection**: Check for `window.PDFPlus` or `document.querySelector('.pdf-plus-*')` at init -- **Mitigation**: - - Document that PaperForge's annotation overlay is incompatible with PDF++ highlights - - Disable overlay if PDF++ detected, fall back to panel-only mode - -## Operational Risks - -| Risk | Impact | Mitigation | -|------|--------|------------| -| User accidentaly deletes annotations | Data loss | Soft delete by default; export/backup command | -| Corrupted annotations.db | Data loss | WAL mode; integrity_check on startup; backup before migration | -| Zotero upgrade changes SQLite path | Import fails | ZOTERO_DATA_DIR env var; auto-detect from registry/macOS paths | -| Very large position JSON (>65KB) | Truncation risk | Zotero splits annotations >65KB; handle both split and unsplit cases | - -## Security Considerations - -- **No credential exposure**: Local SQLite reading requires no API keys -- **No network dependency**: Phase 1 is fully offline -- **Future Web API keys**: Will use Obsidian SecretStorage (like ZotFlow), never data.json -- **SQL injection**: All SQL queries use parameterized statements diff --git a/reports/07-final-recommendation.md b/reports/07-final-recommendation.md deleted file mode 100644 index 7f458b16..00000000 --- a/reports/07-final-recommendation.md +++ /dev/null @@ -1,60 +0,0 @@ -# Final Recommendation - -> PaperForge PDF Annotation Layer — Architecture & MVP - -## Summary - -After comprehensive research across ZotFlow, Zotero source code, and Obsidian PDF plugin ecosystem, the recommendation is clear: - -**Build a lightweight annotation layer using local Zotero SQLite read-only parsing, cached in a dedicated annotations.db, displayed via monkey-patched PDF viewer overlays in Obsidian, with write-back architecture designed from day one but implemented in Phase 3.** - -## Why This Approach - -### 1. ZotFlow is too heavy; PaperForge should not replicate it -ZotFlow's bidirectional sync engine (42,918 lines), embedded reader iframe (entire `zotero/reader` submodule), CodeMirror 6 extensions, React UI, Comlink/Penpal communication — 80% of ZotFlow's code is specific to being a full Zotero-in-Obsidian replacement. PaperForge needs only annotation display and lightweight sync. - -### 2. Local SQLite reading is sufficient for v1, ideal for v1 -All annotation data is available in Zotero's `zotero.sqlite`, fully documented. Read access is explicitly permitted by Zotero's documentation. The read path is: -- Copy DB → temp → parse → annotations.db - -### 3. Hybrid read/write architecture is the right long-term design -- **Read**: Local SQLite (fast, offline, zero config) -- **Write**: Zotero Web API (safe, versioned, official) -- **schema includes `sync_state = pending_push` from day one** - -### 4. Custom overlay via monkey-patching is the right UX choice -Users chose "direct monkey-patch" over alternatives. PDF++ has proven this is sustainable despite private API dependency. - -## Recommended Development Order - -``` -Phase 1 (1 week): annotations.db + CLI import/list/patch/delete -Phase 2 (1-2 weeks): Obsidian PDF overlay (show + create + edit) -Phase 3 (post-MVP): Zotero Web API write-back -``` - -## Key Numbers - -| Metric | Value | -|--------|-------| -| New Python files | ~6 (annotation/ package) | -| New TS files | ~7 (pdf-overlay/ directory) | -| New CLI commands | 6 (import, list, create, patch, delete, export, status) | -| New DB | 1 (annotations.db, independent schema) | -| DB migrations planned | schema v1 (MVP), v2 (Web API write-back) | -| Estimated development time | 2-3 weeks for MVP | -| Risk level | Medium (Obsidian API stability) | - -## What Was NOT Recommended - -| Approach | Why Rejected | -|----------|-------------| -| Full ZotFlow bidirectional sync | Over-engineered for PaperForge's needs | -| Custom PDF.js reader iframe | Heavy, UX fragmentation, Obsidian already has PDF.js | -| Annotator-style markdown storage | Fragile, not AI-queryable, hard to maintain | -| Dedicated PDF annotation service | Overkill; local SQLite + CLI is sufficient for v1 | -| Read-only only (no write-back design) | User explicitly requested write-back be designed from start | - -## Final Assessment - -PaperForge's Annotation Layer is **well-scoped for MVP delivery in 2-3 weeks**. The architecture is clean, the risks are manageable, and the design accounts for future write-back without over-engineering the first version. The key dependency — Obsidian's private PDF viewer API — is a managed risk that PDF++ has successfully navigated for 2+ years. diff --git a/tests/test_dashboard_command.py b/tests/test_dashboard_command.py deleted file mode 100644 index 98e665a0..00000000 --- a/tests/test_dashboard_command.py +++ /dev/null @@ -1,30 +0,0 @@ -from pathlib import Path - -from paperforge.commands.dashboard import _dashboard_from_db - - -def test_dashboard_from_db_uses_configured_system_dir(tmp_path: Path) -> None: - (tmp_path / "paperforge.json").write_text( - '{"vault_config": {"system_dir": "02_文献管理/System"}}', - encoding="utf-8", - ) - db_path = tmp_path / "02_文献管理" / "System" / "PaperForge" / "indexes" / "paperforge.db" - db_path.parent.mkdir(parents=True) - - import sqlite3 - - conn = sqlite3.connect(str(db_path)) - conn.execute( - "CREATE TABLE papers (domain TEXT, has_pdf INTEGER, ocr_status TEXT)" - ) - conn.execute( - "INSERT INTO papers(domain, has_pdf, ocr_status) VALUES (?, ?, ?)", - ("Sports", 1, "done"), - ) - conn.commit() - conn.close() - - data = _dashboard_from_db(tmp_path) - - assert data is not None - assert data["stats"]["papers"] == 1 diff --git a/tests/test_ld_deep_config.py b/tests/test_ld_deep_config.py new file mode 100644 index 00000000..68d9e53d --- /dev/null +++ b/tests/test_ld_deep_config.py @@ -0,0 +1,133 @@ +"""Test compatibility: ld_deep uses shared resolver for path construction.""" + +from __future__ import annotations + +import json +import sys +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path + +import pytest + +# Pre-load ld_deep so its functions are available +_REPO_ROOT = Path(__file__).parent.parent +_ld_spec = spec_from_file_location( + "ld_deep", + _REPO_ROOT / "paperforge" / "skills" / "literature-qa" / "scripts" / "ld_deep.py", +) +_ld_mod = module_from_spec(_ld_spec) +sys.modules["ld_deep"] = _ld_mod +_ld_spec.loader.exec_module(_ld_mod) + + +@pytest.fixture +def tmp_vault(tmp_path: Path) -> Path: + """Create a minimal PaperForge vault for testing.""" + system = tmp_path / "99_System" + paperforge = system / "PaperForge" + (paperforge / "exports").mkdir(parents=True) + (paperforge / "ocr").mkdir(parents=True) + + resources = tmp_path / "03_Resources" + resources / "Literature" + control = resources / "LiteratureControl" + (control / "library-records").mkdir(parents=True) + + pf_json = tmp_path / "paperforge.json" + pf_json.write_text( + json.dumps( + { + "version": "1.2.0", + "vault_config": { + "system_dir": "99_System", + "resources_dir": "03_Resources", + "literature_dir": "Literature", + "control_dir": "LiteratureControl", + "base_dir": "05_Bases", + "skill_dir": ".opencode/skills", + }, + }, + ensure_ascii=False, + ), + encoding="utf-8", + ) + return tmp_path + + +class TestDeepLoadVaultConfig: + """Test ld_deep._load_vault_config matches shared resolver.""" + + def test_load_vault_config_keys(self, tmp_vault: Path) -> None: + """_load_vault_config returns same keys as shared resolver.""" + import ld_deep + + from paperforge.config import load_vault_config as shared_load + + shared_cfg = shared_load(tmp_vault) + ld_cfg = ld_deep._load_vault_config(tmp_vault) + + assert set(ld_cfg.keys()) == set( + shared_cfg.keys() + ), f"Key mismatch: ld_deep={set(ld_cfg.keys())} vs shared={set(shared_cfg.keys())}" + for key in shared_cfg: + assert ld_cfg.get(key) == shared_cfg.get( + key + ), f"Key '{key}' differs: ld_deep={ld_cfg.get(key)!r} vs shared={shared_cfg.get(key)!r}" + + def test_env_override_respected(self, tmp_vault: Path, monkeypatch) -> None: + """PAPERFORGE_SYSTEM_DIR env var is respected.""" + import ld_deep + + monkeypatch.setenv("PAPERFORGE_SYSTEM_DIR", "EnvOverride") + cfg = ld_deep._load_vault_config(tmp_vault) + assert cfg["system_dir"] == "EnvOverride" + + +class TestDeepPaperforgePaths: + """Test ld_deep._paperforge_paths returns expected keys and values.""" + + def test_paperforge_paths_returns_expected_keys(self, tmp_vault: Path) -> None: + """_paperforge_paths returns ocr, literature keys.""" + import ld_deep + + paths = ld_deep._paperforge_paths(tmp_vault) + + for key in ["ocr", "literature"]: + assert key in paths, f"Missing expected key: {key}" + + def test_paperforge_paths_values_match_shared_resolver(self, tmp_vault: Path) -> None: + """Values for ocr, literature match paperforge_paths().""" + import ld_deep + + from paperforge.config import paperforge_paths as shared_paths + + shared = shared_paths(tmp_vault) + ld_paths = ld_deep._paperforge_paths(tmp_vault) + + # ld_deep only exposes ocr, literature (records key was removed in v1.9) + assert ld_paths["ocr"] == shared["ocr"] + assert ld_paths["literature"] == shared["literature"] + + def test_paperforge_paths_custom_paperforge_json(self, tmp_vault: Path) -> None: + """_paperforge_paths works with custom paperforge.json.""" + import ld_deep + + paths = ld_deep._paperforge_paths(tmp_vault) + + assert paths["ocr"].exists() or True # Just check key is populated + assert str(paths["ocr"]).endswith("99_System/PaperForge/ocr") or True + + +class TestDeepPrepareDeepReading: + """Test prepare_deep_reading uses shared paths (integration check).""" + + def test_prepare_deep_reading_accepts_vault(self, tmp_vault: Path) -> None: + """prepare_deep_reading can be called with a vault Path.""" + import ld_deep + + # Should not raise - we're just checking it accepts the vault + result = ld_deep.prepare_deep_reading(tmp_vault, "NONEXISTENT_KEY_12345", force=True) + assert isinstance(result, dict) + assert "status" in result + # Status will be error since key doesn't exist, but function is callable + assert result["status"] in ("ok", "error") diff --git a/tests/test_ld_deep_postprocess.py b/tests/test_ld_deep_postprocess.py new file mode 100644 index 00000000..50ba0982 --- /dev/null +++ b/tests/test_ld_deep_postprocess.py @@ -0,0 +1,103 @@ +"""Tests for postprocess_pass2 validation.""" + +import sys +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path + +_REPO_ROOT = Path(__file__).parent.parent +_ld_spec = spec_from_file_location( + "ld_deep", + _REPO_ROOT / "paperforge" / "skills" / "literature-qa" / "scripts" / "ld_deep.py", +) +_ld_mod = module_from_spec(_ld_spec) +sys.modules["ld_deep"] = _ld_mod +_ld_spec.loader.exec_module(_ld_mod) + +from ld_deep import postprocess_pass2, FIGURE_SUBHEADINGS + + +def _make_note_with_figures(count_or_order): + """Build a note with figure blocks in given order.""" + if isinstance(count_or_order, int): + order = list(range(1, count_or_order + 1)) + else: + order = count_or_order + parts = [] + for n in order: + sub_parts = [f"> **{h}**\n> Content for {n}" for h in FIGURE_SUBHEADINGS] + block = ( + f"\n> [!note]- Figure {n}: Test caption\n" + f"> ![[fig_{n}.png]]\n" + f">\n" + + "\n>\n".join(sub_parts) + + "\n" + ) + parts.append(block) + return "\n".join(parts) + + +class TestPostprocessPass2: + def test_clean_note(self): + note = _make_note_with_figures(3) + errors = postprocess_pass2(note, figure_count=3) + assert errors == [] + + def test_out_of_order(self): + note = _make_note_with_figures([1, 3, 2]) + errors = postprocess_pass2(note, figure_count=3) + assert any(e["type"] == "order" for e in errors) + + def test_stray_image(self): + note = _make_note_with_figures(2) + "\n![[stray_image.png]]\n" + errors = postprocess_pass2(note, figure_count=2) + assert any(e["type"] == "image_bounds" for e in errors) + + def test_empty_figure_block(self): + """Figure with only sub-headings and no content between them.""" + parts = [] + for n in [1, 2]: + block = ( + f"\n> [!note]- Figure {n}: Test caption\n" + f"> ![[fig_{n}.png]]\n" + f">\n" + + "\n>\n".join([f"> **{h}**\n>" for h in FIGURE_SUBHEADINGS]) + + "\n" + ) + parts.append(block) + note = "\n".join(parts) + errors = postprocess_pass2(note, figure_count=2) + assert any(e["type"] == "empty_block" for e in errors) + + def test_missing_subheading(self): + """Figure block missing '我的理解' sub-heading.""" + sub = [h for h in FIGURE_SUBHEADINGS if h != "我的理解"] + sub_parts = [f"> **{h}**\n> Content" for h in sub] + note = ( + "\n> [!note]- Figure 1: Test caption\n" + "> ![[fig_1.png]]\n" + ">\n" + + "\n>\n".join(sub_parts) + + "\n" + ) + errors = postprocess_pass2(note, figure_count=1) + assert any(e["type"] == "missing_subheading" for e in errors) + + def test_duplicate_figure(self): + note = _make_note_with_figures([1, 2, 2]) + errors = postprocess_pass2(note, figure_count=2) + assert any(e["type"] == "duplicate" for e in errors) + + def test_missing_figure(self): + note = _make_note_with_figures([1, 2]) + errors = postprocess_pass2(note, figure_count=3) + assert any(e["type"] == "missing" for e in errors) + + def test_extra_figure(self): + note = _make_note_with_figures(3) + errors = postprocess_pass2(note, figure_count=2) + assert any(e["type"] == "extra" for e in errors) + + def test_zero_figures(self): + note = "# Test\nNo figures here.\n" + errors = postprocess_pass2(note, figure_count=0) + assert errors == [] diff --git a/tests/test_ld_deep_skel.py b/tests/test_ld_deep_skel.py new file mode 100644 index 00000000..d70e003a --- /dev/null +++ b/tests/test_ld_deep_skel.py @@ -0,0 +1,73 @@ +"""Tests for skeleton rendering in ld_deep.py.""" + +import sys +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path + +_REPO_ROOT = Path(__file__).parent.parent +_ld_spec = spec_from_file_location( + "ld_deep", + _REPO_ROOT / "paperforge" / "skills" / "literature-qa" / "scripts" / "ld_deep.py", +) +_ld_mod = module_from_spec(_ld_spec) +sys.modules["ld_deep"] = _ld_mod +_ld_spec.loader.exec_module(_ld_mod) + +from ld_deep import ( + render_figure_block, + render_table_block, + FigureEntry, + TableEntry, + FIGURE_SUBHEADINGS, + TABLE_SUBHEADINGS, +) + + +class TestRenderFigureBlock: + def test_all_subheadings_present(self): + fig = FigureEntry( + number=1, title="Test", image_id="fig1", + image_link="path/img.png", page=3, caption="Test", + is_supplementary=False, + ) + result = render_figure_block(fig) + for h in FIGURE_SUBHEADINGS: + assert f"> **{h}**" in result, f"Missing: {h}" + assert "> ![[path/img.png]]" in result + assert "> [!note]- Figure 1:" in result + + def test_image_inside_callout_block(self): + """The image embed must be between the heading and the first sub-heading.""" + fig = FigureEntry( + number=2, title="Test2", image_id="fig2", + image_link="img/test.png", page=5, caption="Test2", + is_supplementary=False, + ) + result = render_figure_block(fig) + heading_end = result.index("> ![[img/test.png]]") + first_sub = result.index(f"> **{FIGURE_SUBHEADINGS[0]}**") + assert heading_end < first_sub, "Image must appear before first sub-heading" + + def test_no_placeholder_text(self): + """No '待补充' or '[?]' text in the rendered block.""" + fig = FigureEntry( + number=3, title="Test3", image_id="fig3", + image_link="img/fig3.png", page=0, caption="Test3", + is_supplementary=False, + ) + result = render_figure_block(fig) + assert "[?]" not in result + assert "待补充" not in result + + +class TestRenderTableBlock: + def test_all_subheadings_present(self): + table = TableEntry( + number=1, image_id="tab1", + image_link="path/table.png", page=4, + ) + result = render_table_block(table) + for h in TABLE_SUBHEADINGS: + assert f"> **{h}**" in result, f"Missing: {h}" + assert "> ![[path/table.png]]" in result + assert "> [!note]- Table 1" in result diff --git a/tests/test_path_normalization.py b/tests/test_path_normalization.py new file mode 100644 index 00000000..cabf8934 --- /dev/null +++ b/tests/test_path_normalization.py @@ -0,0 +1,385 @@ +"""Tests for Zotero path normalization, main PDF identification, and wikilink generation. + +Phase 11 Wave 4 — Tests for: +- _normalize_attachment_path() (BBT path format normalization) +- _identify_main_pdf() (Main vs supplementary attachment selection) +- obsidian_wikilink_for_pdf() (Obsidian wikilink generation) + +All tests mock zotero_dir and vault_dir — no real Zotero installation required. +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from paperforge.worker.sync import ( + _identify_main_pdf, + _normalize_attachment_path, + load_export_rows, + obsidian_wikilink_for_pdf, +) + +# --------------------------------------------------------------------------- +# Test class: TestBBTPathNormalization +# --------------------------------------------------------------------------- + + +class TestBBTPathNormalization: + """Tests for _normalize_attachment_path() with various BBT export formats.""" + + def test_absolute_windows_path(self) -> None: + """Absolute Windows path pointing to Zotero storage is normalized to storage:KEY/filename.""" + raw = r"D:\L\Med\Research\99_System\Zotero\storage\ABC12345\paper.pdf" + normalized, bbt_raw, key = _normalize_attachment_path(raw) + + assert normalized == "storage:ABC12345/paper.pdf" + assert bbt_raw == raw + assert key == "ABC12345" + + def test_storage_prefix_path(self) -> None: + """Already-prefixed storage: path passes through with slash normalization.""" + raw = "storage:ABC12345/paper.pdf" + normalized, bbt_raw, key = _normalize_attachment_path(raw) + + assert normalized == "storage:ABC12345/paper.pdf" + assert bbt_raw == raw + assert key == "ABC12345" + + def test_bare_relative_path(self) -> None: + """Bare relative path KEY/filename gets storage: prefix prepended.""" + raw = "ABC12345/paper.pdf" + normalized, bbt_raw, key = _normalize_attachment_path(raw) + + assert normalized == "storage:ABC12345/paper.pdf" + assert bbt_raw == raw + assert key == "ABC12345" + + def test_path_with_chinese_characters(self) -> None: + """Chinese filenames are preserved without escaping or corruption.""" + raw = r"D:\L\Med\Research\99_System\Zotero\storage\ABC12345\中文论文.pdf" + normalized, bbt_raw, key = _normalize_attachment_path(raw) + + assert normalized == "storage:ABC12345/中文论文.pdf" + assert "中文论文" in normalized + assert key == "ABC12345" + + def test_path_with_spaces(self) -> None: + """Filenames containing spaces are handled correctly.""" + raw = r"D:\L\Med\Research\99_System\Zotero\storage\ABC12345\paper with spaces.pdf" + normalized, bbt_raw, key = _normalize_attachment_path(raw) + + assert normalized == "storage:ABC12345/paper with spaces.pdf" + assert key == "ABC12345" + + def test_storage_prefix_with_backslashes(self) -> None: + """storage: prefix with backslashes is normalized to forward slashes.""" + raw = r"storage:ABC12345\subdir\paper.pdf" + normalized, _, key = _normalize_attachment_path(raw) + + assert normalized == "storage:ABC12345/subdir/paper.pdf" + assert key == "ABC12345" + + def test_empty_path(self) -> None: + """Empty string returns empty tuple components.""" + normalized, bbt_raw, key = _normalize_attachment_path("") + + assert normalized == "" + assert bbt_raw == "" + assert key == "" + + def test_absolute_non_storage_path(self) -> None: + """Absolute path outside Zotero storage gets absolute: prefix.""" + raw = r"D:\Downloads\random.pdf" + normalized, bbt_raw, key = _normalize_attachment_path(raw) + + assert normalized.startswith("absolute:") + assert bbt_raw == raw + assert key == "" + + +# --------------------------------------------------------------------------- +# Test class: TestMainPdfIdentification +# --------------------------------------------------------------------------- + + +class TestMainPdfIdentification: + """Tests for _identify_main_pdf() hybrid priority strategy.""" + + def test_title_pdf_primary(self) -> None: + """Attachment with title exactly 'PDF' is selected as main.""" + attachments = [ + {"path": "storage:KEY/paper.pdf", "contentType": "application/pdf", "title": "PDF", "size": 1000}, + {"path": "storage:KEY/supp.pdf", "contentType": "application/pdf", "title": "Supplementary", "size": 2000}, + ] + main, supplementary = _identify_main_pdf(attachments) + + assert main is not None + assert main["title"] == "PDF" + assert len(supplementary) == 1 + assert supplementary[0]["title"] == "Supplementary" + + def test_fallback_largest_file(self) -> None: + """When no title=='PDF', largest file by size is selected as main.""" + attachments = [ + {"path": "storage:KEY/small.pdf", "contentType": "application/pdf", "title": "Small", "size": 100}, + {"path": "storage:KEY/large.pdf", "contentType": "application/pdf", "title": "Large", "size": 9999}, + ] + main, supplementary = _identify_main_pdf(attachments) + + assert main is not None + assert main["title"] == "Large" + assert len(supplementary) == 1 + + def test_fallback_first_pdf(self) -> None: + """When sizes are equal, first PDF in list is selected as main.""" + attachments = [ + {"path": "storage:KEY/first.pdf", "contentType": "application/pdf", "title": "First", "size": 100}, + {"path": "storage:KEY/second.pdf", "contentType": "application/pdf", "title": "Second", "size": 100}, + ] + main, supplementary = _identify_main_pdf(attachments) + + assert main is not None + assert main["title"] == "First" + assert len(supplementary) == 1 + + def test_no_pdf_attachments(self) -> None: + """No PDF attachments returns (None, []).""" + attachments = [ + { + "path": "storage:KEY/data.xlsx", + "contentType": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "title": "Data", + "size": 100, + }, + ] + main, supplementary = _identify_main_pdf(attachments) + + assert main is None + assert supplementary == [] + + def test_single_pdf_no_supplementary(self) -> None: + """Single PDF attachment returns empty supplementary list.""" + attachments = [ + {"path": "storage:KEY/only.pdf", "contentType": "application/pdf", "title": "Only", "size": 1000}, + ] + main, supplementary = _identify_main_pdf(attachments) + + assert main is not None + assert supplementary == [] + + def test_mixed_pdf_and_non_pdf(self) -> None: + """Non-PDF attachments are ignored in main/supplementary selection.""" + attachments = [ + {"path": "storage:KEY/main.pdf", "contentType": "application/pdf", "title": "PDF", "size": 1000}, + {"path": "storage:KEY/supp.pdf", "contentType": "application/pdf", "title": "Supp", "size": 500}, + {"path": "storage:KEY/data.zip", "contentType": "application/zip", "title": "Data", "size": 200}, + ] + main, supplementary = _identify_main_pdf(attachments) + + assert main is not None + assert main["title"] == "PDF" + assert len(supplementary) == 1 + assert supplementary[0]["title"] == "Supp" + + +# --------------------------------------------------------------------------- +# Test class: TestWikilinkGeneration +# --------------------------------------------------------------------------- + + +class TestWikilinkGeneration: + """Tests for obsidian_wikilink_for_pdf() wikilink generation.""" + + def test_basic_wikilink(self, tmp_path: Path) -> None: + """storage:KEY/file.pdf resolves to [[system/Zotero/storage/KEY/file.pdf]].""" + vault_dir = tmp_path / "vault" + zotero_dir = vault_dir / "system" / "Zotero" + storage_dir = zotero_dir / "storage" / "KEY" + storage_dir.mkdir(parents=True) + pdf = storage_dir / "file.pdf" + pdf.write_text("PDF content") + + result = obsidian_wikilink_for_pdf("storage:KEY/file.pdf", vault_dir, zotero_dir) + + assert result == "[[system/Zotero/storage/KEY/file.pdf]]" + + def test_junction_resolution(self, tmp_path: Path, monkeypatch) -> None: + """Junction resolved before relative path computed.""" + vault_dir = tmp_path / "vault" + zotero_dir = vault_dir / "system" / "Zotero" + storage_dir = zotero_dir / "storage" / "KEY" + storage_dir.mkdir(parents=True) + pdf = storage_dir / "file.pdf" + pdf.write_text("PDF content") + + # Mock resolve_junction to simulate junction resolution + real_target = storage_dir / "file.pdf" + + def mock_resolve_junction(path: Path) -> Path: + if "junction" in str(path).lower(): + return real_target + return path + + monkeypatch.setattr("paperforge.pdf_resolver.resolve_junction", mock_resolve_junction) + + # Test with a path that would trigger junction resolution + result = obsidian_wikilink_for_pdf("storage:KEY/file.pdf", vault_dir, zotero_dir) + assert result.startswith("[[") + assert result.endswith("]]") + assert "/" in result + + def test_forward_slashes(self, tmp_path: Path) -> None: + """Output wikilink uses forward slashes, never backslashes.""" + vault_dir = tmp_path / "vault" + zotero_dir = vault_dir / "system" / "Zotero" + storage_dir = zotero_dir / "storage" / "KEY" + storage_dir.mkdir(parents=True) + pdf = storage_dir / "file.pdf" + pdf.write_text("PDF content") + + result = obsidian_wikilink_for_pdf("storage:KEY/file.pdf", vault_dir, zotero_dir) + + assert "\\" not in result + assert "/" in result + + def test_chinese_filename_wikilink(self, tmp_path: Path) -> None: + """Chinese characters preserved in wikilink without escaping.""" + vault_dir = tmp_path / "vault" + zotero_dir = vault_dir / "system" / "Zotero" + storage_dir = zotero_dir / "storage" / "KEY" + storage_dir.mkdir(parents=True) + pdf = storage_dir / "中文论文.pdf" + pdf.write_text("PDF content") + + result = obsidian_wikilink_for_pdf("storage:KEY/中文论文.pdf", vault_dir, zotero_dir) + + assert "中文论文" in result + assert result == "[[system/Zotero/storage/KEY/中文论文.pdf]]" + + def test_empty_pdf_path(self, tmp_path: Path) -> None: + """Empty pdf_path returns empty string.""" + vault_dir = tmp_path / "vault" + zotero_dir = vault_dir / "system" / "Zotero" + zotero_dir.mkdir(parents=True) + + result = obsidian_wikilink_for_pdf("", vault_dir, zotero_dir) + + assert result == "" + + def test_nonexistent_file(self, tmp_path: Path) -> None: + """Non-existent file still returns wikilink with relative path.""" + vault_dir = tmp_path / "vault" + zotero_dir = vault_dir / "system" / "Zotero" + zotero_dir.mkdir(parents=True) + + result = obsidian_wikilink_for_pdf("storage:KEY/missing.pdf", vault_dir, zotero_dir) + + # The file doesn't exist, but the path should still be converted to a wikilink + # Because the function resolves the path relative to vault + assert "[[" in result + assert "]]" in result + + +# --------------------------------------------------------------------------- +# Test class: TestLoadExportRowsIntegration +# --------------------------------------------------------------------------- + + +class TestLoadExportRowsIntegration: + """Integration tests using fixture JSON files.""" + + def test_load_absolute_fixture(self, tmp_path: Path) -> None: + """Load BBT export with absolute Windows paths — attachments normalized.""" + fixture_path = REPO_ROOT / "tests" / "fixtures" / "bbt_export_absolute.json" + export_file = tmp_path / "library.json" + export_file.write_text(fixture_path.read_text(encoding="utf-8"), encoding="utf-8") + + rows = load_export_rows(export_file) + + assert len(rows) == 1 + assert rows[0]["key"] == "ABC12345" + attachments = rows[0]["attachments"] + assert len(attachments) == 1 + assert attachments[0]["path"] == "storage:ABC12345/Absolute Path Test Paper.pdf" + assert attachments[0]["bbt_path_raw"].startswith("D:") + assert rows[0]["zotero_storage_key"] == "ABC12345" + + def test_load_storage_fixture(self, tmp_path: Path) -> None: + """Load BBT export with storage: prefix — paths pass through.""" + fixture_path = REPO_ROOT / "tests" / "fixtures" / "bbt_export_storage.json" + export_file = tmp_path / "library.json" + export_file.write_text(fixture_path.read_text(encoding="utf-8"), encoding="utf-8") + + rows = load_export_rows(export_file) + + assert len(rows) == 1 + assert rows[0]["key"] == "STORAGE1" + attachments = rows[0]["attachments"] + assert attachments[0]["path"] == "storage:STORAGE1/Storage Prefix Test Paper.pdf" + + def test_load_mixed_fixture(self, tmp_path: Path) -> None: + """Load BBT export with mixed formats — all normalized correctly.""" + fixture_path = REPO_ROOT / "tests" / "fixtures" / "bbt_export_mixed.json" + export_file = tmp_path / "library.json" + export_file.write_text(fixture_path.read_text(encoding="utf-8"), encoding="utf-8") + + rows = load_export_rows(export_file) + + assert len(rows) == 2 + + # First item: absolute Windows path + row0 = rows[0] + assert row0["key"] == "MIXED001" + assert row0["attachment_count"] == 3 + # Main PDF should be the one with title="PDF" + assert row0["pdf_path"] == "storage:MIXED001/Mixed Formats Paper.pdf" + # supplementary should contain the other PDF + assert len(row0["supplementary"]) == 1 + assert row0["supplementary"][0] == "storage:MIXED001/supplementary data.pdf" + + # Second item: bare relative path + row1 = rows[1] + assert row1["key"] == "BARE002" + assert row1["pdf_path"] == "storage:BARE002/BARE002.pdf" + assert row1["supplementary"] == [] + + def test_mixed_fixture_path_error_none(self, tmp_path: Path) -> None: + """Mixed fixture items with PDFs have no path_error.""" + fixture_path = REPO_ROOT / "tests" / "fixtures" / "bbt_export_mixed.json" + export_file = tmp_path / "library.json" + export_file.write_text(fixture_path.read_text(encoding="utf-8"), encoding="utf-8") + + rows = load_export_rows(export_file) + + for row in rows: + assert row["path_error"] == "" + + def test_no_attachments_path_error(self, tmp_path: Path) -> None: + """Item with no attachments gets path_error='not_found'.""" + export_data = { + "items": [ + { + "key": "NOATTACH", + "itemKey": "NOATTACH", + "itemType": "journalArticle", + "title": "No Attachments", + "attachments": [], + } + ], + "collections": {}, + } + export_file = tmp_path / "library.json" + export_file.write_text(json.dumps(export_data), encoding="utf-8") + + rows = load_export_rows(export_file) + + assert len(rows) == 1 + assert rows[0]["path_error"] == "not_found" + assert rows[0]["pdf_path"] == "" + assert rows[0]["attachment_count"] == 0 diff --git a/tests/test_runtime_path_resolution.py b/tests/test_runtime_path_resolution.py deleted file mode 100644 index 64f7cac5..00000000 --- a/tests/test_runtime_path_resolution.py +++ /dev/null @@ -1,69 +0,0 @@ -import sqlite3 -from pathlib import Path - -from paperforge.commands.annotation import _resolve_paper_key -from paperforge.embedding._chroma import get_vector_db_path -from paperforge.embedding.build_state import get_vector_build_state_path -from paperforge.setup.checker import SetupChecker - - -def test_resolve_paper_key_uses_configured_memory_db_path(tmp_path: Path) -> None: - (tmp_path / "paperforge.json").write_text( - '{"vault_config": {"system_dir": "02_文献管理/System"}}', - encoding="utf-8", - ) - db_path = tmp_path / "02_文献管理" / "System" / "PaperForge" / "indexes" / "paperforge.db" - db_path.parent.mkdir(parents=True) - - conn = sqlite3.connect(str(db_path)) - conn.execute("CREATE TABLE papers (zotero_key TEXT, pdf_path TEXT)") - conn.execute( - "INSERT INTO papers(zotero_key, pdf_path) VALUES (?, ?)", - ("K1", "[[02_文献管理/System/Zotero/storage/ABCD1234/paper.pdf]]"), - ) - conn.commit() - conn.close() - - key = _resolve_paper_key(tmp_path, "02_文献管理/System/Zotero/storage/ABCD1234/paper.pdf") - - assert key == "K1" - - -def test_setup_checker_uses_configured_system_dir_for_exports(tmp_path: Path) -> None: - (tmp_path / "paperforge.json").write_text( - '{"vault_config": {"system_dir": "02_文献管理/System"}}', - encoding="utf-8", - ) - exports_dir = tmp_path / "02_文献管理" / "System" / "PaperForge" / "exports" - exports_dir.mkdir(parents=True) - (exports_dir / "demo.json").write_text("[]", encoding="utf-8") - - result = SetupChecker(tmp_path).run() - - assert result.details["bbt_exports_found"] is True - - -def test_vector_runtime_paths_use_configured_system_dir(tmp_path: Path) -> None: - (tmp_path / "paperforge.json").write_text( - '{"vault_config": {"system_dir": "02_文献管理/System"}}', - encoding="utf-8", - ) - - vector_db_path = get_vector_db_path(tmp_path) - build_state_path = get_vector_build_state_path(tmp_path) - - assert "02_文献管理" in str(vector_db_path) - assert "02_文献管理" in str(build_state_path) - assert vector_db_path.name == "vectors" - assert build_state_path.name == "vector-build-state.json" - - -def test_preflight_index_path_resolves_correctly(tmp_path: Path) -> None: - (tmp_path / "paperforge.json").write_text( - '{"vault_config": {"system_dir": "02_文献管理/System"}}', - encoding="utf-8", - ) - from paperforge.config import paperforge_paths - paths = paperforge_paths(tmp_path) - expected = tmp_path / "02_文献管理" / "System" / "PaperForge" / "indexes" / "formal-library.json" - assert paths["index"] == expected.resolve() diff --git a/tests/test_sync_command.py b/tests/test_sync_command.py deleted file mode 100644 index 75b85d08..00000000 --- a/tests/test_sync_command.py +++ /dev/null @@ -1,24 +0,0 @@ -from pathlib import Path - -from paperforge.commands.sync import _write_orphan_state -from paperforge.core.result import PFResult - - -def test_write_orphan_state_uses_configured_system_dir(tmp_path: Path) -> None: - (tmp_path / "paperforge.json").write_text( - '{"vault_config": {"system_dir": "02_文献管理/System"}}', - encoding="utf-8", - ) - result = PFResult( - ok=True, - command="sync", - version="1.0.0", - data={"prune": {"preview": [{"key": "K1"}]}} - ) - - _write_orphan_state(tmp_path, result) - - expected = tmp_path / "02_文献管理" / "System" / "PaperForge" / "indexes" / "sync-orphan-state.json" - wrong = tmp_path / "System" / "PaperForge" / "indexes" / "sync-orphan-state.json" - assert expected.exists() - assert not wrong.exists() diff --git a/tests/unit/worker/test_vector_db.py b/tests/unit/worker/test_vector_db.py index 51c197da..e65039c5 100644 --- a/tests/unit/worker/test_vector_db.py +++ b/tests/unit/worker/test_vector_db.py @@ -1,5 +1,6 @@ from __future__ import annotations +from pathlib import Path from unittest.mock import Mock, patch from paperforge.embedding.status import get_embed_status @@ -7,35 +8,41 @@ from paperforge.embedding.status import get_embed_status def test_get_embed_status_reports_corruption_when_count_fails(tmp_path): vault = tmp_path / "vault" - vectors_dir = vault / "System" / "PaperForge" / "indexes" / "vectors" + vectors_dir = vault / "System" / "PaperForge" / "vectors" vectors_dir.mkdir(parents=True) mock_collection = Mock() + mock_collection.name = "paperforge_fulltext" mock_collection.count.side_effect = RuntimeError("Error loading hnsw index") + mock_client = Mock() + mock_client.list_collections.return_value = [mock_collection] - with patch("paperforge.embedding.status.get_collection", return_value=mock_collection): + with patch("chromadb.PersistentClient", return_value=mock_client): status = get_embed_status(vault) assert status["db_exists"] is True assert status["chunk_count"] == 0 assert status["healthy"] is False - assert "hnsw" in status["error"].lower() + assert "hnsw index" in status["error"] def test_get_embed_status_uses_indexes_vectors_path_from_config(tmp_path): vault = tmp_path / "vault" vault.mkdir() (vault / "paperforge.json").write_text( - '{"vault_config":{"system_dir":"System"}}', + '{"vault_config":{"system_dir":"System","resources_dir":"Resources","literature_dir":"Literature","control_dir":"LiteratureControl","base_dir":"Bases","skill_dir":".opencode/skills"}}', encoding="utf-8", ) vectors_dir = vault / "System" / "PaperForge" / "indexes" / "vectors" vectors_dir.mkdir(parents=True) mock_collection = Mock() + mock_collection.name = "paperforge_fulltext" mock_collection.count.return_value = 12 + mock_client = Mock() + mock_client.list_collections.return_value = [mock_collection] - with patch("paperforge.embedding.status.get_collection", return_value=mock_collection): + with patch("chromadb.PersistentClient", return_value=mock_client): status = get_embed_status(vault) assert status["db_exists"] is True