)', 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'
\s*([^<]+?)\s*
',
+ 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*\s*
([^<]+?)",
+ 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]:
+ """从结果页中抽取指向公布详情的
。"""
+ hits: list[EpubSearchHit] = []
+ for m in re.finditer(
+ r']*href="([^"]+)"[^>]*>([^<]*)',
+ 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
[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"\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 注释 ````(预览不显示图,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"(?"
+)
+
+# 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}"
+ 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"\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"\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"\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"\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("
+ ):
+ 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 表格、引用块(>)、水平线(---)、行内图片 ````
+(在最大宽、最大高约束下**等比缩放**,竖图自动缩小宽度以整图落入版面)。
+
+**连续多行正文**(中间无空行、且非列表/标题等)时,**每一行**输出为 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""
+)
+_INLINE_MATH_WITH_HIDDEN_IMG_RE = re.compile(
+ r"(?"
+)
+_INLINE_MATH_PAREN_WITH_HIDDEN_IMG_RE = re.compile(
+ r"\\\(((?:\\.|[^)])+?)\\\)\s*"
+ r""
+)
+
+
+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"\$\$|\\\[|\\\(|(? 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)
+ 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""
+)
+
+
+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"\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)", " ", html)
+ text = re.sub(r"(?is)", " ", text)
+ text = re.sub(r"(?is)", " ", text)
+ text = re.sub(r"(?is)", " ", text)
+ text = re.sub(r"(?is)
", "\n", text)
+ text = re.sub(r"(?is)", "\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)]*>(.*?)", 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\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