diff --git a/.agents/skills/patent-disclosure-skill/.gitignore b/.agents/skills/patent-disclosure-skill/.gitignore new file mode 100644 index 0000000..80c514c --- /dev/null +++ b/.agents/skills/patent-disclosure-skill/.gitignore @@ -0,0 +1,31 @@ +# 用户产出(整目录不提交) +outputs/ +disclosures/ +tmp/ +*.tmp +~$* + +# 示例专利材料(仅提交 README 中的镜像链接;PDF/TXT 等本地自备) +examples/example_patent_reader/** +!examples/example_patent_reader/README.md + +# 国知局抓取结果缓存(tools/) +tools/_last_result*.html + +# Python / tools +tools/node_modules/ +__pycache__/ +*.py[cod] +.pyo +.venv/ +venv/ +*.egg-info/ + +# math_render 联调产物(tests/test_math_render.py 运行时生成) +tests/_math_test_figures/ +tests/_math_test_out.md +tests/_math_test_bad.md + +# OS +.DS_Store +Thumbs.db diff --git a/.agents/skills/patent-disclosure-skill/INSTALL.md b/.agents/skills/patent-disclosure-skill/INSTALL.md new file mode 100644 index 0000000..4a45bd9 --- /dev/null +++ b/.agents/skills/patent-disclosure-skill/INSTALL.md @@ -0,0 +1,112 @@ +# 安装说明 + +本技能遵循 [AgentSkills](https://agentskills.io) 常见布局:仓库根目录即技能根目录,内含 `SKILL.md`。 + +## Claude Code + +在 **git 仓库根目录** 下安装: + +```bash +mkdir -p .claude/skills +git clone <本仓库 URL> .claude/skills/patent-disclosure-skill +``` + +或使用本地路径复制到 `.claude/skills/patent-disclosure-skill`。 + +运行时环境通常会设置 **`CLAUDE_SKILL_DIR`** 指向该技能目录;`SKILL.md` 中的 `${CLAUDE_SKILL_DIR}/prompts/...` 即解析到此路径。 + +## Cursor + +Cursor 支持 [Agent Skills](https://www.cursor.com/docs/context/skills) 约定:每个技能是一个**子文件夹**,内含根级 `SKILL.md`(`name` 字段须与文件夹名一致,本仓库为 `patent-disclosure-skill`)。可将**本仓库完整内容**(含 `prompts/`、`tools/` 等)放在下列位置之一,重启 Cursor 后在 **Settings → Rules** 中查看是否已被发现;亦可用 Agent 输入 `/` 后选择技能名。 + +### 用户主目录(全局,所有项目可用) + +| 系统 | 推荐路径 | +|------|----------| +| Windows | `%USERPROFILE%\.cursor\skills\patent-disclosure-skill\`(即 `C:\Users\<用户名>\.cursor\skills\patent-disclosure-skill\`) | +| macOS / Linux | `~/.cursor/skills/patent-disclosure-skill/` | + +示例(将仓库克隆到全局技能目录): + +```bash +mkdir -p ~/.cursor/skills +git clone <本仓库 URL> ~/.cursor/skills/patent-disclosure-skill +``` + +Windows(PowerShell): + +```powershell +New-Item -ItemType Directory -Force -Path "$env:USERPROFILE\.cursor\skills" +git clone <本仓库 URL> "$env:USERPROFILE\.cursor\skills\patent-disclosure-skill" +``` + +### 项目目录(仅当前仓库) + +将本技能放在当前工作区下的: + +`<项目根>/.cursor/skills/patent-disclosure-skill/` + +(同样需包含完整仓库文件树,且 **`SKILL.md` 中 `name: patent-disclosure-skill` 与文件夹名一致**。) + +### 与「仅打开文件夹」等价关系 + +若未使用上述 `skills/` 布局,也可**直接用 Cursor 打开本仓库根目录**作为工作区;此时将 **`CLAUDE_SKILL_DIR`** 理解为「包含 `SKILL.md` 的目录」,prompts 路径为 `./prompts/*.md`,与 `SKILL.md` 示例命令中的 **`${CLAUDE_SKILL_DIR}`** 同义。 + +为与 Claude Code 迁移一致,Cursor 也会扫描 **`~/.claude/skills/`**、项目内 **`.claude/skills/`** 等路径;详见 Cursor 官方文档与当前版本设置项。 + +## 可选依赖 + +若仅使用交底书 Markdown 流程,不必安装 Python。 + +若需使用 **`tools/md_to_docx.py`**(Markdown → Word)、**`tools/docx_to_md.py`**(Word → Markdown + 图片)或 **`tools/pptx_to_md.py`**(PPT → Markdown + 图片,供扫描): + +```bash +pip install -r requirements.txt +``` + +交底书定稿须同时产出 **.md + .docx**,且将 **mermaid**(**3.2 系统框图**与 **3.4 流程图**)经 **`tools/mermaid_render.py`** 转为 PNG 嵌入。**mermaid** 须 **Node.js**:在 **`tools/`** 执行 **`npm install`**(含 **`puppeteer`**);若 **`mmdc`** 报找不到 Chrome,再执行 **`npx puppeteer browsers install chrome-headless-shell`**。详见 **`tools/README.md`**。 + +## 可选:国知局公布公告站抓取(Step 5 查新优先路径) + +若需使用 **`tools/cnipa_epub_search.py`**(一步,推荐)或 **`tools/cnipa_epub_crawler.py`** / **`tools/cnipa_epub_parse.py`**([epub.cnipa.gov.cn](http://epub.cnipa.gov.cn/),见 `prompts/prior_art_search.md`): + +```bash +pip install -r tools/requirements-cnipa.txt +python -m playwright install chromium +``` + +**Windows 终端中文**:`cnipa_epub_search.py` / `cnipa_epub_crawler.py` 已对 stdout/stderr 尝试 **UTF-8**(`reconfigure`)。若仍乱码,可在运行前执行 **`chcp 65001`**,或设置环境变量 **`PYTHONUTF8=1`**,以便复制 **`EPUB_HITS_JSON:`** 一行给 Agent 时不误判为失败。 + +与主流程 `requirements.txt` **独立**;未安装时 Step 5 仍可按该 prompt 降级为 **WebSearch**(如 Google 学术)。 + +## 强烈建议:专利通俗解读 + Obsidian 库 + +**强烈建议安装并配置 Obsidian**,才能完整体验索引、Canvas 知识图谱、术语网、关系图配色与公开线索旁注。无库时可降级到 `outputs/patent_reader/`,效果会弱一截。 + +对话开始前由 Agent 运行探测(也可手动): + +```bash +python tools/patent_reader/check_obsidian_env.py +# 自动接受唯一/当前打开的库: +python tools/patent_reader/check_obsidian_env.py --auto-accept +# 手动指定并持久化(+ Windows 用户环境变量): +python tools/patent_reader/check_obsidian_env.py --set "C:\Users\你\Documents\Obsidian Vault" --setx +``` + +亦可仅设会话变量: + +```bash +# Windows PowerShell +$env:PATENT_READER_OBSIDIAN_VAULT = "D:\Obsidian\你的库" +# 可选:库内目录,默认 Research/Patents +$env:PATENT_READER_PAPERS_DIR = "Research/Patents" +$env:PATENT_READER_GLOSSARY_DIR = "Research/术语" +``` + +```bash +pip install -r tools/patent_reader/requirements.txt # PDF:pymupdf +``` + +**首次使用**:解读**入库时会自动**初始化库(CSS、Bases、索引、关系图配色)。用户只需安装 Obsidian、配置库路径,并(可选)在社区插件市场安装 Dataview 等——步骤与插件清单见 **`docs/obsidian-setup-guide.md`**。交付后 Agent 按 **`prompts/obsidian_plugin_guide.md`** 引导可选插件。 + +工具链见 **`tools/patent_reader/README.md`**。 diff --git a/.agents/skills/patent-disclosure-skill/LICENSE b/.agents/skills/patent-disclosure-skill/LICENSE new file mode 100644 index 0000000..acf80fa --- /dev/null +++ b/.agents/skills/patent-disclosure-skill/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 handsomestWei + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/.agents/skills/patent-disclosure-skill/README.md b/.agents/skills/patent-disclosure-skill/README.md new file mode 100644 index 0000000..459b781 --- /dev/null +++ b/.agents/skills/patent-disclosure-skill/README.md @@ -0,0 +1,248 @@ +
+ +# 中国专利.skill + +> 中国专利 Agent Skill:**挖掘并写成可交付交底书**,或把**已有专利读成通俗笔记**。 + +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) +[![Python 3.9+](https://img.shields.io/badge/Python-3.9%2B-blue.svg)](https://www.python.org/) +[![Node.js](https://img.shields.io/badge/Node.js-mermaid%2Fmmdc-339933.svg)](https://nodejs.org/) +[![AgentSkills](https://img.shields.io/badge/AgentSkills-Standard-green)](https://agentskills.io) + +
+ +有设计文档和代码,但**专利点还没梳**?交底书要**框图 + 可改 Word**?
+定稿后还要**多轮补材料、纠错**并留下修改追溯?
+公开专利晦涩难懂,想**快速看懂权要与落地语境**? + +[愿景](#愿景) · [两种用法](#两种用法) · [功能特性](#功能特性) · [安装](#安装) · [使用](#使用) · [示例](#示例) · [运行效果](#运行效果) · [参考文档](#参考文档) · [详细安装说明](INSTALL.md) · [技能入口](SKILL.md) + +
+ +--- + +## 愿景 + +### 专利交底书编写 + +> **做了多年核心研发,专利发明人那一栏从没写过我的名字。** + +代码是自己敲的,方案是自己扛的,轮到交底书却卡在「专利点怎么挖、查新怎么写、框图和 Word 怎么一次交得出去」。本技能把这一环打通:从项目材料梳出可申请的点,查新、脱敏、成文、迭代另存——让真正干活的人,也能把技术贡献写进可交付的交底书里。 + +### 专利通俗解读 + +> **不止一篇。** + +公开专利常把阅读门槛抬得很高:权要绕、术语密、落地语境散落在说明书与附图里。本技能把单篇读成通俗笔记与图谱,并入库 Obsidian;依托双链、图谱、插件与 Bases 等生态,陆续解读的专利可以沉淀成**只属于自己的私有专利知识库**——权要、术语、线索与附图彼此勾连,越读越厚。再叠上 [Obsidian CLI](https://help.obsidian.md/cli) 与库内外连接能力,检索、批处理、和外部工具接力都更容易:从单篇通俗笔记,走向可检索、可关联、可继续生长的个人专利情报层,把沉睡在 PDF 里的技术细节重新点亮。库厚了之后,还能在这层之上做**专利比对、挖掘与分析**——同族对照、技术路线梳理、差异点扫描,把「读懂」推进到「用起来」。 + +--- + +## 两种用法 + +| | **专利交底书编写** | **专利通俗解读** | +|--|-------------------|------------------| +| **输入** | 项目文档 / 代码 / 主题 | 公开号、专利 PDF / 全文 | +| **输出** | `{案件}_{时间戳}.md` + `.docx` | Obsidian 解读笔记(或 `outputs/patent_reader/`) | +| **典型说法** | 专利挖掘、交底书、查新、`/交底书` | 读专利、专利解读、`/读专利`、`/patent-read` | +| **入口** | `SKILL.md` 主流程 Step 1–8 | `prompts/patent_plain_reader.md` | + +提供专利号或专利全文/PDF 时,技能**优先走解读**,不会默认开交底书流水线。 + +--- + +## 功能特性 + +### 专利交底书编写 + + + ++++ + + + + + + + + + + + +
能力说明
项目扫描按优先级读文档 / 代码;.docx / .pptx 先转 Markdown 再扫(prompts/project_scan.md
专利点候选点讨论与融合(patent_points_analyzer.md
查新优先 国知局 · 中国专利公布公告tools/cnipa_epub_search.py);异常或无果时降级 WebSearch。著录写入第一章(prior_art_search.md
交底书成稿脱敏模版 + mermaid 框图与流程图;mermaid_render.py → PNG,默认再出 .docx
交付命名{案件名}_{YYYYMMDDHHmmss}.md 与同名 .docxdisclosure_builder.md §7.3)
自检 / 迭代逻辑与公式自检(不写入正文);合并 / 纠正另存新文件 + 交底书修订对话记录.md
+ +### 专利通俗解读 + +**强烈推荐安装 Obsidian**:索引、Canvas 知识图谱、术语网与 callout 配色依赖库内呈现,才能发挥本模式的完整体验。安装与可选社区插件见 [docs/obsidian-setup-guide.md](docs/obsidian-setup-guide.md)。 + + ++++ + + + + + + + + + +
能力说明
取证解读全文 / PDF 抽取 → 权要树、术语表、特征—说明书—附图对照(patent_plain_reader.md
叙述故事线一句话总览 + 连贯叙事:把权要与说明书「讲成人话」,降低首次通读成本
知识图谱单篇 *_图谱.canvas、多篇 _专利关联.canvas、术语双链与关系图配色;入库自动配置 CSS / Bases
公开线索辅助联网检索公开材料(≤3 条);Agent 读 URL 写摘要;L1–L4 旁注与 clues/ 落地,用行业语境辅助理解(权要 / 说明书证据)
+ +--- + +## 安装 + +### 接入任意支持 Agent Skills 的环境 + +通用做法(任选其一): + +1. **克隆 / 复制到宿主的 skills 目录**(全局或当前项目均可),例如: + ```bash + git clone <本仓库 URL> <宿主-skills目录>/patent-disclosure-skill + ``` +2. **直接用 Agent 打开本仓库根目录**作为工作区;此时把「含 `SKILL.md` 的目录」当作技能根。 +3. 在对话里用自然语言或斜杠触发(如「写交底书」「读专利」);以当前宿主是否扫描到本技能为准。 + +Claude Code、Cursor、以及其他兼容 AgentSkills 的客户端,具体落盘路径不同,详见 [INSTALL.md](INSTALL.md)。装好后按宿主习惯重启 / 刷新,确认技能已被发现即可。 + +### 依赖 + +```bash +# 共用基础(Office 转换、交底书相关 Python 包) +pip install -r requirements.txt +``` + +```bash +# 可选:国知局查新(交底书 Step 5) +pip install -r tools/requirements-cnipa.txt +python -m playwright install chromium +``` + +```bash +# 可选:专利解读 PDF 抽取 +pip install -r tools/patent_reader/requirements.txt +``` + +- **交底书图示定稿**另需 **Node.js**:在 `tools/` 下 `npm install` 或使用 `npx mmdc`(见 [tools/README.md](tools/README.md))。 +- **解读 + Obsidian**:**强烈推荐**配置 Obsidian 库(`PATENT_READER_OBSIDIAN_VAULT`),才能完整体验索引、Canvas、术语网、关系图配色与公开线索旁注;无库时可降级到 `outputs/patent_reader/`,效果会弱一截。Windows 安装与可选社区插件见 [docs/obsidian-setup-guide.md](docs/obsidian-setup-guide.md)。 +- 不装国知局依赖时,查新按 `prior_art_search.md` 降级为 **WebSearch**。 + +--- + +## 使用 + +### 专利交底书编写 + +在 Agent 中用自然语言即可,例如:专利挖掘、专利点、**技术交底书**、查新、现有技术对比;斜杠如 `/patent-disclosure-skill`、`/交底书`。 + +建议说明 **项目路径** 或 **技术主题**。查新优先 [中国专利公布公告](http://epub.cnipa.gov.cn/),见 `prompts/prior_art_search.md`。 +在**已有交底书**上补材料或纠错时无需说「迭代」——按 `merger.md` / `correction_handler.md` 另存新稿;细则见 [SKILL.md](SKILL.md)。 + +### 专利通俗解读 + +例如:读专利、专利解读、看懂权要、`/读专利`、`/patent-read`,并给出**公开号或 PDF 路径**。 + +技能会走阅读模式:取证 → 叙述故事线 → 公开线索辅助 →(推荐)Obsidian 入库与知识图谱。**强烈推荐**配置库路径以获得完整体验;无库时仍可落到 `outputs/patent_reader/`。流程与工具见 [tools/patent_reader/README.md](tools/patent_reader/README.md)、[SKILL.md](SKILL.md)「专利通俗解读」。 + +--- + +## 示例 + +- **交底书**:虚构扫描原材料见 [examples/README.md](examples/README.md)(如 `examples/example_batch_job_scheduler/knowledge/`)。 +- **专利解读**:示例 PDF 镜像见 [examples/example_patent_reader/README.md](examples/example_patent_reader/README.md)(材料本地自备,不入库)。 + +完整产物由流程生成到 **`outputs/`** 或 Obsidian 库。 + +--- + +## 运行效果 + +### 专利交底书编写 + + + + + + + + + + +
初版生成
首次落盘交付
迭代更新
多版本并存 + 对话记录
+初版生成:outputs 目录下的时间戳交底书、mermaid 图目录等 + +迭代更新:新时间戳文件与交底书修订对话记录 +
+ +### 专利通俗解读 + + + + + + + + + + +
Obsidian 关系图
知识图谱与多色节点
解读 Canvas
叙事故事线 · 术语 · 公开线索
+Obsidian 关系图:解读笔记、术语与 Canvas 知识图谱 + +专利解读 Canvas:叙事、权要、术语与公开线索图谱 +
+ +--- + +## 参考文档 + +- [技能入口与 Agent 流程](SKILL.md)(交底书主流程 + 阅读模式) +- [详细安装说明](INSTALL.md) +- [交底书:图示与转换 / 国知局工具](tools/README.md) +- [专利解读工具](tools/patent_reader/README.md) +- [Obsidian 安装与可选社区插件(Windows)](docs/obsidian-setup-guide.md) +- [示例案件与原材料](examples/README.md) +- [交底书模版细则](prompts/template_reference.md) + +--- + +## 支持作者 + +如果这个 Skill 帮您节省了写交底书或读专利的时间,可以请我喝杯咖啡☕随缘支持,感谢感谢🙏🙏 + +
+ + + + + + +
+ +随缘支持 + + + + + + + + Star History Chart + + + +
+ +
+ +--- + +
+ +MIT License © [handsomestWei](https://github.com/handsomestWei/) + +
diff --git a/.agents/skills/patent-disclosure-skill/SKILL.md b/.agents/skills/patent-disclosure-skill/SKILL.md new file mode 100644 index 0000000..b18ee35 --- /dev/null +++ b/.agents/skills/patent-disclosure-skill/SKILL.md @@ -0,0 +1,147 @@ +--- +name: patent-disclosure-skill +description: "中国专利:从项目文档挖掘专利点并生成可交付技术交底书(查新、脱敏成文、自检与迭代);或将已有专利解读为通俗笔记与 Obsidian 知识图谱(叙事故事线、公开线索辅助)。| China patents: draft technical disclosures from project docs, or read existing patents into plain-language notes and an Obsidian knowledge graph." +version: "2.0.0" +user-invocable: true +argument-hint: "[可选:项目路径 / 技术主题 / 专利号或 PDF 路径]" +allowed-tools: Read, Write, Edit, Grep, Glob, WebSearch, Bash +--- + +# 中国专利 · 交底书编写与通俗解读 + +本技能支持两种用法;分步指令在 **`prompts/`**,执行前须 **`Read`** 对应文件。 + +| 模式 | 何时用 | 主入口 | +|------|--------|--------| +| **A · 交底书编写** | 从项目材料挖专利点 → 查新 → 成稿 `.md`/`.docx` → 迭代 | 下文「交底书主流程」 | +| **B · 专利通俗解读** | 已有公开号 / PDF / 全文,原文抽象难读,要通俗叙事 + 图谱 | 下文「专利通俗解读」+ `patent_plain_reader.md` | + +提供**专利号或专利全文/PDF**且意图为「读懂」时 → **优先模式 B**,**不**默认跑交底书 Step 1–8。 + +## 环境与约定 + +- **语言**:默认与用户语种一致;专利与法律术语用行业常用表述。 +- **交底书图示(Step 7)**:**3.2**/**3.4** 用 fenced **mermaid**;见工具表与 **`tools/README.md`**。 +- **解读 + Obsidian**:**强烈推荐**配置库(`PATENT_READER_OBSIDIAN_VAULT`),以完整体验索引、Canvas 知识图谱、术语网、关系图配色与公开线索旁注;无库可降级 `outputs/patent_reader/`。入库时**自动** bootstrap(CSS / Bases / 关系图),勿再引导用户手动装 CSS。用户侧 Obsidian 安装与可选社区插件见 **`docs/obsidian-setup-guide.md`**。 + +--- + +## 触发条件 + +- **交底书**:专利挖掘、专利点、技术交底书、交底书、查新、现有技术对比;`/patent-disclosure-skill`、`/交底书` 等。 +- **通俗解读**:专利解读、读专利、看懂专利、反向专利、专利翻译成通俗;`/patent-read`、`/读专利`;或用户给出公开号 / 专利 PDF / 全文且目标为理解而非写交底书。 +- **交底书迭代(意图识别)**:在**已有交底书**上补材料、改章节、纠错等——**无需**固定「迭代」一词,也**不必**先问是否迭代。`Read` `iteration_context.md`,再 `merger.md`(扩合并)或 `correction_handler.md`(纠错);**另存** `{案件名}_{YYYYMMDDHHmmss}.md`/`.docx`,**不覆盖**旧稿(除非用户明确要求)。**禁止**迭代意图成立时默认回到 Step 3–4 重挖专利点。对话中已有交底书路径/附件时优先按迭代处理。 + +--- + +## 工具与数据来源 + +按任务选用;工具名以当前 Agent 环境为准。扫描含 **`.docx`/`.pptx`** 时,Step 2 阅读前须先 `docx_to_md.py` / `pptx_to_md.py`(`pip install -r requirements.txt`)。 + +### 常见任务与建议方式 + +| 任务 | 建议方式 | +|------|----------| +| 加载分步指令 | **`Read`** → `${CLAUDE_SKILL_DIR}/prompts/*.md` | +| 读代码、设计文档、PDF、图片 | 文件读取;大仓库先检索再精读 | +| Word / PPT → Markdown | `docx_to_md.py` / `pptx_to_md.py`(见上) | +| 联网查新(交底书 Step 5) | **`Read`** `prior_art_search.md`。优先 **`cnipa_epub_search.py`**:先归纳 2~8 语义块,**每次工具调用仅一词**,自行按 `pub_number` 合并 `EPUB_HITS_JSON`;需 `tools/requirements-cnipa.txt` + Playwright Chromium。`abstract` 必用。异常或无果再 **WebSearch** | +| 交底书定稿(**.md + .docx**) | **3.2/3.4** 用 mermaid;`mermaid_render.py` → PNG 并默认出 docx。见 **`tools/README.md`** | +| 交底书落盘 | 建议 `./outputs/{案件标识}/`;文件名 **`{案件名}_{YYYYMMDDHHmmss}`**(§7.3 第 5 点,含首次与迭代) | +| 迭代对话留档 | 案件目录追加 **`交底书修订对话记录.md`**(`iteration_dialog_log.py`) | +| **专利通俗解读** | **`Read`** `patent_plain_reader.md`。**先** `check_obsidian_env.py`(**强烈推荐**有库;未检测到则询问并用 `--set`;用户明确不要库才跳过)。仅公开号时用 **`fetch_patent_pdf.py`**(源表 `references/patent_pdf_sources.yaml`;**禁止**会话内现写下载脚本)→ `extract` → 叙事/权要可视化 → 公开线索由 **Agent 读 URL 写 summary** → `write_patent_obsidian_note`(入库自动 bootstrap;线索脚本抓取仅 `--fetch-clues-fallback`)。交付后可选社区插件引导;库内 ≥2 篇**须反问**关联,同意后 `link_patent_notes.py` | + +--- + +## Prompt 文件映射 + +### 交底书编写 + +| 步骤 | 文件 | 用途 | +|------|------|------| +| Step 1 | `prompts/intake.md` | 边界与输入 | +| Step 2 | `prompts/project_scan.md` | 项目扫描;Office 须先转换 | +| Step 3–4 | `prompts/patent_points_analyzer.md` | 专利点融合与选定 | +| Step 5 | `prompts/prior_art_search.md` | 查新 | +| Step 6 | `prompts/disclosure_preview.md` | 摘要预览 | +| Step 7 | `prompts/disclosure_builder.md` + `template_reference.md` | 成文、脱敏、符号/公式体例、mermaid | +| Step 8 | `prompts/disclosure_self_check.md` | 内部自检(不入正文) | +| 迭代 | `iteration_context.md` / `merger.md` / `correction_handler.md` | 扩合并 / 纠错另存 | + +### 专利通俗解读 + +| 步骤 | 文件 | 用途 | +|------|------|------| +| 门禁 | `check_obsidian_env.py`(见 `patent_plain_reader.md` 第 0 步) | 探测/写入库路径;强烈推荐有库 | +| 仅公开号取 PDF | `tools/patent_reader/fetch_patent_pdf.py` + `references/patent_pdf_sources.yaml` | 固化下载(第 1 步前);**禁止**会话内现写脚本 | +| 主流程 | `prompts/patent_plain_reader.md` | extract / 附图 / 权树校对 / 线索 / 写笔记 / 入库(须先 Read) | +| 写笔记时 | `prompts/obsidian_ofm_companion.md` + `references/patent_obsidian_format.md` + `assets/patent_note_template.md` | Callout / 结构 / 模板 | +| 写笔记时(按需) | `references/ipc_application_hints.yaml` | IPC 应用场景坐标 | +| 自检 | `prompts/patent_reader_self_check.md` | 交付前内部自检(不入笔记) | +| 交付时(对话) | `prompts/obsidian_plugin_guide.md` | 可选社区插件引导(不入笔记) | +| 用户文档 | `docs/obsidian-setup-guide.md` | 装 Obsidian / 社区插件(给人看,勿当主链全文 Read) | +| 按需 | `tools/patent_reader/README.md` | 解读工具链说明 | + +顺序摘要:门禁 →(仅公开号)`fetch_patent_pdf` → 主流程(extract / 附图 / 线索 / 入库)→ 写笔记时读 ofm/format/模板 → lint 后自检 → 已入库则交付引导;用户已给 PDF/全文则跳过下载;`obsidian-setup-guide` 与工具 README 仅按需查阅。 + +--- + +## 模式 A · 交底书主流程 + +1. **`Read`** `intake.md` → Step 1 +2. **`Read`** `project_scan.md` → Step 2 +3. **`Read`** `patent_points_analyzer.md` → Step 3–4 +4. **`Read`** `prior_art_search.md` → Step 5 +5. **`Read`** `disclosure_preview.md` → Step 6(可跳过) +6. **`Read`** `disclosure_builder.md` + `template_reference.md` → Step 7(文件名 §7.3 第 5 点;对话附 §7.6 权利要求偏向点,**不入正文**) +7. **`Read`** `disclosure_self_check.md` → Step 8 内部自检后交付 + +**禁止**:交底书正文出现「自检清单」章节。 + +--- + +## 模式 B · 专利通俗解读 + +**启用**:读懂已有专利(见触发条件),且**非**交底书迭代。 + +**目标能力**(交付物应体现): + +- **取证解读**:权要树、术语、特征—说明书—附图对照 +- **叙述故事线**:一句话 + 问题/思路/怎么做/效果/差别等连贯叙事 +- **知识图谱**:`*_图谱.canvas`、术语双链、关系图配色;多篇可 `_专利关联.canvas` +- **公开线索辅助**:≤3 条公开材料,L1–L4 旁注与 `clues/`(**推测语境,非权要/说明书证据**) + +**步骤**: + +1. **`Read`** `patent_plain_reader.md` → **先跑** `check_obsidian_env.py`(强烈推荐有库;无库则询问路径/`--set`,或确认仅 outputs)→ **仅公开号、无本地全文时**跑 **`fetch_patent_pdf.py`**(源表 `patent_pdf_sources.yaml`;失败可 `cnipa_epub_search` 核验元数据,**勿**现写下载脚本)→ `extract_patent_text`(及附图)→ 校对 `claim_tree` / 写 `claim_deltas` → 叙事与可视化 → Agent 读线索 URL 写 summary → 写笔记 → lint → `write_patent_obsidian_note`(有库时入库并**自动** bootstrap) +2. **`Read`** `obsidian_ofm_companion.md` + `patent_obsidian_format.md` + 模板 +3. **`Read`** `patent_reader_self_check.md` → lint 通过后复核 +4. 已入库:对话末尾 **`Read`** `obsidian_plugin_guide.md`(仅可选社区插件;勿要求用户再装 CSS) +5. 库内 ≥2 篇解读时**须反问**是否关联;同意后 `link_patent_notes.py` + +**与模式 A 互斥**:解读**不**跑交底书 Step 1–8。若用户要「先解读再写交底书」,先完成模式 B 交付,再询问是否进入 intake。 + +--- + +## 迭代模式(交底书 · 摘要) + +按自然语言意图启用(见触发条件),**不**为「是否迭代」打断用户。 + +- **补材料 / 扩展 / §7.6 侧重点已声明**:`iteration_context.md` → `merger.md` → 另存时间戳稿 + 追加修订对话记录 + 输出合并摘要 +- **纠错 / 与事实不符**:`iteration_context.md` → `correction_handler.md` → 另存 + 记录 + 纠正摘要;定稿仍附 §7.6 引导 + +新稿定稿路径上仍内部执行 `disclosure_self_check.md`。 + +--- + +## Agent 自用工作流检查清单 + +``` +□ 已区分模式 A(交底书)/ B(解读)/ 交底书迭代,未混跑 +□ 已按步骤 Read 对应 prompts;Step 2 若含 Office,已 docx_to_md / pptx_to_md 并读产出 `.md` +□ 「在已有交底书上改」类意图:已 Read iteration_context 并走 merger/correction;交付为新时间戳文件,未无故覆盖旧稿;已追加交底书修订对话记录.md 并输出留档摘要 +□ 查新:优先 cnipa_epub_search(分次一词并合并 EPUB_HITS_JSON);abstract 必用;异常再 WebSearch;1.1 与区别论述已写 +□ 除用户跳过外已做摘要预览;脱敏/mermaid/§7.7/3.4.1 与 .md+.docx 时间戳文件名符合要求;正文无技能仓库类脚注 +□ 定稿对话含 §7.6 权利要求偏向点(不入正文、不捏造);自检仅后台,正文无自检清单 +□ 【模式 B】已 check_obsidian_env;仅公开号已用 fetch_patent_pdf(未现写下载脚本);强烈推荐有库(无库已确认降级 outputs);叙事/线索/图谱要点已覆盖;入库自动 bootstrap,未误导用户手装 CSS;≥2 篇已反问关联并按需 link_patent_notes +``` diff --git "a/.agents/skills/patent-disclosure-skill/assets/obsidian/_\344\270\223\345\210\251\350\247\243\350\257\273\347\264\242\345\274\225.template.md" "b/.agents/skills/patent-disclosure-skill/assets/obsidian/_\344\270\223\345\210\251\350\247\243\350\257\273\347\264\242\345\274\225.template.md" new file mode 100644 index 0000000..f4435f5 --- /dev/null +++ "b/.agents/skills/patent-disclosure-skill/assets/obsidian/_\344\270\223\345\210\251\350\247\243\350\257\273\347\264\242\345\274\225.template.md" @@ -0,0 +1,61 @@ +--- +tags: + - patents/index +cssclasses: + - patent-index +--- + +# 专利解读索引 + +> 入库后自动维护。启用 **Bases** 核心插件后可用下表;亦提供 Dataview 回退。 + +## 仪表盘(Bases) + +![[{{PAPERS_DIR}}/patents.base#全部专利解读]] + +### 按领域 / 含推测 + +![[{{PAPERS_DIR}}/patents.base#按领域]] + +![[{{PAPERS_DIR}}/patents.base#含推测线索]] + +## Dataview 回退(可选插件) + +安装 [Dataview](https://obsidian.md/plugins?id=dataview) 后,下列查询可替代或补充 Bases: + +```dataview +TABLE pub_number AS "公开号", domain AS "领域", read_date AS "解读日期", default(evidence_label, choice(evidence_scope = "full_text", "全文", choice(evidence_scope = "abstract_only", "仅摘要", choice(evidence_scope = "partial", "部分", evidence_scope)))) AS "证据范围", ipc AS "IPC", default(speculative_label, choice(confidence_speculative, "是", "否")) AS "含推测" +FROM "{{PAPERS_DIR}}" +WHERE contains(file.name, "_解读_") +SORT read_date DESC +``` + +### 按领域分组 + +```dataview +TABLE length(rows) AS "篇数" +FROM "{{PAPERS_DIR}}" +WHERE contains(file.name, "_解读_") +GROUP BY domain +SORT length(rows) DESC +``` + +### 术语网(反链入口) + +> 下列列表依赖 Dataview;若仍为空,请打开 `{{GLOSSARY_DIR}}/` 核对术语页,或点开 `glossary.base`。 + +```dataview +LIST +FROM "{{GLOSSARY_DIR}}" AND #glossary +WHERE file.name != "_术语索引" +SORT file.name ASC +``` + +## 关联图谱 + +- [[{{PAPERS_DIR}}/_专利关联.canvas|专利关联总览]](交付后可生成专利关联) +- 打开左侧 **关系图**:节点已按类型自动上色(靛=解读,青绿=Canvas,橙=术语,琥珀=含推测)。若仍为灰色,请重载库(Ctrl/Cmd+R)。 + +## 笔记列表 + +(入库时自动追加条目。) diff --git a/.agents/skills/patent-disclosure-skill/assets/obsidian/glossary.base b/.agents/skills/patent-disclosure-skill/assets/obsidian/glossary.base new file mode 100644 index 0000000..66412c9 --- /dev/null +++ b/.agents/skills/patent-disclosure-skill/assets/obsidian/glossary.base @@ -0,0 +1,31 @@ +filters: + and: + - 'file.hasTag("glossary")' + +properties: + title: + displayName: 术语 + source_pub: + displayName: 来源公开号 + seen_in: + displayName: 出现于 + +views: + - type: table + name: 全部术语 + order: + - file.name + - title + - source_pub + filters: + and: + - 'file.inFolder("{{GLOSSARY_DIR}}")' + - 'file.name != "_术语索引"' + - type: cards + name: 术语卡片 + order: + - title + - source_pub + filters: + and: + - 'file.hasTag("glossary")' diff --git a/.agents/skills/patent-disclosure-skill/assets/obsidian/graph_color_groups.json b/.agents/skills/patent-disclosure-skill/assets/obsidian/graph_color_groups.json new file mode 100644 index 0000000..037ad22 --- /dev/null +++ b/.agents/skills/patent-disclosure-skill/assets/obsidian/graph_color_groups.json @@ -0,0 +1,12 @@ +{ + "_comment": "与 tools/patent_reader/obsidian.py::build_patent_graph_color_groups 保持同步;供文档对照。实际写入以脚本为准(含动态 path)。", + "legend": [ + { "color": "#14B8A6", "meaning": "单篇 Canvas 图谱(file:_图谱)" }, + { "color": "#0D9488", "meaning": "全局关联 Canvas(file:_专利关联)" }, + { "color": "#F97316", "meaning": "术语页(path:术语 / tag:#glossary)" }, + { "color": "#64748B", "meaning": "索引页(tag:#patents/index)" }, + { "color": "#F59E0B", "meaning": "含推测线索的解读(tag:#patent/speculative)" }, + { "color": "#4F46E5", "meaning": "专利解读笔记(file:_解读_ / tag:#patents)" }, + { "color": "#0F766E", "meaning": "Bases 仪表盘(file:.base)" } + ] +} diff --git a/.agents/skills/patent-disclosure-skill/assets/obsidian/patent-reader.css b/.agents/skills/patent-disclosure-skill/assets/obsidian/patent-reader.css new file mode 100644 index 0000000..36b7a87 --- /dev/null +++ b/.agents/skills/patent-disclosure-skill/assets/obsidian/patent-reader.css @@ -0,0 +1,69 @@ +/* 专利通俗解读笔记样式 — 复制到库 .obsidian/snippets/ 并在 外观→CSS 代码片段 中启用 */ + +.patent-reader .callout[data-callout="patent-meta"] { + --callout-color: 99, 102, 241; + --callout-icon: file-badge; + border-width: 1px; +} + +.patent-reader .callout[data-callout="grounding"] { + --callout-color: 16, 185, 129; + --callout-icon: map-pin; +} + +.patent-reader .callout[data-callout="speculative"], +.patent-reader .callout[data-callout="warning"] { + --callout-color: 245, 158, 11; + --callout-icon: alert-triangle; +} + +.patent-reader .callout[data-callout="patent-claim"] { + --callout-color: 88, 86, 214; + --callout-icon: scroll-text; +} + +.patent-reader .callout[data-callout="figure"] { + --callout-color: 14, 165, 233; + --callout-icon: image; +} + +.patent-reader h2 { + border-bottom: 1px solid var(--background-modifier-border); + padding-bottom: 0.25em; +} + +.patent-reader .mermaid { + margin: 1em 0; +} + +/* 权项一览表:窄列 + 可读行高 */ +.patent-reader h3 { + margin-top: 1.25em; +} + +.patent-reader table { + font-size: 0.92em; +} + +.patent-reader table td:nth-child(1), +.patent-reader table th:nth-child(1) { + white-space: nowrap; + width: 3em; +} + +.patent-reader table td:nth-child(2), +.patent-reader table th:nth-child(2), +.patent-reader table td:nth-child(3), +.patent-reader table th:nth-child(3) { + white-space: nowrap; + width: 4.5em; +} + +.patent-reader .callout[data-callout="tip"] { + --callout-color: 14, 165, 233; +} + +.patent-index .dataview-error, +.patent-index .block-language-dataview { + font-size: 0.9em; +} diff --git a/.agents/skills/patent-disclosure-skill/assets/obsidian/patents.base b/.agents/skills/patent-disclosure-skill/assets/obsidian/patents.base new file mode 100644 index 0000000..82d410e --- /dev/null +++ b/.agents/skills/patent-disclosure-skill/assets/obsidian/patents.base @@ -0,0 +1,82 @@ +filters: + and: + - 'file.hasTag("patents")' + +formulas: + evidence_zh: 'if(evidence_label, evidence_label, if(evidence_scope == "full_text", "全文", if(evidence_scope == "abstract_only", "仅摘要", if(evidence_scope == "partial", "部分", evidence_scope))))' + speculative_zh: 'if(speculative_label, speculative_label, if(confidence_speculative, "是", "否"))' + +properties: + pub_number: + displayName: 公开号 + domain: + displayName: 领域 + ipc: + displayName: IPC + read_date: + displayName: 解读日期 + evidence_scope: + displayName: 证据范围(英文) + evidence_label: + displayName: 证据范围 + perspective: + displayName: 视角 + confidence_speculative: + displayName: 含推测(布尔) + speculative_label: + displayName: 含推测 + formula.evidence_zh: + displayName: 证据范围 + formula.speculative_zh: + displayName: 含推测 + +views: + - type: table + name: 全部专利解读 + order: + - file.name + - pub_number + - domain + - ipc + - read_date + - formula.evidence_zh + - perspective + - formula.speculative_zh + filters: + and: + - 'file.inFolder("{{PAPERS_DIR}}")' + - 'file.name.contains("_解读_")' + - type: table + name: 按领域 + order: + - domain + - pub_number + - read_date + - formula.evidence_zh + filters: + and: + - 'file.inFolder("{{PAPERS_DIR}}")' + - 'file.name.contains("_解读_")' + - type: table + name: 含推测线索 + order: + - pub_number + - domain + - read_date + - formula.evidence_zh + filters: + and: + - 'file.inFolder("{{PAPERS_DIR}}")' + - 'file.name.contains("_解读_")' + - 'confidence_speculative == true' + - type: cards + name: 卡片视图 + order: + - pub_number + - domain + - read_date + - formula.evidence_zh + filters: + and: + - 'file.inFolder("{{PAPERS_DIR}}")' + - 'file.name.contains("_解读_")' diff --git a/.agents/skills/patent-disclosure-skill/assets/patent_note_template.md b/.agents/skills/patent-disclosure-skill/assets/patent_note_template.md new file mode 100644 index 0000000..2ff7141 --- /dev/null +++ b/.agents/skills/patent-disclosure-skill/assets/patent_note_template.md @@ -0,0 +1,114 @@ +--- +tags: + - patents/未分类 + - patent/evidence/full +aliases: [] +cssclasses: + - patent-reader +pub_number: "" +domain: 未分类 +ipc: "" +assignees: [] +read_date: YYYY-MM-DD +perspective: 入门 +evidence_scope: full_text +confidence_speculative: false +--- + +# 专利解读:{{发明名称或公开号}} + +## Obsidian 导航 + +- [[Research/Patents/_专利解读索引|专利解读索引]] +- [[Research/Patents/未分类/_领域索引|领域索引]] +- [[Research/术语/_术语索引|术语索引]] +- [[Research/Patents/{{公开号目录}}/{{公开号}}_图谱.canvas|专利族图谱]] + +> [!patent-meta] 著录项 +> - **公开号**:{{CN…}} +> - **领域**:{{domain}} +> - **IPC**:{{ipc}} +> - **申请人**:{{assignees}} +> - **视角**:{{入门|研发|规避}} +> - **证据范围**:{{全文|仅摘要|部分}} +> - **运行 ID**:{{RUN}} + +## 一、一句话 + +(写作提示 · 勿写入交付稿:≤3 句,解决什么问题、核心手段、对读者意味着什么。) + +## 二、连贯叙事 + +(写作提示 · 勿写入交付稿:按「问题 → 思路 → 怎么做 → 效果」讲清,避免权项编号堆砌。) + +## 三、权利要求树 + +(写作提示 · 勿写入交付稿:入库由 `claim_tree.json` 生成**单一树形表**「结构 | 权 | 本项新增」。不要同时再贴一份 mermaid 主视图。) + +> 共 N 项 · 独立 x / 从属 y。独立权展开见第四节。 + +| 结构 | 权 | 本项新增 | +| --- | ---: | --- | +| `◆` | 1 | (短句) | +| `├─` | 2 | (短句) | +| `└─` | 3 | (短句) | + +## 四、独立权利要求精读 + +> [!patent-claim] 权利要求 {{N}} + +> 【{{公开号}}·权利要求{{N}}】{{原文逐字片段}} + +| 特征 | 大白话 | 说明书依据 | +|------|--------|------------| +| F1 | | 说明书 0006 / 说明书 0058–0061 | + +## 五、专利内术语表 + +| 术语 | 本文含义/位置 | 备注 | +|------|---------------|------| +| | | 来自说明书定义句 / 一般理解 | + +## 六、特征—说明书—附图对照 + +| 特征 | 说明书位置 | 附图 | +|------|------------|------| +| | | 图 N | + +(写作提示 · 勿写入交付稿:有精修图用 `![[images/…]]`;扫描件由入库脚本追加「附图」整页预览。) + +## 七、和现有技术的差别 + +## 八、阅读建议 + +(写作提示 · 勿写入交付稿:3–5 条可执行建议;规避视角可含「删/换/绕」初判方向。) + +## 九、技术应用场景 + +> [!grounding] 应用场景 +> +> | 场景/模块 | 大白话 | 专利内依据 | +> |-----------|--------|------------| +> | | | 说明书 0056 / 实施例… | + +(写作提示 · 勿写入交付稿:不得含 URL 或 WebSearch 推测。) + +## 十、附录:行业坐标与公开线索 + +### A. IPC 行业坐标 + +(写作提示 · 勿写入交付稿:行业坐标来自离线 IPC 词表;正文「来源」只写「离线 IPC 行业词表」等自然语言,禁止写脚本名、JSON 字段路径如 context_anchor.*。) + +### B. 公开检索线索 + +> [!warning]- 公开检索线索 +> +> 详情见 [[clues/_线索索引|线索文件夹]](最多 3 条;摘要由 Agent 读取 URL 后写入)。 +> +> - **线索**:[[clues/01-标题|标题]] — 置信度:中 — [来源](URL) — 理由:… +> +> 无可靠 URL 时写:「未发现可核验的公开对应,可能为防御性/储备专利。」 + +## 十一、免责声明 + +本解读仅供技术理解辅助,不构成法律意见;专利保护范围以官方法律文本为准。重大决策请咨询专利代理师/律师。 diff --git a/.agents/skills/patent-disclosure-skill/docs/obsidian-setup-guide.md b/.agents/skills/patent-disclosure-skill/docs/obsidian-setup-guide.md new file mode 100644 index 0000000..d324d68 --- /dev/null +++ b/.agents/skills/patent-disclosure-skill/docs/obsidian-setup-guide.md @@ -0,0 +1,110 @@ +# Obsidian 使用与插件配置简介(Windows · 专利通俗解读) + +本文说明两件事: + +1. **你需要做的**:在 Windows 安装 Obsidian、(可选)从社区插件市场装增强插件。 +2. **技能已自动做的**:CSS 样式、核心插件 Bases、索引 / Bases 文件、关系图配色——**解读入库时写入,用户不必再装、不必再跑初始化脚本。** + +相关文件:`prompts/obsidian_plugin_guide.md`、`INSTALL.md`。 + +--- + +## 1. Windows 下载与安装 Obsidian + +### 1.1 下载 + +1. 打开官网: +2. 选择 **Windows** → **Download for Windows**(或 **Universal**)。 +3. **请只用官网 / 官方 GitHub Releases**,避免第三方改装包。 + +### 1.2 安装与开库 + +1. 双击 `.exe` 按向导安装(一般无需管理员权限)。 +2. 打开 Obsidian,**创建新库**或**打开文件夹作为库**(例如 `C:\Users\<用户名>\Documents\Obsidian Vault`)。 +3. 把库路径告诉技能(或设环境变量),例如: + +```powershell +$env:PATENT_READER_OBSIDIAN_VAULT = "D:\你的库路径" +# 可选持久化: +python tools/patent_reader/check_obsidian_env.py --set "D:\你的库路径" --setx +``` + +### 1.3 与本技能 + +- **不强制装 Obsidian**:无库时解读可落到 `outputs/patent_reader/`。 +- **有库时**:用技能解读并入库后,笔记一般在 `Research/Patents/`;库级样式与索引由入库自动写好。入库后在 Obsidian 按 **Ctrl+R** 重载即可看到效果。 + +--- + +## 2. 技能自动配置(无需你操作) + +解读笔记**写入 Obsidian 库时**会自动: + +| 项目 | 结果 | +|------|------| +| CSS 片段 `patent-reader` | 复制到 `.obsidian/snippets/` 并启用;笔记带 `cssclasses: patent-reader` | +| 核心插件 **Bases** | 写入 `.obsidian/core-plugins.json` 开启 | +| `patents.base`、解读索引、术语索引 | 写入 `Research/Patents/`(及术语目录) | +| 关系图彩色 Groups | 写入 `.obsidian/graph.json`(无需社区插件);过滤器排除图片、`.json`、权项锚点/说明书段落旁路 | + +Callout 观感(自动生效):紫=著录/权要,绿=应用场景,橙=推测/公开线索,蓝=附图与 tip。 + +> 你**不需要**手动复制 CSS,也**不需要**为「配库」再跑 `setup_obsidian_vault.py`。 + +--- + +## 3. 社区插件(可选;须在 App 内安装) + +技能**不能**代装社区插件(Obsidian 安全限制)。未装时笔记仍可正常阅读;装了更「好看」、索引表更灵活。 + +### 3.1 打开市场 + +1. **设置** → **社区插件** +2. 关闭「限制模式 / 安全模式」 +3. 点 **浏览** + +### 3.2 检索 → 安装 → 启用 + +对下表每个插件:搜索英文名 → **安装** → **启用** → 全部完成后 **Ctrl+R**。 + +| 插件(搜索名) | 作用 | 安装页 | +|----------------|------|--------| +| **Dataview** | 索引页动态表回退 | | +| **Colored Tags** | 标签上色 | | +| **Colored Bases Properties** | Bases 属性 pill 上色 | | +| **Iconize** | 侧栏图标 | | +| **Supercharged Links** | 双链按属性上色 | | + +推荐至少装 **Dataview**;其余按需。关系图多色**不依赖**这些插件。 + +### 3.3 建议打开验收 + +| 页面 | 看什么 | +|------|--------| +| `Research/Patents/_专利解读索引.md` | Bases / Dataview | +| `Research/Patents/<公开号>/*_解读_*.md` | 解读与旁注配色 | +| 同目录 `*_图谱.canvas` | 单篇图谱 | +| `Research/Patents/_专利关联.canvas` | 多篇关联(若已有) | +| 左侧 **关系图** | 多色节点 | + +--- + +## 4. 清单(用户侧) + +- [ ] 安装 Obsidian 并打开/创建库 +- [ ] 配置库路径(或让技能探测) +- [ ] 用技能解读入库 → **Ctrl+R** +- [ ] (可选)社区市场安装 Dataview 等 + +--- + +## 5. 常见问题 + +**Q:社区插件搜不到?** +需联网;已关限制模式,并点「浏览」进市场。 + +**Q:Bases / CSS / 关系图颜色没有?** +先确认已用技能**入库过**至少一篇,再 **Ctrl+R**。Bases 在**核心插件**里(不在社区市场)。关系图看右侧 Groups,与 Colored Tags 无关。 + +**Q:不能装社区插件?** +无妨:自动配置的 CSS + Bases + 原生关系图已足够阅读。 diff --git a/.agents/skills/patent-disclosure-skill/docs/thanks.jpg b/.agents/skills/patent-disclosure-skill/docs/thanks.jpg new file mode 100644 index 0000000..1fd9083 Binary files /dev/null and b/.agents/skills/patent-disclosure-skill/docs/thanks.jpg differ diff --git "a/.agents/skills/patent-disclosure-skill/docs/\346\225\210\346\236\234\344\276\213-obs\345\233\276\350\260\261.jpg" "b/.agents/skills/patent-disclosure-skill/docs/\346\225\210\346\236\234\344\276\213-obs\345\233\276\350\260\261.jpg" new file mode 100644 index 0000000..f55fba8 Binary files /dev/null and "b/.agents/skills/patent-disclosure-skill/docs/\346\225\210\346\236\234\344\276\213-obs\345\233\276\350\260\261.jpg" differ diff --git "a/.agents/skills/patent-disclosure-skill/docs/\346\225\210\346\236\234\344\276\213-\345\210\235\347\211\210\347\224\237\346\210\220.jpg" "b/.agents/skills/patent-disclosure-skill/docs/\346\225\210\346\236\234\344\276\213-\345\210\235\347\211\210\347\224\237\346\210\220.jpg" new file mode 100644 index 0000000..fbb395a Binary files /dev/null and "b/.agents/skills/patent-disclosure-skill/docs/\346\225\210\346\236\234\344\276\213-\345\210\235\347\211\210\347\224\237\346\210\220.jpg" differ diff --git "a/.agents/skills/patent-disclosure-skill/docs/\346\225\210\346\236\234\344\276\213-\350\247\243\350\257\273.jpg" "b/.agents/skills/patent-disclosure-skill/docs/\346\225\210\346\236\234\344\276\213-\350\247\243\350\257\273.jpg" new file mode 100644 index 0000000..d4e6122 Binary files /dev/null and "b/.agents/skills/patent-disclosure-skill/docs/\346\225\210\346\236\234\344\276\213-\350\247\243\350\257\273.jpg" differ diff --git "a/.agents/skills/patent-disclosure-skill/docs/\346\225\210\346\236\234\344\276\213-\350\277\255\344\273\243\346\233\264\346\226\260.jpg" "b/.agents/skills/patent-disclosure-skill/docs/\346\225\210\346\236\234\344\276\213-\350\277\255\344\273\243\346\233\264\346\226\260.jpg" new file mode 100644 index 0000000..4d83ea9 Binary files /dev/null and "b/.agents/skills/patent-disclosure-skill/docs/\346\225\210\346\236\234\344\276\213-\350\277\255\344\273\243\346\233\264\346\226\260.jpg" differ diff --git a/.agents/skills/patent-disclosure-skill/examples/README.md b/.agents/skills/patent-disclosure-skill/examples/README.md new file mode 100644 index 0000000..5cf3ecd --- /dev/null +++ b/.agents/skills/patent-disclosure-skill/examples/README.md @@ -0,0 +1,62 @@ +# 示例案件目录 + +本目录提供**可随仓库提交的演练材料**。 + +| 路径 | 说明 | +|------|------| +| `example_batch_job_scheduler/` | 虚构场景「分布式批任务调度与资源感知」;**仅含 `knowledge/` 原材料**,用于交底书主流程 Step 2 扫描演练 | +| `example_patent_reader/` | **专利通俗解读**示例:仓库只保留 [README.md](example_patent_reader/README.md) 中的 **CDN 镜像下载链接**;PDF 等材料本地自备(已 gitignore) | + +冒烟用极简 TXT 见 `tests/fixtures/patent_reader_sample.txt`。 + +## 如何使用 `example_patent_reader`(阅读模式) + +按 [example_patent_reader/README.md](example_patent_reader/README.md) 下载镜像 PDF 到 `source/` 后,再以本地路径触发解读;主示例 `CN119961390A`,另备 `CN119961396A` 可用于关联 / 同域对照测试。 + +## 如何使用 `example_batch_job_scheduler` 跑出效果 + +全流程产物(`patent_points.md`、`prior_art_notes.md`、`disclosure_preview.md`、带时间戳的交底书 `.md`/`.docx` 等)由技能在 **`outputs/{案件标识}/`** 生成;命名与版本规则见 **`disclosure_builder.md` §7.3**、**`iteration_context.md`**。 + +### 方式 A:只看原材料(不跑 Agent) + +打开 `example_batch_job_scheduler/knowledge/`,阅读 `docs/README.md`、`docs/architecture.md`、`pkg/scheduler/*.go` 等,理解**扫描输入长什么样**。 + +### 方式 B:在 Agent 里全流程演练(推荐) + +前提:已在 Cursor / Claude Code 等环境中加载本仓库技能(见仓库根目录 [INSTALL.md](../INSTALL.md))。技能入口与步骤见 [SKILL.md](../SKILL.md)。 + +1. **指定「项目」路径** + + `examples/example_batch_job_scheduler/knowledge/` + +2. **用自然语言触发技能并写明边界**(可复制改写后发给 Agent): + + ```text + 请按 patent-disclosure-skill 全流程执行: + - 项目扫描目录:examples/example_batch_job_scheduler/knowledge/ + - 技术主题:分布式批任务调度、异构集群、资源感知与限频重排队(可参考 knowledge 内文档与代码) + ``` + +3. **查新说明(重要)** + + 查新细则见 `prompts/prior_art_search.md`。演练时著录项可为练习占位,结构须符合该 prompt。 + +4. **验收「效果」** + + - 打开你指定的输出目录(如 `outputs/某练习目录/`,整目录不提交 Git),检查是否生成专利点、查新笔记、摘要预览、交底书(**Markdown + Word**,文件名含 **案件名 + 时间戳**)。 + - 定稿须经 `tools/mermaid_render.py`(需 Node.js、`tools` 下可选 `npm install`,以及 `pip install -r requirements.txt`)。Word 失败时按脚本 stderr 手动执行 `md_to_docx.py`。详见 `tools/README.md`。 + +5. **版本与迭代** + + 交付与迭代命名见 **`disclosure_builder.md` §7.3 第 5 点**;修订对话记录见 **`iteration_context.md`**。可选另存 `versions/` 非强制。 + +6. **迭代模式(按意图,无需固定关键词)** + + 与 [SKILL.md](../SKILL.md) 一致:只要用户明显是在**已有交底书**上**补充材料**或**纠错/改表述**,Agent 即应 **`Read`** `prompts/iteration_context.md` 与 `prompts/merger.md` 或 `prompts/correction_handler.md`。 + + 示例话术: + + ```text + 在现有交底书 outputs/.../一种XXX_时间戳.md 上: + - 合并附录里的新实施例(偏 merger);或修正 3.5 与正文公式不一致(偏 correction_handler) + ``` \ No newline at end of file diff --git a/.agents/skills/patent-disclosure-skill/examples/example_batch_job_scheduler/README.md b/.agents/skills/patent-disclosure-skill/examples/example_batch_job_scheduler/README.md new file mode 100644 index 0000000..e0d562a --- /dev/null +++ b/.agents/skills/patent-disclosure-skill/examples/example_batch_job_scheduler/README.md @@ -0,0 +1,9 @@ +# 示例案件:分布式批任务调度与资源感知 + +本目录为**虚构教学案件**,仅保留 **`knowledge/`** 作为 **Step 2 扫描原材料**(设计文档、示例 Office、代码片段)。专利点、查新笔记、交底书等产出请在 Agent 全流程中生成到 **`outputs/{案件标识}/`**(见仓库 [examples/README.md](../README.md))。 + +## 目录说明 + +| 路径 | 说明 | +|------|------| +| `knowledge/` | **虚构「项目原材料」**:架构说明、示例 Word/PPT(含图)、调度示例代码,供 Step 2 与 Office→MD 演练 | diff --git a/.agents/skills/patent-disclosure-skill/examples/example_batch_job_scheduler/knowledge/docs/README.md b/.agents/skills/patent-disclosure-skill/examples/example_batch_job_scheduler/knowledge/docs/README.md new file mode 100644 index 0000000..8d0b375 --- /dev/null +++ b/.agents/skills/patent-disclosure-skill/examples/example_batch_job_scheduler/knowledge/docs/README.md @@ -0,0 +1,20 @@ +# docs / 设计说明与示例 Office 附件 + +## Markdown + +| 文件 | 说明 | +|------|------| +| `architecture.md` | 批任务调度架构文字说明(与交底书一致) | + +## 示例 Word / PPT(含嵌入图) + +用于演练 **`tools/docx_to_md.py`**、**`tools/pptx_to_md.py`**;与 `architecture.md` 口径一致,均为虚构示例。 + +**Step 2 扫描约定**(见 `prompts/project_scan.md`):Agent **须**先将下表 `.docx`/`.pptx` **转为 `.md` 再 Read**,不可只扫 `architecture.md` 而忽略 Office;**勿**对 `sample_assets/*.png` 单独做识图(与内嵌图重复,以转换后的 Markdown 为准)。 + +| 文件 | 说明 | +|------|------| +| `sample_architecture_review.docx` | 虚构纪要 Word,内嵌 2 张 PNG → 转换后读 `.md` + `_media/` | +| `sample_scheduler_deck.pptx` | 虚构评审 PPT,多页含嵌入图 → 同上 | +| `sample_assets/sample_fig_modules.png` | 模块关系示意(与 Word/PPT 内嵌图一致;**跳过单独扫描**) | +| `sample_assets/sample_fig_queue.png` | 队列与节点示意(**跳过单独扫描**) | diff --git a/.agents/skills/patent-disclosure-skill/examples/example_batch_job_scheduler/knowledge/docs/architecture.md b/.agents/skills/patent-disclosure-skill/examples/example_batch_job_scheduler/knowledge/docs/architecture.md new file mode 100644 index 0000000..deee9f6 --- /dev/null +++ b/.agents/skills/patent-disclosure-skill/examples/example_batch_job_scheduler/knowledge/docs/architecture.md @@ -0,0 +1,41 @@ +# 数据处理平台 — 批任务调度架构(虚构示例) + +> **声明**:本文档仅供本仓库专利技能示例使用,不对应任何真实产品。 + +## 1. 总体结构 + +- **调度器(Scheduler)**:维护全局待执行任务队列,按配置优先级入队;周期性向工作节点派发任务。 +- **工作节点(Worker)**:执行具体批任务,上报 CPU/内存/IO 等可观测指标。 +- **配置中心**:任务优先级、资源阈值、亲和性标签等静态规则。 + +## 2. 当前痛点(业务侧描述) + +- 集群节点**异构**:部分节点算力强、本地 SSD 大,部分节点 IO 带宽高但 CPU 一般。 +- 现象:**大任务**长期占据高性能节点,**小任务**在低配节点排队时间过长;仅靠静态优先级无法反映节点**实时**可用资源。 +- 配置侧已有优先级与简单负载阈值,但**未**将「任务需求特征」与「节点多维资源画像」联合用于二次排序。 + +## 3. 目标(与 intake 一致) + +- 降低长尾任务「饿死」概率。 +- 可接受**分钟级**调度延迟;避免秒级频繁重排导致震荡。 + +## 4. 模块划分(与交底书框图对应) + +| 模块 | 职责 | +|------|------| +| 资源画像聚合 | 汇总各节点指标,滑动窗口平滑 | +| 匹配打分 | 任务需求向量 × 节点画像 → 匹配分数 | +| 限频重排队 | 满足间隔与分数变化阈值时,对队列窗口子集重排 | +| 派发 | 按序下发,失败重试(略) | +| 心跳契约 | 节点轻量心跳,用于假死检测与迁移触发(从属特征) | + +## 5. 相关代码路径(示例仓库布局) + +- `pkg/scheduler/types.go`:任务需求向量、节点画像结构 +- `pkg/scheduler/scorer.go`:匹配打分 +- `pkg/scheduler/reorder.go`:限频重排队触发与窗口重排 +- `pkg/scheduler/heartbeat.go`:心跳与假死判定(示例) + +## 6. 同目录 Office 示例(可选扫描) + +- `sample_architecture_review.docx`、`sample_scheduler_deck.pptx`:与本节口径一致的**虚构** Word/PPT,内嵌示意 PNG,用于 `docx_to_md` / `pptx_to_md` 演练;文件说明见同目录 `README.md`。 diff --git a/.agents/skills/patent-disclosure-skill/examples/example_batch_job_scheduler/knowledge/docs/sample_architecture_review.docx b/.agents/skills/patent-disclosure-skill/examples/example_batch_job_scheduler/knowledge/docs/sample_architecture_review.docx new file mode 100644 index 0000000..3caf86e Binary files /dev/null and b/.agents/skills/patent-disclosure-skill/examples/example_batch_job_scheduler/knowledge/docs/sample_architecture_review.docx differ diff --git a/.agents/skills/patent-disclosure-skill/examples/example_batch_job_scheduler/knowledge/docs/sample_assets/sample_fig_modules.png b/.agents/skills/patent-disclosure-skill/examples/example_batch_job_scheduler/knowledge/docs/sample_assets/sample_fig_modules.png new file mode 100644 index 0000000..3a3d9f6 Binary files /dev/null and b/.agents/skills/patent-disclosure-skill/examples/example_batch_job_scheduler/knowledge/docs/sample_assets/sample_fig_modules.png differ diff --git a/.agents/skills/patent-disclosure-skill/examples/example_batch_job_scheduler/knowledge/docs/sample_assets/sample_fig_queue.png b/.agents/skills/patent-disclosure-skill/examples/example_batch_job_scheduler/knowledge/docs/sample_assets/sample_fig_queue.png new file mode 100644 index 0000000..61b2f14 Binary files /dev/null and b/.agents/skills/patent-disclosure-skill/examples/example_batch_job_scheduler/knowledge/docs/sample_assets/sample_fig_queue.png differ diff --git a/.agents/skills/patent-disclosure-skill/examples/example_batch_job_scheduler/knowledge/docs/sample_scheduler_deck.pptx b/.agents/skills/patent-disclosure-skill/examples/example_batch_job_scheduler/knowledge/docs/sample_scheduler_deck.pptx new file mode 100644 index 0000000..3ceca00 Binary files /dev/null and b/.agents/skills/patent-disclosure-skill/examples/example_batch_job_scheduler/knowledge/docs/sample_scheduler_deck.pptx differ diff --git a/.agents/skills/patent-disclosure-skill/examples/example_batch_job_scheduler/knowledge/meeting_notes_intake_example.md b/.agents/skills/patent-disclosure-skill/examples/example_batch_job_scheduler/knowledge/meeting_notes_intake_example.md new file mode 100644 index 0000000..b819afe --- /dev/null +++ b/.agents/skills/patent-disclosure-skill/examples/example_batch_job_scheduler/knowledge/meeting_notes_intake_example.md @@ -0,0 +1,20 @@ +# 立项沟通纪要(虚构示例) + +**日期**:2026-03-15 +**主题**:批调度长尾与资源错配 + +## 背景 + +- 现网全局优先级队列 + 异构节点,运维反馈小任务 P99 等待偏长。 +- 已有「超阈值迁移」,但多在事后触发,希望能在派发前多做一层匹配。 + +## 决策与边界 + +- **目标**:优先缓解饥饿与错配,不追求秒级最优。 +- **可接受延迟**:调度决策可放宽到分钟级刷新窗口。 +- **不做**:本次不讨论具体云厂商 API、不绑定某一存储产品。 + +## 待设计输出 + +- 架构补充:资源画像、匹配分、限频重排队流程。 +- 后续:专利侧单独拉专利点清单与查新(见案件目录产物)。 diff --git a/.agents/skills/patent-disclosure-skill/examples/example_batch_job_scheduler/knowledge/pkg/scheduler/heartbeat.go b/.agents/skills/patent-disclosure-skill/examples/example_batch_job_scheduler/knowledge/pkg/scheduler/heartbeat.go new file mode 100644 index 0000000..a68a887 --- /dev/null +++ b/.agents/skills/patent-disclosure-skill/examples/example_batch_job_scheduler/knowledge/pkg/scheduler/heartbeat.go @@ -0,0 +1,20 @@ +package scheduler + +import "time" + +// HeartbeatConfig 节点心跳契约参数(从属特征 P3)。 +type HeartbeatConfig struct { + Interval time.Duration + DeadAfter time.Duration // 超过该时间未收到心跳则判定假死 +} + +// NodeHeartbeat 最近一次心跳时间(示例结构)。 +type NodeHeartbeat struct { + NodeID string + LastSeen time.Time +} + +// IsProbablyDead 判定节点是否假死(示意)。 +func IsProbablyDead(h NodeHeartbeat, now time.Time, cfg HeartbeatConfig) bool { + return now.Sub(h.LastSeen) > cfg.DeadAfter +} diff --git a/.agents/skills/patent-disclosure-skill/examples/example_batch_job_scheduler/knowledge/pkg/scheduler/reorder.go b/.agents/skills/patent-disclosure-skill/examples/example_batch_job_scheduler/knowledge/pkg/scheduler/reorder.go new file mode 100644 index 0000000..93342ec --- /dev/null +++ b/.agents/skills/patent-disclosure-skill/examples/example_batch_job_scheduler/knowledge/pkg/scheduler/reorder.go @@ -0,0 +1,46 @@ +package scheduler + +import "time" + +// ReorderPolicy 限频重排队策略参数(与交底书参数表对应)。 +type ReorderPolicy struct { + MinInterval time.Duration // T_r + ScoreDelta float64 // Δs:分数分布变化阈值(示意) + WindowSize int // W +} + +// reorderState 调度器内部状态(示例)。 +type reorderState struct { + lastReorder time.Time + lastScoreSig float64 // 上次用于比较的分数分布签名(简化标量) +} + +// ShouldReorder 是否满足限频重排条件(示例逻辑:间隔 + 变化阈值)。 +func ShouldReorder(now time.Time, p ReorderPolicy, st *reorderState, currentSig float64) bool { + if st.lastReorder.IsZero() { + return true + } + if now.Sub(st.lastReorder) < p.MinInterval { + return false + } + delta := absFloat(currentSig - st.lastScoreSig) + return delta >= p.ScoreDelta +} + +func absFloat(x float64) float64 { + if x < 0 { + return -x + } + return x +} + +// ApplyReorder 对队列窗口内任务按匹配分重排(占位:真实实现需结合队首候选与节点集合)。 +func ApplyReorder(tasks []TaskDemand, _ []NodeProfile, window int) []TaskDemand { + if len(tasks) == 0 || window <= 0 { + return tasks + } + out := make([]TaskDemand, len(tasks)) + copy(out, tasks) + // 虚构示例:应对前 min(window, len) 个任务按 Score 重排;此处保持原序,仅保留 API 形态供扫描引用。 + return out +} diff --git a/.agents/skills/patent-disclosure-skill/examples/example_batch_job_scheduler/knowledge/pkg/scheduler/scorer.go b/.agents/skills/patent-disclosure-skill/examples/example_batch_job_scheduler/knowledge/pkg/scheduler/scorer.go new file mode 100644 index 0000000..09cebfc --- /dev/null +++ b/.agents/skills/patent-disclosure-skill/examples/example_batch_job_scheduler/knowledge/pkg/scheduler/scorer.go @@ -0,0 +1,15 @@ +package scheduler + +import "math" + +// Score 根据需求向量与节点画像计算匹配分(简化线性加权 + 过载惩罚,示例实现)。 +func Score(d TaskDemand, p NodeProfile) float64 { + if p.CPUAvail <= 0 || p.MemFreeMB <= 0 { + return -1 + } + cpuFit := d.CPUBudget * p.CPUAvail + memFit := math.Min(1.0, p.MemFreeMB/math.Max(1, d.MemPeakMB)) + ioFit := (1.0 - p.IOBusy) * d.IOSensitive + inflightPenalty := float64(p.Inflight) * 0.05 + return cpuFit + memFit + ioFit - inflightPenalty +} diff --git a/.agents/skills/patent-disclosure-skill/examples/example_batch_job_scheduler/knowledge/pkg/scheduler/types.go b/.agents/skills/patent-disclosure-skill/examples/example_batch_job_scheduler/knowledge/pkg/scheduler/types.go new file mode 100644 index 0000000..7780fcf --- /dev/null +++ b/.agents/skills/patent-disclosure-skill/examples/example_batch_job_scheduler/knowledge/pkg/scheduler/types.go @@ -0,0 +1,31 @@ +// Package scheduler 虚构示例:批任务调度领域类型定义(教学用,非生产代码)。 +package scheduler + +import "time" + +// TaskDemand 任务需求向量(示意字段,可随业务扩展)。 +type TaskDemand struct { + ID string + CPUBudget float64 // 相对 CPU 需求权重 + MemPeakMB float64 + IOSensitive float64 // IO 敏感度 0~1 + MaxWait time.Duration + Priority int // 业务静态优先级 +} + +// NodeProfile 节点多维资源画像(示意)。 +type NodeProfile struct { + NodeID string + CPUAvail float64 // 可用比例 0~1 + MemFreeMB float64 + IOBusy float64 // IO 饱和度 0~1 + Inflight int // 在途任务数 + UpdatedAt time.Time +} + +// MatchScore 任务在某节点上的匹配分(越大越适合)。 +type MatchScore struct { + TaskID string + NodeID string + Score float64 +} diff --git a/.agents/skills/patent-disclosure-skill/examples/example_patent_reader/README.md b/.agents/skills/patent-disclosure-skill/examples/example_patent_reader/README.md new file mode 100644 index 0000000..3634854 --- /dev/null +++ b/.agents/skills/patent-disclosure-skill/examples/example_patent_reader/README.md @@ -0,0 +1,34 @@ +# 专利通俗解读 · 示例 PDF(镜像下载) + +本目录材料**不入库**,请自行下载到 `source/` 后做解读 / 关联测试。 + +> **说明**:下列示例专利**仅用于本技能的功能测试与效果演示**,不代表技术优劣、权利状态或商业立场,亦不构成任何推荐或评价。 + +## 下载方式(推荐) + +通用入口(技能已固化,勿另写脚本): + +```bash +python tools/patent_reader/fetch_patent_pdf.py --pub CN119961396A -o examples/example_patent_reader +# → examples/example_patent_reader/source/CN119961396A.pdf +``` + +源优先级与备选说明:`references/patent_pdf_sources.yaml`。 + +## Google Patents CDN(已知镜像直链 · 与 yaml 同步) + +| 公开号 | 用途 | PDF 镜像 | +|--------|------|----------| +| `CN119961390A` | 主示例(政法领域语言大模型问答;软件/RAG 类解读) | https://patentimages.storage.googleapis.com/58/1b/9b/07a9f35635df34/CN119961390A.pdf | +| `CN119961396A` | 近似专利(税务 AI 智能体;关联测试 / 同域对照) | https://patentimages.storage.googleapis.com/3f/29/d0/a2461c5080d73d/CN119961396A.pdf | +| `CN114552122A` | **按图裁切解读**:文字层附图页含可选中「图1」「图2」,适合 caption+bbox 裁切后写入 Obsidian | https://patentimages.storage.googleapis.com/c2/6c/51/75412585086edf/CN114552122A.pdf | + +建议本地文件名: + +- `source/CN119961390A.pdf` +- `source/CN119961396A.pdf` +- `source/CN114552122A.pdf` + +### 按图裁切(CN114552122A) + +提供该 PDF 做通俗解读时,技能会按 `patent_plain_reader.md` **自动**跑附图抽取:有可选中图注则按图号裁切写入「特征—附图对照」;扫描件则回退整页预览。无需手动执行抽取脚本。 diff --git a/.agents/skills/patent-disclosure-skill/prompts/correction_handler.md b/.agents/skills/patent-disclosure-skill/prompts/correction_handler.md new file mode 100644 index 0000000..fe02c97 --- /dev/null +++ b/.agents/skills/patent-disclosure-skill/prompts/correction_handler.md @@ -0,0 +1,32 @@ +# 迭代模式:对话纠正 + +## 执行门禁 + +与 `merger.md` 相同:先 **`Read`** **`prompts/iteration_context.md`**,再 **Read** 当前定稿与纠正意图;纠正结果须**另存为** **`{案件名}_{YYYYMMDDHHmmss}.md`** 及同名 `.docx`(§7.3 第 5 点),**禁止**默认覆盖旧稿。**禁止**在迭代意图下无合并/纠正落盘却回到 Step 3–4 全文专利点分析(除非用户明确要求重做专利点)。 + +## 何时启用 + +由 Agent 根据用户**意图**判断:用户针对**已有交底书**指出**错误、与事实或参数不符、表述问题、保护点调整**等(例如「这里不对」「和 3.5 不一致」「保护点应强调 XXX」)时,**应**执行本流程。**不要求**用户说出「迭代」等固定词;也**不必**先询问是否进入迭代模式。 + +## 步骤 + +1. **提取纠正点**:具体章节、原问题、用户期望,可摘要用户原话。 +2. **分类**: + - **事实与技术**:流程、参数、模块关系 → 改第三章及相关实施例、3.5。 + - **符号与公式体例**:上标维度(如 `^{cpu}`)、符号多义、LaTeX 分隔符混用、3.5 与 3.4.1 不同形 → 改 **3.4.1 符号表**、相关公式、**3.5 符号列**及第六章实施例;遵循 **`disclosure_builder.md` §7.7** 与 **`template_reference.md` §3.4.1** 正/反例。 + - **查新与区别**:现有技术或区别论述不准 → 改第一章,必要时再检索。 + - **保护点与表述**:第四章、第五章论点 → 与第三章对齐,避免矛盾。 +3. **落地修改**:形成纠正后全文,**写入新文件** **`{案件名}_{YYYYMMDDHHmmss}.md`** 并经 `mermaid_render.py` 生成同名 `.docx`(§7.3 第 5 点);**禁止**无必要大段重写无关章节;**勿默认覆盖**用户上一版文件名。 +4. **自检**:执行 `disclosure_self_check.md` 的 **8.2、8.3**。 +5. **对话记录**:按 **`iteration_context.md`**「修订对话记录」在案件目录追加 **`交底书修订对话记录.md`**(优先 **`tools/iteration_dialog_log.py --kind correct`**)。 + +## 输出(强制,不可省略) + +在交付修改后的正文(或说明已写入路径)之后,**必须在同一条回复中**追加独立小节,**标题固定为**: + +## 纠正摘要(留档) + +其下用 **2–5 句完整中文**,说明:**修改位置**、**依据**、**是否影响保护点或检索**。 +若未输出本节,视为未完成本 prompt。 + +**定稿延续**:若本轮纠正结果作为**向用户交付的定稿**,在**同一条回复**中于上文之后,**还须**按 **`disclosure_builder.md` §7.6** 补充「权利要求偏向点」建议交互(可 1~2 句缩写版),**不得**写入 `.md`/`.docx` 正文;**禁止**编造与定稿不符的「偏向」选项(见 §7.6 第 3 点)。 diff --git a/.agents/skills/patent-disclosure-skill/prompts/disclosure_builder.md b/.agents/skills/patent-disclosure-skill/prompts/disclosure_builder.md new file mode 100644 index 0000000..872aa58 --- /dev/null +++ b/.agents/skills/patent-disclosure-skill/prompts/disclosure_builder.md @@ -0,0 +1,147 @@ +# 技术交底书生成模板(Step 7) + +详细章节范例与 **mermaid** 图示模版见同目录 **`template_reference.md`**。 + +## 7.1 章节结构 + +``` +1. 注意事项 +2. 一、介绍相关技术背景,描述与本发明技术最相近的现有技术,并说明该现有技术存在的缺点 + - 1.1 现有技术(按技术方向分类,含专利检索结果) + - 1.2 现有技术存在的缺点 +3. 二、针对上述缺点,说明本发明所要解决的技术问题 +4. 三、本发明技术方案的详细阐述 + - 3.1 背景 + - 3.2 系统框图(**mermaid**,如 `flowchart` + `subgraph` 分层;交付前经工具转 PNG;**不要** ASCII 文字框图) + - 3.3 模块功能说明(聚焦作用与关联关系,非输入输出) + - 3.4 系统流程说明(**mermaid 流程图**,交付前经工具转 PNG;**不要** ASCII 文字/箭头流程图) + - 3.4.1 符号与公式(出现公式或形式化变量时**须设**;**须**先符号与变量定义、再写公式;体例见 **§7.7** 与 **`template_reference.md` §3.4.1**) + - 3.5 关键技术参数(参数表「符号」列与 3.4.1 **同形**) +5. 四、与现有技术相比,本发明具有哪些优点? +6. 五、本发明的技术关键点和欲保护点是什么? +7. 六、其它(实施例、技术效果、参数示例) +``` + +**第一章 1.1 撰写硬性要求**:在「按技术方向分类」的**每一条**现有技术(专利或文献)末尾或表格中,必须给出 **经核验的公开源 URL**(规范与示例见 `prior_art_search.md` 与 `template_reference.md` §1.1)。**国知局检索**(`EPUB_HITS_JSON`)中对该专利给出的 **`abstract` 非空时**:文中「技术方案 / 应用 / 局限」类表述**须以摘要理解为前提**(消化后重写,非整段粘贴),**禁止**与摘要明显矛盾或脱离摘要杜撰;详见 **`prior_art_search.md`**「`abstract` 必用」。 + +**禁止输出**:交底书正文中**不得**包含「自检清单」章节,例如检查项表格或带状态符号的清单列。自检见 `disclosure_self_check.md`,**内部执行**,不写入交付正文。 + +**禁止仓库/技能脚注**:交付用 Markdown **末尾不得**出现任何指向本技能或示例仓库的说明,例如「本文件为 `patent-disclosure-skill` 仓库内教学示例」「不构成任何法律或技术承诺」「虚构教学」「详见 `examples/`」等。正文**止于第六章及此前章节**;**不要**在文末追加斜体免责或仓库署名。 + +## 7.2 文档头部模板 + +```markdown +# 技术交底书 + +**案件名称**:[待填写]一种XXX方法及系统 + +**技术联系人**: +- 姓名:[待填写] +- 电话:[待填写] +- 邮箱:[待填写] + +**专利类型**:发明 + +--- + +## 注意事项 + +(1)交底书应使代理人能看懂,尤其是背景技术和详细技术方案,一定要写得全面、清楚、完整; +(2)技术的公开程度,应以本领域普通技术人员不需付出创造性劳动即可进行实施为准。 +(3)在与代理人沟通时,对于代理人咨询的技术问题,应给予回答并认真讲解,并且按要求及时正确地补充相应技术材料。 +``` + +## 7.3 输出文件命名(用户产出目录,必遵循) + +在用户目录(如 `outputs/{案件标识}/`)写入交底书 **Markdown / Word** 时,**主文件名须体现正文中的「案件名称」**,且**凡向用户落盘交付的定稿均须带时间戳后缀**(见下第 5 点),避免无区分度的固定名(如一律 `disclosure_draft.md`)。**仓库内 `examples/` 为教学统一可仍用固定名**;用户产出不适用该惯例。 + +1. **提取**:从 `**案件名称**:` 行取完整发明名称;去掉占位(如 `[待填写]`、`【待填写】`)、首尾空格。 +2. **规范化**:删除或替换 Windows 非法字符 `\ / : * ? " < > |` 与换行;连续空格可压为单个空格或删去(中文标题通常无空格亦可)。 +3. **长度**:文件名(不含扩展名)建议 **≤ 80 个字符**;超长则**截断**到约 80 字并保留语义完整(例如截在「方法及系统」等结尾词之前),勿用无意义随机串。 +4. **定稿命令**:`mermaid_render.py` 的 `-o` / `--docx` 须使用**同一主文件名**,且该主名**必须符合第 5 点**(**案件名 + `_` + 14 位时间戳**)。示例(案件名已按上文规范化、过长已截断时): + + `python3 tools/mermaid_render.py -i "…草稿.md" -o "一种异构计算环境下基于资源画像与限频重排队的批任务调度方法及系统_20260408143025.md"` + + 默认同目录生成同名 `.docx`。过程性中间稿(仅自用、非交付)文件名可自定,但**最终交付**不得省略时间戳。 + +5. **时间戳后缀(凡落盘交付必遵循)**:凡写入用户产出目录、作为**向用户交付**的技术交底书 **`.md` / `.docx`**,主文件名须为: + **`{§7.3 规范化案件名}_{YYYYMMDDHHmmss}.md`** 与同名 **`.docx`**。 + - **首次定稿**与**迭代合并/纠正**适用**同一规则**;每次交付取**当次落盘时**的**本地时间** 14 位数字(年月日时分秒各 2 位,如 `20260408143025`)。 + - **不要覆盖**已有交付文件;新一次交付即**新时间戳**、新文件名。用户**明确要求**覆盖某路径时从其意。 + - **`mermaid_render.py`** 的 `-o` 与可选 `--docx` 须与上述主名一致。 + - **例外**:(1)仓库内 **`examples/`** 示例可继续固定文件名;(2)用户书面指定文件名时从其意(仍建议保留时间戳以免混淆)。 + +Agent **落盘时**即采用上述命名,并在回复中写明路径,便于用户对照标题与磁盘文件。 + +## 7.4 系统框图与流程图要点 + +- **系统框图与流程图**均仅用 **fenced mermaid**(本地 `mmdc` 渲染,不依赖外网 PlantUML);Word 中以 PNG 为准,**无需**再附 ASCII 文字框图 +- mermaid 内节点标签用简短中文/数字,避免 ①②③。可另写简短「流程说明」段落概括各步,**不得**用 ASCII 框线箭头代替图示 + +## 7.5 脱敏要求 + +| 类型 | 脱敏方式 | 示例 | +|------|----------|------| +| 业务/行业 | 抽象为通用描述 | 「XX检测」→「多标签分类场景」 | +| 具体分类 | 用 ABC 等替代 | 「类别1、类别2」→「分类A、分类B、分类C」 | +| 具体数值 | 用范围或示例说明 | 「每日 N 人」→「每日一定规模」 | +| 公司/产品 | 不出现具体名称 | 删除或替换为「某系统」 | + +## 7.7 符号与公式体例(必遵) + +撰写 **3.4.1**、**3.5** 及全文含 LaTeX 的段落时须遵循;正/反例与符号表示例见 **`template_reference.md` §3.4.1**。 + +### 符号表先行 + +- 撰写 **3.4.1** 或全文含 LaTeX 时,**先写符号与变量定义**(可按(1)任务/对象下标、(2)节点/环境下标、(3)标量与向量分组),每项至少含:**符号、含义、下标含义(如 \(i\)=任务、\(j\)=节点)、量纲或取值范围**。 +- 其后公式、**3.5 参数表**、**第六章实施例**中出现的符号须与符号表 **同形、同义**。 +- **禁止同一字母多义**:例如任务侧权重用 \(b\),节点侧饱和度应改用 \(g\)、\(h\) 等其它字母,勿任务/节点共用 \(b\) 表示不同物理量。 + +### 下标与上标 + +- **资源维度/类型标签**(cpu、mem、io、peak 等)一律写入 **下标**,维度名用 `\mathrm{cpu}`、`\mathrm{mem}` 等正体,例如 `b_{i,\mathrm{cpu}}`、`a_{j,\mathrm{mem}}`。 +- **禁止**用 `^{cpu}`、`^{mem}`、`^{io}` 等 **上标** 表示维度或字段名(易被读作幂次,亦不符合专利正文常见写法)。 +- **上标仅用于**:幂次、转置、序号、撇号类标记;**下标用于**对象编号与维度标签。 + +### LaTeX 分隔与写法(全文统一) + +- **行内公式**:全文统一 **`\(...\)`** 或 **`$...$`** 二选一,**不得混用**。 +- **块级公式**:全文统一 **`\[...\]`** 或 **`$$...$$`** 二选一,**不得混用**。 +- 比较符优先写 `\leq`、`\geq`(定稿工具可兼容 `\le`/`\ge`,正文仍推荐 `\leq`/`\geq`)。 +- 块级公式尽量 **单行写完**;需编号时用 `\tag{1}` 或正文写「式 (1)」,**全文择一**并保持体例一致。 +- 逻辑连接词(「且」「或」)优先写在公式 **外** 的中文叙述中;若必须写入公式内,用 `\land`/`\lor`,**避免** `\text{且}` 等复杂文本命令(定稿渲染易失败)。 + +### 跨节一致 + +- **3.4.1 符号表**、正文首次定义式、**3.5「符号」列**、**第六章实施例** 四处须 **逐字同形**。 +- 修改任一处符号时,须同步核对上述各处及 3.3/3.4 文字叙述中的同名变量。 + +--- + +## 定稿交付:Markdown + Word(必做) + +**版本与覆盖**:凡交付均以 **§7.3 第 5 点**「**案件名 + 时间戳**」落盘,同目录下多文件即版本历史。**可选**:另存 `versions/` 手工快照非强制。 + +须**同时**交付: + +1. **Markdown**:定稿 `.md` **保留** `` ```mermaid`` 围栏源码,并含 ```` 注释引用(由 `mermaid_render.py` 生成)。 +2. **Word**:对上一文件执行 `md_to_docx.py`,或使用一条命令: + +`python3 tools/mermaid_render.py -i <含图示的草稿.md> -o "<案件名_YYYYMMDDHHmmss>.md"`(默认在同目录生成**同名** `.docx`;可用 `--docx` 指定路径,`--no-docx` 跳过 Word。Word 失败时见终端提示的手动命令。) + +(mermaid 依赖 Node/mmdc、`pip install -r requirements.txt` 见 `tools/README.md`。) + +## 7.6 交付回复:权利要求偏向点(建议交互,必做) + +每次向用户**交付**本技能产出的定稿(已写明 **`{案件名}_{YYYYMMDDHHmmss}.md` / `.docx` 路径**)时,在**同一条对话回复**中**追加**一段**仅供用户选用**的交互引导(**不得**写入交底书正文、不得出现在 `.md`/`.docx` 内)。 + +**须交代清楚:** + +1. 用户若希望对**第五章「技术关键点和欲保护点」**做更贴近**权利要求书撰写习惯**的强化,可**用一句话说明侧重点**。 +2. 承接方式:Agent **`Read`** **`iteration_context.md`**,再按 **`merger.md`** 以**当前交付稿为基准**合并,**另存**新时间戳 **`.md` / `.docx`**(§7.3 第 5 点),并维护 **`交底书修订对话记录.md`**。 +3. **禁止捏造偏向**:对话里提出的「可对举的两类侧重点」**必须**能从**当前定稿与上游已用材料**中推出——包括 Step 2 扫描文档、Step 3–4 已整理专利点、**第三至五章已写明的技术方案与保护点表述**;**不得**为了凑交互而编造本案未涉及的场景、模块或行业词。若全文仅有一条清晰保护主线,**只须忠实概括该主线**并询问是否改为更「方法/系统/流程步骤」或更「装置/模块」等**书式侧重**(仍须对应文中已有结构,不新增技术事实)。 +4. 在满足上条前提下,可给出**两组可对举的偏向**(用语须**摘编或概括**自正文已有概念,而非套用泛例);句式可参考: + +> 若您希望权利要求/保护点表述更偏「……」或更偏「……」(**二者均须与本稿已阐述的技术路线或保护点一致,仅为书式或强调重心之择**),请说明侧重点;我可按 **`iteration_context.md`** 与 **`merger.md`** 另存一版,对**第五章做权利要求书式强化**(无新材料时其它章节以衔接一致为前提,尽量保持既有结论)。 + +**合并 / 纠正迭代**若再次交付定稿,**仍须**附带本节同类引导(可缩短为 1~2 句,但须保留「第五章」「新时间戳」「iteration_context + merger」三要素之一或等效说明,且**仍遵守「不捏造」**)。 diff --git a/.agents/skills/patent-disclosure-skill/prompts/disclosure_preview.md b/.agents/skills/patent-disclosure-skill/prompts/disclosure_preview.md new file mode 100644 index 0000000..abed60a --- /dev/null +++ b/.agents/skills/patent-disclosure-skill/prompts/disclosure_preview.md @@ -0,0 +1,24 @@ +# 交底书生成前摘要预览(Step 6) + +## 目的 + +在输出完整长文前降低返工:先展示结构化摘要,请用户 **确认 / 调整方向**。 + +若用户明确要求跳过预览,可直接进入 `disclosure_builder.md` 生成全文。 + +## 摘要应包含 + +- 选定专利点名称(工作标题) +- 解决的技术问题:对应现有技术缺点,1–3 条 +- 核心创新模块或步骤(3–6 条) +- 与检索到的**最相近**现有技术的区别(1 段) + +## 可选确认语 + +``` +以上方向是否确认?确认后将按模版输出完整技术交底书,含 mermaid 系统框图与流程图(定稿时将转为 PNG 并同时交付 .md 与 .docx)。 +``` + +## 禁止写入 + +摘要 Markdown **末尾不得**追加技能仓库名、`examples/` 路径、「教学示例」「虚构」「不构成法律承诺」等脚注(与 `disclosure_builder.md` 对定稿的要求一致)。 diff --git a/.agents/skills/patent-disclosure-skill/prompts/disclosure_self_check.md b/.agents/skills/patent-disclosure-skill/prompts/disclosure_self_check.md new file mode 100644 index 0000000..990839e --- /dev/null +++ b/.agents/skills/patent-disclosure-skill/prompts/disclosure_self_check.md @@ -0,0 +1,53 @@ +# 交底书生成后自检(Step 8,内部执行) + +自检结果用于**修订正文**,默认**不单独输出自检报告**;用户索要时可单独提供。**不得**将自检清单作为交底书一章写入正文。 + +## 8.1 逻辑与闭环 + +- [ ] 技术方案是否形成完整闭环(发现→处理→扩展/输出)? +- [ ] 模块之间关联关系是否清晰?上下游是否衔接? +- [ ] 分支样本(如边界样本、低置信度样本等)是否有明确处理路径? +- [ ] 依赖项(如类别中心、动态阈值等)是否有来源说明? +- [ ] 第五章「技术关键点」与第三章「系统流程」是否呼应?第五章以论点概述为主,技术细节在第三章。 + +## 8.2 公式与参数一致性 + +**Step 8 必做(含公式时)**:除符号体例与跨节一致外,须**主动复核公式是否正确、公式逻辑是否与第三章叙述一致**(等同用户会提出的「检查公式和公式逻辑是否有误,有误请调整」);发现问题**直接改稿**,勿仅提示用户自行修改。 + +### 符号与体例 + +- [ ] **符号表(3.4.1)**:全文含公式时是否已设 **3.4.1** 并 **先定义符号**(含义、下标、量纲);式 (1) 及后文每个符号是否均已在表中定义? +- [ ] **维度下标**:是否存在 `^{cpu}`、`^{mem}`、`^{io}` 等 **上标表示维度** 的写法?若有须改为 `b_{i,\mathrm{cpu}}`、`a_{j,\mathrm{mem}}` 等 **下标 + `\mathrm{}`** 形式(见 **`disclosure_builder.md` §7.7**) +- [ ] **字母多义**:同一字母是否兼指任务侧与节点侧等不同对象?若有须拆分符号(如任务用 \(b\)、节点用 \(g\)) +- [ ] **公式表述一致**:同一物理量在不同公式、段落中是否 **同形同义**(如权重公式、调整系数、匹配分 \(M_{ij}\) 等) +- [ ] **LaTeX 体例**:行内/块级分隔符是否全文统一(`\(...\)`/`\[...\]` 或 `$`/`$$` 二选一);块级公式是否尽量单行;是否避免 `\text{且}` 等易渲染失败写法 +- [ ] **3.5 与符号表**:3.5 参数表「符号」列是否与 3.4.1 **逐字同形** +- [ ] **阈值范围**:与正文一致(如 0.5–1.5、0.8–1.2 等) +- [ ] **参数命名**:全文统一,避免同义不同名混用 +- [ ] **实施例数值**:与 3.5 关键技术参数对应,不冲突 + +### 公式正确性与逻辑(必核) + +- [ ] **式面正确性**:各式是否无明显笔误(运算符、括号配对、上下标错位、同一式内符号与符号表不符、缺项/多写项、指数或分母写错等)? +- [ ] **约束与不等式方向**:公式中的 `\leq`/`\geq`/正负号是否与文字含义一致(如「饱和度」「有余量」「限频间隔」等);多条件「且/或」是否与 3.4 流程分支一致? +- [ ] **公式与流程互推**:3.4.1 主公式(含式 (1))及衍生式,能否与 **3.4 流程说明**(各步骤 S1—Sn)、**3.3 模块职责** 逐步对应?叙述中的打分、重排、限频、窗口聚合等是否在公式中有体现,**无**「文字一套、公式另一套」? +- [ ] **边界与特殊情形**:空队列、单节点、分母为零、指标缺失、达到上下界等情形,公式或正文是否给出合理处理或说明(至少不与主公式矛盾)? +- [ ] **量纲与取值**:符号表声明的量纲/取值范围是否与公式用法一致(如权重为正、比例在 0—1、计数为非负整数等)? +- [ ] **修订联动**:若修正公式或逻辑,是否已同步改 **3.4 文字**、**3.5 参数**、**第六章实施例** 及 **3.4.1 符号表**(见 §7.7 跨节一致)? + +## 8.3 格式与引用 + +- [ ] **迭代路径**:若本次走 `merger.md` / `correction_handler.md`,对话中是否已含 **`## 合并摘要(留档)`** 或 **`## 纠正摘要(留档)`**(按该 prompt 字数要求) +- [ ] **修订对话记录**:案件目录是否已追加 **`交底书修订对话记录.md`** 一条(含记录时间、用户说明摘要、本轮交付文件名、摘要摘录),见 **`iteration_context.md`** +- [ ] **交付文件名**:凡落盘交付的交底书是否均为 **`{案件名}_{YYYYMMDDHHmmss}.md`** 及同名 `.docx`(§7.3 第 5 点,**含首次定稿与迭代**),未无故覆盖旧稿 +- [ ] **系统框图与流程图**:均为 fenced mermaid,定稿已用 `mermaid_render.py` 转 PNG,**无** ASCII 文字流程图/框图;`.md` 已交付,`.docx` 已生成或已按提示手动 `md_to_docx.py` 补全 +- [ ] 章节引用:如「详见 3.4.1」须指向真实存在的章节 +- [ ] **1.1 现有技术**:每个方向下列举的专利/文献是否均附有**可访问且与著录项一致**的公开 URL(非编造);是否与 `prior_art_search.md` 要求及 1.1「检索说明」自洽;**检索说明**是否**未**出现 `cnipa_epub_search.py`、WebSearch/降级等内部流程用语 +- [ ] **查新摘要(国知局)**:凡 **`EPUB_HITS_JSON`** 中带非空 **`abstract`** 的条目,1.1 对该条的方案概括与局限**是否体现对摘要的理解**(非仅标题、且不与摘要矛盾);无 `abstract` 的条目是否在查新路径上已按 `prior_art_search.md` 用其它可核验来源补全理解 +- [ ] **文末清洁**:正文**无**技能仓库名、`examples/`、`disclosure_draft` 路径、「教学示例」「虚构」「不构成法律承诺」等**元信息脚注**(若存在则删除) +- [ ] **权利要求偏向点(对话)**:凡**定稿交付**的对话回复,是否已按 **`disclosure_builder.md` §7.6** 补充「可选下一步」类建议交互(**仅对话**,不入正文);其中对举的侧重点是否**源于本稿与已定观点**,**无**凭空捏造 + +## 处理原则 + +发现问题则**直接修订正文**后再交付。 +**公式类**:Step 8 须完成 §8.2「公式正确性与逻辑」核对;有误则改公式并联动修订相关章节,**不要**只向用户报告问题而不改稿(除非缺少必要技术事实、无法从正文与材料推断正确写法)。 diff --git a/.agents/skills/patent-disclosure-skill/prompts/intake.md b/.agents/skills/patent-disclosure-skill/prompts/intake.md new file mode 100644 index 0000000..f0b5dda --- /dev/null +++ b/.agents/skills/patent-disclosure-skill/prompts/intake.md @@ -0,0 +1,35 @@ +# 边界与输入录入(Step 1) + +## 用途 + +在开始专利挖掘与交底书撰写前,用**少量问题**收敛案件边界。信息不全时可跳过,由 Agent 后续推断并**注明假设**。 + +## 可选开场 + +``` +为便于挖掘专利点与组织交底书,先确认几项边界;可跳过,将按已有材料推断。 +``` + +## 问题序列 + +### Q1:技术主题或产品模块 + +``` +请用一句话描述本方案所属技术主题或产品模块,例如「多模态检索排序」「工业质检缺陷分级」。 +``` + +### Q2:专利类型倾向 + +``` +是否已有倾向的权利要求类型?方法 / 系统 / 装置 / 暂不确定 +``` + +### Q3:文头联系人 + +``` +交底书文头的技术联系人是否需要占位?需要则提供姓名/电话/邮箱;不需要则全部写「待填写」。 +``` + +## 汇总 + +将已确认项用 3–6 行 bullet 复述给用户,再进入 `project_scan.md` 对应的扫描阶段。 diff --git a/.agents/skills/patent-disclosure-skill/prompts/iteration_context.md b/.agents/skills/patent-disclosure-skill/prompts/iteration_context.md new file mode 100644 index 0000000..9f077c9 --- /dev/null +++ b/.agents/skills/patent-disclosure-skill/prompts/iteration_context.md @@ -0,0 +1,77 @@ +# 迭代上下文(进入 merger / correction 前必读) + +## 本文作用 + +约定**迭代时先干什么、产出什么**,避免 Agent 只读了合并/纠正模板却转去跑 **Step 3–4 专利点分析**或空泛「更新分析」而不落盘新稿。 + +--- + +## 何时读本文 + +用户明显在**已有交底书或上一轮交付稿**上继续工作时(补材料、改章节、纠错、调保护点表述等),在 **`Read` `merger.md` 或 `correction_handler.md` 之前**先读本文,再读对应迭代模板。 + +| 意图 | 下一步模板 | +|------|------------| +| 补充文档、扩展方案、合并新材料 | `merger.md` | +| 指出错误、与事实/参数不符、风格或保护点调整 | `correction_handler.md` | +| 用户已按 `disclosure_builder.md` §7.6 声明侧重点,仅需 **第五章权利要求书式强化**(取向须与本稿已有材料及第五、三章已写观点一致,**禁止**为交互而编造新场景) | `merger.md`(以最近定稿为基准,合并范围以第五章为主,必要时微调第四章与第五章衔接句) | + +--- + +## 输入与输出 + +**输入** + +- 对话中的本轮说明;用户 **@** 的文件或粘贴片段。 +- **`Read`** 当前作为基准的交底书 `.md`(路径由用户给出或对话中已出现)。 + +**输出** + +- 合并或纠正后的全文写入**新文件**,不得默认覆盖旧稿: + **`{规范化案件名}_{YYYYMMDDHHmmss}.md`** + **`mermaid_render.py` 生成的同名 `.docx`**。 +- 与 **Step 7 首次定稿**为**同一命名规则**(凡落盘交付均带时间戳),详见 **`disclosure_builder.md` §7.3 第 5 点**。 +- 旧版 `.md`/`.docx` 保留在同目录,便于对照(用户明确要求覆盖时再覆盖)。 + +版本历史依赖**同目录下多个带时间戳的文件**,不需要 `iterations/` 子目录或快照脚本。 + +### 修订对话记录(单独 Markdown,必做) + +每完成一轮 **合并**或**纠正**并在磁盘上写出新 `.md`/`.docx` 后,须在**案件产出目录**(与本轮交付文件同一目录,如 `outputs/{案件标识}/`)维护**一个固定文件**: + +- **文件名**:`交底书修订对话记录.md`(默认;若环境对中文路径敏感,可用 `--log-name disclosure_revision_log.md` 调用脚本)。 + +**每条记录须含**: + +1. **记录时间**:**本地时间**与 **UTC**(脚本自动生成;手工追加时两者都写)。 +2. **类型**:合并迭代 / 纠正迭代。 +3. **用户说明摘要**:本轮用户意图、要点(可含 @ 文件名称)。 +4. **本轮交付文件**:新时间戳 `.md`、`.docx` 文件名。 +5. **合并/纠正摘要摘录**:与当轮对话中「合并摘要(留档)」「纠正摘要(留档)」一致或为其缩写。 + +**推荐**:在写出交付文件并生成 Word 之后,执行 **`Bash`**: + +```text +python3 ${CLAUDE_SKILL_DIR}/tools/iteration_dialog_log.py --case-dir "{案件目录}" --kind merge --user "{用户说明摘要}" --summary "{摘要摘录}" --artifacts "{案件名_时间戳.md},{案件名_时间戳.docx}" +``` + +`--kind` 纠正时用 `correct`。若无法执行脚本,须 **`Read`** 已有 `交底书修订对话记录.md`(若无则 **`Write`** 创建),再 **`StrReplace`** 或等价方式在文末**追加**与上表结构相同的一条(时间须真实)。 + +**禁止**:完成迭代交付却**完全不**更新该对话记录文件。 + +--- + +## 建议执行顺序(短清单) + +1. 读本文 → 按上表选 `merger.md` 或 `correction_handler.md` 并 **`Read`**。 +2. **`Read`** 基准稿 + 本轮补充材料。 +3. 在稿内完成合并或纠正逻辑(自检 **8.2、8.3** 见 `disclosure_self_check.md`)。 +4. **`Write`** 新时间戳 `.md` → 运行 **`mermaid_render.py -o`** 写出定稿图与 **`.docx`**。 +5. 追加 **`交底书修订对话记录.md`**(**`iteration_dialog_log.py`** 或手工),见上文「修订对话记录」。 +6. 在回复中写明新文件路径,并输出该模板要求的 **「合并摘要(留档)」**或 **「纠正摘要(留档)」**。 + +--- + +## 禁止 + +- 已判定为迭代意图时,**不**经合并/纠正流程、不把结果写入**新时间戳文件**,却去跑全文专利点挖掘或仅输出分析段落。 +- 例外:用户**明确要求**「重新挖掘专利点 / 从头再走查新」时,可走主流程 Step 3 起。 \ No newline at end of file diff --git a/.agents/skills/patent-disclosure-skill/prompts/merger.md b/.agents/skills/patent-disclosure-skill/prompts/merger.md new file mode 100644 index 0000000..02d77f6 --- /dev/null +++ b/.agents/skills/patent-disclosure-skill/prompts/merger.md @@ -0,0 +1,38 @@ +# 迭代模式:增量合并(修订与补充) + +## 执行门禁(优先执行,不可跳过) + +1. **`Read`** **`prompts/iteration_context.md`**(迭代时读用户意图 + 已有稿路径,**不要**跳过合并去跑专利点分析)。 +2. **`Read`** 用户给出的**当前定稿** `.md`(及本轮 @ 的补充材料)。 +3. **落盘**:合并结果须写入**新文件**,文件名为 **`{案件名}_{YYYYMMDDHHmmss}.md`**,并经 `mermaid_render.py`(或等价流程)生成**同名** `.docx`,规则见 **`disclosure_builder.md` §7.3 第 5 点**。**禁止覆盖**上一轮交付文件(除非用户明确要求覆盖)。 + +**禁止**:在已判定为「在已有稿上迭代」时,去跑 Step 3–4 专利点全文分析、或仅泛泛「更新专利分析」而**未**把合并结果写入用户案件目录下的**新带时间戳文件**。**除非**用户明确要求重新挖掘/重写专利点。 + +## 何时启用 + +由 Agent 根据用户**意图**判断:**在已有交底书或上一轮输出上补充新材料**(新文档、新代码说明、粘贴片段、扩展章节等),且以**合并进现有结构**为主时,**应**执行本流程;用户按 **`disclosure_builder.md` §7.6** 仅要求**第五章权利要求书式强化**、已说明侧重点者,同样走本流程(合并范围以第五章为主)。**不要求**用户说出「迭代」等固定词;也**不必**先询问是否进入迭代模式。 + +## 流程 + +1. **识别增量**:新内容主要影响哪些章节:背景、1.1 现有技术、3.4 流程、实施例等。 +2. **非破坏性合并**:以**追加或局部重写**为主,不推翻未涉及且用户未要求修改的章节。 +3. **查新联动**:若增量改变了技术实质,判断是否需要**补充检索**并更新 1.1 / 区别论述。 +4. **一致性**:合并后执行 `disclosure_self_check.md` 中的 **8.2、8.3** 快速检查;若涉及 **3.4.1 公式/3.5 参数**,须同步核对 **`disclosure_builder.md` §7.7**(符号表、维度下标、3.5 符号列同形)。 +5. **落盘**:将合并后的全文写入 **`{案件名}_{YYYYMMDDHHmmss}.md`**,再经 `mermaid_render.py` 生成同名 `.docx`(§7.3 第 5 点)。 +6. **对话记录**:按 **`iteration_context.md`**「修订对话记录」在案件目录追加 **`交底书修订对话记录.md`**(优先 **`tools/iteration_dialog_log.py --kind merge`**)。 + +## 输出(强制,不可省略) + +在交付修改后的正文(或说明已写入路径)之后,**必须在同一条回复中**追加独立小节,**标题固定为**: + +## 合并摘要(留档) + +其下用 **3–6 句完整中文**,依次说明:**改了哪些章节**、**原因**、**是否影响保护点或检索结论**、**是否已做 8.2/8.3 核对**。 +若未输出本节,视为未完成本 prompt。 + +**定稿延续**:若本轮合并结果作为**向用户交付的定稿**,在**同一条回复**中于上文之后,**还须**按 **`disclosure_builder.md` §7.6** 补充「权利要求偏向点」建议交互(可缩写),**不得**写入交底书正文;交互中的对举选项**须**来自本稿已有论述,**禁止捏造**(见 §7.6 第 3 点)。 + +## 与「纠正」的区别 + +- **merger**:侧重新材料、新功能的**扩展**。 +- **correction_handler**:侧重用户指出**错误、风格或与事实不符**的修正。 diff --git a/.agents/skills/patent-disclosure-skill/prompts/obsidian_ofm_companion.md b/.agents/skills/patent-disclosure-skill/prompts/obsidian_ofm_companion.md new file mode 100644 index 0000000..80055fa --- /dev/null +++ b/.agents/skills/patent-disclosure-skill/prompts/obsidian_ofm_companion.md @@ -0,0 +1,54 @@ +# Obsidian Flavored Markdown 伴生规范(专利解读写笔记时 Read) + +> 与 [kepano/obsidian-skills](https://github.com/kepano/obsidian-skills) 的 `obsidian-markdown` 对齐子集。若环境已安装该技能,写笔记前可额外 **`Read`** 其 `SKILL.md`;**未安装时**以本文件为准即可。 + +## 写笔记前 + +与 `assets/patent_note_template.md`、`references/patent_obsidian_format.md` 一并使用。 + +## 必用 Callout 类型 + +| 语法 | 位置 | +|------|------| +| `> [!patent-meta] 著录项` | `#` 标题下、导航后 | +| `> [!patent-claim] 权利要求 N` | 第四节各独立权前(可选包裹引用块) | +| `> [!grounding] 应用场景` | 第九节全文 | +| `> [!warning]- 公开检索线索` | 第十节 B(`-` 默认折叠) | +| `> [!figure] 图 N …` | 第六节附图占位(无图时) | + +## Frontmatter + +必须含:`cssclasses: [patent-reader]`、`ipc`、`evidence_scope`、`confidence_speculative`(有附录 B 中/低置信线索时为 `true`)。 + +标签: + +- `patents/<领域>` +- `patent/evidence/full` | `abstract` | `partial` +- `patent/speculative`(有推测附录时) + +## 权利要求树 + +入库脚本用 `claim_tree.json` 生成第三节:**一张树形表**(`◆/├─/└─` + 权号 + 本项新增)。 +结构与内容合一,避免 mermaid 与表各讲一遍。`claim_mermaid.mmd` 可留作可选,默认不进正文。 + +## 嵌套与折叠 + +- 推测内容**仅**放在 `[!warning]-` 内 +- 冗长从属权枝可用 `[!note]-` 折叠 + +## Canvas + +入库后笔记导航须链到 `{{公开号}}_图谱.canvas`。 + +## 说明书段落引用 + +- 写 `说明书 0002` / `说明书 0002–0004`,勿写裸 `[0002]` +- 入库改成块锚单链:`#^p0002` / `#^r0002-0004`;**悬停预览**依赖核心插件「页面预览」(设置 → 核心插件 → 页面预览) + +## 禁止 + +- HTML 块替代 callout(可移植性差) +- 在第九节写 URL +- 依赖仅社区插件才有的语法(Dataview 只写在索引页,不写解读正文) +- **用户可见标题**加说明性括号(如「若能从原文读出」「故事线」「专利内依据」「可选 mermaid」);写作约束写在 prompt,不写进交付标题 +- 交付正文出现脚本名(`*.py`)、流水线字段(`context_anchor.*`)、内部附图文件名(`page_*_xref_*`) diff --git a/.agents/skills/patent-disclosure-skill/prompts/obsidian_plugin_guide.md b/.agents/skills/patent-disclosure-skill/prompts/obsidian_plugin_guide.md new file mode 100644 index 0000000..7105acf --- /dev/null +++ b/.agents/skills/patent-disclosure-skill/prompts/obsidian_plugin_guide.md @@ -0,0 +1,38 @@ +# Obsidian 插件与库配置引导(交付用户,不写入笔记) + +## 使用时机 + +`write_patent_obsidian_note.py` 成功入库后,在**对话末尾**向用户出示(勿写入专利解读正文)。 + +## 话术模板 + +解读笔记已写入 Obsidian。**库级 CSS、Bases、索引与关系图配色已在入库时自动配置**;请 **Ctrl/Cmd+R** 重载后查看。 + +### 1. 建议打开 + +- `Research/Patents/_专利解读索引.md` — Bases 表(+ 可选 Dataview) +- 本次笔记与同目录 `*_图谱.canvas` +- 若已关联:`Research/Patents/_专利关联.canvas` +- **关系图**:靛=解读,青绿=Canvas,橙=术语(原生 Groups,无需插件) + +### 2. 可选社区插件(须在 App 内安装,技能无法代下) + +详见 **`docs/obsidian-setup-guide.md`**(安装步骤与插件表): + +| 插件 | 安装 | 作用 | +|------|------|------| +| Dataview | https://obsidian.md/plugins?id=dataview | 索引动态表 | +| Colored Tags | https://obsidian.md/plugins?id=colored-tags | 标签上色 | +| Colored Bases Properties | https://obsidian.md/plugins?id=colored-bases-properties | Bases pill | +| Iconize | https://obsidian.md/plugins?id=iconize | 侧栏图标 | +| Supercharged Links | https://obsidian.md/plugins?id=supercharged-links | 双链上色 | + +步骤:设置 → 社区插件 → 关限制模式 → 浏览 → 搜索 → 安装 → 启用。 + +### 3. Obsidian CLI(可选,1.12+) + +https://help.obsidian.md/cli — 检测到 `obsidian` 命令时入库可同步属性。 + +## 若用户未配置库 + +说明笔记在 `outputs/patent_reader/`;配置库路径后再解读入库即可自动完成库级配置。 diff --git a/.agents/skills/patent-disclosure-skill/prompts/patent_plain_reader.md b/.agents/skills/patent-disclosure-skill/prompts/patent_plain_reader.md new file mode 100644 index 0000000..2c6f521 --- /dev/null +++ b/.agents/skills/patent-disclosure-skill/prompts/patent_plain_reader.md @@ -0,0 +1,370 @@ +# 专利通俗解读(阅读模式) + +## 适用时机 + +用户意图为**读懂已有专利**(反向阅读),而非撰写交底书。典型触发: + +- 专利通俗解读、读专利、看懂专利、反向专利 +- 提供专利号 / 专利 PDF / 粘贴权利要求或说明书 +- `/patent-read`、`/读专利` + +**与主流程关系**:本模式**不**执行 intake → 交底书 Step 1–8。 + +## 是否依赖 Obsidian? + +**强烈推荐配置 Obsidian 库**(`PATENT_READER_OBSIDIAN_VAULT`),才能完整体验索引、Canvas 知识图谱、术语网、关系图配色与公开线索旁注。 +无库时仍可写入 `outputs/patent_reader/`(降级),解读主链路照常,但图谱与旁注体验会弱一截。 +因此**对话一开始、取证之前**必须先探测环境;用户安装与可选社区插件见 **`docs/obsidian-setup-guide.md`**。 + +## 第 0 步(门禁):探测 Obsidian 与库路径 + +**在向用户确认主题之外的实质步骤之前**,先运行: + +```bash +python3 ${CLAUDE_SKILL_DIR}/tools/patent_reader/check_obsidian_env.py --json +``` + +| 结果 | Agent 行为 | +|------|------------| +| `status=ready` 且有 `resolved.vault` | 可 `--auto-accept` 写入持久化;本会话设置 `PATENT_READER_OBSIDIAN_VAULT` 后继续 | +| `needs_user_input=true`(未装 / 多库 / 无库) | **暂停取证**,**强烈建议**用户给出库根路径以获得完整体验(常见:`C:\Users\<用户>\Documents\Obsidian Vault`);仅当用户明确只要 Markdown / 不要库时,才降级 `outputs/` | +| 用户提供路径后 | 执行 `--set "路径"`(Windows 可选再加 `--setx`),并在后续 Shell 中带上该环境变量 | + +```bash +# 用户给出路径后 +python3 ${CLAUDE_SKILL_DIR}/tools/patent_reader/check_obsidian_env.py --set "库路径" --setx + +# Windows 当前会话(Agent 后续命令需带上) +# PowerShell: $env:PATENT_READER_OBSIDIAN_VAULT = "库路径" +``` + +持久化文件:`~/.patent-disclosure-skill/obsidian_vault.txt`(脚本自动读,不单靠环境变量)。 + +用户明确说「只要 Markdown / 不要 Obsidian」→ 跳过入库增强,写入 `outputs/` 即可。 + +## Obsidian 增强(L0–L2) + +写笔记前 **`Read`**: + +- `prompts/obsidian_ofm_companion.md` — Callout / frontmatter / Mermaid 规范 +- `references/patent_obsidian_format.md` + `assets/patent_note_template.md` + +**标题纪律**:交付笔记的 `##` / `###` / callout 标题只用简洁名称(如「二、连贯叙事」「七、和现有技术的差别」),**禁止**加「(故事线)」「(若能从原文读出)」「(专利内依据)」等给 Agent 的说明。模板里「写作提示 · 勿写入交付稿」仅供撰写时参考,不得原样留在正文。 + +**实现痕迹禁令(硬性)**:交付笔记与库内索引**禁止**出现脚本/工具文件名(如 `*.py`)、流水线字段路径(如 `context_anchor.ipc_application`)、内部裁图文件名(如 `page_001_xref_01.png`)。附录「来源」只写「离线 IPC 行业词表」等自然语言;附图说明只写「第 N 页」。 + +有库时,`write_patent_obsidian_note.py` 入库会**自动** `bootstrap_vault`(CSS / Bases / 关系图 Groups)。**勿**再引导用户手动复制 CSS 或单独为「配库」跑初始化;交付对话只引导可选社区插件(见 `obsidian_plugin_guide.md`)。 +## 工作流(严格按序) + +生成运行 ID:`read-<公开号或slug>-`(**RUN**)。 + +### 第 1 步:取证与结构化 + +**仅有公开号、无本地全文/PDF 时**(**禁止**每次现写下载脚本):先跑固化入口,再 extract: + +```bash +python3 ${CLAUDE_SKILL_DIR}/tools/patent_reader/fetch_patent_pdf.py \ + --pub <公开号> -o tmp/patent_reader/${RUN} +# → tmp/patent_reader/${RUN}/source/<公开号>.pdf +# 源优先级与备选见 references/patent_pdf_sources.yaml +# 排障可加 --save-html;已有直链可 --url … +``` + +失败时:用 `cnipa_epub_search.py` **核验**公开号/摘要(通常无全文 PDF)→ 请用户自备 PDF,或稍后重试 Google CDN。**勿**臆造 PDF URL。 + +用户已提供 PDF/全文路径时:**跳过** `fetch_patent_pdf`,直接 extract。 + +```bash +python3 ${CLAUDE_SKILL_DIR}/tools/patent_reader/extract_patent_text.py \ + -i <全文或 ${RUN}/source/<公开号>.pdf> -o tmp/patent_reader/${RUN} --pub-number <若有> +``` + +**PDF 附图(有 PDF 时执行;caption+bbox + 质量门)**: + +```bash +python3 ${CLAUDE_SKILL_DIR}/tools/patent_reader/extract_patent_figures.py \ + -i -o tmp/patent_reader/${RUN}/figures +# 人工确认后少丢可用图:追加 --include-review +``` + +- 读 `FIGURES_MANIFEST`:`decision=insert` 可嵌入;`placeholder` 仅用 `[!figure]` 占位。 +- `quality=review` 默认 placeholder;加 `--include-review`(抽取或入库)可按 insert 处理。 +- 写笔记第六节:`insert` → `![[images/…]]`;其余 → `[!figure]`。 + +### 第 1.5 步:技术落地线索 + 可视化草稿 + +```bash +python3 ${CLAUDE_SKILL_DIR}/tools/patent_reader/build_context_anchor.py -w tmp/patent_reader/${RUN} + +python3 ${CLAUDE_SKILL_DIR}/tools/patent_reader/build_claim_mermaid.py \ + --claim-tree tmp/patent_reader/${RUN}/claim_tree.json \ + --pub-number <公开号> \ + -o tmp/patent_reader/${RUN}/claim_mermaid.mmd +``` + +- 第三节**树形结构**(◆/├─/└─)由入库脚本按**已校对**的 `claim_tree.json` 渲染;**「本项新增」列优先用你写的 `claim_deltas.json`**(见 1.6),勿再「mermaid + 表」双份主展示;`claim_mermaid.mmd` 仅作可选附件。 + +### 第 1.55 步:校对 `claim_tree.json`(Agent 主路径 · 父号/独立权) + +抽取脚本只会用正则猜独立权与父号,**遇「如权利要求1或2所述」「权1至3」等极易错**。你必须在写 `claim_deltas` / 第三节前完成校对: + +1. 打开 `claim_tree.json`,对照权要原文(`raw_sections.jsonl` / PDF / 全文)。 +2. 逐项核对并改写节点字段: + - `is_independent`:是否独立权(真/假) + - `parent`:从属权的**直接**父号(单值);独立权必须为 `null` + - 若节点已有 `parent_candidates`(多引用列表):从中择一写入 `parent`,并在 `review.notes` 说明选型理由 +3. 多引用规则(硬性): + - 「权 A 或 B」→ 选**依赖链上更合理的直接限定对象**(通常选独立权,或原文主述的那一项);**不要**留多个 parent + - 「权 A 至 C」→ 一般挂到区间内被进一步限定的那一项,拿不准时挂**最小编号且已存在的独立权/父权** +4. 回写同一文件,并增加: + +```json +{ + "roots": [1], + "nodes": [ + { + "number": 1, + "is_independent": true, + "parent": null, + "text_preview": "…" + }, + { + "number": 5, + "is_independent": false, + "parent": 1, + "parent_candidates": [1, 2], + "text_preview": "如权利要求1或2所述的…" + } + ], + "review": { + "by": "agent", + "status": "reviewed", + "notes": "权5「1或2」挂到独立权1;权12 父号由4改为6", + "corrections": [ + {"claim": 5, "to_parent": 1, "reason": "或引多项,挂独立权1"} + ] + } +} +``` + +5. **再跑校验**(有 issues 必须修到通过;`multi_parent_candidates` 为警告,校对后可保留 candidates): + +```bash +python3 ${CLAUDE_SKILL_DIR}/tools/patent_reader/validate_claim_tree.py \ + -i tmp/patent_reader/${RUN}/claim_tree.json \ + --write \ + --require-review +``` + +- `--write`:规范化 roots / 悬空父号后再写回。 +- 未写 `review.by=agent|human` 时 `--require-review` 会失败——用于防止跳过校对。 + +### 第 1.6 步:`claim_deltas.json`(Agent 主路径 · 本项新增) + +在**已校对**的 `claim_tree.json` 上,为每一项权利要求写一句大白话「相对父权/独立权多了什么」(独立权写骨架要点)。**禁止**照抄「如权利要求…所述」「其特征在于」套话。 + +写入 `tmp/patent_reader/${RUN}/claim_deltas.json`: + +```json +{ + "source": "agent", + "deltas": [ + {"claim": 1, "delta": "基膜+至少一面涂覆层,涂层含陶瓷与纤维素"}, + {"claim": 2, "delta": "限定纤维素分子量 5万~250万"}, + {"claim": 3, "delta": "非衍生化纤维素经碱尿素溶解后涂布"} + ] +} +``` + +- 每句建议 12~40 字;从属权只写**增量**,不要重复父权已有内容。 +- 也可写在 `note_plan.json` 的 `claim_deltas` 字段(同结构);或写入 `claim_tree.json` 各 node 的 `delta` 字段。 +- 入库时**优先**用本文件;缺省权号才用脚本启发式从 `text_preview` 截句(效果较差)。 + +### 第 2 步:公开检索 → 充实 `public_clues.json`(Agent 主路径) + +对 `context_anchor.json` → `web_search_queries` 执行 **WebSearch**(或国知局脚本)得到候选后: + +1. **先**跑校验+筛选(置信度高→低,**默认最多 3 条**): + +```bash +python3 ${CLAUDE_SKILL_DIR}/tools/patent_reader/validate_public_clues.py \ + -i tmp/patent_reader/${RUN}/public_clues.json \ + -o tmp/patent_reader/${RUN}/public_clues.lint.json \ + --write-filtered +``` + +2. **再由你(Agent)自主规划**打开每条 URL 并写回同文件(**主路径,勿依赖入库脚本爬虫**): + - 按站点选型:静态页可用 WebFetch / `mcp_web_fetch`;强反爬或 SPA 用浏览器类工具;打不开则标失败,勿编造正文。 + - 每条补全字段(可复现、可入库): + +```json +[ + { + "title": "……", + "url": "https://…", + "confidence": "中", + "reason": "与权利要求/实施例的对应关系…", + "page_title": "页面标题(若可得)", + "summary": "200~800 字可读摘要,只写页面上能核验的内容", + "status": "agent_fetched", + "related_claims": [1, 6], + "related_features": ["涂覆层含陶瓷和纤维素"], + "anchor_fits": [ + { + "kind": "feature", + "key": "涂覆层含陶瓷和纤维素", + "fit": "页面写在纸基上涂布纤维素溶液(未出现本案数值区间)" + }, + { + "kind": "claim", + "key": "1", + "fit": "公开制备流程与权1「涂覆隔膜」同主题,但基材表述为纸基" + }, + { + "kind": "term", + "key": "纤维素", + "fit": "文中举例棉浆溶解后用于隔膜涂层" + } + ], + "fetch_note": "选用的读取方式;失败原因(可选)" + } +] +``` + +- `status`:`agent_fetched`(已读)/ `fetch_failed`(打不开)/ `draft`(仅有链接)。 +- `related_claims` / `related_features`:弱匹配建议,**标注为推测**,不得写成说明书证据。 +- **`anchor_fits`(硬性 · 大模型主路径)**:你(Agent)**读完该线索页面/摘要全文后**,对照本案权要与特征表,为**真正有对应的**锚点各写一句贴合点(`fit`≤40~80字)。 + - `kind`:`feature` | `claim` | `term`;`key` 必须能对上笔记里的特征名 / 权号 / 术语。 + - **只写页面上能核验的对应**;摘要未点名的数值/尺寸特征不要硬编 `fit`(可省略该条,或入库后由脚本归入「另涉」)。 + - 入库旁注**优先使用** `anchor_fits`;缺省时脚本才用启发式从 `summary` 抽句(效果较差)。 + - 特征表若在写笔记时才定稿:第 4 步入库前应用最终特征名**回填/修订** `anchor_fits` 再写 `public_clues.json`。 +- **`summary` 写法(硬性)**:写成 3~8 条要点或两三句连贯短文;**禁止**粘贴导航/页脚/面包屑/整页纯文本;化学式写在同一行(如 `Al2O3/勃姆石涂覆`,勿拆成多行单字)。不要用行首 `>`(会在 Obsidian 变成引用竖线)。 +- 无可靠结果时写 `[]`,附录 B「未发现…」。 +- **禁止**把推测线索写进一至八主结论。 + +入库脚本只做**结构化落地与 L1–L4 融合**(`clues/`、导航入口、各节折叠旁注、权/特征点对点、附录 B、Canvas)。**贴合句以 Agent `anchor_fits` 为准**;脚本 HTTP 抓取仅为**降级**:仅当某条缺少 `summary` 且显式传入 `--fetch-clues-fallback` 时才尝试。 + +### 第 3 步:`note_plan.json` + +含 `context_anchor_ref`、`public_clues_ref`、`grounding`;可选内嵌 `claim_deltas`(若未单独写 `claim_deltas.json`)。 + +### 第 4 步:写解读笔记 + +遵循 **L0 Callout 模板**(`[!patent-meta]`、`[!grounding]`、`[!warning]-`)。 + +**说明书段落依据(硬性)**:引用原文段落时写 **`说明书 0002`** 或区间 **`说明书 0002–0004`**(四位编号,用 en-dash `–`)。**禁止**再写裸 `[0002]`(Obsidian 会误染成假链接)。 +入库会生成同目录 `{公开号}_说明书段落.md`(仅含本篇引用到的段;文首含页面预览**使用说明**),并把上述写法改成**单条**可悬停预览 wikilink,例如 `[[…#^p0002|说明书 0002]]`、`[[…#^r0002-0004|说明书 0002–0004]]`。 +正文可写「权2–3」「图1–3」:入库改为 `[[…_权项锚点#^claim-N|权N]]`(旁路笔记)、`[[#图N|图N]]`(附图区标题)。悬停预览需开启「页面预览」并**按住 Ctrl**。 + +第三节可先留简表或占位;**入库会按 `claim_tree` + `claim_deltas` 重写第三节树形表**。第四节独立权精读仍须你写 callout 与特征表。 + +若有 `figures/manifest.json`: +- `decision=insert` → 第六节 `![[images/…]]`(入库脚本也会自动补嵌) +- `decision=placeholder` → `[!figure]` 占位,**不要**当正式插图 + +术语:第五节优先 `[[Research/术语/术语名|术语]]`。入库脚本会合并 `glossary_candidates` **与笔记第五节表/已有 wikilink** 再建 stub、反链与 Canvas(避免 extract 抽不到术语时术语网空白)。 + +### 第 5 步:lint + +```bash +python3 ${CLAUDE_SKILL_DIR}/tools/patent_reader/lint_patent_note.py \ + --note <笔记.md> \ + --manifest tmp/patent_reader/${RUN}/source_manifest.json \ + --claim-tree tmp/patent_reader/${RUN}/claim_tree.json \ + --plan tmp/patent_reader/${RUN}/note_plan.json \ + --context-anchor tmp/patent_reader/${RUN}/context_anchor.json \ + --figures-manifest tmp/patent_reader/${RUN}/figures/manifest.json \ + --output tmp/patent_reader/${RUN}/lint.json +``` + +- `section3_missing_mermaid_optional`、`insert_figure_not_referenced:*` 为 **warnings**(不阻断);issues 须修到 passed。 +- 有 ≥2 条独立权时第三节应补 mermaid。 + +### 第 6 步:写入 Obsidian + +```bash +python3 ${CLAUDE_SKILL_DIR}/tools/patent_reader/write_patent_obsidian_note.py \ + --content-file <笔记.md> \ + --manifest tmp/patent_reader/${RUN}/source_manifest.json \ + --context-anchor tmp/patent_reader/${RUN}/context_anchor.json \ + --bundle tmp/patent_reader/${RUN}/synthesis_bundle.json \ + --public-clues tmp/patent_reader/${RUN}/public_clues.json \ + --workdir tmp/patent_reader/${RUN} \ + --lint-json tmp/patent_reader/${RUN}/lint.json \ + --output tmp/patent_reader/${RUN}/write_status.json +# workdir 内若有 claim_deltas.json,第三节「本项新增」优先采用(也可 --claim-deltas 显式指定) +# 可选:--include-review(入库时把 review 图当 insert) +# 可选:--strict-figures(要求笔记已嵌入,禁止只靠自动补嵌) +# 官方 PDF 默认拷到笔记目录 source/;不需要时加 --no-copy-source-pdf +``` + +自动:`bootstrap_vault`(CSS / Bases / 关系图)、frontmatter 标签、`*.canvas` 图谱(叙事/著录/权项/术语/相关专利/**公开线索卡**)、第三节树形表(**本项新增优先 `claim_deltas`**)、`clues/` 落地(用第 2 步 Agent 已写的 summary/`anchor_fits`)、**说明书段落锚点笔记 + 引用 wikilink 改写**、**官方 PDF → `source/`(默认)**、扫描件整页预览、术语反链。线索脚本抓取默认关闭,仅 `--fetch-clues-fallback`。无 vault 时仍写 `outputs/`。入库可加 `--scan-pages`。 + +可选单独生成 Canvas: + +```bash +python3 ${CLAUDE_SKILL_DIR}/tools/patent_reader/build_patent_canvas.py \ + --note-rel Research/Patents/<领域>/<公开号>/<文件>.md \ + --manifest tmp/patent_reader/${RUN}/source_manifest.json \ + -o Research/Patents/<领域>/<公开号>/<公开号>_图谱.canvas +``` + +### 第 7 步:交付 + +1. 笔记路径、Canvas 路径、索引页路径 +2. 一句话结论、证据范围、应用场景要点 +3. **`Read`** `prompts/obsidian_plugin_guide.md` → 向用户出示**插件安装引导**(含社区插件 URL) + +### 第 8 步(交付后常问 · 推荐):库内专利关联 + +交付第 7 步后,**只要库内已有 ≥2 篇解读笔记,就必须用一句话反问**(勿静默跳过): + +> 库里已有其它专利解读,要不要做一次「专利关联」?会按同申请人 / IPC / 共术语等规则连边,并生成全局 `Research/Patents/_专利关联.canvas`(不构成法律意见)。 + +| 用户态度 | Agent 行为 | +|----------|------------| +| 明确同意 / 「要关联」 / 「好」 | 执行下方命令;可先 `--dry-run` 预览边再写入 | +| 拒绝 / 「不用」 | **跳过**,结束本轮 | +| 「先看看有哪些边」 | 仅 `--dry-run`,再问是否写入 | +| 库内不足 2 篇 | 可省略反问,或说明「再解读一篇后可做关联」 | + +```bash +# 预览 +python3 ${CLAUDE_SKILL_DIR}/tools/patent_reader/link_patent_notes.py \ + --focus-pub <本轮公开号> --dry-run \ + -o tmp/patent_reader/${RUN}/patent_links.preview.json + +# 写入(双向回写 related_pubs + 相关专利节 + 单篇图谱 + 全局 _专利关联.canvas) +python3 ${CLAUDE_SKILL_DIR}/tools/patent_reader/link_patent_notes.py \ + --focus-pub <本轮公开号> \ + -o tmp/patent_reader/${RUN}/patent_links.json +``` + +**可选增强(模型边)**:若 dry-run 后仍有「疑似相关但规则分不够」的对,Agent 可阅读两篇笔记后写 `model_links.json`: + +```json +[ + { + "pub_a": "CNxxx", + "pub_b": "CNyyy", + "relation": "improvement", + "score": 0.75, + "rationale": "独立权均含…;B 增加…" + } +] +``` + +再执行: + +```bash +python3 ${CLAUDE_SKILL_DIR}/tools/patent_reader/link_patent_notes.py \ + --focus-pub <本轮公开号> \ + --model-scores tmp/patent_reader/${RUN}/model_links.json \ + -o tmp/patent_reader/${RUN}/patent_links.json +``` + +**约束**:无 vault 时跳过本步;关联仅为辅助导航,须在相关专利节保留免责提示;**禁止**把关联写成侵权/无效结论。 + +## 与国知局查新的一致性 + +公开号、摘要须先检索核验;**禁止虚构**链接。 diff --git a/.agents/skills/patent-disclosure-skill/prompts/patent_points_analyzer.md b/.agents/skills/patent-disclosure-skill/prompts/patent_points_analyzer.md new file mode 100644 index 0000000..ea01251 --- /dev/null +++ b/.agents/skills/patent-disclosure-skill/prompts/patent_points_analyzer.md @@ -0,0 +1,30 @@ +# 专利点挖掘与融合(Step 3–4) + +## Step 3:候选专利点 + +- 列出 **3–5 个**候选专利点。 +- 每个专利点需说明: + - 技术背景 + - 创新点 + - 与现有技术区别(可先基于材料推断,查新后在第一章收紧) + - 可实施性 +- 可基于已有事实**适度推演**,但须有技术合理性支撑。 + +## Step 4:融合与选定 + +- 检查多个专利点是否可合并创新要素。 +- 将相关技术点融合为**方法+系统**类专利(如适用)。 +- 突出「组合创新」:若干要素有机结合形成的独创性方案。 + +### 输出策略 + +**默认**:最终优先产出**最有价值的一篇**交底书。 + +若用户明确要求多篇:先列多篇大纲(标题 + 核心区别),再与用户约定撰写顺序。 + +### 选定依据 + +- 创新性、可授权性 +- 与查新结果的差异化(查新完成后复核) +- 技术方案完整、可实施 +- 保护范围合理 diff --git a/.agents/skills/patent-disclosure-skill/prompts/patent_reader_self_check.md b/.agents/skills/patent-disclosure-skill/prompts/patent_reader_self_check.md new file mode 100644 index 0000000..97b609b --- /dev/null +++ b/.agents/skills/patent-disclosure-skill/prompts/patent_reader_self_check.md @@ -0,0 +1,18 @@ +# 专利解读内部自检(不写入交付笔记) + +## 核对清单 + +- [ ] **L0 Callout**:`[!patent-meta]`、`[!grounding]`、`[!warning]-` 齐全 +- [ ] **Frontmatter**:`cssclasses: patent-reader`、`ipc`、`confidence_speculative` 正确 +- [ ] **权利要求树**:`claim_tree.json` 已经 Agent 校对(`review.by=agent`,父号/独立权正确,含「权1或2」类多引用);单一树形表;「本项新增」来自 `claim_deltas.json`;勿 mermaid+表双份主展示 +- [ ] **第九节无 URL**;推测仅在 `[!warning]` 内 +- [ ] **附图闸门**:`insert` 图已嵌入;`placeholder` 仅 callout +- [ ] **术语网**:Canvas 术语为 file 节点或已建 stub;第五节有 wikilink +- [ ] **入库**:`write_status.json` 含 canvas / figures_inserted;索引含 Bases 嵌入 +- [ ] **无实现痕迹**:正文无 `*.py`、无 `context_anchor.*`、附图说明无 `page_*_xref_*.png` +- [ ] **公开线索 L1–L4**:导航入口;一/二/七/八/九旁注;权/特征点对点;附录 B + `clues/` + Canvas;全文标「推测」、未污染说明书依据列;Agent 已写 `summary`(或明确 `fetch_failed`) +- [ ] **交付**:已向用户出示插件引导(对话末尾,勿写入解读正文) + +## 发现问题的处理 + +改笔记后重跑 lint → 入库。 diff --git a/.agents/skills/patent-disclosure-skill/prompts/prior_art_search.md b/.agents/skills/patent-disclosure-skill/prompts/prior_art_search.md new file mode 100644 index 0000000..8909fa4 --- /dev/null +++ b/.agents/skills/patent-disclosure-skill/prompts/prior_art_search.md @@ -0,0 +1,103 @@ +# 联网检索查新(Step 5) + +## 必做时机 + +生成交底书全文**之前或生成过程中**必须执行;检索结论写入第一章 **1.1 现有技术** 及与本案的**区别论述**。 + +## 检索渠道(**优先国知局公布公告站,再降级 WebSearch**) + +### A. 中国专利公布公告(**优先**,官方站点) + +1. **站点**:[国家知识产权局 中国专利公布公告](http://epub.cnipa.gov.cn/)(**仅** `epub.cnipa.gov.cn`)。 +2. **工具**(本仓库 `tools/`):**`cnipa_epub_search.py`** —— **一步**完成公布站检索与结果解析(Playwright 过站点 WAF);结果页 HTML **仅在内存中处理,不落盘**。成功时终端含 **`EPUB_NOTE:`**(ASCII,如 `html_bytes=… disk=0`)与 **`EPUB_HITS_JSON:`** 一行(JSON 数组:标题、公开号、链接、**`abstract`** 等)。 +3. **国知局检索词(生成阶段必做,须在拼 Bash 之前完成)** + + - **拆分责任在 Agent**:在**生成/构造命令阶段**,从本案技术方案、专利点或用户主题中归纳 **2~8 个与方案相关度高的检索单位**,**仅用 ASCII 空格分隔**,再写入 `cnipa_epub_search.py` 的参数。每一单位宜为 **有检索意义的语义块**,例如:**专业术语**、**名词短语**、**名动组合(如「批量调度」「异构调度」)**、**业内固定搭配**;**不要**拆成过碎的单字、泛义双字(如单独 `检索`、`增强`、`系统`、`方法` 等泛词),也**不要**把无关联词硬凑成一串。 + - **禁止**把**无空格的一整句长中文**当作**唯一**参数(例如不要:`".../cnipa_epub_search.py" "知识库检索增强大语言模型"`)。长串在公布站单框内易被当作整句 AND,**极易 0 条**。 + - **Agent 执行时**:**每一轮 `Bash` 只传一个**检索单位(一个词块一句参数);**2~8 个单位须对应 2~8 次**独立调用,**禁止**在一次工具调用里把多个词块同时作为多个 argv 传给 `cnipa_epub_search.py`(脚本虽支持多词单次进程内合并,**仅供本地/人工**;Agent 为控时、降单次 Playwright 链路与 IDE/终端超时风险,**必须**拆进程)。 + - 示意(须按本案替换;**三次调用、每次一词**): + + ```bash + python3 …/cnipa_epub_search.py 知识库 + python3 …/cnipa_epub_search.py 检索增强 + python3 …/cnipa_epub_search.py 大语言模型 + ``` + + - **脚本不做**自动分词或自动拆长中文;若确需**整句一次** AND 检索,改用 **`cnipa_epub_crawler.py`** 单传一句。 + +4. **执行方式**(Step 5 在读完本文件后**先尝试**): + + ```bash + pip install -r tools/requirements-cnipa.txt + python -m playwright install chromium + # Agent:对上一节每个检索单位各执行一次(示例仅展示首轮) + python3 ${CLAUDE_SKILL_DIR}/tools/cnipa_epub_search.py 词甲 + ``` + + - **合并责任在 Agent**:每次调用解析 **stdout** 上**唯一一行** **`EPUB_HITS_JSON:`** 后的 JSON 数组;在推理中按 **`pub_number`** 为主键去重合并(无则 **`link`**,再否则可用标题前缀),得到**一份**总表后再写入查新笔记与 1.1。 + - **`cnipa_epub_search.py`** 若人工单次传入多词,会按空白拆段、进程内**一段一查**并去重(**stderr** 可出现 **`EPUB_MERGE:`**);与 Agent **分多次调用**策略无关。 + - 成功时 **stdout 仅一行** **`EPUB_HITS_JSON:`** + JSON 数组(UTF-8,含中文 `abstract`);**`EPUB_MERGE:`** / **`EPUB_NOTE:`** / **`EPUB_HINT:`** 等在 **stderr** 且为 **ASCII**(减轻 PowerShell 把中文 stderr 当成错误流)。解析命中时请以 **stdout 该行 JSON 为准**,勿因 stderr 或终端编码误判「未命中」而不必要地降级 WebSearch。Windows 乱码与 PowerShell 注意见 **`INSTALL.md`**(`chcp 65001` / `PYTHONUTF8=1`、勿滥用 `2>&1`)。 + - 将 JSON 中**可核验**的公开号、标题、**国知局站点内详情链接**写入查新笔记与 1.1(见下 **`abstract` 必用**)。 + - **降级条件**(满足任一则进入 **B**):命令非 0 退出、超时、无 Playwright、**`EPUB_HITS_JSON` 为空数组**、或条目经人工核对明显与主题无关。 + +5. **`abstract` 字段(国知局条目,规定必用)** + + 若 **`EPUB_HITS_JSON`** 中某项含非空的 **`abstract`**(解析自公布站结果页摘要),对**该条专利**须同时遵守: + + - **必用**:查新笔记、交底书 **1.1** 中对该专利的**技术方案概括、应用场景与局限性分析**,**必须先基于对该 `abstract` 的完整阅读与理解**后再撰写;**禁止**仅凭标题、公开号或 URL **臆造**方案要点或与摘要矛盾的表述。 + - **充分理解**:在写入 1.1 或查新笔记前,Agent 须在**推理过程内**明确:摘要所涉**技术领域、解决什么问题、核心手段/模块、主要效果或流程**;若摘要与标题存在差异,**以摘要为准**概括该技术。 + - **正文呈现**:交底书 1.1 中**不得**大段逐字粘贴官方摘要(避免抄袭与超字数);应**消化后**用**自己的话**压缩为「方案概括 + 应用 + 缺点/局限」;查新笔记可保留稍长的摘录供自用核对,但须标注来源于公布站摘要。 + - **缺失时**:若某条 JSON **无** `abstract` 或为空(旧版页面 / 表格布局未解析到等),须在查新笔记中注明「该条无摘要字段」,并改用**详情页**或 **Google Patents** 等可核验来源补全理解后再写 1.1,**不得**留空理由含糊带过。 + +6. **链接与著录**:`EPUB_HITS_JSON` 命中项在 1.1 的「来源链接」**直接使用 JSON 的 `link` 字段**(国知局公布站 `epub.cnipa.gov.cn`);**禁止编造**。**不得**用 Google Patents URL **替换**已有 `link`。Google Patents 仅用于 **§B** 降级检索所得条目,或 JSON **无** `link`、仅知 `pub_number` 时的备选取址(如 `https://patents.google.com/patent/CN…/en`)。 + +### B. Google 学术与 Google Patents(**降级 / 补充**) + +在 **A 不可用或结果不足**时启用,与历史约定一致: + +1. **中文文献与学术**:[Google 学术搜索](https://scholar.google.com)(`scholar.google.com`)。 + - 用**中文关键词**、技术方案核心术语、应用场景;可组合 2–3 组查询。 + - 强化「中国」语境时可加:`中国`、`site:.cn`、`专利`、`CN`(与专利号区分使用)等,以实际命中为准。 + - 通过 **WebSearch** 或浏览器可用能力检索 Scholar;结果中优先选用**可打开、与标题/作者匹配**的条目链接。 +2. **中国专利公开文献(补充)**:[Google Patents](https://patents.google.com/) 检索中文标题、申请人或公开号(`CN…A` / `CN…B` 等),每条使用**稳定著录页 URL**。 +3. **其它来源**:英文文献、非中国专利等可继续用 Google Patents、出版社页面、DOI、arXiv 等 + WebSearch。 +4. **关键词构造**:技术方案核心术语、应用场景与方法名称,可组合 2–3 组查询。 + +## 分析要求 + +对检索到的、与方案**高度相关**的现有专利或公开文献逐项概括: + +- 专利号 / 文献标识 +- 技术方案要点(**若为国知局 JSON 且含 `abstract`,要点须与摘要理解一致**,见上文「`abstract` 必用」) +- 应用场景 +- **局限性** +- **公开源 URL(必填)**:每一条必须附带**至少一个可公开访问、与著录项一致**的链接,写入查新笔记与交底书 1.1,便于代理人复核。**禁止编造或猜测 URL**;写入前应在浏览器中打开确认页面可访问且对应同一文献/专利。 + +### 链接来源与格式(须准确) + +| 类型 | 推荐 URL 形式 | 说明 | +|------|----------------|------| +| 美国等专利(公开出版物号) | `https://patents.google.com/patent/US20240118920A1/en` | 将 `US20240118920A1` 替换为实际公开号;以 Google Patents 页面能打开且标题/摘要匹配为准。 | +| 中国专利(**§A 国知局 JSON 命中**) | JSON 的 **`link`**,如 `http://epub.cnipa.gov.cn/patent/CN119781913A` | 1.1「来源链接」**照抄 `link`**,勿改域名为 `patents.google.com`。 | +| 中国专利(**§B / WebSearch 补条**) | `https://patents.google.com/patent/CNXXXXXXXXXA/en`(或对应 B 型等) | 仅无国知局 `link`、经 §B 检索所得条目使用;勿用于替换 §A 命中项的 `link`。 | +| 学术论文(含 Scholar) | Scholar 条目页、出版社官方页或 **`https://doi.org/10.xxxx/...`** | Scholar 链接若重定向或镜像,以最终可长期解析的 DOI/出版社页为准。 | +| arXiv 预印本 | `https://arxiv.org/abs/2008.09213` | `abs` 页为规范条目页;勿用未经验证的镜像域名冒充官方。 | +| 期刊 / 会议 | 出版社 DOI:`https://doi.org/10.xxxx/...` 或官方摘要页 | 以 DOI 解析后页面与文献一致为准。 | + +文末给出:**检索总结**与**本发明与现有技术的本质区别**,与 1.1 结尾及 1.2 缺点呼应。 + +## 记录习惯 + +便于写进交底书:保留专利号、标题、**消化摘要后的**一两句方案概括(有 **`abstract`** 时概括须可追溯至该摘要);**每条另起一行或表格列给出「来源 URL」**。避免大段抄袭权利要求或整段粘贴官方摘要。 + +### 1.1「检索说明」写法(交付正文,必遵) + +写入交底书 **1.1** 开头的「检索说明」时,面向**代理人/审查员**表述,**不要**暴露 Agent 查新流程或本仓库工具实现。 + +- **须写**:实际使用的**公开数据库或渠道名称**(如「国家知识产权局专利公布公告系统」)、本案**主要检索词**(与 Step 5 用词一致或概括);若部分条目经 **Google Patents** 等公开页复核著录项,可一句带过。 +- **禁止写入 1.1 正文**:脚本/文件名(如 **`cnipa_epub_search.py`**、**`cnipa_epub_crawler.py`**)、「查新优先使用…检索工具」「是否触发 Google 学术降级」、Playwright、WebSearch、Agent、技能仓库名等**内部或流程元信息**。 +- **示例(须按本案替换检索词与渠道)**: + + > 检索说明:在**国家知识产权局专利公布公告系统**及 **Google Patents** 中,以「批任务调度」「异构集群调度」「任务队列重排」「负载感知调度」等为检索词进行检索;部分条目的公开文本与著录项以 Google Patents 页面复核。 + +查新笔记(Agent 内部或对话留档)仍可记录是否调用脚本、是否降级 WebSearch;**上述内容不得原样抄进交底书 1.1**。 diff --git a/.agents/skills/patent-disclosure-skill/prompts/project_scan.md b/.agents/skills/patent-disclosure-skill/prompts/project_scan.md new file mode 100644 index 0000000..99e4b59 --- /dev/null +++ b/.agents/skills/patent-disclosure-skill/prompts/project_scan.md @@ -0,0 +1,68 @@ +# 项目文档扫描(Step 2) + +## 目标 + +按优先级扫描并提取**可专利化**内容。**根据当前项目结构调整扫描路径**。 + +## 优先级表 + +| 优先级 | 文档类型 | 关注内容 | +|--------|----------|----------| +| 1 | 专利相关文档 | 专利点分析、已有交底书、专利申报建议、创新点汇总 | +| 2 | 详细设计/方案文档 | 详细设计、方案讨论、流程图、完整流程、技术对比分析 | +| 3 | 核心实现代码 | 算法与策略实现、业务逻辑与流程编排、数据处理与转换、规则引擎与决策逻辑、接口与集成设计、状态机与调度机制、性能优化与缓存策略、安全与权限控制等(依项目领域灵活识别) | +| 4 | 系统设计文档 | 系统设计、架构说明、模块划分、数据流与控制流 | + +## 扫描目标目录模版 + +执行时按项目实际目录填写: + +``` +[项目根目录]/ +├── [专利或文档目录]/ ← 专利点分析、交底书、申报建议 +├── [设计文档目录]/ ← 详细设计、方案讨论、流程图、技术对比 +├── [代码目录]/ ← 算法实现、业务逻辑、规则引擎、接口与集成、调度机制等 +└── [根目录]/ ← 系统设计、架构说明、模块与数据流 +``` + +## 执行提示 + +- 大仓库先用搜索 / 语义检索定位关键文件,再精读。 +- 记录**引用路径或文件名**,便于在交底书中写「参见某设计」时脱敏表述。 +- 凡出现 **`.docx` / `.pptx`**,**必须**按下一节 **「Office 文档」** 先转 Markdown 再读,不可跳过或只扫纯文本而漏掉 Office。 + +## Office 文档(.docx / .pptx):必先转换再读 + +**格式**:脚本仅支持 OOXML(**`.docx` / `.pptx`**)。旧版 **`.doc` / `.ppt`** 须先在 Office / WPS 中**另存为**新格式后再走下列流程。 + +Agent **不得**因「只能舒适读取文本」而**遗漏**项目内的 Word / PPT:**必须先转为 Markdown 再纳入扫描**,不能只扫 `.md` 与源码。 + +1. **发现**:在扫描目录内 **`Glob` 或列举** `*.docx`、`*.pptx`(含子目录,如 `docs/sample_*.docx`)。 +2. **转换(本仓库脚本)**:对每个文件执行(路径按实际替换;`${CLAUDE_SKILL_DIR}` 为技能根): + + ```bash + python3 ${CLAUDE_SKILL_DIR}/tools/docx_to_md.py -i "<路径>/<名>.docx" -o "<同目录或 docs>/<名>.md" + python3 ${CLAUDE_SKILL_DIR}/tools/pptx_to_md.py -i "<路径>/<名>.pptx" -o "<同目录或 docs>/<名>.md" + ``` + + 需已 `pip install -r requirements.txt`。输出旁会生成 **`{md 主名}_media/`**,内为嵌入图,**以生成的 `.md` 正文与图片引用为扫描依据**。 +3. **再读**:**`Read`** 上述新生成的 `.md`(及必要时扫一眼 `_media` 文件名用于脱敏引用),与原有 `.md`、代码**同等对待**,摘要进专利点材料表。 +4. **解析重点**:表格、编号列表、**PPT 每页标题与正文**、**Word 修订区以外的正文**、**备注**(`pptx_to_md` 会写入「备注」小节)——均属可专利化叙述来源。 + +## 图片与裸图目录(跳过单独识图) + +- **`sample_assets/`** 等目录下的 **独立 `.png` / `.jpg` / `.webp` 等**:**不作为** Step 2 必须逐个打开、OCR 或描述的对象(与 Word/PPT 内嵌图**通常重复**时更不必重复读图)。 +- **例外**:用户**点名**某图片路径,或某图**未**出现在任何已转换 Office 的 `_media` 中且对专利点明显关键时,再按需处理。 +- Word/PPT 转换后,嵌入图已在 **`![](相对路径)`** 中体现,**以 Markdown 文本扫描为主**即可。 + +## 示例案件 `knowledge/docs/`(练习时勿漏) + +若扫描路径包含 **`examples/example_batch_job_scheduler/knowledge/`**(或同构案件目录),须覆盖: + +| 路径 | 动作 | +|------|------| +| `docs/architecture.md` | 直接 Read | +| `docs/sample_architecture_review.docx` | **先** `docx_to_md.py` → 再 Read 生成的 `.md` | +| `docs/sample_scheduler_deck.pptx` | **先** `pptx_to_md.py` → 再 Read 生成的 `.md` | +| `docs/sample_assets/*.png` | **跳过**单独精读(内容已由 Office 内嵌图 + 转换 MD 覆盖) | +| `pkg/scheduler/*.go` | Read | diff --git a/.agents/skills/patent-disclosure-skill/prompts/template_reference.md b/.agents/skills/patent-disclosure-skill/prompts/template_reference.md new file mode 100644 index 0000000..1ae550e --- /dev/null +++ b/.agents/skills/patent-disclosure-skill/prompts/template_reference.md @@ -0,0 +1,220 @@ +# 专利交底书模版参考(脱敏版) + +本文档为技术交底书格式与章节要点参考,内容已脱敏,适用于多领域专利撰写。由 `disclosure_builder.md` 引用。 + +--- + +## 文档头部 + +```markdown +# 技术交底书 + +**案件名称**:[待填写]一种XXX方法及系统 + +**技术联系人**: +- 姓名:[待填写] +- 电话:[待填写] +- 邮箱:[待填写] + +**专利类型**:发明 + +--- + +## 注意事项 + +(1)交底书应使代理人能看懂,尤其是背景技术和详细技术方案,一定要写得全面、清楚、完整; +(2)技术的公开程度,应以本领域普通技术人员不需付出创造性劳动即可进行实施为准。 +(3)在与代理人沟通时,对于代理人咨询的技术问题,应给予回答并认真讲解,并且按要求及时正确地补充相应技术材料。 +``` + +在用户产出目录保存时,**`.md` / `.docx` 主文件名**应为 **`{案件名称规范化}_{YYYYMMDDHHmmss}`**(占位去掉、非法字符、过长截断及时间戳规则见 `disclosure_builder.md` **§7.3**,**凡交付均须时间戳**),避免与标题无关的固定名。 + +--- + +## 一、技术背景与现有技术 + +### 1.1 现有技术 + +- 检索渠道、链接格式与禁止事项以 Step 5 **`prior_art_search.md`** 为准(不在此重复)。 +- **检索说明**(建议置于 1.1 开头):写**公开数据库名称**与**检索词**;**勿**写 `cnipa_epub_search.py` 等脚本名或「查新降级」等流程用语(见 **`prior_art_search.md`「1.1 检索说明写法」**) +- 按**技术方向**分类列举(如:单标签方法、多标签方法、聚类策略等) +- 每条现有技术需包含:专利号 / 文献标识、申请方(或来源机构)、技术方案、应用场景、**局限性**、**公开源 URL(必填)** + - **国知局 `abstract`**:若 Step 5 JSON 含 **`abstract`**,该条「技术方案」等叙述**必须先充分理解摘要后**再概括(见 **`prior_art_search.md`**);交底书正文勿大段粘贴官方摘要全文。 + - **URL 要求**:与 `prior_art_search.md` 一致——每条**至少一个**可公开访问链接,**写入前验证**有效且与著录项一致;**禁止虚构链接**。 + - **正文呈现建议**:在每条方向下可用「**来源链接**:…」单独一行,或表格增列「链接」。 +- 结尾总结:检索总结、**本发明与现有技术的本质区别** + +### 1.2 现有技术存在的缺点 + +- 分点列举,与 1.1 的局限性呼应 +- 突出**核心缺陷**:现有技术无法解决的问题 + +--- + +## 二、本发明所要解决的技术问题 + +- 对应一中的缺点,逐条说明本发明的解决思路 +- 简明扼要,为第三章详细方案做铺垫 + +--- + +## 三、技术方案详细阐述 + +### 3.1 背景 + +- 应用场景的通用描述(脱敏:用分类A/B/C、场景X等) +- 本发明针对的问题与核心创新点概述 +- 若有人工环节,说明前提条件(如:样本需具有可区分显著特征) + +### 3.2 系统框图 + +- 使用 **fenced mermaid**(推荐 `flowchart TB` / `LR` + `subgraph` 分层);模块名抽象通用,避免业务术语 +- 定稿交付前经 **`tools/mermaid_render.py`** 转为 PNG 并**默认**生成 Word;**不需要**再附 ASCII 文字框图(Word 中以图为准) +- 布局宜层次清晰;复杂时可拆多张 mermaid 图 + +**mermaid 系统框图模版**(替换标题与模块名、连线;与 3.4 相同为 `` ```mermaid`` 围栏): + +```mermaid +flowchart TB + %% [系统名称] — 可在正文用 Markdown 小标题标注 + subgraph top[" "] + direction LR + A["[模块A]"] + B["[模块B]"] + C["[模块C]"] + end + subgraph mid[" "] + direction LR + A1["[子模块A1]"] + B1["[子模块B1]"] + C1["[子模块C1]"] + end + D["[模块D]"] + E["[模块E]"] + A --> B --> C + A --> A1 + B --> B1 + C --> C1 + A1 --> D + B1 --> D + C1 --> D + D --> E +``` + +### 3.3 模块功能说明 + +**重点**:各模块的**作用**和**模块间关联关系**,专利不强调输入输出。 + +- 作用:该模块在整体方案中的角色 +- 关联关系:上下游依赖、数据流/控制流、闭环关系 + +### 3.4 系统流程说明 + +#### 流程图 + +- 使用 **fenced mermaid** 代码块;**不要** ASCII 文字/箭头流程图。 +- 定稿交付前用仓库 **`tools/mermaid_render.py`**(本地 `mmdc`)转为 PNG 并**默认**生成 Word;失败时按终端提示用 **`md_to_docx.py`** 手动转换。 + +#### 流程说明 + +- 用文字简要说明各步骤或与图中节点的对应关系(**不替代**流程图图示) +- 流程涉及算法、评分、约束或形式化变量时,在 **3.4.1** 集中给出符号定义与主公式;须遵守 **`disclosure_builder.md` §7.7** + +### 3.4.1 符号与公式 + +**撰写顺序**:先 **符号与变量定义** → 再 **核心公式**(含式 (1))→ 再文字解释与流程衔接。 + +#### 符号表示例(Markdown 正文可直接采用) + +```markdown +#### (1)任务侧符号 + +| 符号 | 含义 | 下标/量纲 | +|------|------|-----------| +| \(i\) | 任务索引 | \(i=1,\ldots,N\) | +| \(b_{i,\mathrm{cpu}}\) | 任务 \(i\) 的 CPU 需求权重 | 无量纲,\(b_{i,\mathrm{cpu}}>0\) | +| \(b_{i,\mathrm{mem}}\) | 任务 \(i\) 的内存需求权重 | 同上 | + +#### (2)节点侧符号 + +| 符号 | 含义 | 下标/量纲 | +|------|------|-----------| +| \(j\) | 计算节点索引 | \(j=1,\ldots,M\) | +| \(a_{j,\mathrm{cpu}}\) | 节点 \(j\) 的 CPU 资源饱和度 | 无量纲,\(a_{j,\mathrm{cpu}}\le 0\) 表示有余量 | +``` + +#### 公式正/反例(体例必遵) + +| 场景 | ✅ 推荐 | ❌ 避免 | +|------|---------|---------| +| CPU 维度权重 | \(b_{i,\mathrm{cpu}}\) | \(b_i^{cpu}\)(上标易被读作幂次) | +| 节点饱和度 | \(a_{j,\mathrm{cpu}} \le 0\) | \(a_j^{cpu} \le 0\) | +| 多维度并列 | \(b_{i,\mathrm{cpu}},\, b_{i,\mathrm{mem}}\) | \(b_i^{cpu}, b_i^{mem}\) | +| 块级主公式 | `\[ M_{ij} = \alpha b_{i,\mathrm{cpu}} + \beta a_{j,\mathrm{cpu}} \tag{1} \]` 单行 | 块内多行 `\\` 换行堆叠(渲染易失败) | +| 逻辑连接 | 公式外写「且 \(a_{j,\mathrm{mem}}\le 0\)」 | 公式内 `\text{且}` | + +**行内/块级分隔符**:全文统一 `\(...\)` / `\[...\]` **或** `$...$` / `$$...$$` 二选一;与 **`disclosure_builder.md` §7.7** 一致。 + +### 3.5 关键技术参数 + +- 置信度/阈值类:含义、取值范围 +- 算法参数:公式、约束条件 +- 参数表须设 **「符号」列**,与 **3.4.1 符号表逐字同形**(勿在 3.5 改用 `^{cpu}` 而上文用下标) +- 确保与正文公式、实施例数值一致 + +--- + +## 四、与现有技术相比的优点 + +- 先概括性观点,再分点详述 +- 与第二章解决的问题、第五章保护点呼应 +- 技术细节以第三章为准,本章以论点为主 + +--- + +## 五、技术关键点和欲保护点 + +- 列出核心创新点,每点简明定义 +- 详细技术方案引用第三章(如「具体实现见 3.4.1」) +- 避免与第三章重复大段技术细节 + +--- + +## 六、其它 + +### 实施例 + +- 应用场景(脱敏) +- 已知类别、无标签数据规模(脱敏) +- 系统流程简述 +- **技术效果**:量化或定性说明 +- **参数设置示例**:注明「不作为权利要求限制」 + +--- + +## 脱敏检查表 + +| 检查项 | 脱敏方式 | +|--------|----------| +| 业务/行业名称 | 抽象为通用描述 | +| 具体分类标签 | 分类A、分类B、分类C 等 | +| 具体数值 | 用「一定规模」「预设值」等 | +| 公司/产品名 | 删除或「某系统」 | + +--- + +## 交付正文禁忌(勿写入交底书) + +- **禁止**在全文任意位置(尤其**文末**)加入技能仓库、示例仓库、`patent-disclosure-skill`、`examples/` 路径、「教学/虚构示例」「不构成法律或技术承诺」等**元脚注**;交付物视为正式技术交底书文稿,**止于业务章节**。 + +## 公式与参数一致性检查 + +- 全文公式表述统一(如:置信度权重、密度调整系数) +- **符号体例**:资源维度用下标 `_{\mathrm{cpu}}` 等,**无** `^{cpu}`/`^{mem}` 类上标维度写法 +- **符号表完整**:3.4.1 已定义符号;式 (1) 及后文每个符号均在表中出现;**无**同一字母多义 +- **公式正确性与逻辑**:各式无笔误;不等式方向与文字一致;公式与 3.4 流程/3.3 模块可互推;边界情形不矛盾 +- **跨节同形**:3.4.1、3.5「符号」列、第六章实施例与正文公式 **逐字一致** +- 阈值范围一致(如 0.5–1.5、0.8–1.2) +- 参数命名统一(避免同义不同名混用) +- LaTeX 分隔符全文统一(`\(...\)`/`\[...\]` 或 `$`/`$$` 二选一) +- 实施例数值与 3.5 节对应 diff --git a/.agents/skills/patent-disclosure-skill/references/ipc_application_hints.yaml b/.agents/skills/patent-disclosure-skill/references/ipc_application_hints.yaml new file mode 100644 index 0000000..3480f38 --- /dev/null +++ b/.agents/skills/patent-disclosure-skill/references/ipc_application_hints.yaml @@ -0,0 +1,127 @@ +# IPC/CPC 前缀 → 行业坐标与应用场景提示(离线,供专利解读「技术落地线索」) +# 匹配:manifest.ipc_codes 前缀优先,否则在标题+摘要+技术领域文本中搜关键词 + +hints: + - ipc_prefix: H01M + keywords: [电池, 电芯, 隔膜, 储能, 锂离子] + industry: 电化学储能 + typical_modules: + - 电芯内部的隔膜/正负极/电解液 + - 电池模组与热管理 + - BMS 与充放电管理 + user_scenarios: + - 新能源车动力电池包 + - 储能电站集装箱 + search_hints: + - 电池材料 技术白皮书 + - 电芯结构 拆解 + + - ipc_prefix: G06F + keywords: [软件, 操作系统, 调度, 内存, 处理器, 数据库] + industry: 计算机软件与系统 + typical_modules: + - 操作系统内核/驱动 + - 应用服务与中间件 + - 数据库与存储引擎 + user_scenarios: + - 服务器/云主机上的后台服务 + - 终端设备上的系统或 App + search_hints: + - 技术架构 白皮书 + - 开发者文档 + + - ipc_prefix: G06Q + keywords: [电商, 支付, 金融, 风控, 推荐] + industry: 商业方法与应用软件 + typical_modules: + - 交易与结算模块 + - 风控与反欺诈 + - 推荐与排序服务 + user_scenarios: + - 线上购物/支付 App + - 企业 ERP/财务系统 + search_hints: + - 解决方案 产品介绍 + + - ipc_prefix: H04L + keywords: [通信, 网络, 协议, 5G, 路由] + industry: 通信与网络 + typical_modules: + - 基站/终端协议栈 + - 路由与交换设备 + - 网络安全模块 + user_scenarios: + - 手机/路由器联网 + - 数据中心互联 + search_hints: + - 通信技术 白皮书 + + - ipc_prefix: H04 + keywords: [天线, 射频, 基站, 调制] + industry: 通信与电子 + typical_modules: + - 射频前端 + - 天线与波束成形 + user_scenarios: + - 移动通信终端 + - 基站设备 + search_hints: + - 无线通信 产品 + + - ipc_prefix: G06N + keywords: [神经网络, 深度学习, 机器学习, 大模型] + industry: 人工智能 + typical_modules: + - 模型训练与推理引擎 + - 特征提取与嵌入层 + user_scenarios: + - 云端 AI 服务 + - 端侧智能应用 + search_hints: + - AI 模型 技术博客 + + - ipc_prefix: B23 + keywords: [加工, 机床, 焊接, 切削] + industry: 机械制造与加工 + typical_modules: + - 机床主轴/刀具 + - 焊接/切割工位 + user_scenarios: + - 汽车零部件产线 + - 通用机械加工车间 + search_hints: + - 加工工艺 设备 + + - ipc_prefix: A61 + keywords: [医药, 医疗, 器械, 诊断, 临床] + industry: 生物医药与医疗器械 + typical_modules: + - 给药装置/植入物 + - 体外诊断试剂与仪器 + user_scenarios: + - 医院诊疗流程 + - 家用医疗器械 + search_hints: + - 医疗器械 注册 产品 + + - ipc_prefix: C08 + keywords: [聚合物, 树脂, 涂料, 复合材料] + industry: 高分子材料与化工 + typical_modules: + - 基材与涂层配方 + - 复合层压结构 + user_scenarios: + - 工业涂料与胶粘剂 + - 轻量化结构件 + search_hints: + - 材料 技术参数 + + - ipc_prefix: DEFAULT + keywords: [] + industry: 通用技术 + typical_modules: + - 按权利要求中的功能模块理解 + user_scenarios: + - 结合说明书实施例与背景技术推断 + search_hints: + - 技术 解决方案 diff --git a/.agents/skills/patent-disclosure-skill/references/patent_domain_rules.yaml b/.agents/skills/patent-disclosure-skill/references/patent_domain_rules.yaml new file mode 100644 index 0000000..694dbb8 --- /dev/null +++ b/.agents/skills/patent-disclosure-skill/references/patent_domain_rules.yaml @@ -0,0 +1,51 @@ +# 专利解读笔记领域路由(库内 Research/Patents/ 下的一级子目录) +# 匹配顺序:application 关键词优先,再 ipc_prefix + +domains: + - label: 软件与互联网 + ipc_prefixes: ["G06F", "G06Q", "H04L"] + keywords: + - 软件 + - 数据库 + - 神经网络 + - 大语言模型 + - 调度 + - 分布式 + + - label: 通信与电子 + ipc_prefixes: ["H04", "H03", "G06N"] + keywords: + - 通信 + - 基站 + - 天线 + - 芯片 + - 电路 + + - label: 机械与制造 + ipc_prefixes: ["B23", "B25", "F16", "B65"] + keywords: + - 机械 + - 加工 + - 模具 + - 传动 + + - label: 化工与材料 + ipc_prefixes: ["C01", "C08", "C09", "H01M"] + keywords: + - 电池 + - 隔膜 + - 涂层 + - 聚合物 + - 催化 + + - label: 生物医药 + ipc_prefixes: ["A61", "C12", "C07K"] + keywords: + - 医药 + - 抗体 + - 基因 + - 临床 + + - label: 未分类 + ipc_prefixes: [] + keywords: [] diff --git a/.agents/skills/patent-disclosure-skill/references/patent_obsidian_format.md b/.agents/skills/patent-disclosure-skill/references/patent_obsidian_format.md new file mode 100644 index 0000000..553715b --- /dev/null +++ b/.agents/skills/patent-disclosure-skill/references/patent_obsidian_format.md @@ -0,0 +1,185 @@ +# 专利解读 Obsidian 笔记格式 + +## 目录与命名 + +- 库内默认:`Research/Patents/<领域>/<公开号>/<公开号>_解读_.md` +- 同目录:`images/`(附图)、`<公开号>_图谱.canvas`(专利族图谱) +- 同目录:`<公开号>_说明书段落.md`、`<公开号>_权项锚点.md`(悬停预览旁路;不占解读主文版面;二者与旁路 JSON 不进关系图) +- 同目录:`source/<公开号>.pdf`(官方原文;入库**默认拷贝**,可用 `--no-copy-source-pdf` 关闭) +- 库级:`Research/Patents/patents.base`(Bases 仪表盘)、`.obsidian/snippets/patent-reader.css` +- 领域路由:见 `references/patent_domain_rules.yaml` + +## YAML Frontmatter + +```yaml +--- +tags: + - patents/化工与材料 + - patent/evidence/full + - patent/speculative +aliases: + - CN107785522B +cssclasses: + - patent-reader +pub_number: CN107785522B +domain: 化工与材料 +ipc: H01M10/0525 +assignees: + - 某某科技有限公司 +read_date: 2026-07-21 +perspective: 入门 +evidence_scope: full_text +confidence_speculative: false +--- +``` + +- `evidence_scope`:`full_text` | `abstract_only` | `partial` +- `confidence_speculative`:附录 B 含中/低置信公开线索时为 `true` +- 标签:`patent/evidence/full|abstract|partial`;有推测时加 `patent/speculative` + +## 著录项卡片(L0) + +```markdown +> [!patent-meta] 著录项 +> - **公开号**:CN… +> - **领域**:… +> - **IPC**:… +``` + +CSS 片段 `patent-reader` 由解读**入库时自动**复制并启用。 + +## 权利要求树 + +推荐结构(`render_claim_tree_markdown` / 入库自动写入)——**只保留一份信息**: + +| 结构 | 权 | 本项新增 | +| --- | ---: | --- | +| `◆` | 1 | … | +| `├─` | 2 | … | +| `└─` | 3 | … | + +- `◆` = 独立权;`├─`/`└─`/`│` = 从属层级 +- **父子结构**:抽取后须 **Agent 校对** `claim_tree.json`(`review.by=agent`),再跑 `validate_claim_tree.py --write --require-review`;多引用(「权1或2」)由 Agent 选定单一 `parent` +- **「本项新增」**:Agent 主路径写 `claim_deltas.json`(或 `note_plan.claim_deltas` / node.`delta`);入库优先采用;缺省权号才启发式从原文截句 +- 独立权细节只在**第四节**展开 +- mermaid 默认不进正文(可生成 `claim_mermaid.mmd` 备用) +- Canvas「权项」卡与第三节同构(树形表,短句);入库时旁路保存 `claim_tree.json` + +## 第四节 Callout + +```markdown +> [!patent-claim] 权利要求 1 +> 【CN…·权利要求1】… +``` + +## 用户可见标题(硬性) + +章节标题、小节标题、callout 标题**只用简洁名称**,禁止加给 Agent 的说明性括号,例如不要写: + +- ~~`## 七、和现有技术的差别(若能从原文读出)`~~ → `## 七、和现有技术的差别` +- ~~`## 二、连贯叙事(故事线)`~~ → `## 二、连贯叙事` +- ~~`## 九、技术应用场景(专利内依据)`~~ → `## 九、技术应用场景` + +写作要求写在 prompt / 模板正文的「写作提示」里,**不要**写进交付笔记标题。入库脚本 `sanitize_user_facing_titles` 会幂等清理常见旧标题。 + +## 说明书段落引用与悬停预览 + +- **写法**:`说明书 0002` 或 `说明书 0002–0004`(禁止裸 `[0002]`) +- **入库产物**:同目录 `{公开号}_说明书段落.md`;单段 `### 0002` + 块 `^p0002`;区间另有合并节 + `^r0002-0004`(**默认仅含本解读引用**) +- **改写结果(单链)**:`[[…#^p0002|说明书 0002]]` / `[[…#^r0002-0004|说明书 0002–0004]]` +- **悬停预览**:开启核心插件 **页面预览(Page preview)** 后,**按住 Ctrl** 再悬停链接即可浮出原文(详见 `{公开号}_说明书段落` 文首「使用说明」) +- 导航只链「说明书段落」(不加括号说明);抽取阶段另写 RUN 内 `description_paragraphs.json` +- **权 N**:`[[{公开号}_权项锚点#^claim-N|权N]]`(旁路笔记,文首含页面预览说明) +- **图 N**:`[[#图N|图N]]`(解读正文附图区 `### 图N` + 嵌入) +- **表格内链接**:别名分隔符须转义为 `\|`(如 `[[…#^claim-2\|权2]]`),否则会拆列露路径 + +## 第九节:应用场景 + +整节置于: + +```markdown +> [!grounding] 应用场景 +> … +``` + +**禁止 URL**。段落依据用 `说明书 0002` / `实施例 …` / `背景 …`。 + +## 第十节 B:推测线索 + +`validate_public_clues.py` 按置信度排序后**默认最多保留 3 条**。 + +**摘要主路径**:Agent 按 `patent_plain_reader.md` 第 2 步自主规划打开 URL,写入 `summary` / `status=agent_fetched` 等字段。 +**脚本降级**:仅 `--fetch-clues-fallback` / `--fetch-fallback`,且只处理缺 `summary` 的条目。 + +入库/materialize 负责结构化落地与 **L1–L4 融合**: + +| 层 | 位置 | +| --- | --- | +| L1 | 导航「公开线索(N 条)」+ 文首 tip 入口 | +| L2 | 一/二/七/八/九氛围旁注:各节角度不同,线索摘要**去重分列**;术语旁注优先用 Agent `anchor_fits` | +| L3 | 第四节权项 / 第六节特征旁注:贴合句**优先** `public_clues.json` → `anchor_fits`(Agent 读页后写入);缺省才启发式;特征块按线索去重;禁止空号 F1–F6 | +| L4 | 附录 B + `clues/` + Canvas「公开线索」分组(总表,不重复展开贴合句) | + +```markdown +> [!warning]- 公开检索线索 +> 详情见 [[clues/_线索索引|线索文件夹]] +> - **线索**:[[clues/01-…|…]] — 置信度:中 — [来源](URL) — 理由:… +``` + +## 附图(P1/P2) + +`extract_patent_figures.py`(caption+bbox 附图裁切): + +- `decision=insert` + 质量 usable → 笔记嵌入 `![[images/…]]` +- 扫描件(无可靠图注):入库启用**扫描件整页预览**(`--scan-pages` 或无 insert 时自动),第六节追加: + +```markdown +### 附图 + +> [!tip] 扫描 PDF +> 官方文本多为扫描件,下列为整页渲染预览(非矢量裁切图)。 + +![[images/page_001_xref_01.png]] +*第 1 页* +``` + +- 仅当确无页面 PNG 时保留 `[!figure]` 占位,避免长期只显示 `insert=0`。 + +## 术语网(P0) + +- 目录:`Research/术语/`(`PATENT_READER_GLOSSARY_DIR`) +- 入库自动建 stub、Canvas **file** 节点、第五节 wikilink +- stub 正文优先写入第五节「本文含义」一句,避免长期停留在「(待补充…)」空壳 +- 索引:`Research/术语/_术语索引.md` + +## Canvas(L2) + +- 路径:`<公开号>_图谱.canvas`(导航须带 `.canvas` 后缀,避免点出空 `.md`) +- 由 `build_patent_canvas.py` / 入库脚本生成,建议包含: + - **叙事分组**(问题 / 思路 / 怎么做 / 效果 / 差别)— 从笔记一、二、七节收获 + - **精简中心卡**(公开号 + 一句话 + 链到笔记,避免属性墙) + - 著录 / 权项摘要;**术语 text 卡**(本文含义一句 + 术语页链接) + - 关联专利精简卡;扫描附图默认不挂画布 + - hex 配色 + group 分区(叙事 / 术语 / 关联) +- **交付后常问(推荐)**:库内 ≥2 篇时反问;用户同意后 `link_patent_notes.py` 刷新单篇边标签,并生成库级 `Research/Patents/_专利关联.canvas`(富文本专利卡 + 关联桥卡写明依据,非裸 file 预览) + +## 相关专利(交付后常问 · link_patent_notes) + +Frontmatter: + +```yaml +related_pubs: + - CN107785522B +``` + +笔记中追加节 `## 相关专利`(表格 + wikilink)。规则边:同申请人 / IPC / 共术语 / 正文互引;可用 `--model-scores` 合并模型判定。主流程不自动写入;交付后**须反问**,同意再跑。 + +## 索引与仪表盘(L1) + +- `_专利解读索引.md` 嵌入 `![[Research/Patents/patents.base#全部专利解读]]` +- Bases / Dataview 证据列显示中文(`evidence_label`:全文 / 仅摘要 / 部分);含推测列用 `speculative_label`(是 / 否) +- Dataview 回退见索引模板 + +## 交付后插件引导 + +见 `prompts/obsidian_plugin_guide.md`、`docs/obsidian-setup-guide.md`。 diff --git a/.agents/skills/patent-disclosure-skill/references/patent_pdf_sources.yaml b/.agents/skills/patent-disclosure-skill/references/patent_pdf_sources.yaml new file mode 100644 index 0000000..f489bfb --- /dev/null +++ b/.agents/skills/patent-disclosure-skill/references/patent_pdf_sources.yaml @@ -0,0 +1,66 @@ +# 专利全文 PDF 取证源(解读模式 fetch_patent_pdf.py 使用) +# 无稳定「国内免费全文官方镜像」可当默认源;国知局 epub 用于核验元数据/摘要。 +# priority 越小越优先;Agent 勿每次现写下载脚本。 + +sources: + - id: google_patents_page + priority: 10 + role: primary + status: proven + name: Google Patents 详情页 + page_templates: + - "https://patents.google.com/patent/{pub}/zh" + - "https://patents.google.com/patent/{pub}/en" + - "https://patents.google.com/patent/{pub}" + notes: | + 从 HTML 解析 citation_pdf_url / pdfLink / patentimages CDN。 + 直链 https://patents.google.com/patent/{pub}.pdf 常 404,勿当作主路径。 + 国内网络偶发超时,可重试或换时段。 + + - id: google_patentimages_cdn + priority: 20 + role: download + status: proven + name: Google Patents PDF CDN + host: patentimages.storage.googleapis.com + url_pattern: "https://patentimages.storage.googleapis.com//{pub}.pdf" + notes: | + 实战多次成功(含示例 CN119961390A / CN119961396A / CN114552122A)。 + hash 路径须从详情页解析,不可臆造。 + + - id: cnipa_epub + priority: 30 + role: verify_meta + status: proven + name: 中国专利公布公告(国知局) + page_templates: + - "http://epub.cnipa.gov.cn/patent/{pub}" + tool: tools/cnipa_epub_search.py + notes: | + 核验公开号、标题、摘要、申请人可靠;详情页通常不提供可脚本化全文 PDF。 + 交底书查新与解读取证的元数据优先源。 + + - id: user_local_pdf + priority: 5 + role: override + status: proven + name: 用户本地 PDF / 已下载文件 + notes: 若用户已提供路径,跳过联网下载,直接 extract。 + +# 非默认 / 不稳定(记录以免重复踩坑;脚本默认不依赖) +unstable_or_skip: + - id: patentguru + status: unreliable + notes: 部分公开号 404,不作默认备选。 + - id: google_patent_dot_pdf + status: often_404 + notes: patents.google.com/patent/{pub}.pdf 常失败。 + - id: commercial_dbs + status: account_required + notes: 智慧芽/incoPat 等需账号,不写入默认技能链路。 + +# 示例专利已知 CDN(仅 examples;通用下载仍应解析页面) +known_cdn_examples: + CN119961390A: "https://patentimages.storage.googleapis.com/58/1b/9b/07a9f35635df34/CN119961390A.pdf" + CN119961396A: "https://patentimages.storage.googleapis.com/3f/29/d0/a2461c5080d73d/CN119961396A.pdf" + CN114552122A: "https://patentimages.storage.googleapis.com/c2/6c/51/75412585086edf/CN114552122A.pdf" diff --git a/.agents/skills/patent-disclosure-skill/requirements.txt b/.agents/skills/patent-disclosure-skill/requirements.txt new file mode 100644 index 0000000..6721cc1 --- /dev/null +++ b/.agents/skills/patent-disclosure-skill/requirements.txt @@ -0,0 +1,9 @@ +# 使用 tools/md_to_docx.py、docx_to_md.py、pptx_to_md.py、math_render.py 时安装 +python-docx>=1.1.0 +# 定稿图示(mermaid → PNG)须 Node:在 tools/ 执行 npm install 或 npx,见 tools/README.md +mammoth>=1.6.0 +python-pptx>=0.6.21 +matplotlib>=3.8.0 + +# 可选:Step 5 国知局 epub.cnipa.gov.cn 抓取(Playwright,体积较大;未写入本文件以免默认全量安装) +# pip install -r tools/requirements-cnipa.txt && python -m playwright install chromium diff --git a/.agents/skills/patent-disclosure-skill/tests/fixtures/patent_reader_sample.txt b/.agents/skills/patent-disclosure-skill/tests/fixtures/patent_reader_sample.txt new file mode 100644 index 0000000..9cddb4a --- /dev/null +++ b/.agents/skills/patent-disclosure-skill/tests/fixtures/patent_reader_sample.txt @@ -0,0 +1,16 @@ +摘要 +本发明涉及一种示例锂离子电池隔膜,通过在多孔基膜上涂覆耐热层以提升热稳定性。 + +权利要求书 + +1.一种锂离子电池隔膜,其特征在于,所述锂离子电池隔膜包括多孔基膜以及覆盖在所述多孔基膜的至少一侧表面上的耐热层;所述耐热层含有耐高温聚合物和纳米材料。 + +2.根据权利要求1所述的锂离子电池隔膜,其特征在于,所述耐高温聚合物与纳米材料的重量比为99:1-8:2。 + +说明书 + +技术领域 +本发明属于电化学储能领域。本文中,「耐热层」是指覆盖在基膜表面的无机或有机涂层。 + +具体实施方式 +实施例1:在聚乙烯多孔膜上涂覆聚酰亚胺与氧化铝纳米颗粒的混合涂层,经干燥后得到隔膜样品。 diff --git a/.agents/skills/patent-disclosure-skill/tests/test_cnipa_epub_chain.py b/.agents/skills/patent-disclosure-skill/tests/test_cnipa_epub_chain.py new file mode 100644 index 0000000..71b5031 --- /dev/null +++ b/.agents/skills/patent-disclosure-skill/tests/test_cnipa_epub_chain.py @@ -0,0 +1,47 @@ +# -*- coding: utf-8 -*- +""" +串联验证:与同目录能力对齐——运行 `tools/cnipa_epub_search.py`(一步:爬取 + 解析,**不落盘 HTML**)。 + +需已安装:pip install -r tools/requirements-cnipa.txt && python -m playwright install chromium + +在仓库根目录执行: + + python tests/test_cnipa_epub_chain.py + +可选参数指定关键词: + + python tests/test_cnipa_epub_chain.py 批处理 + +无参数时本测试脚本仍传入「知识图谱」以便本地联调;**命令行直接运行** `cnipa_epub_search.py` **须自带关键词**(不设默认)。技能要求 Agent 在 Step 5 **每词一次 Bash、自行合并 JSON**(见 `prior_art_search.md`);本测试单次传参仅为联调。 +""" +from __future__ import annotations + +import os +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "tools")) + + +def main(argv: list[str] | None = None) -> int: + argv = argv if argv is not None else sys.argv[1:] + if argv: + extra = argv + else: + extra = ["知识图谱"] + os.environ.setdefault("EPUB_WAF_MAX_WAIT_SEC", "180") + + try: + import playwright # noqa: F401 + except ImportError: + print("请先安装: pip install -r tools/requirements-cnipa.txt", file=sys.stderr) + return 1 + + from cnipa_epub_search import main as search_main + + return search_main(extra) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.agents/skills/patent-disclosure-skill/tests/test_cnipa_epub_crawler.py b/.agents/skills/patent-disclosure-skill/tests/test_cnipa_epub_crawler.py new file mode 100644 index 0000000..652079b --- /dev/null +++ b/.agents/skills/patent-disclosure-skill/tests/test_cnipa_epub_crawler.py @@ -0,0 +1,58 @@ +# -*- coding: utf-8 -*- +from __future__ import annotations + +import sys +import unittest +from pathlib import Path +from unittest.mock import MagicMock + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "tools")) + +from cnipa_epub_crawler import ( # noqa: E402 + EPUB_TITLE_NO_HIT, + EPUB_TITLE_RESULT, + _RESULT_PAGE_READY_JS, + submit_index_search, +) + + +class SubmitIndexSearchTests(unittest.TestCase): + def test_uses_committed_navigation_and_result_ready_wait(self) -> None: + page = MagicMock() + + submit_index_search(page, "数据标注") + + page.expect_navigation.assert_called_once_with(timeout=120_000, wait_until="commit") + page.wait_for_function.assert_called_once() + page.wait_for_load_state.assert_not_called() + page.wait_for_timeout.assert_not_called() + + def test_wait_checks_result_dom_not_title_only(self) -> None: + page = MagicMock() + + submit_index_search(page, "数据标注") + + js = page.wait_for_function.call_args.args[0] + self.assertIs(js, _RESULT_PAGE_READY_JS) + self.assertIn("#result", js) + self.assertIn("div.item", js) + self.assertIn("h1.title", js) + self.assertIn("titles.noHit", js) + self.assertIn("titles.result", js) + + def test_passes_title_constants_as_wait_arg(self) -> None: + page = MagicMock() + + submit_index_search(page, "测试") + + kwargs = page.wait_for_function.call_args.kwargs + self.assertEqual(kwargs["timeout"], 120_000) + self.assertEqual( + kwargs["arg"], + {"result": EPUB_TITLE_RESULT, "noHit": EPUB_TITLE_NO_HIT}, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/.agents/skills/patent-disclosure-skill/tests/test_desc_paragraphs.py b/.agents/skills/patent-disclosure-skill/tests/test_desc_paragraphs.py new file mode 100644 index 0000000..ff71361 --- /dev/null +++ b/.agents/skills/patent-disclosure-skill/tests/test_desc_paragraphs.py @@ -0,0 +1,117 @@ +# -*- coding: utf-8 -*- +"""说明书段落锚点与引用改写。""" +from __future__ import annotations + +import sys +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "tools" / "patent_reader")) + +from desc_paragraphs import ( # noqa: E402 + format_citation_wikilinks, + materialize_description_paragraphs, + parse_cited_paragraph_numbers, + split_cn_description_paragraphs, + upgrade_legacy_citation_wikilinks, + wikilink_description_citations, +) + + +class DescParagraphsTest(unittest.TestCase): + def test_split_paragraphs(self) -> None: + text = ( + "背景技术\n[0002]\n第一段内容。\n[0003]\n第二段内容。\n" + "说明书 1/9 页\nCN 119961396 A\n[0004]\n第三段。" + ) + paras = split_cn_description_paragraphs(text) + self.assertIn("0002", paras) + self.assertIn("第一段", paras["0002"]) + self.assertIn("第二段", paras["0003"]) + self.assertNotIn("说明书 1/9", paras["0003"]) + + def test_parse_citations(self) -> None: + note = "难普及([0002]–[0004])。另见说明书 0056 与 [0128]–[0131]。" + cited = parse_cited_paragraph_numbers(note) + self.assertEqual( + cited, + ["0002", "0003", "0004", "0056", "0128", "0129", "0130", "0131"], + ) + + def test_wikilink_rewrite_range_single_link(self) -> None: + pub = "CN119961396A" + src = "问题([0002]–[0004])。单段说明书 0056。" + out = wikilink_description_citations(src, pub=pub) + self.assertIn( + "[[CN119961396A_说明书段落#^r0002-0004|说明书 0002–0004]]", out + ) + self.assertIn( + "[[CN119961396A_说明书段落#^p0056|说明书 0056]]", out + ) + self.assertNotIn("[0002]", out) + self.assertNotIn("]]–[[", out) + out2 = wikilink_description_citations(out, pub=pub) + self.assertEqual( + out.count("#^r0002-0004"), out2.count("#^r0002-0004") + ) + + def test_upgrade_legacy_split_links(self) -> None: + pub = "CN1" + old = ( + "([[CN1_说明书段落#0002|说明书 0002]]–" + "[[CN1_说明书段落#0004|0004]])。" + ) + new = upgrade_legacy_citation_wikilinks(old, pub=pub) + self.assertEqual( + new, + "([[CN1_说明书段落#^r0002-0004|说明书 0002–0004]])。", + ) + + def test_format_single(self) -> None: + self.assertEqual( + format_citation_wikilinks("CN1", "0007"), + "[[CN1_说明书段落#^p0007|说明书 0007]]", + ) + + def test_materialize_cited_only(self) -> None: + paras = { + "0002": "法规更新。", + "0003": "人力不足。", + "0004": "成本高。", + "0099": "未引用。", + } + content = ( + "## Obsidian 导航\n\n" + "- [[Research/Patents/_专利解读索引|专利解读索引]]\n" + "- [[x/CN1_图谱.canvas|专利族图谱]]\n\n" + "## 二、连贯叙事\n\n" + "难普及(说明书 0002–0004)。\n" + ) + with tempfile.TemporaryDirectory() as td: + base = Path(td) + new_content, path, cited = materialize_description_paragraphs( + content=content, + pub="CN1", + note_dir=base, + paragraphs=paras, + cited_only=True, + ) + self.assertIsNotNone(path) + assert path is not None + body = path.read_text(encoding="utf-8") + self.assertIn("### 0002", body) + self.assertIn("^p0002", body) + self.assertIn("### 0002–0004", body) + self.assertIn("^r0002-0004", body) + self.assertNotIn("### 0099", body) + self.assertEqual(cited, ["0002", "0003", "0004"]) + self.assertIn( + "[[CN1_说明书段落#^r0002-0004|说明书 0002–0004]]", + new_content, + ) + + +if __name__ == "__main__": + raise SystemExit(unittest.main()) diff --git a/.agents/skills/patent-disclosure-skill/tests/test_extract_patent_text.py b/.agents/skills/patent-disclosure-skill/tests/test_extract_patent_text.py new file mode 100644 index 0000000..cd8d17f --- /dev/null +++ b/.agents/skills/patent-disclosure-skill/tests/test_extract_patent_text.py @@ -0,0 +1,49 @@ +# -*- coding: utf-8 -*- +"""extract_patent_text.py 冒烟测试。""" +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SAMPLE = ROOT / "tests" / "fixtures" / "patent_reader_sample.txt" + + +def main() -> int: + out = ROOT / "tmp" / "test_patent_reader" + if out.exists(): + import shutil + + shutil.rmtree(out) + cmd = [ + sys.executable, + str(ROOT / "tools" / "patent_reader" / "extract_patent_text.py"), + "-i", + str(SAMPLE), + "-o", + str(out), + "--pub-number", + "CN999999999B", + ] + r = subprocess.run( + cmd, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) + if r.returncode != 0: + print(r.stderr or r.stdout) + return r.returncode + tree = json.loads((out / "claim_tree.json").read_text(encoding="utf-8")) + assert len(tree.get("roots", [])) >= 1 + manifest = json.loads((out / "source_manifest.json").read_text(encoding="utf-8")) + assert manifest.get("claim_count", 0) >= 2 + print("OK extract_patent_text smoke") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.agents/skills/patent-disclosure-skill/tests/test_fetch_patent_pdf.py b/.agents/skills/patent-disclosure-skill/tests/test_fetch_patent_pdf.py new file mode 100644 index 0000000..a00c3be --- /dev/null +++ b/.agents/skills/patent-disclosure-skill/tests/test_fetch_patent_pdf.py @@ -0,0 +1,98 @@ +"""fetch_patent_pdf:HTML/CDN 解析与 known_cdn 兜底(不依赖外网)。""" +from __future__ import annotations + +import sys +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "tools" / "patent_reader")) + +from fetch_patent_pdf import ( # noqa: E402 + extract_pdf_urls_from_html, + load_known_cdn_examples, + normalize_pub, + resolve_pdf_url, +) + + +SAMPLE_HTML = """ + + +CN119961390A - demo +Download PDF + +""" + + +class FetchPatentPdfTest(unittest.TestCase): + def test_normalize_pub(self) -> None: + self.assertEqual(normalize_pub(" cn119961390a "), "CN119961390A") + + def test_extract_cdn_from_html(self) -> None: + urls = extract_pdf_urls_from_html(SAMPLE_HTML, "CN119961390A") + self.assertTrue(urls) + self.assertIn("CN119961390A.pdf", urls[0]) + self.assertTrue(urls[0].startswith("https://patentimages.storage.googleapis.com/")) + + def test_load_known_cdn_examples(self) -> None: + known = load_known_cdn_examples() + self.assertIn("CN114552122A", known) + self.assertTrue(known["CN114552122A"].endswith(".pdf")) + + def test_resolve_falls_back_to_known_cdn(self) -> None: + """页面全部失败时,用 known_cdn_examples 兜底。""" + + def boom(*_a, **_k): + raise TimeoutError("simulated") + + import fetch_patent_pdf as mod + + old = mod.http_get + mod.http_get = boom # type: ignore[assignment] + try: + url, source, log = resolve_pdf_url( + "CN114552122A", + timeout=1, + known_cdn={ + "CN114552122A": "https://patentimages.storage.googleapis.com/c2/6c/51/75412585086edf/CN114552122A.pdf" + }, + ) + self.assertEqual(source, "known_cdn_examples") + self.assertIn("CN114552122A.pdf", url) + self.assertTrue(any("fail_page" in x for x in log)) + finally: + mod.http_get = old + + def test_fetch_uses_direct_url(self) -> None: + from fetch_patent_pdf import fetch_patent_pdf + import fetch_patent_pdf as mod + + fake = b"%PDF-1.4" + b"0" * 6000 + calls: list[str] = [] + + def fake_dl(url: str, *, timeout: int = 120) -> bytes: + calls.append(url) + return fake + + old = mod.download_pdf_bytes + mod.download_pdf_bytes = fake_dl # type: ignore[assignment] + try: + with tempfile.TemporaryDirectory() as td: + out = Path(td) + st = fetch_patent_pdf( + "CN1", + out, + url="https://example.com/CN1.pdf", + ) + self.assertTrue(st["ok"]) + self.assertEqual(st["source_id"], "direct_url") + self.assertTrue((out / "source" / "CN1.pdf").is_file()) + self.assertEqual(calls, ["https://example.com/CN1.pdf"]) + finally: + mod.download_pdf_bytes = old + + +if __name__ == "__main__": + raise SystemExit(unittest.main()) diff --git a/.agents/skills/patent-disclosure-skill/tests/test_math_render.py b/.agents/skills/patent-disclosure-skill/tests/test_math_render.py new file mode 100644 index 0000000..0c5249f --- /dev/null +++ b/.agents/skills/patent-disclosure-skill/tests/test_math_render.py @@ -0,0 +1,66 @@ +# -*- coding: utf-8 -*- +"""math_render 联调脚本(需 matplotlib;仅跑通渲染,不做断言)。""" +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "tools")) + +_SOFTMAX_BLOCK = r"P(z_i) = \frac{\exp(z_i / T)}{\sum_j \exp(z_j / T)}" +_INLINE_COMPLEX = r"\sum_{j=1}^{n} w_j \cdot \exp(z_j / T)" +# 交底书常见、matplotlib 需归一化的简写 +_INLINE_GE_LE = r"a_{cpu,j} \le 0" +_INLINE_PAREN = r"T_r" +_BLOCK_LOGIC = ( + r"t - t_{last} \ge T_r \quad \land \quad |\sigma_{now} - \sigma_{last}| \ge \Delta s" +) +_SCORE_BLOCK = ( + r"Score(\mathbf{d},\mathbf{p}) = w_{cpu}\cdot d_{cpu}\cdot p_{cpu} + w_{mem}\cdot " + r"\min\left(1,\frac{p_{mem}}{\max(1,d_{mem})}\right) + w_{io}\cdot(1-p_{io\_busy})\cdot d_{io} " + r"- \lambda\cdot n_{inflight}\n\tag{1}" +) + + +def test_block_and_inline() -> None: + try: + import matplotlib # noqa: F401 + except ImportError: + return + + from math_render import render_markdown_math + + md = ( + f"温度参数 $T$ 下,加权 logits 为 ${_INLINE_COMPLEX}$," + f"约束 ${_INLINE_GE_LE}$ 与 \\({_INLINE_PAREN}\\)。\n\n" + f"$$\n{_SOFTMAX_BLOCK}\n$$\n\n" + f"$$\n{_BLOCK_LOGIC}\n$$\n\n" + f"$$\n{_SCORE_BLOCK}\n$$\n" + ) + render_markdown_math( + md, + out_md_path=ROOT / "tests" / "_math_test_out.md", + assets_rel="_math_test_figures", + ) + + +def test_fallback_on_bad_latex() -> None: + try: + import matplotlib # noqa: F401 + except ImportError: + return + + from math_render import render_markdown_math + + render_markdown_math( + "$$\\notacommand{x}$$\n", + out_md_path=ROOT / "tests" / "_math_test_bad.md", + assets_rel="_math_test_figures", + ) + + +if __name__ == "__main__": + test_block_and_inline() + test_fallback_on_bad_latex() + print("ok") diff --git a/.agents/skills/patent-disclosure-skill/tests/test_md_to_docx_table.py b/.agents/skills/patent-disclosure-skill/tests/test_md_to_docx_table.py new file mode 100644 index 0000000..15fc477 --- /dev/null +++ b/.agents/skills/patent-disclosure-skill/tests/test_md_to_docx_table.py @@ -0,0 +1,36 @@ +"""md_to_docx 表格行解析:单元格内 LaTeX \\| 不应拆列。""" +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "tools")) + +from md_to_docx import _parse_table_row # noqa: E402 + + +def test_latex_norm_pipes_in_cell_stays_one_column() -> None: + row = ( + "| \\(I_W\\) " + "| 队首窗口内任务索引集合 " + "| \\(\\|I_W\\| \\leq W\\) |" + ) + cells = _parse_table_row(row) + assert len(cells) == 3 + assert cells[1] == "队首窗口内任务索引集合" + assert "\\|I_W\\|" in cells[2] + assert "inline_076.png" in cells[2] + + +def test_simple_three_column_row() -> None: + row = "| \\(M_{ij}\\) | 匹配分 | 无量纲 |" + assert _parse_table_row(row) == ["\\(M_{ij}\\)", "匹配分", "无量纲"] + + +def test_escaped_pipe_outside_math() -> None: + row = r"| a \| b | c |" + cells = _parse_table_row(row) + assert len(cells) == 2 + assert cells[0] == r"a \| b" + assert cells[1] == "c" diff --git a/.agents/skills/patent-disclosure-skill/tests/test_note_cites.py b/.agents/skills/patent-disclosure-skill/tests/test_note_cites.py new file mode 100644 index 0000000..2ca09aa --- /dev/null +++ b/.agents/skills/patent-disclosure-skill/tests/test_note_cites.py @@ -0,0 +1,117 @@ +# -*- coding: utf-8 -*- +from __future__ import annotations + +import re +import sys +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "tools" / "patent_reader")) + +from note_cites import ( # noqa: E402 + enhance_note_citations, + escape_wikilink_pipes_in_tables, + format_claim_wikilinks, + wikilink_claim_citations, + wikilink_figure_citations, +) + + +class NoteCitesTest(unittest.TestCase): + def test_claim_range(self) -> None: + self.assertEqual( + format_claim_wikilinks(2, 3, pub="CN1"), + "[[CN1_权项锚点#^claim-2|权2]]–[[CN1_权项锚点#^claim-3|权3]]", + ) + + def test_rewrite_body(self) -> None: + src = "### 图1\n\n### 图3\n\n外部知识(权2–3;见图1–3)。" + out = wikilink_claim_citations(src, pub="CN1") + out = wikilink_figure_citations(out) + self.assertIn("[[CN1_权项锚点#^claim-2|权2]]", out) + self.assertIn("[[CN1_权项锚点#^claim-3|权3]]", out) + self.assertIn("[[#图1|图1]]", out) + self.assertIn("[[#图3|图3]]", out) + + def test_enhance_sidecar(self) -> None: + content = ( + "## Obsidian 导航\n\n" + "- [[x/CN1_图谱.canvas|专利族图谱]]\n" + "- [[CN1_说明书段落|说明书段落]]\n\n" + "## 三、权利要求树\n\n" + "| 结构 | 权 | 本项新增 |\n" + "| --- | ---: | --- |\n" + "| `◆` | 1 | 骨架 |\n" + "| `└─` | 2 | 建库 |\n\n" + "## 四、独立权利要求精读\n\n" + "见(权2–3)。\n" + ) + tree = { + "nodes": [ + {"number": 1, "is_independent": True, "delta": "骨架"}, + {"number": 2, "is_independent": False, "delta": "建库"}, + {"number": 3, "is_independent": False, "delta": "训模"}, + ] + } + with tempfile.TemporaryDirectory() as td: + base = Path(td) + out, path, nums = enhance_note_citations( + content, + pub="CN1", + note_dir=base, + claim_tree=tree, + claim_summaries={2: "建库", 3: "训模"}, + ) + self.assertIsNotNone(path) + assert path is not None + body = path.read_text(encoding="utf-8") + self.assertIn("^claim-2", body) + self.assertIn("使用说明", body) + self.assertIn("[[CN1_权项锚点#^claim-2|权2]]", out) + self.assertIn("[[CN1_权项锚点|权项锚点]]", out) + self.assertNotIn("### 权项锚点", out) + self.assertNotRegex(out, r"^>\s*\^claim-", re.M) + self.assertEqual(nums, [1, 2, 3]) + + def test_escape_pipes_in_table(self) -> None: + src = ( + "| 特征 | 依据 |\n" + "| --- | --- |\n" + "| x | [[CN1_说明书段落#^r0057-0061|说明书 0057–0061]];" + "[[CN1_权项锚点#^claim-2|权2]] |\n" + ) + out = escape_wikilink_pipes_in_tables(src) + self.assertIn(r"[[CN1_说明书段落#^r0057-0061\|说明书 0057–0061]]", out) + self.assertIn(r"[[CN1_权项锚点#^claim-2\|权2]]", out) + self.assertIn("| x |", out) + + +class CluesIndexTableTest(unittest.TestCase): + def test_render_clues_index_table_wikilinks(self) -> None: + from clue_vault import render_clues_index # noqa: E402 + + body = render_clues_index( + [ + { + "title": "甲/乙:测试标题很长很长很长很长很长很长很长", + "filename": "01-test.md", + "confidence": "高", + "status": "agent_fetched", + "related_claims": [1, 2], + } + ], + pub="CN1", + ) + self.assertIn(r"[[01-test\|", body) + self.assertIn(r"[[CN1_权项锚点#^claim-1\|权1]]", body) + self.assertIn(r"[[CN1_权项锚点#^claim-2\|权2]]", body) + # 表行内 wikilink 不得出现未转义的别名 | + for line in body.splitlines(): + if "| [[" in line: + self.assertNotRegex(line, r"\[\[[^\]]*(? None: + hints = load_ipc_application_hints() + assert len(hints) >= 5, f"IPC hints too few: {len(hints)}" + industries = {h.get("industry") for h in hints} + assert "电化学储能" in industries + # 不应被下一条污染成「通用技术」独占 + h01 = next(h for h in hints if h.get("ipc_prefix") == "H01M") + assert h01.get("industry") == "电化学储能" + assert "电池" in (h01.get("keywords") or []) + + +def test_glossary_pair_quotes() -> None: + text = ( + "本发明属于电化学储能领域。本文中,「耐热层」是指覆盖在基膜表面的无机涂层。" + "基膜是指多孔聚合物膜。" + ) + gloss = extract_glossary(text) + terms = {g["term"] for g in gloss} + assert "耐热层" in terms, gloss + assert not any("本文中" in t or "发明属于" in t for t in terms) + + +def test_nav_merge() -> None: + content = ( + "# 标题\n\n## Obsidian 导航\n\n" + "- [[Research/Patents/_专利解读索引|旧索引]]\n\n" + "## 一、一句话\n\nok\n" + ) + out = _ensure_nav_section( + content, + [ + "[[Research/Patents/_专利解读索引|专利解读索引]]", + "[[Research/术语/_术语索引|术语索引]]", + ], + ) + assert "术语索引" in out + assert "专利解读索引" in out + assert out.count("## Obsidian 导航") == 1 + + +def test_lint_heading_not_bare_feature() -> None: + # 「特征」仅出现在第四节表头,不应满足第六节 + labels = [lab for _, lab in REQUIRED_HEADINGS] + assert any("特征" in lab for lab in labels) + note = ( + "---\ncssclasses:\n - patent-reader\nipc: H01M\ndomain: 测试\n---\n" + "# t\n\n## Obsidian 导航\n\n## 一、一句话\n\n" + "## 二、连贯叙事\n\n## 三、权利要求树\n\n" + "## 四、独立权利要求精读\n\n| 特征 | 说明 |\n|---|---|\n| a | b |\n\n" + "## 五、专利内术语表\n\n## 七、和现有技术的差别\n\n" + "## 八、阅读建议\n\n## 九、技术应用场景\n\ndesc_001 背景\n\n" + "## 十、附录\n\nIPC 行业坐标\n\n### B. 公开\n\n未发现可靠对应,防御性。\n\n" + "## 十一、免责声明\n\n" + "不构成法律意见。专利保护范围以官方法律文本为准。" + "重大决策请咨询专利代理师。\n\n" + "> [!patent-meta]\n> [!grounding]\n> [!warning]-\n" + ) + with tempfile.TemporaryDirectory() as td: + td_p = Path(td) + note_p = td_p / "n.md" + note_p.write_text(note, encoding="utf-8") + man = {"evidence_scope": "full_text", "independent_claim_count": 0} + tree = {"nodes": [], "roots": []} + (td_p / "m.json").write_text(json.dumps(man), encoding="utf-8") + (td_p / "t.json").write_text(json.dumps(tree), encoding="utf-8") + rc = lint_main( + [ + "--note", + str(note_p), + "--manifest", + str(td_p / "m.json"), + "--claim-tree", + str(td_p / "t.json"), + "--output", + str(td_p / "lint.json"), + ] + ) + assert rc == 1 + lint = json.loads((td_p / "lint.json").read_text(encoding="utf-8")) + assert any("特征" in i for i in lint["issues"]) + + +def test_glossary_index_ignores_tags() -> None: + with tempfile.TemporaryDirectory() as td: + vault = Path(td) + gdir = "Research/术语" + (vault / gdir).mkdir(parents=True) + (vault / gdir / "耐热层.md").write_text( + "---\ntags:\n - glossary\naliases:\n - 耐热涂层\ntitle: 耐热层\n---\n\n# 耐热层\n", + encoding="utf-8", + ) + idx = scan_glossary_index(vault, gdir) + assert "耐热层" in idx + assert "耐热涂层" in idx + assert "glossary" not in idx + + +def test_stub_collision() -> None: + with tempfile.TemporaryDirectory() as td: + vault = Path(td) + gdir = "Research/术语" + rel1, _ = ensure_glossary_stub( + vault, gdir, "foo bar", definition="一", source_pub="CN1" + ) + rel2, created = ensure_glossary_stub( + vault, gdir, "foo!!bar", definition="二", source_pub="CN2" + ) + assert created is True + assert rel1 != rel2 + assert (vault / f"{rel2}.md").is_file() + text2 = (vault / f"{rel2}.md").read_text(encoding="utf-8") + assert "foo!!bar" in text2 or "title: foo!!bar" in text2 + # 原文件未被错误覆盖 + text1 = (vault / f"{rel1}.md").read_text(encoding="utf-8") + assert "一" in text1 or "foo bar" in text1 + assert "二" not in text1[:200] or "foo!!bar" not in text1 + + +def test_validate_public_clues() -> None: + ok = validate_clues( + [ + { + "title": "某白皮书", + "url": "https://example.org/a", + "confidence": "中", + "reason": "与实施例隔膜结构对应", + } + ] + ) + assert ok["passed"] + bad = validate_clues([{"title": "x", "confidence": "高"}]) + assert not bad["passed"] + + +def test_filter_clues_max_three() -> None: + from clue_vault import filter_clues + + clues = [ + {"title": "低1", "url": "https://a.example/1", "confidence": "低", "reason": "理由足够长了"}, + {"title": "高1", "url": "https://a.example/2", "confidence": "高", "reason": "理由足够长了"}, + {"title": "中1", "url": "https://a.example/3", "confidence": "中", "reason": "理由足够长了"}, + {"title": "中2", "url": "https://a.example/4", "confidence": "中", "reason": "理由足够长了"}, + {"title": "高2", "url": "https://a.example/5", "confidence": "高", "reason": "理由足够长了"}, + ] + kept, dropped = filter_clues(clues, max_keep=3) + assert len(kept) == 3 + assert len(dropped) == 2 + assert [c["title"] for c in kept] == ["高1", "高2", "中1"] + + +def test_sanitize_clue_summary_nav_and_glyphs() -> None: + from clue_vault import format_summary_for_markdown, sanitize_clue_summary + + dirty = ( + "OA\n邮箱\n主页\n>\n产品展示\n>\n涂覆\n>>\nAl\n2\nO\n3\n/勃姆石涂覆\n" + ">>\n单面/双面涂覆\n>>\n较基膜更高的穿刺强度,进一步降低电芯制程短路率\n" + "联系方式\nCopyright © 2016\n" + ) + clean = sanitize_clue_summary(dirty, title="陶瓷涂覆隔膜") + assert "OA" not in clean + assert "主页" not in clean + assert "Al2O3/勃姆石涂覆" in clean or "Al2O3" in clean + assert "穿刺强度" in clean + assert "页面要点" in clean or clean.lstrip().startswith("-") + assert not re.search(r"^>", clean, re.M) + md = format_summary_for_markdown(">> 卖点甲\n正文") + assert not md.lstrip().startswith(">") + assert "卖点甲" in md + + +def test_materialize_prefers_agent_summary_no_script_by_default() -> None: + import tempfile + from clue_vault import materialize_clues + + clues = [ + { + "title": "Agent已读", + "url": "https://example.org/a", + "confidence": "高", + "reason": "与权1水性浆料对应充分", + "summary": "页面写明水系PVDF涂覆产线。", + "status": "agent_fetched", + "related_claims": [1], + }, + { + "title": "仅有链接", + "url": "https://example.org/b", + "confidence": "中", + "reason": "同申请人产品新闻足够长", + }, + ] + with tempfile.TemporaryDirectory() as td: + rich, _ = materialize_clues( + clues, + note_dir=Path(td), + pub="CN1", + fetch_fallback=False, + ) + by_title = {c["title"]: c for c in rich} + assert by_title["Agent已读"]["status"] == "agent_fetched" + assert "水系PVDF" in by_title["Agent已读"]["summary"] + assert by_title["Agent已读"]["related_claims"] == [1] + # 无降级时不脚本抓取,保持 draft + assert by_title["仅有链接"]["status"] == "draft" + assert not (by_title["仅有链接"].get("summary") or "").strip() + + +def test_inject_clue_annotations_l1_l4() -> None: + from clue_vault import inject_clue_annotations + + note = """# 专利解读:测试 + +## Obsidian 导航 + +- [[x_图谱.canvas|专利族图谱]] + +## 一、一句话 + +一句话正文。 + +## 二、连贯叙事 + +叙事。 + +## 三、权利要求树 + +表。 + +## 四、独立权利要求精读 + +> [!patent-claim] 权利要求 1 + +> 【CN1·权利要求1】含陶瓷涂层与基膜。 + +| 特征 | 大白话 | 说明书依据 | +|------|--------|------------| +| F1 双面陶瓷涂层结构 | 上下陶瓷 | 发明 | +| F6 拉伸后陶瓷涂覆 | 仍要涂层 | 权1 | + +## 五、专利内术语表 + +| 术语 | 本文含义/位置 | 备注 | +|------|---------------|------| +| [[Research/术语/陶瓷涂层|陶瓷涂层]] | 涂层 | — | + +## 六、特征—说明书—附图对照 + +| 特征 | 说明书位置 | 附图 | +|------|------------|------| +| 陶瓷 | 背景 | — | + +## 七、和现有技术的差别 + +差别。 + +## 八、阅读建议 + +1. 建议。 + +## 九、技术应用场景 + +> [!grounding] 应用场景 +> 场景。 + +## 十、附录:行业坐标与公开线索 + +### A. IPC + +x + +### B. 公开检索线索 + +old + +## 十一、免责声明 + +免责。 +""" + clues = [ + { + "title": "陶瓷产品页", + "filename": "01-陶瓷产品页.md", + "summary": "页面要点:\n- Al2O3/勃姆石涂覆\n- 单面/双面涂覆", + "reason": "同申请人陶瓷涂覆隔膜产品线", + "related_claims": [1], + "related_feature_ids": ["F1", "F6"], + "confidence": "中", + "status": "agent_fetched", + } + ] + out = inject_clue_annotations(note, clues) + assert "公开线索(1 条)" in out + assert "公开线索入口" in out + assert "公开案例(推测)" in out + assert "差别对照·公开线索" in out + assert "阅读建议·公开线索" in out + assert "场景·公开线索" in out + assert "权项—公开语境(推测)" in out + assert "特征—公开语境(推测)" in out + assert "F1 双面陶瓷涂层结构" in out or "**F1" in out + assert "术语·公开语境" in out + # 术语旁注在第五节后、第六节前;特征旁注紧挨第六节对照表后、附图/第七节前 + i5, i_term = out.find("## 五、"), out.find("术语·公开语境") + i6, i_feat = out.find("## 六、"), out.find("特征—公开语境(推测)") + i7 = out.find("## 七、") + assert 0 <= i5 < i_term < i6 < i_feat < i7 + # 对照表在旁注之前 + assert out.find("| 特征 | 说明书位置 | 附图 |") < i_feat + # 幂等 + out2 = inject_clue_annotations(out, clues) + assert out2.count("公开线索入口") == 1 + assert out2.count("特征—公开语境(推测)") == 1 + + +def test_agent_anchor_fits_preferred() -> None: + """有 anchor_fits 时,旁注必须用 Agent 贴合句,而非启发式首句。""" + from clue_vault import inject_clue_annotations + + note = """# t + +## Obsidian 导航 + +- [[x|图谱]] + +## 一、一句话 + +a + +## 二、连贯叙事 + +b + +## 三、权利要求树 + +c + +## 四、独立权利要求精读 + +> [!patent-claim] 权利要求 1 + +> 含陶瓷与纤维素涂覆层。 + +## 五、专利内术语表 + +| 术语 | 本文含义/位置 | 备注 | +|------|---------------|------| +| 纤维素 | 涂层 | — | + +## 六、特征—说明书—附图对照 + +| 特征 | 说明书位置 | 附图 | +|------|------------|------| +| 涂覆层含陶瓷和纤维素 | 权1 | 图1 | +| 纤维素分子量 5万-250万 | 权2 | — | + +## 七、和现有技术的差别 + +d + +## 八、阅读建议 + +1. x + +## 九、技术应用场景 + +y + +## 十、附录 + +### A. IPC + +x + +## 十一、免责声明 + +z +""" + clues = [ + { + "title": "公开页A", + "filename": "01-公开页A.md", + "summary": "页面要点:\n- 无关的第一句卖点会被启发式误用\n- 纸基涂布纤维素溶液", + "reason": "同主题", + "related_claims": [1], + "related_feature_ids": ["涂覆层含陶瓷和纤维素", "纤维素分子量 5万-250万"], + "anchor_fits": [ + { + "kind": "feature", + "key": "涂覆层含陶瓷和纤维素", + "fit": "AGENT贴合:纸基涂布纤维素溶液", + }, + { + "kind": "claim", + "key": "1", + "fit": "AGENT权贴合:同主题涂覆隔膜制备", + }, + { + "kind": "term", + "key": "纤维素", + "fit": "AGENT术语:文中举例棉浆溶解", + }, + ], + "confidence": "中", + "status": "agent_fetched", + } + ] + out = inject_clue_annotations(note, clues) + feat = out.split("特征—公开语境(推测)")[1].split("## 七、")[0] + assert "AGENT贴合:纸基涂布纤维素溶液" in feat + assert "无关的第一句卖点" not in feat + claim = out.split("权项—公开语境(推测)")[1].split("## 五、")[0] + assert "AGENT权贴合:同主题涂覆隔膜制备" in claim + term = out.split("术语·公开语境")[1].split("## 六、")[0] + assert "AGENT术语:文中举例棉浆溶解" in term + + +def test_claim_and_term_callouts_distill() -> None: + """权项/术语 warning:按锚点抽贴合句,且 L2 不再硬编码「湿法」话术。""" + from clue_vault import inject_clue_annotations + + note = """# t + +## Obsidian 导航 + +- [[x|图谱]] + +## 一、一句话 + +a + +## 二、连贯叙事 + +b + +## 三、权利要求树 + +c + +## 四、独立权利要求精读 + +> [!patent-claim] 权利要求 1 + +> 【CN·权利要求1】含陶瓷涂层与基膜,采用碱尿素溶解纤维素后涂布。 + +## 五、专利内术语表 + +| 术语 | 本文含义/位置 | 备注 | +|------|---------------|------| +| [[Research/术语/陶瓷涂层|陶瓷涂层]] | 涂层 | — | +| 纤维素 | 涂层组分 | — | + +## 六、特征—说明书—附图对照 + +| 特征 | 说明书位置 | 附图 | +|------|------------|------| +| 陶瓷涂层 | 权1 | 图1 | + +## 七、和现有技术的差别 + +d + +## 八、阅读建议 + +1. x + +## 九、技术应用场景 + +y + +## 十、附录 + +### A. IPC + +x + +## 十一、免责声明 + +z +""" + clues = [ + { + "title": "纤维素涂布与陶瓷隔膜公开页", + "filename": "01-纤维素陶瓷.md", + "summary": ( + "页面要点:\n" + "- 提出在纸基上涂布纤维素溶液\n" + "- 采用碱尿素体系溶解非衍生化纤维素后涂布\n" + "- 陶瓷涂层用于提升热稳定性" + ), + "reason": "同主题公开", + "related_claims": [1], + "related_feature_ids": ["陶瓷涂层"], + "confidence": "中", + "status": "agent_fetched", + } + ] + out = inject_clue_annotations(note, clues) + assert "湿法工艺" not in out + claim_zone = out.split("权项—公开语境(推测)")[1].split("## 五、")[0] + assert "碱尿素" in claim_zone or "纤维素" in claim_zone or "陶瓷" in claim_zone + assert "同主题语境(摘要未点名该权项)" not in claim_zone or "—" in claim_zone + term_zone = out.split("术语·公开语境")[1].split("## 六、")[0] + assert "陶瓷涂层" in term_zone or "纤维素" in term_zone + + +def test_feature_callout_dedupes_clue_and_distills() -> None: + """多特征命中同一线索时:线索只出现一次,并为特征抽不同贴合句。""" + from clue_vault import inject_clue_annotations + + note = """# t + +## Obsidian 导航 + +- [[x|图谱]] + +## 一、一句话 + +a + +## 二、连贯叙事 + +b + +## 三、权利要求树 + +c + +## 四、独立权利要求精读 + +> [!patent-claim] 权利要求 1 + +> 隔膜。 + +## 五、专利内术语表 + +| 术语 | 本文含义/位置 | 备注 | +|------|---------------|------| +| 纤维素 | 涂层 | — | + +## 六、特征—说明书—附图对照 + +| 特征 | 说明书位置 | 附图 | +|------|------------|------| +| 基膜+至少一面涂覆层 | 权1 | 图1 | +| 涂覆层含陶瓷和纤维素 | 权1 | 图1 | +| 纤维素分子量 5万-250万 | 权2 | — | +| 非衍生化碱尿素溶解 | 权3 | 图2 | + +## 七、和现有技术的差别 + +d + +## 八、阅读建议 + +1. x + +## 九、技术应用场景 + +y + +## 十、附录 + +### A. IPC + +x + +## 十一、免责声明 + +z +""" + clues = [ + { + "title": "一种纤维素涂布锂离子电池隔膜的制备方法(碱尿素溶)", + "filename": "01-纤维素涂布.md", + "summary": ( + "页面要点:\n" + "- 该公开文本针对聚烯烃隔膜热稳定性差,提出在纸基上涂布纤维素溶液\n" + "- 采用碱尿素体系溶解非衍生化纤维素后涂布" + ), + "reason": "同主题纤维素涂布隔膜公开文本", + "related_feature_ids": [ + "基膜+至少一面涂覆层", + "涂覆层含陶瓷和纤维素", + "纤维素分子量 5万-250万", + "非衍生化碱尿素溶解", + ], + "confidence": "中", + "status": "agent_fetched", + } + ] + out = inject_clue_annotations(note, clues) + zone = out.split("特征—公开语境(推测)")[1].split("## 七、")[0] + # 线索标题在特征块内只出现一次(按线索归纳) + assert zone.count("纤维素涂布锂离子电池隔膜") == 1 + assert "涂覆层含陶瓷和纤维素" in zone + assert "非衍生化碱尿素溶解" in zone + # 应有针对特征的贴合句,而非每行重复整段同一摘要 + assert "碱尿素" in zone + assert "涂布纤维素" in zone or "纸基" in zone + # 数值特征无摘要锚点时收进另涉,避免刷屏 + assert "另涉" in zone or "分子量" in zone + + +def test_feature_callout_drops_orphan_fids() -> None: + """第六节只有特征名、无 F 编号时:丢弃 sidecar 空号 F1–F6,改挂表内名称。""" + from clue_vault import inject_clue_annotations, match_clue_to_note + + note = """# t + +## Obsidian 导航 + +- [[x|图谱]] + +## 一、一句话 + +a + +## 二、连贯叙事 + +b + +## 三、权利要求树 + +c + +## 四、独立权利要求精读 + +> [!patent-claim] 权利要求 1 + +> 【CN·权利要求1】水性PVDF浆料涂覆隔膜。 + +## 五、专利内术语表 + +| 术语 | 本文含义/位置 | 备注 | +|------|---------------|------| +| PVDF | 涂层聚合物 | — | + +## 六、特征—说明书—附图对照 + +| 特征 | 说明书位置 | 附图 | +|------|------------|------| +| 循环性能对比 | 表2 | 图1 | +| 水性球状形貌 | 成孔机理 | 图2 | +| 油性海绵状对比 | 溶剂对比 | 图3 | + +## 七、和现有技术的差别 + +d + +## 八、阅读建议 + +1. x + +## 九、技术应用场景 + +y + +## 十、附录 + +### A. IPC + +x + +## 十一、免责声明 + +z +""" + clues = [ + { + "title": "水系PVDF产品页", + "filename": "01-水系PVDF产品页.md", + "summary": "掌握水系 PVDF、油系 PVDF 涂覆技术", + "reason": "公开水系/油系 PVDF 涂覆能力", + # 旧 sidecar 空号,笔记里并不存在 + "related_feature_ids": ["F1", "F2", "F4", "F6", "F5"], + "related_claims": [1], + "confidence": "中", + "status": "agent_fetched", + } + ] + out = inject_clue_annotations(note, clues) + feat_zone = out.split("特征—公开语境(推测)")[1].split("## 七、")[0] + assert "水性球状形貌" in feat_zone + assert "油性海绵状对比" in feat_zone + assert re.search(r"\bF[1-6]\b", feat_zone) is None + m = match_clue_to_note( + clues[0], + feature_entries=[ + {"id": "", "label": "水性球状形貌", "text": "水性球状形貌 成孔"}, + {"id": "", "label": "油性海绵状对比", "text": "油性海绵状对比 溶剂"}, + {"id": "", "label": "循环性能对比", "text": "循环性能对比 表2"}, + ], + ) + assert "水性球状形貌" in m["related_feature_ids"] + assert "油性海绵状对比" in m["related_feature_ids"] + assert "F1" not in m["related_feature_ids"] + + +def test_clue_appendix_and_annotate() -> None: + from clue_vault import ( + inject_clue_annotations, + match_clue_to_note, + render_appendix_b, + upsert_appendix_b, + ) + + clue = { + "title": "水性PVDF产品报道", + "url": "https://news.example/x", + "confidence": "中", + "reason": "公开水系PVDF涂覆能力", + "filename": "01-水性PVDF产品报道.md", + "summary": "公司掌握水性PVDF浆料涂覆隔膜技术", + } + m = match_clue_to_note( + clue, + claim_summaries={1: "水性PVDF浆料涂覆隔膜"}, + feature_rows=["F1 水性PVDF 浆料"], + ) + assert 1 in m["related_claims"] + clue.update(m) + app = render_appendix_b([clue], clues_dir_link="clues/_线索索引") + assert "[[clues/01-水性PVDF产品报道|" in app + note = "## 四、独立权利要求精读\n\nbody\n\n## 五、专利内术语表\n\nx\n" + note2 = inject_clue_annotations(note, [clue]) + assert "外部线索(推测)" in note2 + full = "## 十、附录\n\n### A. IPC\n\nx\n\n### B. 公开检索线索\n\nold\n\n## 十一、免责声明\n" + full2 = upsert_appendix_b(full, app) + assert "线索文件夹" in full2 + assert "old" not in full2.split("### B.")[1] + + +def test_optional_path_not_cwd() -> None: + from common import optional_path + import argparse + + assert optional_path("") is None + assert optional_path(None) is None + assert optional_path("tmp/x") == Path("tmp/x") + + ap = argparse.ArgumentParser() + ap.add_argument("--workdir", default=None, type=optional_path) + ap.add_argument("--output", default=None, type=optional_path) + ns = ap.parse_args([]) + assert ns.workdir is None + assert ns.output is None + + +def test_obsidian_detect_and_env_disable() -> None: + import os + from common import ( + candidate_default_vault_paths, + detect_obsidian_installed, + probe_obsidian_environment, + resolve_obsidian_vault, + ) + + install = detect_obsidian_installed() + assert "installed" in install + assert any("Obsidian Vault" in str(p) for p in candidate_default_vault_paths()) + + # 显式清空环境变量 = 本会话禁用 Obsidian,不得回退探测 + old = { + k: os.environ.get(k) + for k in ( + "PATENT_READER_OBSIDIAN_VAULT", + "PATENT_DISCLOSURE_OBSIDIAN_VAULT", + ) + } + try: + os.environ["PATENT_READER_OBSIDIAN_VAULT"] = "" + os.environ.pop("PATENT_DISCLOSURE_OBSIDIAN_VAULT", None) + r = resolve_obsidian_vault() + assert r.get("source") == "env_disabled" + assert r.get("vault") == "" + assert r.get("needs_user_input") is False + finally: + for k, v in old.items(): + if v is None: + os.environ.pop(k, None) + else: + os.environ[k] = v + + report = probe_obsidian_environment() + assert report.get("obsidian_required") is False + assert "status" in report + + +def test_bootstrap_creates_appearance() -> None: + with tempfile.TemporaryDirectory() as td: + vault = Path(td) + actions = bootstrap_vault(vault, "Research/MyPatents") + assert (vault / ".obsidian" / "appearance.json").is_file() + data = json.loads( + (vault / ".obsidian" / "appearance.json").read_text(encoding="utf-8") + ) + assert "patent-reader" in data.get("enabledCssSnippets", []) + base = (vault / "Research" / "MyPatents" / "patents.base").read_text( + encoding="utf-8" + ) + assert "MyPatents" in base + assert "{{PAPERS_DIR}}" not in base + assert any("enabled_snippet" in a or "appearance" in a for a in actions) + core = json.loads( + (vault / ".obsidian" / "core-plugins.json").read_text(encoding="utf-8") + ) + assert core.get("bases") is True + assert any("enabled_core:bases" in a for a in actions) + graph = json.loads( + (vault / ".obsidian" / "graph.json").read_text(encoding="utf-8") + ) + assert graph.get("colorGroups") + assert any("file:_解读_" in (g.get("query") or "") for g in graph["colorGroups"]) + search = graph.get("search") or "" + assert "-file:.json" in search + assert "-file:_权项锚点" in search + assert "-file:_说明书段落" in search + assert any("graph_colors:" in a for a in actions) + + +def test_harvest_narrative_from_note() -> None: + note = """# t + +## 一、一句话 + +用一句话说清专利。 + +## 二、连贯叙事 + +**问题**:旧工艺不安全。 + +**思路**:改用水性浆料。 + +**怎么做**:分散、研磨、涂布。 + +**效果**:更薄更透气。 + +## 七、和现有技术的差别 + +- **相对丙酮**:更安全。 +""" + n = harvest_narrative_from_note(note) + assert n.get("problem") and "不安全" in n["problem"] + assert n.get("effect") and "薄" in n["effect"] + assert n.get("diff") + + +def test_glossary_backlink_backslash_dedupe() -> None: + assert normalize_wiki_path(r"Research\Patents\a\b.md") == "Research/Patents/a/b" + with tempfile.TemporaryDirectory() as td: + vault = Path(td) + gdir = "Research/术语" + (vault / gdir).mkdir(parents=True) + path = vault / gdir / "耐热层.md" + path.write_text( + "---\ntags:\n - glossary\ntitle: 耐热层\n---\n\n# 耐热层\n\n定义\n\n" + "## 反链\n" + "- 解读:[[Research/Patents/化工/CN1/CN1_解读_20260101|CN1]]\n" + "- 解读:[[Research\\Patents\\化工\\CN1\\CN1_解读_20260101|CN1]]\n", + encoding="utf-8", + ) + n = repair_glossary_backlinks(vault, gdir) + assert n == 1 + text = path.read_text(encoding="utf-8") + assert text.count("CN1_解读_20260101") == 1 + assert "\\" not in text.split("## 反链", 1)[1] + # 再追加反斜杠路径不应重复 + append_glossary_backlinks( + path, + source_pub="CN1", + note_rel=r"Research\Patents\化工\CN1\CN1_解读_20260101.md", + ) + text2 = path.read_text(encoding="utf-8") + assert text2.count("|CN1]]") == 1 + + +def test_render_claim_tree_markdown() -> None: + tree = { + "roots": [1], + "nodes": [ + { + "number": 1, + "is_independent": True, + "parent": None, + "text_preview": "一种方法,其特征在于:先分散再拉伸成膜。", + }, + { + "number": 2, + "is_independent": False, + "parent": 1, + "text_preview": "如权利要求1所述的方法,其特征在于:拉伸比为3~12。", + }, + ], + } + md = render_claim_tree_markdown(tree, pub="CN1", summaries={1: "分散+拉伸成膜"}) + assert "| 结构 | 权 | 本项新增 |" in md + assert "◆" in md and "└─" in md or "├─" in md + assert "分散+拉伸成膜" in md + assert "拉伸比" in md or "3~12" in claim_delta_text(tree["nodes"][1]["text_preview"]) + # 默认不嵌入 mermaid,避免与表重复 + assert "```mermaid" not in md + md2 = render_claim_tree_markdown(tree, pub="CN1", include_mermaid=True) + assert "```mermaid" in md2 + + +def test_claim_tree_multi_parent_and_validate() -> None: + """多引用父号解析 + Agent 校对校验。""" + from common import ( + normalize_claim_tree, + parent_claim_numbers, + validate_claim_tree, + ) + + assert parent_claim_numbers("如权利要求1或2所述的隔膜,其特征在于:…") == [1, 2] + assert parent_claim_numbers("according to claims 3 or 4, wherein")[:2] == [3, 4] + + tree = { + "roots": [1], + "nodes": [ + { + "number": 1, + "is_independent": True, + "parent": None, + "text_preview": "一种隔膜", + }, + { + "number": 2, + "is_independent": False, + "parent": 1, + "text_preview": "如权利要求1", + }, + { + "number": 5, + "is_independent": False, + "parent": 2, + "parent_candidates": [1, 2], + "text_preview": "如权利要求1或2所述", + }, + ], + "review": { + "by": "agent", + "status": "reviewed", + "notes": "权5挂独立权1", + "corrections": [{"claim": 5, "to_parent": 1}], + }, + } + # Agent 纠正父号 + tree["nodes"][2]["parent"] = 1 + result = validate_claim_tree(tree) + assert result["passed"] + assert result["tree"]["nodes"][2]["parent"] == 1 + assert any("multi_parent_candidates" in w for w in result["warnings"]) + + bad = normalize_claim_tree( + { + "nodes": [ + {"number": 1, "is_independent": True, "parent": 9}, + {"number": 2, "is_independent": False, "parent": 99}, + ] + } + ) + assert bad["nodes"][0]["parent"] is None + assert bad["nodes"][1]["parent"] == 1 # 悬空父号回退到独立权1 + unchecked = validate_claim_tree({"nodes": bad["nodes"]}) + assert "not_agent_reviewed" in unchecked["warnings"] + + +def test_claim_deltas_agent_preferred() -> None: + """Agent claim_deltas 覆盖启发式截句。""" + from obsidian import ( + load_claim_deltas, + merge_claim_summaries, + render_claim_tree_markdown, + ) + + tree = { + "roots": [1], + "nodes": [ + { + "number": 1, + "is_independent": True, + "parent": None, + "text_preview": "一种隔膜,其特征在于:包括基膜和涂覆层。", + }, + { + "number": 2, + "is_independent": False, + "parent": 1, + "text_preview": "如权利要求1所述的隔膜,其特征在于:涂覆层含陶瓷和纤维素。", + }, + ], + } + agent = load_claim_deltas( + { + "source": "agent", + "deltas": [ + {"claim": 1, "delta": "基膜+涂覆层骨架"}, + {"claim": 2, "delta": "涂层同时含陶瓷与纤维素"}, + ], + } + ) + assert agent[2] == "涂层同时含陶瓷与纤维素" + # 笔记旧句不应压过 Agent + summaries = merge_claim_summaries({2: "旧启发式句子"}, agent) + assert summaries[2] == "涂层同时含陶瓷与纤维素" + md = render_claim_tree_markdown(tree, pub="CN1", summaries=summaries) + assert "涂层同时含陶瓷与纤维素" in md + assert "基膜+涂覆层骨架" in md + # 缺 Agent 的权号仍可启发式 + md_fb = render_claim_tree_markdown(tree, pub="CN1", summaries={1: "骨架"}) + assert "骨架" in md_fb + assert "陶瓷" in md_fb or "纤维素" in md_fb + + +def test_spurious_patent_note_detect() -> None: + with tempfile.TemporaryDirectory() as td: + p = Path(td) / "CN1_解读_20260721 1.md" + p.write_text("", encoding="utf-8") + assert is_spurious_patent_note(p) + p2 = Path(td) / "CN1_解读_20260721.md" + p2.write_text("# ok\n", encoding="utf-8") + assert not is_spurious_patent_note(p2) + + +def test_sanitize_user_facing_titles() -> None: + from write_patent_obsidian_note import sanitize_user_facing_titles + + dirty = ( + "## 二、连贯叙事(故事线)\n" + "### 结构图(可选 mermaid)\n" + "## 七、和现有技术的差别(若能从原文读出)\n" + "## 九、技术应用场景(专利内依据)\n" + "### A. IPC 行业坐标(离线词表)\n" + "### B. 公开检索线索(推测)\n" + "## 相关专利(自动关联)\n" + "### 附图(扫描件整页预览)\n" + "> [!grounding] 应用场景(专利内依据 · 高置信)\n" + "> [!warning]- 公开检索线索(推测 · 默认折叠)\n" + "- [[Research/Patents/x/y_图谱.canvas|专利族图谱]](入库后生成)\n" + "**效果(专利自述)**:ok\n" + "- **来源**:`context_anchor.ipc_application`(离线词表)+ Google Patents 分类信息\n" + "*第 1 页 · `page_001_xref_01.png`*\n" + "> 由 `write_patent_obsidian_note.py` / `setup_obsidian_vault.py` 维护。\n" + "- 关联(交付后运行 `link_patent_notes.py` 生成)\n" + ) + clean = sanitize_user_facing_titles(dirty) + assert "(故事线)" not in clean + assert "(若能从原文读出)" not in clean + assert "(专利内依据)" not in clean + assert "## 二、连贯叙事\n" in clean + assert "## 七、和现有技术的差别\n" in clean + assert "## 相关专利\n" in clean + assert "### 附图\n" in clean + assert "> [!grounding] 应用场景\n" in clean + assert "(入库后生成)" not in clean + assert "**效果**:" in clean + assert "context_anchor" not in clean + assert "page_001_xref" not in clean + assert ".py" not in clean + assert "离线 IPC 行业词表;Google Patents 分类信息" in clean + assert "*第 1 页*" in clean + assert "入库后自动维护" in clean + + +def test_evidence_label_zh_in_frontmatter() -> None: + assert evidence_scope_zh("full_text") == "全文" + note = ( + "---\npub_number: CN1\ndomain: 测试\nevidence_scope: full_text\n" + "confidence_speculative: true\n---\n\n# t\n\n" + "> [!speculative]\n> 低置信度线索\n" + ) + out = enrich_note_frontmatter( + note, + pub="CN1", + domain="测试", + manifest={"evidence_scope": "full_text"}, + anchor={}, + ) + assert "evidence_label: 全文" in out + assert "speculative_label: 是" in out + + +def test_glossary_stub_fills_section5_definition() -> None: + with tempfile.TemporaryDirectory() as td: + vault = Path(td) + gdir = "Research/术语" + rel, _ = ensure_glossary_stub( + vault, gdir, "陶瓷涂层", definition="", source_pub="CN1" + ) + path = vault / f"{rel}.md" + assert "待补充" in path.read_text(encoding="utf-8") + ensure_glossary_stub( + vault, + gdir, + "陶瓷涂层", + definition="复合在基础层表面的无机涂层", + source_pub="CN1", + ) + text = path.read_text(encoding="utf-8") + assert "复合在基础层表面的无机涂层" in text + assert "待补充" not in text.split("# 陶瓷涂层", 1)[1].split("来源专利", 1)[0] + + +def test_harvest_glossary_from_note_section5() -> None: + note = """# t + +## 五、专利内术语表 + +| 术语 | 本文含义/位置 | 备注 | +|------|---------------|------| +| [[Research/术语/耐热层|耐热层]] | 涂层 | 定义句 | +| 基膜 | 多孔膜 | | + +## 六、特征—说明书—附图对照 +""" + harvested = harvest_glossary_from_note(note) + terms = {g["term"] for g in harvested} + assert "耐热层" in terms and "基膜" in terms, harvested + merged = merge_glossary_candidates([{"term": "耐热层", "definition": ""}], harvested) + by = {g["term"]: g for g in merged} + assert by["耐热层"]["definition"] == "涂层" + assert "基膜" in by + + +def main() -> int: + test_ipc_hints_count() + test_glossary_pair_quotes() + test_nav_merge() + test_lint_heading_not_bare_feature() + test_glossary_index_ignores_tags() + test_stub_collision() + test_validate_public_clues() + test_filter_clues_max_three() + test_sanitize_clue_summary_nav_and_glyphs() + test_materialize_prefers_agent_summary_no_script_by_default() + test_inject_clue_annotations_l1_l4() + test_feature_callout_drops_orphan_fids() + test_clue_appendix_and_annotate() + test_optional_path_not_cwd() + test_obsidian_detect_and_env_disable() + test_bootstrap_creates_appearance() + test_evidence_label_zh_in_frontmatter() + test_glossary_stub_fills_section5_definition() + test_harvest_glossary_from_note_section5() + test_harvest_narrative_from_note() + test_sanitize_user_facing_titles() + test_glossary_backlink_backslash_dedupe() + test_spurious_patent_note_detect() + test_render_claim_tree_markdown() + test_claim_tree_multi_parent_and_validate() + test_claim_deltas_agent_preferred() + print("OK debt_and_enhancement smoke") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.agents/skills/patent-disclosure-skill/tests/test_patent_reader_links.py b/.agents/skills/patent-disclosure-skill/tests/test_patent_reader_links.py new file mode 100644 index 0000000..33beb1f --- /dev/null +++ b/.agents/skills/patent-disclosure-skill/tests/test_patent_reader_links.py @@ -0,0 +1,173 @@ +# -*- coding: utf-8 -*- +"""专利库内关联 link_patent_notes 冒烟。""" +from __future__ import annotations + +import json +import sys +import tempfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "tools" / "patent_reader")) + +from patent_link import discover_links, load_patent_notes, run_link_pipeline # noqa: E402 + + +def _note(pub: str, domain: str, ipc: str, assignees: list[str], terms: list[str], extra: str = "") -> str: + term_rows = "\n".join(f"| {t} | 定义 | 说明书 |" for t in terms) + asg = "\n".join(f" - {a}" for a in assignees) + return f"""--- +tags: + - patents/{domain} +cssclasses: + - patent-reader +pub_number: {pub} +domain: {domain} +ipc: {ipc} +assignees: +{asg} +evidence_scope: full_text +confidence_speculative: false +--- + +# 专利解读:{pub} + +## 五、专利内术语表 + +| 术语 | 专利内含义 | 依据 | +| --- | --- | --- | +{term_rows} + +## 十一、免责声明 + +不构成法律意见。 + +{extra} +""" + + +def test_link_same_assignee_and_terms() -> None: + with tempfile.TemporaryDirectory() as td: + vault = Path(td) + papers = "Research/Patents" + d1 = vault / papers / "化工与材料" / "CN111" + d2 = vault / papers / "化工与材料" / "CN222" + d1.mkdir(parents=True) + d2.mkdir(parents=True) + (d1 / "CN111_解读_20260101.md").write_text( + _note( + "CN111111111A", + "化工与材料", + "H01M50/00", + ["示例科技有限公司"], + ["耐热层", "基膜"], + ), + encoding="utf-8", + ) + (d2 / "CN222_解读_20260102.md").write_text( + _note( + "CN222222222A", + "化工与材料", + "H01M10/0525", + ["示例科技有限公司"], + ["耐热层", "隔膜"], + extra="背景中提及 CN111111111A 作为对比。", + ), + encoding="utf-8", + ) + # 无关第三件:不同申请人、不同领域 + d3 = vault / papers / "软件" / "CN333" + d3.mkdir(parents=True) + (d3 / "CN333_解读_20260103.md").write_text( + _note( + "CN333333333A", + "软件", + "G06F9/00", + ["另一家公司"], + ["调度器"], + ), + encoding="utf-8", + ) + + notes = load_patent_notes(vault, papers) + assert len(notes) == 3 + edges = discover_links(notes, min_score=0.45) + pubs_pairs = {tuple(sorted([e["pub_a"], e["pub_b"]])) for e in edges} + assert ("CN111111111A", "CN222222222A") in pubs_pairs + assert all("CN333333333A" not in p for p in pubs_pairs) + + result = run_link_pipeline( + vault, + papers_dir=papers, + min_score=0.45, + focus_pub="CN222222222A", + refresh_canvas=True, + refresh_global_canvas=True, + ) + assert result["edge_count"] >= 1 + assert result["global_canvas"] + gpath = Path(result["global_canvas"]) + assert gpath.is_file() + canvas = json.loads(gpath.read_text(encoding="utf-8")) + texts = "\n".join( + n.get("text") or "" for n in canvas["nodes"] if n.get("type") == "text" + ) + assert "专利关联总览" in texts + assert "CN111111111A" in texts or "CN222222222A" in texts + assert any(str(n.get("id", "")).startswith("br") for n in canvas["nodes"]), ( + "应有关联桥卡" + ) + assert any(n.get("id") == "legend" for n in canvas["nodes"]) + n2 = (d2 / "CN222_解读_20260102.md").read_text(encoding="utf-8") + assert "## 相关专利" in n2 + assert "相关专利(自动关联)" not in n2 + assert "related_pubs:" in n2 + assert "CN111111111A" in n2 + # 双向 + n1 = (d1 / "CN111_解读_20260101.md").read_text(encoding="utf-8") + assert "CN222222222A" in n1 + + +def test_model_scores_merge() -> None: + with tempfile.TemporaryDirectory() as td: + vault = Path(td) + papers = "Research/Patents" + for pub, asg, ipc, terms in ( + ("CNAAA000001A", "甲", "G06F1/00", ["模块甲"]), + ("CNBBB000001A", "乙", "A61K9/00", ["完全不同词"]), + ): + p = vault / papers / "未分类" / pub + p.mkdir(parents=True) + (p / f"{pub}_解读_20260101.md").write_text( + _note(pub, "未分类", ipc, [asg], terms), + encoding="utf-8", + ) + notes = load_patent_notes(vault, papers) + edges = discover_links( + notes, + min_score=0.4, + model_scores=[ + { + "pub_a": "CNAAA000001A", + "pub_b": "CNBBB000001A", + "relation": "improvement", + "score": 0.8, + "rationale": "测试模型边", + } + ], + ) + assert len(edges) == 1 + assert edges[0]["relation"] == "improvement" + assert edges[0]["source"] in ("model", "rules+model") + assert edges[0]["score"] >= 0.8 + + +def main() -> int: + test_link_same_assignee_and_terms() + test_model_scores_merge() + print("OK link_patent_notes smoke") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.agents/skills/patent-disclosure-skill/tests/test_patent_reader_p0_p2.py b/.agents/skills/patent-disclosure-skill/tests/test_patent_reader_p0_p2.py new file mode 100644 index 0000000..86a8c02 --- /dev/null +++ b/.agents/skills/patent-disclosure-skill/tests/test_patent_reader_p0_p2.py @@ -0,0 +1,131 @@ +# -*- coding: utf-8 -*- +"""P0 术语 / P1-P2 附图引擎冒烟。""" +from __future__ import annotations + +import json +import sys +import tempfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "tools" / "patent_reader")) + +from figure_extract import CAPTION_RE, _classify_visual_quality, extract_patent_pdf_figures # noqa: E402 +from obsidian import build_canvas, resolve_glossary_nodes, scan_glossary_index # noqa: E402 + + +def test_caption_re() -> None: + assert CAPTION_RE.match("图1 一种隔膜结构示意图") + assert CAPTION_RE.match("图 2") + assert CAPTION_RE.match("FIG. 3") + assert CAPTION_RE.match("【图1】") + assert not CAPTION_RE.match("如图1所示,基膜包括") + + +def test_quality_gate() -> None: + q = _classify_visual_quality( + page_coverage_ratio=0.3, + visual_rect_count=5, + visual_body_ratio=0.25, + paragraph_text_chars=20, + ) + assert q["status"] == "usable" + q2 = _classify_visual_quality( + page_coverage_ratio=0.95, + visual_rect_count=1, + visual_body_ratio=0.01, + paragraph_text_chars=300, + ) + assert q2["status"] == "reject" + + +def test_glossary_and_canvas() -> None: + with tempfile.TemporaryDirectory() as td: + vault = Path(td) + papers = "Research/Patents" + gloss = "Research/术语" + (vault / papers / "化工与材料" / "CN1").mkdir(parents=True) + note = vault / papers / "化工与材料" / "CN1" / "CN1_解读_20260101.md" + note.write_text("# 测试\n", encoding="utf-8") + resolved = resolve_glossary_nodes( + vault, + gloss, + [{"term": "耐热层", "definition": "覆盖在基膜表面的涂层"}], + create_stubs=True, + source_pub="CN1", + papers_dir=papers, + ) + assert resolved[0]["has_file"] + assert (vault / gloss / "耐热层.md").is_file() + idx = scan_glossary_index(vault, gloss) + assert "耐热层" in idx + canvas = build_canvas( + vault=vault, + papers_dir=papers, + note_rel_path=str(note.relative_to(vault)).replace("\\", "/"), + pub="CN1", + title="测试专利", + related={"related_patents": [], "disclosures": []}, + glossary_terms=[{"term": "耐热层", "definition": "覆盖在基膜表面的涂层"}], + glossary_dir=gloss, + create_glossary_stubs=False, + narrative={ + "problem": "旧工艺不安全", + "approach": "改用水性浆料", + "how": "分散涂布", + "effect": "更薄", + }, + ) + types = {n.get("type") for n in canvas["nodes"]} + assert "group" in types, "应有叙事/术语分组" + narr = [n for n in canvas["nodes"] if str(n.get("id", "")).startswith("narr-")] + assert len(narr) >= 3, narr + term_cards = [ + n + for n in canvas["nodes"] + if n.get("type") == "text" and "耐热层" in (n.get("text") or "") + ] + assert term_cards and "覆盖在基膜表面" in term_cards[0]["text"] + center = next(n for n in canvas["nodes"] if n.get("id") == "center") + assert center.get("type") == "text" + assert "打开解读笔记" in (center.get("text") or "") + + +def test_figure_extract_synthetic_pdf() -> None: + try: + import fitz + except ImportError: + print("SKIP figure pdf (no pymupdf)") + return + with tempfile.TemporaryDirectory() as td: + pdf = Path(td) / "t.pdf" + out = Path(td) / "figures" + doc = fitz.open() + page = doc.new_page() + page.draw_rect(fitz.Rect(72, 72, 400, 400), color=(0, 0, 0), width=2) + page.draw_line(fitz.Point(100, 100), fitz.Point(300, 300), color=(0, 0, 0), width=1) + page.draw_circle(fitz.Point(200, 200), 40, color=(0, 0, 0), width=1) + # 合成 PDF 用拉丁图号(中文字体在无系统字体时可能插不进) + page.insert_text(fitz.Point(72, 430), "FIG. 1 structure", fontsize=12) + doc.save(pdf) + doc.close() + man = extract_patent_pdf_figures(pdf, out) + assert man["count"] >= 1, man + fig = man["figures"][0] + assert fig.get("extraction_level") in ("figure", "page") + assert (out / fig["filename"]).is_file() + assert "decision" in fig + assert "quality_signals" in fig + + +def main() -> int: + test_caption_re() + test_quality_gate() + test_glossary_and_canvas() + test_figure_extract_synthetic_pdf() + print("OK p0_p1_p2 smoke") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.agents/skills/patent-disclosure-skill/tests/test_patent_reader_pipeline.py b/.agents/skills/patent-disclosure-skill/tests/test_patent_reader_pipeline.py new file mode 100644 index 0000000..5d44362 --- /dev/null +++ b/.agents/skills/patent-disclosure-skill/tests/test_patent_reader_pipeline.py @@ -0,0 +1,254 @@ +# -*- coding: utf-8 -*- +"""专利解读工具链冒烟测试(含 Obsidian L0–L2)。""" +from __future__ import annotations + +import json +import subprocess +import sys +import tempfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SAMPLE = ROOT / "tests" / "fixtures" / "patent_reader_sample.txt" + + +def run(cmd: list[str], env: dict | None = None) -> subprocess.CompletedProcess[str]: + import os + + merged = {**os.environ, **(env or {})} + return subprocess.run( + cmd, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + env=merged, + ) + + +def build_sample_note() -> str: + t = (ROOT / "assets" / "patent_note_template.md").read_text(encoding="utf-8") + t = ( + t.replace("{{发明名称或公开号}}", "示例隔膜") + .replace("{{CN…}}", "CN999999999B") + .replace("{{domain}}", "化工与材料") + .replace("{{ipc}}", "H01M") + .replace("{{assignees}}", "示例科技") + .replace("{{入门|研发|规避}}", "入门") + .replace("{{全文|仅摘要|部分}}", "全文") + .replace("{{RUN}}", "test-run") + .replace("{{公开号目录}}", "CN999999999B") + .replace("YYYY-MM-DD", "2026-07-21") + .replace( + "| | | desc_… / 实施例… |", + "| 电芯隔膜 | 电池内部 | desc_001 / 实施例1 |", + ) + .replace( + "(来自 `context_anchor.ipc_application`。)", + "电化学储能(离线词表)。", + ) + .replace( + "> - **线索**:标题 — 置信度:中 — [来源](URL) — 理由:…\n>\n> 无可靠 URL 时写:", + "> 未发现可核验的公开对应,可能为防御性/储备专利。\n>\n> 无可靠 URL 时写:", + ) + .replace( + "> 【{{公开号}}·权利要求{{N}}】{{原文逐字片段}}", + "> 【CN999999999B·权利要求1】1.一种锂离子电池隔膜", + ) + ) + return t + + +def main() -> int: + out = ROOT / "tmp" / "test_patent_reader" + if out.exists(): + import shutil + + shutil.rmtree(out) + + r = run( + [ + sys.executable, + str(ROOT / "tools" / "patent_reader" / "extract_patent_text.py"), + "-i", + str(SAMPLE), + "-o", + str(out), + "--pub-number", + "CN999999999B", + ] + ) + if r.returncode != 0: + print(r.stderr or r.stdout) + return r.returncode + + r2 = run( + [sys.executable, str(ROOT / "tools" / "patent_reader" / "build_context_anchor.py"), "-w", str(out)] + ) + if r2.returncode != 0: + print(r2.stderr or r2.stdout) + return r2.returncode + + mmd = out / "claim_mermaid.mmd" + r2b = run( + [ + sys.executable, + str(ROOT / "tools" / "patent_reader" / "build_claim_mermaid.py"), + "--claim-tree", + str(out / "claim_tree.json"), + "--pub-number", + "CN999999999B", + "-o", + str(mmd), + ] + ) + if r2b.returncode != 0: + print(r2b.stderr or r2b.stdout) + return r2b.returncode + assert "subgraph" in mmd.read_text(encoding="utf-8") + + note = ROOT / "tmp" / "test_patent_note.md" + note.write_text(build_sample_note(), encoding="utf-8") + + plan = { + "sections": ["all"], + "grounding": {"section9": "desc_001"}, + "context_anchor_ref": str(out / "context_anchor.json"), + "public_clues_ref": str(out / "public_clues.json"), + } + (out / "note_plan.json").write_text(json.dumps(plan, ensure_ascii=False), encoding="utf-8") + (out / "public_clues.json").write_text("[]", encoding="utf-8") + + lint_json = out / "lint.json" + r3 = run( + [ + sys.executable, + str(ROOT / "tools" / "patent_reader" / "lint_patent_note.py"), + "--note", + str(note), + "--manifest", + str(out / "source_manifest.json"), + "--claim-tree", + str(out / "claim_tree.json"), + "--plan", + str(out / "note_plan.json"), + "--context-anchor", + str(out / "context_anchor.json"), + "--output", + str(lint_json), + ] + ) + if r3.returncode != 0: + print(r3.stderr or r3.stdout) + return r3.returncode + + with tempfile.TemporaryDirectory() as td: + vault = Path(td) + env = {"PATENT_READER_OBSIDIAN_VAULT": str(vault)} + + r_setup = run( + [sys.executable, str(ROOT / "tools" / "patent_reader" / "setup_obsidian_vault.py")], + env=env, + ) + if r_setup.returncode != 0: + print(r_setup.stderr or r_setup.stdout) + return r_setup.returncode + + assert (vault / "Research" / "Patents" / "patents.base").is_file() + assert (vault / ".obsidian" / "snippets" / "patent-reader.css").is_file() + assert (vault / ".obsidian" / "appearance.json").is_file() + appearance = json.loads( + (vault / ".obsidian" / "appearance.json").read_text(encoding="utf-8") + ) + assert "patent-reader" in appearance.get("enabledCssSnippets", []) + assert (vault / "Research" / "术语" / "glossary.base").is_file() + + r4 = run( + [ + sys.executable, + str(ROOT / "tools" / "patent_reader" / "write_patent_obsidian_note.py"), + "--content-file", + str(note), + "--manifest", + str(out / "source_manifest.json"), + "--context-anchor", + str(out / "context_anchor.json"), + "--bundle", + str(out / "synthesis_bundle.json"), + "--public-clues", + str(out / "public_clues.json"), + "--workdir", + str(out), + "--lint-json", + str(lint_json), + "--output", + str(out / "write_status.json"), + ], + env=env, + ) + if r4.returncode != 0: + print(r4.stderr or r4.stdout) + return r4.returncode + + status = json.loads((out / "write_status.json").read_text(encoding="utf-8")) + assert status.get("canvas"), "应生成 canvas" + canvas = Path(status["canvas"]) + assert canvas.is_file() + canvas_data = json.loads(canvas.read_text(encoding="utf-8")) + assert len(canvas_data.get("nodes", [])) >= 2 + + written = Path(status["written"]) + body = written.read_text(encoding="utf-8") + assert "patent-reader" in body + assert "patents/化工与材料" in body + assert "术语索引" in body + assert body.count("## Obsidian 导航") == 1 + + # 校验 public_clues 脚本 + r_clues = run( + [ + sys.executable, + str(ROOT / "tools" / "patent_reader" / "validate_public_clues.py"), + "-i", + str(out / "public_clues.json"), + ] + ) + if r_clues.returncode != 0: + print(r_clues.stderr or r_clues.stdout) + return r_clues.returncode + + # 无 vault:仍应产出 canvas + 本地术语 + r5 = run( + [ + sys.executable, + str(ROOT / "tools" / "patent_reader" / "write_patent_obsidian_note.py"), + "--content-file", + str(note), + "--manifest", + str(out / "source_manifest.json"), + "--context-anchor", + str(out / "context_anchor.json"), + "--bundle", + str(out / "synthesis_bundle.json"), + "--public-clues", + str(out / "public_clues.json"), + "--lint-json", + str(lint_json), + "--output", + str(out / "write_status_novault.json"), + ], + env={"PATENT_READER_OBSIDIAN_VAULT": "", "PATENT_DISCLOSURE_OBSIDIAN_VAULT": ""}, + ) + if r5.returncode != 0: + print(r5.stderr or r5.stdout) + return r5.returncode + st2 = json.loads((out / "write_status_novault.json").read_text(encoding="utf-8")) + assert st2.get("canvas"), "无 vault 也应生成 canvas" + assert Path(st2["canvas"]).is_file() + + print("OK patent reader pipeline smoke") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.agents/skills/patent-disclosure-skill/tools/README.md b/.agents/skills/patent-disclosure-skill/tools/README.md new file mode 100644 index 0000000..69658c0 --- /dev/null +++ b/.agents/skills/patent-disclosure-skill/tools/README.md @@ -0,0 +1,291 @@ +# tools / 可选脚本 + +本目录存放**可重复执行的辅助脚本**。技能主流程以 `SKILL.md` 与 `prompts/` 为准;本目录侧重格式转换等可执行工具。 + +## 国知局公布公告检索(epub.cnipa.gov.cn,Step 5 查新优先) + +| 脚本 | 作用 | +|------|------| +| **`cnipa_epub_search.py`** | **(Step 5 优先)** 一步:拉取 + 解析,**不写结果页 HTML 落盘**;**Agent 须按 `prior_art_search.md` 分多次调用、每轮一词并自行合并 JSON**;脚本在**单次命令多词**时也会进程内循环检索并合并(人工/本地便利);**stdout 仅一行** `EPUB_HITS_JSON:`;stderr 上 `EPUB_*` 为 **ASCII**;UTF-8 / PowerShell 见 **INSTALL.md**。 | +| **`cnipa_epub_crawler.py`** | 仅 Playwright 拉取并**默认保存**结果页 HTML;stdout 亦含 **`EPUB_HITS_JSON:`**。结果页就绪判据:`commit` 导航后等 **title**(`EPUB_TITLE_*`)且 **`#result`** 内出现 `div.item` 或零结果文案(见脚本内 `_wait_result_page_ready`)。 | +| **`cnipa_epub_parse.py`** | 仅解析已保存的 HTML:`python tools/cnipa_epub_parse.py path/to/_last_result_xxx.html`;字段含标题、公开号、链接、**`abstract`**(若有)。 | + +依赖:`pip install -r tools/requirements-cnipa.txt` 与 `python -m playwright install chromium`。环境变量见各脚本文件头。默认结果 HTML 落在 **`tools/_last_result_*.html`**(已 `.gitignore`)。 + +抓取失败或解析无命中时,Agent 按 **`prompts/prior_art_search.md`** 降级 **WebSearch**(如 Google 学术 / Google Patents)。 + +--- + +## Office 文档(Word / PPT)转成可扫描文本 + +用本仓库 **`docx_to_md.py`**、**`pptx_to_md.py`**(纯 Python + 仓库根目录 `requirements.txt`),见下文各节;与 `SKILL.md`「工具与数据来源」一致。 + +## mermaid_render.py — mermaid:图示 → PNG + 定稿 Markdown + **默认生成 Word** + +将 fenced **mermaid**(`` ```mermaid`` ``)逐块交给 **`mmdc`** 渲染为 PNG;输出 `.md` 中**保留** mermaid 围栏源码,并追加 ```` 供 **`md_to_docx.py`** 嵌入 Word(Word **仅**嵌 PNG,不写 mermaid 代码块)。**3.2 系统框图**与 **3.4 流程图**均用 mermaid(`flowchart` / `subgraph` 等),交底书正文**不再**要求单独的文字框图或 PlantUML。 + +**生图失败降级**:某一围栏 `mmdc` 失败时**不中断**——该处**保留**原 `` ```mermaid`` … `` ``` `` 源码;其余块照常出图。仍写出定稿 `.md`,并**照常尝试**生成 Word(未出图块在 Word 中为 **Consolas 代码块**,与 `md_to_docx` 行为一致)。 + +### 依赖:mermaid(须 Node.js + `mmdc`) + +| 方式 | 安装 | 说明 | +|------|------|------| +| **本地 npm(推荐)** | **Node.js** + 本目录 `npm install`(见 `package.json`) | 优先使用 `tools/node_modules/.bin/mmdc`,避免每次 npx 拉包 | +| **npx** | 未执行 `npm install` 时由脚本调用 `npx -y @mermaid-js/mermaid-cli mmdc` | 首次可能较慢 | +| **全局 npm** | `npm install -g @mermaid-js/mermaid-cli` | 提供 **PATH** 上的 `mmdc` | + +mermaid 脚本按顺序查找:`tools/node_modules/.bin/mmdc` → **PATH** 上的 `mmdc` → `npx`。 + +生成 Word 仍需:`pip install -r requirements.txt`(与上表无关)。 + +**npm 推荐(本地 CLI)**: + +```bash +cd tools +npm install +``` + +`package.json` 已包含 **`puppeteer`**(`@mermaid-js/mermaid-cli` 的 peer)。**Puppeteer 23+** 可能不会在 `npm install` 时自动下载浏览器;若自检或 `mmdc` 报错 **Could not find Chrome**,在 **`tools/`** 再执行: + +```bash +npx puppeteer browsers install chrome-headless-shell +``` + +(或按报错提示选用 `chrome` 等;详见 [Puppeteer 文档](https://pptr.dev/)。) + +### mermaid CLI 与手动试转 + +**`mermaid_render.py` 与 11.x 一致**:在 **`mmdc -i <.mmd> -o <.png> -b white`** 基础上默认追加 **`-s 2 -w 1400 -H 1050`**(更高像素密度与视口,系统框图在 Word 中更清晰)。需要再锐化可 **`--mmdc-scale 3`**(PNG 更大);恢复接近旧版可 **`--mmdc-scale 1 --mmdc-width 800 --mmdc-height 600`**。 +若某处写的是 `npx -y @mermaid-js/mermaid-cli -i …`,**少了子命令 `mmdc`**,参数会错位;正确示例: + +```bash +npx -y @mermaid-js/mermaid-cli mmdc -i sample.mmd -o sample.png -b white +``` + +可自建极简 `sample.mmd`(如一行 `flowchart LR; A-->B`)试转;能出 PNG 则说明 **mmdc + Chrome** 正常,否则按上文安装 **`puppeteer` 浏览器**。 + +### 用法 + +```bash +# 写出定稿 .md,并在同目录生成同名 .docx(默认);-o 须为「案件名_YYYYMMDDHHmmss.md」(见 prompts/disclosure_builder.md §7.3 第 5 点) +python3 tools/mermaid_render.py -i draft.md -o "一种XXX方法及系统_20260408143025.md" + +# 指定 .docx 路径(.md 主名仍须含时间戳) +python3 tools/mermaid_render.py -i draft.md -o out/一种XXX方法及系统_20260408143025.md --docx out/一种XXX方法及系统_20260408143025.docx + +# 仅 Markdown,不要 Word +python3 tools/mermaid_render.py -i draft.md -o "一种XXX方法及系统_20260408143025.md" --no-docx + +# 更高清晰度(可选) +python3 tools/mermaid_render.py -i draft.md -o "…定稿.md" --mmdc-scale 3 --mmdc-width 1600 --mmdc-height 1200 + +# 指定 mermaid 图片子目录(相对输出 .md) +python3 tools/mermaid_render.py -i draft.md -o out/一种XXX方法及系统_20260408143025.md --assets-dir figures/mermaid +``` + +**Word 生成失败**(缺依赖、版式报错等)时:脚本仍以退出码 **0** 结束(Markdown 已成功);stderr 会打印 **`md_to_docx.py` 的手动命令**,请复制执行。 + +Windows 上若仅装 Node 未执行 `npm install`,脚本会通过 `npx -y @mermaid-js/mermaid-cli mmdc` 调用(首次可能较慢)。 + +### 与交底书约定 + +- 技能要求定稿**同时**交付 **Markdown + Word**,且 **`-o` 主文件名须含 `_{YYYYMMDDHHmmss}`**(`prompts/disclosure_builder.md` §7.3 第 5 点,含首次定稿);**3.2 系统框图**与 **3.4 流程图**均用 fenced mermaid,**不要** ASCII 文字流程图或框图。 +- 交付代理人前:运行 `mermaid_render.py` 一步即可(默认再调 `md_to_docx.py`);若 Word 失败,按 stderr 提示手动执行 `md_to_docx.py`。 + +--- + +## math_render.py — LaTeX 公式 → PNG + +将 Markdown 中的 **LaTeX 公式**(``$...$`` / ``\\(...\\)`` 行内;``$$...$$`` / ``\\[...\\]`` 块级)用 **matplotlib mathtext** 渲染为 PNG;**保留 LaTeX 原文**,图片引用写入 HTML 注释 ````(Markdown 预览不显示图),供 **`md_to_docx.py`** 嵌入 Word。 + +**Mermaid 框图**:``mermaid_render.py`` **保留** `` ```mermaid`` 源码,并追加 ````(预览隐藏图引用,Word 仍大图嵌入)。 + +**mathtext 兼容**:渲染前自动将常见 LaTeX 简写映射为 mathtext 符号(如 ``\ge``→``\geq``、``\le``→``\leq``、``\land``→``\wedge``);块级式内**换行压成一行**、``\tag{1}`` 转为式末 ``(1)``;仍无法解析的公式保留原文。 + +**失败降级**:某一公式渲染失败时**不中断**——该处**保留原文**(``$...$`` 或 ``$$...$$``);``md_to_docx`` 对未转换的 ``$$`` 块以 **Consolas 代码块**写入 Word。 + +**Word 版式**:**全部公式图**(行内与块级式 (1) 等)在 Word 中统一按约 **0.17 英寸**高度嵌入;**mermaid 框图/流程图**仍按 **5.5×8.2 英寸**上限等比嵌入。块级 PNG 默认与行内同字号(10.5pt)渲染,避免块级式显得过粗过大。 + +### 依赖 + +```bash +pip install -r requirements.txt # 含 matplotlib +``` + +### 用法 + +```bash +python3 tools/math_render.py -i draft.md -o draft_with_math.md +python3 tools/math_render.py -i draft.md -o out.md --assets-dir math_figures +``` + +定稿流水线:**``mermaid_render.py`` 默认先跑公式再跑 mermaid**(可用 ``--no-math`` 跳过)。单独转 Word 时 **`md_to_docx.py` 也会自动尝试公式渲染**(``--no-math-render`` 可关闭)。 + +--- + +## md_to_docx.py — Markdown → Word + +将交底书 Markdown 转为 `.docx`,**`#`–`######` 映射为 Word 内置「标题 1」–「标题 9」**,正文为宋体 10.5pt,代码块为 Consolas,便于交给代理人或所内用 Word 修订。 + +**图示**:定稿应用 **`mermaid_render.py`** 将 mermaid 转为 PNG;若个别块生图失败被降级保留围栏,本脚本会将**仍存在的** `` ```mermaid`` 块按**代码块**写入 Word。本脚本不调用 `mmdc`。 + +### 依赖 + +```bash +pip install -r requirements.txt +``` + +依赖为 `python-docx`(见仓库根目录 `requirements.txt`)。 + +### 用法 + +```bash +python3 tools/md_to_docx.py --input path/to/交底书.md --output path/to/交底书.docx +``` + +图片 `![](相对路径.png)`:默认相对 **Markdown 文件所在目录**;也可指定根目录: + +```bash +python3 tools/md_to_docx.py -i ./outputs/case/disclosure.md -o ./outputs/case/disclosure.docx --base-dir ./outputs/case +``` + +**插图**:对 PNG/GIF/JPEG 会读取像素尺寸,在默认 **最大宽 5.5" × 最大高 8.2"** 内**等比缩放**并同时指定 `width`/`height`,避免竖长流程图仅按宽度放大后**高度超出版心**、打印或阅读时像被裁切。可按纸张边距调整,例如: + +```bash +python3 tools/md_to_docx.py -i a.md -o a.docx --image-max-width-inches 6 --image-max-height-inches 9 +``` + +在 Claude Code 中可将 `tools` 换为 `${CLAUDE_SKILL_DIR}/tools`。 + +### 支持的 Markdown 子集 + +| 元素 | 行为 | +|------|------| +| `#`–`######` | Word 标题 1–9 | +| 段落 | 宋体正文,支持 `**粗体**`、`` `行内代码` ``;**相邻非空行(中间无空行)各自成段**,「(1)…(2)…」会分行显示 | +| `-` / `*` 列表 | 项目符号列表 | +| `1.` 列表 | 编号列表 | +| ` ``` ` 围栏 | 等宽代码块 | +| `\| 表格 \|` | 简单表格(Table Grid);单元格内 ``\\(...\\)``、``$...$``、```` 及 ``\\|`` 中的 ``|`` **不会**被当作列分隔符 | +| `> ` | 左缩进引用 | +| `---` 等 | 浅色分隔线 | +| `![](path)` | 嵌入图片(路径需存在;默认宽/高上限内等比缩放;公式图与正文混排) | +| `$` / `\\(...\\)` / `$$` / `\\[...\\]` LaTeX | 默认先 **`math_render`** 转 PNG(注释隐藏引用);失败则 **原文**写入 Word | + +**未完整支持**:复杂嵌套列表、HTML 块、**未预渲染的** mermaid 围栏(仍为代码块)、脚注、任务列表等。定稿前请运行 **`mermaid_render.py`**;若仅用外部工具导出 PNG,可直接写 `![](...)`。 + +### 版式说明(md_to_docx) + +- 不同语言 Word 中「标题 1」显示名可能为「Heading 1」或「标题 1」,样式仍为大纲级别标题,可用导航窗格与目录域。 +- 若需所内固定模版(页眉、首页不同),可在本脚本生成后套用单位 `.dotx`,或后续扩展 `python-docx` 打开模版再写入。 + +--- + +## iteration_dialog_log.py — 修订对话记录(迭代用) + +每轮 **`merger.md` / `correction_handler.md`** 交付后,在**案件目录**追加一条 **`交底书修订对话记录.md`**:含**本地时间与 UTC**、用户说明摘要、本轮交付文件名、合并/纠正摘要摘录。规则见 **`prompts/iteration_context.md`**。 + +**依赖**:仅标准库。 + +```bash +python3 tools/iteration_dialog_log.py --case-dir outputs/某案件 --kind merge \ + --user "补充了调度装置资料,合并进第三章" \ + --summary "已扩写 3.4,并更新实施例;未改保护点表述。" \ + --artifacts "一种XXX方法及系统_20260408143025.md,一种XXX方法及系统_20260408143025.docx" +``` + +- `--kind`:`merge` 或 `correct`。 +- `--log-name`:可选,默认 `交底书修订对话记录.md`;英文环境可改用 `disclosure_revision_log.md`。 +- 无法执行脚本时,由 Agent 按同结构手工追加。 + +--- + +## docx_to_md.py — Word → Markdown + 抽取图片 + +将 **.docx**(Word / WPS 等另存为 docx)转为 **Markdown**,并把文档内嵌图片落到磁盘,便于 **`Read` 与 Step 2 扫描**(与直接读二进制 .docx 相比更稳)。**Step 2** 对扫描树内**每一个** `.docx` 都应先转换再读产出 `.md`,见 `prompts/project_scan.md`。 + +### 依赖 + +与 `md_to_docx` 共用根目录 `requirements.txt`(`python-docx` + **`mammoth`**)。 + +```bash +pip install -r requirements.txt +``` + +### 用法 + +```bash +python3 tools/docx_to_md.py --input path/to/设计说明.docx --output outputs/case/design.md +``` + +- 默认图片目录:`outputs/case/design_media/`,Markdown 内为相对路径 `![](design_media/img_0001.png)`。 +- 自定义图片目录: + +```bash +python3 tools/docx_to_md.py -i ./raw/spec.docx -o ./knowledge/spec.md --media-dir ./knowledge/spec_assets +``` + +转换警告(如部分样式、WMF 图)会输出到 **stderr**,仍可能生成可用 `.md`。 + +### 局限(mammoth) + +- 仅 **`.docx`**(OOXML);老版 **`.doc`** 不支持。 +- **Markdown 输出在 mammoth 侧标记为 deprecated**,复杂排版可能弱于「先导出 HTML 再转 MD」;专利扫描一般足够。若版式崩坏,建议所内 **另存为 PDF 或纯文本** 再扫。 +- **WMF/EMF** 等 Windows 图元可能需单独处理(见 [mammoth WMF 配方](https://github.com/mwilliamson/python-mammoth))。 + +在 Claude Code 中可将 `tools` 换为 `${CLAUDE_SKILL_DIR}/tools`。Windows 无 `python3` 时用 `python`。 + +--- + +## pptx_to_md.py — PowerPoint → Markdown + 抽取图片 + +将 **.pptx** / **.ppsx** 按**幻灯片页**导出为 Markdown,并抽取幻灯片中的**嵌入位图**(`PICTURE` 形状),便于 **`Read` 与 Step 2 扫描**。**Step 2** 对扫描树内**每一个** `.pptx` 均应先转换再读 `.md`,见 `prompts/project_scan.md`。 + +### 依赖 + +根目录 `requirements.txt` 中的 **`python-pptx`**。 + +```bash +pip install -r requirements.txt +``` + +### 用法 + +```bash +python3 tools/pptx_to_md.py --input path/to/评审材料.pptx --output outputs/case/review.md +``` + +- 默认图片目录:`outputs/case/review_media/`,文件名形如 `slide03_img0001.png`。 +- 自定义图片目录: + +```bash +python3 tools/pptx_to_md.py -i ./raw/deck.pptx -o ./knowledge/deck.md --media-dir ./knowledge/deck_media +``` + +每页输出二级标题 `## 第 N 页`,其后为该页形状中的**文本与表格**(简化为管道表)及图片引用;若存在**演讲者备注**,以「**备注**」小节附于该页末尾。 + +### 局限(python-pptx) + +- 仅 **`.pptx` / `.ppsx`**(OOXML);**`.ppt`** 不支持,请先另存。 +- **图表、SmartArt、嵌入 OLE** 等若未以普通图片形状存在,**不会**自动栅格化为 PNG;可先在 PowerPoint 中另存为图片或导出 PDF 作补充材料。 +- 文本按形状遍历顺序输出,与视觉阅读顺序可能略有差异。 + +在 Claude Code 中可将 `tools` 换为 `${CLAUDE_SKILL_DIR}/tools`。Windows 无 `python3` 时用 `python`。 + +--- + +## 专利通俗解读(阅读模式) + +脚本与说明见 **[`patent_reader/README.md`](patent_reader/README.md)**。 + +```bash +pip install -r tools/patent_reader/requirements.txt +``` + +--- + +## 扩展其它脚本时 + +- Word / PPT 转换依赖写在 `requirements.txt`。 +- 在 `SKILL.md`「工具与数据来源」表中增加一行调用说明。 +- 勿将密钥写入仓库;配置使用环境变量或用户主目录。 diff --git a/.agents/skills/patent-disclosure-skill/tools/cnipa_epub_crawler.py b/.agents/skills/patent-disclosure-skill/tools/cnipa_epub_crawler.py new file mode 100644 index 0000000..e2bfa5c --- /dev/null +++ b/.agents/skills/patent-disclosure-skill/tools/cnipa_epub_crawler.py @@ -0,0 +1,273 @@ +# -*- coding: utf-8 -*- +""" +中国专利公布公告网站点:http://epub.cnipa.gov.cn/ —— **首页「公布公告查询」** 检索(#indexForm / #searchStr)。 + +须安装 **Playwright + Chromium**。若只需内存中解析、不落盘 HTML,优先用同目录 **`cnipa_epub_search.py`**; +本文件侧重 **写出结果页 HTML** 与可插拔的 ``fetch_epub_result_html`` API。 + +------------------------------------------------------------------------------- +一、整体流程(单次检索) +------------------------------------------------------------------------------- +1. 启动 Chromium(默认无头;可用环境变量改为有界面)。 +2. 新建浏览器上下文:设定 **桌面 Chrome UA**、**zh-CN**、固定 **视口**(见 ``_new_context``),使请求形态接近普通用户浏览器。 +3. ``page.goto`` 站点首页,**wait_until="load"**。 +4. **等待首页可检索**:首页在访客到达后会先经 **前端脚本/WAF 一类逻辑**,未通过前 **不会出现** 检索输入框 ``#searchStr``。本实现通过 **周期性轮询 DOM**(每 3 秒一次,总时长见 ``EPUB_WAF_MAX_WAIT_SEC``,默认 180s)直到 ``#searchStr`` 出现;**不是**用 requests 直接 POST 能等价替代的步骤。 +5. ``page.fill`` 将关键词写入 ``#searchStr``,对 ``#indexForm`` 执行 **submit**(而非单独点按钮),并等待结果页导航 **commit**。 +6. 等待结果页就绪:标题为 **「专利查询结果展示」或「无查询结果」**(见 ``EPUB_TITLE_*`` 常量),且 ``#result`` 内出现列表条目(``div.item`` / ``h1.title``)或明确零结果文案;不等待完整 ``load``。国知局改版时需同步调整常量与 ``_RESULT_PAGE_READY_JS``。 +7. ``page.content()`` 取全页 HTML;若处于导航中抛错则 **重试退避**(``_safe_page_content``),避免竞态。 +8. 后续解析由 **`cnipa_epub_parse.py`** 完成(本文件 ``search_epub_keyword`` 内会调用)。 + +------------------------------------------------------------------------------- +二、策略摘要:在解决什么、用了哪些手段 +------------------------------------------------------------------------------- +- **为何用 Playwright**:站点依赖 **浏览器内 JavaScript** 渲染与风控后再开放检索框;**纯 HTTP 抓取**往往拿不到含 ``#searchStr`` 的可用首页或拿不到真实结果 DOM。 +- **所谓「绕过」**:指 **技术层面** 与无头自动化、静态抓取之间的 gap——通过 **真实 Chromium 内核 + 等待 JS 完成 + 常见浏览器指纹**(UA、语言、viewport)降低「一进来就_submit」的失败率;**不**表示规避法律法规或站点服务条款,用途应限合法检索与交底书查新辅助。 +- **反自动化/特征**:启动参数 ``--disable-blink-features=AutomationControlled`` 用于减弱 Chromium 的 **webdriver 自动化开关** 暴露(效果因站点升级而变,非保证)。 +- **不覆盖的场景**:图形/滑块验证码、短信验证、强制登录等——若站点突然启用,本脚本**无**专门破解逻辑;可尝试 ``PLAYWRIGHT_HEADED=1`` 人工辅助或改用 **WebSearch**(见 ``prompts/prior_art_search.md``)。 + +------------------------------------------------------------------------------- +三、检索关键词建议 +------------------------------------------------------------------------------- +- 公布站首页检索框对 **多个词** 通常按 **同时包含(AND)** 理解,**词多且专**时极易 **0 条**;**建议每次尽量使用单个词或极短短语** 做一次检索,需要宽召回时可用 **`cnipa_epub_search.py`**(按空白拆成多词、多次检索再合并),或分多次手动换关键词。 +- 本脚本命令行默认仍接受一个参数字符串(可含空格);含空格时与浏览器内一次提交一致,语义上仍是 **整句 AND**,不等同于拆词多查。 + +------------------------------------------------------------------------------- +环境变量 +------------------------------------------------------------------------------- + EPUB_WAF_MAX_WAIT_SEC 轮询等待 #searchStr 的最长时间,默认 180 + PLAYWRIGHT_HEADED 设为 1 时使用有界面 Chromium + EPUB_RESULT_HTML 结果页 HTML 完整路径;不设则 tools/_last_result_YYYYMMDDHHmmss.html +""" +from __future__ import annotations + +import json +import os +import sys +from datetime import datetime +from pathlib import Path +from typing import Callable + +from playwright.sync_api import Browser, BrowserContext, Error, Page, Playwright, sync_playwright + +from cnipa_epub_parse import EpubSearchHit, hits_to_jsonable, parse_search_result_html + + +def _ensure_utf8_stdio() -> None: + """减轻 Windows 终端下 JSON 中文乱码(与 cnipa_epub_search.py 一致)。""" + for stream in (sys.stdout, sys.stderr): + try: + if hasattr(stream, "reconfigure"): + stream.reconfigure(encoding="utf-8", errors="replace") + except (OSError, ValueError, TypeError): + pass + + +EPUB_BASE = "http://epub.cnipa.gov.cn/" +# 国知局 /Dxb/IndexQuery 结果页 ;改版时须同步单测与 _RESULT_PAGE_READY_JS +EPUB_TITLE_RESULT = "专利查询结果展示" +EPUB_TITLE_NO_HIT = "无查询结果" +# 在浏览器内判断结果页可解析:title + #result DOM(列表或零结果文案) +_RESULT_PAGE_READY_JS = """(titles) => { + const t = document.title.trim(); + if (t === titles.noHit) return true; + if (t !== titles.result) return false; + const r = document.querySelector("#result"); + if (!r) return false; + if (r.querySelector("div.item, h1.title")) return true; + const html = r.innerHTML; + if ( + html.includes("无查询结果") || + html.includes("没有找到") || + html.includes("未检索到") || + html.includes("0条") + ) { + return true; + } + return false; +}""" +DEFAULT_USER_AGENT = ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" +) + + +def _max_wait_sec() -> float: + return float(os.environ.get("EPUB_WAF_MAX_WAIT_SEC", "180")) + + +def _headed() -> bool: + return os.environ.get("PLAYWRIGHT_HEADED", "").strip() in ("1", "true", "yes") + + +def default_result_html_path() -> Path: + ts = datetime.now().strftime("%Y%m%d%H%M%S") + return Path(__file__).resolve().parent / f"_last_result_{ts}.html" + + +def wait_for_epub_home_ready(page: Page, *, max_wait_sec: float | None = None) -> None: + limit = max_wait_sec if max_wait_sec is not None else _max_wait_sec() + page.goto(EPUB_BASE, wait_until="load", timeout=120_000) + elapsed = 0.0 + step = 3.0 + while elapsed < limit: + page.wait_for_timeout(int(step * 1000)) + elapsed += step + if page.query_selector("#searchStr"): + return + raise TimeoutError( + f"{limit}s 内未出现检索框 #searchStr;可增大 EPUB_WAF_MAX_WAIT_SEC 或设置 PLAYWRIGHT_HEADED=1" + ) + + +def _safe_page_content(page: Page, *, max_attempts: int = 10) -> str: + last_err: Exception | None = None + for i in range(max_attempts): + try: + return page.content() + except Error as e: + msg = str(e).lower() + last_err = e + if "navigating" not in msg and "changing" not in msg: + raise + try: + page.wait_for_load_state("load", timeout=20_000) + except Exception: + pass + page.wait_for_timeout(400 + 200 * i) + if last_err: + raise last_err + raise RuntimeError("_safe_page_content: 未返回内容") + + +def _wait_result_page_ready(page: Page) -> None: + """等结果页 title 与 #result 列表/零结果 DOM 就绪(不等完整 load)。""" + page.wait_for_function( + _RESULT_PAGE_READY_JS, + arg={"result": EPUB_TITLE_RESULT, "noHit": EPUB_TITLE_NO_HIT}, + timeout=120_000, + ) + + +def submit_index_search(page: Page, keyword: str) -> None: + page.fill("#searchStr", keyword) + with page.expect_navigation(timeout=120_000, wait_until="commit"): + form = page.query_selector("#indexForm") + if form: + form.evaluate("el => el.submit()") + else: + page.evaluate( + """() => { + const f = document.getElementById('indexForm'); + if (f) f.submit(); + }""" + ) + _wait_result_page_ready(page) + + +def fetch_epub_result_html( + keyword: str, + *, + playwright_factory: Callable[[], Playwright] | None = None, +) -> str: + """ + 只拉取检索结果页 HTML,不在此函数内做正文解析。 + 解析请使用 ``cnipa_epub_parse.parse_search_result_html(html)``。 + """ + pw_gen = playwright_factory or sync_playwright + with pw_gen() as p: + browser = _launch_browser(p) + context = _new_context(browser) + try: + page = context.new_page() + wait_for_epub_home_ready(page) + submit_index_search(page, keyword) + return _safe_page_content(page) + finally: + context.close() + browser.close() + + +def search_epub_keyword( + keyword: str, + *, + playwright_factory: Callable[[], Playwright] | None = None, +) -> tuple[str, list[EpubSearchHit]]: + html = fetch_epub_result_html(keyword, playwright_factory=playwright_factory) + return html, parse_search_result_html(html) + + +def search_epub_keyword_with_page( + page: Page, + keyword: str, +) -> tuple[str, list[EpubSearchHit]]: + wait_for_epub_home_ready(page) + submit_index_search(page, keyword) + html = _safe_page_content(page) + return html, parse_search_result_html(html) + + +def _launch_browser(p: Playwright) -> Browser: + return p.chromium.launch( + headless=not _headed(), + args=[ + "--disable-blink-features=AutomationControlled", + "--no-sandbox", + ], + ) + + +def _new_context(browser: Browser) -> BrowserContext: + return browser.new_context( + user_agent=DEFAULT_USER_AGENT, + locale="zh-CN", + viewport={"width": 1280, "height": 900}, + ) + + +def _dump_home_debug() -> None: + """调试:仅拉取首页并保存 WAF 通过后 HTML。""" + out = Path(__file__).resolve().parent / "_last_home.html" + with sync_playwright() as p: + browser = _launch_browser(p) + context = _new_context(browser) + page = context.new_page() + try: + wait_for_epub_home_ready(page) + out.write_text(page.content(), encoding="utf-8") + print("已保存:", out) + finally: + context.close() + browser.close() + + +if __name__ == "__main__": + _ensure_utf8_stdio() + argv = [a for a in sys.argv[1:] if a.strip()] + if argv and argv[0] in ("--dump-home", "-d"): + _dump_home_debug() + sys.exit(0) + kw = (argv[0] if argv else "批处理").strip() + try: + out_html, hits = search_epub_keyword(kw) + except Exception as e: + print("CNIPA_EPUB_ERROR:", e, file=sys.stderr) + sys.exit(1) + out_path = Path( + os.environ.get("EPUB_RESULT_HTML", "").strip() or default_result_html_path() + ) + out_path = out_path.expanduser().resolve() + out_path.write_text(out_html, encoding="utf-8") + print( + "结果页长度", + len(out_html), + "解析条目数", + len(hits), + file=sys.stderr, + flush=True, + ) + print("结果页 HTML 已保存:", out_path, file=sys.stderr, flush=True) + print( + "EPUB_HITS_JSON:", + json.dumps(hits_to_jsonable(hits), ensure_ascii=False), + flush=True, + ) diff --git a/.agents/skills/patent-disclosure-skill/tools/cnipa_epub_parse.py b/.agents/skills/patent-disclosure-skill/tools/cnipa_epub_parse.py new file mode 100644 index 0000000..44b9ceb --- /dev/null +++ b/.agents/skills/patent-disclosure-skill/tools/cnipa_epub_parse.py @@ -0,0 +1,260 @@ +# -*- coding: utf-8 -*- +""" +解析 http://epub.cnipa.gov.cn/ 检索结果页 HTML,提取公布公告列表中的标题、公开号、详情链接、摘要(若有)。 + +与 `cnipa_epub_crawler.py` / **`cnipa_epub_search.py`**(一步检索+解析)配合:爬虫落盘 HTML 后可用本模块单独再解析;也可被其它脚本 import。 +""" +from __future__ import annotations + +import json +import re +import sys +from dataclasses import asdict, dataclass +from pathlib import Path + +EPUB_BASE = "http://epub.cnipa.gov.cn/" + + +@dataclass +class EpubSearchHit: + """单条检索命中(字段随页面结构尽力解析,可能为空)。""" + + raw_html: str + title: str | None = None + pub_number: str | None = None + link: str | None = None + abstract: str | None = None + + +def _html_fragment_to_plain(html_snippet: str) -> str: + """从一小段 HTML 抽取可读纯文本(用于摘要等)。""" + t = re.sub(r"<script[^>]*>.*?</script>", "", html_snippet, flags=re.I | re.DOTALL) + t = re.sub(r"<style[^>]*>.*?</style>", "", t, flags=re.I | re.DOTALL) + t = re.sub(r"<[^>]+>", " ", t) + t = re.sub(r"\s+", " ", t).strip() + t = re.sub(r"\s*全部\s*$", "", t).strip() + return t + + +def _extract_abstract_from_item_html(item_html: str) -> str | None: + """从单条 ``div.item`` 内 ``dt`` 摘要对应的 ``dd`` 中抽取全文(含折叠 span)。""" + m = re.search( + r'<dt[^>]*>\s*摘要\s*[::]\s*</dt>\s*<dd[^>]*>(.*?)</dd>', + item_html, + flags=re.IGNORECASE | re.DOTALL, + ) + if not m: + return None + plain = _html_fragment_to_plain(m.group(1)) + return plain if len(plain) >= 4 else None + + +def _abs_url(href: str) -> str: + if href.startswith("http://") or href.startswith("https://"): + return href + if href.startswith("/"): + return EPUB_BASE.rstrip("/") + href + return EPUB_BASE.rstrip("/") + "/" + href.lstrip("/") + + +def parse_search_result_html(html: str, base_url: str = EPUB_BASE) -> list[EpubSearchHit]: + """ + 解析「公布公告」检索结果列表页 HTML。 + 兼容常见表格行 / 带链接的条目(站点改版时需调整正则或选择器)。 + """ + _ = base_url # 预留与绝对链接拼接策略扩展 + hits: list[EpubSearchHit] = [] + for m in re.finditer( + r"<tr[^>]*>(.*?)</tr>", + html, + flags=re.IGNORECASE | re.DOTALL, + ): + row = m.group(1) + low = row.lower() + if "indexquery" in low or "searchstr" in low: + continue + title_m = re.search( + r'title="([^"]+)"', + row, + re.IGNORECASE, + ) or re.search(r">([^<]{6,200})<", row) + title = title_m.group(1).strip() if title_m else None + link_m = re.search(r'href="([^"]+)"', row) + href = link_m.group(1).strip() if link_m else None + link = _abs_url(href) if href else None + pub_m = re.search( + r"(CN\s*\d{9,}[A-Z]\s*|ZL\s*\d{9,}\.\d+)", + row, + re.IGNORECASE, + ) + pub_number = pub_m.group(1).replace(" ", "") if pub_m else None + text = re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", row)).strip() + if len(text) < 8 and not pub_number: + continue + hits.append( + EpubSearchHit( + raw_html=row[:2000], + title=title or (text[:200] if text else None), + pub_number=pub_number, + link=link, + ) + ) + seen: set[str] = set() + out: list[EpubSearchHit] = [] + for h in hits: + key = h.pub_number or h.title or h.raw_html[:80] + if key in seen: + continue + seen.add(key) + out.append(h) + if out: + return out + overview = _parse_overview_card_layout(html) + if overview: + return overview + return _parse_search_result_fallback_links(html) + + +def _parse_overview_card_layout(html: str) -> list[EpubSearchHit]: + """ + 新版「公布模式」结果页:无表格行,每条为 ``div.item``,题名在 ``h1.title``, + 详情 URL 多在二维码 ``div.qrcode`` 的 ``title="http://epub.../patent/CN…"`` 上; + 摘要位于 ``dt`` 为「摘要」的 ``dd`` 内(含 ``span.alltxt`` 折叠段)。 + """ + low = html.lower() + if "overview-default" not in low and 'class="item"' not in low: + return [] + parts = re.split(r'(<div\s+class="item"\s*>)', html, flags=re.IGNORECASE) + blocks: list[str] = [] + for j in range(1, len(parts) - 1, 2): + blocks.append(parts[j] + parts[j + 1]) + if not blocks: + return [] + + base = EPUB_BASE.rstrip("/") + hits: list[EpubSearchHit] = [] + for item_html in blocks: + tm = re.search( + r'<h1\s+class="title">\s*([^<]+?)\s*</h1>', + item_html, + flags=re.IGNORECASE | re.DOTALL, + ) + title = re.sub(r"\s+", " ", tm.group(1)).strip() if tm else None + lm = re.search( + r'title="(https?://epub\.cnipa\.gov\.cn/patent/[^"]+)"', + item_html, + flags=re.IGNORECASE, + ) + link = lm.group(1).strip() if lm else None + pm = re.search( + r"(?:申请公布号|授权公告号)[::]\s*</dt>\s*<dd>([^<]+?)</dd>", + item_html, + flags=re.IGNORECASE, + ) + pub_number = None + if pm: + pub_number = pm.group(1).strip().replace(" ", "") + if not re.match(r"^(?:CN|ZL)", pub_number, re.IGNORECASE): + pub_number = None + if not link and pub_number: + link = f"{base}/patent/{pub_number}" + if link: + m_pub = re.search( + r"/patent/((?:CN|ZL)[^/?#]+)", + link, + flags=re.IGNORECASE, + ) + if m_pub and not pub_number: + pub_number = m_pub.group(1).strip() + abstract = _extract_abstract_from_item_html(item_html) + if not title and not pub_number and not link: + continue + raw = "|".join( + x for x in (title, pub_number, link, (abstract or "")[:400]) if x + )[:2000] + hits.append( + EpubSearchHit( + raw_html=raw, + title=title, + pub_number=pub_number, + link=link, + abstract=abstract, + ) + ) + seen: set[str] = set() + out: list[EpubSearchHit] = [] + for h in hits: + key = h.pub_number or h.link or (h.title or "")[:120] + if key in seen: + continue + seen.add(key) + out.append(h) + return out + + +def _parse_search_result_fallback_links(html: str) -> list[EpubSearchHit]: + """从结果页中抽取指向公布详情的 <a href>。""" + hits: list[EpubSearchHit] = [] + for m in re.finditer( + r'<a\s+[^>]*href="([^"]+)"[^>]*>([^<]*)</a>', + html, + flags=re.IGNORECASE | re.DOTALL, + ): + href = (m.group(1) or "").strip() + title = (m.group(2) or "").strip() + if not href.startswith("/") and "epub.cnipa.gov.cn" not in href: + continue + hlow = href.lower() + if not any( + x in hlow + for x in ("/dxb/", "/sw/", "/patent/", "detail", "show") + ): + continue + if "indexForm" in href or "javascript:" in href.lower(): + continue + low = href.lower() + if "article" in low and "indexquery" in low: + continue + link = _abs_url(href) + pub_m = re.search(r"(CN\s*\d{9,}[A-Z]?|ZL\s*\d{9,}\.\d+)", href + title, re.I) + pub_number = pub_m.group(1).replace(" ", "") if pub_m else None + raw = m.group(0)[:2000] + if len(title) < 2 and not pub_number: + continue + hits.append( + EpubSearchHit( + raw_html=raw, + title=title or None, + pub_number=pub_number, + link=link, + ) + ) + seen: set[str] = set() + out: list[EpubSearchHit] = [] + for h in hits: + key = h.link or h.title or "" + if key in seen: + continue + seen.add(key) + out.append(h) + return out + + +def hits_to_jsonable(hits: list[EpubSearchHit]) -> list[dict]: + """供 JSON 序列化(不含 raw_html 过大字段时可裁剪)。""" + rows = [] + for h in hits: + d = asdict(h) + d.pop("raw_html", None) + rows.append(d) + return rows + + +if __name__ == "__main__": + if len(sys.argv) < 2: + print("用法: python cnipa_epub_parse.py <结果页.html>", file=sys.stderr) + sys.exit(2) + p = Path(sys.argv[1]).expanduser().resolve() + html = p.read_text(encoding="utf-8") + hits = parse_search_result_html(html) + print(json.dumps(hits_to_jsonable(hits), ensure_ascii=False, indent=2)) diff --git a/.agents/skills/patent-disclosure-skill/tools/cnipa_epub_search.py b/.agents/skills/patent-disclosure-skill/tools/cnipa_epub_search.py new file mode 100644 index 0000000..37924d7 --- /dev/null +++ b/.agents/skills/patent-disclosure-skill/tools/cnipa_epub_search.py @@ -0,0 +1,170 @@ +# -*- coding: utf-8 -*- +""" +国知局公布站「检索 + 解析」一步完成:内存中持有结果页 HTML,**默认不落盘**。 + +内部调用 ``cnipa_epub_crawler.search_epub_keyword``(等同先 ``fetch_epub_result_html`` 再 +``parse_search_result_html``)。 + +**输出约定**(便于 Agent 抓取且不触发误判降级): + +- **stdout**:**仅一行** ``EPUB_HITS_JSON:`` + JSON 数组(UTF-8,含中文标题与 ``abstract``)。 +- **stderr**:``EPUB_MERGE:`` / ``EPUB_NOTE:`` / ``EPUB_HINT:`` 等为 **ASCII**,减轻 PowerShell 把 + 含中文的 stderr 当成 ``NativeCommandError``,以及 ``2>&1`` 合并流时的乱码。stdout 上 JSON 仍为 UTF-8 + 中文。启动时 ``reconfigure`` UTF-8。 + +**检索词拆分(仅按空白)**:命令行中所有参数会按 **Python 空白规则**(`str.split()`)拆成多段; +**一段一查**,结果按公开号去重合并。**不在本脚本内**对长中文做自动分词或拆字——**相关度高的语义化 +检索单位须在 Agent 生成 Bash 前完成**(见 ``prompts/prior_art_search.md``「国知局检索词(生成阶段必做)」)。 +若需**整句一次**向公布站提交(站内 AND),请改用 ``cnipa_epub_crawler.py`` 单传一句。 + +需已安装:pip install -r tools/requirements-cnipa.txt && python -m playwright install chromium + +用法: + + python tools/cnipa_epub_search.py 词1 + python tools/cnipa_epub_search.py "短语 含 空格" + python tools/cnipa_epub_search.py 词甲 词乙 词丙 + +**必须**至少有一个非空检索词;**不设默认**。 + +若需将结果页 HTML 保存到磁盘,请改用 ``cnipa_epub_crawler.py``;若只对已有 HTML 文件做解析, +请用 ``cnipa_epub_parse.py``。 + +环境变量:与 ``cnipa_epub_crawler.py`` 相同(如 ``EPUB_WAF_MAX_WAIT_SEC``、``PLAYWRIGHT_HEADED``)。 +""" +from __future__ import annotations + +import json +import os +import sys + +_MAX_TERMS = 8 + + +def _ensure_utf8_stdio() -> None: + """在 Windows 等环境下将 stdout/stderr 设为 UTF-8,避免中文 JSON 在终端乱码导致误判检索失败。""" + for stream in (sys.stdout, sys.stderr): + try: + if hasattr(stream, "reconfigure"): + stream.reconfigure(encoding="utf-8", errors="replace") + except (OSError, ValueError, TypeError): + pass + + +def _terms_from_argv(argv: list[str]) -> list[str]: + """从所有 argv 片段中按空白拆分(等价 str.split,连续空格视为一次分隔)。""" + terms: list[str] = [] + for a in argv: + for part in (a or "").split(): + p = part.strip() + if p: + terms.append(p) + return terms + + +def _dedupe_hits(hits_lists: list) -> list: + from cnipa_epub_parse import EpubSearchHit + + seen: set[str] = set() + out: list[EpubSearchHit] = [] + for hits in hits_lists: + for h in hits: + key = h.pub_number or h.link or (h.title or "")[:120] + if key in seen: + continue + seen.add(key) + out.append(h) + return out + + +def _usage() -> None: + print("usage: python tools/cnipa_epub_search.py <term> [more terms...]", file=sys.stderr) + print( + "whitespace splits to multiple terms; one Playwright run per term; merge by pub_number.", + file=sys.stderr, + ) + print('example: python tools/cnipa_epub_search.py "batch 调度 异构"', file=sys.stderr) + + +def main(argv: list[str] | None = None) -> int: + _ensure_utf8_stdio() + argv = argv if argv is not None else sys.argv[1:] + terms = _terms_from_argv(argv) + if not terms: + _usage() + return 2 + if len(terms) > _MAX_TERMS: + print( + "ERROR: too many terms after split (%d > %d); shorten or run in batches." + % (len(terms), _MAX_TERMS), + file=sys.stderr, + ) + return 2 + + os.environ.setdefault("EPUB_WAF_MAX_WAIT_SEC", "180") + + try: + import playwright # noqa: F401 + except ImportError: + print( + "ERROR: pip install -r tools/requirements-cnipa.txt && python -m playwright install chromium", + file=sys.stderr, + ) + return 1 + + from cnipa_epub_crawler import search_epub_keyword + from cnipa_epub_parse import hits_to_jsonable + + multi = len(terms) > 1 + last_html = "" + all_batches: list = [] + + try: + for kw in terms: + html, hits = search_epub_keyword(kw) + last_html = html + all_batches.append(hits) + except Exception as e: + print("CNIPA_EPUB_ERROR:", e, file=sys.stderr) + return 1 + + if multi: + hits = _dedupe_hits(all_batches) + print( + "EPUB_MERGE: terms=%d merged_hits=%d" % (len(terms), len(hits)), + file=sys.stderr, + flush=True, + ) + else: + hits = all_batches[0] + + if not hits and last_html and len(last_html) < 20_000: + if multi: + print( + "EPUB_HINT: 0 hits after multi-term run; try broader terms or WebSearch (prior_art_search.md)", + file=sys.stderr, + flush=True, + ) + else: + print( + "EPUB_HINT: 0 hits; try more terms (space-separated) or WebSearch", + file=sys.stderr, + flush=True, + ) + + print( + "EPUB_NOTE: html_bytes=%d disk=0" % len(last_html), + file=sys.stderr, + flush=True, + ) + # 仅此一行写入 stdout,供管道/Agent 稳定解析(勿混入多行文本,避免误判未命中) + print( + "EPUB_HITS_JSON:", + json.dumps(hits_to_jsonable(hits), ensure_ascii=False), + flush=True, + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.agents/skills/patent-disclosure-skill/tools/docx_to_md.py b/.agents/skills/patent-disclosure-skill/tools/docx_to_md.py new file mode 100644 index 0000000..ab49e8b --- /dev/null +++ b/.agents/skills/patent-disclosure-skill/tools/docx_to_md.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +""" +将 Word(.docx)转为 Markdown,并把内嵌图片抽取到磁盘,便于 Step 2 扫描与 Agent Read。 + +依赖 mammoth(见仓库根目录 requirements.txt)。 + +用法: + python docx_to_md.py --input design.docx --output outputs/case/design.md + python docx_to_md.py -i a.docx -o b/out.md --media-dir b/my_images + +默认图片目录:与输出 .md 同级的「{md 文件名}_media/」,Markdown 中为相对路径引用。 +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + + +def _require_mammoth(): + try: + import mammoth + except ImportError: + print( + "缺少依赖 mammoth。请在技能根目录执行: pip install -r requirements.txt", + file=sys.stderr, + ) + sys.exit(1) + return mammoth + + +def _extension_for_content_type(content_type: str) -> str: + subtype = (content_type or "").split("/")[-1].lower().strip() + if not subtype or subtype == "octet-stream": + return "bin" + if subtype == "jpeg": + return "jpg" + return subtype[:12] + + +def _run( + input_docx: Path, + output_md: Path, + media_dir: Path | None, +) -> int: + mammoth = _require_mammoth() + + if not input_docx.is_file(): + print(f"输入文件不存在: {input_docx}", file=sys.stderr) + return 2 + if input_docx.suffix.lower() != ".docx": + print("警告: 期望 .docx(Office Open XML);旧版 .doc 不支持。", file=sys.stderr) + + output_md = output_md.resolve() + output_md.parent.mkdir(parents=True, exist_ok=True) + + if media_dir is None: + media_dir = output_md.parent / f"{output_md.stem}_media" + else: + media_dir = media_dir.resolve() + media_dir.mkdir(parents=True, exist_ok=True) + + counter = [0] + + def save_image(image): + counter[0] += 1 + ext = _extension_for_content_type(getattr(image, "content_type", "") or "") + filename = f"img_{counter[0]:04d}.{ext}" + out_path = media_dir / filename + try: + with image.open() as f: + out_path.write_bytes(f.read()) + except Exception as e: + print(f"警告: 抽取图片失败 ({filename}): {e}", file=sys.stderr) + return {"src": "", "alt": ""} + + try: + rel = out_path.relative_to(output_md.parent).as_posix() + except ValueError: + rel = out_path.as_posix() + alt = getattr(image, "alt_text", None) or "" + return {"src": rel, "alt": alt} + + image_converter = mammoth.images.img_element(save_image) + + with input_docx.open("rb") as docx_file: + result = mammoth.convert_to_markdown(docx_file, convert_image=image_converter) + + for msg in result.messages: + text = getattr(msg, "message", str(msg)) + typ = getattr(msg, "type", "message") + print(f"mammoth [{typ}]: {text}", file=sys.stderr) + + text = (result.value or "").strip() + header = ( + f"<!-- 由 docx_to_md.py 自 {input_docx.name} 转换,勿手改本行元信息 -->\n\n" + ) + output_md.write_text(header + text + ("\n" if text else ""), encoding="utf-8") + + print(f"已写入: {output_md}") + print(f"图片目录: {media_dir}") + return 0 + + +def main() -> int: + p = argparse.ArgumentParser(description="Word (.docx) → Markdown + 抽取图片") + p.add_argument("-i", "--input", required=True, type=Path, help="输入 .docx 路径") + p.add_argument("-o", "--output", required=True, type=Path, help="输出 .md 路径") + p.add_argument( + "--media-dir", + type=Path, + default=None, + help="图片输出目录(默认:与 .md 同级的 {md 主名}_media)", + ) + args = p.parse_args() + return _run(args.input, args.output, args.media_dir) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.agents/skills/patent-disclosure-skill/tools/iteration_dialog_log.py b/.agents/skills/patent-disclosure-skill/tools/iteration_dialog_log.py new file mode 100644 index 0000000..480767f --- /dev/null +++ b/.agents/skills/patent-disclosure-skill/tools/iteration_dialog_log.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +""" +在案件目录追加「交底书修订对话记录.md」一条:含记录时间(本地 + UTC)、用户说明摘要、交付文件名、合并/纠正摘要摘录。 +""" +from __future__ import annotations + +import argparse +import sys +from datetime import datetime, timezone +from pathlib import Path + +DEFAULT_LOG = "交底书修订对话记录.md" + +FILE_HEADER = """# 交底书修订对话记录 + +> 由 `iteration_dialog_log.py` 或 Agent 按 `prompts/iteration_context.md` 追加;每条含**记录时间**与本轮说明。请勿删除既有条目。 + +""" + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Append one revision dialog entry to case-dir log markdown" + ) + parser.add_argument( + "--case-dir", + type=Path, + required=True, + help="案件产出目录(与交底书 .md 同级或为其父目录,须已存在)", + ) + parser.add_argument( + "--kind", + choices=("merge", "correct"), + required=True, + help="merge=合并迭代;correct=纠正迭代", + ) + parser.add_argument( + "--user", + default="", + help="用户本轮说明摘要(建议 1–8 句)", + ) + parser.add_argument( + "--summary", + default="", + help="合并摘要 / 纠正摘要的简短摘录(可与对话中留档段落一致)", + ) + parser.add_argument( + "--artifacts", + default="", + help="本轮交付文件名,多个用英文逗号分隔,如:一种XX_20260408143025.md,一种XX_20260408143025.docx", + ) + parser.add_argument( + "--log-name", + default=DEFAULT_LOG, + help=f"日志文件名(默认:{DEFAULT_LOG})", + ) + args = parser.parse_args() + + case_dir = args.case_dir.expanduser().resolve() + if not case_dir.is_dir(): + print(f"ERROR: 目录不存在或不是目录: {case_dir}", file=sys.stderr) + return 2 + + log_path = case_dir / args.log_name + now_local = datetime.now().astimezone() + now_utc = datetime.now(timezone.utc) + kind_zh = "合并迭代" if args.kind == "merge" else "纠正迭代" + + user_block = (args.user or "").strip() or "(未传入 --user,请 Agent 用编辑工具在本条内补写用户说明摘要。)" + summary_block = (args.summary or "").strip() or "—" + art = (args.artifacts or "").strip() + if art: + art_lines = "\n".join(f"- `{x.strip()}`" for x in art.split(",") if x.strip()) + else: + art_lines = "—" + + entry = f"""## {now_local.strftime("%Y-%m-%d %H:%M:%S")}(本地) · {now_utc.strftime("%Y-%m-%dT%H:%M:%SZ")}(UTC) + +**类型**:{kind_zh} + +**用户说明摘要**: + +{user_block} + +**本轮交付文件**: + +{art_lines} + +**合并/纠正摘要摘录**: + +{summary_block} + +--- + +""" + + if log_path.exists(): + prev = log_path.read_text(encoding="utf-8") + if prev and not prev.endswith("\n"): + prev += "\n" + log_path.write_text(prev + "\n" + entry, encoding="utf-8") + else: + log_path.write_text(FILE_HEADER + "\n" + entry, encoding="utf-8") + + print(f"LOG_FILE={log_path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.agents/skills/patent-disclosure-skill/tools/math_render.py b/.agents/skills/patent-disclosure-skill/tools/math_render.py new file mode 100644 index 0000000..9808b20 --- /dev/null +++ b/.agents/skills/patent-disclosure-skill/tools/math_render.py @@ -0,0 +1,388 @@ +#!/usr/bin/env python3 +r""" +将 Markdown 中的 LaTeX 公式渲染为 PNG(matplotlib mathtext),**保留 `$...$` / `\(...\)` / `$$...$$` / `\[...\]` 原文**, +图片引用写入 HTML 注释 ``<!-- ![...](path) -->``(预览不显示图,Word 仍嵌入)。 + +支持(失败时**保留原文**,不中断): + +- **块级**:``$$ ... $$``(可跨行)、单行 ``$$...$$``、``\\[ ... \\]`` +- **行内**:``$...$``、``\(...\)``(渲染失败则保留原文) + +用法: + + python tools/math_render.py -i draft.md -o draft_with_math.md + python tools/math_render.py -i draft.md -o out.md --assets-dir math_figures + +依赖:``pip install matplotlib``(见仓库根 ``requirements.txt``)。 +""" +from __future__ import annotations + +import argparse +import re +import sys +from pathlib import Path + +_DEFAULT_ASSETS = "math_figures" +_INLINE_RE = re.compile( + r"(?<!\$)\$(?!\$)((?:\\.|[^$\n])+?)\$(?!\$)(?!\s*<!--)" +) +_INLINE_PAREN_RE = re.compile(r"\\\(((?:\\.|[^)])+?)\\\)(?!\s*<!--)") +_HIDDEN_IMG_COMMENT_RE = re.compile( + r"<!--\s*!\[[^\]]*\]\([^)]+\)\s*-->" +) + +# matplotlib mathtext 不识别部分 LaTeX 简写;按「长命令优先」映射为 mathtext 符号 +_LATEX_CMD_ALIASES: tuple[tuple[str, str], ...] = ( + ("geqslant", "geq"), + ("leqslant", "leq"), + ("geqq", "geq"), + ("leqq", "leq"), + ("ge", "geq"), + ("le", "leq"), + ("ne", "neq"), + ("land", "wedge"), + ("lor", "vee"), + ("gets", "leftarrow"), + ("to", "rightarrow"), + ("iff", "Longleftrightarrow"), + ("implies", "Rightarrow"), +) + + +def normalize_latex_for_mathtext(body: str) -> str: + """将常见 LaTeX 命令转为 matplotlib mathtext 可解析形式。""" + out = body + for short, repl in _LATEX_CMD_ALIASES: + if short == repl: + continue + out = re.sub(rf"\\{short}(?![A-Za-z])", rf"\\{repl}", out) + # 交底书 Word 正文为常规宋体,公式图不做数学粗体 + for cmd in ("mathbf", "bm", "boldsymbol", "textbf"): + prev = None + while prev != out: + prev = out + out = re.sub(rf"\\{cmd}\{{([^{{}}]+)\}}", r"\1", out) + # mathtext 不支持 amsmath 编号/标签;块级公式内换行也会解析失败 + out = re.sub(r"\\label\s*\{[^{}]*\}", "", out) + out = re.sub(r"\\tag\s*\{([^{}]*)\}", r"\\quad (\1)", out) + out = re.sub(r"\\notag\b", "", out) + out = re.sub(r"\s+", " ", out).strip() + return out + + +def render_latex_to_png( + latex: str, + png_path: Path, + *, + dpi: int = 200, + fontsize: float = 14.0, +) -> None: + """用 matplotlib mathtext 将 LaTeX 片段写入 PNG(无坐标轴/网格)。""" + import matplotlib + + matplotlib.use("Agg") + from matplotlib import mathtext + from matplotlib.font_manager import FontProperties + + body = latex.strip() + if body.startswith("$") and body.endswith("$") and not body.startswith("$$"): + body = body[1:-1].strip() + if body.startswith("\\(") and body.endswith("\\)"): + body = body[2:-2].strip() + + body = normalize_latex_for_mathtext(body) + + png_path.parent.mkdir(parents=True, exist_ok=True) + mathtext.math_to_image( + f"${body}$", + str(png_path), + prop=FontProperties(size=fontsize, weight="normal"), + dpi=dpi, + format="png", + ) + + +def _next_eq_name(counter: dict[str, int], kind: str) -> str: + counter[kind] = counter.get(kind, 0) + 1 + n = counter[kind] + if kind == "inline": + return f"inline_{n:03d}.png" + return f"eq_{n:03d}.png" + + +def _try_render( + latex: str, + png_path: Path, + *, + dpi: int, + fontsize: float, +) -> bool: + try: + render_latex_to_png(latex, png_path, dpi=dpi, fontsize=fontsize) + return png_path.is_file() and png_path.stat().st_size > 0 + except Exception as e: + snippet = latex.strip().replace("\n", " ")[:120] + print(f"[math_render] 渲染失败(将保留原文):{snippet}", file=sys.stderr) + print(f" {e}", file=sys.stderr) + return False + + +def _replace_inline_math( + text: str, + assets_dir: Path, + assets_rel: str, + counter: dict[str, int], + *, + dpi: int, + fontsize: float, +) -> tuple[str, int, int]: + ok = 0 + failed = 0 + + def render_one(inner: str, wrapper: str) -> str: + nonlocal ok, failed + fname = _next_eq_name(counter, "inline") + png_path = assets_dir / fname + if _try_render(inner, png_path, dpi=dpi, fontsize=fontsize): + ok += 1 + rel = f"{assets_rel.strip('/')}/{fname}".replace("\\", "/") + return f"{wrapper}<!-- ![公式·行内]({rel}) -->" + failed += 1 + return wrapper + + def repl_dollar(m: re.Match[str]) -> str: + inner = m.group(1) + return render_one(inner, f"${inner}$") + + def repl_paren(m: re.Match[str]) -> str: + inner = m.group(1) + return render_one(inner, f"\\({inner}\\)") + + text = _INLINE_RE.sub(repl_dollar, text) + text = _INLINE_PAREN_RE.sub(repl_paren, text) + return text, ok, failed + + +def render_markdown_math( + md_text: str, + *, + out_md_path: Path, + assets_rel: str = _DEFAULT_ASSETS, + dpi: int = 200, + block_fontsize: float = 10.5, + inline_fontsize: float = 10.5, +) -> tuple[str, int, int]: + """ + 返回 (新 markdown, 成功渲染数, 失败保留原文数)。 + PNG 目录:``out_md_path.parent / assets_rel``。 + """ + assets_dir = out_md_path.parent / assets_rel.strip("/\\") + assets_dir.mkdir(parents=True, exist_ok=True) + counter: dict[str, int] = {} + ok = 0 + failed = 0 + + lines = md_text.splitlines(keepends=True) + out: list[str] = [] + i = 0 + + while i < len(lines): + stripped = lines[i].strip() + + # 块级 $$ ... $$ + if stripped == "$$": + i += 1 + body_lines: list[str] = [] + while i < len(lines) and lines[i].strip() != "$$": + body_lines.append(lines[i]) + i += 1 + closing = i < len(lines) + if closing: + i += 1 + if i < len(lines) and _HIDDEN_IMG_COMMENT_RE.match(lines[i].strip()): + out.append("$$\n") + out.extend(body_lines) + out.append("$$\n") + out.append(lines[i]) + i += 1 + continue + latex = "".join(body_lines).strip() + if not latex: + out.append("$$\n") + if body_lines: + out.extend(body_lines) + if closing: + out.append("$$\n") + continue + fname = _next_eq_name(counter, "block") + png_path = assets_dir / fname + if _try_render(latex, png_path, dpi=dpi, fontsize=block_fontsize): + ok += 1 + rel = f"{assets_rel.strip('/')}/{fname}".replace("\\", "/") + out.append("$$\n") + out.extend(body_lines) + out.append("$$\n") + out.append(f"<!-- ![公式]({rel}) -->\n") + else: + failed += 1 + out.append("$$\n") + out.extend(body_lines) + if not body_lines or not body_lines[-1].endswith("\n"): + pass + out.append("$$\n") + continue + + # 单行 $$...$$ + if ( + stripped.startswith("$$") + and stripped.endswith("$$") + and len(stripped) > 4 + ): + latex = stripped[2:-2].strip() + fname = _next_eq_name(counter, "block") + png_path = assets_dir / fname + if _try_render(latex, png_path, dpi=dpi, fontsize=block_fontsize): + ok += 1 + rel = f"{assets_rel.strip('/')}/{fname}".replace("\\", "/") + out.append(f"$${latex}$$\n") + out.append(f"<!-- ![公式]({rel}) -->\n") + else: + failed += 1 + out.append(lines[i]) + i += 1 + continue + + # 块级 \[ ... \] + if stripped.startswith("\\["): + if stripped.endswith("\\]") and len(stripped) > 4: + latex = stripped[2:-2].strip() + fname = _next_eq_name(counter, "block") + png_path = assets_dir / fname + if _try_render(latex, png_path, dpi=dpi, fontsize=block_fontsize): + ok += 1 + rel = f"{assets_rel.strip('/')}/{fname}".replace("\\", "/") + out.append(f"\\[{latex}\\]\n") + out.append(f"<!-- ![公式]({rel}) -->\n") + else: + failed += 1 + out.append(lines[i]) + i += 1 + continue + i += 1 + body_lines = [] + while i < len(lines) and "\\]" not in lines[i]: + body_lines.append(lines[i]) + i += 1 + tail = lines[i] if i < len(lines) else "" + if i < len(lines): + i += 1 + if i < len(lines) and _HIDDEN_IMG_COMMENT_RE.match(lines[i].strip()): + out.append("\\[\n") + out.extend(body_lines) + out.append(tail if tail.endswith("\n") else tail + "\n") + out.append(lines[i]) + i += 1 + continue + latex = "".join(body_lines) + tail + latex = latex.replace("\\[", "", 1).replace("\\]", "").strip() + fname = _next_eq_name(counter, "block") + png_path = assets_dir / fname + if latex and _try_render(latex, png_path, dpi=dpi, fontsize=block_fontsize): + ok += 1 + rel = f"{assets_rel.strip('/')}/{fname}".replace("\\", "/") + out.append("\\[\n") + out.extend(body_lines) + out.append(tail if tail.endswith("\n") else tail + "\n") + out.append(f"<!-- ![公式]({rel}) -->\n") + else: + failed += 1 + out.append("\\[\n") + out.extend(body_lines) + out.append(tail if tail.endswith("\n") else tail + "\n") + continue + + # 围栏代码 / mermaid:不处理行内 $ + if stripped.startswith("```"): + out.append(lines[i]) + i += 1 + while i < len(lines) and not lines[i].strip().startswith("```"): + out.append(lines[i]) + i += 1 + if i < len(lines): + out.append(lines[i]) + i += 1 + continue + + # 标题、图片行、空行:原样(图片行不跑行内替换) + if ( + not stripped + or stripped.startswith("#") + or (stripped.startswith("![") and "](" in stripped) + ): + out.append(lines[i]) + i += 1 + continue + + new_line, i_ok, i_fail = _replace_inline_math( + lines[i], + assets_dir, + assets_rel, + counter, + dpi=dpi, + fontsize=inline_fontsize, + ) + ok += i_ok + failed += i_fail + out.append(new_line if new_line.endswith("\n") else new_line + "\n") + i += 1 + + return "".join(out), ok, failed + + +def main(argv: list[str] | None = None) -> int: + p = argparse.ArgumentParser(description="Markdown LaTeX 公式 → PNG") + p.add_argument("-i", "--input", required=True, type=Path) + p.add_argument("-o", "--output", required=True, type=Path) + p.add_argument( + "--assets-dir", + default=_DEFAULT_ASSETS, + help=f"PNG 相对输出 .md 的子目录(默认 {_DEFAULT_ASSETS})", + ) + p.add_argument("--dpi", type=int, default=200) + p.add_argument("--block-fontsize", type=float, default=10.5) + p.add_argument("--inline-fontsize", type=float, default=10.5) + args = p.parse_args(argv) + + in_path = args.input.resolve() + if not in_path.is_file(): + print(f"错误:找不到输入 {in_path}", file=sys.stderr) + return 1 + + try: + import matplotlib # noqa: F401 + except ImportError: + print("请先安装: pip install matplotlib", file=sys.stderr) + return 1 + + out_path = args.output.resolve() + out_path.parent.mkdir(parents=True, exist_ok=True) + md = in_path.read_text(encoding="utf-8") + + new_md, ok, failed = render_markdown_math( + md, + out_md_path=out_path, + assets_rel=args.assets_dir.strip("/\\") or _DEFAULT_ASSETS, + dpi=args.dpi, + block_fontsize=args.block_fontsize, + inline_fontsize=args.inline_fontsize, + ) + out_path.write_text(new_md, encoding="utf-8") + msg = f"已写入 {out_path}(公式:{ok} 处已转为 PNG" + if failed: + msg += f",{failed} 处失败已保留原文" + print(msg + ")", file=sys.stderr) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.agents/skills/patent-disclosure-skill/tools/md_to_docx.py b/.agents/skills/patent-disclosure-skill/tools/md_to_docx.py new file mode 100644 index 0000000..aca1200 --- /dev/null +++ b/.agents/skills/patent-disclosure-skill/tools/md_to_docx.py @@ -0,0 +1,1007 @@ +#!/usr/bin/env python3 +""" +将 Markdown 转为 Word(.docx),按标题层级映射为 Word 内置「标题 1–9」样式, +便于交底书交付代理人或所内流程。 + +支持:ATX 标题 (#–######)、段落、**粗体**、行内 `代码`、无序/有序列表、 +围栏代码块、简单 GFM 表格、引用块(>)、水平线(---)、行内图片 ``![](path.png)`` +(在最大宽、最大高约束下**等比缩放**,竖图自动缩小宽度以整图落入版面)。 + +**连续多行正文**(中间无空行、且非列表/标题等)时,**每一行**输出为 Word 中**独立一段**, +以便「(1)…(2)…」等分条换行;若须在同一段内接排,请写**同一行**内或用 Markdown 空行分隔逻辑段。 + +定稿宜先用同目录 **`mermaid_render.py`** 将 **mermaid** 转为 PNG;**LaTeX 公式**(``$...$`` / ``$$...$$``)由 **`math_render.py`**(或 ``md_to_docx`` 自动调用)转为 PNG;失败时保留原文写入 Word。 + +用法: + python md_to_docx.py --input disclosure.md --output disclosure.docx + python md_to_docx.py -i a.md -o b.docx --base-dir . # 解析图片相对路径 + +依赖:python-docx +""" + +from __future__ import annotations + +import argparse +import re +import sys +from pathlib import Path + +from docx import Document +from docx.enum.text import WD_ALIGN_PARAGRAPH +from docx.oxml.ns import qn +from docx.shared import Inches, Pt, RGBColor + +# 插图最大尺寸(英寸):在常见 A4、默认边距下保证整图可见、按比例缩放(不过宽也不过高)。 +_DEFAULT_IMAGE_MAX_W_IN = 5.5 +_DEFAULT_IMAGE_MAX_H_IN = 8.2 +# 公式图在 Word 中统一按固定高度嵌入(英寸),避免块级式随 PNG 像素被放大、行内式过小 +_FORMULA_DISPLAY_MAX_H_IN = 0.17 +# 兼容旧名 +_FORMULA_INLINE_MAX_H_IN = _FORMULA_DISPLAY_MAX_H_IN +_FORMULA_BLOCK_MAX_W_IN = 4.0 # 仅作块级超宽时的宽度上限(通常由固定高度约束) +_FORMULA_BLOCK_MAX_H_IN = _FORMULA_DISPLAY_MAX_H_IN + +_MD_IMAGE_RE = re.compile(r"!\[([^\]]*)\]\(([^)]+)\)") +_HIDDEN_MD_IMAGE_COMMENT_RE = re.compile( + r"<!--\s*!\[([^\]]*)\]\(([^)]+)\)\s*-->" +) +_INLINE_MATH_WITH_HIDDEN_IMG_RE = re.compile( + r"(?<!\$)\$(?!\$)((?:\\.|[^$\n])+?)\$(?!\$)\s*" + r"<!--\s*!\[([^\]]*)\]\(([^)]+)\)\s*-->" +) +_INLINE_MATH_PAREN_WITH_HIDDEN_IMG_RE = re.compile( + r"\\\(((?:\\.|[^)])+?)\\\)\s*" + r"<!--\s*!\[([^\]]*)\]\(([^)]+)\)\s*-->" +) + + +def _parse_hidden_image_comment(line: str) -> tuple[str, str] | None: + m = _HIDDEN_MD_IMAGE_COMMENT_RE.match(line.strip()) + if not m: + return None + return m.group(1), m.group(2).strip() + + +def _try_embed_hidden_comment_line( + doc: Document, + line: str, + base_dir: Path | None, + *, + image_max_w_in: float, + image_max_h_in: float, +) -> bool: + hidden = _parse_hidden_image_comment(line) + if not hidden or not base_dir: + return False + alt, src = hidden + if not _resolve_image_path(src, base_dir): + return False + _embed_from_image_ref( + alt, + src, + base_dir, + doc=doc, + image_max_w_in=image_max_w_in, + image_max_h_in=image_max_h_in, + ) + return True + + +def _image_pixel_size(path: Path) -> tuple[int, int] | None: + """读取常见位图宽高(像素),失败返回 None。不依赖 Pillow。""" + try: + raw = path.read_bytes() + except OSError: + return None + if len(raw) >= 24 and raw.startswith(b"\x89PNG\r\n\x1a\n") and raw[12:16] == b"IHDR": + w = int.from_bytes(raw[16:20], "big") + h = int.from_bytes(raw[20:24], "big") + if w > 0 and h > 0: + return w, h + if len(raw) >= 10 and raw[:3] == b"GIF" and raw[3:6] in (b"87a", b"89a"): + w = int.from_bytes(raw[6:8], "little") + h = int.from_bytes(raw[8:10], "little") + if w > 0 and h > 0: + return w, h + if len(raw) >= 4 and raw.startswith(b"\xff\xd8"): + i = 2 + n = len(raw) + while i < n: + if raw[i] != 0xFF: + i += 1 + continue + i += 1 + while i < n and raw[i] == 0xFF: + i += 1 + if i >= n: + break + marker = raw[i] + i += 1 + if marker in (0xD8, 0xD9): + continue + if marker == 0xDA: + break + if 0xD0 <= marker <= 0xD7: + continue + if i + 2 > n: + break + seg_len = int.from_bytes(raw[i : i + 2], "big") + if seg_len < 2: + break + i += 2 + if marker in (0xC0, 0xC1, 0xC2) and i + 5 <= n: + h = int.from_bytes(raw[i + 1 : i + 3], "big") + w = int.from_bytes(raw[i + 3 : i + 5], "big") + if w > 0 and h > 0: + return w, h + i += seg_len - 2 + return None + + +def _fit_image_display_inches( + px_w: int, + px_h: int, + *, + max_w_in: float, + max_h_in: float, +) -> tuple[Inches, Inches]: + """在不超过 max_w / max_h 的前提下等比缩放,使整图落入版面。""" + if px_w <= 0 or px_h <= 0: + return Inches(max_w_in), Inches(max_h_in * 0.5) + aw = max_w_in + ah = aw * px_h / px_w + if ah > max_h_in: + ah = max_h_in + aw = ah * px_w / px_h + return Inches(aw), Inches(ah) + + +def _formula_image_kind(alt: str, src: str) -> str | None: + """返回 ``block`` / ``inline`` 表示公式图,否则 None(含注释内引用)。""" + a = alt or "" + s = src.replace("\\", "/") + if "math_figures" not in s and "公式" not in a: + return None + if "行内" in a: + return "inline" + return "block" + + +def _is_diagram_image(alt: str, src: str) -> bool: + """mermaid 系统框图 / 流程图等(非公式,用全幅插图尺寸)。""" + a = alt or "" + s = src.replace("\\", "/") + if "mermaid_figures" in s: + return True + if a.startswith("图示") or a.startswith("图 "): + return True + return False + + +def _span_overlaps(spans: list[tuple[int, int]], start: int, end: int) -> bool: + return any(not (end <= s or start >= e) for s, e in spans) + + +def _embed_from_image_ref( + alt: str, + src: str, + base_dir: Path | None, + *, + doc: Document | None = None, + paragraph=None, + image_max_w_in: float = _DEFAULT_IMAGE_MAX_W_IN, + image_max_h_in: float = _DEFAULT_IMAGE_MAX_H_IN, +) -> None: + """按公式 / 框图 / 普通图规则嵌入 PNG(仅公式用小尺寸)。""" + ipath = _resolve_image_path(src, base_dir) if base_dir else None + missing = f"[图片缺失: {alt or src}]" + if not ipath: + if paragraph is not None: + paragraph.add_run(missing) + elif doc is not None: + doc.add_paragraph().add_run(missing) + return + + kind = _formula_image_kind(alt, src) + if kind == "inline": + p = paragraph + if p is None and doc is not None: + p = doc.add_paragraph() + p.paragraph_format.space_after = Pt(6) + p.paragraph_format.line_spacing = 1.15 + if p is not None: + _embed_picture_inline(p, ipath, max_h_in=_FORMULA_DISPLAY_MAX_H_IN) + return + + if doc is None: + if paragraph is not None: + paragraph.add_run(missing) + return + + if kind == "block": + p = doc.add_paragraph() + p.paragraph_format.space_after = Pt(6) + p.paragraph_format.space_before = Pt(3) + _embed_picture_inline( + p, + ipath, + max_h_in=_FORMULA_DISPLAY_MAX_H_IN, + max_w_in=_FORMULA_BLOCK_MAX_W_IN, + ) + else: + _embed_picture( + doc, + ipath, + alt=alt, + src=src, + max_w_in=image_max_w_in, + max_h_in=image_max_h_in, + center=False, + ) + + +def _maybe_render_math_md(md_text: str, base_dir: Path) -> str: + """若含 LaTeX 公式则尝试调用 ``math_render``(已注释的 PNG 引用会跳过)。""" + if not re.search(r"\$\$|\\\[|\\\(|(?<!\$)\$(?!\$)", md_text): + return md_text + try: + from math_render import render_markdown_math + except ImportError: + print( + "[md_to_docx] 未安装 matplotlib,公式将按原文写入 Word", + file=sys.stderr, + ) + return md_text + stub = base_dir / "_md_to_docx_math_stub.md" + new_md, ok, failed = render_markdown_math( + md_text, + out_md_path=stub, + assets_rel="math_figures", + ) + if ok or failed: + print( + f"[md_to_docx] 公式渲染:{ok} 成功,{failed} 保留原文", + file=sys.stderr, + ) + return new_md + + +def _add_math_fallback_block(doc: Document, lines: list[str]) -> None: + """未渲染成功的 ``$$ ... $$`` 以等宽原文写入 Word。""" + body = [ln.rstrip("\n") for ln in lines] + _add_code_block(doc, ["$$", *body, "$$"]) + + +def _embed_picture( + doc: Document, + path: Path, + *, + alt: str, + src: str, + max_w_in: float, + max_h_in: float, + center: bool, +) -> None: + p = doc.add_paragraph() + if center: + p.alignment = WD_ALIGN_PARAGRAPH.CENTER + p.paragraph_format.space_after = Pt(6) + p.paragraph_format.space_before = Pt(3) + try: + dims = _image_pixel_size(path) + if dims: + w_in, h_in = _fit_image_display_inches( + *dims, max_w_in=max_w_in, max_h_in=max_h_in + ) + run = p.add_run() + run.font.bold = False + run.add_picture(str(path.resolve()), width=w_in, height=h_in) + else: + run = p.add_run() + run.font.bold = False + run.add_picture(str(path.resolve()), width=Inches(max_w_in)) + except Exception: + p.add_run(f"[图片无法嵌入: {alt or src} — {path}]") + + +def _embed_picture_inline( + paragraph, + path: Path, + *, + max_h_in: float, + max_w_in: float | None = None, +) -> None: + try: + dims = _image_pixel_size(path) + run = paragraph.add_run() + run.font.bold = False + if dims: + px_w, px_h = dims + h_in = max_h_in + w_in = h_in * px_w / px_h if px_h else max_h_in + if max_w_in is not None and w_in > max_w_in: + w_in = max_w_in + h_in = w_in * px_h / px_w if px_w else max_h_in + run.add_picture(str(path.resolve()), width=Inches(w_in), height=Inches(h_in)) + else: + run.add_picture(str(path.resolve()), height=Inches(max_h_in)) + except Exception: + paragraph.add_run(f"[行内公式图缺失: {path}]") + + +def _add_rich_content_to_paragraph( + paragraph, + text: str, + base_dir: Path | None, + *, + formula_inline_max_h_in: float = _FORMULA_DISPLAY_MAX_H_IN, + image_max_w_in: float = _DEFAULT_IMAGE_MAX_W_IN, + image_max_h_in: float = _DEFAULT_IMAGE_MAX_H_IN, + mono: bool = False, +) -> None: + """同一段内混排文字(**粗体**/`代码`)与公式/插图(含 HTML 注释隐藏引用)。""" + taken: list[tuple[int, int]] = [] + tokens: list[tuple[int, int, str, tuple]] = [] + + for m in _INLINE_MATH_WITH_HIDDEN_IMG_RE.finditer(text): + tokens.append((m.start(), m.end(), "math_img", (m.group(2), m.group(3).strip()))) + taken.append((m.start(), m.end())) + + for m in _INLINE_MATH_PAREN_WITH_HIDDEN_IMG_RE.finditer(text): + if _span_overlaps(taken, m.start(), m.end()): + continue + tokens.append((m.start(), m.end(), "math_img", (m.group(2), m.group(3).strip()))) + taken.append((m.start(), m.end())) + + for m in _HIDDEN_MD_IMAGE_COMMENT_RE.finditer(text): + if _span_overlaps(taken, m.start(), m.end()): + continue + tokens.append((m.start(), m.end(), "hidden_img", (m.group(1), m.group(2).strip()))) + taken.append((m.start(), m.end())) + + for m in _MD_IMAGE_RE.finditer(text): + if _span_overlaps(taken, m.start(), m.end()): + continue + tokens.append((m.start(), m.end(), "visible_img", (m.group(1), m.group(2).strip()))) + taken.append((m.start(), m.end())) + + inline_pat = re.compile(r"(\*\*[^*]+?\*\*|`[^`]+?`)") + for m in inline_pat.finditer(text): + if _span_overlaps(taken, m.start(), m.end()): + continue + tokens.append((m.start(), m.end(), "inline", (m.group(1),))) + taken.append((m.start(), m.end())) + + tokens.sort(key=lambda t: t[0]) + pos = 0 + for start, end, kind, payload in tokens: + if start > pos: + _add_inline_to_paragraph(paragraph, text[pos:start], mono=mono) + if kind == "inline": + token = payload[0] + if token.startswith("**"): + run = paragraph.add_run(token[2:-2]) + _set_run_font(run, "宋体", 10.5, bold=True) + else: + run = paragraph.add_run(token[1:-1]) + _set_run_font(run, "Consolas", 9) + run.font.color.rgb = RGBColor(0x33, 0x33, 0x33) + else: + alt, src = payload[0], payload[1] + _embed_from_image_ref( + alt, + src, + base_dir, + paragraph=paragraph, + image_max_w_in=image_max_w_in, + image_max_h_in=image_max_h_in, + ) + pos = end + if pos < len(text): + _add_inline_to_paragraph(paragraph, text[pos:], mono=mono) + + +def _set_run_font(run, name: str = "宋体", size_pt: float | None = None, bold: bool | None = None): + run.font.name = name + run._element.rPr.rFonts.set(qn("w:eastAsia"), name) + if size_pt is not None: + run.font.size = Pt(size_pt) + if bold is not None: + run.font.bold = bold + + +def _add_inline_to_paragraph(paragraph, text: str, *, mono: bool = False): + """解析 **粗体**、`行内代码` 与普通文本,写入同一段落。""" + if not text: + return + # 拆分为:粗体、行内代码、普通 + pattern = re.compile(r"(\*\*[^*]+?\*\*|`[^`]+?`)") + pos = 0 + for m in pattern.finditer(text): + if m.start() > pos: + run = paragraph.add_run(text[pos : m.start()]) + _set_run_font(run, "Consolas" if mono else "宋体", 10.5 if not mono else 9) + token = m.group(1) + if token.startswith("**"): + run = paragraph.add_run(token[2:-2]) + _set_run_font(run, "宋体", 10.5, bold=True) + else: # `code` + run = paragraph.add_run(token[1:-1]) + _set_run_font(run, "Consolas", 9) + run.font.color.rgb = RGBColor(0x33, 0x33, 0x33) + pos = m.end() + if pos < len(text): + run = paragraph.add_run(text[pos:]) + _set_run_font(run, "Consolas" if mono else "宋体", 10.5 if not mono else 9) + + +def _add_heading(doc: Document, level: int, text: str): + """level 1–9 对应 Word 标题 1–标题 9;去除行内标记时保留可读文本。""" + plain = re.sub(r"\*\*([^*]+)\*\*", r"\1", text) + plain = re.sub(r"`([^`]+)`", r"\1", plain) + h = doc.add_heading(plain, level=min(max(level, 1), 9)) + for run in h.runs: + _set_run_font(run, "黑体" if level <= 2 else "宋体") + + +def _add_body_paragraph( + doc: Document, + text: str, + base_dir: Path | None = None, + *, + image_max_h_in: float = _DEFAULT_IMAGE_MAX_H_IN, +): + p = doc.add_paragraph() + p.paragraph_format.space_after = Pt(6) + p.paragraph_format.line_spacing = 1.15 + if ( + _MD_IMAGE_RE.search(text) + or _HIDDEN_MD_IMAGE_COMMENT_RE.search(text) + or _INLINE_MATH_WITH_HIDDEN_IMG_RE.search(text) + or _INLINE_MATH_PAREN_WITH_HIDDEN_IMG_RE.search(text) + ): + _add_rich_content_to_paragraph( + p, + text, + base_dir, + image_max_w_in=_DEFAULT_IMAGE_MAX_W_IN, + image_max_h_in=image_max_h_in, + ) + else: + _add_inline_to_paragraph(p, text) + for run in p.runs: + if run.font.name in (None, ""): + _set_run_font(run, "宋体", 10.5) + + +def _add_code_block(doc: Document, lines: list[str]): + p = doc.add_paragraph() + p.paragraph_format.left_indent = Inches(0.2) + p.paragraph_format.space_after = Pt(6) + p.paragraph_format.keep_together = True + body = "\n".join(lines) + run = p.add_run(body) + _set_run_font(run, "Consolas", 9) + run.font.color.rgb = RGBColor(0x20, 0x20, 0x20) + + +def _add_list_item( + doc: Document, + text: str, + ordered: bool, + base_dir: Path | None, + *, + image_max_h_in: float = _DEFAULT_IMAGE_MAX_H_IN, +): + style = "List Number" if ordered else "List Bullet" + try: + p = doc.add_paragraph(style=style) + except (KeyError, ValueError): + p = doc.add_paragraph() + p.paragraph_format.left_indent = Inches(0.35) + p.paragraph_format.space_after = Pt(3) + if ( + _MD_IMAGE_RE.search(text) + or _HIDDEN_MD_IMAGE_COMMENT_RE.search(text) + or _INLINE_MATH_WITH_HIDDEN_IMG_RE.search(text) + or _INLINE_MATH_PAREN_WITH_HIDDEN_IMG_RE.search(text) + ): + _add_rich_content_to_paragraph( + p, + text, + base_dir, + image_max_w_in=_DEFAULT_IMAGE_MAX_W_IN, + image_max_h_in=image_max_h_in, + ) + else: + _add_inline_to_paragraph(p, text) + for run in p.runs: + _set_run_font(run, "宋体", 10.5) + + +def _is_table_row(line: str) -> bool: + s = line.strip() + return s.startswith("|") and s.endswith("|") and "|" in s[1:-1] + + +def _split_table_cells(line: str) -> list[str]: + """按列分隔符 ``|`` 拆分表格行,忽略 ``\\(...\\)``、``$...$``、``<!-- -->`` 与 ``\\|`` 内的竖线。""" + s = line.strip() + if s.startswith("|"): + s = s[1:] + if s.endswith("|"): + s = s[:-1] + + cells: list[str] = [] + buf: list[str] = [] + i = 0 + n = len(s) + + while i < n: + if s.startswith("<!--", i): + end = s.find("-->", i) + if end == -1: + buf.append(s[i:]) + break + buf.append(s[i : end + 3]) + i = end + 3 + continue + + if s.startswith("\\(", i): + end = s.find("\\)", i + 2) + if end == -1: + buf.append(s[i:]) + break + buf.append(s[i : end + 2]) + i = end + 2 + continue + + if s[i] == "$": + if i + 1 < n and s[i + 1] == "$": + end = s.find("$$", i + 2) + if end == -1: + buf.append(s[i:]) + break + buf.append(s[i : end + 2]) + i = end + 2 + continue + j = i + 1 + while j < n: + if s[j] == "$" and (j == 0 or s[j - 1] != "\\"): + buf.append(s[i : j + 1]) + i = j + 1 + break + j += 1 + else: + buf.append(s[i:]) + break + continue + + if s[i] == "\\" and i + 1 < n and s[i + 1] == "|": + buf.append("\\|") + i += 2 + continue + + if s[i] == "|": + cells.append("".join(buf).strip()) + buf = [] + i += 1 + continue + + buf.append(s[i]) + i += 1 + + cells.append("".join(buf).strip()) + return cells + + +def _parse_table_row(line: str) -> list[str]: + return _split_table_cells(line) + + +def _is_table_sep(row: list[str]) -> bool: + if not row: + return False + return all(re.match(r"^:?-{3,}:?$", c.strip()) for c in row if c.strip()) + + +def _add_table(doc: Document, rows: list[list[str]], base_dir: Path | None = None): + if not rows: + return + ncols = max(len(r) for r in rows) + table = doc.add_table(rows=len(rows), cols=ncols) + table.style = "Table Grid" + for i, row in enumerate(rows): + for j in range(ncols): + cell_text = row[j] if j < len(row) else "" + cell = table.rows[i].cells[j] + cell.text = "" + p = cell.paragraphs[0] + if _line_has_embeddable_images(cell_text): + _add_rich_content_to_paragraph(p, cell_text, base_dir) + else: + _add_inline_to_paragraph(p, cell_text) + for run in p.runs: + _set_run_font(run, "宋体", 10) + + +def _add_horizontal_rule(doc: Document): + p = doc.add_paragraph() + p.paragraph_format.space_after = Pt(8) + p.paragraph_format.space_before = Pt(8) + run = p.add_run("─" * 32) + _set_run_font(run, "宋体", 8) + run.font.color.rgb = RGBColor(0xAA, 0xAA, 0xAA) + + +def _resolve_image_path(src: str, base_dir: Path | None) -> Path | None: + if not base_dir: + return None + path = (base_dir / src).resolve() if not Path(src).is_absolute() else Path(src) + return path if path.is_file() else None + + +def _try_add_image( + doc: Document, + line: str, + base_dir: Path | None, + *, + max_w_in: float = _DEFAULT_IMAGE_MAX_W_IN, + max_h_in: float = _DEFAULT_IMAGE_MAX_H_IN, +) -> bool: + m = _MD_IMAGE_RE.match(line.strip()) + if not m or not base_dir: + return False + alt, src = m.group(1), m.group(2).strip() + _embed_from_image_ref( + alt, + src, + base_dir, + doc=doc, + image_max_w_in=max_w_in, + image_max_h_in=max_h_in, + ) + return True + + +def _line_has_embeddable_images(line: str) -> bool: + return bool( + _MD_IMAGE_RE.search(line) + or _HIDDEN_MD_IMAGE_COMMENT_RE.search(line) + or _INLINE_MATH_WITH_HIDDEN_IMG_RE.search(line) + or _INLINE_MATH_PAREN_WITH_HIDDEN_IMG_RE.search(line) + ) + + +def _add_paragraph_with_inline_images( + doc: Document, + line: str, + base_dir: Path | None, + *, + max_w_in: float = _DEFAULT_IMAGE_MAX_W_IN, + max_h_in: float = _DEFAULT_IMAGE_MAX_H_IN, +) -> None: + """段落内混排文字与公式/插图(含 HTML 注释隐藏引用)。""" + p = doc.add_paragraph() + p.paragraph_format.space_after = Pt(6) + p.paragraph_format.line_spacing = 1.15 + _add_rich_content_to_paragraph( + p, + line, + base_dir, + image_max_w_in=max_w_in, + image_max_h_in=max_h_in, + ) + for run in p.runs: + if run.font.name in (None, ""): + _set_run_font(run, "宋体", 10.5) + + +def convert_md_to_docx( + md_text: str, + base_dir: Path | None, + *, + image_max_w_in: float = _DEFAULT_IMAGE_MAX_W_IN, + image_max_h_in: float = _DEFAULT_IMAGE_MAX_H_IN, +) -> Document: + doc = Document() + # 默认正文样式 + try: + style = doc.styles["Normal"] + style.font.name = "宋体" + if style._element.rPr is not None: + style._element.rPr.rFonts.set(qn("w:eastAsia"), "宋体") + style.font.size = Pt(10.5) + except (AttributeError, KeyError): + pass + + lines = md_text.splitlines() + i = 0 + para_buf: list[str] = [] + + def flush_paragraph(): + nonlocal para_buf + if not para_buf: + return + # 每行独立成段,避免「(1)…\n(2)…」被空格拼成一段(Word 内不换行) + for p in para_buf: + t = p.strip() + if t: + _add_body_paragraph( + doc, + t, + base_dir, + image_max_h_in=image_max_h_in, + ) + para_buf = [] + + while i < len(lines): + raw = lines[i] + line = raw.rstrip("\n") + + if line.strip() == "": + flush_paragraph() + i += 1 + continue + + # 围栏代码块 + if line.strip().startswith("```"): + flush_paragraph() + fence_lang = line.strip()[3:].strip() + i += 1 + code_lines: list[str] = [] + while i < len(lines) and not lines[i].strip().startswith("```"): + code_lines.append(lines[i]) + i += 1 + if i < len(lines): + i += 1 + # 定稿 MD 保留 mermaid 源码 + 图示注释:Word 只嵌 PNG,不写源码块 + if fence_lang.lower() == "mermaid": + j = i + while j < len(lines) and lines[j].strip() == "": + j += 1 + if j < len(lines): + cm = _HIDDEN_MD_IMAGE_COMMENT_RE.match(lines[j].strip()) + if cm and _is_diagram_image(cm.group(1), cm.group(2).strip()): + continue + _add_code_block(doc, code_lines) + continue + + # 块级公式:\[ ... \] + 可选 HTML 注释 + if line.strip() == "\\[": + flush_paragraph() + i += 1 + math_lines: list[str] = [] + while i < len(lines) and lines[i].strip() != "\\]": + math_lines.append(lines[i]) + i += 1 + if i < len(lines): + i += 1 + hidden: tuple[str, str] | None = None + if i < len(lines): + cm = _HIDDEN_MD_IMAGE_COMMENT_RE.match(lines[i].strip()) + if cm: + hidden = (cm.group(1), cm.group(2).strip()) + i += 1 + if hidden and _formula_image_kind(*hidden): + ipath = _resolve_image_path(hidden[1], base_dir) + if ipath: + _embed_from_image_ref( + hidden[0], + hidden[1], + base_dir, + doc=doc, + image_max_w_in=image_max_w_in, + image_max_h_in=image_max_h_in, + ) + continue + _add_math_fallback_block(doc, ["\\[", *math_lines, "\\]"]) + continue + + # 块级公式:$$ ... $$ + 可选 HTML 注释(Word 嵌 PNG;预览见 LaTeX 原文) + if line.strip() == "$$": + flush_paragraph() + i += 1 + math_lines: list[str] = [] + while i < len(lines) and lines[i].strip() != "$$": + math_lines.append(lines[i]) + i += 1 + if i < len(lines): + i += 1 + hidden: tuple[str, str] | None = None + if i < len(lines): + cm = _HIDDEN_MD_IMAGE_COMMENT_RE.match(lines[i].strip()) + if cm: + hidden = (cm.group(1), cm.group(2).strip()) + i += 1 + if hidden and _formula_image_kind(*hidden): + ipath = _resolve_image_path(hidden[1], base_dir) + if ipath: + _embed_from_image_ref( + hidden[0], + hidden[1], + base_dir, + doc=doc, + image_max_w_in=image_max_w_in, + image_max_h_in=image_max_h_in, + ) + continue + _add_math_fallback_block(doc, math_lines) + continue + + # 独立 HTML 注释行(公式图 / mermaid 框图引用) + if _HIDDEN_MD_IMAGE_COMMENT_RE.fullmatch(line.strip()): + flush_paragraph() + _try_embed_hidden_comment_line( + doc, + line, + base_dir, + image_max_w_in=image_max_w_in, + image_max_h_in=image_max_h_in, + ) + i += 1 + continue + + # 图片行或含行内公式/注释的段落 + if _line_has_embeddable_images(line): + flush_paragraph() + stripped = line.strip() + if _MD_IMAGE_RE.fullmatch(stripped) or ( + stripped.startswith("![") and stripped.count("![") == 1 + ): + _try_add_image( + doc, + line, + base_dir, + max_w_in=image_max_w_in, + max_h_in=image_max_h_in, + ) + else: + _add_paragraph_with_inline_images( + doc, + line, + base_dir, + max_w_in=image_max_w_in, + max_h_in=image_max_h_in, + ) + i += 1 + continue + + # 水平线 + if re.match(r"^[\s\-*_]{3,}\s*$", line) and set(line.strip()) <= {"-", "*", "_", " "}: + flush_paragraph() + _add_horizontal_rule(doc) + i += 1 + continue + + # 标题 + m = re.match(r"^(#{1,6})\s+(.+)$", line) + if m: + flush_paragraph() + level = len(m.group(1)) + title = m.group(2).strip() + title = re.sub(r"\s+#+\s*$", "", title) + _add_heading(doc, level, title) + i += 1 + continue + + # 引用 + if line.lstrip().startswith("> "): + flush_paragraph() + quote = line.lstrip()[2:].strip() + p = doc.add_paragraph() + p.paragraph_format.left_indent = Inches(0.25) + p.paragraph_format.space_after = Pt(4) + _add_inline_to_paragraph(p, quote) + for run in p.runs: + _set_run_font(run, "宋体", 10.5) + i += 1 + continue + + # 表格块 + if _is_table_row(line): + flush_paragraph() + table_rows: list[list[str]] = [] + while i < len(lines) and _is_table_row(lines[i]): + row = _parse_table_row(lines[i]) + if not _is_table_sep(row): + table_rows.append(row) + i += 1 + _add_table(doc, table_rows, base_dir) + continue + + # 无序列表 + um = re.match(r"^(\s*)[-*+]\s+(.+)$", line) + if um: + flush_paragraph() + _add_list_item( + doc, + um.group(2).strip(), + ordered=False, + base_dir=base_dir, + image_max_h_in=image_max_h_in, + ) + i += 1 + continue + + # 有序列表 + om = re.match(r"^(\s*)\d+\.\s+(.+)$", line) + if om: + flush_paragraph() + _add_list_item( + doc, + om.group(2).strip(), + ordered=True, + base_dir=base_dir, + image_max_h_in=image_max_h_in, + ) + i += 1 + continue + + para_buf.append(line) + i += 1 + + flush_paragraph() + return doc + + +def main(argv: list[str] | None = None) -> int: + p = argparse.ArgumentParser(description="Markdown → Word(标题样式映射)") + p.add_argument("-i", "--input", required=True, help="输入 .md 路径") + p.add_argument("-o", "--output", required=True, help="输出 .docx 路径") + p.add_argument( + "--base-dir", + default=None, + help="解析 ![](/相对路径) 图片时的根目录(默认使用 .md 所在目录)", + ) + p.add_argument( + "--image-max-width-inches", + type=float, + default=_DEFAULT_IMAGE_MAX_W_IN, + metavar="IN", + help=f"插图最大宽度(英寸,默认 {_DEFAULT_IMAGE_MAX_W_IN}),与高度共同约束等比缩放", + ) + p.add_argument( + "--image-max-height-inches", + type=float, + default=_DEFAULT_IMAGE_MAX_H_IN, + metavar="IN", + help=f"插图最大高度(英寸,默认 {_DEFAULT_IMAGE_MAX_H_IN}),避免竖图仅按宽度缩放后超出单页可视区域", + ) + p.add_argument( + "--no-math-render", + action="store_true", + help="不自动调用 math_render(默认会先渲染 $ / $$ 公式为 PNG)", + ) + args = p.parse_args(argv) + + in_path = Path(args.input).resolve() + if not in_path.is_file(): + print(f"错误:找不到输入文件 {in_path}", file=sys.stderr) + return 1 + + base = Path(args.base_dir).resolve() if args.base_dir else in_path.parent + try: + md_text = in_path.read_text(encoding="utf-8") + except UnicodeDecodeError: + md_text = in_path.read_text(encoding="utf-8", errors="replace") + print("警告:输入文件含非 UTF-8 字节,已使用替换字符解码后继续转换。", file=sys.stderr) + + if not args.no_math_render: + md_text = _maybe_render_math_md(md_text, base) + + doc = convert_md_to_docx( + md_text, + base_dir=base, + image_max_w_in=args.image_max_width_inches, + image_max_h_in=args.image_max_height_inches, + ) + out_path = Path(args.output).resolve() + out_path.parent.mkdir(parents=True, exist_ok=True) + doc.save(str(out_path)) + print(f"已写入: {out_path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.agents/skills/patent-disclosure-skill/tools/mermaid_render.py b/.agents/skills/patent-disclosure-skill/tools/mermaid_render.py new file mode 100644 index 0000000..71dcc70 --- /dev/null +++ b/.agents/skills/patent-disclosure-skill/tools/mermaid_render.py @@ -0,0 +1,474 @@ +#!/usr/bin/env python3 +""" +将 Markdown 中的 **mermaid** 围栏与(默认)**LaTeX 公式** 转为 PNG,再写定稿 `.md` 并默认生成 Word。 + +**公式**:默认先调用同目录 **`math_render.py`**(``matplotlib``;``--no-math`` 可跳过)。**Mermaid** 围栏块逐块渲染为 PNG,**保留** `` ```mermaid`` … `` ``` `` 源码,并在其后追加 HTML 注释 +``<!-- ![图示](相对路径) -->``(预览不显示图),便于 ``md_to_docx.py`` 将图嵌入 Word(Word **仅**嵌 PNG,不写 mermaid 代码块)。 + +**Mermaid 渲染后端(``mmdc``)**检测顺序见 ``_find_mmdc_invocation``: +1. ``tools/node_modules``(``npm install`` 官方 ``@mermaid-js/mermaid-cli``); +2. **PATH 上的 ``mmdc``**(通常为 ``npm install -g @mermaid-js/mermaid-cli``); +3. **Node.js + npx** 临时拉取 ``@mermaid-js/mermaid-cli``(无本地安装时)。 + +交底书 **3.2 系统框图**与 **3.4 流程图**均使用 fenced mermaid;**不要** ASCII「文字箭头」流程图或框图。 + +**降级**:某一围栏 ``mmdc`` 生图失败时**不中断**:该处**保留原** `` ```mermaid`` … `` ``` `` 围栏;其余块照常渲染。仍写出 .md 并**照常尝试** ``md_to_docx.py``(Word 中失败块以代码块形式出现)。 + +**清晰度**:默认对 ``mmdc`` 传入较大视口(``-w`` / ``-H``)与 ``-s 2``(Puppeteer 像素密度),PNG 在 Word 中按约 5.5 英寸宽嵌入时更锐利。可用 ``--mmdc-scale 3`` 等进一步提高(文件更大)。 + +用法: + python tools/mermaid_render.py -i draft.md -o disclosure.md + # 默认在同目录生成 disclosure.docx;失败时 stderr 会给出可复制的 md_to_docx 命令 + python tools/mermaid_render.py -i draft.md -o out/disclosure.md --docx out/custom.docx + python tools/mermaid_render.py -i draft.md -o disclosure.md --no-docx # 仅 Markdown + +写出 .md 后**默认**调用 ``md_to_docx.py``;Word 失败不导致进程失败(退出码 0),并提示手动转换。 +""" +from __future__ import annotations + +import argparse +import re +import shlex +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + + +def _local_mmdc() -> tuple[list[str], bool] | None: + """``tools/npm install`` 后可用 ``node_modules/.bin/mmdc``,避免每次 npx 拉包。""" + here = Path(__file__).resolve().parent + if sys.platform == "win32": + cand = here / "node_modules" / ".bin" / "mmdc.cmd" + else: + cand = here / "node_modules" / ".bin" / "mmdc" + if cand.is_file(): + return [str(cand)], False + return None + + +def _find_mmdc_invocation() -> tuple[list[str], bool]: + """ + 返回 (argv 前缀, use_shell)。 + Windows 上 npx 常为 .ps1,无独立 .exe,需 shell=True 调用 ``npx ...``。 + PATH 中的 ``mmdc`` 一般为 npm 全局安装的官方 CLI。 + """ + local = _local_mmdc() + if local: + return local + mmdc = shutil.which("mmdc") + if mmdc and Path(mmdc).suffix.lower() not in (".ps1",): + return [mmdc], False + if sys.platform == "win32": + return ["npx", "-y", "@mermaid-js/mermaid-cli", "mmdc"], True + return ["npx", "-y", "@mermaid-js/mermaid-cli", "mmdc"], False + + +def _mmdc_extra_args( + *, + scale: float, + width: int, + height: int, +) -> list[str]: + """传给 mmdc 的分辨率相关参数(-s 为 Puppeteer deviceScaleFactor,显著影响 PNG 清晰度)。""" + return [ + "-s", + str(scale), + "-w", + str(width), + "-H", + str(height), + ] + + +def _render_one_mermaid( + mermaid_source: str, + png_path: Path, + mmdc_base: list[str], + *, + use_shell: bool, + scale: float, + width: int, + height: int, +) -> None: + png_path.parent.mkdir(parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile( + mode="w", + suffix=".mmd", + delete=False, + encoding="utf-8", + ) as tmp: + tmp.write(mermaid_source.strip() + "\n") + tmp_path = Path(tmp.name) + try: + extra = _mmdc_extra_args(scale=scale, width=width, height=height) + if use_shell: + parts = [ + *mmdc_base, + "-i", + str(tmp_path), + "-o", + str(png_path), + "-b", + "white", + *extra, + ] + cmd = " ".join(shlex.quote(p) for p in parts) + r = subprocess.run( + cmd, + shell=True, + capture_output=True, + text=True, + timeout=180, + ) + else: + cmd = [ + *mmdc_base, + "-i", + str(tmp_path), + "-o", + str(png_path), + "-b", + "white", + *extra, + ] + r = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=180, + ) + if r.returncode != 0: + err = (r.stderr or r.stdout or "").strip() + raise RuntimeError(f"mmdc 失败 (exit {r.returncode}): {err[:2000]}") + finally: + try: + tmp_path.unlink(missing_ok=True) + except OSError: + pass + + +_MMD_START = re.compile(r"^```mermaid\s*$", re.IGNORECASE) +_MMD_END = re.compile(r"^```\s*$") +_MERMAID_HIDDEN_COMMENT_RE = re.compile( + r"<!--\s*!\[([^\]]*)\]\(([^)]+)\)\s*-->" +) + + +def _is_mermaid_figure_comment(alt: str, src: str) -> bool: + s = src.strip().replace("\\", "/") + if "mermaid_figures" in s: + return True + a = alt.strip() + return a.startswith("图示") or a.startswith("图 ") + + +def render_markdown_mermaid( + md_text: str, + *, + out_md_path: Path, + assets_rel: str, + mmdc_scale: float = 2.0, + mmdc_width: int = 1400, + mmdc_height: int = 1050, +) -> tuple[str, int, int]: + """ + 返回 (新 markdown 全文, 成功转为 PNG 的块数, 生图失败而保留围栏的块数)。 + 资源目录为 out_md_path.parent / assets_rel。 + 失败的块原样写回 `` ```mermaid`` … `` ``` ``,不抛错。 + 成功的块写回围栏源码 + 紧随其后的 ``<!-- ![图示](…) -->``(与 ``math_render`` 保留 LaTeX 原文同理)。 + 若围栏后已有 mermaid 图示注释,则视为已处理,原样跳过(可重复跑脚本)。 + """ + lines = md_text.splitlines(keepends=True) + out: list[str] = [] + i = 0 + ok = 0 + failed = 0 + block_idx = 0 + assets_dir = out_md_path.parent / assets_rel + mmdc_base, use_shell = _find_mmdc_invocation() + + while i < len(lines): + line = lines[i] + if _MMD_START.match(line): + fence_open = line + i += 1 + body: list[str] = [] + while i < len(lines) and not _MMD_END.match(lines[i]): + body.append(lines[i]) + i += 1 + closing = lines[i] if i < len(lines) else "```\n" + if i < len(lines): + i += 1 + + # 已定稿:围栏 + 图示注释,不重复渲染 + j = i + while j < len(lines) and lines[j].strip() == "": + j += 1 + if j < len(lines): + cm = _MERMAID_HIDDEN_COMMENT_RE.match(lines[j].strip()) + if cm and _is_mermaid_figure_comment(cm.group(1), cm.group(2)): + out.append(fence_open) + out.extend(body) + if not closing.endswith("\n"): + closing = closing + "\n" + out.append(closing) + while i < j: + out.append(lines[i]) + i += 1 + out.append(lines[i]) + i += 1 + ok += 1 + continue + + block_idx += 1 + fname = f"fig_{ok + 1:03d}.png" + png_path = assets_dir / fname + try: + _render_one_mermaid( + "".join(body), + png_path, + mmdc_base, + use_shell=use_shell, + scale=mmdc_scale, + width=mmdc_width, + height=mmdc_height, + ) + except Exception as e: + failed += 1 + print( + f"[mermaid_render] 第 {block_idx} 个 mermaid 围栏生图失败(已保留源码):{e}", + file=sys.stderr, + ) + out.append(fence_open) + out.extend(body) + if not closing.endswith("\n"): + closing = closing + "\n" + out.append(closing) + continue + ok += 1 + rel = f"{assets_rel.strip('/')}/{fname}".replace("\\", "/") + out.append(fence_open) + out.extend(body) + if not closing.endswith("\n"): + closing = closing + "\n" + out.append(closing) + out.append(f"<!-- ![图示 {ok}]({rel}) -->\n") + continue + out.append(line) + i += 1 + + return "".join(out), ok, failed + + +def _print_manual_docx_hint(out_md: Path, docx_out: Path, base_dir: Path, md_script: Path) -> None: + print( + "提示:可手动将上述 Markdown 转为 Word(需已 pip install -r requirements.txt):", + file=sys.stderr, + ) + if md_script.is_file(): + parts = [ + sys.executable, + str(md_script), + "-i", + str(out_md), + "-o", + str(docx_out), + "--base-dir", + str(base_dir), + ] + print(" " + " ".join(shlex.quote(p) for p in parts), file=sys.stderr) + else: + print( + " python tools/md_to_docx.py -i <上述.md> -o <输出.docx> --base-dir <.md 所在目录>", + file=sys.stderr, + ) + + +def try_write_docx(out_md: Path, docx_out: Path) -> bool: + """ + 调用同目录下的 md_to_docx.py。成功返回 True;失败打印警告与手动命令,返回 False。 + """ + tools_dir = Path(__file__).resolve().parent + md_script = tools_dir / "md_to_docx.py" + base_dir = out_md.parent + docx_out.parent.mkdir(parents=True, exist_ok=True) + + if not md_script.is_file(): + print("警告:未找到 md_to_docx.py,跳过 Word。", file=sys.stderr) + _print_manual_docx_hint(out_md, docx_out, base_dir, md_script) + return False + + cmd = [ + sys.executable, + str(md_script), + "-i", + str(out_md), + "-o", + str(docx_out), + "--base-dir", + str(base_dir), + ] + try: + r = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=300, + ) + except subprocess.TimeoutExpired: + print("警告:生成 Word 超时(300s)。", file=sys.stderr) + _print_manual_docx_hint(out_md, docx_out, base_dir, md_script) + return False + except OSError as e: + print(f"警告:无法启动 md_to_docx:{e}", file=sys.stderr) + _print_manual_docx_hint(out_md, docx_out, base_dir, md_script) + return False + + if r.returncode != 0: + print(f"警告:md_to_docx 失败(退出码 {r.returncode})。", file=sys.stderr) + err = (r.stderr or r.stdout or "").strip() + if err: + print(err[:2000], file=sys.stderr) + _print_manual_docx_hint(out_md, docx_out, base_dir, md_script) + return False + + print(f"已写入 Word: {docx_out}", file=sys.stderr) + return True + + +def main(argv: list[str] | None = None) -> int: + p = argparse.ArgumentParser( + description="Markdown 内 mermaid 围栏 → PNG,默认再生成同名 Word" + ) + p.add_argument("-i", "--input", required=True, type=Path, help="含 mermaid 围栏的 .md") + p.add_argument("-o", "--output", required=True, type=Path, help="输出 .md(图片引用)") + p.add_argument( + "--assets-dir", + default="mermaid_figures", + help="mermaid 生成 PNG 的相对子目录(默认 mermaid_figures)", + ) + p.add_argument( + "--docx", + type=Path, + default=None, + metavar="PATH", + help="输出 .docx 路径(默认与 -o 同主文件名、扩展名 .docx)", + ) + p.add_argument( + "--no-docx", + action="store_true", + help="不生成 Word,仅输出替换图片后的 Markdown", + ) + p.add_argument( + "--no-math", + action="store_true", + help="不渲染 LaTeX 公式(默认先 math_render 再 mermaid)", + ) + p.add_argument( + "--math-assets-dir", + default="math_figures", + help="公式 PNG 相对 -o 输出 .md 的子目录(默认 math_figures)", + ) + p.add_argument( + "--mmdc-scale", + type=float, + default=2.0, + metavar="N", + help="mmdc -s:Puppeteer 缩放(默认 2,约 2 倍像素密度;越大越清晰但文件更大)", + ) + p.add_argument( + "--mmdc-width", + type=int, + default=1400, + metavar="PX", + help="mmdc -w:渲染视口宽度像素(默认 1400,复杂 flowchart 不易裁切)", + ) + p.add_argument( + "--mmdc-height", + type=int, + default=1050, + metavar="PX", + help="mmdc -H:渲染视口高度像素(默认 1050)", + ) + args = p.parse_args(argv) + if args.mmdc_scale <= 0: + print("错误:--mmdc-scale 须为正数", file=sys.stderr) + return 1 + if args.mmdc_width < 400 or args.mmdc_height < 400: + print("错误:--mmdc-width / --mmdc-height 建议不小于 400", file=sys.stderr) + return 1 + + in_path = args.input.resolve() + if not in_path.is_file(): + print(f"错误:找不到输入 {in_path}", file=sys.stderr) + return 1 + + out_path = args.output.resolve() + out_path.parent.mkdir(parents=True, exist_ok=True) + + try: + md = in_path.read_text(encoding="utf-8") + except UnicodeDecodeError: + md = in_path.read_text(encoding="utf-8", errors="replace") + + math_ok = math_fail = 0 + if not getattr(args, "no_math", False): + try: + from math_render import render_markdown_math + + md, math_ok, math_fail = render_markdown_math( + md, + out_md_path=out_path, + assets_rel=getattr(args, "math_assets_dir", "math_figures"), + ) + if math_ok or math_fail: + parts_m = [f"公式:{math_ok} 处已转为 PNG"] + if math_fail: + parts_m.append(f",{math_fail} 处失败已保留原文") + print("[mermaid_render] " + "".join(parts_m), file=sys.stderr) + except ImportError: + print( + "[mermaid_render] 未安装 matplotlib,跳过公式渲染(pip install matplotlib)", + file=sys.stderr, + ) + + new_md, n_ok, n_fail = render_markdown_mermaid( + md, + out_md_path=out_path, + assets_rel=args.assets_dir.strip("/\\") or "mermaid_figures", + mmdc_scale=args.mmdc_scale, + mmdc_width=args.mmdc_width, + mmdc_height=args.mmdc_height, + ) + + out_path.write_text(new_md, encoding="utf-8") + parts = [f"已写入 {out_path}(mermaid:{n_ok} 处已转为 PNG"] + if n_fail: + parts.append(f",{n_fail} 处生图失败已保留 fenced 源码") + parts.append(")") + print("".join(parts), file=sys.stderr) + if n_fail: + print( + "[mermaid_render] 已继续生成 Markdown" + + (" 并将尝试 Word" if not args.no_docx else "") + + ";请检查 Node/mmdc 或修正语法后重跑本脚本。", + file=sys.stderr, + ) + + if args.no_docx: + return 0 + + docx_path = ( + args.docx.resolve() + if args.docx is not None + else out_path.with_suffix(".docx") + ) + try_write_docx(out_path, docx_path) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.agents/skills/patent-disclosure-skill/tools/package-lock.json b/.agents/skills/patent-disclosure-skill/tools/package-lock.json new file mode 100644 index 0000000..f758cdf --- /dev/null +++ b/.agents/skills/patent-disclosure-skill/tools/package-lock.json @@ -0,0 +1,3755 @@ +{ + "name": "patent-disclosure-skill-tools", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "patent-disclosure-skill-tools", + "devDependencies": { + "@mermaid-js/mermaid-cli": "^11.4.0", + "puppeteer": "^23.1.1" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@antfu/install-pkg": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "package-manager-detector": "^1.3.0", + "tinyexec": "^1.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@braintree/sanitize-url": { + "version": "7.1.2", + "dev": true, + "license": "MIT" + }, + "node_modules/@chevrotain/cst-dts-gen": { + "version": "12.0.0", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/gast": "12.0.0", + "@chevrotain/types": "12.0.0" + } + }, + "node_modules/@chevrotain/gast": { + "version": "12.0.0", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/types": "12.0.0" + } + }, + "node_modules/@chevrotain/regexp-to-ast": { + "version": "12.0.0", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@chevrotain/types": { + "version": "12.0.0", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@chevrotain/utils": { + "version": "12.0.0", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/react": { + "version": "0.27.19", + "dev": true, + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.1.8", + "@floating-ui/utils": "^0.2.11", + "tabbable": "^6.0.0" + }, + "peerDependencies": { + "react": ">=17.0.0", + "react-dom": ">=17.0.0" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.8", + "dev": true, + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.6" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "dev": true, + "license": "MIT" + }, + "node_modules/@headlessui/react": { + "version": "2.2.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@floating-ui/react": "^0.26.16", + "@react-aria/focus": "^3.20.2", + "@react-aria/interactions": "^3.25.0", + "@tanstack/react-virtual": "^3.13.9", + "use-sync-external-store": "^1.5.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": "^18 || ^19 || ^19.0.0-rc", + "react-dom": "^18 || ^19 || ^19.0.0-rc" + } + }, + "node_modules/@headlessui/react/node_modules/@floating-ui/react": { + "version": "0.26.28", + "dev": true, + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.1.2", + "@floating-ui/utils": "^0.2.8", + "tabbable": "^6.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@headlessui/tailwindcss": { + "version": "0.2.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "tailwindcss": "^3.0 || ^4.0" + } + }, + "node_modules/@iconify/types": { + "version": "2.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/@iconify/utils": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@antfu/install-pkg": "^1.1.0", + "@iconify/types": "^2.0.0", + "mlly": "^1.8.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mermaid-js/mermaid-cli": { + "version": "11.12.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@mermaid-js/mermaid-zenuml": "^0.2.0", + "chalk": "^5.0.1", + "commander": "^14.0.0", + "import-meta-resolve": "^4.1.0", + "mermaid": "^11.0.2" + }, + "bin": { + "mmdc": "src/cli.js" + }, + "engines": { + "node": "^18.19 || >=20.0" + }, + "peerDependencies": { + "puppeteer": "^23" + } + }, + "node_modules/@mermaid-js/mermaid-zenuml": { + "version": "0.2.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@zenuml/core": "^3.35.2" + }, + "peerDependencies": { + "mermaid": "^10 || ^11" + } + }, + "node_modules/@mermaid-js/parser": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "langium": "^4.0.0" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@puppeteer/browsers": { + "version": "2.6.1", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.4.0", + "extract-zip": "^2.0.1", + "progress": "^2.0.3", + "proxy-agent": "^6.5.0", + "semver": "^7.6.3", + "tar-fs": "^3.0.6", + "unbzip2-stream": "^1.4.3", + "yargs": "^17.7.2" + }, + "bin": { + "browsers": "lib/cjs/main-cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@react-aria/focus": { + "version": "3.21.5", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@react-aria/interactions": "^3.27.1", + "@react-aria/utils": "^3.33.1", + "@react-types/shared": "^3.33.1", + "@swc/helpers": "^0.5.0", + "clsx": "^2.0.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-aria/interactions": { + "version": "3.27.1", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@react-aria/ssr": "^3.9.10", + "@react-aria/utils": "^3.33.1", + "@react-stately/flags": "^3.1.2", + "@react-types/shared": "^3.33.1", + "@swc/helpers": "^0.5.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-aria/ssr": { + "version": "3.9.10", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + }, + "engines": { + "node": ">= 12" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-aria/utils": { + "version": "3.33.1", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@react-aria/ssr": "^3.9.10", + "@react-stately/flags": "^3.1.2", + "@react-stately/utils": "^3.11.0", + "@react-types/shared": "^3.33.1", + "@swc/helpers": "^0.5.0", + "clsx": "^2.0.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-stately/flags": { + "version": "3.1.2", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + } + }, + "node_modules/@react-stately/utils": { + "version": "3.11.0", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-types/shared": { + "version": "3.33.1", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@swc/helpers": { + "version": "0.5.21", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tanstack/react-virtual": { + "version": "3.13.23", + "dev": true, + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.13.23" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.13.23", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tootallnate/quickjs-emscripten": { + "version": "0.23.0", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3": { + "version": "7.4.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/d3-axis": "*", + "@types/d3-brush": "*", + "@types/d3-chord": "*", + "@types/d3-color": "*", + "@types/d3-contour": "*", + "@types/d3-delaunay": "*", + "@types/d3-dispatch": "*", + "@types/d3-drag": "*", + "@types/d3-dsv": "*", + "@types/d3-ease": "*", + "@types/d3-fetch": "*", + "@types/d3-force": "*", + "@types/d3-format": "*", + "@types/d3-geo": "*", + "@types/d3-hierarchy": "*", + "@types/d3-interpolate": "*", + "@types/d3-path": "*", + "@types/d3-polygon": "*", + "@types/d3-quadtree": "*", + "@types/d3-random": "*", + "@types/d3-scale": "*", + "@types/d3-scale-chromatic": "*", + "@types/d3-selection": "*", + "@types/d3-shape": "*", + "@types/d3-time": "*", + "@types/d3-time-format": "*", + "@types/d3-timer": "*", + "@types/d3-transition": "*", + "@types/d3-zoom": "*" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-axis": { + "version": "3.0.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-brush": { + "version": "3.0.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-chord": { + "version": "3.0.6", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-contour": { + "version": "3.0.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.4", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-dispatch": { + "version": "3.0.7", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-dsv": { + "version": "3.0.7", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-fetch": { + "version": "3.0.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-dsv": "*" + } + }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "3.0.4", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.7", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-polygon": { + "version": "3.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-quadtree": { + "version": "3.0.6", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-random": { + "version": "3.0.3", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.1.0", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-time-format": { + "version": "4.0.3", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.5.2", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@upsetjs/venn.js": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "d3-selection": "^3.0.0", + "d3-transition": "^3.0.1" + } + }, + "node_modules/@zenuml/core": { + "version": "3.47.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@floating-ui/react": "^0.27.16", + "@headlessui/react": "^2.2.9", + "@headlessui/tailwindcss": "^0.2.2", + "antlr4": "~4.11.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "color-string": "^2.1.4", + "dompurify": "^3.3.1", + "highlight.js": "^10.7.3", + "html-to-image": "^1.11.13", + "immer": "^10.2.0", + "jotai": "^2.16.1", + "lodash": "^4.17.21", + "marked": "^4.3.0", + "pako": "^2.1.0", + "pino": "^8.21.0", + "react": "^19.2.3", + "react-dom": "^19.2.3", + "tailwind-merge": "^3.4.0", + "tailwindcss": "^3.4.19" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/antlr4": { + "version": "4.11.0", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=14" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/ast-types": { + "version": "0.13.4", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/b4a": { + "version": "1.8.0", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, + "node_modules/bare-events": { + "version": "2.8.2", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "version": "4.6.0", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.16.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-os": { + "version": "3.8.7", + "dev": true, + "license": "Apache-2.0", + "engines": { + "bare": ">=1.14.0" + } + }, + "node_modules/bare-path": { + "version": "3.0.0", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-os": "^3.0.1" + } + }, + "node_modules/bare-stream": { + "version": "2.12.0", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "streamx": "^2.25.0", + "teex": "^1.0.1" + }, + "peerDependencies": { + "bare-abort-controller": "*", + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + }, + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.4.0", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-path": "^3.0.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/basic-ftp": { + "version": "5.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chevrotain": { + "version": "12.0.0", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/cst-dts-gen": "12.0.0", + "@chevrotain/gast": "12.0.0", + "@chevrotain/regexp-to-ast": "12.0.0", + "@chevrotain/types": "12.0.0", + "@chevrotain/utils": "12.0.0" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/chevrotain-allstar": { + "version": "0.4.1", + "dev": true, + "license": "MIT", + "dependencies": { + "lodash-es": "^4.17.21" + }, + "peerDependencies": { + "chevrotain": "^12.0.0" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/chromium-bidi": { + "version": "0.11.0", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "mitt": "3.0.1", + "zod": "3.23.8" + }, + "peerDependencies": { + "devtools-protocol": "*" + } + }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-convert/node_modules/color-name": { + "version": "1.1.4", + "dev": true, + "license": "MIT" + }, + "node_modules/color-name": { + "version": "2.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.20" + } + }, + "node_modules/color-string": { + "version": "2.1.4", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/commander": { + "version": "14.0.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/confbox": { + "version": "0.1.8", + "dev": true, + "license": "MIT" + }, + "node_modules/cose-base": { + "version": "1.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "layout-base": "^1.0.0" + } + }, + "node_modules/cosmiconfig": { + "version": "9.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.1", + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/cytoscape": { + "version": "3.33.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/cytoscape-cose-bilkent": { + "version": "4.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "cose-base": "^1.0.0" + }, + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, + "node_modules/cytoscape-fcose": { + "version": "2.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "cose-base": "^2.2.0" + }, + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, + "node_modules/cytoscape-fcose/node_modules/cose-base": { + "version": "2.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "layout-base": "^2.0.0" + } + }, + "node_modules/cytoscape-fcose/node_modules/layout-base": { + "version": "2.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/d3": { + "version": "7.9.0", + "dev": true, + "license": "ISC", + "dependencies": { + "d3-array": "3", + "d3-axis": "3", + "d3-brush": "3", + "d3-chord": "3", + "d3-color": "3", + "d3-contour": "4", + "d3-delaunay": "6", + "d3-dispatch": "3", + "d3-drag": "3", + "d3-dsv": "3", + "d3-ease": "3", + "d3-fetch": "3", + "d3-force": "3", + "d3-format": "3", + "d3-geo": "3", + "d3-hierarchy": "3", + "d3-interpolate": "3", + "d3-path": "3", + "d3-polygon": "3", + "d3-quadtree": "3", + "d3-random": "3", + "d3-scale": "4", + "d3-scale-chromatic": "3", + "d3-selection": "3", + "d3-shape": "3", + "d3-time": "3", + "d3-time-format": "4", + "d3-timer": "3", + "d3-transition": "3", + "d3-zoom": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-array": { + "version": "3.2.4", + "dev": true, + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-axis": { + "version": "3.0.0", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-brush": { + "version": "3.0.0", + "dev": true, + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "3", + "d3-transition": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-chord": { + "version": "3.0.1", + "dev": true, + "license": "ISC", + "dependencies": { + "d3-path": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-contour": { + "version": "4.0.2", + "dev": true, + "license": "ISC", + "dependencies": { + "d3-array": "^3.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-delaunay": { + "version": "6.0.4", + "dev": true, + "license": "ISC", + "dependencies": { + "delaunator": "5" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "dev": true, + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv": { + "version": "3.0.1", + "dev": true, + "license": "ISC", + "dependencies": { + "commander": "7", + "iconv-lite": "0.6", + "rw": "1" + }, + "bin": { + "csv2json": "bin/dsv2json.js", + "csv2tsv": "bin/dsv2dsv.js", + "dsv2dsv": "bin/dsv2dsv.js", + "dsv2json": "bin/dsv2json.js", + "json2csv": "bin/json2dsv.js", + "json2dsv": "bin/json2dsv.js", + "json2tsv": "bin/json2dsv.js", + "tsv2csv": "bin/dsv2dsv.js", + "tsv2json": "bin/dsv2json.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv/node_modules/commander": { + "version": "7.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-fetch": { + "version": "3.0.1", + "dev": true, + "license": "ISC", + "dependencies": { + "d3-dsv": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force": { + "version": "3.0.0", + "dev": true, + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo": { + "version": "3.1.1", + "dev": true, + "license": "ISC", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-hierarchy": { + "version": "3.1.2", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "dev": true, + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-polygon": { + "version": "3.0.1", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-random": { + "version": "3.0.1", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-sankey": { + "version": "0.12.3", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "1 - 2", + "d3-shape": "^1.2.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-array": { + "version": "2.12.1", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "internmap": "^1.0.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-path": { + "version": "1.0.9", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/d3-sankey/node_modules/d3-shape": { + "version": "1.3.7", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "d3-path": "1" + } + }, + "node_modules/d3-sankey/node_modules/internmap": { + "version": "1.0.1", + "dev": true, + "license": "ISC" + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "dev": true, + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "dev": true, + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "dev": true, + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "dev": true, + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "dev": true, + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "dev": true, + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "dev": true, + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/dagre-d3-es": { + "version": "7.0.14", + "dev": true, + "license": "MIT", + "dependencies": { + "d3": "^7.9.0", + "lodash-es": "^4.17.21" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "6.0.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/dayjs": { + "version": "1.11.20", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/degenerator": { + "version": "5.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "ast-types": "^0.13.4", + "escodegen": "^2.1.0", + "esprima": "^4.0.1" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/delaunator": { + "version": "5.1.0", + "dev": true, + "license": "ISC", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, + "node_modules/devtools-protocol": { + "version": "0.0.1367902", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "dev": true, + "license": "MIT" + }, + "node_modules/dompurify": { + "version": "3.3.3", + "dev": true, + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escodegen": { + "version": "2.1.0", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/events-universal": { + "version": "1.0.1", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, + "node_modules/extract-zip": { + "version": "2.0.1", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-redact": { + "version": "3.5.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-stream": { + "version": "5.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-uri": { + "version": "6.0.5", + "dev": true, + "license": "MIT", + "dependencies": { + "basic-ftp": "^5.0.2", + "data-uri-to-buffer": "^6.0.2", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/hachure-fill": { + "version": "0.5.2", + "dev": true, + "license": "MIT" + }, + "node_modules/hasown": { + "version": "2.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/highlight.js": { + "version": "10.7.3", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/html-to-image": { + "version": "1.11.13", + "dev": true, + "license": "MIT" + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/immer": { + "version": "10.2.0", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-meta-resolve": { + "version": "4.2.0", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/ip-address": { + "version": "10.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "dev": true, + "license": "MIT" + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/jotai": { + "version": "2.19.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@babel/core": ">=7.0.0", + "@babel/template": ">=7.0.0", + "@types/react": ">=17.0.0", + "react": ">=17.0.0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "@babel/template": { + "optional": true + }, + "@types/react": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "dev": true, + "license": "MIT" + }, + "node_modules/katex": { + "version": "0.16.45", + "dev": true, + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/katex/node_modules/commander": { + "version": "8.3.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/khroma": { + "version": "2.1.0", + "dev": true + }, + "node_modules/langium": { + "version": "4.2.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@chevrotain/regexp-to-ast": "~12.0.0", + "chevrotain": "~12.0.0", + "chevrotain-allstar": "~0.4.1", + "vscode-languageserver": "~9.0.1", + "vscode-languageserver-textdocument": "~1.0.11", + "vscode-uri": "~3.1.0" + }, + "engines": { + "node": ">=20.10.0", + "npm": ">=10.2.3" + } + }, + "node_modules/layout-base": { + "version": "1.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash": { + "version": "4.18.1", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "7.18.3", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/marked": { + "version": "4.3.0", + "dev": true, + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 12" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/mermaid": { + "version": "11.14.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@braintree/sanitize-url": "^7.1.1", + "@iconify/utils": "^3.0.2", + "@mermaid-js/parser": "^1.1.0", + "@types/d3": "^7.4.3", + "@upsetjs/venn.js": "^2.0.0", + "cytoscape": "^3.33.1", + "cytoscape-cose-bilkent": "^4.1.0", + "cytoscape-fcose": "^2.2.0", + "d3": "^7.9.0", + "d3-sankey": "^0.12.3", + "dagre-d3-es": "7.0.14", + "dayjs": "^1.11.19", + "dompurify": "^3.3.1", + "katex": "^0.16.25", + "khroma": "^2.1.0", + "lodash-es": "^4.17.23", + "marked": "^16.3.0", + "roughjs": "^4.6.6", + "stylis": "^4.3.6", + "ts-dedent": "^2.2.0", + "uuid": "^11.1.0" + } + }, + "node_modules/mermaid/node_modules/marked": { + "version": "16.4.2", + "dev": true, + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mitt": { + "version": "3.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/mlly": { + "version": "1.8.2", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/netmask": { + "version": "2.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/pac-proxy-agent": { + "version": "7.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@tootallnate/quickjs-emscripten": "^0.23.0", + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "get-uri": "^6.0.1", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.6", + "pac-resolver": "^7.0.1", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/pac-resolver": { + "version": "7.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "degenerator": "^5.0.0", + "netmask": "^2.0.2" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/package-manager-detector": { + "version": "1.6.0", + "dev": true, + "license": "MIT" + }, + "node_modules/pako": { + "version": "2.1.0", + "dev": true, + "license": "(MIT AND Zlib)" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-data-parser": { + "version": "0.1.0", + "dev": true, + "license": "MIT" + }, + "node_modules/path-parse": { + "version": "1.0.7", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "dev": true, + "license": "MIT" + }, + "node_modules/pend": { + "version": "1.2.0", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pino": { + "version": "8.21.0", + "dev": true, + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0", + "fast-redact": "^3.1.1", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^1.2.0", + "pino-std-serializers": "^6.0.0", + "process-warning": "^3.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^3.7.0", + "thread-stream": "^2.6.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "1.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "readable-stream": "^4.0.0", + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "6.2.2", + "dev": true, + "license": "MIT" + }, + "node_modules/pirates": { + "version": "4.0.7", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-types": { + "version": "1.3.1", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/points-on-curve": { + "version": "0.2.0", + "dev": true, + "license": "MIT" + }, + "node_modules/points-on-path": { + "version": "0.2.1", + "dev": true, + "license": "MIT", + "dependencies": { + "path-data-parser": "0.1.0", + "points-on-curve": "0.2.0" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "dev": true, + "license": "MIT" + }, + "node_modules/process": { + "version": "0.11.10", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/process-warning": { + "version": "3.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/progress": { + "version": "2.0.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/proxy-agent": { + "version": "6.5.0", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "http-proxy-agent": "^7.0.1", + "https-proxy-agent": "^7.0.6", + "lru-cache": "^7.14.1", + "pac-proxy-agent": "^7.1.0", + "proxy-from-env": "^1.1.0", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "dev": true, + "license": "MIT" + }, + "node_modules/pump": { + "version": "3.0.4", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/puppeteer": { + "version": "23.11.1", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "2.6.1", + "chromium-bidi": "0.11.0", + "cosmiconfig": "^9.0.0", + "devtools-protocol": "0.0.1367902", + "puppeteer-core": "23.11.1", + "typed-query-selector": "^2.12.0" + }, + "bin": { + "puppeteer": "lib/cjs/puppeteer/node/cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/puppeteer-core": { + "version": "23.11.1", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "2.6.1", + "chromium-bidi": "0.11.0", + "debug": "^4.4.0", + "devtools-protocol": "0.0.1367902", + "typed-query-selector": "^2.12.0", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "dev": true, + "license": "MIT" + }, + "node_modules/react": { + "version": "19.2.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "dev": true, + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readable-stream": { + "version": "4.7.0", + "dev": true, + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/real-require": { + "version": "0.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/robust-predicates": { + "version": "3.0.3", + "dev": true, + "license": "Unlicense" + }, + "node_modules/roughjs": { + "version": "4.6.6", + "dev": true, + "license": "MIT", + "dependencies": { + "hachure-fill": "^0.5.2", + "path-data-parser": "^0.1.0", + "points-on-curve": "^0.2.0", + "points-on-path": "^0.2.1" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/rw": { + "version": "1.3.3", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "dev": true, + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.27.0", + "dev": true, + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.7", + "dev": true, + "license": "MIT", + "dependencies": { + "ip-address": "^10.0.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/sonic-boom": { + "version": "3.8.1", + "dev": true, + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/streamx": { + "version": "2.25.0", + "dev": true, + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/stylis": { + "version": "4.3.6", + "dev": true, + "license": "MIT" + }, + "node_modules/sucrase": { + "version": "3.35.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/sucrase/node_modules/commander": { + "version": "4.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tabbable": { + "version": "6.4.0", + "dev": true, + "license": "MIT" + }, + "node_modules/tailwind-merge": { + "version": "3.5.0", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tar-fs": { + "version": "3.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0", + "tar-stream": "^3.1.5" + }, + "optionalDependencies": { + "bare-fs": "^4.0.1", + "bare-path": "^3.0.0" + } + }, + "node_modules/tar-stream": { + "version": "3.1.8", + "dev": true, + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "bare-fs": "^4.5.5", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/teex": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "streamx": "^2.12.5" + } + }, + "node_modules/text-decoder": { + "version": "1.2.7", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/thread-stream": { + "version": "2.7.0", + "dev": true, + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + } + }, + "node_modules/through": { + "version": "2.3.8", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-dedent": { + "version": "2.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.10" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tslib": { + "version": "2.8.1", + "dev": true, + "license": "0BSD" + }, + "node_modules/typed-query-selector": { + "version": "2.12.1", + "dev": true, + "license": "MIT" + }, + "node_modules/ufo": { + "version": "1.6.3", + "dev": true, + "license": "MIT" + }, + "node_modules/unbzip2-stream": { + "version": "1.4.3", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^5.2.1", + "through": "^2.3.8" + } + }, + "node_modules/unbzip2-stream/node_modules/buffer": { + "version": "5.7.1", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "dev": true, + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/uuid": { + "version": "11.1.0", + "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, + "node_modules/vscode-jsonrpc": { + "version": "8.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/vscode-languageserver": { + "version": "9.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "vscode-languageserver-protocol": "3.17.5" + }, + "bin": { + "installServerIntoExtension": "bin/installServerIntoExtension" + } + }, + "node_modules/vscode-languageserver-protocol": { + "version": "3.17.5", + "dev": true, + "license": "MIT", + "dependencies": { + "vscode-jsonrpc": "8.2.0", + "vscode-languageserver-types": "3.17.5" + } + }, + "node_modules/vscode-languageserver-textdocument": { + "version": "1.0.12", + "dev": true, + "license": "MIT" + }, + "node_modules/vscode-languageserver-types": { + "version": "3.17.5", + "dev": true, + "license": "MIT" + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "dev": true, + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.20.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yauzl": { + "version": "2.10.0", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, + "node_modules/zod": { + "version": "3.23.8", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/.agents/skills/patent-disclosure-skill/tools/package.json b/.agents/skills/patent-disclosure-skill/tools/package.json new file mode 100644 index 0000000..3da82a3 --- /dev/null +++ b/.agents/skills/patent-disclosure-skill/tools/package.json @@ -0,0 +1,9 @@ +{ + "name": "patent-disclosure-skill-tools", + "private": true, + "description": "Optional local install for @mermaid-js/mermaid-cli (mmdc); speeds up mermaid_render.py", + "devDependencies": { + "@mermaid-js/mermaid-cli": "^11.4.0", + "puppeteer": "^23.1.1" + } +} diff --git a/.agents/skills/patent-disclosure-skill/tools/patent_reader/README.md b/.agents/skills/patent-disclosure-skill/tools/patent_reader/README.md new file mode 100644 index 0000000..668dd12 --- /dev/null +++ b/.agents/skills/patent-disclosure-skill/tools/patent_reader/README.md @@ -0,0 +1,67 @@ +# 专利通俗解读工具(`tools/patent_reader/`) + +阅读模式专用脚本,与交底书主流程工具(`mermaid_render.py`、`cnipa_*.py` 等)分离。 + +## 目录结构 + +| 文件 | 作用 | +|------|------| +| `common.py` | 领域路由、IPC 提示、路径与环境变量 | +| `obsidian.py` | Frontmatter、Canvas、库 bootstrap、Mermaid | +| `fetch_patent_pdf.py` | **按公开号下载全文 PDF**(固化入口;源表 `references/patent_pdf_sources.yaml`) | +| `extract_patent_text.py` | 全文/PDF 取证 | +| `figure_extract.py` | 专利 caption+bbox 裁切 + 质量门(被 extract 调用) | +| `extract_patent_figures.py` | PDF 附图 CLI → manifest(insert/placeholder) | +| `build_context_anchor.py` | 技术落地线索包 | +| `build_claim_mermaid.py` | 权利要求 mermaid | +| `validate_claim_tree.py` | 权项树校验/规范化;Agent 校对后 `--require-review` | +| `build_patent_canvas.py` | JSON Canvas 图谱 | +| `lint_patent_note.py` | 笔记结构校验 | +| `validate_public_clues.py` | 附录 B 线索校验 + 置信度筛选(默认最多 3 条) | +| `clue_vault.py` | `clues/` 落地、附录/旁注/Canvas;脚本 HTTP 仅作降级 | +| `materialize_public_clues.py` | 对已有解读补跑线索落地;`--fetch-fallback` 才脚本抓取 | +| `check_obsidian_env.py` | 对话开始前探测库路径(**强烈推荐**有库;可降级 outputs) | +| `link_patent_notes.py` | 交付后常问:库内专利关联(规则+模型分)与全局 `_专利关联.canvas` | +| `desc_paragraphs.py` | 说明书 `[000N]` 解析、`*_说明书段落.md` 锚点、引用改写为可悬停 wikilink | +| `write_patent_obsidian_note.py` | 入库 + 自动 bootstrap;第三节「本项新增」优先 `claim_deltas.json`(Agent);默认拷贝官方 PDF 到 `source/`(`--no-copy-source-pdf` 关闭) | +| `setup_obsidian_vault.py` | 与入库等价的库初始化(开发/排障用;用户流程勿单独强调) | +| `requirements.txt` | 可选依赖(`pymupdf`) | + +库内模板:`assets/obsidian/`(CSS、Bases、索引页)。 + +流程见 **`prompts/patent_plain_reader.md`**。 + +## 快速开始 + +```bash +pip install -r tools/patent_reader/requirements.txt + +# 先探测库路径(强烈推荐已装 Obsidian 并开库) +python tools/patent_reader/check_obsidian_env.py --auto-accept + +# 仅公开号:先下载 PDF(Google Patents 页 → CDN;见 patent_pdf_sources.yaml) +python tools/patent_reader/fetch_patent_pdf.py \ + --pub CN119961390A -o tmp/patent_reader/demo + +python tools/patent_reader/extract_patent_text.py \ + -i tmp/patent_reader/demo/source/CN119961390A.pdf \ + -o tmp/patent_reader/demo --pub-number CN119961390A +# 或本地样例文本: +# python tools/patent_reader/extract_patent_text.py \ +# -i tests/fixtures/patent_reader_sample.txt \ +# -o tmp/patent_reader/demo --pub-number CN999999999B +``` + +入库用 `write_patent_obsidian_note.py`(内含 bootstrap);勿再单独要求用户跑 `setup_obsidian_vault.py`。 + +## 环境变量 + +| 变量 | 说明 | +|------|------| +| `PATENT_READER_OBSIDIAN_VAULT` | Obsidian 库根(兼容 `PATENT_DISCLOSURE_OBSIDIAN_VAULT`);也可用 `check_obsidian_env.py --set` 持久化到 `~/.patent-disclosure-skill/obsidian_vault.txt` | +| `PATENT_READER_PAPERS_DIR` | 库内目录,默认 `Research/Patents` | +| `PATENT_READER_OUTPUT_DIR` | 未配置库时输出目录 | +| `PATENT_READER_GLOSSARY_DIR` | 术语目录,默认 `Research/术语` | + +交付后可选社区插件引导:`prompts/obsidian_plugin_guide.md`。 +关系图配色与插件说明:`docs/obsidian-setup-guide.md`(原生 Groups,无需插件)。 diff --git a/.agents/skills/patent-disclosure-skill/tools/patent_reader/__init__.py b/.agents/skills/patent-disclosure-skill/tools/patent_reader/__init__.py new file mode 100644 index 0000000..c878cf8 --- /dev/null +++ b/.agents/skills/patent-disclosure-skill/tools/patent_reader/__init__.py @@ -0,0 +1 @@ +"""专利通俗解读工具包(tools/patent_reader/)。""" diff --git a/.agents/skills/patent-disclosure-skill/tools/patent_reader/build_claim_mermaid.py b/.agents/skills/patent-disclosure-skill/tools/patent_reader/build_claim_mermaid.py new file mode 100644 index 0000000..fe92105 --- /dev/null +++ b/.agents/skills/patent-disclosure-skill/tools/patent_reader/build_claim_mermaid.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python3 +""" +由 claim_tree.json 生成 mermaid 权利要求树(独立权=子图)。 + +用法: + python tools/patent_reader/build_claim_mermaid.py --claim-tree claim_tree.json --pub-number CNxxx -o claim_mermaid.mmd +""" +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +try: + from obsidian import claim_tree_to_mermaid +except ImportError: + from tools.patent_reader.obsidian import claim_tree_to_mermaid + + +def main(argv: list[str] | None = None) -> int: + if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--claim-tree", required=True, type=Path) + ap.add_argument("--pub-number", default="") + ap.add_argument("-o", "--output", required=True, type=Path) + args = ap.parse_args(argv) + + tree = json.loads(args.claim_tree.read_text(encoding="utf-8")) + mmd = claim_tree_to_mermaid(tree, args.pub_number) + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(mmd + "\n", encoding="utf-8") + print(f"OK mermaid lines={len(mmd.splitlines())}") + print(f"MERMAID: {args.output.resolve()}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.agents/skills/patent-disclosure-skill/tools/patent_reader/build_context_anchor.py b/.agents/skills/patent-disclosure-skill/tools/patent_reader/build_context_anchor.py new file mode 100644 index 0000000..596aeb8 --- /dev/null +++ b/.agents/skills/patent-disclosure-skill/tools/patent_reader/build_context_anchor.py @@ -0,0 +1,229 @@ +#!/usr/bin/env python3 +""" +为专利通俗解读生成「技术落地线索」包(零付费 API): + - 专利内:实施例、背景、术语 + - 离线:IPC 行业坐标(references/ipc_application_hints.yaml) + - WebSearch 查询模板(供 Agent 执行) + - Obsidian 导航建议 + +用法: + python tools/patent_reader/build_context_anchor.py -w tmp/patent_reader/RUN +""" +from __future__ import annotations + +import argparse +import json +import re +import sys +from pathlib import Path + +try: + from common import ( + resolve_domain, + resolve_ipc_hints, + runtime_config, + slugify_pub, + ) +except ImportError: + from tools.patent_reader.common import ( + resolve_domain, + resolve_ipc_hints, + runtime_config, + slugify_pub, + ) + + +def load_json(path: Path) -> dict: + return json.loads(path.read_text(encoding="utf-8")) + + +def title_from_bundle(bundle: dict, manifest: dict) -> str: + for sec in bundle.get("sections_preview") or []: + if sec.get("kind") == "abstract": + return manifest.get("pub_number", "") + return manifest.get("pub_number", "patent") + + +def read_abstract(bundle_path: Path) -> str: + workdir = bundle_path.parent + jsonl = workdir / "raw_sections.jsonl" + if not jsonl.is_file(): + return "" + for line in jsonl.read_text(encoding="utf-8").splitlines(): + if not line.strip(): + continue + row = json.loads(line) + if row.get("kind") == "abstract": + return row.get("text", "")[:500] + return "" + + +def build_web_search_queries( + pub: str, + assignees: list[str], + title_hint: str, + ipc_hint: dict, + claim_keywords: list[str], +) -> list[dict]: + queries: list[dict] = [] + assignee = assignees[0] if assignees else "" + kw = " ".join(claim_keywords[:3]) if claim_keywords else title_hint[:40] + + if assignee and kw: + queries.append( + { + "purpose": "官网或新闻中的产品/方案线索", + "query": f'"{assignee}" {kw} (产品 OR 解决方案 OR 发布会)', + "priority": 1, + } + ) + # 尝试猜官网(仅作搜索提示,不爬) + short = re.sub(r"(股份|有限|公司|集团|科技|技术).*$", "", assignee)[:12] + if short: + queries.append( + { + "purpose": "限定企业站点", + "query": f"{short} {kw} site:com OR site:cn", + "priority": 2, + } + ) + + for hint in (ipc_hint.get("search_hints") or [])[:2]: + queries.append( + { + "purpose": f"行业语境:{ipc_hint.get('industry', '')}", + "query": f"{kw} {hint}", + "priority": 3, + } + ) + + queries.append( + { + "purpose": "同申请人其他专利(国知局公开信息)", + "query": f"{assignee or pub} 专利 {kw}", + "priority": 4, + "tool": "cnipa_epub_search.py", + "note": "可用 cnipa_epub_search.py 分词检索,合并 EPUB_HITS_JSON", + } + ) + return queries[:6] + + +def claim_keyword_tokens(bundle: dict) -> list[str]: + stop = { + "一种", "一種", "其特征", "特徵", "在于", "在於", "所述", "包括", "其中", + "权利要求", "方法", "系统", "装置", "步骤", "用于", "以及", "或者", + "根据", "通过", "进行", "具有", "配置", "对应", "以上", "以下", + } + tokens: list[str] = [] + for c in bundle.get("claims") or []: + if not c.get("is_independent"): + continue + text = c.get("text", "") + # 优先 4–8 字块,过滤半截功能词 + for m in re.finditer(r"[\u4e00-\u9fff]{4,8}", text): + w = m.group(0) + if any(s in w for s in ("其特征", "根据权利要求")): + continue + if w in stop or w[:2] in stop: + continue + if w not in tokens: + tokens.append(w) + if len(tokens) >= 6: + break + for m in re.finditer(r"[\u4e00-\u9fff]{2,3}", text): + w = m.group(0) + if w in stop or w in tokens: + continue + tokens.append(w) + break + return tokens[:6] + + +def obsidian_navigation(domain: str, pub: str, cfg: dict) -> dict: + papers = cfg["papers_dir"] + slug = slugify_pub(pub) + return { + "vault_root": cfg["obsidian_vault"] or "", + "note_dir": f"{papers}/{domain}/{slug}", + "moc_global": f"{papers}/_专利解读索引", + "moc_domain": f"{papers}/{domain}/_领域索引", + "wikilinks": [ + f"[[{papers}/_专利解读索引|专利解读索引]]", + f"[[{papers}/{domain}/_领域索引|{domain}领域索引]]", + ], + "frontmatter_suggest": { + "domain": domain, + "pub_number": pub, + }, + } + + +def main(argv: list[str] | None = None) -> int: + if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("-w", "--workdir", required=True, type=Path, help="extract 产出目录") + ap.add_argument("-o", "--output", default="", help="默认 workdir/context_anchor.json") + args = ap.parse_args(argv) + + workdir = args.workdir.resolve() + manifest_path = workdir / "source_manifest.json" + bundle_path = workdir / "synthesis_bundle.json" + if not manifest_path.is_file() or not bundle_path.is_file(): + print("错误:请先运行 extract_patent_text.py", file=sys.stderr) + return 1 + + manifest = load_json(manifest_path) + bundle = load_json(bundle_path) + pub = manifest.get("pub_number", "") + assignees = manifest.get("assignees") or bundle.get("assignees") or [] + ipc_codes = manifest.get("ipc_codes") or bundle.get("ipc_codes") or [] + abstract = read_abstract(bundle_path) + text_for_match = abstract + "\n" + " ".join(bundle.get("background_snippets") or []) + + ipc_hint = resolve_ipc_hints(text_for_match, ipc_codes) + domain = resolve_domain(text_for_match, ipc_codes[0] if ipc_codes else "") + claim_kw = claim_keyword_tokens(bundle) + cfg = runtime_config() + + anchor = { + "pub_number": pub, + "domain": domain, + "assignees": assignees, + "ipc_codes": ipc_codes, + "patent_internal": { + "embodiments": bundle.get("embodiments") or [], + "background_snippets": bundle.get("background_snippets") or [], + "glossary_candidates": bundle.get("glossary_candidates") or [], + }, + "ipc_application": { + "matched_by": ipc_hint.get("matched_by"), + "ipc_prefix": ipc_hint.get("ipc_prefix"), + "industry": ipc_hint.get("industry"), + "typical_modules": ipc_hint.get("typical_modules") or [], + "user_scenarios": ipc_hint.get("user_scenarios") or [], + }, + "web_search_queries": build_web_search_queries( + pub, assignees, abstract[:80], ipc_hint, claim_kw + ), + "obsidian": obsidian_navigation(domain, pub, cfg), + "writing_guide": { + "section_9": "九、技术应用场景:必须锚定 desc_id/实施例/背景句,标置信度「高」", + "appendix_a": "附录 A IPC 行业坐标:来自 ipc_application,标「离线词表」", + "appendix_b": "附录 B:检索后 Agent 打开 URL 写 summary(主路径);入库只落地 clues/;脚本抓取仅降级", + "forbidden": "不得将推测线索写入主结论章节(一至八)", + }, + } + + out_path = Path(args.output) if args.output else workdir / "context_anchor.json" + out_path.write_text(json.dumps(anchor, ensure_ascii=False, indent=2), encoding="utf-8") + + print(f"OK domain={domain} ipc={ipc_hint.get('ipc_prefix')} queries={len(anchor['web_search_queries'])}") + print(f"CONTEXT_ANCHOR: {out_path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.agents/skills/patent-disclosure-skill/tools/patent_reader/build_patent_canvas.py b/.agents/skills/patent-disclosure-skill/tools/patent_reader/build_patent_canvas.py new file mode 100644 index 0000000..565c6fe --- /dev/null +++ b/.agents/skills/patent-disclosure-skill/tools/patent_reader/build_patent_canvas.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +""" +生成专利解读 JSON Canvas(公开号中心,连相关笔记与术语)。 + +用法: + python tools/patent_reader/build_patent_canvas.py \\ + --vault /path/to/vault \\ + --note-rel Research/Patents/领域/CNxxx/CNxxx_解读_20260721.md \\ + --manifest source_manifest.json \\ + [--bundle synthesis_bundle.json] [--claim-tree claim_tree.json] \\ + [--workdir tmp/patent_reader/RUN] \\ + -o Research/Patents/领域/CNxxx/CNxxx_图谱.canvas +""" +from __future__ import annotations + +import argparse +import json +import re +import sys +from pathlib import Path + +try: + from common import optional_path, resolve_domain, runtime_config + from obsidian import build_canvas, harvest_claim_summaries_from_note, scan_vault_related + from write_patent_obsidian_note import ( + harvest_glossary_from_note, + harvest_narrative_from_note, + merge_glossary_candidates, + ) +except ImportError: + from tools.patent_reader.common import optional_path, resolve_domain, runtime_config + from tools.patent_reader.obsidian import ( + build_canvas, + harvest_claim_summaries_from_note, + scan_vault_related, + ) + from tools.patent_reader.write_patent_obsidian_note import ( + harvest_glossary_from_note, + harvest_narrative_from_note, + merge_glossary_candidates, + ) + + +def main(argv: list[str] | None = None) -> int: + if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--vault", default="", help="Obsidian 库根;默认 PATENT_READER_OBSIDIAN_VAULT") + ap.add_argument("--note-rel", required=True, help="相对库根的笔记路径") + ap.add_argument("--manifest", required=True, type=Path) + ap.add_argument("--bundle", default=None, type=optional_path) + ap.add_argument("--claim-tree", default=None, type=optional_path) + ap.add_argument("--context-anchor", default=None, type=optional_path) + ap.add_argument("--workdir", default=None, type=optional_path) + ap.add_argument("-o", "--output", required=True, type=Path) + ap.add_argument("--title", default="") + args = ap.parse_args(argv) + + cfg = runtime_config() + vault_s = args.vault.strip() or cfg["obsidian_vault"] + if not vault_s: + print("错误:未指定 --vault 且未设置 PATENT_READER_OBSIDIAN_VAULT", file=sys.stderr) + return 1 + vault = Path(vault_s).resolve() + manifest = json.loads(args.manifest.read_text(encoding="utf-8")) + pub = manifest.get("pub_number") or "patent" + assignees = manifest.get("assignees") or [] + + note_path = vault / args.note_rel.replace("\\", "/") + note_text = note_path.read_text(encoding="utf-8") if note_path.is_file() else "" + title = args.title.strip() + if not title and note_text: + hm = re.search(r"^#\s+(.+)$", note_text, re.M) + if hm: + title = hm.group(1).strip() + if not title: + title = f"专利解读 {pub}" + + glossary: list = [] + if args.bundle and args.bundle.is_file(): + bundle = json.loads(args.bundle.read_text(encoding="utf-8")) + glossary = bundle.get("glossary_candidates") or [] + if note_text: + glossary = merge_glossary_candidates(glossary, harvest_glossary_from_note(note_text)) + + anchor: dict = {} + if args.context_anchor and args.context_anchor.is_file(): + anchor = json.loads(args.context_anchor.read_text(encoding="utf-8")) + elif args.workdir: + ca = args.workdir / "context_anchor.json" + if ca.is_file(): + anchor = json.loads(ca.read_text(encoding="utf-8")) + + domain = "" + dm = re.search(r"^domain:\s*(.+)$", note_text, re.M) if note_text else None + if dm: + domain = dm.group(1).strip().strip("\"'") + if not domain: + ipc0 = "" + codes = anchor.get("ipc_codes") or manifest.get("ipc_codes") or [] + if codes: + ipc0 = str(codes[0]) + domain = resolve_domain(note_text[:2000] if note_text else title, ipc0) + + related = scan_vault_related( + vault, cfg["papers_dir"], pub, assignees, domain=domain + ) + + claim_tree = None + ct = args.claim_tree + if (not ct or not ct.is_file()) and args.workdir: + ct = args.workdir / "claim_tree.json" + if ct and ct.is_file(): + claim_tree = json.loads(ct.read_text(encoding="utf-8")) + + figure_rels: list[str] = [] + images = note_path.parent / "images" + if images.is_dir(): + note_dir_rel = str(note_path.parent.relative_to(vault)).replace("\\", "/") + for img in sorted(images.glob("*.png"))[:4]: + figure_rels.append(f"{note_dir_rel}/images/{img.name}") + + narrative = harvest_narrative_from_note(note_text) if note_text else {} + claim_summaries = ( + harvest_claim_summaries_from_note(note_text) if note_text else {} + ) + + canvas = build_canvas( + vault=vault, + papers_dir=cfg["papers_dir"], + note_rel_path=args.note_rel.replace("\\", "/"), + pub=pub, + title=title, + related=related, + glossary_terms=glossary, + glossary_dir=cfg["glossary_dir"], + create_glossary_stubs=True, + meta={ + "domain": domain, + "ipc": anchor.get("ipc_codes") or manifest.get("ipc_codes") or "", + "assignees": assignees or anchor.get("assignees") or [], + "evidence_scope": manifest.get("evidence_scope") or "", + }, + claim_tree=claim_tree, + claim_summaries=claim_summaries, + figure_rels=figure_rels, + narrative=narrative, + ) + canvas.pop("glossary_resolved", None) + + out = args.output + if not out.is_absolute(): + out = vault / out + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text(json.dumps(canvas, ensure_ascii=False, indent=2), encoding="utf-8") + print(f"OK nodes={len(canvas['nodes'])} edges={len(canvas['edges'])}") + print(f"CANVAS: {out}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.agents/skills/patent-disclosure-skill/tools/patent_reader/check_obsidian_env.py b/.agents/skills/patent-disclosure-skill/tools/patent_reader/check_obsidian_env.py new file mode 100644 index 0000000..7b0f445 --- /dev/null +++ b/.agents/skills/patent-disclosure-skill/tools/patent_reader/check_obsidian_env.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +""" +阅读模式对话开始前:探测 Obsidian 是否安装、默认/已登记库路径。 + +不强制依赖 Obsidian——无库时仍可写入 outputs/patent_reader/。 +有库时自动解析 PATENT_READER_OBSIDIAN_VAULT,发挥索引/Canvas/术语网最大效果。 + +用法: + python tools/patent_reader/check_obsidian_env.py + python tools/patent_reader/check_obsidian_env.py --json + python tools/patent_reader/check_obsidian_env.py --set "C:\\Users\\you\\Documents\\Obsidian Vault" + python tools/patent_reader/check_obsidian_env.py --set "D:\\Vault" --setx # 顺便写用户级环境变量(Windows) + python tools/patent_reader/check_obsidian_env.py --auto-accept # 唯一/当前打开库则写入持久化 +""" +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +from pathlib import Path + +try: + from common import ( + probe_obsidian_environment, + resolve_obsidian_vault, + write_persisted_vault, + ) +except ImportError: + from tools.patent_reader.common import ( + probe_obsidian_environment, + resolve_obsidian_vault, + write_persisted_vault, + ) + + +def _print_human(report: dict) -> None: + print(f"STATUS: {report['status']}") + print(f"OBSIDIAN_REQUIRED: false # 可不装;装了效果更好") + print(f"OBSIDIAN_INSTALLED: {report['obsidian_installed']}") + resolved = report.get("resolved") or {} + vault = resolved.get("vault") or "" + if vault: + print(f"VAULT: {vault}") + print(f"VAULT_SOURCE: {resolved.get('source')}") + print(f"NEEDS_USER_INPUT: false") + else: + print("VAULT: (未配置)") + print("NEEDS_USER_INPUT: true") + msg = resolved.get("message") or report.get("status") + print(f"MESSAGE: {msg}") + cands = resolved.get("candidates") or report.get("vaults") or [] + if cands: + print("CANDIDATES:") + for c in cands[:8]: + print(f" - {c.get('path')} (open={c.get('open')}, source={c.get('source')})") + defaults = resolved.get("suggested_defaults") or [] + if defaults: + print("SUGGESTED_DEFAULTS:") + for d in defaults: + print(f" - {d}") + print("ACTION: 请用户提供 Obsidian 库根目录,然后执行:") + print(' python tools/patent_reader/check_obsidian_env.py --set "库路径"') + if sys.platform == "win32": + print(" # 当前 PowerShell 会话:") + print(' $env:PATENT_READER_OBSIDIAN_VAULT = "库路径"') + else: + print(' export PATENT_READER_OBSIDIAN_VAULT="库路径"') + print(f"PERSISTED_CONFIG: {report.get('persisted_config')}") + print(f"ENV_VAR: {report.get('env_var')}") + + +def _set_user_env_windows(name: str, value: str) -> bool: + try: + r = subprocess.run( + ["setx", name, value], + capture_output=True, + text=True, + timeout=30, + ) + return r.returncode == 0 + except (OSError, subprocess.TimeoutExpired): + return False + + +def main(argv: list[str] | None = None) -> int: + if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--json", action="store_true", help="输出 JSON 报告") + ap.add_argument( + "--set", + default="", + help="将库路径写入持久化配置,并设置当前进程环境变量", + ) + ap.add_argument( + "--setx", + action="store_true", + help="Windows 下额外 setx 到用户环境变量(新开终端生效)", + ) + ap.add_argument( + "--auto-accept", + action="store_true", + help="若已唯一解析到库路径,写入持久化配置", + ) + ap.add_argument( + "--require-vault", + action="store_true", + help="无库路径时退出码 2(默认仅提示,退出 0;强制入库场景可用)", + ) + args = ap.parse_args(argv) + + if args.set.strip(): + vault_path = Path(args.set.strip()).expanduser() + if not vault_path.exists(): + print(f"警告:路径尚不存在,将创建:{vault_path}", file=sys.stderr) + vault_path.mkdir(parents=True, exist_ok=True) + cfg = write_persisted_vault(vault_path) + os.environ["PATENT_READER_OBSIDIAN_VAULT"] = str(vault_path.resolve()) + print(f"OK set vault={vault_path.resolve()}") + print(f"PERSISTED: {cfg}") + print(f"ENV_SET: PATENT_READER_OBSIDIAN_VAULT={vault_path.resolve()}") + if args.setx and sys.platform == "win32": + ok = _set_user_env_windows( + "PATENT_READER_OBSIDIAN_VAULT", str(vault_path.resolve()) + ) + print(f"SETX: {'ok' if ok else 'failed'}") + if ok: + print("注意:setx 仅对新开终端生效;当前会话已用 ENV_SET。") + elif args.setx: + print("SETX: skipped (非 Windows)", file=sys.stderr) + report = probe_obsidian_environment() + if args.json: + print(json.dumps(report, ensure_ascii=False, indent=2)) + return 0 + + if args.auto_accept: + resolved = resolve_obsidian_vault() + if resolved.get("vault") and not resolved.get("needs_user_input"): + cfg = write_persisted_vault(resolved["vault"]) + os.environ["PATENT_READER_OBSIDIAN_VAULT"] = resolved["vault"] + print(f"OK auto-accept vault={resolved['vault']}") + print(f"PERSISTED: {cfg}") + else: + print("AUTO_ACCEPT: skipped (无唯一可解析库路径)", file=sys.stderr) + report = probe_obsidian_environment() + if args.json: + print(json.dumps(report, ensure_ascii=False, indent=2)) + else: + _print_human(report) + return 2 if args.require_vault else 0 + + report = probe_obsidian_environment() + if args.json: + print(json.dumps(report, ensure_ascii=False, indent=2)) + else: + _print_human(report) + + if args.require_vault and ( + report["status"] != "ready" or not (report.get("resolved") or {}).get("vault") + ): + return 2 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.agents/skills/patent-disclosure-skill/tools/patent_reader/clue_vault.py b/.agents/skills/patent-disclosure-skill/tools/patent_reader/clue_vault.py new file mode 100644 index 0000000..b217138 --- /dev/null +++ b/.agents/skills/patent-disclosure-skill/tools/patent_reader/clue_vault.py @@ -0,0 +1,1704 @@ +"""公开检索线索:筛选、抓取、clues/ 入库、附录 B、旁注与 Canvas 数据。""" +from __future__ import annotations + +import json +import re +from datetime import date +from html import unescape +from pathlib import Path +from urllib.error import HTTPError, URLError +from urllib.parse import urlparse +from urllib.request import Request, urlopen + +DEFAULT_MAX_CLUES = 3 + +CONF_RANK = { + "高": 3, + "high": 3, + "中": 2, + "medium": 2, + "med": 2, + "mid": 2, + "低": 1, + "low": 1, +} + +APPENDIX_B_RE = re.compile( + r"(###\s*B\.\s*公开检索线索[\s\S]*?)(?=^##\s+|\Z)", + re.M, +) +SECTION4_RE = re.compile( + r"(^##\s*四、独立权利要求精读[\s\S]*?)(?=^##\s*五、|\Z)", + re.M, +) +SECTION6_RE = re.compile( + r"(^##\s*六、[\s\S]*?)(?=^##\s*七、|\Z)", + re.M, +) + + +def as_clues(raw) -> list[dict]: + if isinstance(raw, list): + return [c for c in raw if isinstance(c, dict)] + if isinstance(raw, dict): + clues = raw.get("clues") or raw.get("items") or [] + if isinstance(clues, list): + return [c for c in clues if isinstance(c, dict)] + return [] + + +def _conf_rank(conf: str) -> int: + s = (conf or "").strip() + if not s: + return 0 + if s in CONF_RANK: + return CONF_RANK[s] + return CONF_RANK.get(s.lower(), 0) + + +def normalize_clue(c: dict, *, index: int = 0) -> dict: + title = (c.get("title") or c.get("name") or "").strip() + url = (c.get("url") or c.get("link") or "").strip() + conf = (c.get("confidence") or "").strip() or "中" + reason = ( + c.get("reason") or c.get("rationale") or c.get("note") or "" + ).strip() + out = { + **c, + "title": title, + "url": url, + "confidence": conf, + "reason": reason, + "clue_id": c.get("clue_id") or f"clue-{index + 1:02d}", + } + return out + + +def filter_clues( + clues: list[dict], + *, + max_keep: int = DEFAULT_MAX_CLUES, +) -> tuple[list[dict], list[dict]]: + """按置信度高→低排序,默认最多保留 max_keep 条。""" + if max_keep <= 0: + return [], list(clues) + ranked: list[tuple[int, int, dict]] = [] + for i, c in enumerate(clues): + n = normalize_clue(c, index=i) + ranked.append((_conf_rank(n["confidence"]), -i, n)) + ranked.sort(key=lambda t: (-t[0], -t[1])) + kept = [t[2] for t in ranked[:max_keep]] + dropped = [t[2] for t in ranked[max_keep:]] + # 重编号 clue_id + for i, c in enumerate(kept): + c["clue_id"] = f"clue-{i + 1:02d}" + return kept, dropped + + +def clue_filename(title: str, index: int) -> str: + base = re.sub(r"[^\w\u4e00-\u9fff]+", "-", (title or "线索").strip()) + base = re.sub(r"-{2,}", "-", base).strip("-")[:36] or "线索" + return f"{index + 1:02d}-{base}.md" + + +def _strip_html(html: str) -> str: + text = re.sub(r"(?is)<script[^>]*>.*?</script>", " ", html) + text = re.sub(r"(?is)<style[^>]*>.*?</style>", " ", text) + text = re.sub(r"(?is)<noscript[^>]*>.*?</noscript>", " ", text) + text = re.sub(r"(?is)<!--.*?-->", " ", text) + text = re.sub(r"(?is)<br\s*/?>", "\n", text) + text = re.sub(r"(?is)</p>", "\n\n", text) + text = re.sub(r"(?is)<[^>]+>", " ", text) + text = unescape(text) + text = re.sub(r"[ \t]+\n", "\n", text) + text = re.sub(r"\n{3,}", "\n\n", text) + text = re.sub(r"[ \t]{2,}", " ", text) + return text.strip() + + +# 企业站常见导航/页脚噪音(脚本降级抓取时常混入) +_NAV_NOISE = { + "oa", + "srm", + "邮箱", + "魔学院", + "主页", + "首页", + "产品展示", + "新闻中心", + "企业动态", + "集团要闻", + "行业资讯", + "通知公告", + "友情链接", + "联系方式", + "分享到", + "基膜", + "涂覆", + "copyright", + "回到顶部", + "新闻频道", + "财经频道", + "城市频道", + "公司新闻", + "名企名片", + "招贤纳士", + "锂电世界", + "我爱电车网", + "石墨烯", + "燃料电池", + "海融网", + "abec", + "放大", + "缩小", + "扫描到手机", + "点击:", + "来源:", + "作者:", +} + + +def _is_formula_glyph_frag(s: str) -> bool: + """仅 ASCII/数字/下标短片段视为化学式竖排拆字(勿吞中文导航词)。""" + return bool(re.fullmatch(r"[A-Za-z0-9²³₀-₉]{1,2}", s)) + + +def _join_broken_glyph_lines(lines: list[str]) -> list[str]: + """把 Al / 2 / O / 3 这类竖排拆字拼回 Al2O3。""" + out: list[str] = [] + buf: list[str] = [] + + def flush() -> None: + if buf: + out.append("".join(buf)) + buf.clear() + + for raw in lines: + s = raw.strip() + if not s: + flush() + continue + if _is_formula_glyph_frag(s): + buf.append(s) + continue + if buf: + # 拆字后紧跟公式续接(如 /勃姆石涂覆) + if s.startswith("/") or re.fullmatch(r"[A-Za-z0-9²³₀-₉/.\-]+", s): + buf.append(s) + flush() + continue + # 中文续接且缓冲已是化学式前缀 + joined = "".join(buf) + if re.search(r"[A-Za-z]\d", joined) and len(s) <= 16: + buf.append(s) + flush() + continue + flush() + out.append(s) + flush() + return out + + +def sanitize_clue_summary( + text: str, + *, + title: str = "", + reason: str = "", + max_chars: int = 700, +) -> str: + """清洗脚本/脏抓取摘要:去导航、拼拆字、去掉会触发引用块的 > 行,整理为可读短文。""" + raw = (text or "").replace("\r\n", "\n").replace("\r", "\n").strip() + if not raw: + return "" + + # 已是干净要点列表 / 短文:勿再拼成一行 + if re.search(r"(?m)^页面要点:\s*$", raw) and re.search(r"(?m)^-\s+\S", raw): + out = format_summary_for_markdown(raw) + if len(out) > max_chars: + out = out[: max_chars - 1].rstrip() + "…" + return out + if ( + raw.count("\n") >= 2 + and not re.search(r"(?m)^>{1,}\s*$", raw) + and not re.search(r"(?m)^(OA|SRM|主页|产品展示)\s*$", raw) + and sum(1 for ln in raw.splitlines() if 0 < len(ln.strip()) <= 2) <= 2 + ): + out = format_summary_for_markdown(raw) + if len(out) > max_chars: + out = out[: max_chars - 1].rstrip() + "…" + return out + + lines = [ln.strip() for ln in raw.split("\n")] + lines = _join_broken_glyph_lines(lines) + + bullets: list[str] = [] + body: list[str] = [] + i = 0 + while i < len(lines): + ln = lines[i] + i += 1 + if not ln: + continue + low = ln.lower() + if low in _NAV_NOISE or ln in _NAV_NOISE: + continue + if re.match(r"^(copyright|苏icp|京icp|邮编\s*:|地址\s*:|邮箱\s*:)", ln, re.I): + continue + if re.search(r"@|\.com\b", ln) and len(ln) < 80: + continue + # 单独的 >> 行:下一非空行视为卖点 + if re.fullmatch(r">+", ln): + while i < len(lines) and not lines[i].strip(): + i += 1 + if i < len(lines): + bit = lines[i].strip() + i += 1 + bit = re.sub(r"^>+\s*", "", bit) + if len(bit) >= 4 and bit not in _NAV_NOISE and bit.lower() not in _NAV_NOISE: + bullets.append(bit) + continue + # 同行面包屑 / 卖点:>> xxx + m = re.match(r"^>{1,}\s*(.+)$", ln) + if m: + bit = m.group(1).strip() + if bit and bit not in _NAV_NOISE and bit.lower() not in _NAV_NOISE: + if len(bit) >= 4: + bullets.append(bit) + continue + # 重复标题噪音 + if title and ln.replace(" ", "") == title.replace(" ", ""): + continue + if "有限公司_有限公司" in ln or (ln.count("_") >= 2 and "公司" in ln): + continue + if len(ln) <= 1: + continue + # 菜单式短词(无句号、偏导航) + if len(ln) <= 16 and not re.search(r"[。!?;,.!?]", ln) and ( + ln.endswith("隔膜") or ln in {"基膜", "涂覆"} + ): + continue + body.append(ln) + + # 脏页特征:短行过多或卖点较多 → 优先要点列表 + short_ratio = 0.0 + if lines: + short_ratio = sum(1 for x in lines if 0 < len(x.strip()) <= 2) / max( + len(lines), 1 + ) + + parts: list[str] = [] + prefer_bullets = bool(bullets) and ( + short_ratio > 0.08 or len(bullets) >= 3 or len(body) < 4 + ) + if prefer_bullets: + parts.append("页面要点:") + for b in list(dict.fromkeys(bullets))[:8]: + parts.append(f"- {b}") + else: + # 正常正文:合并为段落;过长时截断 + para = re.sub(r"\s+", " ", " ".join(body)).strip() + # 去掉残留面包屑符号 + para = re.sub(r"\s*>+\s*", " ", para) + para = re.sub(r"\s{2,}", " ", para).strip() + # 新闻站:从首个「实质句」切开(去掉频道栏粘连) + m_lead = re.search( + r"((?:\d{4}-\d{2}-\d{2}|\d{1,2}月\d{1,2}日).{20,})", + para, + ) + if not m_lead: + m_lead = re.search( + r"((?:获悉|讯(|报道)|将携|掌握|展示).{30,})", + para, + ) + if m_lead and m_lead.start() > 12: + para = m_lead.group(1).strip() + para = re.sub( + r"(点击:?\s*|扫描到手机\s*|放大\s*|缩小\s*|回到顶部\s*)", + "", + para, + ) + para = re.sub(r"\s{2,}", " ", para).strip() + if para: + parts.append(para) + elif bullets: + parts.append("页面要点:") + for b in list(dict.fromkeys(bullets))[:8]: + parts.append(f"- {b}") + + out = "\n".join(parts).strip() + if not out and reason: + out = f"(页面正文未能干净抽取,仅保留检索理由){reason[:160]}" + if len(out) > max_chars: + out = out[: max_chars - 1].rstrip() + "…" + return out + + +def format_summary_for_markdown(summary: str) -> str: + """写入笔记时避免以 > 开头的行被 Obsidian 当成引用块。""" + lines_out: list[str] = [] + for ln in (summary or "").splitlines(): + if re.match(r"^\s*>+", ln): + ln = re.sub(r"^\s*>+\s*", "", ln) + if ln: + lines_out.append(f"- {ln}") + continue + lines_out.append(ln) + return "\n".join(lines_out).strip() + + +def fetch_url_summary(url: str, *, max_chars: int = 900, timeout: int = 18) -> dict: + """自动抓取 URL 可读摘要;失败不抛错,返回 status=fetch_failed。""" + parsed = urlparse(url) + if parsed.scheme not in ("http", "https") or not parsed.netloc: + return { + "ok": False, + "status": "fetch_failed", + "page_title": "", + "summary": "", + "error": "invalid_url", + } + headers = { + "User-Agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/121.0.0.0 Safari/537.36" + ), + "Accept": "text/html,application/xhtml+xml;q=0.9,*/*;q=0.8", + "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", + } + try: + req = Request(url, headers=headers) + with urlopen(req, timeout=timeout) as resp: + raw = resp.read() + charset = resp.headers.get_content_charset() or "utf-8" + html = raw.decode(charset, errors="replace") + final_url = resp.geturl() + except (HTTPError, URLError, TimeoutError, OSError, ValueError) as e: + return { + "ok": False, + "status": "fetch_failed", + "page_title": "", + "summary": "", + "error": str(e)[:160], + } + + page_title = "" + summary = "" + # 优先 readability / bs4(若已安装) + try: + from readability import Document # type: ignore + + doc = Document(html) + page_title = (doc.short_title() or "").strip() + summary = _strip_html(doc.summary()) + except Exception: + try: + from bs4 import BeautifulSoup # type: ignore + + soup = BeautifulSoup(html, "html.parser") + if soup.title and soup.title.string: + page_title = soup.title.string.strip() + for tag in soup(["script", "style", "nav", "footer", "header"]): + tag.decompose() + summary = soup.get_text("\n", strip=True) + except Exception: + mt = re.search(r"(?is)<title[^>]*>(.*?)", html) + page_title = unescape(mt.group(1)).strip() if mt else "" + summary = _strip_html(html) + + summary = sanitize_clue_summary( + summary, title=page_title, max_chars=max_chars + ) + if not summary: + return { + "ok": False, + "status": "fetch_failed", + "page_title": page_title, + "summary": "", + "error": "empty_body", + "final_url": final_url, + } + return { + "ok": True, + "status": "script_fetched", + "page_title": page_title, + "summary": summary, + "final_url": final_url, + "error": "", + } + + +def _tokenize(text: str) -> set[str]: + parts = re.findall(r"[\u4e00-\u9fff]{2,}|[A-Za-z][A-Za-z0-9\-]{2,}", text or "") + stop = { + "一种", + "方法", + "包括", + "所述", + "以及", + "进行", + "公开", + "专利", + "公司", + "技术", + "产品", + "相关", + "是否", + "需要", + "本申请", + "本专利", + "置信度", + "来源", + "理由", + } + return {p for p in parts if p not in stop and len(p) >= 2} + + +def _term_match_tokens(term: str) -> list[str]: + """术语共现匹配:整词 + 连续汉字二元组(避免「陶瓷涂层」整词过严)。""" + term = (term or "").strip() + if not term: + return [] + chars = "".join(re.findall(r"[\u4e00-\u9fff]", term)) + tokens: list[str] = [term] + if len(chars) >= 2: + if chars != term: + tokens.append(chars) + tokens.extend(chars[i : i + 2] for i in range(len(chars) - 1)) + tokens.extend(re.findall(r"[A-Za-z0-9]{2,}", term)) + return list(dict.fromkeys(t for t in tokens if len(t) >= 2)) + + +def harvest_feature_rows(content: str) -> list[str]: + return [e["text"] for e in harvest_feature_entries(content)] + + +def harvest_feature_entries(content: str) -> list[dict]: + """从第四节/第六节表收集特征行;优先识别 F1、F2… 编号。""" + entries: list[dict] = [] + seen: set[str] = set() + for sec_re in (SECTION4_RE, SECTION6_RE): + m = sec_re.search(content or "") + block = m.group(1) if m else "" + for line in block.splitlines(): + if not line.strip().startswith("|"): + continue + cells = [c.strip() for c in line.strip().strip("|").split("|")] + if not cells or cells[0] in ("特征", "---") or re.match(r"^[-:]+$", cells[0]): + continue + head = cells[0] + fm = re.match(r"^(F\d+)\s*(.*)$", head, re.I) + fid = fm.group(1).upper() if fm else "" + label = (fm.group(2).strip() if fm else head)[:40] + text = " ".join(cells) + key = fid or text[:48] + if key in seen: + continue + seen.add(key) + entries.append({"id": fid, "label": label or head, "text": text}) + return entries + + +# 特征名 ↔ 公开话术常见同义(用于弱匹配,勿当权要解释) +_FEATURE_SYNONYMS: dict[str, tuple[str, ...]] = { + "水性": ("水性", "水系", "水基"), + "油性": ("油性", "油系", "油基"), + "涂覆": ("涂覆", "涂布", "涂层"), + "陶瓷": ("陶瓷", "无机涂覆", "勃姆石"), + "球状": ("球状", "球形", "颗粒"), + "基膜": ("基膜", "隔膜基材", "基材"), +} + +_FEATURE_LABEL_STOP = { + "对比", + "相关", + "性能", + "具体", + "实施", + "方式", + "问题", + "参数", + "叙述", + "占位", + "附图", +} + + +def feature_display_name(ent: dict) -> str: + """旁注展示名:有 F 编号则「F1 标签」,否则用对照表特征名。""" + fid = (ent.get("id") or "").strip() + label = (ent.get("label") or "").strip() + if fid and label: + return f"{fid} {label}" + return fid or label or "特征" + + +def resolve_feature_entry(key: str, catalog: list[dict]) -> dict | None: + """把 related_feature_ids 中的 F1 / 特征名 解析到笔记内真实特征行。""" + key = (key or "").strip() + if not key or not catalog: + return None + ku = key.upper() + for e in catalog: + if e.get("id") and str(e["id"]).upper() == ku: + return e + for e in catalog: + lab = (e.get("label") or "").strip() + if lab == key or key == feature_display_name(e): + return e + for e in catalog: + lab = (e.get("label") or "").strip() + if lab and (key in lab or lab in key): + return e + return None + + +def _label_hits_blob(label: str, blob: str) -> bool: + """特征名与线索文本弱共现(含水系/水性等同义)。""" + label = (label or "").strip() + blob = blob or "" + if not label or not blob: + return False + if label in blob: + return True + blob_l = blob.lower() + for eng in re.findall(r"[A-Za-z][A-Za-z0-9\-]{1,}", label): + if eng.lower() in blob_l: + return True + chars = "".join(re.findall(r"[\u4e00-\u9fff]", label)) + for i in range(max(0, len(chars) - 1)): + d = chars[i : i + 2] + if d in _FEATURE_LABEL_STOP: + continue + alts = _FEATURE_SYNONYMS.get(d, (d,)) + if any(a in blob for a in alts): + return True + return False + + +def _clue_stem(clue: dict) -> str: + return Path(clue.get("filename") or "x.md").stem + + +def _clue_link(clue: dict, title: str | None = None) -> str: + stem = _clue_stem(clue) + t = title or clue.get("title") or stem + return f"[[clues/{stem}|{t}]]" + + +def _table_wikilink(target: str, alias: str) -> str: + """表格单元格内 wikilink:别名前的 | 必须转义,否则会被拆列。""" + return f"[[{target}\\|{alias}]]" + + +def _clue_highlight(clue: dict, *, limit: int = 72) -> str: + """取摘要首条要点或理由短句,供旁注。""" + summary = sanitize_clue_summary( + clue.get("summary") or "", + title=clue.get("title") or "", + reason=clue.get("reason") or "", + ) + for ln in summary.splitlines(): + s = ln.strip() + if s.startswith("- "): + return s[2:].strip()[:limit] + if s and s != "页面要点:": + return s[:limit] + reason = (clue.get("reason") or "").strip() + return reason[:limit] if reason else (clue.get("title") or "公开线索") + + +def _feature_anchor_tokens(ent: dict) -> list[str]: + """特征名/行文本中用于贴合抽取的锚点词。""" + label = (ent.get("label") or "").strip() + text = (ent.get("text") or "").strip() + tokens = _term_match_tokens(label) + tokens.extend(_term_match_tokens(text)) + # 三字词(如「纤维素」「碱尿素」)比二元组更贴切 + for src in (label, text): + chars = "".join(re.findall(r"[\u4e00-\u9fff]", src or "")) + tokens.extend(chars[i : i + 3] for i in range(max(0, len(chars) - 2))) + tokens.extend(re.findall(r"\d+(?:\.\d+)?(?:万|%|nm|μm|um|µm|mm)?", label, re.I)) + # 同义扩展,便于「涂覆」对上「涂布」 + expanded: list[str] = [] + for t in tokens: + expanded.append(t) + if t in _FEATURE_SYNONYMS: + expanded.extend(_FEATURE_SYNONYMS[t]) + elif len(t) == 2 and t in _FEATURE_SYNONYMS: + expanded.extend(_FEATURE_SYNONYMS[t]) + return list(dict.fromkeys(t for t in expanded if len(t) >= 2))[:36] + + +def _clue_snippet_pool(clue: dict, *, include_title: bool = False) -> list[str]: + """线索可抽取短句池:只用摘要要点/分句做专属贴合(不用标题/理由抢分)。""" + pool: list[str] = [] + summary = sanitize_clue_summary( + clue.get("summary") or "", + title=clue.get("title") or "", + reason=clue.get("reason") or "", + ) + for ln in summary.replace("\\n", "\n").splitlines(): + s = ln.strip() + if s.startswith("- "): + s = s[2:].strip() + if not s or s.startswith("页面要点"): + continue + if len(s) <= 100: + pool.append(s) + for part in re.split(r"[。;;,,]", s): + part = part.strip("  ,,") + if 8 <= len(part) <= 90: + pool.append(part) + if include_title: + title = (clue.get("title") or "").strip() + if title: + pool.append(title) + return list(dict.fromkeys(pool)) + + +def _score_snippet_for_feature(snippet: str, tokens: list[str]) -> int: + """命中分 − 长度惩罚,使更短、更贴特征的分句胜出。""" + if not snippet or not tokens: + return 0 + hits = 0 + weight = 0 + for t in tokens: + if t in snippet: + hits += 1 + weight += 5 if len(t) >= 3 else 2 + if re.search(r"\d", t): + weight += 3 + if hits <= 0: + return 0 + # 密度优先:同等命中偏好短句 + return weight * 10 + hits * 3 - min(len(snippet), 80) + +def clue_highlight_for_feature(clue: dict, ent: dict, *, limit: int = 56) -> tuple[str, bool]: + """按特征从线索摘要中抽一句贴合点。 + + 返回 (短句, 是否特征专属)。专属=摘要中命中该特征锚点;标题弱共现不算专属。 + """ + label = (ent.get("label") or "").strip() + tokens = _feature_anchor_tokens(ent) + best = "" + best_score = 0 + for sn in _clue_snippet_pool(clue, include_title=False): + sc = _score_snippet_for_feature(sn, tokens) + if sc > best_score: + best_score = sc + best = sn + if not best or best_score < 20: + return "", False + # 数值/尺寸类特征:摘要未出现数字或单位时,不算专属贴合 + nums = re.findall(r"\d+", label) + if nums and not any(n in best for n in nums): + if re.search(r"分子量|直径|粒径|厚度|孔隙|孔隙率|μm|um|nm|%|万", label, re.I): + return "", False + return best[:limit], True + + +def clue_highlight_for_text(clue: dict, text: str, *, limit: int = 56) -> tuple[str, bool]: + """按任意锚点文本(权项摘要/术语名)从线索摘要抽贴合句(启发式降级)。""" + text = (text or "").strip() + if not text: + return "", False + return clue_highlight_for_feature( + clue, + {"id": "", "label": text[:48], "text": text[:240]}, + limit=limit, + ) + + +def normalize_anchor_fits(clue: dict) -> list[dict]: + """Agent 写入的锚点贴合:[{kind, key, fit}, ...]。""" + raw = clue.get("anchor_fits") or clue.get("fits") or [] + if not isinstance(raw, list): + return [] + out: list[dict] = [] + for item in raw: + if not isinstance(item, dict): + continue + kind = str(item.get("kind") or item.get("type") or "").strip().lower() + if kind in ("feat", "特征"): + kind = "feature" + elif kind in ("权利要求", "claim_id", "权"): + kind = "claim" + elif kind in ("术语",): + kind = "term" + if kind not in ("feature", "claim", "term"): + continue + key = item.get("key") + if key is None: + key = item.get("id") or item.get("name") or item.get("label") + if key is None or str(key).strip() == "": + continue + fit = str(item.get("fit") or item.get("highlight") or item.get("point") or "").strip() + if not fit: + continue + out.append({"kind": kind, "key": str(key).strip(), "fit": fit[:120]}) + return out + + +def agent_fits_for(clue: dict, kind: str) -> list[tuple[str, str]]: + """返回 (key, fit) 列表;kind=feature|claim|term。""" + return [ + (str(x["key"]), str(x["fit"])) + for x in normalize_anchor_fits(clue) + if x.get("kind") == kind + ] + + +def _claim_key_to_num(key: str) -> int | None: + m = re.search(r"\d+", str(key or "")) + return int(m.group(0)) if m else None + + +def harvest_claim_bodies(content: str) -> dict[int, str]: + """从第四节 patent-claim callout 抽取权号 → 正文短摘。""" + out: dict[int, str] = {} + for m in re.finditer( + r">\s*\[!patent-claim\]\s*权利要求\s*(\d+)\b([\s\S]*?)(?=\n>\s*\[!patent-claim\]|\n##\s|\n\| 特征 \||\Z)", + content or "", + re.M, + ): + num = int(m.group(1)) + body = m.group(2) + # 去掉引用前缀与空行,拼成一段 + bits: list[str] = [] + for ln in body.splitlines(): + s = ln.strip() + if s.startswith(">"): + s = s[1:].strip() + if not s or s.startswith("[!"): + continue + bits.append(s) + text = " ".join(bits) + text = re.sub(r"\s+", " ", text).strip() + if text: + out[num] = text[:320] + return out + + +def _distinct_clue_lines(clues: list[dict], *, limit: int = 3, hl_limit: int = 42) -> list[str]: + """多条线索各取一句不重复的摘要要点(供 L2 氛围旁注)。""" + lines: list[str] = [] + seen: set[str] = set() + for c in clues: + hl = _clue_highlight(c, limit=hl_limit) + key = re.sub(r"\s+", "", hl)[:18] + if not key or key in seen: + continue + seen.add(key) + lines.append(f"- {_clue_link(c, (c.get('title') or '')[:22])} — {hl}") + if len(lines) >= limit: + break + return lines + + +def feature_clue_affinity(ent: dict, clue: dict) -> int: + """特征↔线索相关度(越高越值得展开贴合句)。""" + blob = " ".join( + [ + clue.get("title") or "", + clue.get("reason") or "", + clue.get("summary") or "", + clue.get("page_title") or "", + ] + ) + label = (ent.get("label") or "").strip() + score = 0 + if _label_hits_blob(label, blob): + score += 3 + overlap = _tokenize(ent.get("text") or label) & _tokenize(blob) + score += min(4, len(overlap)) + nums = re.findall(r"\d+", label) + if nums: + if any(n in blob for n in nums): + score += 4 + else: + score -= 1 + _, specific = clue_highlight_for_feature(clue, ent) + if specific: + score += 3 + return score + + +def match_clue_to_note( + clue: dict, + *, + claim_summaries: dict[int, str] | None = None, + feature_rows: list[str] | None = None, + feature_entries: list[dict] | None = None, + extra_text: str = "", +) -> dict: + """弱匹配:线索文本 ↔ 权项摘要 / 特征行(含 F1…)。""" + blob = " ".join( + [ + clue.get("title") or "", + clue.get("reason") or "", + clue.get("summary") or "", + clue.get("page_title") or "", + extra_text, + ] + ) + tokens = _tokenize(blob) + related_claims: list[int] = [] + claim_hits: list[str] = [] + for num, summ in sorted((claim_summaries or {}).items()): + ct = _tokenize(str(summ)) + overlap = sorted(tokens & ct) + if len(overlap) >= 1 and ( + len(overlap) >= 2 or any(len(x) >= 4 for x in overlap) + ): + related_claims.append(int(num)) + claim_hits.append("、".join(overlap[:4])) + + entries = list(feature_entries or []) + if not entries and feature_rows: + entries = [{"id": "", "label": "", "text": r} for r in feature_rows] + + related_features: list[str] = [] + related_feature_ids: list[str] = [] + for ent in entries: + ft = _tokenize(ent.get("text") or "") + overlap = sorted(tokens & ft) + label_hit = _label_hits_blob(ent.get("label") or "", blob) + if ( + len(overlap) >= 2 + or (overlap and any(len(x) >= 4 for x in overlap)) + or label_hit + ): + related_features.append( + "、".join(overlap[:4]) if overlap else (ent.get("label") or "")[:20] + ) + if ent.get("id"): + related_feature_ids.append(str(ent["id"])) + elif ent.get("label"): + related_feature_ids.append(str(ent["label"])[:40]) + # 启发式:仅当笔记里确有对应 F 编号时才补(避免空号 F1–F6) + joined = " ".join(tokens) + if any(k in joined for k in ("陶瓷", "涂覆", "勃姆石", "al2o3")): + for prefer in ("F1", "F6"): + if prefer not in related_feature_ids and any( + e.get("id") == prefer for e in entries + ): + related_feature_ids.append(prefer) + if any(k in joined for k in ("湿法", "拉伸", "基膜")): + for prefer in ("F2", "F5"): + if prefer not in related_feature_ids and any( + e.get("id") == prefer for e in entries + ): + related_feature_ids.append(prefer) + + return { + "related_claims": related_claims[:6], + "related_features": related_features[:4], + "related_feature_ids": list(dict.fromkeys(related_feature_ids))[:6], + "claim_hit_terms": claim_hits[:6], + "match_score": len(related_claims) + len(related_feature_ids or related_features), + } + + +def _strip_injected_clue_blocks(content: str) -> str: + """幂等:去掉先前 L1–L3 注入的线索旁注/入口。""" + titles = ( + "公开线索入口", + "公开线索", + "公开案例(推测)", + "外部线索(推测)", + "权项—公开语境(推测)", + "特征—公开语境(推测)", + "阅读建议·公开线索", + "差别对照·公开线索", + "场景·公开线索", + "术语·公开语境", + ) + for title in titles: + content = re.sub( + rf"\n?>\s*\[!(?:warning|tip)\]-?\s*{re.escape(title)}[\s\S]*?" + rf"(?=\n##\s|\n###\s|\n>\s*\[!|\Z)", + "\n", + content, + ) + content = re.sub( + r"^[ \t]*-\s*\[\[[^\]]*clues/_线索索引[^\]]*\]\][^\n]*\n?", + "", + content, + flags=re.M, + ) + return content + + +def _insert_before_heading(content: str, heading_pat: str, block: str) -> str: + m = re.search(heading_pat, content, re.M) + if not m: + return content + return content[: m.start()].rstrip() + "\n\n" + block.strip() + "\n\n" + content[m.start() :] + + +def _insert_after_section(content: str, section_re: re.Pattern[str], block: str) -> str: + m = section_re.search(content) + if not m: + return content + end = m.end() + return content[:end].rstrip() + "\n\n" + block.strip() + "\n\n" + content[end:] + + +def _render_warning(title: str, body_lines: list[str]) -> str: + lines = [f"> [!warning]- {title}", ">"] + for ln in body_lines: + lines.append(f"> {ln}" if ln else ">") + return "\n".join(lines) + + +def _insert_after_section6_feature_table(content: str, block: str) -> str: + """插在第六节对照表正下方(附图/扫描预览之前),避免沉到节末看不见。""" + sec = re.search( + r"(^##\s*六、[^\n]*\n)([\s\S]*?)(?=^##\s*七、|\Z)", + content, + re.M, + ) + if not sec: + if re.search(r"^##\s*七、", content, re.M): + return _insert_before_heading(content, r"^##\s*七、", block) + return content.rstrip() + "\n\n" + block.strip() + "\n" + + head, body = sec.group(1), sec.group(2) + # 节内第一张 markdown 表(特征|说明书|附图) + m_table = re.search( + r"(\|[^\n]+\|\n\|[-: |]+\|\n(?:\|[^\n]+\|\n)+)", + body, + ) + if m_table: + insert_at = sec.start(2) + m_table.end() + return ( + content[:insert_at].rstrip() + + "\n\n" + + block.strip() + + "\n\n" + + content[insert_at:].lstrip("\n") + ) + # 无表则插在「### 附图」前,再不行节末(七之前) + m_fig = re.search(r"^###\s*附图", body, re.M) + if m_fig: + insert_at = sec.start(2) + m_fig.start() + return ( + content[:insert_at].rstrip() + + "\n\n" + + block.strip() + + "\n\n" + + content[insert_at:] + ) + return _insert_before_heading(content, r"^##\s*七、", block) + + +def inject_clue_annotations(content: str, clues: list[dict]) -> str: + """L1–L3:导航入口 + 一/二/七/八/九语境旁注 + 权/特征点对点;L4 附录由 upsert_appendix_b。""" + if not clues: + return content + content = _strip_injected_clue_blocks(content) + n = len(clues) + primary = clues[0] + distinct_l2 = _distinct_clue_lines(clues, limit=3) + + # —— L1:导航 + 文首入口 —— + nav_item = f"[[clues/_线索索引|公开线索({n} 条)]]" + if "## Obsidian 导航" in content and f"公开线索({n} 条)" not in content: + content = re.sub( + r"(##\s*Obsidian\s*导航\s*\n(?:- .+\n)*)", + rf"\1- {nav_item}\n", + content, + count=1, + ) + l1 = ( + f"> [!tip]- 公开线索入口\n" + f"> 本案整理了 **{n}** 条公开检索线索(推测语境)。" + f"详见 [[clues/_线索索引|线索文件夹]];" + f"下文各节有折叠旁注,**不是**说明书/权利要求证据。" + ) + if "公开线索入口" not in content: + content = _insert_before_heading(content, r"^##\s*一、", l1) + + # —— L2:氛围旁注(各节角度不同,避免同一摘要首句跨节刷屏)—— + l2_one = _render_warning( + "公开案例(推测)", + [ + "进入正文前可先扫一眼公开语境(**不能**等同保护范围):", + *(distinct_l2[:1] or [f"- {_clue_link(primary)}"]), + ], + ) + content = _insert_before_heading(content, r"^##\s*二、", l2_one) + + l2_two_body = [ + "叙事对照:下列为公开材料各自强调的要点;本案以**权要/说明书**为准。", + *(distinct_l2[:3] or [f"- {_clue_link(c)}" for c in clues[:3]]), + ] + l2_two = _render_warning("公开案例(推测)", l2_two_body) + content = _insert_before_heading(content, r"^##\s*三、", l2_two) + + l2_seven = _render_warning( + "差别对照·公开线索", + [ + "对照公开话术时:分清哪些差别来自**专利文本**,哪些只是行业语境。", + "线索入口:[[clues/_线索索引|线索文件夹]]" + + (" · " + " · ".join(_clue_link(c) for c in clues[:3]) if clues else ""), + ], + ) + content = _insert_before_heading(content, r"^##\s*八、", l2_seven) + + l2_eight = _render_warning( + "阅读建议·公开线索", + [ + "建议打开 [[clues/_线索索引|线索文件夹]] 扫摘要,再回看权1骨架与特征表。", + "详细贴合点见第四节权项旁注、第六节「特征—公开语境」。", + ], + ) + content = _insert_before_heading(content, r"^##\s*九、", l2_eight) + + l2_nine = _render_warning( + "场景·公开线索", + [ + "应用场景的专利内依据见上表;公开线索仅补充同主题落地/产品语境。", + " · ".join(_clue_link(c) for c in clues[:3]), + ], + ) + content = _insert_before_heading(content, r"^##\s*十、", l2_nine) + + # —— L3:权项点对点(优先 Agent anchor_fits;否则按权正文启发式)—— + claim_bodies = harvest_claim_bodies(content) + claim_groups: dict[int, list[dict]] = {} + for c in clues: + claim_nums: set[int] = set() + for num in c.get("related_claims") or []: + try: + claim_nums.add(int(num)) + except (TypeError, ValueError): + continue + for key, _fit in agent_fits_for(c, "claim"): + n = _claim_key_to_num(key) + if n: + claim_nums.add(n) + for num in claim_nums: + claim_groups.setdefault(num, []).append(c) + # 从后往前插,避免偏移 + for num, group in sorted(claim_groups.items(), reverse=True): + # 去重同一线索 + uniq_group: list[dict] = [] + seen_stem: set[str] = set() + for c in group: + st = _clue_stem(c) + if st in seen_stem: + continue + seen_stem.add(st) + uniq_group.append(c) + anchor = claim_bodies.get(num, f"权利要求{num}") + body = [ + f"与**权利要求 {num}** 弱匹配的公开语境(非权要证据);" + f"贴合句优先来自 Agent 读页归纳:", + ] + seen_hl: set[str] = set() + for c in uniq_group[:3]: + agent_hl = next( + ( + fit + for key, fit in agent_fits_for(c, "claim") + if _claim_key_to_num(key) == num + ), + "", + ) + if agent_hl: + hl, specific = agent_hl[:52], True + else: + hl, specific = clue_highlight_for_text(c, anchor, limit=52) + if specific and hl: + key = re.sub(r"\s+", "", hl)[:20] + if key in seen_hl: + body.append(f"- {_clue_link(c)}") + else: + seen_hl.add(key) + body.append(f"- {_clue_link(c)} — {hl}") + else: + body.append( + f"- {_clue_link(c)} — 同主题语境(摘要未点名该权项)" + ) + block = _render_warning("权项—公开语境(推测)", body) + pat = re.compile( + rf"(>\s*\[!patent-claim\]\s*权利要求\s*{num}\b[\s\S]*?)(?=\n>\s*\[!patent-claim\]|\n##\s|\n\| 特征 \||\Z)", + re.M, + ) + m = pat.search(content) + if m: + content = content[: m.end()].rstrip() + "\n\n" + block + "\n\n" + content[m.end() :] + else: + pass + + # 若完全没插到权 callout,保留总览挂在第四节末 + if "权项—公开语境(推测)" not in content: + overview = render_annotation_callout(clues) + if overview: + content = _insert_before_heading(content, r"^##\s*五、", overview.strip()) + + # —— L3:特征公开语境 → 紧挨第六节对照表下方(附图之前)—— + # 优先 Agent anchor_fits;无则启发式。按线索去重呈现。 + catalog = harvest_feature_entries(content) + # stem -> {clue, pairs: [(score, ent, hl, specific)]} + clue_buckets: dict[str, dict] = {} + for c in clues: + stem = _clue_stem(c) + bucket = clue_buckets.setdefault(stem, {"clue": c, "pairs": []}) + agent_feat = agent_fits_for(c, "feature") + if agent_feat: + for key, fit in agent_feat[:6]: + ent = resolve_feature_entry(str(key), catalog) + if not ent: + # Agent 已点名:即使当前表无完全同名行,仍展示贴合句 + ent = { + "id": "", + "label": str(key)[:40], + "text": str(key), + } + bucket["pairs"].append((100, ent, fit[:52], True)) + continue + keys = list(c.get("related_feature_ids") or []) + if not keys and c.get("related_features"): + keys = [str(x) for x in c.get("related_features")[:2]] + resolved: list[dict] = [] + for key in keys: + ent = resolve_feature_entry(str(key), catalog) + if ent: + resolved.append(ent) + if not resolved and catalog: + live = match_clue_to_note(c, feature_entries=catalog) + for key in live.get("related_feature_ids") or []: + ent = resolve_feature_entry(str(key), catalog) + if ent: + resolved.append(ent) + seen_disp: set[str] = set() + for ent in resolved: + disp = feature_display_name(ent) + if disp in seen_disp: + continue + seen_disp.add(disp) + sc = feature_clue_affinity(ent, c) + hl, specific = clue_highlight_for_feature(c, ent, limit=52) + bucket["pairs"].append((sc, ent, hl if specific else "", specific)) + clue_buckets = {k: v for k, v in clue_buckets.items() if v.get("pairs")} + if clue_buckets: + lines = [ + "按**公开线索**归纳(每条只出现一次);贴合句优先来自 Agent 读页归纳,否则启发式抽取。", + "仅供语境理解,**不是**说明书/权利要求证据。", + "", + ] + for stem, bucket in list(clue_buckets.items())[:DEFAULT_MAX_CLUES]: + c = bucket["clue"] + pairs = sorted( + bucket["pairs"], key=lambda x: (-x[0], feature_display_name(x[1])) + ) + # 每条线索最多展开 3 条有专属贴合句的特征;其余收进「另涉」 + specific_rows = [ + (sc, ent, hl) for sc, ent, hl, sp in pairs if sp and sc >= 2 + ][:3] + specific_names = {feature_display_name(e) for _, e, _ in specific_rows} + other = [ + feature_display_name(ent) + for _sc, ent, _hl, _sp in pairs + if feature_display_name(ent) not in specific_names + ][:6] + lines.append(f"**{_clue_link(c, (c.get('title') or '')[:28])}**") + if specific_rows: + for _sc, ent, hl in specific_rows: + lines.append(f"- **{feature_display_name(ent)}** — {hl}") + if other: + lines.append("- 另涉(同主题、摘要未点名):" + ";".join(other)) + elif pairs: + lines.append(f"- 同主题语境:{_clue_highlight(c, limit=48)}") + names = ";".join( + feature_display_name(ent) for _sc, ent, _hl, _sp in pairs[:5] + ) + lines.append(f"- 相关特征:{names}") + lines.append("") + while lines and lines[-1] == "": + lines.pop() + feat_block = _render_warning("特征—公开语境(推测)", lines) + content = _insert_after_section6_feature_table(content, feat_block) + + # —— L2 补充:术语节(按线索去重 + 术语贴合句)—— + sec5 = re.search(r"^##\s*五、专利内术语表([\s\S]*?)(?=^##\s*六、|\Z)", content, re.M) + if sec5: + terms = re.findall(r"\|\s*\[\[(?:[^\]|]+\|)?([^\]]+)\]\]", sec5.group(1)) + if not terms: + terms = [ + m.group(1).strip() + for m in re.finditer( + r"^\|\s*([^|]+?)\s*\|", sec5.group(1), re.M + ) + if m.group(1).strip() + and not re.match(r"^[-:]+$", m.group(1).strip()) + and m.group(1).strip() not in ("术语", "本文含义/位置", "备注") + ] + term_buckets: dict[str, dict] = {} + # 先吃 Agent 术语贴合 + for c in clues: + agent_terms = agent_fits_for(c, "term") + if not agent_terms: + continue + stem = _clue_stem(c) + bucket = term_buckets.setdefault(stem, {"clue": c, "rows": []}) + for key, fit in agent_terms[:6]: + # 尽量对齐笔记术语表用词 + matched = next( + (t for t in terms if t == key or key in t or t in key), + key, + ) + if matched not in terms and key not in terms: + # 仍展示 Agent 点名的术语 + matched = key + bucket["rows"].append((matched, fit[:44], True)) + # 无 Agent 术语贴合时,启发式共现 + if not term_buckets: + for term in terms[:12]: + tokens = _term_match_tokens(term) + if not tokens: + continue + for c in clues: + blob = f"{c.get('title')} {c.get('summary')} {c.get('reason')}" + if not any(t in blob for t in tokens): + continue + stem = _clue_stem(c) + bucket = term_buckets.setdefault(stem, {"clue": c, "rows": []}) + hl, specific = clue_highlight_for_text(c, term, limit=44) + bucket["rows"].append((term, hl if specific else "", specific)) + break + if term_buckets: + lines = [ + "按线索归纳术语共现(推测);贴合句优先来自 Agent 读页归纳。", + "", + ] + for stem, bucket in list(term_buckets.items())[:DEFAULT_MAX_CLUES]: + c = bucket["clue"] + lines.append(f"**{_clue_link(c, (c.get('title') or '')[:28])}**") + specific_rows = [(t, hl) for t, hl, sp in bucket["rows"] if sp][:4] + other = [t for t, hl, sp in bucket["rows"] if not sp][:6] + for t, hl in specific_rows: + lines.append(f"- **{t}** — {hl}") + if other: + if specific_rows: + lines.append("- 另涉:" + ";".join(other)) + else: + lines.append( + f"- 同主题语境:{_clue_highlight(c, limit=40)}" + ) + lines.append("- 相关术语:" + ";".join(other)) + lines.append("") + while lines and lines[-1] == "": + lines.pop() + term_block = _render_warning("术语·公开语境", lines) + content = _insert_before_heading(content, r"^##\s*六、", term_block) + + return content + + +def render_annotation_callout(clues: list[dict]) -> str: + matched = [ + c + for c in clues + if c.get("related_claims") + or c.get("related_features") + or c.get("related_feature_ids") + ] + if not matched: + matched = list(clues) + if not matched: + return "" + lines = [ + "", + "> [!warning]- 外部线索(推测)", + "> 下列公开线索与权项/特征有**弱匹配**,仅供理解语境,**不是**说明书依据。", + "> (每条线索一行;详细贴合见权项/特征旁注。)", + ] + seen_hl: set[str] = set() + for c in matched: + bits: list[str] = [] + if c.get("related_claims"): + bits.append("权" + "、".join(str(n) for n in c["related_claims"])) + fids = c.get("related_feature_ids") or [] + if fids: + bits.append("特征 " + "、".join(str(x) for x in fids[:4])) + elif c.get("related_features"): + bits.append("特征共现:" + ";".join(c["related_features"][:2])) + hl = _clue_highlight(c, limit=40) + key = re.sub(r"\s+", "", hl)[:18] + if key and key in seen_hl: + hl = "" + elif key: + seen_hl.add(key) + tail = " · ".join(bits) if bits else (hl or "同主题语境") + if bits and hl: + tail = f"{' · '.join(bits)} — {hl}" + lines.append(f"> - {_clue_link(c)} — {tail}") + lines.append("") + return "\n".join(lines) + + +def render_clue_note( + clue: dict, + *, + pub: str, + note_link: str = "", +) -> str: + claims = clue.get("related_claims") or [] + feats = clue.get("related_features") or [] + status = clue.get("status") or "draft" + fetched_at = clue.get("fetched_at") or "" + summary = format_summary_for_markdown( + sanitize_clue_summary( + clue.get("summary") or "", + title=clue.get("title") or "", + reason=clue.get("reason") or "", + ) + ) + page_title = (clue.get("page_title") or "").strip() + # 去掉「公司_公司」重复标题噪音 + page_title = re.sub(r"(_[^_]*){2,}$", "", page_title).strip("_") or page_title + err = (clue.get("fetch_error") or "").strip() + lines = [ + "---", + "tags:", + " - patent/clue", + "cssclasses:", + " - patent-clue", + f"pub_number: {pub}", + f"clue_id: {clue.get('clue_id') or ''}", + f"confidence: {clue.get('confidence') or '中'}", + f"status: {status}", + f"url: {clue.get('url') or ''}", + "related_claims:", + ] + if claims: + for n in claims: + lines.append(f" - {n}") + else: + lines.append(" []") + lines.append("related_features:") + if feats: + for f in feats: + lines.append(f" - {json.dumps(f, ensure_ascii=False)}") + else: + lines.append(" []") + if fetched_at: + lines.append(f"fetched_at: {fetched_at}") + lines.extend( + [ + "---", + f"# 线索:{clue.get('title') or '未命名'}", + "", + "> [!warning] 推测线索", + "> 公开网页语境,**不构成法律意见**,也不是说明书/权利要求证据。", + "", + "## 元信息", + "", + f"- **置信度**:{clue.get('confidence') or '中'}", + f"- **来源**:[打开原文]({clue.get('url') or ''})", + f"- **与本案关系**:{clue.get('reason') or '—'}", + f"- **状态**:{status}", + ] + ) + if note_link: + lines.append(f"- **所属解读**:[[{note_link}|打开解读]]") + lines.extend(["", "## 页面摘要", ""]) + if page_title: + lines.append(f"**页面标题**:{page_title}") + lines.append("") + if summary: + # 列表/段落直接写;勿把原始网页整页粘贴进笔记 + lines.append(summary) + lines.append("") + elif err: + lines.append(f"(抓取未成功:{err}。请 Agent 重读该 URL,或启用脚本降级。)") + lines.append("") + else: + lines.append( + "(暂无摘要。主路径应由 Agent 写入可读短文/要点列表,勿粘贴整页导航。)" + ) + lines.append("") + lines.extend(["", "## 可能相关权项 / 特征", ""]) + if claims: + lines.append("- **权项**:" + "、".join(f"权{n}" for n in claims)) + if feats: + lines.append("- **特征共现**:" + ";".join(feats)) + if not claims and not feats: + lines.append("- (弱匹配未命中;仍可作行业语境参考)") + lines.append("") + return "\n".join(lines) + + +def render_clues_index(clues: list[dict], *, pub: str, note_link: str = "") -> str: + lines = [ + "---", + "tags:", + " - patent/clue-index", + "---", + f"# `{pub}` 公开线索索引", + "", + "> 推测层材料,可手工追加笔记后在此补链。**不构成法律意见**。", + "", + ] + if note_link: + lines.append(f"- 解读:[[{note_link}|打开]]") + lines.append("") + lines.extend( + [ + "| 线索 | 置信 | 状态 | 可能相关 |", + "| --- | --- | --- | --- |", + ] + ) + # 索引在 clues/ 子目录:链到同目录笔记用短路径;权项锚点在上级专利目录 + claim_base = f"{pub}_权项锚点" + for c in clues: + fname = c.get("filename") or "" + stem = Path(fname).stem if fname else c.get("clue_id") or "线索" + title = (c.get("title") or stem)[:40] + claims = c.get("related_claims") or [] + if claims: + rel = "、".join( + _table_wikilink(f"{claim_base}#^claim-{n}", f"权{n}") for n in claims + ) + else: + rel = "—" + # 索引页本身在 clues/ 下,链同目录笔记勿加 clues/ 前缀 + clue_cell = _table_wikilink(stem, title) + lines.append( + f"| {clue_cell} | {c.get('confidence') or '中'} " + f"| {c.get('status') or '—'} | {rel} |" + ) + lines.append("") + return "\n".join(lines) + + +def render_appendix_b(clues: list[dict], *, clues_dir_link: str) -> str: + if not clues: + return ( + "### B. 公开检索线索\n\n" + "> [!warning]- 公开检索线索\n" + ">\n" + "> 未发现可核验的公开对应,可能为防御性/储备专利。\n" + ) + lines = [ + "### B. 公开检索线索", + "", + "> [!warning]- 公开检索线索", + ">", + f"> 详情与抓取摘要见 [[{clues_dir_link}|线索文件夹]](最多保留高置信条目)。", + ">", + ] + for c in clues: + stem = Path(c.get("filename") or "x.md").stem + title = c.get("title") or stem + conf = c.get("confidence") or "中" + url = c.get("url") or "" + reason = (c.get("reason") or "")[:80] + link = f"[[clues/{stem}|{title}]]" + src = f"[来源]({url})" if url else "来源:—" + lines.append( + f"> - **线索**:{link} — 置信度:{conf} — {src} — 理由:{reason}" + ) + lines.append("") + return "\n".join(lines) + + +def upsert_appendix_b(content: str, section_md: str) -> str: + section_md = section_md.rstrip() + "\n" + if APPENDIX_B_RE.search(content): + return APPENDIX_B_RE.sub(section_md, content, count=1) + # 插在「十、附录」内、免责之前 + m = re.search(r"^##\s*十、附录[\s\S]*?(?=^##\s*十一、|\Z)", content, re.M) + if m: + block = m.group(0) + if "### B." in block: + return content + insert_at = m.end() + return content[:insert_at].rstrip() + "\n\n" + section_md + "\n" + content[insert_at:] + return content.rstrip() + "\n\n" + section_md + + +def materialize_clues( + clues: list[dict], + *, + note_dir: Path, + pub: str, + note_rel: str = "", + claim_summaries: dict[int, str] | None = None, + feature_rows: list[str] | None = None, + feature_entries: list[dict] | None = None, + max_keep: int = DEFAULT_MAX_CLUES, + fetch_fallback: bool = False, + fetch: bool | None = None, +) -> tuple[list[dict], str]: + """筛选→写入 clues/→附录 Markdown。 + + 摘要主路径应由 Agent 写入 public_clues.json(summary/status=agent_fetched)。 + fetch_fallback=True 时,仅对**缺少 summary** 的条目尝试脚本 HTTP 降级抓取。 + 参数 fetch 为旧别名:True/False 映射到 fetch_fallback(兼容调用方)。 + """ + if fetch is not None: + fetch_fallback = bool(fetch) + kept, _dropped = filter_clues(clues, max_keep=max_keep) + clues_dir = note_dir / "clues" + clues_dir.mkdir(parents=True, exist_ok=True) + note_link = note_rel[:-3] if note_rel.endswith(".md") else note_rel + today = date.today().isoformat() + entries = list(feature_entries or []) + if not entries and feature_rows: + entries = [{"id": "", "label": "", "text": r} for r in feature_rows] + + rich: list[dict] = [] + for i, c in enumerate(kept): + item = normalize_clue(c, index=i) + # 保留 Agent 已写字段 + for key in ( + "summary", + "page_title", + "status", + "related_claims", + "related_features", + "related_feature_ids", + "anchor_fits", + "fits", + "fetch_note", + "fetched_at", + ): + if c.get(key) not in (None, "", []): + item[key] = c[key] + # 统一规范 Agent 贴合字段 + fits = normalize_anchor_fits(item) + if fits: + item["anchor_fits"] = fits + item.pop("fits", None) + fname = clue_filename(item.get("title") or "线索", i) + item["filename"] = fname + + # 无论来源,落盘前清洗摘要(修竖排拆字 / 导航噪音 / > 引用污染) + if item.get("summary"): + item["summary"] = sanitize_clue_summary( + str(item.get("summary") or ""), + title=item.get("title") or "", + reason=item.get("reason") or "", + ) + + has_summary = bool(str(item.get("summary") or "").strip()) + status = str(item.get("status") or "").strip() + if has_summary and not status: + item["status"] = "agent_fetched" + item.setdefault("fetched_at", today) + elif has_summary and status in ("draft", ""): + item["status"] = "agent_fetched" + item.setdefault("fetched_at", today) + elif ( + fetch_fallback + and item.get("url") + and not has_summary + and status + not in ("agent_fetched", "fetched", "script_fetched", "reviewed") + ): + fetched = fetch_url_summary(item["url"]) + item["status"] = ( + "script_fetched" if fetched.get("ok") else "fetch_failed" + ) + item["summary"] = fetched.get("summary") or "" + item["page_title"] = fetched.get("page_title") or item.get("page_title") or "" + item["fetch_error"] = fetched.get("error") or "" + item["fetch_note"] = (item.get("fetch_note") or "") + ( + " · 脚本降级抓取" if fetched.get("ok") else " · 脚本降级失败" + ) + if fetched.get("ok"): + item["fetched_at"] = today + else: + item.setdefault("status", status or "draft") + + match = match_clue_to_note( + item, + claim_summaries=claim_summaries, + feature_entries=entries, + ) + # 权项:保留 Agent 已写;缺则启发式补 + if not item.get("related_claims"): + item["related_claims"] = match.get("related_claims") or [] + for key, _fit in agent_fits_for(item, "claim"): + n = _claim_key_to_num(key) + if n and n not in (item.get("related_claims") or []): + item.setdefault("related_claims", []).append(n) + # 特征:优先 Agent anchor_fits / related_*,再与笔记表对齐;禁止空号 + agent_feat_keys = [k for k, _ in agent_fits_for(item, "feature")] + if not agent_feat_keys: + agent_feat_keys = [ + str(x) + for x in (item.get("related_feature_ids") or item.get("related_features") or []) + if str(x).strip() + ] + resolved_ids: list[str] = [] + if agent_feat_keys and entries: + for key in agent_feat_keys: + ent = resolve_feature_entry(str(key), entries) + if not ent: + continue + prefer = str(ent.get("id") or ent.get("label") or "")[:40] + if prefer and prefer not in resolved_ids: + resolved_ids.append(prefer) + if resolved_ids: + item["related_feature_ids"] = resolved_ids[:6] + item["related_features"] = resolved_ids[:4] + else: + # 无可用 Agent 锚定:按正文弱匹配重算 + item["related_features"] = match.get("related_features") or [] + item["related_feature_ids"] = match.get("related_feature_ids") or [] + + body = render_clue_note(item, pub=pub, note_link=note_link) + (clues_dir / fname).write_text(body, encoding="utf-8") + rich.append(item) + + index_body = render_clues_index(rich, pub=pub, note_link=note_link) + (clues_dir / "_线索索引.md").write_text(index_body, encoding="utf-8") + + # 旁路 JSON,供 Canvas / 再入库 + (clues_dir / "clues.json").write_text( + json.dumps(rich, ensure_ascii=False, indent=2), encoding="utf-8" + ) + + clues_dir_link = "clues/_线索索引" + appendix = render_appendix_b(rich, clues_dir_link=clues_dir_link) + return rich, appendix + + +def load_clues_sidecar(note_dir: Path) -> list[dict]: + path = note_dir / "clues" / "clues.json" + if not path.is_file(): + return [] + try: + return as_clues(json.loads(path.read_text(encoding="utf-8"))) + except (OSError, json.JSONDecodeError): + return [] + + +def clue_cards_for_canvas(clues: list[dict], *, note_dir_rel: str = "") -> list[dict]: + """供 build_canvas 使用的短卡数据。""" + cards = [] + base = note_dir_rel.replace("\\", "/").rstrip("/") + for c in clues[: DEFAULT_MAX_CLUES]: + stem = Path(c.get("filename") or "x.md").stem + link = f"{base}/clues/{stem}" if base else f"clues/{stem}" + claims = c.get("related_claims") or [] + fids = c.get("related_feature_ids") or [] + cards.append( + { + "id": c.get("clue_id") or stem, + "title": c.get("title") or stem, + "confidence": c.get("confidence") or "中", + "status": c.get("status") or "", + "link": link, + "related_claims": claims, + "related_feature_ids": fids, + "reason": _clue_highlight(c, limit=72), + } + ) + return cards diff --git a/.agents/skills/patent-disclosure-skill/tools/patent_reader/common.py b/.agents/skills/patent-disclosure-skill/tools/patent_reader/common.py new file mode 100644 index 0000000..3f20122 --- /dev/null +++ b/.agents/skills/patent-disclosure-skill/tools/patent_reader/common.py @@ -0,0 +1,810 @@ +"""专利解读工具共享配置与路径解析。""" +from __future__ import annotations + +import json +import os +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent.parent + +DEP_PATTERNS = ( + "根据权利要求", + "如权利要求", + "按照权利要求", + "按权利要求", + "依据权利要求", + "according to claim", + "of claim", +) + + +def optional_path(value: str | None) -> Path | None: + """argparse 用:空字符串 → None,避免 default='' + type=Path 变成 Path('.').""" + if value is None: + return None + s = str(value).strip() + if not s: + return None + return Path(s) + + +def persisted_vault_config_path() -> Path: + """用户级持久化:Obsidian 库路径(不强制依赖系统环境变量)。""" + home = Path.home() + return home / ".patent-disclosure-skill" / "obsidian_vault.txt" + + +def read_persisted_vault() -> str: + path = persisted_vault_config_path() + if not path.is_file(): + return "" + try: + return path.read_text(encoding="utf-8").strip().strip('"').strip("'") + except OSError: + return "" + + +def write_persisted_vault(vault: str | Path) -> Path: + path = persisted_vault_config_path() + path.parent.mkdir(parents=True, exist_ok=True) + resolved = str(Path(vault).expanduser().resolve()) + path.write_text(resolved + "\n", encoding="utf-8") + return path + + +def _looks_like_vault(path: Path) -> bool: + if not path.is_dir(): + return False + # 已打开过的库通常有 .obsidian;新建空目录也允许用户指定 + if (path / ".obsidian").is_dir(): + return True + # 常见:目录存在且非系统盘根 + return path.exists() and path.name not in ("", "/", "\\") + + +def _obsidian_appdata_dirs() -> list[Path]: + dirs: list[Path] = [] + if sys.platform == "win32": + appdata = os.environ.get("APPDATA", "").strip() + local = os.environ.get("LOCALAPPDATA", "").strip() + if appdata: + dirs.append(Path(appdata) / "obsidian") + if local: + dirs.append(Path(local) / "Obsidian") + elif sys.platform == "darwin": + home = Path.home() + dirs.append(home / "Library" / "Application Support" / "obsidian") + else: + home = Path.home() + dirs.append(home / ".config" / "obsidian") + return dirs + + +def detect_obsidian_installed() -> dict: + """探测本机是否安装 / 使用过 Obsidian。""" + evidence: list[str] = [] + installed = False + + for d in _obsidian_appdata_dirs(): + cfg = d / "obsidian.json" + if cfg.is_file(): + installed = True + evidence.append(str(cfg)) + if d.is_dir(): + try: + if any(d.iterdir()): + installed = True + evidence.append(str(d)) + except OSError: + pass + + if sys.platform == "win32": + for cand in ( + Path(os.environ.get("LOCALAPPDATA", "")) / "Obsidian" / "Obsidian.exe", + Path(os.environ.get("PROGRAMFILES", r"C:\Program Files")) / "Obsidian" / "Obsidian.exe", + Path(os.environ.get("PROGRAMFILES(X86)", r"C:\Program Files (x86)")) + / "Obsidian" + / "Obsidian.exe", + ): + if cand.is_file(): + installed = True + evidence.append(str(cand)) + elif sys.platform == "darwin": + app = Path("/Applications/Obsidian.app") + if app.is_dir(): + installed = True + evidence.append(str(app)) + else: + # Linux:桌面入口或 PATH + for name in ("obsidian", "Obsidian"): + for p in os.environ.get("PATH", "").split(os.pathsep): + exe = Path(p) / name + if exe.is_file(): + installed = True + evidence.append(str(exe)) + break + + return { + "installed": installed, + "evidence": list(dict.fromkeys(evidence)), + } + + +def _parse_obsidian_json_vaults(cfg_path: Path) -> list[dict]: + try: + data = json.loads(cfg_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return [] + vaults_raw = data.get("vaults") or {} + out: list[dict] = [] + if isinstance(vaults_raw, dict): + for vid, meta in vaults_raw.items(): + if not isinstance(meta, dict): + continue + p = (meta.get("path") or "").strip() + if not p: + continue + path = Path(p) + out.append( + { + "id": str(vid), + "path": str(path), + "exists": path.is_dir(), + "open": bool(meta.get("open")), + "ts": meta.get("ts") or 0, + "source": "obsidian.json", + } + ) + out.sort(key=lambda v: (not v.get("open"), -(v.get("ts") or 0))) + return out + + +def candidate_default_vault_paths() -> list[Path]: + """常见默认库路径(含用户举例的 Documents\\Obsidian Vault)。""" + home = Path.home() + docs = home / "Documents" + # Windows 可能用「文档」 + docs_zh = home / "文档" + cands = [ + docs / "Obsidian Vault", + docs_zh / "Obsidian Vault", + home / "Obsidian" / "Vault", + home / "ObsidianVault", + home / "obsidian", + docs / "Obsidian", + docs_zh / "Obsidian", + ] + return cands + + +def detect_obsidian_vaults() -> list[dict]: + """汇总:obsidian.json 登记库 + 常见默认路径中已存在的目录。""" + found: list[dict] = [] + seen: set[str] = set() + + for d in _obsidian_appdata_dirs(): + cfg = d / "obsidian.json" + if cfg.is_file(): + for v in _parse_obsidian_json_vaults(cfg): + key = v["path"].lower() + if key in seen: + continue + seen.add(key) + found.append(v) + + for p in candidate_default_vault_paths(): + key = str(p).lower() + if key in seen: + continue + if p.is_dir() and ((p / ".obsidian").is_dir() or any(p.iterdir())): + seen.add(key) + found.append( + { + "id": "", + "path": str(p.resolve()), + "exists": True, + "open": False, + "ts": 0, + "source": "common_path", + } + ) + return found + + +def resolve_obsidian_vault( + *, + prefer_env: bool = True, + prefer_persisted: bool = True, + prefer_detect: bool = True, +) -> dict: + """ + 解析入库用库路径(不强制 Obsidian)。 + + 优先级:环境变量 → 持久化文件 → 自动探测(open 库 / 唯一库 / 常见默认路径) + + 若环境变量键存在但值为空,视为「本会话不要 Obsidian」(不回退到自动探测)。 + """ + if prefer_env: + for key in ( + "PATENT_READER_OBSIDIAN_VAULT", + "PATENT_DISCLOSURE_OBSIDIAN_VAULT", + ): + if key not in os.environ: + continue + env_vault = os.environ.get(key, "").strip() + if not env_vault: + return { + "vault": "", + "source": "env_disabled", + "exists": False, + "needs_user_input": False, + "message": "环境变量已清空:本会话不使用 Obsidian 库,写入 outputs/。", + } + p = Path(env_vault).expanduser() + return { + "vault": str(p.resolve()) if p.exists() else str(p), + "source": "env", + "exists": p.is_dir(), + "needs_user_input": False, + } + + if prefer_persisted: + persisted = read_persisted_vault() + if persisted: + p = Path(persisted).expanduser() + return { + "vault": str(p.resolve()) if p.exists() else str(p), + "source": "persisted", + "exists": p.is_dir(), + "needs_user_input": False, + } + + install = detect_obsidian_installed() + vaults = detect_obsidian_vaults() if prefer_detect else [] + existing = [v for v in vaults if v.get("exists")] + + chosen = None + reason = "" + if prefer_detect and existing: + open_ones = [v for v in existing if v.get("open")] + if len(open_ones) == 1: + chosen = open_ones[0] + reason = "obsidian_open_vault" + elif len(existing) == 1: + chosen = existing[0] + reason = "single_vault" + elif open_ones: + chosen = open_ones[0] + reason = "obsidian_open_vault_first" + else: + return { + "vault": "", + "source": "", + "exists": False, + "needs_user_input": True, + "obsidian_installed": install["installed"], + "candidates": existing, + "message": "检测到多个 Obsidian 库,请指定要用的库路径。", + } + + if chosen: + return { + "vault": chosen["path"], + "source": reason, + "exists": True, + "needs_user_input": False, + "obsidian_installed": install["installed"], + "candidates": existing, + } + + return { + "vault": "", + "source": "", + "exists": False, + "needs_user_input": True, + "obsidian_installed": install["installed"], + "candidates": existing, + "suggested_defaults": [str(p) for p in candidate_default_vault_paths()[:3]], + "message": ( + "未检测到可用的 Obsidian 库路径。解读仍可写入 outputs/;" + "若希望入库(索引/Canvas/术语网),请提供库根目录。" + ), + } + + +def probe_obsidian_environment() -> dict: + """阅读模式对话开始前调用:完整探测报告。""" + install = detect_obsidian_installed() + vaults = detect_obsidian_vaults() + resolved = resolve_obsidian_vault() + status = "ready" if resolved.get("vault") and not resolved.get("needs_user_input") else "need_vault_path" + if not install["installed"] and not resolved.get("vault"): + status = "obsidian_optional_missing" + + return { + "status": status, + "obsidian_required": False, + "obsidian_installed": install["installed"], + "install_evidence": install["evidence"], + "vaults": vaults, + "resolved": resolved, + "env_var": "PATENT_READER_OBSIDIAN_VAULT", + "persisted_config": str(persisted_vault_config_path()), + "hint_powershell": ( + '$env:PATENT_READER_OBSIDIAN_VAULT = "你的库路径"\n' + "python tools/patent_reader/check_obsidian_env.py --set \"你的库路径\"" + ), + "hint_bash": ( + 'export PATENT_READER_OBSIDIAN_VAULT="你的库路径"\n' + 'python tools/patent_reader/check_obsidian_env.py --set "你的库路径"' + ), + } + + +def runtime_config() -> dict[str, str]: + resolved = resolve_obsidian_vault() + vault = (resolved.get("vault") or "").strip() + # 仅当目录存在时采用自动探测结果,避免脏路径 + if vault and not Path(vault).is_dir(): + # 环境变量显式指定时仍保留(用户可能稍后创建) + if resolved.get("source") != "env": + vault = "" + + papers_dir = os.environ.get("PATENT_READER_PAPERS_DIR", "Research/Patents").strip( + "/\\" + ) or "Research/Patents" + glossary_dir = os.environ.get("PATENT_READER_GLOSSARY_DIR", "Research/术语").strip( + "/\\" + ) or "Research/术语" + output_dir = os.environ.get( + "PATENT_READER_OUTPUT_DIR", "outputs/patent_reader" + ).strip() + return { + "obsidian_vault": vault, + "papers_dir": papers_dir, + "glossary_dir": glossary_dir, + "output_dir": output_dir, + "vault_source": str(resolved.get("source") or ""), + } + + +def slugify_term(term: str) -> str: + s = re.sub(r"[^\w\u4e00-\u9fff\-]+", "_", term.strip(), flags=re.UNICODE) + return s.strip("_")[:60] or "term" + + +def slugify_pub(pub: str) -> str: + s = re.sub(r"[^\w\-]+", "_", pub.strip(), flags=re.UNICODE) + return s.strip("_") or "patent" + + +def guess_independent(claim_text: str) -> bool: + body = re.sub(r"^\s*\d+\s*[.\.、]\s*", "", claim_text.strip()) + if re.match(r"^(一种|一種|a |an )", body, re.I): + return True + low = body.lower() + return not any(p.lower() in low for p in DEP_PATTERNS) + + +def parent_claim_numbers(claim_text: str) -> list[int]: + """解析从属权引用的全部权号(含「权1或2」「claims 1-3」)。""" + text = claim_text or "" + nums: list[int] = [] + + def _extend_chunk(chunk: str) -> None: + for n in re.findall(r"\d+", chunk or ""): + try: + nums.append(int(n)) + except ValueError: + continue + + for m in re.finditer( + r"(?:根据|如|按照|按|依据)(?:前述)?权利要求\s*" + r"([\d或与以及至到、,,\s与和及\-–—]+)", + text, + ): + _extend_chunk(m.group(1)) + for m in re.finditer( + r"according to claims?\s*([\d\s,orand\-–—]+)", + text, + re.I, + ): + _extend_chunk(m.group(1)) + for m in re.finditer( + r"权利要求?\s*(\d+)\s*或\s*(?:权利要求?\s*)?(\d+)", + text, + ): + nums.extend([int(m.group(1)), int(m.group(2))]) + # 去重保序 + return list(dict.fromkeys(n for n in nums if n > 0)) + + +def parent_claim_number(claim_text: str) -> int | None: + """启发式单父号:取引用列表首个(多选一时由 Agent 校对 claim_tree)。""" + nums = parent_claim_numbers(claim_text) + return nums[0] if nums else None + + +def normalize_claim_tree(tree: dict | None) -> dict: + """规范化权项树:独立权清 parent、重建 roots、修正悬空父号。""" + if not isinstance(tree, dict): + return {"roots": [], "nodes": []} + nodes_in = list(tree.get("nodes") or []) + nodes: list[dict] = [] + seen: set[int] = set() + for raw in nodes_in: + if not isinstance(raw, dict): + continue + try: + num = int(raw.get("number")) + except (TypeError, ValueError): + continue + if num <= 0 or num in seen: + continue + seen.add(num) + node = dict(raw) + node["number"] = num + indep = bool(node.get("is_independent")) + node["is_independent"] = indep + parent = node.get("parent") + try: + parent_i = int(parent) if parent is not None else None + except (TypeError, ValueError): + parent_i = None + if indep: + parent_i = None + elif parent_i is not None and parent_i == num: + parent_i = None + node["parent"] = parent_i + # 保留多引用候选供 Agent/展示 + cands = node.get("parent_candidates") + if isinstance(cands, list): + cleaned = [] + for x in cands: + try: + xi = int(x) + except (TypeError, ValueError): + continue + if xi > 0 and xi != num: + cleaned.append(xi) + node["parent_candidates"] = list(dict.fromkeys(cleaned)) + nodes.append(node) + + by_num = {n["number"]: n for n in nodes} + # 悬空父号 → 挂到最近的更小编号独立权,否则前一条 + for n in nodes: + if n["is_independent"]: + n["parent"] = None + continue + p = n.get("parent") + if p in by_num and p != n["number"]: + continue + cands = [c for c in (n.get("parent_candidates") or []) if c in by_num] + if cands: + n["parent"] = cands[0] + continue + fallback = next( + ( + x["number"] + for x in reversed(nodes) + if x["number"] < n["number"] and x.get("is_independent") + ), + None, + ) + if fallback is None: + fallback = next( + (x["number"] for x in reversed(nodes) if x["number"] < n["number"]), + None, + ) + n["parent"] = fallback + + # 断环:若沿 parent 走回自身,改为挂最近独立权 + for n in nodes: + if n["is_independent"]: + continue + seen_path: set[int] = set() + cur: int | None = n["number"] + guard = 0 + while cur is not None and guard < 64: + if cur in seen_path: + n["parent"] = next( + ( + x["number"] + for x in reversed(nodes) + if x["number"] < n["number"] and x.get("is_independent") + ), + None, + ) + break + seen_path.add(cur) + cur = (by_num.get(cur) or {}).get("parent") + guard += 1 + + roots = [n["number"] for n in nodes if n.get("is_independent")] + out = {**tree, "roots": roots, "nodes": nodes} + return out + + +def validate_claim_tree(tree: dict | None) -> dict: + """校验权项树;返回 passed/issues/warnings/count。""" + issues: list[str] = [] + warnings: list[str] = [] + norm = normalize_claim_tree(tree) + nodes = norm.get("nodes") or [] + if not nodes: + warnings.append("empty_claim_tree") + return { + "passed": True, + "issues": issues, + "warnings": warnings, + "count": 0, + "tree": norm, + } + by_num = {n["number"]: n for n in nodes} + for n in nodes: + num = n["number"] + if n.get("is_independent"): + if n.get("parent") is not None: + issues.append(f"claim[{num}]:independent_has_parent") + else: + p = n.get("parent") + if p is None: + issues.append(f"claim[{num}]:dependent_missing_parent") + elif p not in by_num: + issues.append(f"claim[{num}]:parent_not_found:{p}") + elif p == num: + issues.append(f"claim[{num}]:parent_self") + cands = n.get("parent_candidates") or [] + if isinstance(cands, list) and len(cands) >= 2: + warnings.append(f"claim[{num}]:multi_parent_candidates:{cands}") + if n.get("parent") not in cands and n.get("parent") is not None: + warnings.append( + f"claim[{num}]:parent_not_in_candidates:{n.get('parent')}" + ) + + # 环检测 + for n in nodes: + if n.get("is_independent"): + continue + seen_path: set[int] = set() + cur: int | None = n["number"] + guard = 0 + while cur is not None and guard < 64: + if cur in seen_path: + issues.append(f"claim[{n['number']}]:cycle_via:{cur}") + break + seen_path.add(cur) + cur = (by_num.get(cur) or {}).get("parent") + guard += 1 + + declared_roots = list(norm.get("roots") or []) + expected = [n["number"] for n in nodes if n.get("is_independent")] + if declared_roots != expected: + warnings.append(f"roots_mismatch:declared={declared_roots}:expected={expected}") + + review = (tree or {}).get("review") if isinstance(tree, dict) else None + if not (isinstance(review, dict) and str(review.get("by") or "").lower() in ("agent", "human")): + warnings.append("not_agent_reviewed") + + return { + "passed": len(issues) == 0, + "issues": issues, + "warnings": warnings, + "count": len(nodes), + "tree": norm, + } + + +def load_domain_rules() -> list[dict]: + path = ROOT / "references" / "patent_domain_rules.yaml" + if not path.is_file(): + return [{"label": "未分类", "ipc_prefixes": [], "keywords": []}] + domains: list[dict] = [] + current: dict | None = None + mode: str | None = None + for line in path.read_text(encoding="utf-8").splitlines(): + stripped = line.strip() + if stripped.startswith("- label:"): + if current: + domains.append(current) + current = { + "label": stripped.split(":", 1)[1].strip(), + "ipc_prefixes": [], + "keywords": [], + } + mode = None + elif stripped.startswith("ipc_prefixes:"): + mode = "ipc" + elif stripped.startswith("keywords:"): + mode = "kw" + elif stripped.startswith("- ") and current is not None and mode: + val = stripped[2:].strip().strip('"') + if mode == "ipc": + current["ipc_prefixes"].append(val) + else: + current["keywords"].append(val) + if current: + domains.append(current) + return domains or [{"label": "未分类", "ipc_prefixes": [], "keywords": []}] + + +def resolve_domain(text: str, ipc: str = "") -> str: + low = (text or "").lower() + ipc = (ipc or "").upper() + for dom in load_domain_rules(): + label = dom.get("label") or "未分类" + for prefix in dom.get("ipc_prefixes") or []: + if ipc.startswith(prefix.upper()): + return label + for kw in dom.get("keywords") or []: + if kw.lower() in low: + return label + return "未分类" + + +def _parse_inline_yaml_list(raw: str) -> list[str] | None: + raw = raw.strip() + if not (raw.startswith("[") and raw.endswith("]")): + return None + inner = raw[1:-1].strip() + if not inner: + return [] + return [p.strip().strip('"').strip("'") for p in inner.split(",") if p.strip()] + + +def _parse_yaml_list_block(lines: list[str], start: int) -> tuple[list[str], int]: + """解析 YAML 列表;遇到新 hint(- ipc_prefix:)或其它映射键则停止。""" + items: list[str] = [] + i = start + while i < len(lines): + stripped = lines[i].strip() + if not stripped or stripped.startswith("#"): + i += 1 + continue + if stripped.startswith("- ipc_prefix:"): + break + if stripped.startswith("- "): + items.append(stripped[2:].strip().strip('"').strip("'")) + i += 1 + continue + # 同级或更外层的 key: value + if re.match(r"^[a-zA-Z_][\w]*:", stripped): + break + break + return items, i + + +def load_ipc_application_hints() -> list[dict]: + path = ROOT / "references" / "ipc_application_hints.yaml" + if not path.is_file(): + return [] + # 优先 PyYAML(若已安装) + try: + import yaml # type: ignore + + data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + hints = data.get("hints") or [] + out: list[dict] = [] + for h in hints: + if not isinstance(h, dict): + continue + out.append( + { + "ipc_prefix": str(h.get("ipc_prefix") or "").strip(), + "keywords": list(h.get("keywords") or []), + "industry": str(h.get("industry") or "").strip(), + "typical_modules": list(h.get("typical_modules") or []), + "user_scenarios": list(h.get("user_scenarios") or []), + "search_hints": list(h.get("search_hints") or []), + } + ) + if out: + return out + except Exception: + pass + + hints: list[dict] = [] + current: dict | None = None + lines = path.read_text(encoding="utf-8").splitlines() + i = 0 + list_fields = ("keywords", "typical_modules", "user_scenarios", "search_hints") + while i < len(lines): + stripped = lines[i].strip() + if stripped.startswith("- ipc_prefix:"): + if current: + hints.append(current) + current = { + "ipc_prefix": stripped.split(":", 1)[1].strip(), + "keywords": [], + "industry": "", + "typical_modules": [], + "user_scenarios": [], + "search_hints": [], + } + i += 1 + continue + if current is None: + i += 1 + continue + matched_list = False + for field in list_fields: + if stripped.startswith(f"{field}:"): + rest = stripped.split(":", 1)[1].strip() + inline = _parse_inline_yaml_list(rest) + if inline is not None: + current[field] = inline + i += 1 + else: + i += 1 + items, i = _parse_yaml_list_block(lines, i) + current[field] = items + matched_list = True + break + if matched_list: + continue + if stripped.startswith("industry:"): + current["industry"] = stripped.split(":", 1)[1].strip() + i += 1 + if current: + hints.append(current) + return hints + + +def resolve_ipc_hints(text: str, ipc_codes: list[str] | None = None) -> dict: + """按 IPC 前缀或关键词匹配离线应用场景提示。""" + hints = load_ipc_application_hints() + ipc_codes = [c.upper() for c in (ipc_codes or [])] + low = (text or "").lower() + + for hint in hints: + prefix = (hint.get("ipc_prefix") or "").upper() + if prefix and prefix != "DEFAULT": + for code in ipc_codes: + if code.startswith(prefix): + return {**hint, "matched_by": f"ipc:{code}"} + + for hint in hints: + if hint.get("ipc_prefix") == "DEFAULT": + continue + for kw in hint.get("keywords") or []: + if kw.lower() in low: + return {**hint, "matched_by": f"keyword:{kw}"} + + for hint in hints: + if hint.get("ipc_prefix") == "DEFAULT": + return {**hint, "matched_by": "default"} + return { + "ipc_prefix": "DEFAULT", + "industry": "通用技术", + "typical_modules": ["按权利要求中的功能模块理解"], + "user_scenarios": ["结合说明书实施例与背景技术推断"], + "search_hints": ["技术 解决方案"], + "matched_by": "fallback", + } + + +def extract_assignees(text: str) -> list[str]: + """从专利文本启发式抽取申请人/专利权人。""" + assignees: list[str] = [] + patterns = [ + r"(?:申请(?:人|单位)|专利权人|申请人)\s*[::]\s*([^\n;;]{2,80})", + r"(?:Applicant|Assignee)\s*[::]\s*([^\n;]{2,80})", + ] + for pat in patterns: + for m in re.finditer(pat, text, re.I): + name = m.group(1).strip().strip(";;,,") + if name and name not in assignees: + assignees.append(name) + return assignees[:5] + + +def extract_ipc_codes(text: str) -> list[str]: + codes = re.findall(r"\b([A-H]\d{2}[A-Z]\d+/\d+)\b", text, re.I) + seen: list[str] = [] + for c in codes: + up = c.upper() + if up not in seen: + seen.append(up) + return seen[:10] diff --git a/.agents/skills/patent-disclosure-skill/tools/patent_reader/desc_paragraphs.py b/.agents/skills/patent-disclosure-skill/tools/patent_reader/desc_paragraphs.py new file mode 100644 index 0000000..0c55796 --- /dev/null +++ b/.agents/skills/patent-disclosure-skill/tools/patent_reader/desc_paragraphs.py @@ -0,0 +1,380 @@ +"""说明书段落锚点:解析 [000N]、生成旁路笔记、改写为可悬停预览的 wikilink。 + +约定: +- 展示:说明书 0002 / 说明书 0002–0004(区间为**一条**链接) +- 单段:[[{pub}_说明书段落#^p0002|说明书 0002]] +- 区间:[[{pub}_说明书段落#^r0002-0004|说明书 0002–0004]] +- 锚点笔记用块 ID,便于 Obsidian「页面预览」悬停浮出正文 +- 默认仅含本解读引用到的段落 +""" +from __future__ import annotations + +import json +import re +from pathlib import Path + +CN_PARA_SPLIT_RE = re.compile(r"\[(\d{4})\]") +# 旧写法:[0002] 或 [0002]–[0004] +BRACKET_CITE_RE = re.compile( + r"\[(\d{4})\](?:\s*[–—\-]\s*\[(\d{4})\])?" +) +# 新写法(未链):说明书 0002 或 说明书 0002–0004 +PLAIN_CITE_RE = re.compile( + r"(? str: + t = (text or "").replace("\r\n", "\n").strip() + t = PAGE_NOISE_RE.sub("", t) + t = re.sub(r"\n{3,}", "\n\n", t) + return t.strip() + + +def split_cn_description_paragraphs(text: str) -> dict[str, str]: + """从全文或说明书区提取官方段落号 → 正文。""" + if not text: + return {} + matches = list(CN_PARA_SPLIT_RE.finditer(text)) + if not matches: + return {} + out: dict[str, str] = {} + for i, m in enumerate(matches): + num = m.group(1) + start = m.end() + end = matches[i + 1].start() if i + 1 < len(matches) else len(text) + body = clean_paragraph_text(text[start:end]) + if not body: + continue + prev = out.get(num, "") + if len(body) > len(prev): + out[num] = body + return out + + +def description_text_from_raw_sections(raw_sections_path: Path) -> str: + if not raw_sections_path.is_file(): + return "" + chunks: list[str] = [] + with raw_sections_path.open(encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line: + continue + try: + obj = json.loads(line) + except json.JSONDecodeError: + continue + if obj.get("kind") in ("description", "description_para"): + chunks.append(obj.get("text") or "") + return "\n\n".join(chunks) + + +def load_description_paragraphs(workdir: Path | None) -> dict[str, str]: + """优先 description_paragraphs.json,否则从 raw_sections.jsonl 解析。""" + if workdir is None: + return {} + jp = workdir / "description_paragraphs.json" + if jp.is_file(): + try: + data = json.loads(jp.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + data = None + if isinstance(data, dict): + paras = data.get("paragraphs") if "paragraphs" in data else data + if isinstance(paras, dict): + return { + str(k).zfill(4)[-4:]: clean_paragraph_text(str(v)) + for k, v in paras.items() + if re.fullmatch(r"\d{1,4}", str(k)) and str(v).strip() + } + raw = workdir / "raw_sections.jsonl" + return split_cn_description_paragraphs(description_text_from_raw_sections(raw)) + + +def write_description_paragraphs_json( + out_path: Path, paragraphs: dict[str, str], *, pub: str = "" +) -> Path: + payload = { + "pub_number": pub, + "count": len(paragraphs), + "paragraphs": {k: paragraphs[k] for k in sorted(paragraphs)}, + } + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_text( + json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8" + ) + return out_path + + +def _protect_zones(content: str) -> tuple[str, list[str]]: + """暂时替换 fenced code / 已有 wikilink,避免二次改写。""" + vault: list[str] = [] + + def stash(m: re.Match[str]) -> str: + vault.append(m.group(0)) + return f"\x00PD{len(vault) - 1}\x00" + + tmp = re.sub(r"```[\s\S]*?```", stash, content) + tmp = re.sub(r"\[\[[^\]]+\]\]", stash, tmp) + return tmp, vault + + +def _restore_zones(content: str, vault: list[str]) -> str: + def unstash(m: re.Match[str]) -> str: + return vault[int(m.group(1))] + + return re.sub(r"\x00PD(\d+)\x00", unstash, content) + + +def _expand_range(a: str, b: str | None) -> list[str]: + if not b or b == a: + return [a] + lo, hi = int(a), int(b) + if lo > hi or hi - lo > 50: + return [a, b] + return [f"{n:04d}" for n in range(lo, hi + 1)] + + +def parse_cited_ranges(content: str) -> list[tuple[str, str | None]]: + """返回引用列表 [(start, end|None), ...],去重保序。""" + out: list[tuple[str, str | None]] = [] + seen: set[tuple[str, str | None]] = set() + + def add(a: str, b: str | None) -> None: + key = (a, b if b and b != a else None) + if key in seen: + return + seen.add(key) + out.append(key) + + for m in SPLIT_RANGE_WIKILINK_RE.finditer(content): + add(m.group(1), m.group(2)) + for m in WIKILINK_CITE_RE.finditer(content): + if m.group(1): + add(m.group(1), None) + elif m.group(2) and m.group(3): + add(m.group(2), m.group(3)) + for m in BRACKET_CITE_RE.finditer(content): + add(m.group(1), m.group(2)) + for m in PLAIN_CITE_RE.finditer(content): + add(m.group(1), m.group(2)) + return out + + +def parse_cited_paragraph_numbers(content: str) -> list[str]: + """从笔记中收集被引用的段落号(展开区间)。""" + found: set[str] = set() + for a, b in parse_cited_ranges(content): + found.update(_expand_range(a, b)) + return sorted(found) + + +def paragraph_anchor_basename(pub: str) -> str: + try: + from common import slugify_pub + except ImportError: + from tools.patent_reader.common import slugify_pub + + return f"{slugify_pub(pub)}_说明书段落" + + +def _para_body(paragraphs: dict[str, str], num: str) -> str: + return paragraphs.get(num) or paragraphs.get(num.lstrip("0") or "0") or "" + + +def render_paragraph_anchor_note( + *, + pub: str, + paragraphs: dict[str, str], + cited: list[str], + ranges: list[tuple[str, str]] | None = None, +) -> str: + """生成锚点笔记:单段 ### + ^p;区间另设合并节 + ^r(供悬停一次看全)。""" + lines = [ + "---", + "tags:", + " - patent/description-paragraphs", + f"pub_number: {pub}", + "cssclasses:", + " - patent-reader", + "---", + f"# {pub} 说明书段落", + "", + "## 使用说明", + "", + "1. **设置**:Obsidian → 设置 → 核心插件 → **页面预览(Page preview)** → 打开", + "2. **使用**:在解读笔记中,**按住 Ctrl** 再将鼠标悬停在「说明书 …」链接上,即可预览本段原文;单击仍可跳转至此。", + "", + "> 本页默认仅含解读笔记中引用到的段落;区间引用另见下方「区间」节。", + "", + "## 单段", + "", + ] + for num in cited: + body = _para_body(paragraphs, num) + if not body: + body = "(原文未解析到该段,请对照官方 PDF。)" + lines.extend( + [ + f"### {num}", + "", + body, + "", + f"^p{num}", + "", + ] + ) + + range_list = ranges or [] + if range_list: + lines.extend(["## 区间(悬停预览用)", ""]) + for start, end in range_list: + if start == end: + continue + nums = _expand_range(start, end) + lines.extend([f"### {start}–{end}", ""]) + for num in nums: + body = _para_body(paragraphs, num) or "(缺)" + lines.extend([f"**{num}**", "", body, ""]) + lines.extend([f"^r{start}-{end}", ""]) + + return "\n".join(lines).rstrip() + "\n" + + +def _link_single(pub: str, num: str) -> str: + base = paragraph_anchor_basename(pub) + return f"[[{base}#^p{num}|说明书 {num}]]" + + +def _link_range(pub: str, start: str, end: str) -> str: + base = paragraph_anchor_basename(pub) + return f"[[{base}#^r{start}-{end}|说明书 {start}–{end}]]" + + +def format_citation_wikilinks(pub: str, start: str, end: str | None = None) -> str: + if not end or end == start: + return _link_single(pub, start) + return _link_range(pub, start, end) + + +def upgrade_legacy_citation_wikilinks(content: str, *, pub: str) -> str: + """把旧双链/旧标题锚升级为块锚单链。""" + + def repl_split(m: re.Match[str]) -> str: + return format_citation_wikilinks(pub, m.group(1), m.group(2)) + + def repl_old_single(m: re.Match[str]) -> str: + return _link_single(pub, m.group(2)) + + out = SPLIT_RANGE_WIKILINK_RE.sub(repl_split, content) + out = OLD_SINGLE_WIKILINK_RE.sub(repl_old_single, out) + return out + + +def wikilink_description_citations(content: str, *, pub: str) -> str: + """将 [0002]/说明书 0002 等改写为可预览 wikilink。""" + content = upgrade_legacy_citation_wikilinks(content, pub=pub) + protected, vault = _protect_zones(content) + + def repl_bracket(m: re.Match[str]) -> str: + return format_citation_wikilinks(pub, m.group(1), m.group(2)) + + def repl_plain(m: re.Match[str]) -> str: + return format_citation_wikilinks(pub, m.group(1), m.group(2)) + + out = BRACKET_CITE_RE.sub(repl_bracket, protected) + out = PLAIN_CITE_RE.sub(repl_plain, out) + return _restore_zones(out, vault) + + +def ensure_desc_paragraphs_nav(content: str, *, pub: str) -> str: + """在 Obsidian 导航列表中补说明书段落入口(无括号说明;用法见该笔记正文)。""" + base = paragraph_anchor_basename(pub) + needle = f"[[{base}|说明书段落]]" + # 去掉历史括号说明 + content = re.sub( + rf"(-\s*\[\[{re.escape(base)}\|[^\]]*\]\])([^)\n]*)", + r"\1", + content, + ) + if needle in content or f"{base}|" in content: + return content + m = re.search(r"(##\s*Obsidian 导航\s*\n)([\s\S]*?)(?=\n##\s|\n> \[!|\Z)", content) + if not m: + return content + block = m.group(2) + if not block.strip().startswith("-"): + return content + lines = block.rstrip().splitlines() + insert_at = len(lines) + for i, line in enumerate(lines): + if "图谱" in line or "canvas" in line.lower(): + insert_at = i + 1 + break + lines.insert(insert_at, f"- {needle}") + new_block = "\n".join(lines) + "\n" + return content[: m.start(2)] + new_block + content[m.end(2) :] + + +def materialize_description_paragraphs( + *, + content: str, + pub: str, + note_dir: Path, + paragraphs: dict[str, str], + cited_only: bool = True, +) -> tuple[str, Path | None, list[str]]: + """生成锚点笔记并改写正文引用。返回 (new_content, path|None, cited_nums)。""" + ranges_raw = parse_cited_ranges(content) + # 也解析升级前的旧双链 + content_for_parse = content + cited = parse_cited_paragraph_numbers(content_for_parse) + ranges = [(a, b) for a, b in ranges_raw if b and b != a] + + if not cited and not paragraphs: + return content, None, [] + if cited_only: + if not cited: + content2 = wikilink_description_citations(content, pub=pub) + content2 = ensure_desc_paragraphs_nav(content2, pub=pub) + return content2, None, [] + selected = cited + else: + selected = sorted(set(cited) | set(paragraphs)) + + note_body = render_paragraph_anchor_note( + pub=pub, + paragraphs=paragraphs, + cited=selected, + ranges=ranges, + ) + dest = note_dir / f"{paragraph_anchor_basename(pub)}.md" + note_dir.mkdir(parents=True, exist_ok=True) + dest.write_text(note_body, encoding="utf-8") + + content2 = wikilink_description_citations(content, pub=pub) + content2 = ensure_desc_paragraphs_nav(content2, pub=pub) + return content2, dest, selected diff --git a/.agents/skills/patent-disclosure-skill/tools/patent_reader/extract_patent_figures.py b/.agents/skills/patent-disclosure-skill/tools/patent_reader/extract_patent_figures.py new file mode 100644 index 0000000..61da2ef --- /dev/null +++ b/.agents/skills/patent-disclosure-skill/tools/patent_reader/extract_patent_figures.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +""" +从专利 PDF 抽取附图(caption+bbox 裁切 + xref 回退 + 质量门)。 + +按专利图注「图 N / FIG. N」锚定邻近矢量/嵌入图 bbox,裁切 PNG 并做质量门决策。 + +依赖:pip install -r tools/patent_reader/requirements.txt + +用法: + python tools/patent_reader/extract_patent_figures.py -i patent.pdf -o tmp/run/figures + python tools/patent_reader/extract_patent_figures.py -i patent.pdf -o tmp/run/figures --include-review + # 产出 figures/manifest.json(含 decision=insert|placeholder)与 PNG +""" +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +try: + from figure_extract import extract_patent_pdf_figures +except ImportError: + from tools.patent_reader.figure_extract import extract_patent_pdf_figures + + +def main(argv: list[str] | None = None) -> int: + if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("-i", "--input", required=True, type=Path) + ap.add_argument("-o", "--output", required=True, type=Path) + ap.add_argument("--dpi", type=int, default=200) + ap.add_argument("--min-xref-bytes", type=int, default=8000) + ap.add_argument( + "--xref-only", + action="store_true", + help="仅 xref 嵌入图(旧行为)", + ) + ap.add_argument( + "--include-review", + action="store_true", + help="quality=review 的图也标为 decision=insert(人工确认后少丢可用图)", + ) + args = ap.parse_args(argv) + + try: + import fitz # noqa: F401 + except ImportError: + print("需安装 pymupdf:pip install pymupdf", file=sys.stderr) + return 1 + + pdf_path = args.input.resolve() + if not pdf_path.is_file(): + print(f"错误:找不到 {pdf_path}", file=sys.stderr) + return 1 + + out_dir = args.output.resolve() + manifest = extract_patent_pdf_figures( + pdf_path, + out_dir, + dpi=args.dpi, + min_xref_bytes=args.min_xref_bytes, + prefer_figure_level=not args.xref_only, + include_review=args.include_review, + ) + manifest_path = out_dir / "manifest.json" + manifest_path.write_text( + json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8" + ) + print( + f"OK figures={manifest['count']} insert={manifest['insert_count']} " + f"placeholder={manifest['placeholder_count']}" + ) + print(f"FIGURES_MANIFEST: {manifest_path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.agents/skills/patent-disclosure-skill/tools/patent_reader/extract_patent_text.py b/.agents/skills/patent-disclosure-skill/tools/patent_reader/extract_patent_text.py new file mode 100644 index 0000000..007ac88 --- /dev/null +++ b/.agents/skills/patent-disclosure-skill/tools/patent_reader/extract_patent_text.py @@ -0,0 +1,400 @@ +#!/usr/bin/env python3 +""" +从专利全文(.txt / .md / .pdf)抽取结构化片段,供专利通俗解读使用。 + +产出(在 -o 目录下): + source_manifest.json 章节与覆盖率 + raw_sections.jsonl 每行一节(claim_n / abstract / description) + claim_tree.json 权利要求父子树 + synthesis_bundle.json 模型阅读入口 + +PDF 需:pip install pymupdf + +用法: + python tools/patent_reader/extract_patent_text.py -i patent.md -o tmp/patent_reader/run1 + python tools/patent_reader/extract_patent_text.py -i patent.pdf -o tmp/run1 --pub-number CN107785522B + python tools/patent_reader/extract_patent_text.py -i abstract_only.txt -o tmp/run1 --abstract-only +""" +from __future__ import annotations + +import argparse +import json +import re +import sys +from pathlib import Path + +try: + from common import ( + extract_assignees, + extract_ipc_codes, + guess_independent, + normalize_claim_tree, + parent_claim_number, + parent_claim_numbers, + slugify_pub, + ) +except ImportError: + from tools.patent_reader.common import ( + extract_assignees, + extract_ipc_codes, + guess_independent, + normalize_claim_tree, + parent_claim_number, + parent_claim_numbers, + slugify_pub, + ) + +CLAIM_START_RE = re.compile( + r"^\s*(\d+)\s*[.\.、]\s*(.+)", + re.DOTALL, +) + + +def read_input(path: Path) -> str: + if path.suffix.lower() == ".pdf": + try: + import fitz # type: ignore + except ImportError as e: + raise SystemExit( + "PDF 需安装 pymupdf:pip install pymupdf" + ) from e + doc = fitz.open(path) + parts = [page.get_text() for page in doc] + doc.close() + return "\n".join(parts) + return path.read_text(encoding="utf-8", errors="replace") + + +def split_claims_block(text: str) -> list[dict]: + """按「数字.」切分权利要求块。""" + claims: list[dict] = [] + # 找权利要求书区域 + m = re.search( + r"(权利要求书|权\s*利\s*要\s*求)(.+)", + text, + re.I | re.DOTALL, + ) + block = m.group(2) if m else text + # 在说明书开始前截断 + for stop in ("说明书", "技术领域", "【书式", "附图说明"): + idx = block.find(stop) + if idx > 50: + block = block[:idx] + break + + parts = re.split(r"(?=\n\s*\d+\s*[.\.、])", "\n" + block) + for part in parts: + part = part.strip() + if not part: + continue + m = CLAIM_START_RE.match(part.replace("\n", " ", 1)[:2000]) + if not m and not CLAIM_START_RE.match(part.split("\n", 1)[0]): + continue + m2 = re.match(r"^\s*(\d+)\s*[.\.、]\s*", part, re.DOTALL) + if not m2: + continue + num = int(m2.group(1)) + body = part[m2.end() :].strip() + if len(body) < 4: + continue + cands = parent_claim_numbers(body) + claims.append( + { + "number": num, + "text": body, + "is_independent": guess_independent(f"{num}. {body}"), + "parent": cands[0] if cands else parent_claim_number(body), + "parent_candidates": cands, + } + ) + claims.sort(key=lambda c: c["number"]) + return claims + + +def extract_glossary(text: str) -> list[dict]: + """从说明书抽「本文中…是指」类定义(要求成对引号)。""" + glossary: list[dict] = [] + patterns = [ + # 「术语」是指/定义为… + r"(?:本文中[,,]?)?" + r"[\u300c\u300e\u201c\"]([^\u300d\u300f\u201d\"\n。]{1,30})" + r"[\u300d\u300f\u201d\"]" + r"\s*(?:是指|指|定义为|意为)\s*" + r"([^。\n]{4,120})", + # 术语是指…(无引号,短术语) + r"(?:本文中[,,]?)?" + r"([\u4e00-\u9fffA-Za-z][\u4e00-\u9fffA-Za-z0-9]{1,15})" + r"\s*是指\s*" + r"([^。\n]{4,120})", + ] + seen: set[str] = set() + for pattern in patterns: + for m in re.finditer(pattern, text): + term, defn = m.group(1).strip(), m.group(2).strip() + if "本文中" in term or "。" in term or len(term) < 2: + continue + if term.startswith("指"): + continue + if term in seen: + continue + seen.add(term) + glossary.append({"term": term, "definition": defn}) + return glossary[:40] + + +def extract_embodiments(text: str) -> list[dict]: + """抽取实施例/示例段落。""" + embodiments: list[dict] = [] + block_m = re.search( + r"(?:具体实施方式|实施例|DETAILED DESCRIPTION)([\s\S]+?)(?=\n\s*(?:附图说明|权利要求|【|$))", + text, + re.I, + ) + block = block_m.group(1) if block_m else text + for m in re.finditer( + r"(实施例\s*\d+|Example\s*\d+)[::]\s*([^\n]{10,500})", + block, + re.I, + ): + embodiments.append( + { + "label": m.group(1).strip(), + "text": m.group(2).strip(), + } + ) + if not embodiments: + for i, p in enumerate( + [x.strip() for x in re.split(r"\n\s*\n", block) if len(x.strip()) > 30][:5], + 1, + ): + if re.search(r"实施|例如|优选", p): + embodiments.append({"label": f"段落{i}", "text": p[:400]}) + return embodiments[:8] + + +def extract_background_snippets(text: str) -> list[str]: + """背景技术/技术领域短句。""" + snippets: list[str] = [] + for label in ("背景技术", "技术领域", "BACKGROUND"): + m = re.search(rf"{label}\s*([\s\S]{{20,800}}?)(?=\n\s*(?:发明内容|具体实施|附图|权利要求))", text, re.I) + if m: + para = m.group(1).strip().split("\n")[0][:300] + if para: + snippets.append(para) + return snippets[:3] + + +def build_claim_tree(claims: list[dict]) -> dict: + nodes = [] + for c in claims: + parent = c.get("parent") + cands = list(c.get("parent_candidates") or []) + if c["is_independent"]: + parent = None + cands = [] + elif parent is None: + # 从属但未解析到父号:挂到前一条 + parent = next( + (p["number"] for p in reversed(claims) if p["number"] < c["number"]), + None, + ) + node = { + "number": c["number"], + "is_independent": c["is_independent"], + "parent": parent, + "text_preview": c["text"][:200], + } + if cands: + node["parent_candidates"] = cands + nodes.append(node) + return normalize_claim_tree({"roots": [], "nodes": nodes}) + + +def sections_from_text( + text: str, pub_number: str, abstract_only: bool +) -> tuple[list[dict], list[dict]]: + sections: list[dict] = [] + claims = [] if abstract_only else split_claims_block(text) + + abs_m = re.search( + r"(?:摘\s*要|ABSTRACT)\s*[::]?\s*([\s\S]{20,2000}?)(?=\n\s*(?:权利要求|说明书|【|$))", + text, + re.I, + ) + abstract = abs_m.group(1).strip() if abs_m else "" + if abstract: + sections.append( + { + "section_id": "abstract", + "kind": "abstract", + "text": abstract, + } + ) + + for c in claims: + sections.append( + { + "section_id": f"claim_{c['number']}", + "kind": "claim", + "number": c["number"], + "text": c["text"], + "is_independent": c["is_independent"], + } + ) + + desc_m = re.search( + r"(?:说明书|技术领域)([\s\S]+)", + text, + re.I, + ) + desc = desc_m.group(1).strip() if desc_m and not abstract_only else "" + if desc: + # 按段落切 desc_001 ... + paras = [p.strip() for p in re.split(r"\n\s*\n", desc) if len(p.strip()) > 20] + for i, p in enumerate(paras[:80], 1): + sections.append( + { + "section_id": f"desc_{i:03d}", + "kind": "description", + "text": p[:4000], + } + ) + + return sections, claims + + +def main(argv: list[str] | None = None) -> int: + if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("-i", "--input", required=True, type=Path) + ap.add_argument("-o", "--output", required=True, type=Path) + ap.add_argument("--pub-number", default="", help="公开号,如 CN107785522B") + ap.add_argument( + "--abstract-only", + action="store_true", + help="仅摘要级(无权利要求正文时)", + ) + args = ap.parse_args(argv) + + in_path = args.input.resolve() + if not in_path.is_file(): + print(f"错误:找不到 {in_path}", file=sys.stderr) + return 1 + + text = read_input(in_path) + pub = args.pub_number.strip() or slugify_pub(in_path.stem).upper() + if re.match(r"^CN\d", pub, re.I): + pub = pub.upper() + + out_dir = args.output.resolve() + out_dir.mkdir(parents=True, exist_ok=True) + + sections, claims = sections_from_text(text, pub, args.abstract_only) + glossary = extract_glossary(text) + claim_tree = build_claim_tree(claims) + assignees = extract_assignees(text) + ipc_codes = extract_ipc_codes(text) + embodiments = extract_embodiments(text) if not args.abstract_only else [] + background_snippets = extract_background_snippets(text) if not args.abstract_only else [] + + try: + from desc_paragraphs import ( + split_cn_description_paragraphs, + write_description_paragraphs_json, + ) + except ImportError: + from tools.patent_reader.desc_paragraphs import ( + split_cn_description_paragraphs, + write_description_paragraphs_json, + ) + + description_paragraphs = ( + {} if args.abstract_only else split_cn_description_paragraphs(text) + ) + + has_claims_heading = bool( + re.search(r"(权利要求书|权\s*利\s*要\s*求|CLAIMS?)", text, re.I) + ) + if args.abstract_only: + scope = "abstract_only" + elif claims: + scope = "full_text" if sections else "partial" + elif has_claims_heading: + scope = "partial" # 有权项标题但解析失败,勿误标仅摘要 + else: + scope = "abstract_only" + + manifest = { + "pub_number": pub, + "source_path": str(in_path), + "evidence_scope": scope, + "claims_parse_failed": bool(has_claims_heading and not claims and not args.abstract_only), + "section_count": len(sections), + "claim_count": len(claims), + "independent_claim_count": sum(1 for c in claims if c["is_independent"]), + "glossary_count": len(glossary), + "assignees": assignees, + "ipc_codes": ipc_codes, + "embodiment_count": len(embodiments), + "description_paragraph_count": len(description_paragraphs), + } + + jsonl_path = out_dir / "raw_sections.jsonl" + with jsonl_path.open("w", encoding="utf-8") as f: + for sec in sections: + f.write(json.dumps(sec, ensure_ascii=False) + "\n") + + manifest_path = out_dir / "source_manifest.json" + manifest_path.write_text( + json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8" + ) + + tree_path = out_dir / "claim_tree.json" + tree_path.write_text( + json.dumps(claim_tree, ensure_ascii=False, indent=2), encoding="utf-8" + ) + + bundle = { + "manifest": manifest, + "claim_tree": claim_tree, + "glossary_candidates": glossary, + "embodiments": embodiments, + "background_snippets": background_snippets, + "assignees": assignees, + "ipc_codes": ipc_codes, + "claims": [ + {"number": c["number"], "text": c["text"], "is_independent": c["is_independent"]} + for c in claims + ], + "sections_preview": [ + {"section_id": s["section_id"], "kind": s.get("kind"), "len": len(s.get("text", ""))} + for s in sections + ], + } + bundle_path = out_dir / "synthesis_bundle.json" + bundle_path.write_text( + json.dumps(bundle, ensure_ascii=False, indent=2), encoding="utf-8" + ) + + para_path = None + if description_paragraphs: + para_path = write_description_paragraphs_json( + out_dir / "description_paragraphs.json", + description_paragraphs, + pub=pub, + ) + + print(f"OK claims={len(claims)} sections={len(sections)} scope={manifest['evidence_scope']}") + print(f"MANIFEST: {manifest_path}") + print(f"BUNDLE: {bundle_path}") + print(f"CLAIM_TREE: {tree_path}") + print(f"SECTIONS: {jsonl_path}") + if para_path: + print(f"DESC_PARAS: {para_path} ({len(description_paragraphs)})") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.agents/skills/patent-disclosure-skill/tools/patent_reader/fetch_patent_pdf.py b/.agents/skills/patent-disclosure-skill/tools/patent_reader/fetch_patent_pdf.py new file mode 100644 index 0000000..7a63c9f --- /dev/null +++ b/.agents/skills/patent-disclosure-skill/tools/patent_reader/fetch_patent_pdf.py @@ -0,0 +1,327 @@ +#!/usr/bin/env python3 +""" +按公开号下载专利全文 PDF(解读模式固化入口,勿每次现写脚本)。 + +默认链路(见 references/patent_pdf_sources.yaml): + 1) 用户已给本地 PDF / --url → 直接用 + 2) Google Patents 详情页解析 CDN(zh → en → 无语言后缀) + 3) 已知示例 CDN(references 里 known_cdn_examples,仅兜底) + 4) 失败时提示:用国知局 epub 核验公开号,或请用户自备 PDF + +用法: + python tools/patent_reader/fetch_patent_pdf.py --pub CN119961390A \\ + -o tmp/patent_reader/read-CN119961390A-YYYYMMDDHHmm + # → {outdir}/source/{PUB}.pdf + + python tools/patent_reader/fetch_patent_pdf.py --pub CN… -o RUN --save-html + python tools/patent_reader/fetch_patent_pdf.py --url https://…/CNxxx.pdf -o RUN --pub CNxxx +""" +from __future__ import annotations + +import argparse +import json +import re +import ssl +import sys +import urllib.error +import urllib.request +from pathlib import Path + +UA = "Mozilla/5.0 (compatible; patent-disclosure-skill/1.0)" +DEFAULT_TIMEOUT = 90 + +# 仓库根:tools/patent_reader/ → ../.. +_REPO_ROOT = Path(__file__).resolve().parents[2] +_SOURCES_YAML = _REPO_ROOT / "references" / "patent_pdf_sources.yaml" + +CDN_HOST_RE = re.compile( + r"https://patentimages\.storage\.googleapis\.com/[^\s\"'<>\\]+?\.pdf", + re.I, +) +CITATION_PDF_RE = re.compile( + r'name=["\']citation_pdf_url["\']\s+content=["\']([^"\']+)["\']' + r'|content=["\']([^"\']+)["\']\s+name=["\']citation_pdf_url["\']', + re.I, +) +PDF_LINK_RE = re.compile( + r'href=["\'](https://patentimages\.storage\.googleapis\.com/[^"\']+\.pdf)["\']', + re.I, +) + + +def normalize_pub(pub: str) -> str: + return re.sub(r"\s+", "", (pub or "").strip()).upper() + + +def load_known_cdn_examples(yaml_path: Path | None = None) -> dict[str, str]: + path = yaml_path or _SOURCES_YAML + if not path.is_file(): + return {} + text = path.read_text(encoding="utf-8") + # 轻量解析,避免强制 pyyaml 依赖 + out: dict[str, str] = {} + in_block = False + for line in text.splitlines(): + if line.strip().startswith("known_cdn_examples:"): + in_block = True + continue + if in_block: + if line and not line.startswith(" ") and not line.startswith("\t"): + break + m = re.match( + r"\s+([A-Z]{2}\d+[A-Z]?\d?)\s*:\s*[\"']([^\"']+)[\"']", + line, + ) + if m: + out[m.group(1).upper()] = m.group(2).strip() + return out + + +def google_patent_page_urls(pub: str) -> list[str]: + p = normalize_pub(pub) + return [ + f"https://patents.google.com/patent/{p}/zh", + f"https://patents.google.com/patent/{p}/en", + f"https://patents.google.com/patent/{p}", + ] + + +def extract_pdf_urls_from_html(html: str, pub: str) -> list[str]: + """从 Google Patents HTML 提取 PDF URL(去重,公开号匹配优先)。""" + pub_u = normalize_pub(pub) + found: list[str] = [] + + for m in CITATION_PDF_RE.finditer(html): + u = (m.group(1) or m.group(2) or "").strip() + if u: + found.append(u) + + for m in PDF_LINK_RE.finditer(html): + found.append(m.group(1).strip()) + + for m in CDN_HOST_RE.finditer(html): + found.append(m.group(0).rstrip(".,);]")) + + # 规范化去重,优先含公开号的 + uniq: list[str] = [] + seen: set[str] = set() + for u in found: + u = u.replace("&", "&") + if u in seen: + continue + seen.add(u) + uniq.append(u) + + prefer = [u for u in uniq if pub_u in u.upper()] + rest = [u for u in uniq if pub_u not in u.upper()] + return prefer + rest + + +def http_get( + url: str, + *, + timeout: int = DEFAULT_TIMEOUT, + binary: bool = False, +) -> bytes | str: + req = urllib.request.Request(url, headers={"User-Agent": UA}) + ctx = ssl.create_default_context() + with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp: + data = resp.read() + if binary: + return data + # Google 页多为 utf-8;失败则 replace + return data.decode("utf-8", errors="replace") + + +def download_pdf_bytes(url: str, *, timeout: int = 120) -> bytes: + data = http_get(url, timeout=timeout, binary=True) + assert isinstance(data, bytes) + if not data.startswith(b"%PDF"): + raise ValueError(f"not a PDF (magic={data[:8]!r}): {url}") + if len(data) < 5000: + raise ValueError(f"PDF too small ({len(data)} bytes): {url}") + return data + + +def resolve_pdf_url( + pub: str, + *, + timeout: int = DEFAULT_TIMEOUT, + save_html_dir: Path | None = None, + known_cdn: dict[str, str] | None = None, +) -> tuple[str, str, list[str]]: + """返回 (pdf_url, source_id, attempts_log)。""" + pub_u = normalize_pub(pub) + log: list[str] = [] + known = known_cdn if known_cdn is not None else load_known_cdn_examples() + + for i, page in enumerate(google_patent_page_urls(pub_u)): + try: + html = http_get(page, timeout=timeout) + assert isinstance(html, str) + log.append(f"ok_page:{page}:len={len(html)}") + if save_html_dir is not None: + save_html_dir.mkdir(parents=True, exist_ok=True) + (save_html_dir / f"_gp_{i}.html").write_text(html, encoding="utf-8") + urls = extract_pdf_urls_from_html(html, pub_u) + if urls: + log.append(f"cdn_from_page:{urls[0]}") + return urls[0], "google_patents_page", log + log.append(f"no_cdn_in_page:{page}") + except (urllib.error.URLError, TimeoutError, OSError, ValueError) as e: + log.append(f"fail_page:{page}:{type(e).__name__}:{e}") + + if pub_u in known: + log.append(f"known_cdn_example:{known[pub_u]}") + return known[pub_u], "known_cdn_examples", log + + raise FileNotFoundError( + "未能解析 PDF 直链。可:1) 检查网络后重试;2) 用 cnipa_epub_search 核验公开号;" + "3) 用户自备 PDF 后直接 extract。attempts=" + " | ".join(log) + ) + + +def fetch_patent_pdf( + pub: str, + outdir: Path, + *, + url: str = "", + timeout: int = DEFAULT_TIMEOUT, + save_html: bool = False, + force: bool = False, +) -> dict: + """下载到 {outdir}/source/{PUB}.pdf,返回状态 dict。""" + pub_u = normalize_pub(pub) + if not pub_u: + raise ValueError("empty pub number") + + outdir = Path(outdir) + source_dir = outdir / "source" + source_dir.mkdir(parents=True, exist_ok=True) + dest = source_dir / f"{pub_u}.pdf" + + status: dict = { + "pub": pub_u, + "outdir": str(outdir.resolve()), + "pdf_path": str(dest.resolve()), + "ok": False, + "source_id": "", + "pdf_url": "", + "bytes": 0, + "attempts": [], + } + + if dest.is_file() and dest.stat().st_size >= 5000 and not force: + head = dest.read_bytes()[:4] + if head == b"%PDF": + status.update( + { + "ok": True, + "source_id": "local_existing", + "bytes": dest.stat().st_size, + "attempts": ["skip_existing"], + } + ) + return status + + pdf_url = (url or "").strip() + source_id = "direct_url" + attempts: list[str] = [] + + if not pdf_url: + pdf_url, source_id, attempts = resolve_pdf_url( + pub_u, + timeout=timeout, + save_html_dir=(outdir if save_html else None), + ) + else: + attempts.append(f"direct_url:{pdf_url}") + + data = download_pdf_bytes(pdf_url, timeout=max(timeout, 120)) + dest.write_bytes(data) + + status.update( + { + "ok": True, + "source_id": source_id, + "pdf_url": pdf_url, + "bytes": len(data), + "attempts": attempts, + } + ) + return status + + +def main(argv: list[str] | None = None) -> int: + if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--pub", required=True, help="公开号,如 CN119961390A") + ap.add_argument( + "-o", + "--outdir", + type=Path, + required=True, + help="RUN 目录;PDF 写入 {outdir}/source/{PUB}.pdf", + ) + ap.add_argument("--url", default="", help="已知 PDF 直链时跳过页面解析") + ap.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT) + ap.add_argument( + "--save-html", + action="store_true", + help="保存 Google Patents HTML 到 outdir/_gp_*.html(排障)", + ) + ap.add_argument("--force", action="store_true", help="覆盖已有 PDF") + ap.add_argument( + "--status-json", + type=Path, + default=None, + help="写入状态 JSON(默认 {outdir}/fetch_pdf_status.json)", + ) + args = ap.parse_args(argv) + + try: + status = fetch_patent_pdf( + args.pub, + args.outdir, + url=args.url, + timeout=args.timeout, + save_html=args.save_html, + force=args.force, + ) + except Exception as e: + err = { + "ok": False, + "pub": normalize_pub(args.pub), + "error": f"{type(e).__name__}: {e}", + } + out_json = args.status_json or (args.outdir / "fetch_pdf_status.json") + args.outdir.mkdir(parents=True, exist_ok=True) + out_json.write_text( + json.dumps(err, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + print(f"FAIL {err['error']}", file=sys.stderr) + print( + "HINT: 无稳定国内免费全文镜像;可 cnipa_epub_search 核验后自备 PDF," + "或稍后重试 Google Patents / CDN。源表见 references/patent_pdf_sources.yaml", + file=sys.stderr, + ) + return 1 + + out_json = args.status_json or (args.outdir / "fetch_pdf_status.json") + out_json.write_text( + json.dumps(status, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + print( + f"OK pdf={status['pdf_path']} bytes={status['bytes']} " + f"source={status['source_id']}" + ) + print(f"FETCH_PDF_STATUS: {out_json}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.agents/skills/patent-disclosure-skill/tools/patent_reader/figure_extract.py b/.agents/skills/patent-disclosure-skill/tools/patent_reader/figure_extract.py new file mode 100644 index 0000000..d14fcd5 --- /dev/null +++ b/.agents/skills/patent-disclosure-skill/tools/patent_reader/figure_extract.py @@ -0,0 +1,556 @@ +"""专利附图抽取:图注锚定 + bbox 裁切 + 质量门。 + +能力: + - 检测「图 N / FIG. N」等图注 + - 按 bbox(矢量+嵌入图并集)从页面 pixmap 裁切 PNG + - 轻量质量门 usable / review / reject + - 决策 insert | placeholder + - 回退:无图注页时整页内容区裁切;仍保留 xref 抽图作补充 +""" +from __future__ import annotations + +import re +from pathlib import Path + +FIGURE_RENDER_DPI = 200 +MIN_FIGURE_HEIGHT_PT = 50 +MIN_FIGURE_WIDTH_PT = 80 + +# 专利 / 中英图号(行首或【】内) +CAPTION_RE = re.compile( + r"^(?:" + r"【?\s*(?:图|附图)\s*([0-9]+[A-Za-z]?)\s*】?" + r"|FIG(?:URE)?\.?\s*([0-9]+[A-Za-z]?)" + r"|Fig\.?\s*([0-9]+[A-Za-z]?)" + r")(?=$|[\s::.。,\、|—–\-])", + re.IGNORECASE, +) + + +def _normalize_ws(text: str) -> str: + return re.sub(r"\s+", " ", text).strip() + + +def _rect_area(bbox: tuple[float, float, float, float]) -> float: + return max(0.0, bbox[2] - bbox[0]) * max(0.0, bbox[3] - bbox[1]) + + +def _intersection_area( + a: tuple[float, float, float, float], + b: tuple[float, float, float, float], +) -> float: + x0 = max(a[0], b[0]) + y0 = max(a[1], b[1]) + x1 = min(a[2], b[2]) + y1 = min(a[3], b[3]) + return _rect_area((x0, y0, x1, y1)) + + +def _clip_to_page( + bbox: tuple[float, float, float, float], + page_rect, + *, + padding: float = 4.0, +) -> tuple[float, float, float, float]: + x0, y0, x1, y1 = bbox + x0 = max(page_rect.x0, x0 - padding) + y0 = max(page_rect.y0, y0 - padding) + x1 = min(page_rect.x1, x1 + padding) + y1 = min(page_rect.y1, y1 + padding) + return (x0, y0, x1, y1) + + +def _collect_xref_rects(page) -> list[tuple[float, float, float, float]]: + import fitz # type: ignore + + rects: list[tuple[float, float, float, float]] = [] + for img_info in page.get_images(full=True): + xref = int(img_info[0]) + try: + img_rects = page.get_image_rects(xref) + except Exception: + continue + for r in img_rects: + if r.is_empty or r.is_infinite: + continue + rects.append((r.x0, r.y0, r.x1, r.y1)) + return rects + + +def _collect_drawing_rects(page) -> list[tuple[float, float, float, float]]: + import fitz # type: ignore + + rects: list[tuple[float, float, float, float]] = [] + try: + for drawing in page.get_drawings(): + r = drawing.get("rect") + if r is None: + continue + rect = fitz.Rect(r) + if rect.is_empty or rect.is_infinite: + continue + if rect.width < 8 or rect.height < 8: + continue + rects.append((rect.x0, rect.y0, rect.x1, rect.y1)) + except Exception: + pass + return rects + + +def _visual_signal_for_bbox(page, bbox: tuple[float, float, float, float]) -> tuple[int, float]: + crop_area = _rect_area(bbox) + if crop_area <= 0: + return 0, 0.0 + count = 0 + visual_area = 0.0 + for rect in _collect_xref_rects(page) + _collect_drawing_rects(page): + area = _intersection_area(rect, bbox) + if area <= 0: + continue + count += 1 + visual_area += area + return count, min(1.0, visual_area / crop_area) + + +def _classify_visual_quality( + *, + page_coverage_ratio: float, + visual_rect_count: int, + visual_body_ratio: float, + paragraph_text_chars: int, +) -> dict: + reasons: list[str] = [] + if paragraph_text_chars >= 500 and visual_body_ratio < 0.12: + reasons.append("large_text_block_suspected") + if page_coverage_ratio >= 0.92 and paragraph_text_chars >= 200: + reasons.append("oversized_page_crop") + if visual_rect_count <= 0 and visual_body_ratio < 0.02: + reasons.append("low_visual_body_ratio") + + if any( + r in reasons + for r in ("large_text_block_suspected", "oversized_page_crop", "low_visual_body_ratio") + ): + status = "reject" + elif visual_rect_count == 0 or visual_body_ratio < 0.06: + if "low_visual_body_ratio" not in reasons: + reasons.append("low_visual_body_ratio") + status = "review" + else: + status = "usable" + return {"status": status, "reasons": reasons} + + +def _count_text_chars_in_bbox(page, bbox: tuple[float, float, float, float]) -> int: + import fitz # type: ignore + + chars = 0 + blocks = page.get_text("dict", flags=fitz.TEXT_PRESERVE_WHITESPACE)["blocks"] + for block in blocks: + if block.get("type") != 0: + continue + bb = tuple(block["bbox"]) + if _intersection_area(bb, bbox) <= 0: + continue + for line in block.get("lines", []): + for span in line.get("spans", []): + chars += len(span.get("text", "") or "") + return chars + + +def _find_caption_blocks(page) -> list[dict]: + import fitz # type: ignore + + anchors: list[dict] = [] + blocks = page.get_text("dict", flags=fitz.TEXT_PRESERVE_WHITESPACE)["blocks"] + for block in blocks: + if block.get("type") != 0: + continue + for line in block.get("lines", []): + spans = line.get("spans", []) + if not spans: + continue + line_text = "".join(s.get("text", "") for s in spans).strip() + match = CAPTION_RE.match(line_text) + if not match: + # 行内「如图1所示」跳过;允许较长图注「图1 为…结构示意图」 + m2 = re.search( + r"(?:^|[\s 【])((?:图|附图)\s*[0-9]+[A-Za-z]?|FIG(?:URE)?\.?\s*[0-9]+[A-Za-z]?)\s*[::]?", + line_text, + re.I, + ) + if not m2: + continue + # 过长且「如图」句式 → 正文引用非图注 + if "如图" in line_text[:4] or "如附图" in line_text[:5]: + continue + if len(line_text) > 120 and not CAPTION_RE.match(line_text[:20]): + continue + label_raw = m2.group(1) + num = re.search(r"[0-9]+[A-Za-z]?", label_raw) + label = f"图{num.group(0)}" if num else label_raw + else: + num = next(g for g in match.groups() if g) + label = f"图{num}" + bb = tuple(line["bbox"]) + anchors.append( + { + "label": label, + "kind": "figure", + "bbox": bb, + "line_text": line_text, + } + ) + anchors.sort(key=lambda a: (a["bbox"][1], a["bbox"][0])) + # 同页同图号保留多条(不同位置),避免只留最后一处 + return anchors + + +def _union_rects( + rects: list[tuple[float, float, float, float]], + caption_bbox: tuple[float, float, float, float], +) -> tuple[float, float, float, float] | None: + if not rects: + return None + x0 = min(r[0] for r in rects) + y0 = min(r[1] for r in rects) + x1 = max(r[2] for r in rects) + y1 = max(r[3] for r in rects) + x0 = min(x0, caption_bbox[0]) + x1 = max(x1, caption_bbox[2]) + y1 = max(y1, caption_bbox[3]) + return (x0, y0, x1, y1) + + +def _estimate_bbox_near_caption( + page, + caption_anchor: dict, + prev_anchor: dict | None, + next_anchor: dict | None, + page_rect, +) -> tuple[float, float, float, float] | None: + """专利附图:图注上下均可;优先同栏矢量/位图;失败则取图注邻近内容带。""" + cy0, cy1 = caption_anchor["bbox"][1], caption_anchor["bbox"][3] + cx0, cx1 = caption_anchor["bbox"][0], caption_anchor["bbox"][2] + cx_mid = (cx0 + cx1) / 2.0 + page_w = page_rect.x1 - page_rect.x0 + # 双栏:以图注中心 ±0.4 页宽为同栏窗口 + col_half = max(page_w * 0.4, (cx1 - cx0) + 36) + col_x0 = max(page_rect.x0, cx_mid - col_half) + col_x1 = min(page_rect.x1, cx_mid + col_half) + + upper = prev_anchor["bbox"][3] + 2.0 if prev_anchor else page_rect.y0 + 36 + lower = next_anchor["bbox"][1] - 2.0 if next_anchor else page_rect.y1 - 36 + + all_rects = _collect_xref_rects(page) + _collect_drawing_rects(page) + + def _in_column(r: tuple[float, float, float, float]) -> bool: + rm = (r[0] + r[2]) / 2.0 + return col_x0 <= rm <= col_x1 + + above: list[tuple[float, float, float, float]] = [] + for r in all_rects: + if not _in_column(r): + continue + mid = (r[1] + r[3]) / 2.0 + if upper <= mid <= cy0 + 8: + above.append((r[0], r[1], r[2], min(r[3], cy0 - 1))) + + below: list[tuple[float, float, float, float]] = [] + for r in all_rects: + if not _in_column(r): + continue + mid = (r[1] + r[3]) / 2.0 + if cy1 - 8 <= mid <= lower: + below.append((r[0], max(r[1], cy1 + 1), r[2], r[3])) + + bbox = None + above_u = _union_rects(above, caption_anchor["bbox"]) if above else None + below_u = _union_rects(below, caption_anchor["bbox"]) if below else None + if above_u and below_u: + bbox = above_u if _rect_area(above_u) >= _rect_area(below_u) else below_u + elif above_u: + bbox = above_u + elif below_u: + bbox = below_u + + if bbox is None: + # 自适应条带:默认 ±180pt,有邻近图注时收紧;上限 ±280pt + band = 180.0 + if prev_anchor or next_anchor: + band = 120.0 + y0 = max(upper, cy0 - min(280.0, band)) + y1 = min(lower, cy1 + min(280.0, band)) + bbox = (col_x0, y0, col_x1, y1) + + # 裁剪到栏宽,减少吃进邻栏正文 + x0, y0, x1, y1 = bbox + bbox = (max(x0, col_x0 - 8), y0, min(x1, col_x1 + 8), y1) + bbox = _clip_to_page(bbox, page_rect) + if bbox[2] - bbox[0] < MIN_FIGURE_WIDTH_PT or bbox[3] - bbox[1] < MIN_FIGURE_HEIGHT_PT: + return None + return bbox + + +def _page_content_bbox(page_rect) -> tuple[float, float, float, float]: + """无图注时:去掉大致页眉页脚的内容区。""" + margin_x = 18 + margin_top = 40 + margin_bottom = 40 + return ( + page_rect.x0 + margin_x, + page_rect.y0 + margin_top, + page_rect.x1 - margin_x, + page_rect.y1 - margin_bottom, + ) + + +def _render_crop(page, bbox: tuple[float, float, float, float], dpi: int) -> bytes: + import fitz # type: ignore + + clip = fitz.Rect(*bbox) + scale = dpi / 72.0 + pix = page.get_pixmap(matrix=fitz.Matrix(scale, scale), clip=clip, alpha=False) + return pix.tobytes("png") + + +def _safe_filename(page_number: int, label: str, used: set[str]) -> str: + safe = re.sub(r"[^\w\u4e00-\u9fff]+", "_", label).strip("_") or "fig" + base = f"page_{page_number:03d}_{safe}.png" + if base not in used: + used.add(base) + return base + i = 2 + while True: + cand = f"page_{page_number:03d}_{safe}_{i}.png" + if cand not in used: + used.add(cand) + return cand + i += 1 + + +def _decision_from_quality(status: str, *, include_review: bool = False) -> str: + if status == "usable": + return "insert" + if include_review and status == "review": + return "insert" + return "placeholder" + + +def extract_figure_level( + page, + page_number: int, + out_dir: Path, + *, + dpi: int = FIGURE_RENDER_DPI, + include_review: bool = False, +) -> list[dict]: + """图注锚定裁切;无图注且有明显矢量/位图时整页内容区一裁。""" + page_rect = page.rect + anchors = _find_caption_blocks(page) + used: set[str] = set() + assets: list[dict] = [] + + if anchors: + for idx, anchor in enumerate(anchors): + prev_a = anchors[idx - 1] if idx else None + next_a = anchors[idx + 1] if idx + 1 < len(anchors) else None + bbox = _estimate_bbox_near_caption(page, anchor, prev_a, next_a, page_rect) + if bbox is None: + continue + try: + png = _render_crop(page, bbox, dpi) + except Exception: + continue + fname = _safe_filename(page_number, anchor["label"], used) + (out_dir / fname).write_bytes(png) + vcount, vratio = _visual_signal_for_bbox(page, bbox) + coverage = _rect_area(bbox) / max(_rect_area( + (page_rect.x0, page_rect.y0, page_rect.x1, page_rect.y1) + ), 1.0) + text_chars = _count_text_chars_in_bbox(page, bbox) + quality = _classify_visual_quality( + page_coverage_ratio=coverage, + visual_rect_count=vcount, + visual_body_ratio=vratio, + paragraph_text_chars=text_chars, + ) + decision = _decision_from_quality( + quality["status"], include_review=include_review + ) + label = anchor["label"] + assets.append( + { + "id": f"fig_{label}_{page_number}_{idx}", + "page": page_number, + "label": label, + "caption_text": _normalize_ws(anchor["line_text"]), + "filename": fname, + "relative_path": f"images/{fname}", + "bytes": len(png), + "extraction_level": "figure", + "bbox_pt": list(bbox), + "quality_signals": quality, + "decision": decision, + "suggested_callout": _callout_for(label, page_number, fname, decision, quality), + "suggested_embed": f"![[images/{fname}]]\n*{label}(第 {page_number} 页)*", + } + ) + return assets + + # 无图注:若有足够矢量/位图,裁整页内容区 + rects = _collect_xref_rects(page) + _collect_drawing_rects(page) + if len(rects) < 2: + return [] + bbox = _page_content_bbox(page_rect) + vcount, vratio = _visual_signal_for_bbox(page, bbox) + if vcount < 2 or vratio < 0.04: + return [] + try: + png = _render_crop(page, bbox, dpi) + except Exception: + return [] + label = f"页{page_number}" + fname = _safe_filename(page_number, label, used) + (out_dir / fname).write_bytes(png) + quality = _classify_visual_quality( + page_coverage_ratio=0.85, + visual_rect_count=vcount, + visual_body_ratio=vratio, + paragraph_text_chars=_count_text_chars_in_bbox(page, bbox), + ) + # 无图号整页裁:默认 review/placeholder,避免误当正式附图 + if quality["status"] == "usable": + quality = {"status": "review", "reasons": quality["reasons"] + ["no_caption_page_crop"]} + decision = _decision_from_quality(quality["status"], include_review=include_review) + assets.append( + { + "id": f"fig_page_{page_number}", + "page": page_number, + "label": label, + "caption_text": "", + "filename": fname, + "relative_path": f"images/{fname}", + "bytes": len(png), + "extraction_level": "page", + "bbox_pt": list(bbox), + "quality_signals": quality, + "decision": decision, + "suggested_callout": _callout_for(label, page_number, fname, decision, quality), + "suggested_embed": f"![[images/{fname}]]\n*第 {page_number} 页附图区域(无图号)*", + } + ) + return assets + + +def extract_xref_fallback( + doc, + page, + page_number: int, + out_dir: Path, + *, + min_bytes: int = 8000, + max_per_page: int = 2, +) -> list[dict]: + """Legacy xref 抽图,决策一律 placeholder(需人工核对)。""" + items: list[dict] = [] + seen = 0 + for img_index, img in enumerate(page.get_images(full=True)): + if seen >= max_per_page: + break + xref = img[0] + try: + base = doc.extract_image(xref) + except Exception: + continue + data = base.get("image", b"") + if len(data) < min_bytes: + continue + ext = base.get("ext", "png") + fname = f"page_{page_number:03d}_xref_{img_index + 1:02d}.{ext}" + (out_dir / fname).write_bytes(data) + quality = {"status": "review", "reasons": ["xref_fragment"]} + items.append( + { + "id": f"xref_p{page_number}_{img_index + 1}", + "page": page_number, + "label": f"嵌入图{img_index + 1}", + "caption_text": "", + "filename": fname, + "relative_path": f"images/{fname}", + "bytes": len(data), + "extraction_level": "xref", + "quality_signals": quality, + "decision": "placeholder", + "suggested_callout": _callout_for( + f"嵌入图{img_index + 1}", page_number, fname, "placeholder", quality + ), + "suggested_embed": "", + } + ) + seen += 1 + return items + + +def _callout_for( + label: str, + page: int, + fname: str, + decision: str, + quality: dict, +) -> str: + status = quality.get("status", "review") + reasons = ", ".join(quality.get("reasons") or []) or "—" + if decision == "insert": + state = f"可插入;质量={status}" + else: + state = f"占位;质量={status};原因={reasons}" + return ( + f"> [!figure] {label}\n" + f"> 建议位置:特征—附图对照\n" + f"> 页码:{page}\n" + f"> 当前状态:{state};文件 `{fname}`" + ) + + +def extract_patent_pdf_figures( + pdf_path: Path, + out_dir: Path, + *, + dpi: int = FIGURE_RENDER_DPI, + min_xref_bytes: int = 8000, + prefer_figure_level: bool = True, + include_review: bool = False, +) -> dict: + """主入口:返回 manifest dict。""" + import fitz # type: ignore + + out_dir.mkdir(parents=True, exist_ok=True) + doc = fitz.open(pdf_path) + figures: list[dict] = [] + for page_index in range(len(doc)): + page = doc[page_index] + page_no = page_index + 1 + if prefer_figure_level: + fig_assets = extract_figure_level( + page, page_no, out_dir, dpi=dpi, include_review=include_review + ) + figures.extend(fig_assets) + if fig_assets: + continue + figures.extend( + extract_xref_fallback( + doc, page, page_no, out_dir, min_bytes=min_xref_bytes + ) + ) + doc.close() + + insert_count = sum(1 for f in figures if f.get("decision") == "insert") + return { + "source_pdf": str(pdf_path), + "count": len(figures), + "insert_count": insert_count, + "placeholder_count": len(figures) - insert_count, + "include_review": include_review, + "figures": figures, + } diff --git a/.agents/skills/patent-disclosure-skill/tools/patent_reader/link_patent_notes.py b/.agents/skills/patent-disclosure-skill/tools/patent_reader/link_patent_notes.py new file mode 100644 index 0000000..619f468 --- /dev/null +++ b/.agents/skills/patent-disclosure-skill/tools/patent_reader/link_patent_notes.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +""" +扫描 Obsidian 库内专利解读笔记,按规则(+可选模型分)建立关联并回写。 + +用法: + # 先预览(不改库) + python tools/patent_reader/link_patent_notes.py --dry-run + + # 写入 related_pubs、相关专利节、刷新单篇图谱 + 全局 _专利关联.canvas + python tools/patent_reader/link_patent_notes.py + + # 仅围绕刚入库的公开号 + python tools/patent_reader/link_patent_notes.py --focus-pub CN999999999B + + # 合并 Agent 模型判定(可选 JSON) + python tools/patent_reader/link_patent_notes.py --model-scores model_links.json +""" +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +try: + from common import optional_path, runtime_config + from patent_link import run_link_pipeline +except ImportError: + from tools.patent_reader.common import optional_path, runtime_config + from tools.patent_reader.patent_link import run_link_pipeline + + +def main(argv: list[str] | None = None) -> int: + if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--vault", default="", help="库根;默认环境变量 / 自动探测") + ap.add_argument("--papers-dir", default="", help="默认 Research/Patents") + ap.add_argument("--glossary-dir", default="", help="默认 Research/术语") + ap.add_argument("--min-score", type=float, default=0.45, help="边阈值 0–1") + ap.add_argument("--focus-pub", default="", help="只计算与该公开号相关的边") + ap.add_argument( + "--model-scores", + default=None, + type=optional_path, + help='JSON 列表:[{"pub_a","pub_b","relation","score","rationale"}]', + ) + ap.add_argument("--dry-run", action="store_true", help="只输出边,不写库") + ap.add_argument("--no-canvas", action="store_true", help="不刷新单篇 Canvas") + ap.add_argument("--no-global-canvas", action="store_true", help="不写全局关联 Canvas") + ap.add_argument("-o", "--output", default=None, type=optional_path, help="结果 JSON") + args = ap.parse_args(argv) + + cfg = runtime_config() + vault_s = args.vault.strip() or cfg["obsidian_vault"] + if not vault_s: + print( + "错误:未配置 Obsidian 库。请先 check_obsidian_env.py 或 --vault。", + file=sys.stderr, + ) + return 1 + vault = Path(vault_s).resolve() + if not vault.is_dir(): + print(f"错误:库不存在 {vault}", file=sys.stderr) + return 1 + + model_scores: list = [] + if args.model_scores and args.model_scores.is_file(): + raw = json.loads(args.model_scores.read_text(encoding="utf-8")) + model_scores = raw if isinstance(raw, list) else raw.get("links") or raw.get("edges") or [] + + result = run_link_pipeline( + vault, + papers_dir=args.papers_dir.strip() or cfg["papers_dir"], + glossary_dir=args.glossary_dir.strip() or cfg["glossary_dir"], + min_score=args.min_score, + model_scores=model_scores, + focus_pub=args.focus_pub.strip(), + refresh_canvas=not args.no_canvas, + refresh_global_canvas=not args.no_global_canvas, + dry_run=args.dry_run, + ) + + print( + f"OK notes={result['note_count']} edges={result['edge_count']} " + f"updated={len(result['updated_notes'])} dry_run={result['dry_run']}" + ) + for e in result["edges"][:20]: + print( + f" LINK {e['pub_a']} <-> {e['pub_b']} " + f"{e['relation']} score={e['score']} ({e.get('source')})" + ) + if result.get("global_canvas"): + print(f"GLOBAL_CANVAS: {result['global_canvas']}") + if args.output: + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text( + json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8" + ) + print(f"LINKS_JSON: {args.output}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.agents/skills/patent-disclosure-skill/tools/patent_reader/lint_patent_note.py b/.agents/skills/patent-disclosure-skill/tools/patent_reader/lint_patent_note.py new file mode 100644 index 0000000..509d06f --- /dev/null +++ b/.agents/skills/patent-disclosure-skill/tools/patent_reader/lint_patent_note.py @@ -0,0 +1,222 @@ +#!/usr/bin/env python3 +""" +专利解读笔记结构、免责声明、应用场景与附录粗校验。 + +用法: + python tools/patent_reader/lint_patent_note.py --note out.md --manifest source_manifest.json \\ + --claim-tree claim_tree.json [--plan note_plan.json] [--context-anchor context_anchor.json] \\ + [--figures-manifest figures/manifest.json] [--output lint.json] +""" +from __future__ import annotations + +import argparse +import json +import re +import sys +from pathlib import Path + +# 按 ## 标题匹配,避免「特征」等裸词假阴性 +REQUIRED_HEADINGS = [ + (re.compile(r"^##\s*Obsidian\s*导航\s*$", re.M | re.I), "Obsidian 导航"), + (re.compile(r"^##\s*一、\s*一句话", re.M), "一句话"), + (re.compile(r"^##\s*二、\s*连贯叙事", re.M), "连贯叙事"), + (re.compile(r"^##\s*三、\s*权利要求树", re.M), "权利要求树"), + (re.compile(r"^##\s*四、\s*独立权利要求精读", re.M), "独立权利要求精读"), + (re.compile(r"^##\s*五、\s*专利内术语表", re.M), "专利内术语表"), + (re.compile(r"^##\s*六、\s*特征", re.M), "特征—说明书—附图对照"), + (re.compile(r"^##\s*八、\s*(?:给你的)?阅读建议", re.M), "阅读建议"), + (re.compile(r"^##\s*九、\s*技术应用场景", re.M), "技术应用场景"), + (re.compile(r"^##\s*十、\s*附录", re.M), "附录"), + (re.compile(r"^##\s*十一、\s*免责声明", re.M), "免责声明"), +] + +DISCLAIMER_PHRASES = ( + "不构成法律意见", + "专利保护范围以官方法律文本为准", + "重大决策请咨询专利代理师", +) + +QUOTE_RE = re.compile( + r"^>\s*【([A-Z]{2}\d+[A-Z]?\d?)·权利要求(\d+)】", + re.M, +) + + +try: + from common import optional_path +except ImportError: + from tools.patent_reader.common import optional_path + + +def load_json(path: Path) -> dict: + return json.loads(path.read_text(encoding="utf-8")) + + +def main(argv: list[str] | None = None) -> int: + if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--note", required=True, type=Path) + ap.add_argument("--manifest", required=True, type=Path) + ap.add_argument("--claim-tree", required=True, type=Path) + ap.add_argument("--plan", default=None, type=optional_path) + ap.add_argument("--context-anchor", default=None, type=optional_path) + ap.add_argument("--figures-manifest", default=None, type=optional_path) + ap.add_argument("--output", default=None, type=optional_path) + args = ap.parse_args(argv) + + note = args.note.read_text(encoding="utf-8", errors="replace") + manifest = load_json(args.manifest) + tree = load_json(args.claim_tree) + issues: list[str] = [] + warnings: list[str] = [] + + for pat, label in REQUIRED_HEADINGS: + if not pat.search(note): + issues.append(f"missing_section:{label}") + + for phrase in DISCLAIMER_PHRASES: + if phrase not in note: + issues.append(f"disclaimer_missing:{phrase}") + + scope = manifest.get("evidence_scope", "") + if scope == "abstract_only": + if "权利要求" in note and "摘要级" not in note and "仅摘要" not in note: + if re.search(r"保护范围", note): + issues.append("abstract_only_but_claims_scope_asserted") + + ind_count = manifest.get("independent_claim_count", 0) or len( + tree.get("roots") or [] + ) + if ind_count > 0 and "权利要求精读" in note: + quotes = QUOTE_RE.findall(note) + if len(quotes) < min(ind_count, 1): + issues.append("missing_claim_quote_block") + # 引文权项号应落在树节点上 + node_nums = {n.get("number") for n in (tree.get("nodes") or []) if n.get("number")} + if node_nums: + for _pub, num_s in quotes: + try: + num = int(num_s) + except ValueError: + continue + if num not in node_nums: + warnings.append(f"quote_claim_not_in_tree:{num}") + + if "[!patent-meta]" not in note: + issues.append("missing_callout:patent-meta") + if "[!grounding]" not in note: + issues.append("missing_callout:grounding") + if "[!warning]" not in note and "warning]-" not in note: + issues.append("missing_callout:warning") + + if "patent-reader" not in note[:1500]: + issues.append("missing_cssclass:patent-reader") + if "ipc:" not in note[:1200] and "IPC" not in note[:2500]: + issues.append("missing_ipc_field") + + # 第三节:推荐单一树形表(缺则 warning) + sec3 = re.search(r"##\s*三、权利要求树[\s\S]*?(?=##\s*四、)", note) + if sec3: + s3 = sec3.group(0) + if "本项新增" not in s3 and "| 权 |" not in s3: + warnings.append("section3_missing_claim_table") + if "```mermaid" in s3 and "| 结构 |" in s3: + warnings.append("section3_mermaid_and_table_redundant") + + # 交付正文不得暴露实现痕迹(脚本名 / 流水线字段 / 内部文件名说明) + if re.search( + r"`?[a-z_]+\.py`?|" + r"context_anchor\.[a-z_]+|" + r"第\s*\d+\s*页\s*[·•]\s*`?page_\d+_xref_", + note, + re.I, + ): + warnings.append("user_facing_internal_tool_leakage") + + + # 第九节须含专利内依据标记 + sec9_m = re.search(r"##\s*九、技术应用场景[\s\S]*?(?=##\s*十、)", note) + if sec9_m: + sec9 = sec9_m.group(0) + if not re.search( + r"desc_|实施例|背景|说明书\s*\d{4}|说明书段落", + sec9, + ): + issues.append("section9_missing_patent_grounding") + if re.search(r"https?://", sec9): + issues.append("section9_contains_url_use_appendix_b") + + # 附录 A IPC + if "IPC" not in note and "行业坐标" not in note: + issues.append("appendix_missing_ipc") + + # 附录 B:有线索须有 URL,或无发现说明 + appendix_m = re.search(r"##\s*十、附录[\s\S]*?(?=##\s*十一、免责声明)", note) + if appendix_m: + appendix = appendix_m.group(0) + has_clue = re.search(r"置信度", appendix) or "线索" in appendix + has_url = "http" in appendix + has_none = "未发现" in appendix or "防御性" in appendix + if has_clue and not has_url and not has_none: + issues.append("appendix_b_missing_url_or_none_statement") + + if args.plan and args.plan.is_file(): + plan = load_json(args.plan) + grounding = plan.get("grounding") or {} + if not grounding and plan.get("sections"): + issues.append("plan_missing_grounding") + if not plan.get("context_anchor_ref"): + issues.append("plan_missing_context_anchor_ref") + + if args.context_anchor and args.context_anchor.is_file(): + anchor = load_json(args.context_anchor) + domain = anchor.get("domain", "") + if domain and f"domain:" not in note[:1200] and domain not in note[:2000]: + issues.append("frontmatter_or_body_missing_domain") + + # 附图:写入阶段会自动补嵌,此处仅 warning,避免 lint↔inject 时序死锁 + if args.figures_manifest and args.figures_manifest.is_file(): + fig_man = load_json(args.figures_manifest) + for fig in fig_man.get("figures") or []: + if fig.get("decision") != "insert": + continue + fname = fig.get("filename") or "" + rel = fig.get("relative_path") or "" + if not fname: + continue + if fname not in note and (not rel or rel not in note): + if f"![[images/{fname}" not in note: + warnings.append(f"insert_figure_not_referenced:{fname}") + + passed = len(issues) == 0 + result = { + "passed": passed, + "issues": issues, + "warnings": warnings, + "evidence_scope": scope, + } + + if args.output: + args.output.write_text( + json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8" + ) + + for w in warnings: + print(f"WARN {w}", file=sys.stderr) + if not passed: + for i in issues: + print(f"FAIL {i}", file=sys.stderr) + return 1 + + print("OK lint passed") + if warnings: + print(f"WARNINGS: {len(warnings)}") + if args.output: + print(f"LINT_JSON: {args.output}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.agents/skills/patent-disclosure-skill/tools/patent_reader/materialize_public_clues.py b/.agents/skills/patent-disclosure-skill/tools/patent_reader/materialize_public_clues.py new file mode 100644 index 0000000..6ad445f --- /dev/null +++ b/.agents/skills/patent-disclosure-skill/tools/patent_reader/materialize_public_clues.py @@ -0,0 +1,202 @@ +#!/usr/bin/env python3 +""" +对已有解读笔记落地公开线索增强:筛选(≤3)→clues/→附录 B→旁注→刷新 Canvas。 + +摘要主路径:Agent 写入 public_clues.json 的 summary/status。 +脚本 HTTP 抓取仅降级:加 --fetch-fallback,且只处理缺 summary 的条目。 + +用法: + python tools/patent_reader/materialize_public_clues.py \\ + --note-rel Research/Patents/领域/CNxxx/CNxxx_解读_20260721.md \\ + --public-clues tmp/patent_reader/RUN/public_clues.json + + # 缺摘要时脚本降级 + python tools/patent_reader/materialize_public_clues.py --note-rel ... --fetch-fallback +""" +from __future__ import annotations + +import argparse +import json +import re +import sys +from pathlib import Path + +try: + from clue_vault import ( + as_clues, + clue_cards_for_canvas, + harvest_feature_entries, + inject_clue_annotations, + load_clues_sidecar, + materialize_clues, + upsert_appendix_b, + ) + from common import optional_path, runtime_config + from obsidian import ( + build_canvas, + ensure_canvas_nav, + harvest_claim_summaries_from_note, + parse_frontmatter, + scan_vault_related, + ) + from write_patent_obsidian_note import ( + harvest_glossary_from_note, + harvest_narrative_from_note, + merge_glossary_candidates, + sanitize_user_facing_titles, + ) +except ImportError: + from tools.patent_reader.clue_vault import ( + as_clues, + clue_cards_for_canvas, + harvest_feature_entries, + inject_clue_annotations, + load_clues_sidecar, + materialize_clues, + upsert_appendix_b, + ) + from tools.patent_reader.common import optional_path, runtime_config + from tools.patent_reader.obsidian import ( + build_canvas, + ensure_canvas_nav, + harvest_claim_summaries_from_note, + parse_frontmatter, + scan_vault_related, + ) + from tools.patent_reader.write_patent_obsidian_note import ( + harvest_glossary_from_note, + harvest_narrative_from_note, + merge_glossary_candidates, + sanitize_user_facing_titles, + ) + + +def _load_clues(args, note_dir: Path) -> list[dict]: + if args.public_clues and args.public_clues.is_file(): + return as_clues(json.loads(args.public_clues.read_text(encoding="utf-8"))) + if args.workdir: + p = args.workdir / "public_clues.json" + if p.is_file(): + return as_clues(json.loads(p.read_text(encoding="utf-8"))) + side = load_clues_sidecar(note_dir) + if side: + # 已 materialize 过:用原 url/title 再跑(允许补抓) + return side + return [] + + +def main(argv: list[str] | None = None) -> int: + if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--vault", default="", help="默认 PATENT_READER_OBSIDIAN_VAULT") + ap.add_argument("--note-rel", required=True) + ap.add_argument("--public-clues", default=None, type=optional_path) + ap.add_argument("--workdir", default=None, type=optional_path) + ap.add_argument( + "--fetch-fallback", + action="store_true", + help="缺 summary 时脚本 HTTP 降级(默认关闭)", + ) + ap.add_argument( + "--no-fetch", + action="store_true", + help="兼容旧参数:等同默认(不脚本抓取)", + ) + ap.add_argument("--max", type=int, default=3) + args = ap.parse_args(argv) + + cfg = runtime_config() + vault_s = args.vault.strip() or cfg["obsidian_vault"] + if not vault_s: + print("错误:未指定 vault", file=sys.stderr) + return 1 + vault = Path(vault_s).resolve() + note_path = vault / args.note_rel.replace("\\", "/") + if not note_path.is_file(): + print(f"错误:笔记不存在 {note_path}", file=sys.stderr) + return 1 + + content = note_path.read_text(encoding="utf-8") + fm, _, body = parse_frontmatter(content) + pub = str(fm.get("pub_number") or "").strip() + if not pub: + m = re.search(r"\b(CN\d+[A-Z]?\d?)\b", note_path.name, re.I) + pub = m.group(1).upper() if m else note_path.parent.name + + clues = _load_clues(args, note_path.parent) + if not clues: + print("WARN 无线索可落地(提供 --public-clues 或 workdir/public_clues.json)", file=sys.stderr) + return 0 + + note_rel = str(note_path.relative_to(vault)).replace("\\", "/") + rich, appendix = materialize_clues( + clues, + note_dir=note_path.parent, + pub=pub, + note_rel=note_rel, + claim_summaries=harvest_claim_summaries_from_note(content), + feature_entries=harvest_feature_entries(content), + max_keep=args.max, + fetch_fallback=bool(args.fetch_fallback) and not args.no_fetch, + ) + content = upsert_appendix_b(content, appendix) + content = inject_clue_annotations(content, rich) + content = sanitize_user_facing_titles(content) + + domain = str(fm.get("domain") or "") + papers = cfg["papers_dir"] + glossary_dir = cfg["glossary_dir"] + related = scan_vault_related( + vault, papers, pub, fm.get("assignees") or [], domain=domain + ) + claim_tree = None + ct = note_path.parent / "claim_tree.json" + if ct.is_file(): + claim_tree = json.loads(ct.read_text(encoding="utf-8")) + + title_m = re.search(r"^#\s+(.+)$", body, re.M) + title = title_m.group(1).strip() if title_m else pub + note_dir_rel = str(note_path.parent.relative_to(vault)).replace("\\", "/") + glossary = merge_glossary_candidates([], harvest_glossary_from_note(content)) + narrative = harvest_narrative_from_note(content) + canvas = build_canvas( + vault=vault, + papers_dir=papers, + note_rel_path=note_rel, + pub=pub, + title=title, + related=related, + glossary_terms=glossary, + glossary_dir=glossary_dir, + create_glossary_stubs=False, + meta={ + "domain": domain, + "ipc": fm.get("ipc") or "", + "assignees": fm.get("assignees") or [], + "evidence_scope": fm.get("evidence_scope") or "", + }, + claim_tree=claim_tree, + claim_summaries=harvest_claim_summaries_from_note(content), + narrative=narrative, + clue_cards=clue_cards_for_canvas(rich, note_dir_rel=note_dir_rel), + ) + canvas.pop("glossary_resolved", None) + pub_slug = pub + canvas_path = note_path.parent / f"{pub_slug}_图谱.canvas" + canvas_path.write_text( + json.dumps(canvas, ensure_ascii=False, indent=2), encoding="utf-8" + ) + canvas_rel = str(canvas_path.relative_to(vault)).replace("\\", "/") + content = ensure_canvas_nav(content, canvas_rel) + note_path.write_text(content, encoding="utf-8") + + print(f"OK clues={len(rich)} note={note_path}") + print(f"CLUES_DIR: {note_path.parent / 'clues'}") + print(f"CANVAS: {canvas_path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.agents/skills/patent-disclosure-skill/tools/patent_reader/note_cites.py b/.agents/skills/patent-disclosure-skill/tools/patent_reader/note_cites.py new file mode 100644 index 0000000..912e333 --- /dev/null +++ b/.agents/skills/patent-disclosure-skill/tools/patent_reader/note_cites.py @@ -0,0 +1,445 @@ +"""解读笔记内「权 N」「图 N」引用 → 可跳转 / 悬停预览的 wikilink。 + +权项锚点默认落在旁路笔记 `{公开号}_权项锚点.md`(与说明书段落同级),避免占主笔记版面。 +""" +from __future__ import annotations + +import re +from pathlib import Path + +# 权1/3、权1/8:表示两点,不是闭区间 +CLAIM_SLASH_RE = re.compile( + r"(? tuple[str, list[str]]: + vault: list[str] = [] + + def stash(m: re.Match[str]) -> str: + vault.append(m.group(0)) + return f"\x00NC{len(vault) - 1}\x00" + + tmp = re.sub(r"```[\s\S]*?```", stash, content) + tmp = re.sub(r"\[\[[^\]]+\]\]", stash, tmp) + tmp = re.sub(r"^#{1,6}\s*图\s*\d+[^\n]*$", stash, tmp, flags=re.M) + tmp = re.sub(r"^#{1,6}\s*权\s*\d+[^\n]*$", stash, tmp, flags=re.M) + tmp = re.sub(r"^>\s*#{1,6}\s*权\s*\d+[^\n]*$", stash, tmp, flags=re.M) + tmp = re.sub(r"^\*图\s*\d+[^\n]*\*$", stash, tmp, flags=re.M) + return tmp, vault + + +def _restore_zones(content: str, vault: list[str]) -> str: + return re.sub( + r"\x00NC(\d+)\x00", lambda m: vault[int(m.group(1))], content + ) + + +def _expand(a: int, b: int | None) -> list[int]: + if b is None or b == a: + return [a] + lo, hi = (a, b) if a <= b else (b, a) + if hi - lo > 40: + return [a, b] + return list(range(lo, hi + 1)) + + +def claim_anchor_basename(pub: str) -> str: + try: + from common import slugify_pub + except ImportError: + from tools.patent_reader.common import slugify_pub + + return f"{slugify_pub(pub)}_权项锚点" + + +def format_claim_wikilinks( + start: int, end: int | None = None, *, pub: str = "" +) -> str: + base = claim_anchor_basename(pub) if pub else "" + prefix = f"{base}#" if base else "#" + + def one(n: int) -> str: + return f"[[{prefix}^claim-{n}|权{n}]]" + + if end is None or end == start: + return one(start) + return f"{one(start)}–{one(end)}" + + +def format_figure_wikilinks(start: int, end: int | None = None) -> str: + if end is None or end == start: + return f"[[#图{start}|图{start}]]" + return f"[[#图{start}|图{start}]]–[[#图{end}|图{end}]]" + + +def upgrade_legacy_claim_wikilinks(content: str, *, pub: str) -> str: + """[[#^claim-N|权N]] → [[{pub}_权项锚点#^claim-N|权N]]""" + base = claim_anchor_basename(pub) + + def repl(m: re.Match[str]) -> str: + n = m.group(1) + return f"[[{base}#^claim-{n}|权{n}]]" + + # 已指向旁路笔记的不改;只改同笔记 #^claim- + return LEGACY_SAME_NOTE_CLAIM_RE.sub(repl, content) + + +def wikilink_claim_citations(content: str, *, pub: str = "") -> str: + """正文「权2–3」→ 旁路笔记块锚 wikilink。""" + content = upgrade_legacy_claim_wikilinks(content, pub=pub) if pub else content + protected, vault = _protect_zones(content) + + def repl_slash(m: re.Match[str]) -> str: + a, b = int(m.group(1)), int(m.group(2)) + return ( + f"{format_claim_wikilinks(a, pub=pub)}/" + f"{format_claim_wikilinks(b, pub=pub)}" + ) + + def repl(m: re.Match[str]) -> str: + a = int(m.group(1)) + b = int(m.group(2)) if m.group(2) else None + return format_claim_wikilinks(a, b, pub=pub) + + out = CLAIM_SLASH_RE.sub(repl_slash, protected) + out = CLAIM_CITE_RE.sub(repl, out) + return _restore_zones(out, vault) + + +def wikilink_figure_citations(content: str) -> str: + """正文「图1–3」→ 文内图标题锚。""" + protected, vault = _protect_zones(content) + + def repl(m: re.Match[str]) -> str: + a = int(m.group(1)) + b = int(m.group(2)) if m.group(2) else None + return format_figure_wikilinks(a, b) + + out = FIG_CITE_RE.sub(repl, protected) + return _restore_zones(out, vault) + + +def parse_cited_claim_numbers(content: str) -> list[int]: + found: set[int] = set() + for m in CLAIM_WIKILINK_RE.finditer(content): + found.add(int(m.group(1))) + protected, _ = _protect_zones(content) + for m in CLAIM_SLASH_RE.finditer(protected): + found.add(int(m.group(1))) + found.add(int(m.group(2))) + for m in CLAIM_CITE_RE.finditer(protected): + a = int(m.group(1)) + b = int(m.group(2)) if m.group(2) else None + found.update(_expand(a, b)) + return sorted(found) + + +def strip_inline_claim_anchors(content: str) -> str: + """去掉主笔记内旧版「权项锚点」节(callout / ###)。""" + content = re.sub( + r"\n*> \[!note\]- 权项锚点[\s\S]*?(?=\n##\s|\Z)", + "\n", + content, + count=1, + ) + content = re.sub( + r"\n*###\s*权项锚点\s*\n[\s\S]*?(?=\n##\s|\Z)", + "\n", + content, + count=1, + ) + return content + + +def render_claim_anchors_note( + *, + pub: str, + claim_tree: dict | None, + summaries: dict[int, str] | None, + nums: list[int], +) -> str: + summaries = summaries or {} + by_num = { + int(n.get("number")): n + for n in (claim_tree or {}).get("nodes") or [] + if n.get("number") is not None + } + lines = [ + "---", + "tags:", + " - patent/claim-anchors", + f"pub_number: {pub}", + "cssclasses:", + " - patent-reader", + "---", + f"# {pub} 权项锚点", + "", + "## 使用说明", + "", + "1. **设置**:Obsidian → 设置 → 核心插件 → **页面预览(Page preview)** → 打开", + "2. **使用**:在解读笔记中 **按住 Ctrl** 悬停「权 N」链接,即可预览本项摘要;单击跳转至此。", + "", + "> 本页供解读正文链接;默认含权项树上的全部权号。", + "", + ] + for n in nums: + node = by_num.get(n) or {} + delta = (summaries.get(n) or "").strip() + if not delta: + delta = str(node.get("delta") or node.get("text_preview") or "").strip() + delta = re.sub(r"\s+", " ", delta)[:120] + if not delta: + delta = f"见解读笔记第三节权{n} / 第四节独立权展开" + kind = "独立权" if node.get("is_independent") else "从属权" + lines.extend( + [ + f"### 权 {n}({kind})", + "", + delta, + "", + f"^claim-{n}", + "", + ] + ) + return "\n".join(lines).rstrip() + "\n" + + +def ensure_claim_anchors_nav(content: str, *, pub: str) -> str: + """导航补「权项锚点」入口(无括号说明)。""" + base = claim_anchor_basename(pub) + needle = f"[[{base}|权项锚点]]" + content = re.sub( + rf"(-\s*\[\[{re.escape(base)}\|[^\]]*\]\])([^)\n]*)", + r"\1", + content, + ) + if needle in content or f"{base}|" in content: + return content + m = re.search( + r"(##\s*Obsidian 导航\s*\n)([\s\S]*?)(?=\n##\s|\n> \[!|\Z)", content + ) + if not m: + return content + block = m.group(2) + if not block.strip().startswith("-"): + return content + lines = block.rstrip().splitlines() + insert_at = len(lines) + for i, line in enumerate(lines): + if "说明书段落" in line: + insert_at = i + 1 + break + if "图谱" in line or "canvas" in line.lower(): + insert_at = i + 1 + lines.insert(insert_at, f"- {needle}") + new_block = "\n".join(lines) + "\n" + return content[: m.start(2)] + new_block + content[m.end(2) :] + + +def materialize_claim_anchors( + content: str, + *, + pub: str, + note_dir: Path, + claim_tree: dict | None = None, + summaries: dict[int, str] | None = None, +) -> tuple[str, Path | None, list[int]]: + """写旁路权项锚点笔记,去掉主文内旧锚点节,改写权链接。""" + content = strip_inline_claim_anchors(content) + nums: set[int] = set(parse_cited_claim_numbers(content)) + for node in (claim_tree or {}).get("nodes") or []: + try: + nums.add(int(node.get("number"))) + except (TypeError, ValueError): + continue + ordered = sorted(nums) + if not ordered: + content = wikilink_claim_citations(content, pub=pub) + content = ensure_claim_anchors_nav(content, pub=pub) + return content, None, [] + + note_body = render_claim_anchors_note( + pub=pub, + claim_tree=claim_tree, + summaries=summaries, + nums=ordered, + ) + dest = note_dir / f"{claim_anchor_basename(pub)}.md" + note_dir.mkdir(parents=True, exist_ok=True) + dest.write_text(note_body, encoding="utf-8") + + content = wikilink_claim_citations(content, pub=pub) + content = ensure_claim_anchors_nav(content, pub=pub) + return content, dest, ordered + + +def build_figure_label_map(insert_figs: list[dict]) -> dict[int, dict]: + """图号 → 优先选用的 figure 记录。""" + out: dict[int, dict] = {} + for fig in insert_figs or []: + label = str(fig.get("label") or "") + m = re.search(r"图\s*(\d+)", label) or re.search( + r"图(\d+)", str(fig.get("filename") or "") + ) + if not m: + continue + num = int(m.group(1)) + prev = out.get(num) + if prev is None: + out[num] = fig + continue + + def score(f: dict) -> tuple: + st = (f.get("quality_signals") or {}).get("status") or "" + return ( + 1 if st == "usable" else 0, + 1 if f.get("decision") == "insert" else 0, + int(f.get("bytes") or 0), + ) + + if score(fig) > score(prev): + out[num] = fig + return out + + +def ensure_figure_headings( + content: str, insert_figs: list[dict] | None = None +) -> str: + """保证第六节/附图区每个图有 `### 图N` + 嵌入。""" + label_map = build_figure_label_map(insert_figs or []) + + def add_heading_before_embed(text: str) -> str: + def repl(m: re.Match[str]) -> str: + embed = m.group(0) + fname = m.group(1) + num_m = re.search(r"图(\d+)", fname) + if not num_m: + return embed + num = int(num_m.group(1)) + start = m.start() + lookback = text[max(0, start - 80) : start] + if re.search(rf"###\s*图\s*{num}\s*$", lookback, re.M): + return embed + return f"### 图{num}\n\n{embed}" + + return re.sub( + r"!\[\[(?:images/)?([^\]]*?图\d+[^\]]*?)\]\]", + repl, + text, + ) + + content = add_heading_before_embed(content) + existing = {int(x) for x in re.findall(r"^###\s*图\s*(\d+)\s*$", content, re.M)} + missing = sorted(set(label_map) - existing) + if not missing: + return content + + lines: list[str] = [] + for num in missing: + fig = label_map[num] + fname = fig.get("filename") or "" + page = fig.get("page") or fig.get("page_number") + cap = f"图{num}" + (f"(第 {page} 页)" if page else "") + lines.extend( + [ + f"### 图{num}", + "", + f"![[images/{fname}]]", + f"*{cap}*", + "", + ] + ) + block = "\n".join(lines) + m = re.search(r"^###\s*附图\s*$", content, re.M) + if m: + insert_at = m.end() + rest = content[insert_at:] + tip = re.match(r"\n*> \[!tip\][\s\S]*?(?:\n\n|\Z)", rest) + if tip: + insert_at += tip.end() + return content[:insert_at] + "\n" + block + content[insert_at:] + m6 = re.search(r"^##\s*六、.*$", content, re.M) + if m6: + end = content.find("\n## ", m6.end()) + if end == -1: + return content.rstrip() + "\n\n" + block + return content[:end] + "\n" + block + content[end:] + return content.rstrip() + "\n\n" + block + + +def escape_wikilink_pipes_in_tables(content: str) -> str: + """表格单元格内 wikilink 的「|别名」必须写成「\\|」。""" + lines = content.splitlines(keepends=True) + out: list[str] = [] + for line in lines: + stripped = line.lstrip(">").lstrip() + if not stripped.startswith("|") or "[[" not in line: + out.append(line) + continue + parts: list[str] = [] + i = 0 + while i < len(line): + if line.startswith("[[", i): + j = line.find("]]", i) + if j < 0: + parts.append(line[i:]) + break + chunk = line[i : j + 2] + chunk = re.sub(r"(? tuple[str, Path | None, list[int]]: + """图标题 + 权/图引用改写 + 旁路权项锚点笔记。 + + 返回 (content, claim_anchors_path|None, claim_nums)。 + """ + content = ensure_figure_headings(content, insert_figs) + content = wikilink_figure_citations(content) + claim_path: Path | None = None + claim_nums: list[int] = [] + if pub and note_dir is not None: + content, claim_path, claim_nums = materialize_claim_anchors( + content, + pub=pub, + note_dir=note_dir, + claim_tree=claim_tree, + summaries=claim_summaries, + ) + else: + content = wikilink_claim_citations(content, pub=pub) + return content, claim_path, claim_nums diff --git a/.agents/skills/patent-disclosure-skill/tools/patent_reader/obsidian.py b/.agents/skills/patent-disclosure-skill/tools/patent_reader/obsidian.py new file mode 100644 index 0000000..2f34f07 --- /dev/null +++ b/.agents/skills/patent-disclosure-skill/tools/patent_reader/obsidian.py @@ -0,0 +1,2214 @@ +"""专利解读 Obsidian 库增强:模板引导、frontmatter、Canvas、库初始化。""" +from __future__ import annotations + +import json +import re +import shutil +import subprocess +from datetime import datetime +from pathlib import Path + +from common import ROOT, runtime_config, slugify_pub, slugify_term + +ASSETS_OBSIDIAN = ROOT / "assets" / "obsidian" + + +def evidence_scope_label(scope: str) -> str: + """标签用短英文(patent/evidence/full)。""" + return { + "full_text": "full", + "abstract_only": "abstract", + "partial": "partial", + }.get(scope or "", "full") + + +def evidence_scope_zh(scope: str) -> str: + """仪表盘/Dataview 显示用中文。""" + return { + "full_text": "全文", + "abstract_only": "仅摘要", + "partial": "部分", + }.get(scope or "", scope or "—") + + +def speculative_zh(flag: bool) -> str: + return "是" if flag else "否" + + +def build_tags(domain: str, evidence_scope: str, confidence_speculative: bool) -> list[str]: + domain_slug = re.sub(r"\s+", "", domain or "未分类") + tags = [ + f"patents/{domain_slug}", + f"patent/evidence/{evidence_scope_label(evidence_scope)}", + ] + if confidence_speculative: + tags.append("patent/speculative") + return tags + + +def _descendants_of(nodes: list[dict], root: int) -> set[int]: + """返回 root 及其所有从属权利要求编号。""" + by_parent: dict[int | None, list[int]] = {} + for n in nodes: + by_parent.setdefault(n.get("parent"), []).append(n["number"]) + desc: set[int] = {root} + stack = [root] + while stack: + cur = stack.pop() + for child in by_parent.get(cur, []): + if child not in desc: + desc.add(child) + stack.append(child) + return desc + + +def claim_delta_text( + text_preview: str, + *, + is_independent: bool = False, + limit: int = 72, +) -> str: + """从权项原文预览抽出「本项新增」短句(启发式降级;优先用 Agent claim_deltas)。""" + t = re.sub(r"\s+", " ", (text_preview or "").strip()) + t = re.sub( + r"^如权利要求[\d、或与以及至到\s]+所述的[^,。;]{0,80}[,,;;]?\s*", + "", + t, + ) + t = re.sub(r"^其特征在于[::]\s*", "", t) + if is_independent: + t = re.sub(r"^一种", "", t) + # 截到首个长分句,避免整段配方灌进表 + for sep in (";", ";", "。"): + if sep in t and t.index(sep) >= 12: + t = t.split(sep, 1)[0] + break + t = t.strip(" ,,;;") + if len(t) > limit: + t = t[: limit - 1] + "…" + return t or "(见原文)" + + +def load_claim_deltas(raw) -> dict[int, str]: + """解析 Agent「本项新增」JSON。 + + 支持: + - {"deltas":[{"claim":1,"delta":"…"}, …]} + - {"1":"…","2":"…"} / {"deltas":{"1":"…"}} + - [{"claim":1,"delta":"…"}] / [{"number":1,"summary":"…"}] + """ + out: dict[int, str] = {} + if raw is None: + return out + if isinstance(raw, Path): + if not raw.is_file(): + return out + try: + raw = json.loads(raw.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return out + if isinstance(raw, list): + items = raw + elif isinstance(raw, dict): + if isinstance(raw.get("deltas"), list): + items = raw["deltas"] + elif isinstance(raw.get("deltas"), dict): + items = [ + {"claim": k, "delta": v} for k, v in raw["deltas"].items() + ] + elif isinstance(raw.get("claim_deltas"), (list, dict)): + return load_claim_deltas(raw.get("claim_deltas")) + else: + # 纯映射:键为权号 + items = [{"claim": k, "delta": v} for k, v in raw.items() if str(k).isdigit() or isinstance(k, int)] + else: + return out + for item in items: + if not isinstance(item, dict): + continue + num = item.get("claim", item.get("number", item.get("id"))) + text = item.get("delta", item.get("summary", item.get("text", item.get("本项新增")))) + try: + n = int(num) + except (TypeError, ValueError): + continue + s = re.sub(r"\s+", " ", str(text or "").strip()) + if n > 0 and s: + out[n] = s + return out + + +def claim_deltas_from_tree(claim_tree: dict | None) -> dict[int, str]: + """若 claim_tree.nodes[].delta / agent_delta 已由 Agent 写入,则回收。""" + out: dict[int, str] = {} + if not claim_tree: + return out + for n in claim_tree.get("nodes") or []: + num = n.get("number") + text = n.get("delta") or n.get("agent_delta") or n.get("summary") + if num is None or not text: + continue + try: + out[int(num)] = re.sub(r"\s+", " ", str(text).strip()) + except (TypeError, ValueError): + continue + return out + + +def merge_claim_summaries(*parts: dict[int, str] | None) -> dict[int, str]: + """后写覆盖先写。推荐顺序:heuristic←note←tree←agent。""" + out: dict[int, str] = {} + for part in parts: + for k, v in (part or {}).items(): + try: + n = int(k) + except (TypeError, ValueError): + continue + s = re.sub(r"\s+", " ", str(v or "").strip()) + if n > 0 and s: + out[n] = s + return out + + +def _mermaid_escape(s: str) -> str: + return ( + (s or "") + .replace("\\", "/") + .replace('"', "'") + .replace("[", "(") + .replace("]", ")") + .replace("\n", " ") + ) + + +def claim_tree_to_mermaid( + claim_tree: dict, + pub: str = "", + *, + summaries: dict[int, str] | None = None, +) -> str: + """由 claim_tree.json 生成 mermaid(短标签;独立权=子图)。""" + nodes = claim_tree.get("nodes") or [] + if not nodes: + return "flowchart TB\n empty[无权利要求树数据]" + summaries = summaries or {} + roots = claim_tree.get("roots") or [ + n["number"] for n in nodes if n.get("is_independent") + ] + by_num = {n["number"]: n for n in nodes if n.get("number") is not None} + lines = [ + "flowchart TB", + " classDef ind fill:#4F46E5,stroke:#312E81,color:#fff", + " classDef dep fill:#F8FAFC,stroke:#64748B,color:#0F172A", + ] + if pub: + lines.append(f' meta["{_mermaid_escape(pub)}"]:::ind') + for root in roots: + family = sorted(_descendants_of(nodes, root)) + sg_id = f"sg{root}" + root_n = by_num.get(root) or {} + root_raw = summaries.get(root) or claim_delta_text( + str(root_n.get("text_preview") or ""), + is_independent=True, + limit=28, + ) + if len(root_raw) > 28: + root_raw = root_raw[:27] + "…" + root_gist = _mermaid_escape(root_raw) + lines.append(f' subgraph {sg_id}["独立权 {root} · {root_gist}"]') + for num in family: + n = by_num.get(num) or {} + raw = summaries.get(num) or claim_delta_text( + str(n.get("text_preview") or ""), + is_independent=bool(n.get("is_independent")), + limit=22, + ) + if len(raw) > 22: + raw = raw[:21] + "…" + gist = _mermaid_escape(raw) + if n.get("is_independent"): + lines.append(f' c{num}["权{num} 独立\\n{gist}"]:::ind') + else: + parent = n.get("parent") + lines.append(f' c{num}["权{num} ←{parent}\\n{gist}"]:::dep') + for num in family: + n = by_num.get(num) or {} + parent = n.get("parent") + if parent in family: + lines.append(f" c{parent} --> c{num}") + lines.append(" end") + if pub: + lines.append(f" meta --> c{root}") + # 多独立权时弱连(产品↔方法) + if len(roots) >= 2: + lines.append(f" c{roots[0]} -.相关.- c{roots[1]}") + return "\n".join(lines) + + +def _claim_tree_branch_prefix( + num: int, + by_num: dict[int, dict], + children: dict[int | None, list[int]], +) -> str: + """为权项生成树形前缀:◆ / ├─ / └─ / │ 等(单一视图表达从属)。""" + n = by_num.get(num) or {} + if n.get("is_independent") or n.get("parent") is None: + return "◆" + parent = n.get("parent") + # 根 → parent 路径(用于画左侧竖线) + path_to_parent: list[int] = [] + cur = parent + guard = 0 + while cur is not None and guard < 32: + path_to_parent.append(int(cur)) + cur = (by_num.get(cur) or {}).get("parent") + guard += 1 + path_to_parent.reverse() + prefix = "" + for anc in path_to_parent[:-1]: + ap = (by_num.get(anc) or {}).get("parent") + # 独立权之间不互画竖线(多根树并排) + if ap is None: + prefix += " " + continue + sibs = children.get(ap, []) + prefix += " " if sibs and sibs[-1] == anc else "│ " + sibs = children.get(parent, []) + prefix += "└─" if sibs and sibs[-1] == num else "├─" + return prefix + + +def render_claim_tree_markdown( + claim_tree: dict, + *, + pub: str = "", + summaries: dict[int, str] | None = None, + include_mermaid: bool = False, +) -> str: + """第三节「权利要求树」:单一树形一览表(结构+新增合在一起)。 + + mermaid 默认不嵌入正文(避免与表重复);需要时可 include_mermaid=True + 或单独使用 claim_mermaid.mmd。 + """ + rows = _claim_tree_rows(claim_tree, summaries=summaries, delta_limit=56) + if not rows: + return ( + "## 三、权利要求树\n\n" + "> 暂无结构化权项树;请对照说明书权利要求书阅读。\n" + ) + ind_count = sum(1 for b, _, _ in rows if b == "◆") + dep_count = len(rows) - ind_count + lines = [ + "## 三、权利要求树", + "", + f"> 共 **{len(rows)}** 项 · 独立 **{ind_count}** / 从属 **{dep_count}**。" + "下表一列看清从属与新增;独立权展开见**第四节**。", + "", + "| 结构 | 权 | 本项新增 |", + "| --- | ---: | --- |", + ] + for branch, num, delta in rows: + lines.append(f"| `{branch}` | {num} | {delta.replace('|', '\\|')} |") + + if include_mermaid: + mmd = claim_tree_to_mermaid(claim_tree, pub, summaries=summaries) + lines.extend( + [ + "", + "> [!note]- 图形示意(可选)", + "> 与上表同一棵树,仅供偏好流程图的读者。", + ">", + "> ```mermaid", + ] + ) + for ml in mmd.splitlines(): + lines.append(f"> {ml}") + lines.append("> ```") + + return "\n".join(lines).rstrip() + "\n" + + +def harvest_claim_summaries_from_note(content: str) -> dict[int, str]: + """从旧版缩进树/表中回收人工写过的短摘要。""" + m = re.search( + r"^##\s*三、\s*权利要求树\s*\n([\s\S]*?)(?=^##\s*四、|\Z)", + content, + re.M, + ) + if not m: + return {} + sec = m.group(1) + out: dict[int, str] = {} + for mm in re.finditer( + r"\*\*权\s*(\d+)[^*]*\*\*[::]\s*(.+?)(?=\n|$)", + sec, + ): + out[int(mm.group(1))] = mm.group(2).strip() + # 旧四列表:权|类型|从属|本项新增 + for mm in re.finditer( + r"^\|\s*(\d+)\s*\|\s*[^|]+\|\s*[^|]+\|\s*([^|]+)\|", + sec, + re.M, + ): + num = int(mm.group(1)) + cell = mm.group(2).strip() + if cell and cell not in ("本项新增", "---"): + out.setdefault(num, cell) + # 新三列表:结构|权|本项新增 + for mm in re.finditer( + r"^\|\s*`?[^|]*`?\s*\|\s*(\d+)\s*\|\s*([^|]+)\|", + sec, + re.M, + ): + num = int(mm.group(1)) + cell = mm.group(2).strip() + if cell and cell not in ("本项新增", "---", "权"): + out.setdefault(num, cell) + return out + + +def upsert_claim_tree_section(content: str, section_md: str) -> str: + """用新版第三节替换笔记中的「三、权利要求树」。""" + section_md = section_md.rstrip() + "\n\n" + pat = re.compile( + r"^##\s*三、\s*权利要求树\s*\n[\s\S]*?(?=^##\s*四、|\Z)", + re.M, + ) + if pat.search(content): + return pat.sub(section_md, content, count=1) + # 插在第二节后 + m = re.search(r"^##\s*二、.*$", content, re.M) + if m: + rest = content[m.end() :] + m2 = re.search(r"^##\s+", rest, re.M) + if m2: + ins = m.end() + m2.start() + return content[:ins] + section_md + content[ins:] + return content.rstrip() + "\n\n" + section_md + + +def parse_frontmatter(content: str) -> tuple[dict, str, str]: + if not content.startswith("---"): + return {}, "", content + end = content.find("\n---", 3) + if end == -1: + return {}, "", content + yaml_block = content[3:end].strip() + body = content[end + 4 :].lstrip("\n") + data: dict = {} + key: str | None = None + for line in yaml_block.splitlines(): + if line.startswith(" - ") and key == "tags": + data.setdefault("tags", []).append(line[4:].strip()) + elif line.startswith(" - ") and key == "assignees": + data.setdefault("assignees", []).append(line[4:].strip()) + elif line.startswith(" - ") and key == "cssclasses": + data.setdefault("cssclasses", []).append(line[4:].strip()) + elif line.startswith(" - ") and key == "aliases": + data.setdefault("aliases", []).append(line[4:].strip()) + elif line.startswith(" - ") and key == "related_pubs": + data.setdefault("related_pubs", []).append(line[4:].strip()) + elif ":" in line and not line.startswith(" "): + key, val = line.split(":", 1) + key = key.strip() + val = val.strip() + if val in ("", "[]"): + data[key] = ( + [] + if key in ("tags", "assignees", "cssclasses", "aliases", "related_pubs") + else "" + ) + elif val == "true": + data[key] = True + elif val == "false": + data[key] = False + else: + data[key] = val.strip('"') + return data, yaml_block, body + + +def render_frontmatter(data: dict) -> str: + lines = ["---"] + order = [ + "tags", + "aliases", + "cssclasses", + "pub_number", + "domain", + "ipc", + "assignees", + "related_pubs", + "read_date", + "perspective", + "evidence_scope", + "confidence_speculative", + ] + written: set[str] = set() + for key in order + sorted(k for k in data if k not in order): + if key in written or key not in data: + continue + written.add(key) + val = data[key] + if isinstance(val, list): + lines.append(f"{key}:") + for item in val: + lines.append(f" - {item}") + elif isinstance(val, bool): + lines.append(f"{key}: {'true' if val else 'false'}") + else: + lines.append(f"{key}: {val}") + lines.append("---") + return "\n".join(lines) + "\n" + + +def _clue_is_speculative(clue: dict) -> bool: + conf = str(clue.get("confidence") or "").strip().lower() + if conf in ("高", "high"): + return False + if conf in ("中", "低", "medium", "low", "med", "mid", ""): + return True + # 未标注置信度但有 URL 的附录线索默认视为推测 + return bool(clue.get("url") or clue.get("link")) + + +def enrich_note_frontmatter( + content: str, + *, + pub: str, + domain: str, + manifest: dict, + anchor: dict, + public_clues: list | None = None, +) -> str: + fm, _, body = parse_frontmatter(content) + ipc_codes = manifest.get("ipc_codes") or anchor.get("ipc_codes") or [] + ipc = ipc_codes[0] if ipc_codes else "" + scope = manifest.get("evidence_scope") or fm.get("evidence_scope") or "full_text" + assignees = manifest.get("assignees") or anchor.get("assignees") or fm.get("assignees") or [] + clues = public_clues or [] + speculative = bool(fm.get("confidence_speculative")) + if clues: + speculative = speculative or any(_clue_is_speculative(c) for c in clues) + # 未传线索文件时,根据正文附录 B / speculative callout 推断 + if not clues and ( + "[!speculative]" in body + or re.search(r"置信度[::]\s*(中|低)", body) + or "公开检索线索" in body and "http" in body + ): + speculative = True + + tags = list(dict.fromkeys(build_tags(domain, scope, speculative) + list(fm.get("tags") or []))) + cssclasses = list(dict.fromkeys(["patent-reader"] + list(fm.get("cssclasses") or []))) + aliases = list(dict.fromkeys([pub] + list(fm.get("aliases") or []))) + + # ipc 可能已是分号串(Agent 手写) + if not ipc and isinstance(fm.get("ipc"), str): + ipc = fm.get("ipc") or "" + elif isinstance(ipc_codes, list) and len(ipc_codes) > 1 and not str(fm.get("ipc") or "").strip(): + ipc = "; ".join(str(x) for x in ipc_codes[:4]) + + fm.update( + { + "tags": tags, + "aliases": aliases, + "cssclasses": cssclasses, + "pub_number": pub, + "domain": domain, + "ipc": ipc or fm.get("ipc") or "", + "assignees": assignees[:5] if isinstance(assignees, list) else assignees, + "evidence_scope": scope, + "evidence_label": evidence_scope_zh(str(scope)), + "confidence_speculative": speculative, + "speculative_label": speculative_zh(bool(speculative)), + } + ) + if not fm.get("read_date"): + fm["read_date"] = datetime.now().strftime("%Y-%m-%d") + return render_frontmatter(fm) + body + + +def scan_vault_related( + vault: Path, + papers_dir: str, + pub: str, + assignees: list[str], + *, + domain: str = "", +) -> dict: + """扫描库内相关笔记:同领域解读、同申请人、交底书。""" + papers = vault / papers_dir + related_patents: list[dict] = [] + disclosures: list[dict] = [] + if not papers.is_dir(): + return {"related_patents": [], "disclosures": [], "glossary_notes": []} + + assignee_set = {a.strip() for a in assignees if a and len(a.strip()) >= 2} + pub_slug = slugify_pub(pub) + seen: set[str] = set() + + for md in papers.rglob("*.md"): + if md.name.startswith("_") or is_spurious_patent_note(md): + continue + rel = str(md.relative_to(vault)).replace("\\", "/") + text = md.read_text(encoding="utf-8", errors="replace")[:4000] + base = md.stem + if pub_slug in base or (pub and pub in base): + continue + + if "_解读_" in md.name and rel not in seen: + same_domain = bool(domain) and f"/{domain}/" in f"/{rel}/" + same_assignee = bool(assignee_set) and any(a in text for a in assignee_set) + if same_domain or same_assignee: + label = "同申请人" if same_assignee else "同领域" + if same_domain and same_assignee: + label = "同申请人·同领域" + related_patents.append( + {"path": rel, "title": base, "label": label} + ) + seen.add(rel) + + if pub in text and ("交底书" in text or "disclosure" in base.lower()): + disclosures.append({"path": rel, "title": base}) + + return { + "related_patents": related_patents[:8], + "disclosures": disclosures[:5], + "glossary_notes": [], + } + + +def _parse_aliases_from_fm(yaml_block: str) -> tuple[str, list[str]]: + """仅从 aliases / title 取值,避免把 tags 误收为 alias。""" + title = "" + aliases: list[str] = [] + in_aliases = False + for line in yaml_block.splitlines(): + stripped = line.rstrip() + if stripped.startswith("title:"): + title = stripped.split(":", 1)[1].strip().strip('"').strip("'") + in_aliases = False + continue + if stripped.startswith("tags:") or stripped.startswith("cssclasses:"): + in_aliases = False + continue + if stripped.startswith("aliases:"): + in_aliases = True + rest = stripped.split(":", 1)[1].strip() + if rest and rest not in ("", "[]"): + aliases.append(rest.strip('"').strip("'")) + continue + if in_aliases: + if stripped.startswith(" - ") or (stripped.startswith("- ") and not stripped.startswith("- ipc")): + aliases.append(stripped.lstrip("- ").strip().strip('"').strip("'")) + continue + if stripped and not stripped.startswith(" "): + in_aliases = False + return title, [a for a in aliases if a] + + +def scan_glossary_index(vault: Path, glossary_dir: str) -> dict[str, str]: + """扫描术语目录:term/alias -> 相对库根路径(无 .md)。""" + root = vault / glossary_dir + index: dict[str, str] = {} + if not root.is_dir(): + return index + for md in root.rglob("*.md"): + if md.name.startswith("_"): + continue + rel = str(md.relative_to(vault).with_suffix("")).replace("\\", "/") + stem = md.stem + index[stem] = rel + index[stem.lower()] = rel + try: + text = md.read_text(encoding="utf-8", errors="replace")[:1200] + except OSError: + continue + fm_m = re.match(r"^---\n([\s\S]*?)\n---", text) + if not fm_m: + continue + title, aliases = _parse_aliases_from_fm(fm_m.group(1)) + if title: + index[title] = rel + index[title.lower()] = rel + for alias in aliases: + index[alias] = rel + index[alias.lower()] = rel + return index + + +def _glossary_file_matches_term(path: Path, term: str) -> bool: + if not path.is_file(): + return False + text = path.read_text(encoding="utf-8", errors="replace")[:1200] + fm_m = re.match(r"^---\n([\s\S]*?)\n---", text) + if not fm_m: + return path.stem == term or path.stem == slugify_term(term) + title, aliases = _parse_aliases_from_fm(fm_m.group(1)) + keys = {title, path.stem, *(aliases or [])} + keys |= {k.lower() for k in keys if k} + return term in keys or term.lower() in keys + + +def normalize_wiki_path(path: str) -> str: + """库内 wikilink 路径:统一 /,去掉 .md(Obsidian 惯例)。""" + s = (path or "").replace("\\", "/").strip() + if s.endswith(".md"): + s = s[:-3] + # 去掉误产生的「文件名 1」后缀对应路径中的空格副本(仅清理链到空壳的情况由调用方处理) + return s + + +def _wikilink_target(line: str) -> str: + m = re.search(r"\[\[([^\]|#]+)", line) + return normalize_wiki_path(m.group(1)) if m else "" + + +def is_spurious_patent_note(path: Path) -> bool: + """空壳/重复解读笔记:如「CN…_解读_20260721 1.md」(点坏链自动生成)。""" + name = path.name + if re.search(r"\s+\d+\.md$", name) and "_解读_" in name: + return True + if "_解读_" in name and path.is_file() and path.stat().st_size == 0: + return True + return False + + +def append_glossary_backlinks( + path: Path, + *, + source_pub: str, + note_rel: str = "", + disclosures: list[dict] | None = None, +) -> None: + """在术语页追加/合并反链:来源专利解读、交底书。路径一律正斜杠并去重。""" + if not path.is_file(): + return + body = path.read_text(encoding="utf-8") + marker = "## 反链" + note_rel = normalize_wiki_path(note_rel) + # 防御:调用方若传入 Path 的 Windows 字符串 + if note_rel.startswith("./"): + note_rel = note_rel[2:] + + # 先规范化已有反链节(去掉反斜杠重复项) + body, section_changed = _normalize_glossary_backlink_section(body) + + lines: list[str] = [] + if source_pub: + if note_rel: + lines.append(f"- 解读:[[{note_rel}|{source_pub}]]") + elif f"`{source_pub}`" not in body and f"|{source_pub}]]" not in body: + lines.append(f"- 专利:`{source_pub}`") + for d in disclosures or []: + p = normalize_wiki_path(str(d.get("path") or "")) + title = d.get("title") or p + if p: + lines.append(f"- 交底书:[[{p}|{title}]]") + + existing_targets: set[str] = set() + if marker in body: + sec = body.split(marker, 1)[1] + for ln in sec.splitlines(): + tgt = _wikilink_target(ln) + if tgt: + existing_targets.add(tgt) + # 无链接的「专利:`CNxxx`」行 + if source_pub and f"`{source_pub}`" in ln: + existing_targets.add(f"pub:{source_pub}") + + new_lines: list[str] = [] + for ln in lines: + tgt = _wikilink_target(ln) + if tgt and tgt in existing_targets: + continue + if source_pub and ln.strip() == f"- 专利:`{source_pub}`" and ( + f"pub:{source_pub}" in existing_targets or f"|{source_pub}]]" in body + ): + continue + if ln in body: + continue + new_lines.append(ln) + if tgt: + existing_targets.add(tgt) + + need_seen_in = False + if source_pub and body.startswith("---"): + fm_end = body.find("\n---", 3) + fm_head = body[: fm_end + 4] if fm_end != -1 else body[:800] + if "seen_in:" not in fm_head: + need_seen_in = True + elif source_pub not in fm_head: + need_seen_in = True + + if not new_lines and not need_seen_in and not section_changed: + return + + if new_lines: + if marker not in body: + body = body.rstrip() + f"\n\n{marker}\n\n" + "\n".join(new_lines) + "\n" + else: + for ln in new_lines: + body = body.rstrip() + f"\n{ln}\n" + + if need_seen_in and source_pub: + if "seen_in:" in body[:800]: + body = re.sub( + r"(seen_in:\s*\n(?:\s+- .+\n)*)", + rf"\1 - {source_pub}\n", + body, + count=1, + ) + else: + end = body.find("\n---", 3) + if end != -1: + body = body[:end] + f"\nseen_in:\n - {source_pub}\n" + body[end:] + path.write_text(body, encoding="utf-8") + + +def _normalize_glossary_backlink_section(body: str) -> tuple[str, bool]: + """反链节:路径改正斜杠,按目标去重。""" + marker = "## 反链" + if marker not in body: + return body, False + pre, rest = body.split(marker, 1) + # 反链节到下一 ## 或文末 + m = re.match(r"(\s*\n)([\s\S]*?)(?=\n##\s|\Z)", rest) + if not m: + return body, False + head_ws, sec = m.group(1), m.group(2) + tail = rest[m.end() :] + kept: list[str] = [] + seen: set[str] = set() + for ln in sec.splitlines(): + raw = ln.rstrip() + if not raw.strip(): + continue + if "[[" in raw: + + def _fix_link(mo: re.Match[str]) -> str: + target = mo.group(1).replace("\\", "/") + rest_g = mo.group(2) or "" + return f"[[{target}{rest_g}]]" + + raw = re.sub(r"\[\[([^\]|#]+)((?:\|[^\]]*)?)\]\]", _fix_link, raw) + key = _wikilink_target(raw) or raw.strip() + if key in seen: + continue + seen.add(key) + kept.append(raw) + new_sec = ("\n".join(kept) + "\n") if kept else "" + new_body = pre + marker + head_ws + new_sec + tail + return new_body, new_body != body + + +def repair_glossary_backlinks(vault: Path, glossary_dir: str) -> int: + """批量修复术语页反链(反斜杠重复)。返回修改文件数。""" + root = vault / glossary_dir + if not root.is_dir(): + return 0 + n = 0 + for path in root.glob("*.md"): + if path.name.startswith("_"): + continue + try: + old = path.read_text(encoding="utf-8") + except OSError: + continue + new, changed = _normalize_glossary_backlink_section(old) + if changed and new != old: + path.write_text(new, encoding="utf-8") + n += 1 + return n + + +def purge_spurious_patent_notes(vault: Path, papers_dir: str) -> list[str]: + """删除点坏链产生的空壳「…解读… 1.md」。""" + root = vault / papers_dir + removed: list[str] = [] + if not root.is_dir(): + return removed + for md in root.rglob("*.md"): + if not is_spurious_patent_note(md): + continue + # 仅删空文件或明确的「 数字」后缀副本 + try: + if md.stat().st_size == 0 or re.search(r"\s+\d+\.md$", md.name): + rel = str(md.relative_to(vault)).replace("\\", "/") + md.unlink() + removed.append(rel) + except OSError: + continue + return removed + + +def ensure_glossary_stub( + vault: Path, + glossary_dir: str, + term: str, + *, + definition: str = "", + source_pub: str = "", + papers_dir: str = "Research/Patents", + note_rel: str = "", + disclosures: list[dict] | None = None, +) -> tuple[str, bool]: + """确保术语页存在;撞名时换唯一 slug;返回 (相对路径无.md, 是否新建)。""" + root = vault / glossary_dir + root.mkdir(parents=True, exist_ok=True) + slug = slugify_term(term) + path = root / f"{slug}.md" + if path.is_file() and not _glossary_file_matches_term(path, term): + # slug 撞名但术语不同 → 换唯一文件名 + i = 2 + while True: + cand = root / f"{slug}_{i}.md" + if not cand.is_file() or _glossary_file_matches_term(cand, term): + path = cand + break + i += 1 + rel = str(path.relative_to(vault).with_suffix("")).replace("\\", "/") + created = False + if path.is_file(): + # 合并 alias;若正文仍是占位且有第五节含义则回填 + text = path.read_text(encoding="utf-8") + if term not in text[:600]: + text = re.sub( + r"(aliases:\s*\n(?:\s+- .+\n)*)", + rf"\1 - {term}\n", + text, + count=1, + ) + path.write_text(text, encoding="utf-8") + if definition.strip(): + _fill_glossary_definition(path, term, definition.strip()) + else: + defn = definition.strip() or "(待补充:来自专利说明书定义或一般理解)" + body = ( + "---\n" + "tags:\n" + " - glossary\n" + "aliases:\n" + f" - {term}\n" + f"title: {term}\n" + f"source_pub: {source_pub}\n" + "seen_in:\n" + f" - {source_pub}\n" + "---\n\n" + f"# {term}\n\n" + f"{defn}\n\n" + f"来源专利:`{source_pub}` · [[{papers_dir}/_专利解读索引|专利解读索引]]\n\n" + "## 反链\n\n" + ) + path.write_text(body, encoding="utf-8") + created = True + append_glossary_backlinks( + path, + source_pub=source_pub, + note_rel=note_rel, + disclosures=disclosures, + ) + return rel, created + + +def _fill_glossary_definition(path: Path, term: str, definition: str) -> bool: + """用第五节「本文含义」回填空壳/占位术语页正文。""" + if not definition or not path.is_file(): + return False + text = path.read_text(encoding="utf-8") + m = re.search( + rf"(^#\s*{re.escape(term)}\s*\n\n)(.+?)(\n\n来源专利[::]|\n\n##\s*反链|\Z)", + text, + re.M | re.S, + ) + if not m: + return False + old = m.group(2).strip() + if old == definition: + return False + if not ( + old.startswith("(待补充") + or old.startswith("(待补充") + or len(old) < 8 + ): + # 已有实质定义则不覆盖,仅当很短时允许补强 + if len(old) >= 8 and "待补充" not in old: + return False + new_text = text[: m.start(2)] + definition + text[m.end(2) :] + path.write_text(new_text, encoding="utf-8") + return True + + +def resolve_glossary_nodes( + vault: Path, + glossary_dir: str, + terms: list[str] | list[dict], + *, + create_stubs: bool = True, + source_pub: str = "", + papers_dir: str = "Research/Patents", + definitions: dict[str, str] | None = None, + note_rel: str = "", + disclosures: list[dict] | None = None, +) -> list[dict]: + """将术语列表解析为 Canvas 可用节点信息。""" + definitions = definitions or {} + index = scan_glossary_index(vault, glossary_dir) + nodes: list[dict] = [] + # 全部术语建 stub/反链;Canvas 仅展示前 8 个节点 + for i, item in enumerate(terms): + if isinstance(item, dict): + term = str(item.get("term") or "").strip() + defn = str(item.get("definition") or definitions.get(term, "")).strip() + else: + term = str(item).strip() + defn = definitions.get(term, "") + if not term: + continue + rel = index.get(term) or index.get(term.lower()) + created = False + if not rel and create_stubs: + rel, created = ensure_glossary_stub( + vault, + glossary_dir, + term, + definition=defn, + source_pub=source_pub, + papers_dir=papers_dir, + note_rel=note_rel, + disclosures=disclosures, + ) + index[term] = rel + elif rel: + # 已有页:补反链;有定义则尝试回填空壳 + stub_path = vault / f"{rel}.md" + if defn: + _fill_glossary_definition(stub_path, term, defn) + append_glossary_backlinks( + stub_path, + source_pub=source_pub, + note_rel=note_rel, + disclosures=disclosures, + ) + if i < 8: + nodes.append( + { + "term": term, + "path": rel or "", + "created": created, + "has_file": bool(rel), + "definition": defn, + } + ) + return nodes + + +def _claim_tree_rows( + claim_tree: dict, + *, + summaries: dict[int, str] | None = None, + delta_limit: int = 40, +) -> list[tuple[str, int, str]]: + """统一权项树行:(结构前缀, 权号, 本项新增)。与笔记第三节同构。""" + nodes = claim_tree.get("nodes") or [] + if not nodes: + return [] + summaries = dict(summaries or {}) + by_num = {n["number"]: n for n in nodes if n.get("number") is not None} + for n in nodes: + num = n.get("number") + if num is None: + continue + if num in summaries and str(summaries[num]).strip(): + summaries[num] = re.sub(r"\s+", " ", str(summaries[num]).strip()) + if len(summaries[num]) > delta_limit: + summaries[num] = summaries[num][: delta_limit - 1] + "…" + continue + summaries[num] = claim_delta_text( + str(n.get("text_preview") or ""), + is_independent=bool(n.get("is_independent")), + limit=delta_limit, + ) + + children: dict[int | None, list[int]] = {} + for n in nodes: + parent = None if n.get("is_independent") else n.get("parent") + children.setdefault(parent, []).append(n["number"]) + for k in children: + children[k] = sorted(children[k]) + + def _walk(num: int, acc: list[int]) -> None: + if num in acc: + return + acc.append(num) + for ch in children.get(num, []): + _walk(ch, acc) + + order: list[int] = [] + roots = list( + dict.fromkeys( + [n["number"] for n in nodes if n.get("is_independent")] + or (claim_tree.get("roots") or []) + ) + ) + for r in roots: + _walk(int(r), order) + for num in sorted(by_num.keys()): + if num not in order: + order.append(num) + + rows: list[tuple[str, int, str]] = [] + for num in order: + n = by_num[num] + if n.get("is_independent") or n.get("parent") is None: + branch = "◆" + else: + branch = _claim_tree_branch_prefix(num, by_num, children) + rows.append((branch, num, summaries.get(num) or "—")) + return rows + + +def _claim_tree_card_text( + claim_tree: dict | None, + pub: str, + *, + summaries: dict[int, str] | None = None, +) -> str: + """Canvas 权项卡:与笔记第三节同一套树形表(更短一句)。""" + if not claim_tree: + return "" + rows = _claim_tree_rows(claim_tree, summaries=summaries, delta_limit=32) + if not rows: + return "" + ind = sum(1 for b, _, _ in rows if b == "◆") + lines = [ + f"## 权项树 · `{pub}`", + "", + f"独立 {ind} / 共 {len(rows)} · 与笔记第三节同构", + "", + "| 结构 | 权 | 本项新增 |", + "| --- | ---: | --- |", + ] + for branch, num, delta in rows[:14]: + lines.append(f"| `{branch}` | {num} | {delta.replace('|', '\\|')} |") + if len(rows) > 14: + lines.append(f"| … | | 另 {len(rows) - 14} 项 |") + return "\n".join(lines) + + +# Canvas 配色(hex;旧客户端不认时可回退预设 1–6) +_CANVAS_COLORS = { + "center": "#4F46E5", + "hub": "#0284C7", + "claims": "#475569", + "related": "#CA8A04", + "term": "#EA580C", + "disclosure": "#0F766E", + "group_narr": "#6366F1", + "group_term": "#F97316", + "group_rel": "#EAB308", + "narr_problem": "#DC2626", + "narr_approach": "#D97706", + "narr_how": "#2563EB", + "narr_effect": "#059669", + "narr_diff": "#7C3AED", + "narr_one": "#4F46E5", + "clue": "#B45309", + "group_clue": "#D97706", +} + + +def _clip_canvas_text(text: str, limit: int = 140) -> str: + s = re.sub(r"\s+", " ", (text or "").strip()) + if len(s) <= limit: + return s + return s[: limit - 1] + "…" + + +def _narrative_cards(narrative: dict | None) -> list[tuple[str, str, str, str]]: + """返回 (id, 标题, 正文, 颜色) 列表。""" + if not narrative: + return [] + order = [ + ("problem", "问题", "narr_problem"), + ("approach", "思路", "narr_approach"), + ("how", "怎么做", "narr_how"), + ("effect", "效果", "narr_effect"), + ("diff", "差别", "narr_diff"), + ("one_liner", "一句话", "narr_one"), + ] + cards: list[tuple[str, str, str, str]] = [] + for key, label, color_key in order: + text = str(narrative.get(key) or "").strip() + if not text: + continue + if key == "one_liner" and any( + k in narrative for k in ("problem", "approach", "effect") + ): + continue + cards.append( + ( + f"narr-{key}", + label, + _clip_canvas_text(text, 160), + _CANVAS_COLORS[color_key], + ) + ) + return cards[:5] + + +def build_canvas( + *, + vault: Path | None, + papers_dir: str, + note_rel_path: str, + pub: str, + title: str, + related: dict, + glossary_terms: list[str] | list[dict] | None = None, + glossary_dir: str = "Research/术语", + create_glossary_stubs: bool = True, + glossary_root: Path | None = None, + meta: dict | None = None, + claim_tree: dict | None = None, + claim_summaries: dict[int, str] | None = None, + figure_rels: list[str] | None = None, + narrative: dict | None = None, + clue_cards: list[dict] | None = None, +) -> dict: + """生成 JSON Canvas:叙事故事地图 + 精简中心 + 术语含义卡 + 分组。 + + glossary_root 用于无 vault 时写入本地术语页。 + 默认不挂扫描附图(易刷屏);figure_rels 仅保留非 page_ 精修图最多 1 张。 + """ + nodes: list[dict] = [] + edges: list[dict] = [] + center_id = "center" + meta = meta or {} + narrative = narrative or {} + note_rel = str(note_rel_path).replace("\\", "/") + note_link = note_rel[:-3] if note_rel.endswith(".md") else note_rel + + domain = str(meta.get("domain") or "").strip() + ipc = meta.get("ipc") or meta.get("ipc_codes") or "" + if isinstance(ipc, list): + ipc = "; ".join(str(x) for x in ipc[:4]) + assignees = meta.get("assignees") or [] + if isinstance(assignees, list): + asg = "、".join(str(a) for a in assignees[:3]) + else: + asg = str(assignees) + scope = str(meta.get("evidence_scope") or "").strip() + scope_zh = { + "full_text": "全文", + "abstract_only": "仅摘要", + "partial": "部分", + }.get(scope, scope or "—") + + one = _clip_canvas_text( + str(narrative.get("one_liner") or narrative.get("problem") or ""), 120 + ) + center_lines = [ + f"# `{pub}`", + "", + f"**{title}**" if title else "", + "", + one or "(打开下方链接阅读全文解读)", + "", + f"[[{note_link}|打开解读笔记]]", + ] + nodes.append( + { + "id": center_id, + "type": "text", + "text": "\n".join(ln for ln in center_lines if ln is not None), + "x": -40, + "y": -40, + "width": 420, + "height": 240, + "color": _CANVAS_COLORS["center"], + } + ) + + # 叙事卡 + 分组(中心上方) + narr_cards = _narrative_cards(narrative) + if narr_cards: + n = len(narr_cards) + card_w, gap = 250, 16 + total_w = n * card_w + (n - 1) * gap + start_x = -total_w // 2 + narr_y = -420 + nodes.append( + { + "id": "grp-narr", + "type": "group", + "x": start_x - 24, + "y": narr_y - 48, + "width": total_w + 48, + "height": 290, + "label": "叙事", + "color": _CANVAS_COLORS["group_narr"], + } + ) + for i, (nid, label, text, color) in enumerate(narr_cards): + nodes.append( + { + "id": nid, + "type": "text", + "text": f"## {label}\n\n{text}", + "x": start_x + i * (card_w + gap), + "y": narr_y, + "width": card_w, + "height": 220, + "color": color, + } + ) + edges.append( + { + "id": f"e-{nid}", + "fromNode": nid, + "fromSide": "bottom", + "toNode": center_id, + "toSide": "top", + "label": label, + "color": color, + } + ) + + hub_lines = [ + "## 著录", + "", + f"**公开号** `{pub}`", + f"**领域** {domain or '—'}", + f"**IPC** {ipc or '—'}", + f"**申请人** {asg or '—'}", + f"**证据** {scope_zh}", + "", + f"[[{papers_dir}/_专利解读索引|索引]]", + ] + if domain: + hub_lines.append(f"[[{papers_dir}/{domain}/_领域索引|{domain}]]") + hub_lines.append(f"[[{glossary_dir}/_术语索引|术语索引]]") + + nodes.append( + { + "id": "hub", + "type": "text", + "text": "\n".join(hub_lines), + "x": -560, + "y": -80, + "width": 300, + "height": 280, + "color": _CANVAS_COLORS["hub"], + } + ) + edges.append( + { + "id": "e-hub-center", + "fromNode": "hub", + "fromSide": "right", + "toNode": center_id, + "toSide": "left", + "label": "著录", + "color": _CANVAS_COLORS["hub"], + } + ) + + claim_text = _claim_tree_card_text( + claim_tree, pub, summaries=claim_summaries + ) + if claim_text: + row_n = max(claim_text.count("\n|"), 3) + nodes.append( + { + "id": "claims", + "type": "text", + "text": claim_text, + "x": -580, + "y": 220, + "width": 360, + "height": min(120 + row_n * 28, 420), + "color": _CANVAS_COLORS["claims"], + } + ) + edges.append( + { + "id": "e-claims-center", + "fromNode": "claims", + "fromSide": "right", + "toNode": center_id, + "toSide": "left", + "label": "权项", + "color": _CANVAS_COLORS["claims"], + } + ) + + rel_items = list(related.get("related_patents") or [])[:4] + if rel_items: + nodes.append( + { + "id": "grp-rel", + "type": "group", + "x": 480, + "y": -200, + "width": 340, + "height": 40 + len(rel_items) * 150, + "label": "关联专利", + "color": _CANVAS_COLORS["group_rel"], + } + ) + y = -160 + for i, item in enumerate(rel_items): + nid = f"rp{i}" + path = str(item.get("path") or "").replace("\\", "/") + link = path[:-3] if path.endswith(".md") else path + other = str(item.get("title") or item.get("pub") or Path(path).stem) + if "_解读_" in other: + other = other.split("_解读_")[0] + elif other.endswith("_解读"): + other = other[: -len("_解读")] + label = item.get("label") or "相关专利" + nodes.append( + { + "id": nid, + "type": "text", + "text": f"## {other}\n\n*{label}*\n\n[[{link}|打开笔记]]", + "x": 500, + "y": y, + "width": 300, + "height": 130, + "color": _CANVAS_COLORS["related"], + } + ) + edges.append( + { + "id": f"e-center-{nid}", + "fromNode": center_id, + "fromSide": "right", + "toNode": nid, + "toSide": "left", + "label": label, + "color": _CANVAS_COLORS["related"], + } + ) + y += 150 + + y_dc = 200 if claim_text else 220 + for i, item in enumerate(related.get("disclosures") or []): + nid = f"dc{i}" + path = str(item.get("path") or "").replace("\\", "/") + link = path[:-3] if path.endswith(".md") else path + nodes.append( + { + "id": nid, + "type": "text", + "text": f"## 交底书\n\n[[{link}|打开]]", + "x": -560, + "y": y_dc + 320 + i * 160, + "width": 300, + "height": 120, + "color": _CANVAS_COLORS["disclosure"], + } + ) + edges.append( + { + "id": f"e-dc-{nid}", + "fromNode": nid, + "fromSide": "right", + "toNode": center_id, + "toSide": "left", + "label": "交底书", + "color": _CANVAS_COLORS["disclosure"], + } + ) + + # 公开线索卡(推测层;链到 clues/ 笔记) + clue_list = list(clue_cards or [])[:6] + if clue_list: + clue_y0 = 420 + nodes.append( + { + "id": "grp-clues", + "type": "group", + "x": 480, + "y": clue_y0 - 40, + "width": 340, + "height": 36 + len(clue_list) * 150, + "label": "公开线索(推测)", + "color": _CANVAS_COLORS["group_clue"], + } + ) + for i, card in enumerate(clue_list): + nid = f"clue{i}" + title = str(card.get("title") or "线索")[:40] + conf = card.get("confidence") or "中" + link = str(card.get("link") or "").replace("\\", "/") + reason = str(card.get("reason") or "")[:64] + claims = card.get("related_claims") or [] + fids = card.get("related_feature_ids") or [] + bits: list[str] = [] + if claims: + bits.append("权" + "、".join(str(n) for n in claims[:4])) + if fids: + bits.append("·".join(str(x) for x in fids[:4])) + claim_bit = "可能相关:" + " ".join(bits) if bits else "弱匹配未命中" + text = ( + f"## {title}\n\n" + f"*置信 {conf} · 推测*\n\n" + f"{claim_bit}\n\n" + f"{reason}\n\n" + f"[[{link}|打开线索]]" + ) + nodes.append( + { + "id": nid, + "type": "text", + "text": text, + "x": 500, + "y": clue_y0 + i * 150, + "width": 300, + "height": 140, + "color": _CANVAS_COLORS["clue"], + } + ) + edges.append( + { + "id": f"e-center-{nid}", + "fromNode": center_id, + "fromSide": "bottom", + "toNode": nid, + "toSide": "left", + "label": "线索", + "color": _CANVAS_COLORS["clue"], + } + ) + if claims and claim_text: + edges.append( + { + "id": f"e-claims-{nid}", + "fromNode": "claims", + "fromSide": "right", + "toNode": nid, + "toSide": "bottom", + "label": "权" + "、".join(str(n) for n in claims[:3]), + "color": _CANVAS_COLORS["clue"], + } + ) + + glossary_nodes: list[dict] = [] + disclosures = related.get("disclosures") or [] + # 从 glossary_terms 预取 definition,resolve 后仍保留 + defn_map: dict[str, str] = {} + for item in list(glossary_terms or []): + if isinstance(item, dict): + t = str(item.get("term") or "").strip() + d = str(item.get("definition") or "").strip() + if t and d: + defn_map[t] = d + + if vault and glossary_terms: + glossary_nodes = resolve_glossary_nodes( + vault, + glossary_dir, + list(glossary_terms), + create_stubs=create_glossary_stubs, + source_pub=pub, + papers_dir=papers_dir, + note_rel=note_link, + disclosures=disclosures, + definitions=defn_map, + ) + elif glossary_terms and glossary_root is not None: + glossary_root.mkdir(parents=True, exist_ok=True) + fake_vault = glossary_root.parent + rel_dir = glossary_root.name + glossary_nodes = resolve_glossary_nodes( + fake_vault, + rel_dir, + list(glossary_terms), + create_stubs=create_glossary_stubs, + source_pub=pub, + papers_dir=papers_dir, + note_rel="", + disclosures=disclosures, + definitions=defn_map, + ) + for g in glossary_nodes: + if g.get("path"): + g["path"] = f"{rel_dir}/{Path(g['path']).name}" + elif glossary_terms: + for item in list(glossary_terms)[:8]: + if isinstance(item, dict): + glossary_nodes.append( + { + "term": item.get("term"), + "path": "", + "has_file": False, + "definition": item.get("definition") or "", + } + ) + else: + glossary_nodes.append( + {"term": str(item), "path": "", "has_file": False, "definition": ""} + ) + + for g in glossary_nodes: + if not g.get("definition") and g.get("term") in defn_map: + g["definition"] = defn_map[g["term"]] + + show_terms = glossary_nodes[:6] + if show_terms: + cols = min(3, len(show_terms)) + rows = (len(show_terms) + cols - 1) // cols + card_w, card_h, gap_x, gap_y = 240, 150, 16, 16 + grid_w = cols * card_w + (cols - 1) * gap_x + grid_h = rows * card_h + (rows - 1) * gap_y + gx = -grid_w // 2 + gy = 320 + nodes.append( + { + "id": "grp-term", + "type": "group", + "x": gx - 20, + "y": gy - 40, + "width": grid_w + 40, + "height": grid_h + 56, + "label": "术语(本文含义)", + "color": _CANVAS_COLORS["group_term"], + } + ) + for i, g in enumerate(show_terms): + nid = f"g{i}" + term = g.get("term") or "" + defn = _clip_canvas_text(str(g.get("definition") or "(见术语页)"), 90) + col, row = i % cols, i // cols + path = str(g.get("path") or "") + if path and not path.endswith(".md"): + link_target = path + elif path: + link_target = path[:-3] + else: + link_target = "" + body = f"## {term}\n\n{defn}" + if link_target: + body += f"\n\n[[{link_target}|术语页]]" + nodes.append( + { + "id": nid, + "type": "text", + "text": body, + "x": gx + col * (card_w + gap_x), + "y": gy + row * (card_h + gap_y), + "width": card_w, + "height": card_h, + "color": _CANVAS_COLORS["term"], + } + ) + edges.append( + { + "id": f"e-g-{nid}", + "fromNode": center_id, + "fromSide": "bottom", + "toNode": nid, + "toSide": "top", + "label": "术语", + "color": _CANVAS_COLORS["term"], + } + ) + + # 仅非扫描页精修图,最多 1 张(可选) + figs = [ + f + for f in (figure_rels or []) + if f and "page_" not in Path(f).name.lower() and "xref" not in Path(f).name.lower() + ][:1] + if figs: + frel = figs[0].replace("\\", "/") + nodes.append( + { + "id": "fig0", + "type": "file", + "file": frel, + "x": 500, + "y": y + 20, + "width": 220, + "height": 160, + "color": "6", + } + ) + edges.append( + { + "id": "e-fig-0", + "fromNode": center_id, + "fromSide": "right", + "toNode": "fig0", + "toSide": "left", + "label": "附图", + } + ) + + return {"nodes": nodes, "edges": edges, "glossary_resolved": glossary_nodes} + + +def upsert_index_entry( + index_path: Path, + title: str, + entry_line: str, + intro: str, + extra_body: str = "", + *, + dedupe_key: str = "", +) -> None: + """创建或更新索引页,追加笔记列表条目。 + + dedupe_key: 若提供,则删除列表中已含该 key 的旧行后再追加(用于术语按 term 去重)。 + """ + index_path.parent.mkdir(parents=True, exist_ok=True) + if index_path.is_file(): + body = index_path.read_text(encoding="utf-8") + else: + body = ( + f"---\ntags:\n - patents/index\n---\n\n" + f"# {title}\n\n{intro}\n\n{extra_body}\n" + ) + + marker = "## 笔记列表" + # 术语索引用「术语列表」 + if "术语" in title and marker not in body and "## 术语列表" in body: + marker = "## 术语列表" + + if dedupe_key: + lines = body.splitlines(keepends=True) + new_lines: list[str] = [] + for ln in lines: + if ln.lstrip().startswith("- ") and dedupe_key in ln: + continue + new_lines.append(ln) + body = "".join(new_lines) + elif entry_line in body: + index_path.write_text(body, encoding="utf-8") + return + + if marker not in body: + body = body.rstrip() + f"\n\n{marker}\n\n" + body = body.rstrip() + f"\n- {entry_line}\n" + index_path.write_text(body, encoding="utf-8") + + +def ensure_domain_index(vault: Path, papers_dir: str, domain: str) -> Path: + """确保领域索引页存在。""" + domain_dir = vault / papers_dir / domain + domain_dir.mkdir(parents=True, exist_ok=True) + index_path = domain_dir / "_领域索引.md" + if not index_path.is_file(): + body = ( + "---\n" + "tags:\n" + " - patents/index\n" + f"cssclasses:\n" + " - patent-index\n" + "---\n\n" + f"# {domain} · 领域索引\n\n" + f"领域:**{domain}**。上级:[[{papers_dir}/_专利解读索引|专利解读索引]]。\n\n" + f"## 本领域仪表盘(Dataview)\n\n" + f"```dataview\n" + f'TABLE pub_number AS "公开号", read_date AS "解读日期", ' + f'default(evidence_label, choice(evidence_scope = "full_text", "全文", ' + f'choice(evidence_scope = "abstract_only", "仅摘要", ' + f'choice(evidence_scope = "partial", "部分", evidence_scope)))) AS "证据范围", ' + f'default(speculative_label, choice(confidence_speculative, "是", "否")) AS "含推测"\n' + f'FROM "{papers_dir}/{domain}"\n' + f'WHERE contains(file.name, "_解读_")\n' + f"SORT read_date DESC\n" + f"```\n\n" + "## 笔记列表\n\n" + ) + index_path.write_text(body, encoding="utf-8") + else: + # 已有领域索引:升级证据列中文显示 + try: + body = index_path.read_text(encoding="utf-8") + except OSError: + return index_path + if "evidence_label" not in body and ( + 'evidence_scope AS "证据' in body + or 'AS "证据"' in body + or 'AS "证据范围"' in body + ): + body2 = re.sub( + r"```dataview\nTABLE[\s\S]*?```", + ( + "```dataview\n" + 'TABLE pub_number AS "公开号", read_date AS "解读日期", ' + 'default(evidence_label, choice(evidence_scope = "full_text", "全文", ' + 'choice(evidence_scope = "abstract_only", "仅摘要", ' + 'choice(evidence_scope = "partial", "部分", evidence_scope)))) AS "证据范围", ' + 'default(speculative_label, choice(confidence_speculative, "是", "否")) AS "含推测"\n' + f'FROM "{papers_dir}/{domain}"\n' + 'WHERE contains(file.name, "_解读_")\n' + "SORT read_date DESC\n" + "```" + ), + body, + count=1, + ) + if body2 != body: + index_path.write_text(body2, encoding="utf-8") + return index_path + + +def _repair_index_glossary_dataview(index_path: Path, glossary_dir: str) -> bool: + """修补索引页「术语网」Dataview:围栏必须为 ```,查询用 FROM … AND #glossary。""" + try: + body = index_path.read_text(encoding="utf-8") + except OSError: + return False + fence = "`" * 3 + new_section = ( + "### 术语网(反链入口)\n\n" + f"> 下列列表依赖 Dataview;若仍为空,请打开 `{glossary_dir}/` 核对术语页," + "或点开 `glossary.base`。\n\n" + f"{fence}dataview\n" + "LIST\n" + f'FROM "{glossary_dir}" AND #glossary\n' + 'WHERE file.name != "_术语索引"\n' + "SORT file.name ASC\n" + f"{fence}\n\n" + ) + pat = re.compile(r"###\s*术语网[\s\S]*?(?=##\s*笔记列表|##\s*关联图谱)") + m = pat.search(body) + if not m: + return False + good = f'{fence}dataview\nLIST\nFROM "{glossary_dir}" AND #glossary\n' + sec = m.group(0) + if good in sec and sec.count(fence) >= 2: + return False + new_body = pat.sub(new_section, body) + if new_body == body: + return False + index_path.write_text(new_body, encoding="utf-8") + return True + + +def _upgrade_index_evidence_dataview(index_path: Path, papers_dir: str) -> bool: + """将主索引 Dataview 证据/推测列升级为中文(evidence_label / speculative_label)。""" + try: + body = index_path.read_text(encoding="utf-8") + except OSError: + return False + if "evidence_label" in body and "speculative_label" in body: + return False + if "evidence_scope AS" not in body and 'AS "含推测"' not in body: + return False + fence = "`" * 3 + new_table = ( + f"{fence}dataview\n" + 'TABLE pub_number AS "公开号", domain AS "领域", read_date AS "解读日期", ' + 'default(evidence_label, choice(evidence_scope = "full_text", "全文", ' + 'choice(evidence_scope = "abstract_only", "仅摘要", ' + 'choice(evidence_scope = "partial", "部分", evidence_scope)))) AS "证据范围", ' + 'ipc AS "IPC", ' + 'default(speculative_label, choice(confidence_speculative, "是", "否")) AS "含推测"\n' + f'FROM "{papers_dir}"\n' + 'WHERE contains(file.name, "_解读_")\n' + "SORT read_date DESC\n" + f"{fence}" + ) + body2, n = re.subn( + rf"{fence}dataview\nTABLE[\s\S]*?{fence}", + new_table, + body, + count=1, + ) + if n == 0 or body2 == body: + return False + # 确保有关联图谱节 + if "_专利关联.canvas" not in body2: + link = f"- [[{papers_dir}/_专利关联.canvas|专利关联总览]](交付后可生成专利关联)\n" + if "## 关联图谱" in body2: + body2 = re.sub( + r"(##\s*关联图谱\s*\n)", + rf"\1\n{link}", + body2, + count=1, + ) + elif "## 笔记列表" in body2: + body2 = body2.replace( + "## 笔记列表", + f"## 关联图谱\n\n{link}\n## 笔记列表", + 1, + ) + index_path.write_text(body2, encoding="utf-8") + return True + + +def _rgb_pack(hex_color: str) -> int: + """#RRGGBB → Obsidian graph.json 的 rgb 整数。""" + h = hex_color.lstrip("#") + return int(h, 16) + + +def build_patent_graph_color_groups( + papers_dir: str = "Research/Patents", + glossary_dir: str = "Research/术语", +) -> list[dict]: + """专利解读关系图配色(先匹配先生效)。""" + papers_q = papers_dir.replace("\\", "/").rstrip("/") + gloss_q = glossary_dir.replace("\\", "/").rstrip("/") + + def g(query: str, hex_color: str) -> dict: + return {"query": query, "color": {"a": 1, "rgb": _rgb_pack(hex_color)}} + + return [ + g("file:_图谱", "#14B8A6"), # Canvas 单篇图谱 · 青绿 + g("file:_专利关联", "#0D9488"), # 全局关联 · 深青 + g(f'path:"{gloss_q}"', "#F97316"), # 术语目录 · 橙 + g("tag:#glossary", "#FB923C"), # 术语标签 · 浅橙 + g("tag:#patents/index", "#64748B"), # 索引 · 石板灰 + g("tag:#patent/speculative", "#F59E0B"), # 含推测 · 琥珀 + g("file:_解读_", "#4F46E5"), # 解读笔记 · 靛 + g("tag:#patents", "#6366F1"), # 专利标签 · 靛紫 + g("file:.base", "#0F766E"), # Bases · 深青 + g(f'path:"{papers_q}"', "#818CF8"), # 专利目录兜底 + ] + + +def _is_managed_graph_query(query: str) -> bool: + q = str(query or "") + return ( + q.startswith("file:_图谱") + or q.startswith("file:_专利关联") + or q.startswith("file:_解读_") + or q.startswith("file:.base") + or q.startswith("tag:#patent") + or q.startswith("tag:#glossary") + or q.startswith("tag:#patents") + or q.startswith('path:"Research/') + or q.startswith("path:Research/") + ) + + +# 关系图保留 Canvas/PDF,过滤附图、旁路 JSON、悬停旁路笔记 +# (search 与 Obsidian 搜索语法一致;负向 file: 排除节点) +GRAPH_EXCLUDE_TERMS = ( + "-file:.png", + "-file:.jpg", + "-file:.jpeg", + "-file:.gif", + "-file:.webp", + "-file:.svg", + "-file:.bmp", + "-file:.tif", + "-file:.tiff", + "-file:.json", + "-file:.jsonl", + "-file:_权项锚点", + "-file:_说明书段落", +) + +# 兼容旧名 +GRAPH_IMAGE_EXCLUDE_TERMS = GRAPH_EXCLUDE_TERMS + + +def _merge_graph_search_excludes(existing: str) -> str: + """在保留用户自定义 filter 的前提下,确保排除噪声节点。""" + parts = [p for p in (existing or "").split() if p] + for term in GRAPH_EXCLUDE_TERMS: + if term not in parts: + parts.append(term) + return " ".join(parts) + + +def _merge_graph_search_hide_images(existing: str) -> str: + """兼容旧调用名。""" + return _merge_graph_search_excludes(existing) + + +def ensure_graph_color_groups( + vault: Path, + papers_dir: str = "Research/Patents", + glossary_dir: str = "Research/术语", +) -> str | None: + """写入/合并 .obsidian/graph.json 颜色分组;返回动作描述或 None。""" + obsidian_dir = vault / ".obsidian" + obsidian_dir.mkdir(parents=True, exist_ok=True) + graph_path = obsidian_dir / "graph.json" + desired = build_patent_graph_color_groups(papers_dir, glossary_dir) + desired_queries = {g["query"] for g in desired} + + if graph_path.is_file(): + try: + data = json.loads(graph_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + data = {} + else: + data = { + "collapse-filter": False, + "search": "", + "showTags": False, + "showAttachments": True, + "hideUnresolved": False, + "showOrphans": True, + "collapse-display": False, + "showArrow": False, + "textFadeMultiplier": 0, + "nodeSizeMultiplier": 1.1, + "lineSizeMultiplier": 1, + "collapse-forces": False, + "centerStrength": 0.5, + "repelStrength": 10, + "linkStrength": 1, + "linkDistance": 250, + "scale": 1, + "close": False, + } + + existing = data.get("colorGroups") or [] + kept = [ + g + for g in existing + if isinstance(g, dict) + and g.get("query") + and g["query"] not in desired_queries + and not _is_managed_graph_query(g["query"]) + ] + data["colorGroups"] = desired + kept + data["collapse-color-groups"] = False # 展开 Groups,便于看到配色图例 + data["showAttachments"] = True # 保留 Canvas/PDF;图片与 JSON 用 search 排除 + data["search"] = _merge_graph_search_excludes(str(data.get("search") or "")) + data["collapse-filter"] = False # 展开过滤器,便于看到已排除项 + graph_path.write_text( + json.dumps(data, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + return f"graph_colors:{graph_path}" + + +def ensure_colored_tags_seed(vault: Path) -> str | None: + """若已装 Colored Tags,写入更鲜明的调色板与已知专利标签序号(不覆盖用户 tagColors)。""" + data_path = vault / ".obsidian" / "plugins" / "colored-tags" / "data.json" + if not data_path.is_file(): + return None + try: + data = json.loads(data_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return None + changed = False + palette = data.setdefault("palette", {}) + # bright 比 adaptive-soft 更适合「一眼能分清」 + if palette.get("selected") in (None, "adaptive-soft", ""): + palette["selected"] = "bright" + changed = True + known = data.setdefault("knownTags", {}) + seeds = { + "patents": 1, + "glossary": 3, + "patent": 2, + "patents/index": 5, + "glossary/index": 3, + "patent/evidence": 4, + "patent/evidence/full": 4, + "patent/evidence/abstract": 6, + "patent/speculative": 2, + } + for tag, idx in seeds.items(): + if tag not in known: + known[tag] = idx + changed = True + if not changed: + return None + data_path.write_text( + json.dumps(data, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + return f"colored_tags:{data_path}" + + +def bootstrap_vault(vault: Path, papers_dir: str = "Research/Patents") -> list[str]: + """将 assets/obsidian 引导文件写入库(幂等)。""" + actions: list[str] = [] + papers = vault / papers_dir + papers.mkdir(parents=True, exist_ok=True) + + obsidian_dir = vault / ".obsidian" + obsidian_dir.mkdir(parents=True, exist_ok=True) + + snippets_dir = obsidian_dir / "snippets" + snippets_dir.mkdir(parents=True, exist_ok=True) + css_src = ASSETS_OBSIDIAN / "patent-reader.css" + css_dst = snippets_dir / "patent-reader.css" + if css_src.is_file(): + shutil.copy2(css_src, css_dst) + actions.append(f"snippet:{css_dst}") + + base_src = ASSETS_OBSIDIAN / "patents.base" + base_dst = papers / "patents.base" + if base_src.is_file(): + need_copy = not base_dst.is_file() or base_src.stat().st_mtime > base_dst.stat().st_mtime + if need_copy: + body = base_src.read_text(encoding="utf-8") + body = body.replace("{{PAPERS_DIR}}", papers_dir) + body = body.replace("Research/Patents", papers_dir) + base_dst.write_text(body, encoding="utf-8") + actions.append(f"base:{base_dst}") + + gloss_base_src = ASSETS_OBSIDIAN / "glossary.base" + cfg = runtime_config() + glossary_rel = cfg.get("glossary_dir") or "Research/术语" + if gloss_base_src.is_file(): + gloss_root = vault / glossary_rel + gloss_root.mkdir(parents=True, exist_ok=True) + gloss_base_dst = gloss_root / "glossary.base" + need_copy = ( + not gloss_base_dst.is_file() + or gloss_base_src.stat().st_mtime > gloss_base_dst.stat().st_mtime + ) + if need_copy: + body = gloss_base_src.read_text(encoding="utf-8") + body = body.replace("{{GLOSSARY_DIR}}", glossary_rel) + gloss_base_dst.write_text(body, encoding="utf-8") + actions.append(f"glossary_base:{gloss_base_dst}") + + index_tpl = ASSETS_OBSIDIAN / "_专利解读索引.template.md" + index_dst = papers / "_专利解读索引.md" + if index_tpl.is_file() and not index_dst.is_file(): + body = index_tpl.read_text(encoding="utf-8") + body = body.replace("{{PAPERS_DIR}}", papers_dir) + body = body.replace("Research/Patents", papers_dir) + body = body.replace("{{GLOSSARY_DIR}}", glossary_rel) + index_dst.write_text(body, encoding="utf-8") + actions.append(f"index:{index_dst}") + elif index_dst.is_file(): + # 已有索引:修补术语网 + 升级证据列中文 + 关系图配色说明 + if _repair_index_glossary_dataview(index_dst, glossary_rel): + actions.append(f"index_glossary_dv:{index_dst}") + if _upgrade_index_evidence_dataview(index_dst, papers_dir): + actions.append(f"index_evidence_zh:{index_dst}") + try: + ibody = index_dst.read_text(encoding="utf-8") + if "自动上色" not in ibody and "## 关联图谱" in ibody: + tip = ( + "- 打开左侧 **关系图**:节点已按类型自动上色" + "(靛=解读,青绿=Canvas,橙=术语,琥珀=含推测)。" + "若仍为灰色,请重载库(Ctrl/Cmd+R)。\n" + ) + ibody2 = ibody.replace("## 关联图谱\n", f"## 关联图谱\n\n{tip}", 1) + if ibody2 != ibody: + index_dst.write_text(ibody2, encoding="utf-8") + actions.append(f"index_graph_tip:{index_dst}") + except OSError: + pass + + glossary_root = vault / glossary_rel + glossary_root.mkdir(parents=True, exist_ok=True) + gloss_index = glossary_root / "_术语索引.md" + if not gloss_index.is_file(): + gloss_index.write_text( + "---\n" + "tags:\n" + " - glossary/index\n" + "---\n\n" + "# 术语索引\n\n" + "本目录存放专利解读产生的术语概念页;Canvas 与笔记第五节可 wikilink 至此。\n\n" + f"上级:[[{papers_dir}/_专利解读索引|专利解读索引]]\n\n" + "## 术语仪表盘(Bases)\n\n" + f"![[{glossary_rel}/glossary.base#全部术语]]\n\n" + "## 术语列表\n\n", + encoding="utf-8", + ) + actions.append(f"glossary:{gloss_index}") + + # 空库也创建 appearance.json 并启用 CSS snippet + appearance = obsidian_dir / "appearance.json" + try: + if appearance.is_file(): + data = json.loads(appearance.read_text(encoding="utf-8")) + else: + data = {} + actions.append("created:appearance.json") + enabled = data.get("enabledCssSnippets") or [] + if "patent-reader" not in enabled: + enabled.append("patent-reader") + data["enabledCssSnippets"] = enabled + appearance.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8") + actions.append("enabled_snippet:patent-reader") + except (json.JSONDecodeError, OSError): + pass + + # 核心插件 Bases:写入 core-plugins.json(社区插件无法由脚本代装) + core_plugins = obsidian_dir / "core-plugins.json" + try: + if core_plugins.is_file(): + cp = json.loads(core_plugins.read_text(encoding="utf-8")) + else: + cp = {} + actions.append("created:core-plugins.json") + if isinstance(cp, dict) and cp.get("bases") is not True: + cp["bases"] = True + core_plugins.write_text( + json.dumps(cp, indent=2, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + actions.append("enabled_core:bases") + except (json.JSONDecodeError, OSError, TypeError): + pass + + # 全局关系图自动上色(原生 Groups,无需插件) + g_act = ensure_graph_color_groups(vault, papers_dir, glossary_rel) + if g_act: + actions.append(g_act) + ct_act = ensure_colored_tags_seed(vault) + if ct_act: + actions.append(ct_act) + + # 清理空壳「解读 1.md」与术语反链反斜杠重复 + purged = purge_spurious_patent_notes(vault, papers_dir) + for rel in purged: + actions.append(f"purged_spurious:{rel}") + repaired = repair_glossary_backlinks(vault, glossary_rel) + if repaired: + actions.append(f"glossary_backlinks_repaired:{repaired}") + + return actions + + +def try_obsidian_cli_property(file_rel: str, name: str, value: str, vault: Path) -> bool: + """若 PATH 中有 obsidian CLI,设置属性。""" + try: + r = subprocess.run( + [ + "obsidian", + "property:set", + f'file={file_rel}', + f"name={name}", + f"value={value}", + ], + capture_output=True, + text=True, + timeout=15, + cwd=str(vault), + ) + return r.returncode == 0 + except (FileNotFoundError, subprocess.TimeoutExpired): + return False + + +def ensure_canvas_nav(content: str, canvas_rel: str, label: str = "专利族图谱") -> str: + """写入指向 *.canvas 的导航;并清除无扩展名占位链接(点开会生成空 .md)。""" + rel = canvas_rel.replace("\\", "/") + if not rel.endswith(".canvas"): + rel = f"{rel}.canvas" + link = f"[[{rel}|{label}]]" + # 去掉模板占位:[[..._图谱|专利族图谱]](入库后生成)等无 .canvas 链接 + content = re.sub( + r"^[ \t]*-?\s*\[\[[^\]]*_图谱(?:\|[^\]]*)?\]\][^\n]*\n?", + "", + content, + flags=re.M, + ) + if link in content: + return content + m = re.search(r"^##\s*Obsidian\s*导航\s*\n", content, re.M | re.I) + if m: + insert_at = m.end() + return content[:insert_at] + f"- {link}\n" + content[insert_at:] + return content diff --git a/.agents/skills/patent-disclosure-skill/tools/patent_reader/patent_link.py b/.agents/skills/patent-disclosure-skill/tools/patent_reader/patent_link.py new file mode 100644 index 0000000..6eb8c8a --- /dev/null +++ b/.agents/skills/patent-disclosure-skill/tools/patent_reader/patent_link.py @@ -0,0 +1,838 @@ +"""库内专利解读笔记关联:规则打分、写边、双向回写、全局 Canvas。""" +from __future__ import annotations + +import json +import re +from pathlib import Path + +try: + from common import slugify_pub + from obsidian import ( + build_canvas, + ensure_canvas_nav, + parse_frontmatter, + render_frontmatter, + ) +except ImportError: + from tools.patent_reader.common import slugify_pub + from tools.patent_reader.obsidian import ( + build_canvas, + ensure_canvas_nav, + parse_frontmatter, + render_frontmatter, + ) + +PUB_RE = re.compile(r"\b([A-Z]{2}\d{6,}[A-Z]?\d?)\b", re.I) +SECTION5_RE = re.compile( + r"^##\s*五、专利内术语表([\s\S]*?)(?=^##\s*六、|\Z)", + re.M, +) +RELATED_SECTION_RE = re.compile( + r"^##\s*相关专利(?:(自动关联))?\s*\n[\s\S]*?(?=^##\s+|\Z)", + re.M, +) + +RELATION_LABELS = { + "explicit_cite": "正文互引", + "same_assignee": "同申请人", + "ipc_overlap": "IPC 相近", + "shared_domain": "同领域", + "shared_terms": "共术语", + "model_hint": "模型判定", + "improvement": "疑似改进", + "family": "同族/系列", +} + + +def _ipc_prefix(ipc: str, n: int = 4) -> str: + s = re.sub(r"\s+", "", (ipc or "").upper()) + return s[:n] if s else "" + + +def extract_glossary_terms_from_note(body: str) -> list[str]: + m = SECTION5_RE.search(body) + if not m: + return [] + block = m.group(1) + terms: list[str] = [] + for line in block.splitlines(): + if not line.strip().startswith("|"): + continue + cells = [c.strip() for c in line.strip().strip("|").split("|")] + if not cells or cells[0] in ("术语", "---", "----"): + continue + if re.match(r"^[-:]+$", cells[0]): + continue + term = re.sub(r"\[\[([^\]|]+)(?:\|[^\]]+)?\]\]", r"\1", cells[0]) + term = Path(term).name.strip() + if term and term not in terms and len(term) <= 40: + terms.append(term) + return terms[:30] + + +def load_patent_notes(vault: Path, papers_dir: str) -> list[dict]: + """扫描库内 *_解读_*.md 笔记。""" + root = vault / papers_dir + notes: list[dict] = [] + if not root.is_dir(): + return notes + try: + from obsidian import is_spurious_patent_note + except ImportError: + from tools.patent_reader.obsidian import is_spurious_patent_note + + for md in sorted(root.rglob("*.md")): + if md.name.startswith("_") or "_解读_" not in md.name: + continue + if is_spurious_patent_note(md): + continue + text = md.read_text(encoding="utf-8", errors="replace") + fm, _, body = parse_frontmatter(text) + pub = str(fm.get("pub_number") or "").strip() + if not pub: + m = PUB_RE.search(md.stem) + pub = m.group(1).upper() if m else slugify_pub(md.stem) + assignees = fm.get("assignees") or [] + if isinstance(assignees, str): + assignees = [assignees] if assignees else [] + rel = str(md.relative_to(vault)).replace("\\", "/") + title_m = re.search(r"^#\s+(.+)$", body, re.M) + title = title_m.group(1).strip() if title_m else pub + notes.append( + { + "path": str(md), + "rel": rel, + "rel_no_ext": rel[:-3] if rel.endswith(".md") else rel, + "pub": pub.upper(), + "domain": str(fm.get("domain") or ""), + "ipc": str(fm.get("ipc") or ""), + "assignees": [str(a).strip() for a in assignees if str(a).strip()], + "terms": extract_glossary_terms_from_note(body), + "body": body, + "fm": fm, + "title": title, + } + ) + return notes + + +def score_pair(a: dict, b: dict) -> dict | None: + """规则打分;低于阈值返回 None。""" + if a["pub"] == b["pub"]: + return None + reasons: list[str] = [] + relations: list[str] = [] + score = 0.0 + + # 正文显式出现对方公开号 + if b["pub"] and re.search(re.escape(b["pub"]), a.get("body") or "", re.I): + score += 0.5 + relations.append("explicit_cite") + reasons.append(f"A 正文提及 {b['pub']}") + if a["pub"] and re.search(re.escape(a["pub"]), b.get("body") or "", re.I): + score += 0.5 + relations.append("explicit_cite") + reasons.append(f"B 正文提及 {a['pub']}") + + set_a = {x for x in a.get("assignees") or [] if x} + set_b = {x for x in b.get("assignees") or [] if x} + if set_a and set_b and set_a & set_b: + score += 0.4 + relations.append("same_assignee") + reasons.append("申请人重叠:" + "、".join(sorted(set_a & set_b))) + + pa, pb = _ipc_prefix(a.get("ipc") or ""), _ipc_prefix(b.get("ipc") or "") + if pa and pb and (pa == pb or pa.startswith(pb[:3]) or pb.startswith(pa[:3])): + score += 0.25 + relations.append("ipc_overlap") + reasons.append(f"IPC 前缀相近:{pa} / {pb}") + + if a.get("domain") and a.get("domain") == b.get("domain") and a["domain"] != "未分类": + score += 0.15 + relations.append("shared_domain") + reasons.append(f"同领域:{a['domain']}") + + terms_a = set(a.get("terms") or []) + terms_b = set(b.get("terms") or []) + shared = sorted(terms_a & terms_b) + if shared: + bump = min(0.3, 0.1 * len(shared)) + score += bump + relations.append("shared_terms") + reasons.append("共术语:" + "、".join(shared[:6])) + + # 去重 relation,主关系取权重最高的一种标签 + relations = list(dict.fromkeys(relations)) + if score < 0.35 or not relations: + return None + primary = relations[0] + for preferred in ( + "explicit_cite", + "same_assignee", + "ipc_overlap", + "shared_terms", + "shared_domain", + ): + if preferred in relations: + primary = preferred + break + return { + "pub_a": a["pub"], + "pub_b": b["pub"], + "rel_a": a["rel"], + "rel_b": b["rel"], + "score": round(min(1.0, score), 3), + "relation": primary, + "relations": relations, + "reasons": reasons, + "source": "rules", + } + + +def merge_model_scores( + edges: list[dict], + model_scores: list[dict], + notes_by_pub: dict[str, dict], +) -> list[dict]: + """合并 Agent/模型给出的边(可抬高分数或新增)。""" + index: dict[tuple[str, str], dict] = {} + for e in edges: + key = tuple(sorted([e["pub_a"].upper(), e["pub_b"].upper()])) + index[key] = e + + for m in model_scores: + pa = str(m.get("pub_a") or m.get("from") or "").upper().strip() + pb = str(m.get("pub_b") or m.get("to") or "").upper().strip() + if not pa or not pb or pa == pb: + continue + if pa not in notes_by_pub or pb not in notes_by_pub: + continue + key = tuple(sorted([pa, pb])) + score = float(m.get("score") or 0.7) + relation = str(m.get("relation") or "model_hint") + rationale = str(m.get("rationale") or m.get("reason") or "模型提示") + if key in index: + cur = index[key] + cur["score"] = round(min(1.0, max(cur["score"], score)), 3) + if relation not in cur.get("relations", []): + cur.setdefault("relations", []).append(relation) + if score >= cur["score"]: + cur["relation"] = relation + cur["reasons"] = list(dict.fromkeys(cur.get("reasons", []) + [rationale])) + cur["source"] = "rules+model" + else: + na, nb = notes_by_pub[pa], notes_by_pub[pb] + index[key] = { + "pub_a": pa, + "pub_b": pb, + "rel_a": na["rel"], + "rel_b": nb["rel"], + "score": round(min(1.0, score), 3), + "relation": relation, + "relations": [relation], + "reasons": [rationale], + "source": "model", + } + return list(index.values()) + + +def discover_links( + notes: list[dict], + *, + min_score: float = 0.45, + model_scores: list[dict] | None = None, + focus_pub: str = "", +) -> list[dict]: + """两两打分并过滤。""" + edges: list[dict] = [] + for i, a in enumerate(notes): + for b in notes[i + 1 :]: + if focus_pub: + fp = focus_pub.upper() + if a["pub"] != fp and b["pub"] != fp: + continue + hit = score_pair(a, b) + if hit: + edges.append(hit) + by_pub = {n["pub"]: n for n in notes if n.get("pub")} + if model_scores: + edges = merge_model_scores(edges, model_scores, by_pub) + edges = [e for e in edges if e["score"] >= min_score] + edges.sort(key=lambda e: -e["score"]) + return edges + + +def _edges_for_pub(edges: list[dict], pub: str) -> list[dict]: + pub = pub.upper() + out: list[dict] = [] + for e in edges: + if e["pub_a"] == pub: + out.append({**e, "other_pub": e["pub_b"], "other_rel": e["rel_b"]}) + elif e["pub_b"] == pub: + out.append({**e, "other_pub": e["pub_a"], "other_rel": e["rel_a"]}) + return out + + +def _related_section_markdown(note: dict, neighbors: list[dict], vault: Path) -> str: + lines = [ + "## 相关专利", + "", + "> 库内规则关联候选,**不构成法律意见**。", + "", + "| 公开号 | 关系 | 置信 | 依据 |", + "| --- | --- | --- | --- |", + ] + for n in neighbors: + other = n["other_pub"] + other_rel = n["other_rel"] + link_target = other_rel[:-3] if other_rel.endswith(".md") else other_rel + label = RELATION_LABELS.get(n["relation"], n["relation"]) + reason = ";".join(n.get("reasons") or [])[:80] + lines.append( + f"| [[{link_target}|{other}]] | {label} | {n['score']:.2f} | {reason} |" + ) + lines.append("") + return "\n".join(lines) + + +def upsert_related_section(content: str, section_md: str) -> str: + if RELATED_SECTION_RE.search(content): + return RELATED_SECTION_RE.sub(section_md.rstrip() + "\n\n", content, count=1) + # 插在免责声明之前,否则附录之后 + m = re.search(r"^##\s*十一、免责声明", content, re.M) + if m: + return content[: m.start()] + section_md + "\n" + content[m.start() :] + m2 = re.search(r"^##\s*十、附录", content, re.M) + if m2: + # 放在附录之后、免责之前;若无免责则附录后 + end = content.find("\n## ", m2.end()) + if end == -1: + return content.rstrip() + "\n\n" + section_md + return content[:end] + "\n\n" + section_md + content[end:] + return content.rstrip() + "\n\n" + section_md + + +def apply_links_to_note( + note: dict, + neighbors: list[dict], + *, + vault: Path, +) -> dict: + """回写 frontmatter.related_pubs + 相关专利节。""" + path = Path(note["path"]) + content = path.read_text(encoding="utf-8") + fm, _, body = parse_frontmatter(content) + pubs = [n["other_pub"] for n in neighbors] + fm["related_pubs"] = list(dict.fromkeys(pubs)) + # 重建全文:fm + 可能已含相关节的 body + full = render_frontmatter(fm) + body + section = _related_section_markdown(note, neighbors, vault) + full = upsert_related_section(full, section) + path.write_text(full, encoding="utf-8") + return {"path": note["rel"], "related_pubs": pubs, "count": len(pubs)} + + +def rebuild_note_canvas( + vault: Path, + note: dict, + neighbors: list[dict], + *, + papers_dir: str, + glossary_dir: str, +) -> str: + """按关联结果刷新单篇图谱 Canvas(保留叙事/术语含义)。""" + try: + from obsidian import harvest_claim_summaries_from_note + from write_patent_obsidian_note import ( + harvest_glossary_from_note, + harvest_narrative_from_note, + ) + except ImportError: + from tools.patent_reader.obsidian import harvest_claim_summaries_from_note + from tools.patent_reader.write_patent_obsidian_note import ( + harvest_glossary_from_note, + harvest_narrative_from_note, + ) + + note_path = Path(note["path"]) + content = note_path.read_text(encoding="utf-8") + fm, _, _ = parse_frontmatter(content) + narrative = harvest_narrative_from_note(content) + glossary = harvest_glossary_from_note(content) + if not glossary: + glossary = [{"term": t} for t in (note.get("terms") or [])[:6]] + claim_summaries = harvest_claim_summaries_from_note(content) + claim_tree = None + ct = note_path.parent / "claim_tree.json" + if ct.is_file(): + try: + claim_tree = json.loads(ct.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + claim_tree = None + + try: + from clue_vault import clue_cards_for_canvas, load_clues_sidecar + except ImportError: + from tools.patent_reader.clue_vault import ( + clue_cards_for_canvas, + load_clues_sidecar, + ) + + note_dir_rel = str(note_path.parent.relative_to(vault)).replace("\\", "/") + clue_cards = clue_cards_for_canvas( + load_clues_sidecar(note_path.parent), note_dir_rel=note_dir_rel + ) + + related = { + "related_patents": [ + { + "path": n["other_rel"], + "title": n["other_pub"], + "label": RELATION_LABELS.get(n["relation"], n["relation"]), + } + for n in neighbors + ], + "disclosures": [], + } + canvas = build_canvas( + vault=vault, + papers_dir=papers_dir, + note_rel_path=note["rel"], + pub=note["pub"], + title=note.get("title") or note["pub"], + related=related, + glossary_terms=glossary, + glossary_dir=glossary_dir, + create_glossary_stubs=False, + meta={ + "domain": fm.get("domain") or note.get("domain") or "", + "ipc": fm.get("ipc") or "", + "assignees": fm.get("assignees") or note.get("assignees") or [], + "evidence_scope": fm.get("evidence_scope") or "", + }, + claim_tree=claim_tree, + claim_summaries=claim_summaries, + narrative=narrative, + clue_cards=clue_cards, + ) + canvas.pop("glossary_resolved", None) + pub_slug = slugify_pub(note["pub"]) + canvas_path = note_path.parent / f"{pub_slug}_图谱.canvas" + canvas_path.write_text( + json.dumps(canvas, ensure_ascii=False, indent=2), encoding="utf-8" + ) + canvas_rel = str(canvas_path.relative_to(vault)).replace("\\", "/") + updated = ensure_canvas_nav(content, canvas_rel) + if updated != content: + note_path.write_text(updated, encoding="utf-8") + return str(canvas_path) + + +_GLOBAL_COLORS = { + "hub": "#0D9488", + "legend": "#64748B", + "patent": "#0284C7", + "bridge": "#CA8A04", + "group": "#6366F1", + "edge": "#94A3B8", +} + + +def _clip(text: str, limit: int) -> str: + s = re.sub(r"\s+", " ", (text or "").strip()) + if len(s) <= limit: + return s + return s[: limit - 1] + "…" + + +def _note_display_title(note: dict) -> str: + title = str(note.get("title") or note.get("pub") or "") + for prefix in ("专利解读:", "专利解读:", "解读:"): + if title.startswith(prefix): + title = title[len(prefix) :].strip() + if "_解读_" in title: + title = title.split("_解读_")[0] + return title or note.get("pub") or "" + + +def _harvest_one_liner(body: str) -> str: + """从笔记正文抽一句摘要(一节 / 叙事)。""" + try: + from write_patent_obsidian_note import harvest_narrative_from_note + except ImportError: + from tools.patent_reader.write_patent_obsidian_note import ( + harvest_narrative_from_note, + ) + + narr = harvest_narrative_from_note(body or "") + for key in ("one_liner", "approach", "problem", "effect"): + t = str(narr.get(key) or "").strip() + if t: + return _clip(t, 72) + m = re.search( + r"^##\s*一、[^\n]*\n+([\s\S]*?)(?=^##\s|\Z)", + body or "", + re.M, + ) + if m: + para = re.sub(r"^>\s*.*$", "", m.group(1), flags=re.M) + para = re.sub(r"\s+", " ", para).strip() + if para: + return _clip(para, 72) + return "" + + +def _patent_card_text(note: dict, *, papers_dir: str) -> str: + pub = note["pub"] + title = _note_display_title(note) + domain = note.get("domain") or "—" + ipc = note.get("ipc") or "—" + asg = "、".join((note.get("assignees") or [])[:2]) or "—" + terms = "、".join((note.get("terms") or [])[:5]) or "—" + one = _harvest_one_liner(note.get("body") or "") + note_link = note.get("rel_no_ext") or ( + note["rel"][:-3] if note["rel"].endswith(".md") else note["rel"] + ) + canvas_rel = str(Path(note["rel"]).parent / f"{slugify_pub(pub)}_图谱.canvas").replace( + "\\", "/" + ) + lines = [ + f"## `{pub}`", + "", + f"**{title}**" if title and title != pub else "", + "", + f"- **领域** {domain}", + f"- **IPC** `{ipc}`", + f"- **申请人** {asg}", + f"- **术语** {terms}", + ] + if one: + lines.extend(["", f"> {one}"]) + lines.extend( + [ + "", + f"[[{note_link}|打开解读]] · [[{canvas_rel}|单篇图谱]]", + f"[[{papers_dir}/_专利解读索引|索引]]", + ] + ) + return "\n".join(x for x in lines if x is not None) + + +def _bridge_card_text(edge: dict) -> str: + label = RELATION_LABELS.get(edge["relation"], edge["relation"]) + rels = [ + RELATION_LABELS.get(r, r) + for r in (edge.get("relations") or [edge["relation"]]) + ] + rels = list(dict.fromkeys(rels)) + reasons = edge.get("reasons") or [] + lines = [ + f"## {label} · {edge['score']:.2f}", + "", + f"`{edge['pub_a']}` ↔ `{edge['pub_b']}`", + "", + "**信号** " + " · ".join(rels), + ] + if reasons: + lines.append("") + lines.append("**依据**") + for r in reasons[:4]: + lines.append(f"- {_clip(r, 56)}") + src = edge.get("source") or "rules" + lines.extend(["", f"*来源:{src}*"]) + return "\n".join(lines) + + +def _layout_patent_positions(notes: list[dict]) -> dict[str, tuple[int, int]]: + """按领域分列;单领域且篇数≤4 时横向排布,便于看桥卡。""" + by_domain: dict[str, list[dict]] = {} + for note in notes: + by_domain.setdefault(note.get("domain") or "未分类", []).append(note) + domains = sorted(by_domain.keys()) + col_w, row_h = 520, 360 + pos: dict[str, tuple[int, int]] = {} + + # 单领域少量:左右排开 + if len(domains) == 1 and len(notes) <= 4: + gap = 720 + start = -((len(notes) - 1) * gap) // 2 + for j, note in enumerate(notes): + pos[note["pub"]] = (start + j * gap, 40) + return pos + + start_x = -((len(domains) - 1) * col_w) // 2 + for di, dom in enumerate(domains): + items = by_domain[dom] + for j, note in enumerate(items): + pos[note["pub"]] = (start_x + di * col_w, j * row_h) + return pos + + +def build_global_links_canvas( + vault: Path, + notes: list[dict], + edges: list[dict], + *, + papers_dir: str, +) -> Path: + """库级全局关联 Canvas:富文本专利卡 + 关联桥卡 + 统计/图例。""" + nodes: list[dict] = [] + canvas_edges: list[dict] = [] + pos = _layout_patent_positions(notes) + id_by_pub: dict[str, str] = {} + + # 统计 + domains = sorted({n.get("domain") or "未分类" for n in notes}) + rel_counts: dict[str, int] = {} + for e in edges: + k = RELATION_LABELS.get(e["relation"], e["relation"]) + rel_counts[k] = rel_counts.get(k, 0) + 1 + hub_lines = [ + "# 专利关联总览", + "", + f"**{len(notes)}** 篇解读 · **{len(edges)}** 条关联", + f"**领域** {' · '.join(domains) if domains else '—'}", + "", + "边 = 规则/模型信号(同申请人 / IPC / 共术语 / 正文互引等)", + "**不构成法律意见**", + "", + f"[[{papers_dir}/_专利解读索引|打开索引]]", + ] + nodes.append( + { + "id": "hub", + "type": "text", + "text": "\n".join(hub_lines), + "x": -260, + "y": -320, + "width": 520, + "height": 220, + "color": _GLOBAL_COLORS["hub"], + } + ) + + legend_bits = [ + f"- {lab} ×{cnt}" for lab, cnt in sorted(rel_counts.items(), key=lambda x: -x[1]) + ] or ["- (暂无过阈关联)"] + nodes.append( + { + "id": "legend", + "type": "text", + "text": "## 关系图例\n\n" + "\n".join(legend_bits) + "\n\n*桥卡写明依据*", + "x": 320, + "y": -300, + "width": 280, + "height": 200, + "color": _GLOBAL_COLORS["legend"], + } + ) + canvas_edges.append( + { + "id": "e-hub-legend", + "fromNode": "hub", + "fromSide": "right", + "toNode": "legend", + "toSide": "left", + "label": "图例", + "color": _GLOBAL_COLORS["edge"], + } + ) + + # 领域分组框 + by_domain: dict[str, list[dict]] = {} + for note in notes: + by_domain.setdefault(note.get("domain") or "未分类", []).append(note) + for di, (dom, items) in enumerate(sorted(by_domain.items())): + xs = [pos[n["pub"]][0] for n in items] + ys = [pos[n["pub"]][1] for n in items] + card_w, card_h = 400, 300 + pad = 36 + nodes.append( + { + "id": f"grp-{di}", + "type": "group", + "x": min(xs) - pad, + "y": min(ys) - pad - 8, + "width": (max(xs) - min(xs)) + card_w + pad * 2, + "height": (max(ys) - min(ys)) + card_h + pad * 2 + 16, + "label": f"领域 · {dom}({len(items)})", + "color": _GLOBAL_COLORS["group"], + } + ) + + for i, note in enumerate(notes): + nid = f"p{i}" + id_by_pub[note["pub"]] = nid + x, y = pos[note["pub"]] + nodes.append( + { + "id": nid, + "type": "text", + "text": _patent_card_text(note, papers_dir=papers_dir), + "x": x, + "y": y, + "width": 400, + "height": 300, + "color": _GLOBAL_COLORS["patent"], + } + ) + canvas_edges.append( + { + "id": f"e-hub-{nid}", + "fromNode": "hub", + "fromSide": "bottom", + "toNode": nid, + "toSide": "top", + "label": "收录", + "color": _GLOBAL_COLORS["hub"], + } + ) + + # 关联桥卡:落在两端中点 + for i, e in enumerate(edges): + fa, fb = id_by_pub.get(e["pub_a"]), id_by_pub.get(e["pub_b"]) + if not fa or not fb: + continue + xa, ya = pos[e["pub_a"]] + xb, yb = pos[e["pub_b"]] + bridge_id = f"br{i}" + # 横排:桥卡落在中缝略上;纵排:落在右侧 + if abs(ya - yb) < 80 and abs(xa - xb) > 200: + bx = (xa + xb) // 2 - 160 + by = min(ya, yb) - 280 + elif abs(xa - xb) < 80: + bx = max(xa, xb) + 440 + by = (ya + yb) // 2 + else: + bx = (xa + xb) // 2 - 160 + by = (ya + yb) // 2 + 60 + nodes.append( + { + "id": bridge_id, + "type": "text", + "text": _bridge_card_text(e), + "x": bx, + "y": by, + "width": 320, + "height": 240, + "color": _GLOBAL_COLORS["bridge"], + } + ) + label = f"{RELATION_LABELS.get(e['relation'], e['relation'])} {e['score']:.2f}" + canvas_edges.append( + { + "id": f"link{i}a", + "fromNode": fa, + "fromSide": "right", + "toNode": bridge_id, + "toSide": "left", + "label": label, + "color": _GLOBAL_COLORS["bridge"], + } + ) + canvas_edges.append( + { + "id": f"link{i}b", + "fromNode": bridge_id, + "fromSide": "right", + "toNode": fb, + "toSide": "left", + "label": "", + "color": _GLOBAL_COLORS["bridge"], + } + ) + + out = vault / papers_dir / "_专利关联.canvas" + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text( + json.dumps({"nodes": nodes, "edges": canvas_edges}, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + # 索引页补链 + index = vault / papers_dir / "_专利解读索引.md" + if index.is_file(): + body = index.read_text(encoding="utf-8") + link = f"[[{papers_dir}/_专利关联.canvas|专利关联总览]]" + # 去掉无 .canvas 的旧链接,避免点出空 md + body2 = re.sub( + r"^[ \t]*-?\s*\[\[[^\]]*_专利关联(?!\.canvas)(?:\|[^\]]*)?\]\][^\n]*\n?", + "", + body, + flags=re.M, + ) + if link not in body2: + if "## 关联图谱" in body2 and link not in body2: + body2 = body2.replace( + "## 关联图谱", + f"## 关联图谱\n\n- {link}", + 1, + ) + elif "## 笔记列表" in body2: + body2 = body2.replace( + "## 笔记列表", + f"## 关联图谱\n\n- {link}\n\n## 笔记列表", + 1, + ) + else: + body2 = body2.rstrip() + f"\n\n## 关联图谱\n\n- {link}\n" + if body2 != body: + index.write_text(body2, encoding="utf-8") + return out + + +def run_link_pipeline( + vault: Path, + *, + papers_dir: str = "Research/Patents", + glossary_dir: str = "Research/术语", + min_score: float = 0.45, + model_scores: list[dict] | None = None, + focus_pub: str = "", + refresh_canvas: bool = True, + refresh_global_canvas: bool = True, + dry_run: bool = False, +) -> dict: + notes = load_patent_notes(vault, papers_dir) + edges = discover_links( + notes, + min_score=min_score, + model_scores=model_scores, + focus_pub=focus_pub, + ) + updates: list[dict] = [] + canvases: list[str] = [] + if not dry_run: + for note in notes: + neighbors = _edges_for_pub(edges, note["pub"]) + if not neighbors: + continue + updates.append(apply_links_to_note(note, neighbors, vault=vault)) + if refresh_canvas: + canvases.append( + rebuild_note_canvas( + vault, + note, + neighbors, + papers_dir=papers_dir, + glossary_dir=glossary_dir, + ) + ) + global_path = "" + if refresh_global_canvas and edges: + global_path = str( + build_global_links_canvas(vault, notes, edges, papers_dir=papers_dir) + ) + else: + global_path = "" + + return { + "vault": str(vault), + "note_count": len(notes), + "edge_count": len(edges), + "edges": edges, + "updated_notes": updates, + "canvases": canvases, + "global_canvas": global_path if not dry_run else "", + "dry_run": dry_run, + "min_score": min_score, + } diff --git a/.agents/skills/patent-disclosure-skill/tools/patent_reader/requirements.txt b/.agents/skills/patent-disclosure-skill/tools/patent_reader/requirements.txt new file mode 100644 index 0000000..63ec592 --- /dev/null +++ b/.agents/skills/patent-disclosure-skill/tools/patent_reader/requirements.txt @@ -0,0 +1,2 @@ +# 专利解读可选依赖(tools/patent_reader/ 处理 PDF) +pymupdf>=1.24.0 diff --git a/.agents/skills/patent-disclosure-skill/tools/patent_reader/setup_obsidian_vault.py b/.agents/skills/patent-disclosure-skill/tools/patent_reader/setup_obsidian_vault.py new file mode 100644 index 0000000..86709e8 --- /dev/null +++ b/.agents/skills/patent-disclosure-skill/tools/patent_reader/setup_obsidian_vault.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +""" +一次性初始化 Obsidian 库:CSS 片段、patents.base、索引页。 + +用法: + python tools/patent_reader/setup_obsidian_vault.py + python tools/patent_reader/setup_obsidian_vault.py --vault D:/Obsidian/MyVault +""" +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +try: + from common import resolve_obsidian_vault, runtime_config + from obsidian import bootstrap_vault +except ImportError: + from tools.patent_reader.common import resolve_obsidian_vault, runtime_config + from tools.patent_reader.obsidian import bootstrap_vault + + +def main(argv: list[str] | None = None) -> int: + if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--vault", default="", help="库根路径") + ap.add_argument("--papers-dir", default="", help="默认 Research/Patents") + ap.add_argument("--output", default="", help="状态 JSON") + args = ap.parse_args(argv) + + cfg = runtime_config() + vault_s = args.vault.strip() or cfg["obsidian_vault"] + if not vault_s: + # 再试一次探测(runtime 已含探测;此处给出明确指引) + resolved = resolve_obsidian_vault() + if resolved.get("vault"): + vault_s = resolved["vault"] + else: + print( + "错误:未配置 Obsidian 库。可先运行:\n" + " python tools/patent_reader/check_obsidian_env.py\n" + "然后:\n" + ' python tools/patent_reader/check_obsidian_env.py --set "你的库路径"\n' + "或不入库,仅用 write 写入 outputs/patent_reader/。", + file=sys.stderr, + ) + return 1 + + vault = Path(vault_s).resolve() + papers = args.papers_dir.strip() or cfg["papers_dir"] + actions = bootstrap_vault(vault, papers) + + status = { + "vault": str(vault), + "papers_dir": papers, + "actions": actions, + "vault_source": cfg.get("vault_source", ""), + } + print(f"OK bootstrap actions={len(actions)}") + for a in actions: + print(f" {a}") + if args.output: + Path(args.output).write_text(json.dumps(status, ensure_ascii=False, indent=2), encoding="utf-8") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.agents/skills/patent-disclosure-skill/tools/patent_reader/validate_claim_tree.py b/.agents/skills/patent-disclosure-skill/tools/patent_reader/validate_claim_tree.py new file mode 100644 index 0000000..6523786 --- /dev/null +++ b/.agents/skills/patent-disclosure-skill/tools/patent_reader/validate_claim_tree.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +""" +校验并规范化 claim_tree.json(权项父子树)。 + +Agent 校对后应再跑本脚本:修复悬空父号/roots,报告多引用候选与未校对警告。 + +用法: + python tools/patent_reader/validate_claim_tree.py -i claim_tree.json + python tools/patent_reader/validate_claim_tree.py -i claim_tree.json --write + python tools/patent_reader/validate_claim_tree.py -i claim_tree.json --strict +""" +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +try: + from common import normalize_claim_tree, validate_claim_tree +except ImportError: + from tools.patent_reader.common import normalize_claim_tree, validate_claim_tree + + +def main(argv: list[str] | None = None) -> int: + if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("-i", "--input", required=True, type=Path) + ap.add_argument( + "-o", + "--output", + default=None, + type=Path, + help="校验报告 JSON(默认 stdout 摘要 + 旁路 .lint.json)", + ) + ap.add_argument( + "--write", + action="store_true", + help="把 normalize 后的树写回 -i(保留 review 等字段)", + ) + ap.add_argument( + "--strict", + action="store_true", + help="warnings 也导致非零退出(含 not_agent_reviewed)", + ) + ap.add_argument( + "--require-review", + action="store_true", + help="未标注 Agent/人工校对时视为失败", + ) + args = ap.parse_args(argv) + + if not args.input.is_file(): + print(f"FAIL missing {args.input}", file=sys.stderr) + return 2 + try: + raw = json.loads(args.input.read_text(encoding="utf-8")) + except json.JSONDecodeError as e: + print(f"FAIL invalid json: {e}", file=sys.stderr) + return 2 + + result = validate_claim_tree(raw) + tree = result.get("tree") or normalize_claim_tree(raw) + # 保留 Agent review 元数据 + if isinstance(raw, dict) and isinstance(raw.get("review"), dict): + tree["review"] = raw["review"] + + report = { + "passed": result["passed"], + "issues": result["issues"], + "warnings": result["warnings"], + "count": result["count"], + "agent_reviewed": isinstance(raw.get("review"), dict) + and str((raw.get("review") or {}).get("by") or "").lower() + in ("agent", "human"), + } + if args.require_review and not report["agent_reviewed"]: + report["passed"] = False + if "not_agent_reviewed" not in report["issues"]: + report["issues"] = list(report["issues"]) + ["not_agent_reviewed"] + + out_path = args.output or Path(str(args.input) + ".lint.json") + out_path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8") + + if args.write: + args.input.write_text( + json.dumps(tree, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + print(f"WROTE normalized tree → {args.input}") + + print( + f"{'OK' if report['passed'] else 'FAIL'} claim_tree " + f"count={report['count']} issues={len(report['issues'])} " + f"warnings={len(report['warnings'])} reviewed={report['agent_reviewed']}" + ) + for x in report["issues"]: + print(f" issue: {x}") + for x in report["warnings"][:12]: + print(f" warn: {x}") + + if not report["passed"]: + return 1 + if args.strict and report["warnings"]: + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.agents/skills/patent-disclosure-skill/tools/patent_reader/validate_public_clues.py b/.agents/skills/patent-disclosure-skill/tools/patent_reader/validate_public_clues.py new file mode 100644 index 0000000..bc9933e --- /dev/null +++ b/.agents/skills/patent-disclosure-skill/tools/patent_reader/validate_public_clues.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +""" +校验并筛选 Agent 生成的 public_clues.json(附录 B 线索)。 + +默认按置信度高→低排序后最多保留 3 条;可用 --max 调整,--no-filter 关闭筛选。 + +用法: + python tools/patent_reader/validate_public_clues.py -i public_clues.json [-o public_clues.lint.json] + python tools/patent_reader/validate_public_clues.py -i public_clues.json --write-filtered + python tools/patent_reader/validate_public_clues.py -i public_clues.json --strict +""" +from __future__ import annotations + +import argparse +import json +import re +import sys +from pathlib import Path +from urllib.parse import urlparse + +try: + from clue_vault import ( + DEFAULT_MAX_CLUES, + as_clues, + filter_clues, + normalize_clue, + ) +except ImportError: + from tools.patent_reader.clue_vault import ( + DEFAULT_MAX_CLUES, + as_clues, + filter_clues, + normalize_clue, + ) + +ALLOWED_CONF = {"高", "中", "低", "high", "medium", "low", "med", "mid"} + + +def validate_clues(clues: list[dict]) -> dict: + issues: list[str] = [] + warnings: list[str] = [] + + if not clues: + warnings.append("empty_clues_ok_if_none_found") + return {"passed": True, "issues": issues, "warnings": warnings, "count": 0} + + for i, c in enumerate(clues): + prefix = f"clue[{i}]" + n = normalize_clue(c, index=i) + title = n["title"] + url = n["url"] + conf = n["confidence"] + reason = n["reason"] + + if not title: + issues.append(f"{prefix}:missing_title") + if not url: + issues.append(f"{prefix}:missing_url") + else: + parsed = urlparse(url) + if parsed.scheme not in ("http", "https") or not parsed.netloc: + issues.append(f"{prefix}:invalid_url") + if re.search(r"example\.com|localhost|127\.0\.0\.1", url, re.I): + warnings.append(f"{prefix}:placeholder_url") + if not conf: + warnings.append(f"{prefix}:missing_confidence") + elif conf.lower() not in {x.lower() for x in ALLOWED_CONF} and conf not in ALLOWED_CONF: + warnings.append(f"{prefix}:unusual_confidence:{conf}") + if not reason: + warnings.append(f"{prefix}:missing_reason") + elif len(reason) < 8: + warnings.append(f"{prefix}:reason_too_short") + # Agent 主路径应写入 summary;缺则提醒(不阻断,可由脚本降级) + summary = (c.get("summary") or "").strip() + status = (c.get("status") or "").strip() + if not summary and status not in ("fetch_failed",): + warnings.append(f"{prefix}:missing_summary_agent_should_fetch") + + return { + "passed": len(issues) == 0, + "issues": issues, + "warnings": warnings, + "count": len(clues), + } + + +def main(argv: list[str] | None = None) -> int: + if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("-i", "--input", required=True, type=Path) + ap.add_argument("-o", "--output", default=None, type=Path) + ap.add_argument( + "--strict", + action="store_true", + help="warnings 也导致非零退出", + ) + ap.add_argument( + "--allow-empty", + action="store_true", + default=True, + help="允许空列表(表示未发现线索)", + ) + ap.add_argument( + "--max", + type=int, + default=DEFAULT_MAX_CLUES, + help=f"按置信度排序后最多保留条数(默认 {DEFAULT_MAX_CLUES})", + ) + ap.add_argument( + "--no-filter", + action="store_true", + help="不做条数筛选(仍校验)", + ) + ap.add_argument( + "--write-filtered", + action="store_true", + help="将筛选后的线索写回 -i(或 --filtered-out)", + ) + ap.add_argument( + "--filtered-out", + default=None, + type=Path, + help="筛选结果输出路径(默认与 --write-filtered 时覆盖 -i)", + ) + args = ap.parse_args(argv) + + raw = json.loads(args.input.read_text(encoding="utf-8")) + clues = as_clues(raw) + result = validate_clues(clues) + + kept = clues + dropped: list[dict] = [] + if not args.no_filter: + kept, dropped = filter_clues(clues, max_keep=max(0, args.max)) + if dropped: + result["warnings"].append( + f"filtered_to_{len(kept)}_dropped_{len(dropped)}" + ) + result["count_before_filter"] = len(clues) + result["count"] = len(kept) + result["kept_titles"] = [c.get("title") for c in kept] + result["dropped_titles"] = [c.get("title") for c in dropped] + + if args.write_filtered or args.filtered_out: + out_path = args.filtered_out or args.input + # 保持与输入同形:list 或 {clues:[]} + payload: list | dict + if isinstance(raw, dict) and not isinstance(raw, list): + payload = {**raw, "clues": kept} + else: + payload = kept + out_path.write_text( + json.dumps(payload, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + print(f"FILTERED: {out_path} keep={len(kept)} drop={len(dropped)}") + + if args.output: + args.output.write_text( + json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8" + ) + + for w in result["warnings"]: + print(f"WARN {w}", file=sys.stderr) + if not result["passed"]: + for i in result["issues"]: + print(f"FAIL {i}", file=sys.stderr) + return 1 + if args.strict and result["warnings"]: + print("FAIL strict_warnings", file=sys.stderr) + return 1 + + print(f"OK public_clues count={result['count']}") + if args.output: + print(f"CLUES_LINT: {args.output}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.agents/skills/patent-disclosure-skill/tools/patent_reader/write_patent_obsidian_note.py b/.agents/skills/patent-disclosure-skill/tools/patent_reader/write_patent_obsidian_note.py new file mode 100644 index 0000000..6140135 --- /dev/null +++ b/.agents/skills/patent-disclosure-skill/tools/patent_reader/write_patent_obsidian_note.py @@ -0,0 +1,1206 @@ +#!/usr/bin/env python3 +""" +将已通过 lint 的专利解读笔记写入 Obsidian 库或 outputs/patent_reader/, +并:初始化库资源、术语 stub、增强 frontmatter、生成 Canvas、附图闸门、更新索引。 + +用法: + python tools/patent_reader/write_patent_obsidian_note.py --content-file note.md \\ + --manifest source_manifest.json --lint-json lint.json \\ + [--context-anchor context_anchor.json] [--bundle synthesis_bundle.json] \\ + [--public-clues public_clues.json] [--claim-deltas claim_deltas.json] \\ + [--workdir tmp/patent_reader/RUN] [--strict-figures] [--include-review] +""" +from __future__ import annotations + +import argparse +import json +import re +import shutil +import sys +from datetime import datetime +from pathlib import Path + +try: + from common import ( + normalize_claim_tree, + optional_path, + resolve_domain, + runtime_config, + slugify_pub, + ) + from obsidian import ( + bootstrap_vault, + build_canvas, + claim_deltas_from_tree, + enrich_note_frontmatter, + ensure_canvas_nav, + ensure_domain_index, + harvest_claim_summaries_from_note, + load_claim_deltas, + merge_claim_summaries, + render_claim_tree_markdown, + scan_vault_related, + try_obsidian_cli_property, + upsert_claim_tree_section, + upsert_index_entry, + ) +except ImportError: # python -m 包内导入 + from tools.patent_reader.common import ( + normalize_claim_tree, + optional_path, + resolve_domain, + runtime_config, + slugify_pub, + ) + from tools.patent_reader.obsidian import ( + bootstrap_vault, + build_canvas, + claim_deltas_from_tree, + enrich_note_frontmatter, + ensure_canvas_nav, + ensure_domain_index, + harvest_claim_summaries_from_note, + load_claim_deltas, + merge_claim_summaries, + render_claim_tree_markdown, + scan_vault_related, + try_obsidian_cli_property, + upsert_claim_tree_section, + upsert_index_entry, + ) + +NAV_SECTION_RE = re.compile(r"^##\s*Obsidian\s*导航\s*$", re.M | re.I) +SECTION5_RE = re.compile( + r"(^##\s*五、专利内术语表[\s\S]*?)(?=^##\s*六、|\Z)", + re.M, +) +WIKILINK_RE = re.compile(r"\[\[([^\]|#]+)(?:\|([^\]]+))?\]\]") +GLOSSARY_LINK_RE = re.compile( + r"\[\[(?:Research/)?术语/([^\]|#]+)(?:\|([^\]]+))?\]\]" +) + +# 用户可见标题:去掉给 Agent 的说明性括号(幂等) +_USER_HEADING_CLEANUPS: list[tuple[re.Pattern[str], str]] = [ + (re.compile(r"^(##\s*二、连贯叙事)(故事线)\s*$", re.M), r"\1"), + (re.compile(r"^(###\s*结构图)(可选\s*mermaid)\s*$", re.M | re.I), r"\1"), + (re.compile(r"^(##\s*七、和现有技术的差别)(若能从原文读出)\s*$", re.M), r"\1"), + (re.compile(r"^(##\s*九、技术应用场景)(专利内依据)\s*$", re.M), r"\1"), + (re.compile(r"^(###\s*A\.\s*IPC\s*行业坐标)(离线词表)\s*$", re.M), r"\1"), + (re.compile(r"^(###\s*B\.\s*公开检索线索)(推测)\s*$", re.M), r"\1"), + (re.compile(r"^(##\s*相关专利)(自动关联)\s*$", re.M), r"\1"), + (re.compile(r"^(###\s*附图)(扫描件整页预览)\s*$", re.M), r"\1"), + (re.compile(r"^(###\s*附图嵌入)(自动)\s*$", re.M), "### 附图"), + ( + re.compile( + r"^(>\s*\[!grounding\]\s*应用场景)(专利内依据\s*[·•]\s*高置信)\s*$", + re.M, + ), + r"\1", + ), + ( + re.compile( + r"^(>\s*\[!warning\]-?\s*公开检索线索)(推测\s*[·•]\s*默认折叠)\s*$", + re.M, + ), + r"\1", + ), + (re.compile(r"(\[\[(?:[^\]]+_图谱\.canvas)\|[^\]]+\]\])(入库后生成)"), r"\1"), + (re.compile(r"(\[\[(?:[^\]]+_图谱)\|[^\]]+\]\])(入库后生成)"), r"\1"), + (re.compile(r"^\|\s*特征\s*\|\s*说明书位置\s*\|\s*附图(若有)\s*\|", re.M), "| 特征 | 说明书位置 | 附图 |"), + (re.compile(r"\*\*效果(专利自述)\*\*"), "**效果**"), +] + + +def sanitize_user_facing_titles(content: str) -> str: + """去掉章节/callout 标题上给 Agent 看的说明性括号,避免交付笔记读起来像说明书。""" + for pat, repl in _USER_HEADING_CLEANUPS: + content = pat.sub(repl, content) + return sanitize_internal_tool_leakage(content) + + +# 交付笔记/索引中不得出现的内部实现痕迹(脚本名、流水线字段、裁图文件名等) +_INTERNAL_LEAK_CLEANUPS: list[tuple[re.Pattern[str], str]] = [ + # 附录来源:模型常把写作提示里的字段路径抄进正文 + ( + re.compile( + r"`?context_anchor\.ipc_application`?\s*(离线词表)\s*" + r"[++]\s*(Google Patents[^\n]*)", + ), + r"离线 IPC 行业词表;\1", + ), + ( + re.compile(r"`?context_anchor\.ipc_application`?\s*(离线词表)"), + "离线 IPC 行业词表", + ), + ( + re.compile(r"`?context_anchor\.[a-zA-Z0-9_.]+`?"), + "离线行业词表", + ), + # 附图说明:只保留页码,不暴露 page_xxx_xref_yy.png + ( + re.compile( + r"(\*(?:第\s*\d+\s*页|预览\s*\d+))\s*[·•]\s*`[^`]*\.(?:png|jpe?g|webp|gif)`\*", + re.I, + ), + r"\1*", + ), + ( + re.compile( + r"(\*(?:第\s*\d+\s*页|预览\s*\d+))\s*[·•]\s*[`']?page_\d+_xref_\d+\.(?:png|jpe?g)[`']?\*", + re.I, + ), + r"\1*", + ), + # 索引/导航中的脚本名 → 用户可读说法 + ( + re.compile( + r"由\s*`write_patent_obsidian_note\.py`\s*/\s*`setup_obsidian_vault\.py`\s*维护。" + ), + "入库后自动维护。", + ), + ( + re.compile(r"由\s*`write_patent_obsidian_note\.py`\s*维护。?"), + "入库后自动维护。", + ), + ( + re.compile(r"(交付后运行\s*`link_patent_notes\.py`\s*生成)"), + "(交付后可生成专利关联)", + ), + ( + re.compile( + r"若仍为灰色,执行\s*`setup_obsidian_vault\.py`\s*后\s*" + r"\*\*Ctrl/Cmd\+R\*\*\s*重载库。" + ), + "若仍为灰色,请重载库(Ctrl/Cmd+R)。", + ), + ( + re.compile(r"(脚本追加 wikilink 条目。)"), + "(入库时自动追加条目。)", + ), + # 残留裸脚本名(兜底,不误伤扩展名说明以外的句子) + ( + re.compile( + r"`(?:write_patent_obsidian_note|setup_obsidian_vault|link_patent_notes|" + r"build_patent_canvas|build_context_anchor|extract_patent_text|" + r"extract_patent_figures|check_obsidian_env)\.py`" + ), + "入库工具", + ), +] + + +def sanitize_internal_tool_leakage(content: str) -> str: + """去掉交付正文中的脚本名、流水线字段名、内部文件名等实现痕迹。""" + for pat, repl in _INTERNAL_LEAK_CLEANUPS: + content = pat.sub(repl, content) + return content + + +def _strip_cell(cell: str) -> str: + s = cell.strip() + m = WIKILINK_RE.fullmatch(s) + if m: + return (m.group(2) or m.group(1).rsplit("/", 1)[-1]).strip() + # 单元格内嵌 wikilink(非整格)时取显示名 + m2 = WIKILINK_RE.search(s) + if m2 and s.startswith("[[" ): + return (m2.group(2) or m2.group(1).rsplit("/", 1)[-1]).strip() + return s + + +def _split_md_table_row(line: str) -> list[str]: + """按 | 拆表行,但忽略 wikilink [[...|...]] 内的竖线。""" + s = line.strip() + if s.startswith("|"): + s = s[1:] + if s.endswith("|"): + s = s[:-1] + cols: list[str] = [] + buf: list[str] = [] + i = 0 + in_link = False + while i < len(s): + if s.startswith("[[", i): + in_link = True + buf.append("[[") + i += 2 + continue + if in_link and s.startswith("]]", i): + in_link = False + buf.append("]]") + i += 2 + continue + ch = s[i] + if ch == "|" and not in_link: + cols.append("".join(buf).strip()) + buf = [] + i += 1 + continue + buf.append(ch) + i += 1 + cols.append("".join(buf).strip()) + return cols + + +def harvest_glossary_from_note(content: str) -> list[dict]: + """从第五节术语表与已有术语 wikilink 收获候选(补 extract 为空的情况)。""" + m = SECTION5_RE.search(content) + if not m: + return [] + sec = m.group(1) + by_term: dict[str, dict] = {} + + for line in sec.splitlines(): + line = line.strip() + if not line.startswith("|"): + continue + if re.match(r"^\|\s*[-:\s|]+$", line): + continue + cols = _split_md_table_row(line) + if len(cols) < 2: + continue + term = _strip_cell(cols[0]) + if not term or term == "术语" or term.startswith("_") or "[[" in term: + continue + defn = _strip_cell(cols[1]) if len(cols) > 1 else "" + by_term[term] = {"term": term, "definition": defn} + + for gm in GLOSSARY_LINK_RE.finditer(sec): + path_term = gm.group(1).strip().rstrip("\\") + display = (gm.group(2) or path_term).strip() + term = display or path_term + if not term or term.startswith("_") or term == "术语索引": + continue + by_term.setdefault(term, {"term": term, "definition": ""}) + + return list(by_term.values()) + + +def merge_glossary_candidates( + bundle_glossary: list, note_glossary: list[dict] +) -> list[dict]: + """合并 bundle 与笔记收获的术语;同名时保留非空 definition。""" + by_term: dict[str, dict] = {} + for item in list(bundle_glossary or []) + list(note_glossary or []): + if isinstance(item, dict): + term = str(item.get("term") or "").strip() + defn = str(item.get("definition") or "").strip() + else: + term = str(item).strip() + defn = "" + if not term: + continue + prev = by_term.get(term) + if not prev: + by_term[term] = {"term": term, "definition": defn} + elif defn and not prev.get("definition"): + prev["definition"] = defn + return list(by_term.values()) + + +def _note_link_name(dest: Path, vault: Path) -> str: + rel = dest.relative_to(vault) + return str(rel.with_suffix("")).replace("\\", "/") + + +def resolve_source_pdf( + manifest: dict, workdir: Path | None +) -> Path | None: + """定位官方 PDF:manifest.source_path → workdir/source/*.{pdf,PDF}。""" + candidates: list[Path] = [] + sp = str(manifest.get("source_path") or "").strip() + if sp: + candidates.append(Path(sp)) + if workdir is not None: + src_dir = workdir / "source" + if src_dir.is_dir(): + candidates.extend(sorted(src_dir.glob("*.pdf"))) + candidates.extend(sorted(src_dir.glob("*.PDF"))) + candidates.extend(sorted(workdir.glob("*.pdf"))) + seen: set[str] = set() + for p in candidates: + try: + rp = p.resolve() + except OSError: + continue + key = str(rp).lower() + if key in seen: + continue + seen.add(key) + if rp.is_file() and rp.suffix.lower() == ".pdf": + return rp + return None + + +def copy_source_pdf_to_note_dir( + pdf: Path, note_dir: Path, pub: str +) -> Path: + """复制到 note_dir/source/{pub}.pdf(幂等覆盖)。""" + dest_dir = note_dir / "source" + dest_dir.mkdir(parents=True, exist_ok=True) + dest = dest_dir / f"{slugify_pub(pub)}.pdf" + shutil.copy2(pdf, dest) + return dest + + +def ensure_source_pdf_nav(content: str, *, pub: str) -> str: + """导航中补「官方 PDF」wikilink(同目录 source/)。""" + link = f"[[source/{slugify_pub(pub)}.pdf|官方 PDF]]" + if link in content or f"source/{slugify_pub(pub)}.pdf" in content: + return content + m = re.search( + r"(##\s*Obsidian 导航\s*\n)([\s\S]*?)(?=\n##\s|\n> \[!|\Z)", content + ) + if not m: + return content + block = m.group(2) + lines = block.rstrip().splitlines() + insert_at = 0 + for i, line in enumerate(lines): + if any( + k in line + for k in ("权项锚点", "说明书段落", "图谱", "canvas") + ): + insert_at = i + 1 + if insert_at == 0: + insert_at = len(lines) + lines.insert(insert_at, f"- {link}") + new_block = "\n".join(lines) + "\n" + return content[: m.start(2)] + new_block + content[m.end(2) :] + + +def _ensure_nav_section(content: str, nav_lines: list[str]) -> str: + """合并/更新 Obsidian 导航节(已有模板导航时也会补全真实链接)。""" + if not nav_lines: + return content + existing = NAV_SECTION_RE.search(content) + if existing: + start = existing.start() + rest = content[existing.end() :] + end_m = re.search(r"^##\s+", rest, re.M) + end = existing.end() + end_m.start() if end_m else len(content) + old_section = content[start:end] + lines_out = ["## Obsidian 导航", ""] + seen: set[str] = set() + for ln in nav_lines: + item = ln.strip() + if not item or item in seen: + continue + lines_out.append(f"- {item}") + seen.add(item) + for m in re.finditer(r"^-\s+(.+)$", old_section, re.M): + item = m.group(1).strip() + if item and item not in seen: + lines_out.append(f"- {item}") + seen.add(item) + block = "\n".join(lines_out) + "\n\n" + return content[:start] + block + content[end:] + + block = "## Obsidian 导航\n\n" + "\n".join(f"- {ln}" for ln in nav_lines) + "\n\n" + m = re.search(r"^##\s*一、", content, re.M) + if m: + return content[: m.start()] + block + content[m.start() :] + m2 = re.search(r"^#\s+", content, re.M) + if m2: + end = content.find("\n", m2.end()) + insert = end + 1 if end != -1 else m2.end() + return content[:insert] + "\n" + block + content[insert:] + return block + content + + +def _load_figures_manifest(workdir: Path) -> dict: + manifest = workdir / "figures" / "manifest.json" + if not manifest.is_file(): + return {"figures": []} + return json.loads(manifest.read_text(encoding="utf-8")) + + +def _figure_allowed(fig: dict, include_review: bool) -> bool: + decision = fig.get("decision") + if decision == "insert": + return True + if include_review: + status = (fig.get("quality_signals") or {}).get("status") + if decision == "placeholder" and status == "review": + return True + if decision == "review": + return True + return False + + +def _copy_figures_gated( + workdir: Path, + images_dir: Path, + *, + include_review: bool = False, +) -> tuple[list[dict], list[str]]: + """复制 insert(及可选 review)附图;返回 (figs, copied_rel_paths)。""" + data = _load_figures_manifest(workdir) + images_dir.mkdir(parents=True, exist_ok=True) + insert_figs: list[dict] = [] + copied: list[str] = [] + for fig in data.get("figures") or []: + if not _figure_allowed(fig, include_review): + continue + src = workdir / "figures" / fig.get("filename", "") + if not src.is_file(): + continue + dest = images_dir / src.name + shutil.copy2(src, dest) + fig = dict(fig) + fig["decision"] = "insert" + insert_figs.append(fig) + copied.append(f"images/{src.name}") + return insert_figs, copied + + +def _note_references_image(note: str, relative: str) -> bool: + name = Path(relative).name + if name in note or relative in note: + return True + return bool(re.search(rf"!\[\[(?:[^\]]*/)?{re.escape(name)}(?:\|[^\]]*)?\]\]", note)) + + +def _is_scan_page_fig(fig: dict) -> bool: + lvl = str(fig.get("extraction_level") or "") + fn = str(fig.get("filename") or "") + return lvl == "page" or fn.startswith("page_") or "xref" in fn + + +def _copy_scan_pages( + workdir: Path, + images_dir: Path, + *, + limit: int = 8, +) -> tuple[list[dict], list[str]]: + """扫描件模式:复制 figures 下整页 PNG(忽略 insert 闸门)。""" + images_dir.mkdir(parents=True, exist_ok=True) + data = _load_figures_manifest(workdir) + figs_src = list(data.get("figures") or []) + if not figs_src: + for src in sorted((workdir / "figures").glob("page_*.png"))[:limit]: + figs_src.append( + { + "filename": src.name, + "extraction_level": "page", + "decision": "placeholder", + "page": None, + } + ) + insert_figs: list[dict] = [] + copied: list[str] = [] + for fig in figs_src: + if len(insert_figs) >= limit: + break + if not _is_scan_page_fig(fig) and fig.get("decision") != "insert": + # 非整页且非 insert 的跳过;整页/xref 一律可作扫描预览 + if not str(fig.get("filename") or "").endswith(".png"): + continue + src = workdir / "figures" / fig.get("filename", "") + if not src.is_file(): + continue + dest = images_dir / src.name + shutil.copy2(src, dest) + fig = dict(fig) + fig["decision"] = "insert" + fig["scan_page"] = True + insert_figs.append(fig) + copied.append(f"images/{src.name}") + return insert_figs, copied + + +def harvest_narrative_from_note(content: str) -> dict[str, str]: + """从一/二/七节收获 Canvas 叙事卡文案。""" + out: dict[str, str] = {} + + def _sec(title_pat: str) -> str: + m = re.search( + rf"^##\s*{title_pat}\s*\n([\s\S]*?)(?=^##\s|\Z)", + content, + re.M, + ) + return (m.group(1).strip() if m else "") + + one = _sec(r"一、一句话") + if one: + # 去掉空行,压成短段 + one = re.sub(r"\n+", " ", one).strip() + if len(one) > 160: + one = one[:160] + "…" + out["one_liner"] = one + + two = _sec(r"二、连贯叙事.*") + if two: + for key, label in ( + ("problem", "问题"), + ("approach", "思路"), + ("how", "怎么做"), + ("effect", "效果"), + ): + m = re.search( + rf"\*\*{label}[^*]*\*\*[::]?\s*(.+?)(?=\n\s*\*\*|\n\n|\Z)", + two, + re.S, + ) + if m: + text = re.sub(r"\s+", " ", m.group(1)).strip() + if len(text) > 140: + text = text[:140] + "…" + out[key] = text + + seven = _sec(r"七、和现有技术的差别.*") + if seven: + # 取首条非空列表或首段 + bullet = re.search(r"^[-*]\s+\*\*[^*]+\*\*[:::]?\s*(.+)$", seven, re.M) + if bullet: + text = bullet.group(1).strip() + else: + text = re.sub(r"\s+", " ", seven.split("\n\n")[0]).strip() + if len(text) > 140: + text = text[:140] + "…" + if text: + out["diff"] = text + return out + + +def _strip_stale_figure_blocks(content: str) -> str: + """去掉过时的 insert=0 占位 callout / 旧自动附图节 / 散落的 ### 图N 块。""" + content = re.sub( + r"(?ms)^> \[!figure\][^\n]*\n(?:>.*\n)*?>[^\n]*insert\s*=\s*0[^\n]*\n(?:>.*\n)*", + "", + content, + ) + content = re.sub( + r"(?ms)^###\s*附图(?:嵌入)?(?:(扫描件整页预览)|(自动))?\s*\n.*?(?=^##\s|\Z)", + "", + content, + ) + # 第六节内散落的图标题+嵌入(避免重复注入) + m6 = re.search( + r"(^##\s*六、[\s\S]*?)(?=^##\s*七、|\Z)", content, re.M + ) + if m6: + sec = m6.group(1) + sec2 = re.sub( + r"(?ms)^###\s*图\s*\d+\s*\n+(?:!\[\[[^\]]+\]\]\s*\n(?:\*[^\n]+\*\s*\n)*)+", + "", + sec, + ) + sec2 = re.sub( + r"(?ms)^!\[\[images/[^\]]+\]\]\s*\n(?:\*[^\n]+\*\s*\n)*", + "", + sec2, + ) + content = content[: m6.start(1)] + sec2 + content[m6.end(1) :] + return content + + +def _inject_figure_embeds( + content: str, + insert_figs: list[dict], + *, + scan_mode: bool = False, +) -> str: + """在第六节嵌入附图;扫描件模式用整页预览说明。""" + if not insert_figs: + return content + content = _strip_stale_figure_blocks(content) + missing = [ + f + for f in insert_figs + if not _note_references_image( + content, f.get("relative_path") or f.get("filename", "") + ) + ] + # 扫描模式:即使已有部分引用,也重建预览节(去重后) + if scan_mode: + missing = insert_figs[:8] + content = _strip_stale_figure_blocks(content) + elif not missing: + return content + + if scan_mode or any(f.get("scan_page") or _is_scan_page_fig(f) for f in missing): + block_lines = [ + "", + "### 附图", + "", + "> [!tip] 扫描 PDF", + "> 官方文本多为扫描件,下列为**整页渲染预览**(非矢量裁切图)。" + "请对照说明书图号阅读;精修裁图需人工确认。", + "", + ] + else: + block_lines = ["", "### 附图", ""] + + for i, f in enumerate(missing[:8], 1): + fname = f.get("filename") or "" + embed = f.get("suggested_embed") or f"![[images/{fname}]]" + # 统一相对笔记目录的 images/ + if "images/" not in embed and fname: + embed = f"![[images/{fname}]]" + page = f.get("page") or f.get("page_number") + label = str(f.get("label") or "") + num_m = re.search(r"图\s*(\d+)", label) or re.search( + r"图(\d+)", fname + ) + fig_no = num_m.group(1) if num_m else None + # 用户可见说明只用页码/图号,不暴露内部文件名 + if fig_no and page: + cap = f"图{fig_no}(第 {page} 页)" + elif fig_no: + cap = f"图{fig_no}" + elif page: + cap = f"第 {page} 页" + else: + cap = f"预览 {i}" + if fig_no: + block_lines.append(f"### 图{fig_no}") + block_lines.append("") + block_lines.append(embed) + block_lines.append(f"*{cap}*") + block_lines.append("") + block = "\n".join(block_lines) + m = re.search(r"^##\s*六、.*$", content, re.M) + if m: + end = content.find("\n## ", m.end()) + if end == -1: + return content[: m.end()] + "\n" + block + content[m.end() :] + return content[:end] + "\n" + block + content[end:] + return content.rstrip() + "\n" + block + + +def _wikilink_glossary_in_section5(content: str, glossary_resolved: list[dict]) -> str: + """仅在第五节术语表内为已解析术语加 wikilink。""" + m = SECTION5_RE.search(content) + if not m: + return content + sec = m.group(1) + new_sec = sec + for g in glossary_resolved: + term = g.get("term") or "" + path = g.get("path") or "" + if not term or not path: + continue + link = f"[[{path}|{term}]]" + if link in new_sec: + continue + new_sec = re.sub( + rf"(\|\s*){re.escape(term)}(\s*\|)", + rf"\1{link}\2", + new_sec, + count=1, + ) + return content[: m.start(1)] + new_sec + content[m.end(1) :] + + +def main(argv: list[str] | None = None) -> int: + if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--content-file", required=True, type=Path) + ap.add_argument("--manifest", required=True, type=Path) + ap.add_argument("--lint-json", default=None, type=optional_path) + ap.add_argument("--context-anchor", default=None, type=optional_path) + ap.add_argument("--bundle", default=None, type=optional_path) + ap.add_argument("--public-clues", default=None, type=optional_path) + ap.add_argument( + "--claim-deltas", + default=None, + type=optional_path, + help="Agent 填写的本项新增 JSON(缺省读 workdir/claim_deltas.json)", + ) + ap.add_argument("--workdir", default=None, type=optional_path) + ap.add_argument( + "--strict-figures", + action="store_true", + help="要求笔记在入库前已嵌入 insert 图;禁止依赖自动 inject", + ) + ap.add_argument( + "--include-review", + action="store_true", + help="将 quality=review 的附图一并按 insert 复制并嵌入", + ) + ap.add_argument( + "--scan-pages", + action="store_true", + help="扫描件模式:复制整页 PNG 并在第六节嵌入预览(无精修图时默认启用)", + ) + ap.add_argument("--no-glossary-stubs", action="store_true") + ap.add_argument( + "--fetch-clues-fallback", + action="store_true", + help="线索缺 summary 时用脚本 HTTP 降级抓取(默认关闭;摘要应由 Agent 主路径填写)", + ) + ap.add_argument( + "--copy-source-pdf", + default=True, + action=argparse.BooleanOptionalAction, + help="将官方 PDF 复制到笔记目录 source/(默认开启;--no-copy-source-pdf 关闭)", + ) + ap.add_argument("--output", default="", help="状态 JSON 路径") + args = ap.parse_args(argv) + + if args.lint_json and args.lint_json.is_file(): + lint = json.loads(args.lint_json.read_text(encoding="utf-8")) + if not lint.get("passed"): + print("拒绝写入:lint 未通过", file=sys.stderr) + return 1 + + manifest = json.loads(args.manifest.read_text(encoding="utf-8")) + content = args.content_file.read_text(encoding="utf-8") + pub = manifest.get("pub_number") or "patent" + cfg = runtime_config() + + anchor: dict = {} + if args.context_anchor and args.context_anchor.is_file(): + anchor = json.loads(args.context_anchor.read_text(encoding="utf-8")) + + public_clues: list = [] + if args.public_clues and args.public_clues.is_file(): + try: + from clue_vault import as_clues, filter_clues + except ImportError: + from tools.patent_reader.clue_vault import as_clues, filter_clues + + raw = json.loads(args.public_clues.read_text(encoding="utf-8")) + public_clues, _ = filter_clues(as_clues(raw)) + + title_m = re.search(r"^#\s+(.+)$", content, re.M) + title = title_m.group(1).strip() if title_m else f"专利解读 {pub}" + domain = anchor.get("domain") or resolve_domain( + title + "\n" + content[:2000], + (manifest.get("ipc_codes") or [""])[0] if manifest.get("ipc_codes") else "", + ) + + ts = datetime.now().strftime("%Y%m%d") + filename = f"{slugify_pub(pub)}_解读_{ts}.md" + pub_slug = slugify_pub(pub) + + vault = Path(cfg["obsidian_vault"]).resolve() if cfg["obsidian_vault"] else None + papers = cfg["papers_dir"] + glossary_dir = cfg["glossary_dir"] + + if vault: + base = vault / papers / domain / pub_slug + bootstrap_actions = bootstrap_vault(vault, papers) + else: + base = Path(cfg["output_dir"]) / f"{pub_slug}_{ts}" + bootstrap_actions = [] + + base.mkdir(parents=True, exist_ok=True) + images_dir = base / "images" + images_dir.mkdir(exist_ok=True) + + insert_figs: list[dict] = [] + copied: list[str] = [] + if args.workdir and args.workdir.is_dir(): + # strict:在 inject 前检查,未嵌入则拒绝(不依赖自动补嵌「刷绿」) + if args.strict_figures: + preview, _ = _copy_figures_gated( + args.workdir.resolve(), + images_dir, + include_review=args.include_review, + ) + pre_missing = [ + f.get("filename") + for f in preview + if not _note_references_image( + content, f.get("relative_path") or f.get("filename", "") + ) + ] + if pre_missing: + print( + f"拒绝写入:--strict-figures 要求笔记已嵌入附图: {pre_missing}", + file=sys.stderr, + ) + return 1 + insert_figs, copied = _copy_figures_gated( + args.workdir.resolve(), + images_dir, + include_review=args.include_review or args.scan_pages, + ) + # 无可用 insert 时自动扫描件整页;或显式 --scan-pages + scan_mode = bool(args.scan_pages) + if not insert_figs or scan_mode: + scan_figs, scan_copied = _copy_scan_pages( + args.workdir.resolve(), images_dir, limit=8 + ) + if scan_figs: + insert_figs = scan_figs + copied = scan_copied + scan_mode = True + else: + scan_mode = any( + f.get("scan_page") or _is_scan_page_fig(f) for f in insert_figs + ) + else: + scan_mode = False + + content = sanitize_user_facing_titles(content) + content = enrich_note_frontmatter( + content, + pub=pub, + domain=domain, + manifest=manifest, + anchor=anchor, + public_clues=public_clues, + ) + + if insert_figs: + content = _inject_figure_embeds( + content, insert_figs, scan_mode=scan_mode + ) + content = sanitize_user_facing_titles(content) + + nav: list[str] = [] + if vault: + nav = [ + f"[[{papers}/_专利解读索引|专利解读索引]]", + f"[[{papers}/{domain}/_领域索引|{domain}领域索引]]", + f"[[{glossary_dir}/_术语索引|术语索引]]", + ] + else: + nav = [ + "[[_专利解读索引|专利解读索引]](本地 outputs)", + ] + content = _ensure_nav_section(content, nav) + + dest = base / filename + note_rel = str(dest.relative_to(vault)).replace("\\", "/") if vault else filename + + canvas_rel = ( + f"{papers}/{domain}/{pub_slug}/{pub_slug}_图谱.canvas" + if vault + else f"{pub_slug}_图谱.canvas" + ) + canvas_path = (vault / canvas_rel) if vault else (base / f"{pub_slug}_图谱.canvas") + glossary_resolved: list[dict] = [] + + glossary: list = [] + if args.bundle and args.bundle.is_file(): + bundle = json.loads(args.bundle.read_text(encoding="utf-8")) + glossary = bundle.get("glossary_candidates") or [] + # Agent 手写第五节/wikilink 时 extract 常为空:入库须从笔记正文补收获 + glossary = merge_glossary_candidates(glossary, harvest_glossary_from_note(content)) + + related = {"related_patents": [], "disclosures": []} + if vault: + related = scan_vault_related( + vault, + papers, + pub, + manifest.get("assignees") or [], + domain=domain, + ) + + claim_tree: dict | None = None + if args.workdir: + ct_path = args.workdir.resolve() / "claim_tree.json" + if ct_path.is_file(): + raw_tree = json.loads(ct_path.read_text(encoding="utf-8")) + review_meta = ( + raw_tree.get("review") if isinstance(raw_tree, dict) else None + ) + claim_tree = normalize_claim_tree(raw_tree) + if isinstance(review_meta, dict): + claim_tree["review"] = review_meta + + # 第三节:树形一览表;「本项新增」优先 Agent claim_deltas,启发式仅降级 + if claim_tree and (claim_tree.get("nodes") or []): + agent_deltas: dict[int, str] = {} + delta_path = args.claim_deltas + if delta_path is None and args.workdir: + cand = args.workdir.resolve() / "claim_deltas.json" + if cand.is_file(): + delta_path = cand + if delta_path and Path(delta_path).is_file(): + agent_deltas = load_claim_deltas(Path(delta_path)) + # note_plan.json 也可内嵌 claim_deltas + if args.workdir: + plan_path = args.workdir.resolve() / "note_plan.json" + if plan_path.is_file(): + try: + plan = json.loads(plan_path.read_text(encoding="utf-8")) + agent_deltas = merge_claim_summaries( + agent_deltas, load_claim_deltas(plan) + ) + except (OSError, json.JSONDecodeError): + pass + summaries = merge_claim_summaries( + harvest_claim_summaries_from_note(content), + claim_deltas_from_tree(claim_tree), + agent_deltas, + ) + content = upsert_claim_tree_section( + content, + render_claim_tree_markdown( + claim_tree, pub=pub, summaries=summaries + ), + ) + side_tree = base / "claim_tree.json" + side_tree.write_text( + json.dumps(claim_tree, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + # 旁路保存 Agent deltas,供 Canvas/再入库 + if agent_deltas and args.workdir: + side_deltas = { + "source": "agent", + "deltas": [ + {"claim": n, "delta": summaries[n]} + for n in sorted(agent_deltas) + if n in summaries + ], + } + (args.workdir.resolve() / "claim_deltas.json").write_text( + json.dumps(side_deltas, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + if args.workdir: + try: + from obsidian import claim_tree_to_mermaid + except ImportError: + from tools.patent_reader.obsidian import claim_tree_to_mermaid + + mmd_path = args.workdir.resolve() / "claim_mermaid.mmd" + mmd_path.write_text( + claim_tree_to_mermaid(claim_tree, pub, summaries=summaries) + "\n", + encoding="utf-8", + ) + + figure_rels: list[str] = [] + if vault and images_dir.is_dir(): + note_dir_rel = str(Path(note_rel).parent).replace("\\", "/") + for img in sorted(images_dir.glob("*.png"))[:4]: + figure_rels.append(f"{note_dir_rel}/images/{img.name}") + + # 公开线索:筛选→自动抓取→clues/→附录 B→权/特征旁注 + clue_cards: list[dict] = [] + rich_clues: list[dict] = [] + if public_clues: + try: + from clue_vault import ( + clue_cards_for_canvas, + harvest_feature_entries, + inject_clue_annotations, + materialize_clues, + upsert_appendix_b, + ) + except ImportError: + from tools.patent_reader.clue_vault import ( + clue_cards_for_canvas, + harvest_feature_entries, + inject_clue_annotations, + materialize_clues, + upsert_appendix_b, + ) + + note_dir_rel = str(Path(note_rel).parent).replace("\\", "/") if vault else "" + rich_clues, appendix_md = materialize_clues( + public_clues, + note_dir=base, + pub=pub, + note_rel=note_rel if vault else filename, + claim_summaries=harvest_claim_summaries_from_note(content), + feature_entries=harvest_feature_entries(content), + fetch_fallback=bool(args.fetch_clues_fallback), + ) + content = upsert_appendix_b(content, appendix_md) + content = inject_clue_annotations(content, rich_clues) + content = sanitize_user_facing_titles(content) + clue_cards = clue_cards_for_canvas(rich_clues, note_dir_rel=note_dir_rel) + + canvas_meta = { + "domain": domain, + "ipc": (anchor or {}).get("ipc_codes") + or manifest.get("ipc_codes") + or "", + "assignees": manifest.get("assignees") + or (anchor or {}).get("assignees") + or [], + "evidence_scope": manifest.get("evidence_scope") or "", + } + narrative = harvest_narrative_from_note(content) + + canvas = build_canvas( + vault=vault, + papers_dir=papers, + note_rel_path=note_rel if vault else filename, + pub=pub, + title=title, + related=related, + glossary_terms=glossary, + glossary_dir=glossary_dir, + create_glossary_stubs=not args.no_glossary_stubs, + glossary_root=(None if vault else (base / "术语")), + meta=canvas_meta, + claim_tree=claim_tree, + claim_summaries=harvest_claim_summaries_from_note(content), + figure_rels=figure_rels, + narrative=narrative, + clue_cards=clue_cards, + ) + glossary_resolved = canvas.pop("glossary_resolved", []) + content = _wikilink_glossary_in_section5(content, glossary_resolved) + canvas_path.parent.mkdir(parents=True, exist_ok=True) + canvas_path.write_text( + json.dumps(canvas, ensure_ascii=False, indent=2), encoding="utf-8" + ) + content = ensure_canvas_nav( + content, canvas_rel if vault else canvas_path.name + ) + + desc_para_path = "" + desc_para_cited: list[str] = [] + try: + from desc_paragraphs import ( + load_description_paragraphs, + materialize_description_paragraphs, + ) + except ImportError: + from tools.patent_reader.desc_paragraphs import ( + load_description_paragraphs, + materialize_description_paragraphs, + ) + + workdir = args.workdir + paragraphs = load_description_paragraphs(workdir) + content, para_dest, desc_para_cited = materialize_description_paragraphs( + content=content, + pub=pub, + note_dir=base, + paragraphs=paragraphs, + cited_only=True, + ) + if para_dest is not None: + desc_para_path = str(para_dest) + + try: + from note_cites import enhance_note_citations + except ImportError: + from tools.patent_reader.note_cites import enhance_note_citations + + content, claim_anchors_path, claim_anchor_nums = enhance_note_citations( + content, + pub=pub, + note_dir=base, + claim_tree=claim_tree if isinstance(claim_tree, dict) else None, + claim_summaries=harvest_claim_summaries_from_note(content), + insert_figs=insert_figs, + ) + + source_pdf_copied = "" + if args.copy_source_pdf: + src_pdf = resolve_source_pdf(manifest, workdir) + if src_pdf is not None: + try: + dest_pdf = copy_source_pdf_to_note_dir(src_pdf, base, pub) + source_pdf_copied = str(dest_pdf) + content = ensure_source_pdf_nav(content, pub=pub) + except OSError as exc: + print(f"WARN copy-source-pdf failed: {exc}", file=sys.stderr) + else: + print( + "WARN copy-source-pdf: 未找到官方 PDF(manifest.source_path / workdir/source)", + file=sys.stderr, + ) + + if vault: + gloss_index = vault / glossary_dir / "_术语索引.md" + for g in glossary_resolved: + if g.get("path") and g.get("term"): + line = f"[[{g['path']}|{g['term']}]] — 来自 `{pub}`" + upsert_index_entry( + gloss_index, + "术语索引", + line, + "专利解读术语概念页。", + dedupe_key=g["term"], + ) + + try: + from note_cites import escape_wikilink_pipes_in_tables + except ImportError: + from tools.patent_reader.note_cites import escape_wikilink_pipes_in_tables + + # 表格内 wikilink 别名的 | 必须转义,否则会露路径、拆列 + content = escape_wikilink_pipes_in_tables(content) + dest.write_text(content, encoding="utf-8") + + moc_paths: list[str] = [] + cli_ok: list[str] = [] + if vault: + link = _note_link_name(dest, vault) + entry = f"[[{link}|{pub} {title[:28]}]] — `{ts}` · `{domain}`" + global_moc = vault / papers / "_专利解读索引.md" + domain_moc = ensure_domain_index(vault, papers, domain) + upsert_index_entry( + global_moc, + "专利解读索引", + entry, + "本页自动汇总专利通俗解读笔记;入库后自动维护。", + ) + upsert_index_entry( + domain_moc, + f"{domain} · 领域索引", + entry, + f"领域:**{domain}**。上级:[[{papers}/_专利解读索引|专利解读索引]]。", + ) + moc_paths = [str(global_moc), str(domain_moc)] + + for prop, val in ( + ("domain", domain), + ("pub_number", pub), + ("evidence_scope", manifest.get("evidence_scope", "")), + ): + if val and try_obsidian_cli_property(note_rel, prop, str(val), vault): + cli_ok.append(prop) + + status = { + "written": str(dest), + "canvas": str(canvas_path) if canvas_path.is_file() else "", + "description_paragraphs": desc_para_path, + "description_paragraphs_cited": desc_para_cited, + "claim_anchors": str(claim_anchors_path) if claim_anchors_path else "", + "claim_anchors_count": len(claim_anchor_nums), + "source_pdf": source_pdf_copied, + "copy_source_pdf": bool(args.copy_source_pdf), + "domain": domain, + "obsidian": bool(vault), + "bootstrap": bootstrap_actions, + "moc_updated": moc_paths, + "obsidian_cli_properties": cli_ok, + "figures_inserted": [f.get("filename") for f in insert_figs], + "figures_copied": copied, + "include_review": args.include_review, + "scan_pages": bool(scan_mode) if args.workdir else args.scan_pages, + "narrative_keys": list(narrative.keys()) if narrative else [], + "glossary_resolved": glossary_resolved, + "clues_count": len(rich_clues), + "clues_dir": str(base / "clues") if rich_clues else "", + } + print(f"OK written: {dest}") + if canvas_path.is_file(): + print(f"CANVAS: {canvas_path}") + if desc_para_path: + print(f"DESC_PARAS: {desc_para_path} cited={len(desc_para_cited)}") + if claim_anchors_path: + print( + f"CLAIM_ANCHORS: {claim_anchors_path} count={len(claim_anchor_nums)}" + ) + if source_pdf_copied: + print(f"SOURCE_PDF: {source_pdf_copied}") + if insert_figs: + print(f"FIGURES_INSERT: {len(insert_figs)}") + for p in moc_paths: + print(f"MOC: {p}") + if args.output: + Path(args.output).write_text( + json.dumps(status, ensure_ascii=False, indent=2), encoding="utf-8" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.agents/skills/patent-disclosure-skill/tools/pptx_to_md.py b/.agents/skills/patent-disclosure-skill/tools/pptx_to_md.py new file mode 100644 index 0000000..0683851 --- /dev/null +++ b/.agents/skills/patent-disclosure-skill/tools/pptx_to_md.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 +""" +将 PowerPoint(.pptx)按页导出为 Markdown,并抽取幻灯片中的嵌入图片,便于 Step 2 扫描与 Agent Read。 + +依赖 python-pptx(见仓库根目录 requirements.txt)。 + +用法: + python pptx_to_md.py --input review.pptx --output outputs/case/review.md + python pptx_to_md.py -i a.pptx -o b/out.md --media-dir b/slide_images + +默认图片目录:与输出 .md 同级的「{md 文件名}_media/」。 +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + + +def _require_pptx(): + try: + from pptx import Presentation + from pptx.enum.shapes import MSO_SHAPE_TYPE + except ImportError: + print( + "缺少依赖 python-pptx。请在技能根目录执行: pip install -r requirements.txt", + file=sys.stderr, + ) + sys.exit(1) + return Presentation, MSO_SHAPE_TYPE + + +def _walk_shapes(shapes, MSO_SHAPE_TYPE): + for shape in shapes: + if shape.shape_type == MSO_SHAPE_TYPE.GROUP: + yield from _walk_shapes(shape.shapes, MSO_SHAPE_TYPE) + else: + yield shape + + +def _shape_text(shape) -> str: + if getattr(shape, "has_text_frame", False): + t = (shape.text_frame.text or "").strip() + return t + if getattr(shape, "has_table", False): + rows = [] + for row in shape.table.rows: + cells = [] + for cell in row.cells: + cells.append((cell.text or "").strip().replace("\n", " ")) + rows.append("| " + " | ".join(cells) + " |") + if rows: + return "\n".join(rows) + return "" + + +def _rel_media_path(out_file: Path, media_file: Path) -> str: + try: + return media_file.relative_to(out_file.parent).as_posix() + except ValueError: + return media_file.as_posix() + + +def _run(input_pptx: Path, output_md: Path, media_dir: Path | None) -> int: + Presentation, MSO_SHAPE_TYPE = _require_pptx() + + if not input_pptx.is_file(): + print(f"输入文件不存在: {input_pptx}", file=sys.stderr) + return 2 + suf = input_pptx.suffix.lower() + if suf not in (".pptx", ".ppsx"): + print("警告: 期望 .pptx / .ppsx(OOXML);旧版 .ppt 不支持。", file=sys.stderr) + + output_md = output_md.resolve() + output_md.parent.mkdir(parents=True, exist_ok=True) + + if media_dir is None: + media_dir = output_md.parent / f"{output_md.stem}_media" + else: + media_dir = media_dir.resolve() + media_dir.mkdir(parents=True, exist_ok=True) + + try: + prs = Presentation(str(input_pptx)) + except Exception as e: + print(f"无法打开演示文稿: {e}", file=sys.stderr) + return 3 + + lines: list[str] = [ + f"\n" + ] + img_counter = [0] + + for sn, slide in enumerate(prs.slides, start=1): + lines.append(f"\n## 第 {sn} 页\n") + + for shape in _walk_shapes(slide.shapes, MSO_SHAPE_TYPE): + if shape.shape_type == MSO_SHAPE_TYPE.PICTURE: + try: + img = shape.image + ext = (img.ext or "png").lower() + if ext == "jpeg": + ext = "jpg" + img_counter[0] += 1 + fname = f"slide{sn:02d}_img{img_counter[0]:04d}.{ext}" + out_img = media_dir / fname + out_img.write_bytes(img.blob) + rel = _rel_media_path(output_md, out_img) + lines.append(f"\n![]({rel})\n") + except Exception as e: + print(f"警告: 第 {sn} 页抽取图片失败: {e}", file=sys.stderr) + continue + + block = _shape_text(shape) + if block: + lines.append(block) + lines.append("\n\n") + + try: + nf = slide.notes_slide.notes_text_frame + note_txt = (nf.text or "").strip() if nf is not None else "" + if note_txt: + lines.append("\n**备注**:\n\n") + lines.append(note_txt) + lines.append("\n\n") + except (AttributeError, ValueError): + pass + + body = "".join(lines).rstrip() + "\n" + output_md.write_text(body, encoding="utf-8") + + print(f"已写入: {output_md}") + print(f"图片目录: {media_dir}") + return 0 + + +def main() -> int: + p = argparse.ArgumentParser(description="PowerPoint (.pptx) → Markdown + 抽取图片") + p.add_argument("-i", "--input", required=True, type=Path, help="输入 .pptx / .ppsx 路径") + p.add_argument("-o", "--output", required=True, type=Path, help="输出 .md 路径") + p.add_argument( + "--media-dir", + type=Path, + default=None, + help="图片输出目录(默认:与 .md 同级的 {md 主名}_media)", + ) + args = p.parse_args() + return _run(args.input, args.output, args.media_dir) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.agents/skills/patent-disclosure-skill/tools/requirements-cnipa.txt b/.agents/skills/patent-disclosure-skill/tools/requirements-cnipa.txt new file mode 100644 index 0000000..7f685d8 --- /dev/null +++ b/.agents/skills/patent-disclosure-skill/tools/requirements-cnipa.txt @@ -0,0 +1,2 @@ +# 国知局公布公告站检索(tools/cnipa_epub_search.py / cnipa_epub_crawler.py)依赖;与仓库根目录 requirements.txt 独立 +playwright>=1.40.0 diff --git a/.github/workflows/update-external-skills.yml b/.github/workflows/update-external-skills.yml new file mode 100644 index 0000000..76da5ac --- /dev/null +++ b/.github/workflows/update-external-skills.yml @@ -0,0 +1,62 @@ +name: Update external skills + +on: + schedule: + # Mondays at 09:17 Asia/Shanghai (01:17 UTC). + - cron: "17 1 * * 1" + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +jobs: + update-patent-disclosure-skill: + runs-on: ubuntu-latest + steps: + - name: Check out the default branch + uses: actions/checkout@v6 + with: + ref: ${{ github.event.repository.default_branch }} + + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: "24" + + - name: Update patent-disclosure-skill + env: + DISABLE_TELEMETRY: "1" + run: >- + npx --yes skills@1.5.21 update patent-disclosure-skill + --project --yes + + - name: Verify the managed skill + shell: bash + run: | + test -f .agents/skills/patent-disclosure-skill/SKILL.md + node -e ' + const lock = require("./skills-lock.json"); + const skill = lock.skills?.["patent-disclosure-skill"]; + if (!skill || skill.source !== "handsomestWei/patent-disclosure-skill") { + throw new Error("Unexpected patent-disclosure-skill source"); + } + ' + + - name: Create or refresh the review pull request + uses: peter-evans/create-pull-request@v8 + with: + token: ${{ secrets.GITHUB_TOKEN }} + add-paths: | + .agents/skills/patent-disclosure-skill + skills-lock.json + branch: automation/update-patent-disclosure-skill + delete-branch: true + commit-message: "chore(skills): update patent-disclosure-skill" + title: "chore(skills): update patent-disclosure-skill" + body: | + Automated upstream refresh from `handsomestWei/patent-disclosure-skill`. + + Review is required before merging because this third-party skill includes executable tools and runs with agent permissions. Check the upstream diff, especially `SKILL.md`, `prompts/`, dependency lockfiles, and `tools/`. + + Source: https://github.com/handsomestWei/patent-disclosure-skill diff --git a/README.md b/README.md index 2313ab5..839cfef 100644 --- a/README.md +++ b/README.md @@ -159,6 +159,26 @@ Re-download from [Releases](https://github.com/trsoliu/mini-wiki/releases/latest +### Project-level Patent Skill + +This repository also carries +[`patent-disclosure-skill`](https://skills.sh/handsomestWei/patent-disclosure-skill/patent-disclosure-skill) +as a project-level Agent Skill under `.agents/skills/patent-disclosure-skill`. +`skills-lock.json` records its upstream source and content hash. + +To refresh it manually: + +```bash +DISABLE_TELEMETRY=1 npx -y skills@1.5.21 update patent-disclosure-skill --project --yes +``` + +The `Update external skills` workflow checks upstream every Monday and opens or +refreshes a review pull request when files change. Updates are deliberately not +auto-merged: this third-party Skill includes executable tools and should be +reviewed before the project starts using a new revision. Forks must enable +**Settings → Actions → General → Allow GitHub Actions to create and approve pull +requests** for the workflow to create its pull request. + ### Plugin Commands ```bash diff --git a/README.zh.md b/README.zh.md index dc17a9b..0faf68f 100644 --- a/README.zh.md +++ b/README.zh.md @@ -159,6 +159,25 @@ cd mini-wiki && git pull origin main +### 项目级专利 Skill + +本仓库已将 +[`patent-disclosure-skill`](https://skills.sh/handsomestwei/patent-disclosure-skill/patent-disclosure-skill) +作为项目级 Agent Skill 放在 `.agents/skills/patent-disclosure-skill`,并由 +`skills-lock.json` 记录上游来源与内容哈希。 + +需要立即手动同步时执行: + +```bash +DISABLE_TELEMETRY=1 npx -y skills@1.5.21 update patent-disclosure-skill --project --yes +``` + +`Update external skills` 工作流每周一检查上游;发现变化后会自动创建或刷新 +更新 PR。工作流不会自动合并,因为该第三方 Skill 含可执行工具,项目启用新版本前 +应先审查 `SKILL.md`、提示词、依赖锁文件和 `tools/` 的差异。Fork 后还需在 +**Settings → Actions → General** 中启用 **Allow GitHub Actions to create and +approve pull requests**,工作流才能创建 PR。 + ### 插件命令 ```bash diff --git a/pyproject.toml b/pyproject.toml index 2313b95..96aca45 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mini-wiki" -version = "3.1.0" +version = "3.2.0" description = "AI Agent skill package for automatic project documentation generation" authors = [ { name = "trsoliu" } @@ -9,9 +9,28 @@ license = { text = "Apache-2.0" } requires-python = ">=3.10" dependencies = [ "PyYAML>=6.0", + "click>=8.1", + "rich>=13.0", "tomli>=2.0; python_version<'3.11'", ] +[project.scripts] +mini-wiki = "cli:main" + +[tool.setuptools] +package-dir = { "" = "scripts" } +py-modules = [ + "analyze_project", + "check_quality", + "cli", + "detect_changes", + "extract_docs", + "generate_diagram", + "generate_toc", + "init_wiki", + "plugin_manager", +] + [project.optional-dependencies] dev = [ "pytest>=8.0", @@ -40,6 +59,11 @@ select = [ "TCH", # flake8-type-checking "RUF", # ruff-specific rules ] +ignore = [ + "RUF001", # Intentional Chinese punctuation in user-facing strings + "RUF002", # Intentional Chinese punctuation in docstrings + "RUF003", # Intentional Chinese punctuation in comments +] [tool.ruff.format] quote-style = "double" diff --git a/scripts/analyze_project.py b/scripts/analyze_project.py index 80694f1..4c3efe0 100644 --- a/scripts/analyze_project.py +++ b/scripts/analyze_project.py @@ -10,128 +10,162 @@ import re from datetime import datetime, timezone from pathlib import Path -from typing import Any, Dict, List, Optional, Set +from typing import Any # 忽略的目录 IGNORE_DIRS = { - 'node_modules', '.git', 'dist', 'build', '__pycache__', - '.next', '.nuxt', 'coverage', '.nyc_output', 'vendor', - 'venv', '.venv', 'env', '.env', 'eggs', '.eggs', - '.tox', '.cache', '.pytest_cache', '.mypy_cache', - '.mini-wiki', '.agent' + "node_modules", + ".git", + "dist", + "build", + "__pycache__", + ".next", + ".nuxt", + "coverage", + ".nyc_output", + "vendor", + "venv", + ".venv", + "env", + ".env", + "eggs", + ".eggs", + ".tox", + ".cache", + ".pytest_cache", + ".mypy_cache", + ".mini-wiki", + ".agent", } # 忽略的文件 IGNORE_FILES = { - '.DS_Store', 'Thumbs.db', '.gitignore', '.gitattributes', - 'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml', - 'poetry.lock', 'Pipfile.lock', 'composer.lock' + ".DS_Store", + "Thumbs.db", + ".gitignore", + ".gitattributes", + "package-lock.json", + "yarn.lock", + "pnpm-lock.yaml", + "poetry.lock", + "Pipfile.lock", + "composer.lock", } # 项目类型检测规则 PROJECT_INDICATORS = { - 'nodejs': ['package.json'], - 'typescript': ['tsconfig.json', 'tsconfig.*.json'], - 'python': ['requirements.txt', 'pyproject.toml', 'setup.py', 'Pipfile'], - 'go': ['go.mod', 'go.sum'], - 'rust': ['Cargo.toml'], - 'java': ['pom.xml', 'build.gradle', 'build.gradle.kts'], - 'ruby': ['Gemfile'], - 'php': ['composer.json'], - 'dotnet': ['*.csproj', '*.fsproj', '*.sln'], - 'react': ['package.json'], # 需进一步检查依赖 - 'vue': ['vue.config.js', 'vite.config.ts', 'nuxt.config.ts'], - 'nextjs': ['next.config.js', 'next.config.mjs', 'next.config.ts'], + "nodejs": ["package.json"], + "typescript": ["tsconfig.json", "tsconfig.*.json"], + "python": ["requirements.txt", "pyproject.toml", "setup.py", "Pipfile"], + "go": ["go.mod", "go.sum"], + "rust": ["Cargo.toml"], + "java": ["pom.xml", "build.gradle", "build.gradle.kts"], + "ruby": ["Gemfile"], + "php": ["composer.json"], + "dotnet": ["*.csproj", "*.fsproj", "*.sln"], + "react": ["package.json"], # 需进一步检查依赖 + "vue": ["vue.config.js", "vite.config.ts", "nuxt.config.ts"], + "nextjs": ["next.config.js", "next.config.mjs", "next.config.ts"], } # 代码文件扩展名 CODE_EXTENSIONS = { - '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', - '.py', '.pyi', - '.go', - '.rs', - '.java', '.kt', '.scala', - '.rb', - '.php', - '.cs', '.fs', - '.vue', '.svelte', '.astro' + ".ts", + ".tsx", + ".js", + ".jsx", + ".mjs", + ".cjs", + ".py", + ".pyi", + ".go", + ".rs", + ".java", + ".kt", + ".scala", + ".rb", + ".php", + ".cs", + ".fs", + ".vue", + ".svelte", + ".astro", } - -def detect_package_manager(root_path: Path) -> List[str]: +def detect_package_manager(root_path: Path) -> list[str]: """检测包管理器""" managers = [] - if (root_path / 'package-lock.json').exists(): - managers.append('npm') - if (root_path / 'yarn.lock').exists(): - managers.append('yarn') - if (root_path / 'pnpm-lock.yaml').exists(): - managers.append('pnpm') - if (root_path / 'bun.lockb').exists(): - managers.append('bun') + if (root_path / "package-lock.json").exists(): + managers.append("npm") + if (root_path / "yarn.lock").exists(): + managers.append("yarn") + if (root_path / "pnpm-lock.yaml").exists(): + managers.append("pnpm") + if (root_path / "bun.lockb").exists(): + managers.append("bun") return managers -def detect_monorepo_tools(root_path: Path) -> List[str]: +def detect_monorepo_tools(root_path: Path) -> list[str]: """检测 Monorepo 工具""" tools = [] - + # workspace configs - if (root_path / 'pnpm-workspace.yaml').exists(): - tools.append('pnpm-workspaces') - if 'monorepo' not in tools: - tools.append('monorepo') - - if (root_path / 'lerna.json').exists(): - tools.append('lerna') - if 'monorepo' not in tools: - tools.append('monorepo') - - if (root_path / 'turbo.json').exists(): - tools.append('turborepo') - if 'monorepo' not in tools: - tools.append('monorepo') - + if (root_path / "pnpm-workspace.yaml").exists(): + tools.append("pnpm-workspaces") + if "monorepo" not in tools: + tools.append("monorepo") + + if (root_path / "lerna.json").exists(): + tools.append("lerna") + if "monorepo" not in tools: + tools.append("monorepo") + + if (root_path / "turbo.json").exists(): + tools.append("turborepo") + if "monorepo" not in tools: + tools.append("monorepo") + # check package.json for workspaces - pkg_path = root_path / 'package.json' + pkg_path = root_path / "package.json" if pkg_path.exists(): try: - with open(pkg_path, 'r', encoding='utf-8') as f: + with open(pkg_path, encoding="utf-8") as f: pkg = json.load(f) - if 'workspaces' in pkg: - tools.append('npm-workspaces') - if 'monorepo' not in tools: - tools.append('monorepo') + if "workspaces" in pkg: + tools.append("npm-workspaces") + if "monorepo" not in tools: + tools.append("monorepo") except Exception: pass - + return tools -def detect_project_types(root_path: Path) -> List[str]: +def detect_project_types(root_path: Path) -> list[str]: """检测项目类型""" types = [] - + # 基础文件检测 for project_type, indicators in PROJECT_INDICATORS.items(): for indicator in indicators: - if '*' in indicator: + if "*" in indicator: if list(root_path.glob(indicator)): types.append(project_type) break elif (root_path / indicator).exists(): types.append(project_type) break - + # 检测包管理器 types.extend(detect_package_manager(root_path)) - + # 检测 Monorepo types.extend(detect_monorepo_tools(root_path)) - + # Python 深度检测 (pyproject.toml) - pyproject_path = root_path / 'pyproject.toml' + pyproject_path = root_path / "pyproject.toml" if pyproject_path.exists(): try: import tomllib # Python 3.11+ @@ -140,290 +174,325 @@ def detect_project_types(root_path: Path) -> List[str]: import tomli as tomllib except ImportError: tomllib = None - + if tomllib: try: - with open(pyproject_path, 'rb') as f: + with open(pyproject_path, "rb") as f: pyproject = tomllib.load(f) - + # Detect build system - build_backend = pyproject.get('build-system', {}).get('build-backend', '') - if 'poetry' in build_backend: - types.append('poetry') - elif 'pdm' in build_backend: - types.append('pdm') - elif 'setuptools' in build_backend: - types.append('setuptools') - elif 'flit' in build_backend: - types.append('flit') - + build_backend = pyproject.get("build-system", {}).get("build-backend", "") + if "poetry" in build_backend: + types.append("poetry") + elif "pdm" in build_backend: + types.append("pdm") + elif "setuptools" in build_backend: + types.append("setuptools") + elif "flit" in build_backend: + types.append("flit") + # Detect specific python frameworks in dependencies # Poetry - deps = pyproject.get('tool', {}).get('poetry', {}).get('dependencies', {}) + deps = pyproject.get("tool", {}).get("poetry", {}).get("dependencies", {}) # Standard project.dependencies - deps_std = pyproject.get('project', {}).get('dependencies', []) - - all_deps = set() + deps_std = pyproject.get("project", {}).get("dependencies", []) + + all_deps: set[str] = set() if isinstance(deps, dict): all_deps.update(deps.keys()) if isinstance(deps_std, list): for d in deps_std: - match = re.match(r'^([a-zA-Z0-9_-]+)', d) + match = re.match(r"^([a-zA-Z0-9_-]+)", d) if match: all_deps.add(match.group(1)) - - if 'fastapi' in all_deps: types.append('fastapi') - if 'django' in all_deps: types.append('django') - if 'flask' in all_deps: types.append('flask') - + + if "fastapi" in all_deps: + types.append("fastapi") + if "django" in all_deps: + types.append("django") + if "flask" in all_deps: + types.append("flask") + except Exception: pass # Node.js 深度检测 (package.json) - if 'nodejs' in types or (root_path / 'package.json').exists(): - pkg_path = root_path / 'package.json' + if "nodejs" in types or (root_path / "package.json").exists(): + pkg_path = root_path / "package.json" if pkg_path.exists(): try: - with open(pkg_path, 'r', encoding='utf-8') as f: + with open(pkg_path, encoding="utf-8") as f: pkg = json.load(f) - deps = {**pkg.get('dependencies', {}), **pkg.get('devDependencies', {})} - - if 'react' in deps and 'react' not in types: - types.append('react') - if 'vue' in deps and 'vue' not in types: - types.append('vue') - if 'next' in deps and 'nextjs' not in types: - types.append('nextjs') - if 'nuxt' in deps or '@nuxt/core' in deps: - types.append('nuxt') + deps = {**pkg.get("dependencies", {}), **pkg.get("devDependencies", {})} + + if "react" in deps and "react" not in types: + types.append("react") + if "vue" in deps and "vue" not in types: + types.append("vue") + if "next" in deps and "nextjs" not in types: + types.append("nextjs") + if "nuxt" in deps or "@nuxt/core" in deps: + types.append("nuxt") except Exception: pass # Rust 深度检测 (Cargo.toml) - cargo_path = root_path / 'Cargo.toml' + cargo_path = root_path / "Cargo.toml" if cargo_path.exists(): try: - with open(cargo_path, 'r', encoding='utf-8') as f: + with open(cargo_path, encoding="utf-8") as f: content = f.read() # Simple TOML parsing for dependencies # Note: A real TOML parser would be better but requires external lib - if 'actix-web' in content: types.append('actix-web') - if 'axum' in content: types.append('axum') - if 'tokio' in content: types.append('tokio') - if 'tauri' in content: types.append('tauri') - if 'rocket' in content: types.append('rocket') + if "actix-web" in content: + types.append("actix-web") + if "axum" in content: + types.append("axum") + if "tokio" in content: + types.append("tokio") + if "tauri" in content: + types.append("tauri") + if "rocket" in content: + types.append("rocket") except Exception: pass - + # Go 深度检测 (go.mod) - go_mod_path = root_path / 'go.mod' + go_mod_path = root_path / "go.mod" if go_mod_path.exists(): try: - with open(go_mod_path, 'r', encoding='utf-8') as f: + with open(go_mod_path, encoding="utf-8") as f: content = f.read() - if 'github.com/gin-gonic/gin' in content: types.append('gin') - if 'github.com/labstack/echo' in content: types.append('echo') - if 'github.com/gofiber/fiber' in content: types.append('fiber') - if 'gorm.io/gorm' in content: types.append('gorm') + if "github.com/gin-gonic/gin" in content: + types.append("gin") + if "github.com/labstack/echo" in content: + types.append("echo") + if "github.com/gofiber/fiber" in content: + types.append("fiber") + if "gorm.io/gorm" in content: + types.append("gorm") except Exception: pass - - return list(set(types)) + return list(set(types)) -def find_entry_points(root_path: Path, project_types: List[str]) -> List[str]: +def find_entry_points(root_path: Path, project_types: list[str]) -> list[str]: """识别入口文件""" entries = [] - + # 常见入口文件 common_entries = [ - 'src/index.ts', 'src/index.tsx', 'src/index.js', - 'src/main.ts', 'src/main.tsx', 'src/main.js', - 'src/App.tsx', 'src/App.vue', - 'app/page.tsx', 'pages/index.tsx', 'pages/index.vue', - 'main.py', 'app.py', 'src/main.py', - 'cmd/main.go', 'main.go', - 'src/main.rs', 'src/lib.rs', + "src/index.ts", + "src/index.tsx", + "src/index.js", + "src/main.ts", + "src/main.tsx", + "src/main.js", + "src/App.tsx", + "src/App.vue", + "app/page.tsx", + "pages/index.tsx", + "pages/index.vue", + "main.py", + "app.py", + "src/main.py", + "cmd/main.go", + "main.go", + "src/main.rs", + "src/lib.rs", ] - + for entry in common_entries: if (root_path / entry).exists(): entries.append(entry) - + return entries -def discover_modules(root_path: Path, exclude_dirs: Optional[Set[str]] = None) -> List[Dict[str, Any]]: +def discover_modules(root_path: Path, exclude_dirs: set[str] | None = None) -> list[dict[str, Any]]: """发现项目模块""" if exclude_dirs is None: exclude_dirs = IGNORE_DIRS - + modules = [] - src_dirs = ['src', 'lib', 'packages', 'apps', 'modules'] - + src_dirs = ["src", "lib", "packages", "apps", "modules"] + for src_dir in src_dirs: src_path = root_path / src_dir if not src_path.exists(): continue - + for item in src_path.iterdir(): if item.is_dir() and item.name not in exclude_dirs: # 统计文件数 - file_count = sum(1 for f in item.rglob('*') - if f.is_file() and f.suffix in CODE_EXTENSIONS - and not any(p in f.parts for p in exclude_dirs)) - + file_count = sum( + 1 + for f in item.rglob("*") + if f.is_file() and f.suffix in CODE_EXTENSIONS and not any(p in f.parts for p in exclude_dirs) + ) + if file_count > 0: - modules.append({ - 'name': item.name, - 'path': str(item.relative_to(root_path)), - 'files': file_count, - 'type': categorize_module(item.name) - }) - + modules.append( + { + "name": item.name, + "path": str(item.relative_to(root_path)), + "files": file_count, + "type": categorize_module(item.name), + } + ) + # 如果没有找到明确的模块,尝试根目录下的主要目录 if not modules: for item in root_path.iterdir(): - if item.is_dir() and item.name not in exclude_dirs and not item.name.startswith('.'): - file_count = sum(1 for f in item.rglob('*') - if f.is_file() and f.suffix in CODE_EXTENSIONS) + if item.is_dir() and item.name not in exclude_dirs and not item.name.startswith("."): + file_count = sum(1 for f in item.rglob("*") if f.is_file() and f.suffix in CODE_EXTENSIONS) if file_count > 0: - modules.append({ - 'name': item.name, - 'path': item.name, - 'files': file_count, - 'type': categorize_module(item.name) - }) - + modules.append( + { + "name": item.name, + "path": item.name, + "files": file_count, + "type": categorize_module(item.name), + } + ) + return modules def categorize_module(name: str) -> str: """根据名称分类模块""" name_lower = name.lower() - - if any(k in name_lower for k in ['component', 'ui', 'view', 'page']): - return 'ui' - elif any(k in name_lower for k in ['api', 'service', 'handler']): - return 'api' - elif any(k in name_lower for k in ['util', 'helper', 'common', 'shared']): - return 'utility' - elif any(k in name_lower for k in ['core', 'lib', 'engine']): - return 'core' - elif any(k in name_lower for k in ['config', 'setting']): - return 'config' - elif any(k in name_lower for k in ['test', 'spec']): - return 'test' + + if any(k in name_lower for k in ["component", "ui", "view", "page"]): + return "ui" + elif any(k in name_lower for k in ["api", "service", "handler"]): + return "api" + elif any(k in name_lower for k in ["util", "helper", "common", "shared"]): + return "utility" + elif any(k in name_lower for k in ["core", "lib", "engine"]): + return "core" + elif any(k in name_lower for k in ["config", "setting"]): + return "config" + elif any(k in name_lower for k in ["test", "spec"]): + return "test" else: - return 'module' + return "module" -def find_documentation(root_path: Path) -> List[str]: +def find_documentation(root_path: Path) -> list[str]: """发现现有文档""" doc_patterns = [ - 'README.md', 'README.*.md', 'readme.md', - 'CHANGELOG.md', 'HISTORY.md', 'changelog.md', - 'CONTRIBUTING.md', 'ARCHITECTURE.md', 'DESIGN.md', - 'API.md', 'SECURITY.md', 'LICENSE', 'LICENSE.md', - 'docs/*.md', 'documentation/*.md' + "README.md", + "README.*.md", + "readme.md", + "CHANGELOG.md", + "HISTORY.md", + "changelog.md", + "CONTRIBUTING.md", + "ARCHITECTURE.md", + "DESIGN.md", + "API.md", + "SECURITY.md", + "LICENSE", + "LICENSE.md", + "docs/*.md", + "documentation/*.md", ] - - docs = [] + + docs: list[str] = [] for pattern in doc_patterns: - if '*' in pattern: + if "*" in pattern: docs.extend(str(p.relative_to(root_path)) for p in root_path.glob(pattern)) elif (root_path / pattern).exists(): docs.append(pattern) - + return docs -def analyze_project(project_root: str, save_to_cache: bool = True) -> Dict[str, Any]: +def analyze_project(project_root: str, save_to_cache: bool = True) -> dict[str, Any]: """ 完整分析项目结构 - + Args: project_root: 项目根目录 save_to_cache: 是否保存到 .mini-wiki/cache/structure.json - + Returns: 项目结构数据 """ root = Path(project_root) - + # 检测项目类型 project_types = detect_project_types(root) - + # 发现入口文件 entry_points = find_entry_points(root, project_types) - + # 发现模块 modules = discover_modules(root) - + # 发现文档 docs = find_documentation(root) - + # 统计代码文件 code_files = [] for ext in CODE_EXTENSIONS: - for f in root.rglob(f'*{ext}'): + for f in root.rglob(f"*{ext}"): if not any(p in f.parts for p in IGNORE_DIRS): code_files.append(str(f.relative_to(root))) - + result = { - 'project_name': root.name, - 'project_type': project_types, - 'entry_points': entry_points, - 'modules': modules, - 'docs_found': docs, - 'stats': { - 'total_files': len(code_files), - 'total_modules': len(modules), - 'total_docs': len(docs) - }, - 'analyzed_at': datetime.now(timezone.utc).isoformat() + "project_name": root.name, + "project_type": project_types, + "entry_points": entry_points, + "modules": modules, + "docs_found": docs, + "stats": {"total_files": len(code_files), "total_modules": len(modules), "total_docs": len(docs)}, + "analyzed_at": datetime.now(timezone.utc).isoformat(), } - + # 保存到缓存 if save_to_cache: - wiki_dir = root / '.mini-wiki' + wiki_dir = root / ".mini-wiki" if wiki_dir.exists(): - cache_path = wiki_dir / 'cache' / 'structure.json' + cache_path = wiki_dir / "cache" / "structure.json" cache_path.parent.mkdir(parents=True, exist_ok=True) - with open(cache_path, 'w', encoding='utf-8') as f: - json.dump(result, f, indent=2, ensure_ascii=False) - + with open(cache_path, "w", encoding="utf-8") as output_file: + json.dump(result, output_file, indent=2, ensure_ascii=False) + return result -def print_analysis(result: Dict[str, Any]): +def print_analysis(result: dict[str, Any]): """打印分析结果""" print(f"📁 项目: {result['project_name']}") print(f"🔧 技术栈: {', '.join(result['project_type']) or '未知'}") - print(f"📊 统计: {result['stats']['total_files']} 个代码文件, " - f"{result['stats']['total_modules']} 个模块, " - f"{result['stats']['total_docs']} 个文档") - - if result['entry_points']: - print(f"\n🚀 入口文件:") - for entry in result['entry_points']: + print( + f"📊 统计: {result['stats']['total_files']} 个代码文件, " + f"{result['stats']['total_modules']} 个模块, " + f"{result['stats']['total_docs']} 个文档" + ) + + if result["entry_points"]: + print("\n🚀 入口文件:") + for entry in result["entry_points"]: print(f" - {entry}") - - if result['modules']: - print(f"\n📦 模块:") - for module in result['modules'][:10]: + + if result["modules"]: + print("\n📦 模块:") + for module in result["modules"][:10]: print(f" - {module['name']} ({module['files']} 个文件)") - - if result['docs_found']: - print(f"\n📄 现有文档:") - for doc in result['docs_found']: + + if result["docs_found"]: + print("\n📄 现有文档:") + for doc in result["docs_found"]: print(f" - {doc}") -if __name__ == '__main__': +if __name__ == "__main__": import sys - + project_path = sys.argv[1] if len(sys.argv) > 1 else os.getcwd() result = analyze_project(project_path, save_to_cache=False) print_analysis(result) diff --git a/scripts/check_quality.py b/scripts/check_quality.py index b84ed68..74a2e08 100644 --- a/scripts/check_quality.py +++ b/scripts/check_quality.py @@ -11,12 +11,13 @@ from dataclasses import dataclass, field from datetime import datetime from pathlib import Path -from typing import Dict, List, Optional +from typing import Any @dataclass class QualityMetrics: """单个文档的质量指标""" + file_path: str line_count: int = 0 section_count: int = 0 # H2 章节数 @@ -31,90 +32,83 @@ class QualityMetrics: has_performance: bool = False # 是否有性能优化章节 has_troubleshooting: bool = False # 是否有错误处理/调试章节 quality_level: str = "basic" # basic / standard / professional - issues: List[str] = field(default_factory=list) + issues: list[str] = field(default_factory=list) @dataclass class QualityReport: """质量检查报告""" + wiki_path: str check_time: str total_docs: int = 0 professional_count: int = 0 standard_count: int = 0 basic_count: int = 0 - docs: List[QualityMetrics] = field(default_factory=list) - summary_issues: List[str] = field(default_factory=list) + docs: list[QualityMetrics] = field(default_factory=list) + summary_issues: list[str] = field(default_factory=list) def analyze_document(file_path: str) -> QualityMetrics: """分析单个文档的质量""" metrics = QualityMetrics(file_path=file_path) - + try: - with open(file_path, 'r', encoding='utf-8') as f: + with open(file_path, encoding="utf-8") as f: content = f.read() - lines = content.split('\n') + lines = content.split("\n") except Exception as e: metrics.issues.append(f"无法读取文件: {e}") return metrics - + metrics.line_count = len(lines) - + # 统计 H2 章节 (##) - metrics.section_count = len(re.findall(r'^## ', content, re.MULTILINE)) - + metrics.section_count = len(re.findall(r"^## ", content, re.MULTILINE)) + # 统计 H3 章节 (###) - metrics.subsection_count = len(re.findall(r'^### ', content, re.MULTILINE)) - + metrics.subsection_count = len(re.findall(r"^### ", content, re.MULTILINE)) + # 统计 Mermaid 图表 - mermaid_blocks = re.findall(r'```mermaid[\s\S]*?```', content) + mermaid_blocks = re.findall(r"```mermaid[\s\S]*?```", content) metrics.diagram_count = len(mermaid_blocks) - + # 统计 classDiagram - metrics.class_diagram_count = len(re.findall(r'classDiagram', content)) - + metrics.class_diagram_count = len(re.findall(r"classDiagram", content)) + # 统计代码示例 (排除 mermaid) - all_code_blocks = re.findall(r'```(?!mermaid)[\s\S]*?```', content) + all_code_blocks = re.findall(r"```(?!mermaid)[\s\S]*?```", content) metrics.code_example_count = len(all_code_blocks) - + # 统计表格 - metrics.table_count = len(re.findall(r'^\|.*\|$', content, re.MULTILINE)) // 2 # 估算 - + metrics.table_count = len(re.findall(r"^\|.*\|$", content, re.MULTILINE)) // 2 # 估算 + # 统计交叉链接 (排除外部链接) - internal_links = re.findall(r'\[.*?\]\((?!http).*?\.md.*?\)', content) + internal_links = re.findall(r"\[.*?\]\((?!http).*?\.md.*?\)", content) metrics.cross_link_count = len(internal_links) - + # 检查源码追溯 - metrics.has_source_tracing = bool( - re.search(r'\*\*Section sources\*\*|\*\*Diagram sources\*\*|file://', content) - ) - + metrics.has_source_tracing = bool(re.search(r"\*\*Section sources\*\*|\*\*Diagram sources\*\*|file://", content)) + # 检查关键章节 content_lower = content.lower() - metrics.has_best_practices = bool( - re.search(r'最佳实践|best practice', content_lower) - ) - metrics.has_performance = bool( - re.search(r'性能优化|性能考量|performance', content_lower) - ) - metrics.has_troubleshooting = bool( - re.search(r'错误处理|调试|故障排除|troubleshoot|debug', content_lower) - ) - + metrics.has_best_practices = bool(re.search(r"最佳实践|best practice", content_lower)) + metrics.has_performance = bool(re.search(r"性能优化|性能考量|performance", content_lower)) + metrics.has_troubleshooting = bool(re.search(r"错误处理|调试|故障排除|troubleshoot|debug", content_lower)) + # 评估质量等级 metrics.quality_level = evaluate_quality_level(metrics) - + # 生成问题列表 metrics.issues = generate_issues(metrics) - + return metrics def evaluate_quality_level(m: QualityMetrics) -> str: """评估质量等级""" score = 0 - + # 行数评分 (400+ 为专业级) if m.line_count >= 400: score += 3 @@ -122,7 +116,7 @@ def evaluate_quality_level(m: QualityMetrics) -> str: score += 2 elif m.line_count >= 150: score += 1 - + # 章节数评分 if m.section_count >= 12: score += 3 @@ -130,7 +124,7 @@ def evaluate_quality_level(m: QualityMetrics) -> str: score += 2 elif m.section_count >= 5: score += 1 - + # 图表评分 if m.diagram_count >= 3: score += 3 @@ -138,11 +132,11 @@ def evaluate_quality_level(m: QualityMetrics) -> str: score += 2 elif m.diagram_count >= 1: score += 1 - + # classDiagram 评分 if m.class_diagram_count >= 1: score += 2 - + # 代码示例评分 if m.code_example_count >= 5: score += 3 @@ -150,11 +144,11 @@ def evaluate_quality_level(m: QualityMetrics) -> str: score += 2 elif m.code_example_count >= 1: score += 1 - + # 源码追溯评分 if m.has_source_tracing: score += 2 - + # 关键章节评分 if m.has_best_practices: score += 1 @@ -162,7 +156,7 @@ def evaluate_quality_level(m: QualityMetrics) -> str: score += 1 if m.has_troubleshooting: score += 1 - + # 最终评级 if score >= 15: return "professional" @@ -172,7 +166,7 @@ def evaluate_quality_level(m: QualityMetrics) -> str: return "basic" -def calculate_expected_metrics(file_path: str) -> Dict[str, int]: +def calculate_expected_metrics(file_path: str) -> dict[str, int]: """基于模块复杂度动态计算期望指标""" # 默认期望值(用于无法分析源码的情况) expected = { @@ -181,21 +175,21 @@ def calculate_expected_metrics(file_path: str) -> Dict[str, int]: "min_diagrams": 1, "min_examples": 2, } - + # 尝试推断模块复杂度 - file_name = os.path.basename(file_path).replace('.md', '') - + file_name = os.path.basename(file_path).replace(".md", "") + # 核心模块检测 - core_keywords = ['core', 'agent', 'editor', 'store', 'main', 'client'] + core_keywords = ["core", "agent", "editor", "store", "main", "client"] is_core = any(kw in file_name.lower() for kw in core_keywords) - + # 工具/配置模块检测 - util_keywords = ['util', 'helper', 'common', 'shared', 'constant', 'config', 'type'] + util_keywords = ["util", "helper", "common", "shared", "constant", "config", "type"] is_util = any(kw in file_name.lower() for kw in util_keywords) - + # 索引文件检测 - is_index = file_name in ['index', '_index', 'TOC', 'doc-map'] - + is_index = file_name in ["index", "_index", "TOC", "doc-map"] + if is_core: expected["min_lines"] = 200 expected["min_sections"] = 8 @@ -211,36 +205,36 @@ def calculate_expected_metrics(file_path: str) -> Dict[str, int]: expected["min_sections"] = 3 expected["min_diagrams"] = 1 expected["min_examples"] = 0 - + return expected -def generate_issues(m: QualityMetrics) -> List[str]: +def generate_issues(m: QualityMetrics) -> list[str]: """生成问题列表(基于动态期望值)""" issues = [] - + # 动态计算期望指标 expected = calculate_expected_metrics(m.file_path) - + # 基于动态期望值检查 if m.line_count < expected["min_lines"]: issues.append(f"行数不足: {m.line_count}/{expected['min_lines']} (基于模块复杂度)") - + if m.section_count < expected["min_sections"]: issues.append(f"章节数不足: {m.section_count}/{expected['min_sections']}") - + if m.diagram_count < expected["min_diagrams"]: issues.append(f"图表数不足: {m.diagram_count}/{expected['min_diagrams']}") - + if m.class_diagram_count < 1 and expected["min_diagrams"] >= 2: issues.append("核心模块缺少 classDiagram 类图") - + if m.code_example_count < expected["min_examples"]: issues.append(f"代码示例不足: {m.code_example_count}/{expected['min_examples']}") - + if not m.has_source_tracing and expected["min_lines"] >= 150: issues.append("缺少源码追溯 (Section sources)") - + # 核心模块需要更多章节 if expected["min_sections"] >= 8: if not m.has_best_practices: @@ -249,38 +243,35 @@ def generate_issues(m: QualityMetrics) -> List[str]: issues.append("核心模块缺少「性能优化」章节") if not m.has_troubleshooting: issues.append("核心模块缺少「错误处理」章节") - + if m.cross_link_count < 1: issues.append("缺少相关文档交叉链接") - + return issues def check_wiki_quality(wiki_path: str) -> QualityReport: """检查整个 Wiki 目录的质量""" - report = QualityReport( - wiki_path=wiki_path, - check_time=datetime.now().isoformat() - ) - + report = QualityReport(wiki_path=wiki_path, check_time=datetime.now().isoformat()) + wiki_dir = Path(wiki_path) / "wiki" if not wiki_dir.exists(): report.summary_issues.append(f"Wiki 目录不存在: {wiki_dir}") return report - + # 遍历所有 .md 文件 for md_file in wiki_dir.rglob("*.md"): metrics = analyze_document(str(md_file)) report.docs.append(metrics) report.total_docs += 1 - + if metrics.quality_level == "professional": report.professional_count += 1 elif metrics.quality_level == "standard": report.standard_count += 1 else: report.basic_count += 1 - + return report @@ -292,21 +283,22 @@ def print_report(report: QualityReport, verbose: bool = False): print(f"📁 Wiki 路径: {report.wiki_path}") print(f"🕐 检查时间: {report.check_time}") print() - + # 总体统计 print("## 📈 总体统计\n") - print(f"| 指标 | 数值 |") - print(f"|------|------|") + print("| 指标 | 数值 |") + print("|------|------|") print(f"| 文档总数 | {report.total_docs} |") - print(f"| 🟢 Professional | {report.professional_count} ({report.professional_count/max(1,report.total_docs)*100:.1f}%) |") - print(f"| 🟡 Standard | {report.standard_count} ({report.standard_count/max(1,report.total_docs)*100:.1f}%) |") - print(f"| 🔴 Basic | {report.basic_count} ({report.basic_count/max(1,report.total_docs)*100:.1f}%) |") + professional_percentage = report.professional_count / max(1, report.total_docs) * 100 + print(f"| 🟢 Professional | {report.professional_count} ({professional_percentage:.1f}%) |") + print(f"| 🟡 Standard | {report.standard_count} ({report.standard_count / max(1, report.total_docs) * 100:.1f}%) |") + print(f"| 🔴 Basic | {report.basic_count} ({report.basic_count / max(1, report.total_docs) * 100:.1f}%) |") print() - + # 需要改进的文档 basic_docs = [d for d in report.docs if d.quality_level == "basic"] standard_docs = [d for d in report.docs if d.quality_level == "standard"] - + if basic_docs: print("## 🔴 需要升级的文档 (Basic)\n") print("| 文档 | 行数 | 章节 | 图表 | 问题数 |") @@ -315,7 +307,7 @@ def print_report(report: QualityReport, verbose: bool = False): rel_path = os.path.basename(doc.file_path) print(f"| {rel_path} | {doc.line_count} | {doc.section_count} | {doc.diagram_count} | {len(doc.issues)} |") print() - + if standard_docs: print("## 🟡 可优化的文档 (Standard)\n") print("| 文档 | 行数 | 章节 | 图表 | 问题数 |") @@ -324,7 +316,7 @@ def print_report(report: QualityReport, verbose: bool = False): rel_path = os.path.basename(doc.file_path) print(f"| {rel_path} | {doc.line_count} | {doc.section_count} | {doc.diagram_count} | {len(doc.issues)} |") print() - + # 详细问题列表 if verbose: print("## 📋 详细问题列表\n") @@ -335,7 +327,7 @@ def print_report(report: QualityReport, verbose: bool = False): for issue in doc.issues: print(f"- ⚠️ {issue}") print() - + # 改进建议 print("## 💡 改进建议\n") if report.basic_count > 0: @@ -344,10 +336,10 @@ def print_report(report: QualityReport, verbose: bool = False): print("- 添加源码追溯 (Section sources / Diagram sources)") if not any(d.class_diagram_count > 0 for d in report.docs): print("- 为核心类添加 classDiagram 类图") - + print() print("=" * 60) - + # 返回退出码 if report.basic_count > report.total_docs * 0.5: return 2 # 超过50%是 basic,严重 @@ -359,41 +351,43 @@ def print_report(report: QualityReport, verbose: bool = False): def save_report_json(report: QualityReport, output_path: str): """保存报告为 JSON""" - data = { + data: dict[str, Any] = { "wiki_path": report.wiki_path, "check_time": report.check_time, "summary": { "total": report.total_docs, "professional": report.professional_count, "standard": report.standard_count, - "basic": report.basic_count + "basic": report.basic_count, }, - "docs": [] + "docs": [], } - + for doc in report.docs: - data["docs"].append({ - "file": doc.file_path, - "metrics": { - "lines": doc.line_count, - "sections": doc.section_count, - "diagrams": doc.diagram_count, - "class_diagrams": doc.class_diagram_count, - "code_examples": doc.code_example_count, - "tables": doc.table_count, - "cross_links": doc.cross_link_count, - "has_source_tracing": doc.has_source_tracing, - "has_best_practices": doc.has_best_practices, - "has_performance": doc.has_performance, - "has_troubleshooting": doc.has_troubleshooting - }, - "quality_level": doc.quality_level, - "issues": doc.issues - }) - - with open(output_path, 'w', encoding='utf-8') as f: + data["docs"].append( + { + "file": doc.file_path, + "metrics": { + "lines": doc.line_count, + "sections": doc.section_count, + "diagrams": doc.diagram_count, + "class_diagrams": doc.class_diagram_count, + "code_examples": doc.code_example_count, + "tables": doc.table_count, + "cross_links": doc.cross_link_count, + "has_source_tracing": doc.has_source_tracing, + "has_best_practices": doc.has_best_practices, + "has_performance": doc.has_performance, + "has_troubleshooting": doc.has_troubleshooting, + }, + "quality_level": doc.quality_level, + "issues": doc.issues, + } + ) + + with open(output_path, "w", encoding="utf-8") as f: json.dump(data, f, indent=2, ensure_ascii=False) - + print(f"📄 报告已保存到: {output_path}") @@ -406,43 +400,30 @@ def main(): python check_quality.py /path/to/project/.mini-wiki python check_quality.py . --verbose python check_quality.py . --json report.json - """ - ) - parser.add_argument( - "wiki_path", - nargs="?", - default=".mini-wiki", - help="Wiki 目录路径 (默认: .mini-wiki)" + """, ) - parser.add_argument( - "-v", "--verbose", - action="store_true", - help="显示详细问题列表" - ) - parser.add_argument( - "--json", - metavar="FILE", - help="将报告保存为 JSON 文件" - ) - + parser.add_argument("wiki_path", nargs="?", default=".mini-wiki", help="Wiki 目录路径 (默认: .mini-wiki)") + parser.add_argument("-v", "--verbose", action="store_true", help="显示详细问题列表") + parser.add_argument("--json", metavar="FILE", help="将报告保存为 JSON 文件") + args = parser.parse_args() - + # 检查路径 wiki_path = args.wiki_path if not os.path.exists(wiki_path): print(f"❌ 路径不存在: {wiki_path}") return 1 - + # 执行检查 report = check_wiki_quality(wiki_path) - + # 打印报告 exit_code = print_report(report, verbose=args.verbose) - + # 保存 JSON if args.json: save_report_json(report, args.json) - + return exit_code diff --git a/scripts/cli.py b/scripts/cli.py new file mode 100644 index 0000000..a0f3726 --- /dev/null +++ b/scripts/cli.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 +""" +Mini-Wiki CLI — Generate professional project documentation with AI. + +Usage: + mini-wiki init [--force] + mini-wiki analyze [PATH] + mini-wiki check [PATH] + mini-wiki changes [PATH] + mini-wiki plugins list [PATH] + mini-wiki plugins enable NAME [PATH] + mini-wiki plugins disable NAME [PATH] + mini-wiki plugins install SOURCE [PATH] + mini-wiki plugins uninstall NAME [PATH] +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + +import click + +from analyze_project import analyze_project, print_analysis +from check_quality import check_wiki_quality +from detect_changes import detect_changes, print_changes +from init_wiki import init_mini_wiki, print_result +from plugin_manager import ( + enable_plugin, + install_plugin, + list_plugins, + print_plugins, + uninstall_plugin, + update_plugin, +) + + +def _resolve_project(path: str | None) -> str: + if path: + return str(Path(path).resolve()) + return os.getcwd() + + +@click.group() +@click.version_option(version="3.2.0", prog_name="mini-wiki") +def main(): + """Mini-Wiki: AI-powered project documentation generator.""" + + +# --- init --- + + +@main.command() +@click.option("--force", is_flag=True, help="Force re-initialization (backs up existing config).") +@click.argument("path", required=False) +def init(force: bool, path: str | None): + """Initialize .mini-wiki directory structure.""" + project = _resolve_project(path) + result = init_mini_wiki(project, force=force) + print_result(result) + sys.exit(0 if result["success"] else 1) + + +# --- analyze --- + + +@main.command() +@click.option("--no-cache", is_flag=True, help="Don't save results to cache.") +@click.argument("path", required=False) +def analyze(no_cache: bool, path: str | None): + """Analyze project structure and tech stack.""" + project = _resolve_project(path) + result = analyze_project(project, save_to_cache=not no_cache) + print_analysis(result) + + +# --- check --- + + +@main.command() +@click.argument("path", required=False) +def check(path: str | None): + """Check documentation quality against standards.""" + project = _resolve_project(path) + wiki_dir = str(Path(project) / ".mini-wiki" / "wiki") + + if not Path(wiki_dir).exists(): + click.echo("No wiki found. Run 'mini-wiki init' first, then generate docs.") + sys.exit(1) + + report = check_wiki_quality(wiki_dir) + click.echo(f"Checked {report.total_docs} documents") + click.echo(f" Professional: {report.professional_count}") + click.echo(f" Standard: {report.standard_count}") + click.echo(f" Basic: {report.basic_count}") + + if report.summary_issues: + click.echo("\nIssues:") + for issue in report.summary_issues: + click.echo(f" - {issue}") + + +# --- changes --- + + +@main.command() +@click.argument("path", required=False) +def changes(path: str | None): + """Detect file changes since last documentation generation.""" + project = _resolve_project(path) + result = detect_changes(project) + print_changes(result) + + +# --- plugins --- + + +@main.group() +def plugins(): + """Manage Mini-Wiki plugins.""" + + +@plugins.command("list") +@click.argument("path", required=False) +def plugins_list(path: str | None): + """List installed plugins.""" + project = _resolve_project(path) + result = list_plugins(project) + print_plugins(result) + + +@plugins.command("install") +@click.argument("source") +@click.argument("path", required=False) +def plugins_install(source: str, path: str | None): + """Install a plugin from path, URL, or GitHub (owner/repo).""" + project = _resolve_project(path) + result = install_plugin(project, source) + click.echo(result["message"]) + sys.exit(0 if result["success"] else 1) + + +@plugins.command("uninstall") +@click.argument("name") +@click.argument("path", required=False) +def plugins_uninstall(name: str, path: str | None): + """Uninstall a plugin.""" + project = _resolve_project(path) + result = uninstall_plugin(project, name) + click.echo(result["message"]) + sys.exit(0 if result["success"] else 1) + + +@plugins.command("enable") +@click.argument("name") +@click.argument("path", required=False) +def plugins_enable(name: str, path: str | None): + """Enable a plugin.""" + project = _resolve_project(path) + result = enable_plugin(project, name, enabled=True) + click.echo(result["message"]) + + +@plugins.command("disable") +@click.argument("name") +@click.argument("path", required=False) +def plugins_disable(name: str, path: str | None): + """Disable a plugin.""" + project = _resolve_project(path) + result = enable_plugin(project, name, enabled=False) + click.echo(result["message"]) + + +@plugins.command("update") +@click.argument("name") +@click.argument("path", required=False) +def plugins_update(name: str, path: str | None): + """Update a plugin to the latest version.""" + project = _resolve_project(path) + result = update_plugin(project, name) + click.echo(result["message"]) + sys.exit(0 if result["success"] else 1) + + +if __name__ == "__main__": + main() diff --git a/scripts/detect_changes.py b/scripts/detect_changes.py index c19ac83..41128ba 100644 --- a/scripts/detect_changes.py +++ b/scripts/detect_changes.py @@ -4,46 +4,72 @@ 对比文件校验和,检测项目变更以支持增量更新 """ -import json import hashlib +import json import os from datetime import datetime, timezone from pathlib import Path -from typing import Any, Dict, Optional, Set +from typing import Any, cast # 默认排除规则 DEFAULT_EXCLUDES = { - 'node_modules', '.git', 'dist', 'build', '__pycache__', - '.next', '.nuxt', 'coverage', '.nyc_output', 'vendor', - 'venv', '.venv', 'env', '.mini-wiki' + "node_modules", + ".git", + "dist", + "build", + "__pycache__", + ".next", + ".nuxt", + "coverage", + ".nyc_output", + "vendor", + "venv", + ".venv", + "env", + ".mini-wiki", } # 支持的代码文件扩展名 CODE_EXTENSIONS = { - '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', - '.py', '.pyi', - '.go', '.rs', '.java', '.kt', '.scala', - '.rb', '.php', '.cs', '.fs', - '.vue', '.svelte', '.astro' + ".ts", + ".tsx", + ".js", + ".jsx", + ".mjs", + ".cjs", + ".py", + ".pyi", + ".go", + ".rs", + ".java", + ".kt", + ".scala", + ".rb", + ".php", + ".cs", + ".fs", + ".vue", + ".svelte", + ".astro", } # 文档扩展名 -DOC_EXTENSIONS = {'.md', '.mdx', '.rst', '.txt'} +DOC_EXTENSIONS = {".md", ".mdx", ".rst", ".txt"} def calculate_file_hash(file_path: str) -> str: """计算文件的 SHA256 哈希值""" sha256 = hashlib.sha256() try: - with open(file_path, 'rb') as f: - for chunk in iter(lambda: f.read(8192), b''): + with open(file_path, "rb") as f: + for chunk in iter(lambda: f.read(8192), b""): sha256.update(chunk) return sha256.hexdigest()[:16] # 只取前16位 except OSError: return "" -def should_include_file(file_path: Path, excludes: Set[str]) -> bool: +def should_include_file(file_path: Path, excludes: set[str]) -> bool: """判断文件是否应该被包含""" # 检查是否在排除目录中 for part in file_path.parts: @@ -51,55 +77,55 @@ def should_include_file(file_path: Path, excludes: Set[str]) -> bool: return False # 检查 glob 模式 for pattern in excludes: - if pattern.startswith('*') and file_path.name.endswith(pattern[1:]): + if pattern.startswith("*") and file_path.name.endswith(pattern[1:]): return False - + # 只包含代码和文档文件 return file_path.suffix in CODE_EXTENSIONS or file_path.suffix in DOC_EXTENSIONS -def scan_project_files(project_root: str, excludes: Optional[Set[str]] = None) -> Dict[str, str]: +def scan_project_files(project_root: str, excludes: set[str] | None = None) -> dict[str, str]: """ 扫描项目文件并计算校验和 - + Returns: {相对路径: 校验和} """ if excludes is None: excludes = DEFAULT_EXCLUDES - + root = Path(project_root) checksums = {} - - for file_path in root.rglob('*'): + + for file_path in root.rglob("*"): if file_path.is_file() and should_include_file(file_path, excludes): rel_path = str(file_path.relative_to(root)) checksums[rel_path] = calculate_file_hash(str(file_path)) - + return checksums -def load_cached_checksums(wiki_dir: str) -> Dict[str, Dict[str, str]]: +def load_cached_checksums(wiki_dir: str) -> dict[str, dict[str, str]]: """加载缓存的校验和""" cache_path = Path(wiki_dir) / "cache" / "checksums.json" if cache_path.exists(): - with open(cache_path, 'r', encoding='utf-8') as f: - return json.load(f) + with open(cache_path, encoding="utf-8") as f: + return cast("dict[str, dict[str, str]]", json.load(f)) return {} -def save_checksums(wiki_dir: str, checksums: Dict[str, Dict[str, str]]): +def save_checksums(wiki_dir: str, checksums: dict[str, dict[str, str]]): """保存校验和到缓存""" cache_path = Path(wiki_dir) / "cache" / "checksums.json" cache_path.parent.mkdir(parents=True, exist_ok=True) - with open(cache_path, 'w', encoding='utf-8') as f: + with open(cache_path, "w", encoding="utf-8") as f: json.dump(checksums, f, indent=2, ensure_ascii=False) -def detect_changes(project_root: str, excludes: Optional[Set[str]] = None) -> Dict[str, Any]: +def detect_changes(project_root: str, excludes: set[str] | None = None) -> dict[str, Any]: """ 检测项目变更 - + Returns: { "added": [新增的文件列表], @@ -112,32 +138,32 @@ def detect_changes(project_root: str, excludes: Optional[Set[str]] = None) -> Di """ root = Path(project_root) wiki_dir = root / ".mini-wiki" - + # 获取当前文件校验和 current_checksums = scan_project_files(project_root, excludes) - + # 加载缓存的校验和 cached = load_cached_checksums(str(wiki_dir)) - cached_checksums = {k: v.get('hash', '') for k, v in cached.items()} - + cached_checksums = {k: v.get("hash", "") for k, v in cached.items()} + current_files = set(current_checksums.keys()) cached_files = set(cached_checksums.keys()) - + # 分类变更 added = list(current_files - cached_files) deleted = list(cached_files - current_files) - + modified = [] unchanged = [] - + for file_path in current_files & cached_files: if current_checksums[file_path] != cached_checksums[file_path]: modified.append(file_path) else: unchanged.append(file_path) - + has_changes = bool(added or modified or deleted) - + summary_parts = [] if added: summary_parts.append(f"+{len(added)} 新增") @@ -147,7 +173,7 @@ def detect_changes(project_root: str, excludes: Optional[Set[str]] = None) -> Di summary_parts.append(f"-{len(deleted)} 删除") if not summary_parts: summary_parts.append("无变更") - + return { "added": sorted(added), "modified": sorted(modified), @@ -155,55 +181,56 @@ def detect_changes(project_root: str, excludes: Optional[Set[str]] = None) -> Di "unchanged": sorted(unchanged), "has_changes": has_changes, "summary": ", ".join(summary_parts), - "current_checksums": current_checksums + "current_checksums": current_checksums, } -def update_checksums_cache(project_root: str, current_checksums: Dict[str, str], - doc_mapping: Optional[Dict[str, str]] = None) -> None: +def update_checksums_cache( + project_root: str, current_checksums: dict[str, str], doc_mapping: dict[str, str] | None = None +) -> None: """ 更新校验和缓存 - + Args: project_root: 项目根目录 current_checksums: 当前文件校验和 doc_mapping: 文件到文档的映射 {源文件: 生成的文档路径} """ wiki_dir = Path(project_root) / ".mini-wiki" - + if doc_mapping is None: doc_mapping = {} - + cache_data = {} for file_path, file_hash in current_checksums.items(): cache_data[file_path] = { "hash": file_hash, "doc": doc_mapping.get(file_path, ""), - "updated_at": datetime.now(timezone.utc).isoformat() + "updated_at": datetime.now(timezone.utc).isoformat(), } - + save_checksums(str(wiki_dir), cache_data) -def print_changes(changes: Dict[str, Any]): +def print_changes(changes: dict[str, Any]): """打印变更信息""" print(f"变更检测结果: {changes['summary']}") print() - + if changes["added"]: print("📁 新增文件:") for f in changes["added"][:10]: print(f" + {f}") if len(changes["added"]) > 10: print(f" ... 还有 {len(changes['added']) - 10} 个文件") - + if changes["modified"]: print("\n📝 修改的文件:") for f in changes["modified"][:10]: print(f" ~ {f}") if len(changes["modified"]) > 10: print(f" ... 还有 {len(changes['modified']) - 10} 个文件") - + if changes["deleted"]: print("\n🗑️ 删除的文件:") for f in changes["deleted"][:10]: @@ -212,9 +239,9 @@ def print_changes(changes: Dict[str, Any]): print(f" ... 还有 {len(changes['deleted']) - 10} 个文件") -if __name__ == '__main__': +if __name__ == "__main__": import sys - + project_path = sys.argv[1] if len(sys.argv) > 1 else os.getcwd() changes = detect_changes(project_path) print_changes(changes) diff --git a/scripts/extract_docs.py b/scripts/extract_docs.py index 7128d63..126ed98 100644 --- a/scripts/extract_docs.py +++ b/scripts/extract_docs.py @@ -7,222 +7,234 @@ import re from dataclasses import dataclass from pathlib import Path -from typing import Any, Dict, List, Optional @dataclass class DocEntry: """文档条目""" + name: str type: str # 'function', 'class', 'method', 'type', 'interface' description: str - params: List[Dict[str, str]] - returns: Optional[str] - examples: List[str] + params: list[dict[str, str]] + returns: str | None + examples: list[str] line_number: int file_path: str -def extract_jsdoc(content: str, file_path: str) -> List[DocEntry]: +def extract_jsdoc(content: str, file_path: str) -> list[DocEntry]: """从 JavaScript/TypeScript 文件中提取 JSDoc 注释""" entries = [] - + # JSDoc 注释模式 - jsdoc_pattern = r'/\*\*\s*([\s\S]*?)\*/\s*(?:export\s+)?(?:async\s+)?(?:function|class|const|let|var|interface|type)\s+(\w+)' - + jsdoc_pattern = ( + r"/\*\*\s*([\s\S]*?)\*/\s*(?:export\s+)?(?:async\s+)?(?:function|class|const|let|var|interface|type)\s+(\w+)" + ) + for match in re.finditer(jsdoc_pattern, content): doc_text = match.group(1) name = match.group(2) - line_number = content[:match.start()].count('\n') + 1 - + line_number = content[: match.start()].count("\n") + 1 + # 解析描述 description_lines = [] params = [] returns = None - examples = [] - - for line in doc_text.split('\n'): - line = line.strip().lstrip('* ') - - if line.startswith('@param'): - param_match = re.match(r'@param\s+{([^}]+)}\s+(\w+)\s*-?\s*(.*)', line) + examples: list[str] = [] + + for line in doc_text.split("\n"): + line = line.strip().lstrip("* ") + + if line.startswith("@param"): + param_match = re.match(r"@param\s+{([^}]+)}\s+(\w+)\s*-?\s*(.*)", line) if param_match: - params.append({ - 'type': param_match.group(1), - 'name': param_match.group(2), - 'description': param_match.group(3) - }) - elif line.startswith('@returns') or line.startswith('@return'): - return_match = re.match(r'@returns?\s+{([^}]+)}\s*(.*)', line) + params.append( + { + "type": param_match.group(1), + "name": param_match.group(2), + "description": param_match.group(3), + } + ) + elif line.startswith("@returns") or line.startswith("@return"): + return_match = re.match(r"@returns?\s+{([^}]+)}\s*(.*)", line) if return_match: returns = f"{return_match.group(1)}: {return_match.group(2)}" - elif line.startswith('@example'): + elif line.startswith("@example"): # 收集示例代码直到下一个 @ 标签 continue - elif not line.startswith('@'): + elif not line.startswith("@"): description_lines.append(line) - - description = ' '.join(description_lines).strip() - + + description = " ".join(description_lines).strip() + # 确定类型 - if 'class' in match.group(0).lower(): - entry_type = 'class' - elif 'interface' in match.group(0).lower(): - entry_type = 'interface' - elif 'type' in match.group(0): - entry_type = 'type' + if "class" in match.group(0).lower(): + entry_type = "class" + elif "interface" in match.group(0).lower(): + entry_type = "interface" + elif "type" in match.group(0): + entry_type = "type" else: - entry_type = 'function' - - entries.append(DocEntry( - name=name, - type=entry_type, - description=description, - params=params, - returns=returns, - examples=examples, - line_number=line_number, - file_path=file_path - )) - + entry_type = "function" + + entries.append( + DocEntry( + name=name, + type=entry_type, + description=description, + params=params, + returns=returns, + examples=examples, + line_number=line_number, + file_path=file_path, + ) + ) + return entries -def extract_python_docstring(content: str, file_path: str) -> List[DocEntry]: +def extract_python_docstring(content: str, file_path: str) -> list[DocEntry]: """从 Python 文件中提取 DocString""" entries = [] - + # 函数/类定义模式 - def_pattern = r'(?:^|\n)((?:async\s+)?def|class)\s+(\w+)[^:]*:\s*(?:\n\s+)?(?:"""([\s\S]*?)"""|\'\'\'([\s\S]*?)\'\'\')' - + def_pattern = ( + r'(?:^|\n)((?:async\s+)?def|class)\s+(\w+)[^:]*:\s*(?:\n\s+)?(?:"""([\s\S]*?)"""|\'\'\'([\s\S]*?)\'\'\')' + ) + for match in re.finditer(def_pattern, content): - def_type = 'function' if 'def' in match.group(1) else 'class' + def_type = "function" if "def" in match.group(1) else "class" name = match.group(2) - docstring = match.group(3) or match.group(4) or '' - line_number = content[:match.start()].count('\n') + 1 - + docstring = match.group(3) or match.group(4) or "" + line_number = content[: match.start()].count("\n") + 1 + # 解析 Google/NumPy 风格 docstring description_lines = [] params = [] returns = None - examples = [] - - current_section = 'description' - - for line in docstring.split('\n'): + examples: list[str] = [] + + current_section = "description" + + for line in docstring.split("\n"): stripped = line.strip() - - if stripped in ('Args:', 'Arguments:', 'Parameters:'): - current_section = 'params' + + if stripped in ("Args:", "Arguments:", "Parameters:"): + current_section = "params" continue - elif stripped in ('Returns:', 'Return:'): - current_section = 'returns' + elif stripped in ("Returns:", "Return:"): + current_section = "returns" continue - elif stripped in ('Example:', 'Examples:'): - current_section = 'examples' + elif stripped in ("Example:", "Examples:"): + current_section = "examples" continue - elif stripped.endswith(':') and not ':' in stripped[:-1]: - current_section = 'other' + elif stripped.endswith(":") and ":" not in stripped[:-1]: + current_section = "other" continue - - if current_section == 'description': + + if current_section == "description": description_lines.append(stripped) - elif current_section == 'params': - param_match = re.match(r'(\w+)\s*(?:\(([^)]+)\))?\s*:\s*(.*)', stripped) + elif current_section == "params": + param_match = re.match(r"(\w+)\s*(?:\(([^)]+)\))?\s*:\s*(.*)", stripped) if param_match: - params.append({ - 'name': param_match.group(1), - 'type': param_match.group(2) or 'Any', - 'description': param_match.group(3) - }) - elif current_section == 'returns': + params.append( + { + "name": param_match.group(1), + "type": param_match.group(2) or "Any", + "description": param_match.group(3), + } + ) + elif current_section == "returns": returns = stripped - elif current_section == 'examples': + elif current_section == "examples": examples.append(stripped) - - description = ' '.join(description_lines).strip() - - entries.append(DocEntry( - name=name, - type=def_type, - description=description, - params=params, - returns=returns, - examples=examples, - line_number=line_number, - file_path=file_path - )) - + + description = " ".join(description_lines).strip() + + entries.append( + DocEntry( + name=name, + type=def_type, + description=description, + params=params, + returns=returns, + examples=examples, + line_number=line_number, + file_path=file_path, + ) + ) + return entries -def extract_docs_from_file(file_path: str) -> List[DocEntry]: +def extract_docs_from_file(file_path: str) -> list[DocEntry]: """从文件中提取文档""" path = Path(file_path) - + if not path.exists(): return [] - - with open(path, 'r', encoding='utf-8', errors='ignore') as f: + + with open(path, encoding="utf-8", errors="ignore") as f: content = f.read() - + suffix = path.suffix.lower() - - if suffix in {'.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs'}: + + if suffix in {".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs"}: return extract_jsdoc(content, file_path) - elif suffix in {'.py', '.pyi'}: + elif suffix in {".py", ".pyi"}: return extract_python_docstring(content, file_path) - + return [] -def docs_to_markdown(entries: List[DocEntry]) -> str: +def docs_to_markdown(entries: list[DocEntry]) -> str: """将文档条目转换为 Markdown""" lines = [] - + # 按类型分组 - functions = [e for e in entries if e.type == 'function'] - classes = [e for e in entries if e.type == 'class'] - types = [e for e in entries if e.type in {'type', 'interface'}] - + functions = [e for e in entries if e.type == "function"] + classes = [e for e in entries if e.type == "class"] + types = [e for e in entries if e.type in {"type", "interface"}] + if functions: - lines.append('## 函数\n') + lines.append("## 函数\n") for func in functions: - lines.append(f'### `{func.name}`\n') - lines.append(f'{func.description}\n') - + lines.append(f"### `{func.name}`\n") + lines.append(f"{func.description}\n") + if func.params: - lines.append('**参数:**\n') + lines.append("**参数:**\n") for param in func.params: lines.append(f"- `{param['name']}` ({param['type']}): {param['description']}") - lines.append('') - + lines.append("") + if func.returns: - lines.append(f'**返回值:** {func.returns}\n') - + lines.append(f"**返回值:** {func.returns}\n") + if classes: - lines.append('## 类\n') + lines.append("## 类\n") for cls in classes: - lines.append(f'### `{cls.name}`\n') - lines.append(f'{cls.description}\n') - + lines.append(f"### `{cls.name}`\n") + lines.append(f"{cls.description}\n") + if types: - lines.append('## 类型定义\n') + lines.append("## 类型定义\n") for t in types: - lines.append(f'### `{t.name}`\n') - lines.append(f'{t.description}\n') - - return '\n'.join(lines) + lines.append(f"### `{t.name}`\n") + lines.append(f"{t.description}\n") + return "\n".join(lines) -if __name__ == '__main__': + +if __name__ == "__main__": import sys - + if len(sys.argv) < 2: print("用法: python extract_docs.py <文件路径>") sys.exit(1) - + file_path = sys.argv[1] entries = extract_docs_from_file(file_path) - + print(docs_to_markdown(entries)) diff --git a/scripts/generate_diagram.py b/scripts/generate_diagram.py index 8e0baad..5f645c5 100644 --- a/scripts/generate_diagram.py +++ b/scripts/generate_diagram.py @@ -7,218 +7,219 @@ import json import re from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import Any, cast -def generate_architecture_diagram(structure: Dict[str, Any]) -> str: +def generate_architecture_diagram(structure: dict[str, Any]) -> str: """ 生成项目架构图 - + Args: structure: 项目结构数据 (来自 structure.json) - + Returns: Mermaid 图表代码 """ - modules = structure.get('modules', []) - project_type = structure.get('project_type', []) - - lines = ['```mermaid', 'flowchart TB'] - + modules = structure.get("modules", []) + project_type = structure.get("project_type", []) + + lines = ["```mermaid", "flowchart TB"] + # 添加子图 - if 'nodejs' in project_type or 'typescript' in project_type: + if "nodejs" in project_type or "typescript" in project_type: lines.append(' subgraph Frontend["前端层"]') - frontend_modules = [m for m in modules if any(p in m.get('path', '') - for p in ['components', 'pages', 'views', 'ui'])] + frontend_modules = [ + m for m in modules if any(p in m.get("path", "") for p in ["components", "pages", "views", "ui"]) + ] for m in frontend_modules[:5]: - safe_name = re.sub(r'[^a-zA-Z0-9]', '', m['name']) + safe_name = re.sub(r"[^a-zA-Z0-9]", "", m["name"]) lines.append(f' {safe_name}["{m["name"]}"]') if not frontend_modules: lines.append(' UI["用户界面"]') - lines.append(' end') - lines.append('') - + lines.append(" end") + lines.append("") + lines.append(' subgraph Core["核心层"]') - core_modules = [m for m in modules if any(p in m.get('path', '') - for p in ['core', 'lib', 'services', 'api', 'src']) - and not any(p in m.get('path', '') - for p in ['components', 'pages', 'views', 'ui', 'utils'])] + core_modules = [ + m + for m in modules + if any(p in m.get("path", "") for p in ["core", "lib", "services", "api", "src"]) + and not any(p in m.get("path", "") for p in ["components", "pages", "views", "ui", "utils"]) + ] for m in core_modules[:5]: - safe_name = re.sub(r'[^a-zA-Z0-9]', '', m['name']) + safe_name = re.sub(r"[^a-zA-Z0-9]", "", m["name"]) lines.append(f' {safe_name}["{m["name"]}"]') if not core_modules: lines.append(' Logic["业务逻辑"]') - lines.append(' end') - lines.append('') - + lines.append(" end") + lines.append("") + lines.append(' subgraph Utils["工具层"]') - util_modules = [m for m in modules if any(p in m.get('path', '') - for p in ['utils', 'helpers', 'common', 'shared'])] + util_modules = [m for m in modules if any(p in m.get("path", "") for p in ["utils", "helpers", "common", "shared"])] for m in util_modules[:3]: - safe_name = re.sub(r'[^a-zA-Z0-9]', '', m['name']) + safe_name = re.sub(r"[^a-zA-Z0-9]", "", m["name"]) lines.append(f' {safe_name}["{m["name"]}"]') if not util_modules: lines.append(' Utilities["工具函数"]') - lines.append(' end') - lines.append('') - + lines.append(" end") + lines.append("") + # 添加连接 - lines.append(' Frontend --> Core') - lines.append(' Core --> Utils') - - lines.append('```') - return '\n'.join(lines) + lines.append(" Frontend --> Core") + lines.append(" Core --> Utils") + + lines.append("```") + return "\n".join(lines) -def generate_module_dependency_diagram(module_name: str, dependencies: Dict[str, List[str]]) -> str: +def generate_module_dependency_diagram(module_name: str, dependencies: dict[str, list[str]]) -> str: """ 生成模块依赖关系图 - + Args: module_name: 模块名称 dependencies: 依赖关系 {"internal": [...], "external": [...]} - + Returns: Mermaid 图表代码 """ - lines = ['```mermaid', 'graph LR'] - - safe_name = re.sub(r'[^a-zA-Z0-9]', '', module_name) + lines = ["```mermaid", "graph LR"] + + safe_name = re.sub(r"[^a-zA-Z0-9]", "", module_name) lines.append(f' {safe_name}["{module_name}"]') - + # 内部依赖 - internal = dependencies.get('internal', []) + internal = dependencies.get("internal", []) for i, dep in enumerate(internal[:8]): dep_name = Path(dep).stem - safe_dep = re.sub(r'[^a-zA-Z0-9]', '', dep_name) + str(i) + safe_dep = re.sub(r"[^a-zA-Z0-9]", "", dep_name) + str(i) lines.append(f' {safe_name} --> {safe_dep}["{dep_name}"]') - + # 外部依赖 - external = dependencies.get('external', []) + external = dependencies.get("external", []) if external: lines.append(f' {safe_name} --> ext["外部依赖"]') for i, dep in enumerate(external[:5]): - safe_dep = re.sub(r'[^a-zA-Z0-9]', '', dep) + 'ext' + str(i) + safe_dep = re.sub(r"[^a-zA-Z0-9]", "", dep) + "ext" + str(i) lines.append(f' ext --> {safe_dep}["{dep}"]') - - lines.append('```') - return '\n'.join(lines) + lines.append("```") + return "\n".join(lines) -def generate_file_tree_diagram(structure: Dict[str, Any], max_depth: int = 3) -> str: + +def generate_file_tree_diagram(structure: dict[str, Any], max_depth: int = 3) -> str: """ 生成目录结构图 - + Args: structure: 项目结构数据 max_depth: 最大深度 - + Returns: Mermaid 图表代码 (使用 mindmap) """ - modules = structure.get('modules', []) - - lines = ['```mermaid', 'mindmap', ' root((项目))'] - + modules = structure.get("modules", []) + + lines = ["```mermaid", "mindmap", " root((项目))"] + for module in modules[:10]: - name = module.get('name', 'unnamed') - path = module.get('path', '') - files_count = module.get('files', 0) - lines.append(f' {name}') - lines.append(f' {files_count} 个文件') - - lines.append('```') - return '\n'.join(lines) + name = module.get("name", "unnamed") + files_count = module.get("files", 0) + lines.append(f" {name}") + lines.append(f" {files_count} 个文件") + + lines.append("```") + return "\n".join(lines) -def generate_data_flow_diagram(entry_points: List[str], modules: List[Dict]) -> str: +def generate_data_flow_diagram(entry_points: list[str], modules: list[dict]) -> str: """ 生成数据流序列图 - + Args: entry_points: 入口文件列表 modules: 模块列表 - + Returns: Mermaid 序列图代码 """ - lines = ['```mermaid', 'sequenceDiagram'] - - lines.append(' participant U as 用户') - lines.append(' participant E as 入口') - + lines = ["```mermaid", "sequenceDiagram"] + + lines.append(" participant U as 用户") + lines.append(" participant E as 入口") + if modules: for i, module in enumerate(modules[:3]): - name = module.get('name', f'Module{i}') - safe_name = re.sub(r'[^a-zA-Z0-9]', '', name) - lines.append(f' participant {safe_name} as {name}') - - lines.append('') - lines.append(' U->>E: 请求') - + name = module.get("name", f"Module{i}") + safe_name = re.sub(r"[^a-zA-Z0-9]", "", name) + lines.append(f" participant {safe_name} as {name}") + + lines.append("") + lines.append(" U->>E: 请求") + if modules: - prev = 'E' + prev = "E" for i, module in enumerate(modules[:3]): - name = module.get('name', f'Module{i}') - safe_name = re.sub(r'[^a-zA-Z0-9]', '', name) - lines.append(f' {prev}->>{safe_name}: 调用') + name = module.get("name", f"Module{i}") + safe_name = re.sub(r"[^a-zA-Z0-9]", "", name) + lines.append(f" {prev}->>{safe_name}: 调用") prev = safe_name - lines.append(f' {prev}-->>U: 响应') + lines.append(f" {prev}-->>U: 响应") else: - lines.append(' E-->>U: 响应') - - lines.append('```') - return '\n'.join(lines) + lines.append(" E-->>U: 响应") + + lines.append("```") + return "\n".join(lines) -def generate_class_diagram(classes: List[Dict[str, Any]]) -> str: +def generate_class_diagram(classes: list[dict[str, Any]]) -> str: """ 生成类图 - + Args: classes: 类信息列表 [{"name": "ClassName", "methods": [...], "properties": [...]}] - + Returns: Mermaid 类图代码 """ - lines = ['```mermaid', 'classDiagram'] - + lines = ["```mermaid", "classDiagram"] + for cls in classes[:10]: - name = cls.get('name', 'Unknown') - safe_name = re.sub(r'[^a-zA-Z0-9]', '', name) - lines.append(f' class {safe_name} {{') - - for prop in cls.get('properties', [])[:5]: - lines.append(f' +{prop}') - - for method in cls.get('methods', [])[:5]: - lines.append(f' +{method}()') - - lines.append(' }') - - lines.append('```') - return '\n'.join(lines) - - -def load_structure(wiki_dir: str) -> Optional[Dict[str, Any]]: + name = cls.get("name", "Unknown") + safe_name = re.sub(r"[^a-zA-Z0-9]", "", name) + lines.append(f" class {safe_name} {{") + + for prop in cls.get("properties", [])[:5]: + lines.append(f" +{prop}") + + for method in cls.get("methods", [])[:5]: + lines.append(f" +{method}()") + + lines.append(" }") + + lines.append("```") + return "\n".join(lines) + + +def load_structure(wiki_dir: str) -> dict[str, Any] | None: """加载项目结构数据""" structure_path = Path(wiki_dir) / "cache" / "structure.json" if structure_path.exists(): - with open(structure_path, 'r', encoding='utf-8') as f: - return json.load(f) + with open(structure_path, encoding="utf-8") as f: + return cast("dict[str, Any]", json.load(f)) return None -if __name__ == '__main__': +if __name__ == "__main__": import sys - + if len(sys.argv) < 2: print("用法: python generate_diagram.py <.mini-wiki目录>") sys.exit(1) - + wiki_dir = sys.argv[1] structure = load_structure(wiki_dir) - + if structure: print("=== 架构图 ===") print(generate_architecture_diagram(structure)) diff --git a/scripts/generate_toc.py b/scripts/generate_toc.py index aee8bb7..f55ae08 100644 --- a/scripts/generate_toc.py +++ b/scripts/generate_toc.py @@ -5,127 +5,122 @@ """ import json -import os from pathlib import Path -from typing import Any, Dict, List def extract_title_from_markdown(file_path: str) -> str: """从 Markdown 文件中提取标题""" try: - with open(file_path, 'r', encoding='utf-8') as f: + with open(file_path, encoding="utf-8") as f: for line in f: line = line.strip() - if line.startswith('# '): + if line.startswith("# "): return line[2:].strip() # 如果没有找到标题,使用文件名 - return Path(file_path).stem.replace('-', ' ').replace('_', ' ').title() + return Path(file_path).stem.replace("-", " ").replace("_", " ").title() except OSError: return Path(file_path).stem -def generate_toc(wiki_dir: str, base_url: str = '') -> str: +def generate_toc(wiki_dir: str, base_url: str = "") -> str: """生成目录结构的 Markdown""" wiki_path = Path(wiki_dir) - + if not wiki_path.exists(): return "目录为空" - - toc_lines = ['# 目录\n'] - + + toc_lines = ["# 目录\n"] + # 主要文档 main_docs = [ - ('index.md', '首页'), - ('getting-started.md', '快速开始'), - ('architecture.md', '架构概览'), - ('configuration.md', '配置说明'), - ('changelog.md', '更新日志'), + ("index.md", "首页"), + ("getting-started.md", "快速开始"), + ("architecture.md", "架构概览"), + ("configuration.md", "配置说明"), + ("changelog.md", "更新日志"), ] - + for filename, default_title in main_docs: file_path = wiki_path / filename if file_path.exists(): title = extract_title_from_markdown(str(file_path)) or default_title - toc_lines.append(f'- [{title}]({base_url}{filename})') - - toc_lines.append('') - + toc_lines.append(f"- [{title}]({base_url}{filename})") + + toc_lines.append("") + # 模块文档 - modules_dir = wiki_path / 'modules' + modules_dir = wiki_path / "modules" if modules_dir.exists(): - toc_lines.append('## 模块文档\n') - for md_file in sorted(modules_dir.glob('*.md')): - if md_file.name != 'index.md': + toc_lines.append("## 模块文档\n") + for md_file in sorted(modules_dir.glob("*.md")): + if md_file.name != "index.md": title = extract_title_from_markdown(str(md_file)) - toc_lines.append(f'- [{title}]({base_url}modules/{md_file.name})') - toc_lines.append('') - + toc_lines.append(f"- [{title}]({base_url}modules/{md_file.name})") + toc_lines.append("") + # API 文档 - api_dir = wiki_path / 'api' + api_dir = wiki_path / "api" if api_dir.exists(): - toc_lines.append('## API 参考\n') - for md_file in sorted(api_dir.glob('*.md')): - if md_file.name != 'index.md': + toc_lines.append("## API 参考\n") + for md_file in sorted(api_dir.glob("*.md")): + if md_file.name != "index.md": title = extract_title_from_markdown(str(md_file)) - toc_lines.append(f'- [{title}]({base_url}api/{md_file.name})') - toc_lines.append('') - + toc_lines.append(f"- [{title}]({base_url}api/{md_file.name})") + toc_lines.append("") + # 指南文档 - guides_dir = wiki_path / 'guides' + guides_dir = wiki_path / "guides" if guides_dir.exists(): - toc_lines.append('## 使用指南\n') - for md_file in sorted(guides_dir.glob('*.md')): + toc_lines.append("## 使用指南\n") + for md_file in sorted(guides_dir.glob("*.md")): title = extract_title_from_markdown(str(md_file)) - toc_lines.append(f'- [{title}]({base_url}guides/{md_file.name})') - toc_lines.append('') - + toc_lines.append(f"- [{title}]({base_url}guides/{md_file.name})") + toc_lines.append("") + # 设计文档 - design_dir = wiki_path / 'design' + design_dir = wiki_path / "design" if design_dir.exists(): - toc_lines.append('## 设计文档\n') - for md_file in sorted(design_dir.glob('*.md')): + toc_lines.append("## 设计文档\n") + for md_file in sorted(design_dir.glob("*.md")): title = extract_title_from_markdown(str(md_file)) - toc_lines.append(f'- [{title}]({base_url}design/{md_file.name})') - - return '\n'.join(toc_lines) + toc_lines.append(f"- [{title}]({base_url}design/{md_file.name})") + + return "\n".join(toc_lines) def generate_sidebar(wiki_dir: str) -> str: """生成侧边栏导航 (适用于 GitHub Wiki 或 VuePress)""" wiki_path = Path(wiki_dir) - + sidebar = { - '/': [ - {'text': '首页', 'link': '/'}, - {'text': '快速开始', 'link': '/getting-started'}, - {'text': '架构概览', 'link': '/architecture'}, + "/": [ + {"text": "首页", "link": "/"}, + {"text": "快速开始", "link": "/getting-started"}, + {"text": "架构概览", "link": "/architecture"}, ] } - + # 添加模块 - modules_dir = wiki_path / 'modules' + modules_dir = wiki_path / "modules" if modules_dir.exists(): module_items = [] - for md_file in sorted(modules_dir.glob('*.md')): - if md_file.name != 'index.md': + for md_file in sorted(modules_dir.glob("*.md")): + if md_file.name != "index.md": title = extract_title_from_markdown(str(md_file)) - module_items.append({ - 'text': title, - 'link': f'/modules/{md_file.stem}' - }) + module_items.append({"text": title, "link": f"/modules/{md_file.stem}"}) if module_items: - sidebar['/modules/'] = module_items - + sidebar["/modules/"] = module_items + # 生成 JSON 格式 return json.dumps(sidebar, indent=2, ensure_ascii=False) -if __name__ == '__main__': +if __name__ == "__main__": import sys - + if len(sys.argv) < 2: print("用法: python generate_toc.py ") sys.exit(1) - + wiki_dir = sys.argv[1] print(generate_toc(wiki_dir)) diff --git a/scripts/init_wiki.py b/scripts/init_wiki.py index 3aacacd..6adf750 100644 --- a/scripts/init_wiki.py +++ b/scripts/init_wiki.py @@ -9,12 +9,12 @@ import shutil from datetime import datetime, timezone from pathlib import Path -from typing import Optional +from typing import Any def get_default_config() -> str: """返回默认配置文件内容""" - return '''# Mini-Wiki 配置文件 + return """# Mini-Wiki 配置文件 # 生成选项 generation: @@ -34,41 +34,36 @@ def get_default_config() -> str: - __pycache__ - venv - .venv -''' +""" -def get_default_meta() -> dict: +def get_default_meta() -> dict[str, Any]: """返回默认元数据""" return { "version": "2.0.0", "created_at": datetime.now(timezone.utc).isoformat(), "last_updated": None, "files_documented": 0, - "modules_count": 0 + "modules_count": 0, } -def init_mini_wiki(project_root: str, force: bool = False) -> dict: +def init_mini_wiki(project_root: str, force: bool = False) -> dict[str, Any]: """ 初始化 .mini-wiki 目录 - + Args: project_root: 项目根目录 force: 是否强制重新初始化 - + Returns: 初始化结果 """ root = Path(project_root) wiki_dir = root / ".mini-wiki" - - result = { - "success": True, - "created": [], - "skipped": [], - "message": "" - } - + + result: dict[str, Any] = {"success": True, "created": [], "skipped": [], "message": ""} + # 检查是否已存在 if wiki_dir.exists(): if not force: @@ -82,7 +77,7 @@ def init_mini_wiki(project_root: str, force: bool = False) -> dict: backup_path = wiki_dir / "config.yaml.bak" shutil.copy(config_path, backup_path) result["skipped"].append("config.yaml (已备份)") - + # 创建目录结构 directories = [ ".mini-wiki", @@ -95,57 +90,52 @@ def init_mini_wiki(project_root: str, force: bool = False) -> dict: ".mini-wiki/i18n/en", ".mini-wiki/i18n/zh", ] - + for dir_path in directories: full_path = root / dir_path if not full_path.exists(): full_path.mkdir(parents=True, exist_ok=True) result["created"].append(dir_path) - + # 创建配置文件 config_path = wiki_dir / "config.yaml" if not config_path.exists() or force: - with open(config_path, 'w', encoding='utf-8') as f: + with open(config_path, "w", encoding="utf-8") as f: f.write(get_default_config()) result["created"].append("config.yaml") - + # 创建元数据文件 meta_path = wiki_dir / "meta.json" if not meta_path.exists() or force: - with open(meta_path, 'w', encoding='utf-8') as f: + with open(meta_path, "w", encoding="utf-8") as f: json.dump(get_default_meta(), f, indent=2, ensure_ascii=False) result["created"].append("meta.json") - + # 创建空的缓存文件 - cache_files = { + cache_files: dict[str, dict[str, Any]] = { "cache/checksums.json": {}, - "cache/structure.json": { - "project_type": [], - "entry_points": [], - "modules": [], - "docs_found": [] - } + "cache/structure.json": {"project_type": [], "entry_points": [], "modules": [], "docs_found": []}, } - + for cache_file, default_content in cache_files.items(): cache_path = wiki_dir / cache_file if not cache_path.exists(): - with open(cache_path, 'w', encoding='utf-8') as f: + with open(cache_path, "w", encoding="utf-8") as f: json.dump(default_content, f, indent=2, ensure_ascii=False) result["created"].append(cache_file) - + # 创建 .gitignore gitignore_path = wiki_dir / ".gitignore" if not gitignore_path.exists(): - with open(gitignore_path, 'w', encoding='utf-8') as f: + with open(gitignore_path, "w", encoding="utf-8") as f: f.write("cache/\n*.bak\n") result["created"].append(".gitignore") - + result["message"] = f"成功初始化 .mini-wiki 目录,创建了 {len(result['created'])} 个文件/目录" return result -def print_result(result: dict): +def print_result(result: dict[str, Any]): """打印初始化结果""" if result["success"]: print("✅", result["message"]) @@ -161,11 +151,11 @@ def print_result(result: dict): print("❌", result["message"]) -if __name__ == '__main__': +if __name__ == "__main__": import sys - + project_path = sys.argv[1] if len(sys.argv) > 1 else os.getcwd() - force = '--force' in sys.argv - + force = "--force" in sys.argv + result = init_mini_wiki(project_path, force) print_result(result) diff --git a/scripts/plugin_manager.py b/scripts/plugin_manager.py index bd61c12..bcc3e2b 100644 --- a/scripts/plugin_manager.py +++ b/scripts/plugin_manager.py @@ -9,14 +9,15 @@ import re import shutil import sys +import urllib.request import zipfile from datetime import datetime from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import Any, cast -import urllib.request import yaml + def get_plugins_dir(project_root: str) -> Path: """Get the plugins directory path.""" return Path(project_root) / "plugins" @@ -27,155 +28,158 @@ def get_registry_path(project_root: str) -> Path: return get_plugins_dir(project_root) / "_registry.yaml" -def load_registry(project_root: str) -> Dict[str, Any]: +def load_registry(project_root: str) -> dict[str, Any]: """Load the plugin registry.""" registry_path = get_registry_path(project_root) if registry_path.exists(): - with open(registry_path, 'r', encoding='utf-8') as f: - return yaml.safe_load(f) or {'plugins': []} - return {'plugins': []} + with open(registry_path, encoding="utf-8") as f: + return yaml.safe_load(f) or {"plugins": []} + return {"plugins": []} -def save_registry(project_root: str, registry: Dict[str, Any]): +def save_registry(project_root: str, registry: dict[str, Any]): """Save the plugin registry.""" registry_path = get_registry_path(project_root) registry_path.parent.mkdir(parents=True, exist_ok=True) - with open(registry_path, 'w', encoding='utf-8') as f: + with open(registry_path, "w", encoding="utf-8") as f: yaml.dump(registry, f, default_flow_style=False, allow_unicode=True) -def parse_plugin_manifest(plugin_path: Path) -> Optional[Dict[str, Any]]: +def parse_plugin_manifest(plugin_path: Path) -> dict[str, Any] | None: """Parse PLUGIN.md frontmatter.""" manifest_path = plugin_path / "PLUGIN.md" if not manifest_path.exists(): return None - - with open(manifest_path, 'r', encoding='utf-8') as f: + + with open(manifest_path, encoding="utf-8") as f: content = f.read() - + # Extract YAML frontmatter - match = re.match(r'^---\s*\n(.*?)\n---', content, re.DOTALL) + match = re.match(r"^---\s*\n(.*?)\n---", content, re.DOTALL) if match: try: - return yaml.safe_load(match.group(1)) + data = yaml.safe_load(match.group(1)) + return cast("dict[str, Any]", data) if isinstance(data, dict) else None except yaml.YAMLError: return None return None -def list_plugins(project_root: str) -> List[Dict[str, Any]]: +def list_plugins(project_root: str) -> list[dict[str, Any]]: """List all installed plugins.""" plugins_dir = get_plugins_dir(project_root) registry = load_registry(project_root) - - plugins = [] - + + plugins: list[dict[str, Any]] = [] + if not plugins_dir.exists(): return plugins - + for item in plugins_dir.iterdir(): - if item.is_dir() and not item.name.startswith('_'): + if item.is_dir() and not item.name.startswith("_"): manifest = parse_plugin_manifest(item) if manifest: # Check if enabled in registry - reg_entry = next( - (e for e in registry.get('plugins', []) if e.get('name') == manifest['name']), - None + reg_entry = next((e for e in registry.get("plugins", []) if e.get("name") == manifest["name"]), None) + plugins.append( + { + **manifest, + "path": str(item), + "enabled": reg_entry.get("enabled", True) if reg_entry else True, + "priority": reg_entry.get("priority", 100) if reg_entry else 100, + } ) - plugins.append({ - **manifest, - 'path': str(item), - 'enabled': reg_entry.get('enabled', True) if reg_entry else True, - 'priority': reg_entry.get('priority', 100) if reg_entry else 100 - }) - - return sorted(plugins, key=lambda x: x.get('priority', 100)) + + return sorted(plugins, key=lambda x: x.get("priority", 100)) -def install_plugin(project_root: str, source: str) -> Dict[str, Any]: +def install_plugin(project_root: str, source: str) -> dict[str, Any]: """ Install an plugin from a path or URL. - + Args: project_root: Project root directory source: Path to plugin directory, .zip file, or URL - + Returns: Result dict with success status and message """ plugins_dir = get_plugins_dir(project_root) plugins_dir.mkdir(parents=True, exist_ok=True) - - result = {'success': False, 'message': '', 'name': None} - + + result = {"success": False, "message": "", "name": None} + try: # Handle GitHub shorthand (owner/repo) - if re.match(r'^[a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+$', source): + if re.match(r"^[a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+$", source): source = f"https://github.com/{source}/archive/refs/heads/main.zip" # Fallback to master if main fails? For now let's assume main or let urllib fail. # We could also try API but let's keep it simple. # Handle URL - if source.startswith('http://') or source.startswith('https://'): + if source.startswith("http://") or source.startswith("https://"): # Download to temp file - temp_zip = plugins_dir / '_temp.zip' + temp_zip = plugins_dir / "_temp.zip" print(f"Downloading from {source}...") # Helper to download with user agent - req = urllib.request.Request(source, headers={'User-Agent': 'Mini-Wiki-Plugin-Manager'}) - with urllib.request.urlopen(req) as response, open(temp_zip, 'wb') as out_file: + req = urllib.request.Request(source, headers={"User-Agent": "Mini-Wiki-Plugin-Manager"}) + with urllib.request.urlopen(req) as response, open(temp_zip, "wb") as out_file: shutil.copyfileobj(response, out_file) source = str(temp_zip) - + source_path = Path(source) - + # Handle zip file - if source_path.suffix == '.zip' or source_path.suffix == '.skill': - with zipfile.ZipFile(source_path, 'r') as zf: + if source_path.suffix == ".zip" or source_path.suffix == ".skill": + with zipfile.ZipFile(source_path, "r") as zf: # Extract to temp directory - temp_dir = plugins_dir / '_temp_extract' + temp_dir = plugins_dir / "_temp_extract" if temp_dir.exists(): shutil.rmtree(temp_dir) temp_dir.mkdir(parents=True, exist_ok=True) - + zf.extractall(temp_dir) - + # Smart find: look for a directory containing PLUGIN.md or SKILL.md found_root = None - + # Check root first - if (temp_dir / 'PLUGIN.md').exists() or (temp_dir / 'SKILL.md').exists(): + if (temp_dir / "PLUGIN.md").exists() or (temp_dir / "SKILL.md").exists(): found_root = temp_dir - + # Check first level subdirs (common in github zips: repo-main/) if not found_root: for item in temp_dir.iterdir(): - if item.is_dir(): - if (item / 'PLUGIN.md').exists() or (item / 'SKILL.md').exists() or (item / 'README.md').exists(): - found_root = item - break - + if item.is_dir() and ( + (item / "PLUGIN.md").exists() + or (item / "SKILL.md").exists() + or (item / "README.md").exists() + ): + found_root = item + break + source_path = found_root if found_root else temp_dir - + # Detect functionality - has_manifest = (source_path / 'PLUGIN.md').exists() - has_skill = (source_path / 'SKILL.md').exists() - + has_manifest = (source_path / "PLUGIN.md").exists() + has_skill = (source_path / "SKILL.md").exists() + target_name = None - + manifest: dict[str, Any] | None = None + if has_manifest: manifest = parse_plugin_manifest(source_path) - target_name = manifest.get('name') + if manifest is None: + raise ValueError(f"Invalid PLUGIN.md manifest in {source_path}") + target_name = manifest.get("name") elif has_skill: # Auto-wrap SKILL.md - with open(source_path / 'SKILL.md', 'r') as f: + with open(source_path / "SKILL.md") as f: content = f.read() # Try to extract name from frontmatter or first line - match = re.search(r'name:\s*(.+)', content) - if match: - target_name = match.group(1).strip() - else: - target_name = source_path.name - + match = re.search(r"name:\s*(.+)", content) + target_name = match.group(1).strip() if match else source_path.name + # Create wrapper PLUGIN.md wrapper_manifest = f"""--- name: {target_name} @@ -196,10 +200,10 @@ def install_plugin(project_root: str, source: str) -> Dict[str, Any]: {content} """ - with open(source_path / 'PLUGIN.md', 'w') as f: + with open(source_path / "PLUGIN.md", "w") as f: f.write(wrapper_manifest) - manifest = True # Now we have it - + manifest = {"type": "enhancer", "version": "1.0.0"} + else: # Last resort: Wrap a generic repo (using README.md if mostly) target_name = source_path.name @@ -219,16 +223,16 @@ def install_plugin(project_root: str, source: str) -> Dict[str, Any]: > Auto-wrapped from repository content. """ - with open(source_path / 'PLUGIN.md', 'w') as f: + with open(source_path / "PLUGIN.md", "w") as f: f.write(wrapper_manifest) - manifest = True + manifest = {"type": "enhancer", "version": "1.0.0"} if not target_name: target_name = "unknown-plugin" - + # Clean name - target_name = re.sub(r'[^a-zA-Z0-9_-]', '-', target_name).lower() - + target_name = re.sub(r"[^a-zA-Z0-9_-]", "-", target_name).lower() + target_dir = plugins_dir / target_name # Copy plugin @@ -238,164 +242,162 @@ def install_plugin(project_root: str, source: str) -> Dict[str, Any]: # Update registry registry = load_registry(project_root) - plugins = registry.get('plugins', []) - + plugins = registry.get("plugins", []) + # Remove existing entry if exists - plugins = [e for e in plugins if e.get('name') != target_name] - + plugins = [e for e in plugins if e.get("name") != target_name] + # Determine source metadata - source_type = 'local' + source_type = "local" source_origin = source source_branch = None - + # Check if it was a GitHub shorthand - github_match = re.match(r'^([a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+)$', source) + github_match = re.match(r"^([a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+)$", source) if github_match: - source_type = 'github' + source_type = "github" source_origin = github_match.group(1) - source_branch = 'main' # Default to main for now - elif source.startswith('http://') or source.startswith('https://'): - source_type = 'url' + source_branch = "main" # Default to main for now + elif source.startswith("http://") or source.startswith("https://"): + source_type = "url" source_origin = source - + # Get version from manifest - installed_version = '0.0.0' - if (target_dir / 'PLUGIN.md').exists(): + installed_version = "0.0.0" + if (target_dir / "PLUGIN.md").exists(): manifest = parse_plugin_manifest(target_dir) if manifest: - installed_version = manifest.get('version', '0.0.0') + installed_version = manifest.get("version", "0.0.0") # Add new entry - plugins.append({ - 'name': target_name, - 'enabled': True, - 'priority': len(plugins) * 10 + 10, - 'type': manifest.get('type', 'enhancer') if manifest else 'enhancer', - 'version': installed_version, - 'source': { - 'type': source_type, - 'origin': source_origin, - 'branch': source_branch - }, - 'installed_at': datetime.now().isoformat() - }) - - registry['plugins'] = plugins + plugins.append( + { + "name": target_name, + "enabled": True, + "priority": len(plugins) * 10 + 10, + "type": manifest.get("type", "enhancer") if manifest else "enhancer", + "version": installed_version, + "source": {"type": source_type, "origin": source_origin, "branch": source_branch}, + "installed_at": datetime.now().isoformat(), + } + ) + + registry["plugins"] = plugins save_registry(project_root, registry) - + # Cleanup - temp_zip = plugins_dir / '_temp.zip' - temp_dir = plugins_dir / '_temp_extract' + temp_zip = plugins_dir / "_temp.zip" + temp_dir = plugins_dir / "_temp_extract" if temp_zip.exists(): temp_zip.unlink() if temp_dir.exists(): shutil.rmtree(temp_dir) - - result['success'] = True - result['name'] = target_name - result['message'] = f'Plugin "{target_name}" installed successfully' - + + result["success"] = True + result["name"] = target_name + result["message"] = f'Plugin "{target_name}" installed successfully' + except Exception as e: - result['message'] = f'Installation failed: {str(e)}' - + result["message"] = f"Installation failed: {e!s}" + return result -def enable_plugin(project_root: str, name: str, enabled: bool = True) -> Dict[str, Any]: +def enable_plugin(project_root: str, name: str, enabled: bool = True) -> dict[str, Any]: """Enable or disable an plugin.""" registry = load_registry(project_root) - plugins = registry.get('plugins', []) - + plugins = registry.get("plugins", []) + for ext in plugins: - if ext.get('name') == name: - ext['enabled'] = enabled + if ext.get("name") == name: + ext["enabled"] = enabled save_registry(project_root, registry) - status = 'enabled' if enabled else 'disabled' - return {'success': True, 'message': f'Plugin "{name}" {status}'} - - return {'success': False, 'message': f'Plugin "{name}" not found'} + status = "enabled" if enabled else "disabled" + return {"success": True, "message": f'Plugin "{name}" {status}'} + + return {"success": False, "message": f'Plugin "{name}" not found'} -def uninstall_plugin(project_root: str, name: str) -> Dict[str, Any]: +def uninstall_plugin(project_root: str, name: str) -> dict[str, Any]: """Uninstall an plugin.""" plugins_dir = get_plugins_dir(project_root) ext_path = plugins_dir / name - + if not ext_path.exists(): - return {'success': False, 'message': f'Plugin "{name}" not found'} - + return {"success": False, "message": f'Plugin "{name}" not found'} + # Remove directory shutil.rmtree(ext_path) - + # Update registry registry = load_registry(project_root) - plugins = registry.get('plugins', []) - plugins = [e for e in plugins if e.get('name') != name] - registry['plugins'] = plugins + plugins = registry.get("plugins", []) + plugins = [e for e in plugins if e.get("name") != name] + registry["plugins"] = plugins save_registry(project_root, registry) - - return {'success': True, 'message': f'Plugin "{name}" uninstalled'} + + return {"success": True, "message": f'Plugin "{name}" uninstalled'} -def update_plugin(project_root: str, name: str) -> Dict[str, Any]: +def update_plugin(project_root: str, name: str) -> dict[str, Any]: """Update a plugin to the latest version.""" registry = load_registry(project_root) - plugins = registry.get('plugins', []) - + plugins = registry.get("plugins", []) + # Find plugin - plugin_entry = next((p for p in plugins if p.get('name') == name), None) + plugin_entry = next((p for p in plugins if p.get("name") == name), None) if not plugin_entry: - return {'success': False, 'message': f'Plugin "{name}" not found'} - + return {"success": False, "message": f'Plugin "{name}" not found'} + # Check source - source_meta = plugin_entry.get('source', {}) + source_meta = plugin_entry.get("source", {}) # Handle legacy registry entries if isinstance(source_meta, str): - # Try to guess - if re.match(r'^[a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+$', source_meta): - source_type = 'github' - source_origin = source_meta - elif source_meta.startswith('http'): - source_type = 'url' - source_origin = source_meta - else: - source_type = 'local' - source_origin = source_meta + # Try to guess + if re.match(r"^[a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+$", source_meta): + source_type = "github" + source_origin = source_meta + elif source_meta.startswith("http"): + source_type = "url" + source_origin = source_meta + else: + source_type = "local" + source_origin = source_meta else: - source_type = source_meta.get('type', 'local') - source_origin = source_meta.get('origin') - - if source_type == 'local': - return {'success': False, 'message': f'Plugin "{name}" is installed locally. Please update files manually.'} - + source_type = source_meta.get("type", "local") + source_origin = source_meta.get("origin") + + if source_type == "local": + return {"success": False, "message": f'Plugin "{name}" is installed locally. Please update files manually.'} + print(f"Updating {name} from {source_type}: {source_origin}...") - + # Re-install triggers the same download logic install_source = source_origin - if source_type == 'github': - install_source = source_origin # install_plugin handles owner/repo - elif source_type == 'url': + if source_type == "github": + install_source = source_origin # install_plugin handles owner/repo + elif source_type == "url": install_source = source_origin - + # We reuse install_plugin (it handles overwrite and registry update) # But we might want to backup first? For simplicity, we just overwrite. return install_plugin(project_root, install_source) -def print_plugins(plugins: List[Dict[str, Any]]): +def print_plugins(plugins: list[dict[str, Any]]): """Print plugin list.""" if not plugins: print("No plugins installed.") return - + print(f"{'Name':<25} {'Type':<12} {'Version':<10} {'Status':<10}") print("-" * 60) for ext in plugins: - status = "✅ enabled" if ext.get('enabled', True) else "❌ disabled" + status = "✅ enabled" if ext.get("enabled", True) else "❌ disabled" print(f"{ext.get('name', 'unknown'):<25} {ext.get('type', '-'):<12} {ext.get('version', '-'):<10} {status:<10}") -if __name__ == '__main__': +if __name__ == "__main__": if len(sys.argv) < 2: print("Usage:") print(" python plugin_manager.py list [project_path]") @@ -405,84 +407,77 @@ def print_plugins(plugins: List[Dict[str, Any]]): print(" python plugin_manager.py disable [project_path]") print(" python plugin_manager.py uninstall [project_path]") sys.exit(1) - + command = sys.argv[1] - + # Default project path is current working directory project_path = os.getcwd() - + # Parse rest of arguments more carefully args = sys.argv[2:] source = None target_name = None - - if command == 'install': + + if command == "install": if len(args) > 0: source = args[0] # If there's a second argument and it's not a flag, it might be project path - if len(args) > 1 and not args[1].startswith('-'): + if len(args) > 1 and not args[1].startswith("-"): project_path = args[1] - - elif command == 'update': - if len(args) > 0: - target_name = args[0] - if len(args) > 1 and not args[1].startswith('-'): - project_path = args[1] - - elif command in ['enable', 'disable', 'uninstall']: + + elif command == "update" or command in ["enable", "disable", "uninstall"]: if len(args) > 0: target_name = args[0] - if len(args) > 1 and not args[1].startswith('-'): + if len(args) > 1 and not args[1].startswith("-"): project_path = args[1] - - elif command == 'list': - if len(args) > 0 and not args[0].startswith('-'): - project_path = args[0] - + + elif command == "list" and len(args) > 0 and not args[0].startswith("-"): + project_path = args[0] + print(f"Project root: {project_path}") - if command == 'list': + if command == "list": plugins = list_plugins(project_path) print_plugins(plugins) - - elif command == 'install': + + elif command == "install": if not source: print("Error: source path or URL required") sys.exit(1) result = install_plugin(project_path, source) - print(result['message']) - sys.exit(0 if result['success'] else 1) - - elif command == 'update': + print(result["message"]) + sys.exit(0 if result["success"] else 1) + + elif command == "update": if not target_name: print("Error: plugin name required") sys.exit(1) result = update_plugin(project_path, target_name) - print(result['message']) - sys.exit(0 if result['success'] else 1) - - elif command == 'enable': + print(result["message"]) + sys.exit(0 if result["success"] else 1) + + elif command == "enable": if not target_name: print("Error: plugin name required") sys.exit(1) result = enable_plugin(project_path, target_name, True) - print(result['message']) - - elif command == 'disable': + print(result["message"]) + + elif command == "disable": if not target_name: print("Error: plugin name required") sys.exit(1) result = enable_plugin(project_path, target_name, False) - print(result['message']) - - elif command == 'uninstall': + print(result["message"]) + + elif command == "uninstall": if not target_name: print("Error: plugin name required") sys.exit(1) result = uninstall_plugin(project_path, target_name) - print(result['message']) - + print(result["message"]) + else: print(f"Unknown command: {command}") sys.exit(1) diff --git a/skills-lock.json b/skills-lock.json new file mode 100644 index 0000000..568b9c6 --- /dev/null +++ b/skills-lock.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "skills": { + "patent-disclosure-skill": { + "source": "handsomestWei/patent-disclosure-skill", + "sourceType": "github", + "skillPath": "SKILL.md", + "computedHash": "c9f17711deab1cc2378d6494f84c7bfda6c2495d92cf02221d1891d38064bd22" + } + } +} diff --git a/tests/conftest.py b/tests/conftest.py index d45c553..6a96f6b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,13 +11,16 @@ def tmp_project(tmp_path: Path) -> Path: """Create a temporary project directory structure.""" # Create basic project structure - (tmp_path / "src").mkdir() + (tmp_path / "src" / "core").mkdir(parents=True) + (tmp_path / "src" / "utils").mkdir() (tmp_path / "tests").mkdir() (tmp_path / "docs").mkdir() # Create some sample files - (tmp_path / "src" / "main.py").write_text("def main():\n pass\n") - (tmp_path / "src" / "utils.py").write_text("def helper():\n return True\n") + (tmp_path / "src" / "core" / "app.py").write_text("def main():\n pass\n") + (tmp_path / "src" / "core" / "main.ts").write_text("export const main = () => true;\n") + (tmp_path / "src" / "utils" / "helpers.js").write_text("export const helper = () => true;\n") + (tmp_path / "docs" / "readme.md").write_text("# Test Documentation\n") (tmp_path / "README.md").write_text("# Test Project\n") return tmp_path diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..5bcafa6 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,93 @@ +"""Tests for scripts/cli.py.""" + +from __future__ import annotations + +from click.testing import CliRunner + +from cli import main + + +runner = CliRunner() + + +def test_version(): + result = runner.invoke(main, ["--version"]) + assert result.exit_code == 0 + assert "3.2.0" in result.output + + +def test_help(): + result = runner.invoke(main, ["--help"]) + assert result.exit_code == 0 + assert "Mini-Wiki" in result.output + assert "init" in result.output + assert "analyze" in result.output + assert "check" in result.output + assert "changes" in result.output + assert "plugins" in result.output + + +def test_init_creates_wiki_dir(tmp_path): + result = runner.invoke(main, ["init", str(tmp_path)]) + assert result.exit_code == 0 + assert (tmp_path / ".mini-wiki").exists() + assert (tmp_path / ".mini-wiki" / "config.yaml").exists() + assert (tmp_path / ".mini-wiki" / "meta.json").exists() + + +def test_init_already_exists(tmp_path): + runner.invoke(main, ["init", str(tmp_path)]) + result = runner.invoke(main, ["init", str(tmp_path)]) + assert result.exit_code == 1 + assert "已存在" in result.output + + +def test_init_force(tmp_path): + runner.invoke(main, ["init", str(tmp_path)]) + result = runner.invoke(main, ["init", "--force", str(tmp_path)]) + assert result.exit_code == 0 + + +def test_analyze(tmp_path): + (tmp_path / "main.py").write_text("print('hello')") + result = runner.invoke(main, ["analyze", "--no-cache", str(tmp_path)]) + assert result.exit_code == 0 + assert "项目" in result.output or "技术栈" in result.output + + +def test_changes(tmp_path): + (tmp_path / "app.py").write_text("pass") + result = runner.invoke(main, ["changes", str(tmp_path)]) + assert result.exit_code == 0 + + +def test_check_no_wiki(tmp_path): + result = runner.invoke(main, ["check", str(tmp_path)]) + assert result.exit_code == 1 + assert "No wiki found" in result.output + + +def test_plugins_list(tmp_path): + result = runner.invoke(main, ["plugins", "list", str(tmp_path)]) + assert result.exit_code == 0 + + +def test_plugins_help(): + result = runner.invoke(main, ["plugins", "--help"]) + assert result.exit_code == 0 + assert "install" in result.output + assert "uninstall" in result.output + assert "enable" in result.output + assert "disable" in result.output + + +def test_plugins_enable_not_found(tmp_path): + (tmp_path / "plugins").mkdir() + result = runner.invoke(main, ["plugins", "enable", "nonexistent", str(tmp_path)]) + assert "not found" in result.output + + +def test_plugins_uninstall_not_found(tmp_path): + result = runner.invoke(main, ["plugins", "uninstall", "nonexistent", str(tmp_path)]) + assert result.exit_code == 1 + assert "not found" in result.output diff --git a/tests/test_generate_diagram.py b/tests/test_generate_diagram.py index 69fddf5..6f14419 100644 --- a/tests/test_generate_diagram.py +++ b/tests/test_generate_diagram.py @@ -1,5 +1,6 @@ """Tests for scripts/generate_diagram.py.""" +import json import re from pathlib import Path