Back to Skills
yuan1z0825/nature-skillsReview behavior before running

SKILL DETAIL

nature-paper-to-patent

yuan1z0825/nature-skills/nature-paper-to-patent

This skill converts scientific papers, theses, technical reports, source code, figures, inventor notes, or research manuscripts into evidence-grounded Chinese invention patent drafts and attorney-facing technical disclosure materials. It supports multiple task modes, including full application drafting, claim set generation, disclosure analysis, technical disclosure writing, disclosure iteration, and paper-patent audit. The skill emphasizes source grounding by assigning stable source IDs to each technical feature and using only explicit or inherent support states. It follows a stage-gated workflow to ensure quality and produces formal Chinese patent documents such as claims, specification, abstract, and figures. For technical disclosures, it generates timestamped Markdown and DOCX files with editable Office Math formulas and Mermaid diagrams. The skill also includes validation scripts to check draft completeness and consistency.

Installs · 147View source

Installation

npx skills add https://github.com/yuan1z0825/nature-skills --skill nature-paper-to-patent

Skill files

SKILL.md

Last synced · Aug 27, 2026

agents/openai.yaml
interface:
  display_name: "Nature Paper to Patent"
  short_description: "Convert research materials into patent-ready drafts"
  default_prompt: "Use $nature-paper-to-patent to convert these research materials into a source-grounded patent draft."
evals/evals.json
{
  "skill_name": "nature-paper-to-patent",
  "evals": [
    {
      "id": 1,
      "prompt": "分析一篇包含模型结构、损失函数和检测流程的PDF论文,生成完整的中国发明专利初稿。分别输出权利要求书、说明书、说明书摘要和摘要附图。所有核心公式必须是Word原生可编辑公式,每项权利要求特征必须能回溯到论文页码或公式。",
      "expected_output": "A complete Chinese patent package with stable source IDs, evidence ledger, claim-feature map, editable equations, a claim-aligned main flowchart, methodology figures, separate DOCX files, and passing validation reports.",
      "files": []
    },
    {
      "id": 2,
      "prompt": "论文是扫描版PDF,部分公式OCR不清楚。请先整理可确认的技术方案并生成权利要求草案,不确定的公式符号不要猜。",
      "expected_output": "An OCR-aware claim draft that marks uncertain source blocks, excludes unsupported limitations from formal claims, lists specific inventor questions, and does not fabricate equation symbols.",
      "files": []
    },
    {
      "id": 3,
      "prompt": "对照论文和一份已有专利,逐项判断专利权利要求在论文中是否有支持,并标注论文独有、专利独有、专利更宽或更窄的内容。",
      "expected_output": "Separate source maps plus a bidirectional feature table using the declared relationship labels, exact locators for both documents, and no assumption that patent-only text is paper-supported.",
      "files": []
    }
  ]
}
manifest.yaml
name: nature-paper-to-patent
version: 1.0.0
description: >
  Agent-neutral routing manifest for converting research disclosures into
  evidence-grounded Chinese invention patent drafts.

always_load:
  - static/core/principles.md
  - static/core/workflow.md
  - static/core/output-contract.md

axes:
  source_format:
    detect: >
      Use pdf-text for a selectable-text PDF, scanned-pdf for an image-only or
      OCR-required PDF, pasted-text for user-provided prose, and mixed-project
      when the workspace also contains source code, supplementary files,
      inventor notes, figures, or an existing patent.
    values:
      pdf-text: static/fragments/source/pdf-text.md
      scanned-pdf: static/fragments/source/scanned-pdf.md
      pasted-text: static/fragments/source/pasted-text.md
      mixed-project: static/fragments/source/mixed-project.md
    default: pdf-text
    multi: true

  task_mode:
    detect: >
      Use full-draft for a complete application package, claim-set for claims
      only, disclosure-analysis for patentability-oriented technical
      extraction, technical-disclosure for patent-point mining, prior-art
      search, or attorney-facing Chinese technical disclosure drafts
      (技术交底书), disclosure-iteration when continuing from an existing
      disclosure draft, and paper-patent-audit for comparison with an existing
      patent.
    values:
      full-draft: static/fragments/task/full-draft.md
      claim-set: static/fragments/task/claim-set.md
      disclosure-analysis: static/fragments/task/disclosure-analysis.md
      technical-disclosure: static/fragments/task/technical-disclosure.md
      disclosure-iteration: static/fragments/task/disclosure-iteration.md
      paper-patent-audit: static/fragments/task/paper-patent-audit.md
    default: full-draft
    multi: false

  invention_type:
    detect: >
      Determine the dominant technical subject. Use algorithm-software for
      data processing, AI, signal processing, control, or computer-implemented
      methods; apparatus-system for physical structures or cooperating
      devices; process-material for manufacturing, chemistry, or material
      preparation; and mixed when no single category is sufficient.
    values:
      algorithm-software: static/fragments/invention/algorithm-software.md
      apparatus-system: static/fragments/invention/apparatus-system.md
      process-material: static/fragments/invention/process-material.md
      mixed: static/fragments/invention/mixed.md
    default: algorithm-software
    multi: false

references:
  on_demand:
    - condition: drafting or auditing Chinese claims and specification
      path: references/cn-patent-drafting-guide.md
    - condition: creating figures or selecting a paper methodology figure
      path: references/patent-figure-guide.md
    - condition: populating or validating the structured draft
      path: references/draft-schema.md
    - condition: learning transformations from paired paper-patent examples
      path: references/corpus-derived-patterns.md
    - condition: comparing a supplied paper with an existing patent
      path: references/corpus-pair-audit.md
README_EN.md
# `nature-paper-to-patent` Skill

[中文说明](README.md)

`nature-paper-to-patent` converts papers, theses, technical reports, source code, figures, or inventor notes into evidence-constrained Chinese invention patent drafts. It also supports patent-point mining and attorney-facing Chinese technical disclosure drafts from project documents and code.

## What To Use It For

- Identify patentable technical contributions from papers or technical materials.
- Scan project documents, code, Word, and PowerPoint files to mine patent points and merge them into a Chinese technical disclosure.
- Run prior-art comparison with a CNIPA published-patent search path and public verification links.
- Generate technical disclosure `.md` and `.docx` files with Mermaid system/process diagrams.
- Revise an existing disclosure by merging new material, correcting facts, or adjusting protection focus while preserving timestamped versions and revision logs.
- Build source IDs and an evidence ledger before drafting claims.
- Route algorithm, device, system, process, material, or hybrid inventions to the right drafting rules.
- Generate claims, specification, abstract, abstract drawing, and full review draft.
- Check whether every claim feature can be traced back to source evidence.

## Typical Requests

- "Convert this paper into a Chinese invention patent disclosure and claim draft."
- "Scan this project, identify patent points, and draft a technical disclosure."
- "Merge these new materials into the existing disclosure and save a new Word version."
- "Extract only the patentable points first; do not write the full specification yet."
- "Compare the paper claims and patent claims, and find features without evidence."

## What You Need To Provide

- Paper PDF, technical report, code, figures, experiment records, or inventor notes.
- Protection target: method, system, device, material, process, or software workflow.
- Known prior art, confidential information, and terms that must be preserved.

## Outputs

- Technical problem, technical solution, implementation chain, and beneficial-effect analysis.
- Technical disclosure Markdown / Word with prior-art discussion, system diagram, process flowchart, and consistency QA.
- Claim draft with evidence mapping for each feature.
- Specification, abstract, drawing description, and black-and-white flowchart.
- Optional DOCX, SVG, PNG, and review report.

## Runtime and Dependencies

- Full functionality depends on the skill directory's `manifest.yaml`, `static/`, `references/`, `scripts/`, and `requirements.txt`.
- DOCX, formula, or flowchart generation uses local Python dependencies and scripts.
- Disclosure-specific Office conversion, Mermaid rendering, CNIPA search, and revision-log scripts live under `scripts/disclosure/`; optional CNIPA dependencies are in `scripts/disclosure/requirements-cnipa.txt`.

## Boundaries

- This is technical drafting support, not a replacement for a patent attorney or legal opinion.
- The skill does not invent embodiments, experimental data, claim features, or prior-art comparisons.
- Features without evidence are marked as gaps rather than written into the independent claim.

## Related Skills

- `nature-reader`: turn the paper into traceable reading material first.
- `nature-writing`: extract contribution and technical narrative.
- `nature-figure`: draft patent flowcharts or structural schematics.
README.md
# `nature-paper-to-patent` 技能

[English](README_EN.md)

`nature-paper-to-patent` 用于把科研论文、学位论文、技术报告、源代码、图表或发明人笔记转换为证据约束的中国发明专利草稿,也支持从项目文档与代码中挖掘专利点并生成可交给代理人继续修改的中文技术交底书。

## 适合用它做什么

- 从论文或技术资料中识别可专利化技术贡献。
- 扫描项目文档、代码、Word、PPT,形成候选专利点并融合为一篇技术交底书。
- 优先使用国知局公布公告站并结合公开链接做现有技术查新。
- 生成含 Mermaid 系统框图/流程图的交底书 `.md` 与 `.docx`。
- 在已有交底书上继续合并材料、纠错、调整保护点,并保留时间戳版本与修订记录。
- 建立 source ID 和 evidence ledger,再起草权利要求。
- 将算法、装置、系统、工艺、材料或混合型发明路由到对应规则。
- 生成权利要求书、说明书、摘要、摘要附图和完整审阅稿。
- 检查每个权利要求特征是否能回溯到来源证据。

## 典型请求

- “把这篇论文转成中国发明专利交底书和权利要求草稿。”
- “扫一下这个项目,挖几个专利点,生成技术交底书。”
- “基于这版交底书补充新材料,另存一版 Word。”
- “只提取可专利点,先不要写完整说明书。”
- “对比论文 claim 和专利 claim,看看哪些技术特征缺证据。”

## 你需要提供

- 论文 PDF、技术报告、代码、图表、实验记录或发明人补充说明。
- 希望保护的对象:方法、系统、装置、材料、工艺或软件流程。
- 已知现有技术、不能公开的信息和必须保留的术语。

## 产出

- 技术问题、技术方案、实施链条和有益效果分析。
- 技术交底书 Markdown / Word,含查新结论、系统框图、流程图、公式与参数一致性自检。
- 权利要求草稿及每个特征的证据映射。
- 说明书、摘要、附图说明和黑白流程图。
- 可选 DOCX、SVG、PNG 和审阅报告。

## 运行和依赖

- 完整功能依赖技能目录中的 `manifest.yaml`、`static/`、`references/`、`scripts/` 和 `requirements.txt`。
- 需要生成 DOCX、公式或流程图时,会使用本机 Python 依赖和脚本。
- 交底书分支的 Office 转换、Mermaid 渲染、国知局查新脚本位于 `scripts/disclosure/`;可选国知局检索依赖见 `scripts/disclosure/requirements-cnipa.txt`。

## 边界

- 这是技术起草辅助,不替代专利代理师或法律意见。
- 不会虚构实施例、实验数据、权利要求特征或现有技术对比。
- 证据不足的特征会被标记为缺口,而不是写进主权利要求。

## 相关技能

- `nature-reader`:先把论文转成可追溯阅读材料。
- `nature-writing`:提炼论文贡献和技术叙事。
- `nature-figure`:生成专利流程图或结构示意图草稿。
references/cn-patent-drafting-guide.md
# Chinese Invention Patent Drafting Guide

## Contents

1. Evidence discipline
2. Converting paper structure
3. Independent claims
4. Dependent claims
5. Specification
6. Algorithm-related inventions
7. Final audit
8. Quality rubric

## 1. Evidence Discipline

Create the claim language from disclosed operations, relationships, structures, and parameters. A paper's broad statement of purpose is not, by itself, support for every implementation that could achieve that purpose.

Distinguish:

- a result demonstrated by experiments;
- a technical effect caused by identified features;
- an aspiration or future research direction.

Use the first two with appropriate scope. Exclude the third unless inventors provide additional disclosure.

## 2. Converting Paper Structure

Map common paper sections as follows:

| Paper material | Patent destination |
|---|---|
| research motivation and limitations | background and technical problem |
| contribution list | candidate essential features and dependent-claim branches |
| method overview | independent-claim sequence |
| module architecture | dependent claims and embodiments |
| formulas and losses | dependent claims and detailed embodiments |
| dataset preparation | acquisition/preprocessing claims and embodiments |
| experiments and ablations | beneficial effects and verification examples |
| conclusion | effect summary, not a substitute for technical detail |
| limitations/future work | inventor questions; normally not claimed |

Rewrite causal and operational relationships. Do not merely translate academic prose.

## 3. Independent Claims

For a method claim:

- identify the technical input;
- state the essential processing sequence;
- preserve dependencies between intermediate data;
- state the specific domain result or control action, such as a detection result, estimated state, target position, classification result, or control instruction;
- include only features needed for the central effect.

Prefer observable operations over labels such as "intelligent module" or "novel network." Define what the module receives, performs, and produces.

Avoid:

- result-only wording unsupported by means;
- unnecessary dataset names, exact model depth, or experimental values;
- optional features mixed into the essential chain;
- steps that appear only in the patent draft and not in the source.

## 4. Dependent Claims

Use dependent claims to build fallback positions around:

- data sources, labels, and preprocessing;
- component topology and data flow;
- feature extraction branches;
- training stages and loss functions;
- reconstruction, fusion, attention, or optimization operations;
- inference and post-processing;
- equations, thresholds, ranges, and preferred parameters;
- deployment as device and storage medium.

Each dependent claim must add a technical limitation. A statement of advantage alone does not narrow a claim.

## 5. Specification

### Technical Field

Use one concise paragraph naming the relevant technical field and the specific subject.

### Background

Describe known approaches and a concrete technical deficiency without unsupported admissions about the closest prior art. Do not use the paper's own method as background.

### Invention Content

State:

1. the technical problem;
2. the technical solution in language aligned with the claims;
3. beneficial effects linked to particular features.

### Figures

Propose only figures supported by available information, such as:

- overall method flow;
- model or system architecture;
- core module structure;
- training and inference flow;
- data-processing flow.

Mark missing drawings as `[待补图]`.

### Detailed Embodiments

Provide enough operational detail for implementation. Include data origin, preprocessing, model flow, training, inference, formulas, parameter examples, and evaluation where disclosed.

Present paper-specific settings as examples unless they are essential. Preserve alternatives disclosed in the source to support broader scope.

### Formulas

For a paper whose technical contribution depends on mathematical operations, reproduce the core formulas in patent notation and explain them in the specification.

Include formulas that define:

- sample selection or prototype computation;
- feature transformation or reconstruction;
- attention, weighting, alignment, or fusion;
- training objectives and loss functions;
- estimation, detection, positioning, or decision rules.

For every formula:

1. assign a consecutive patent formula number;
2. identify its source page and paper formula number in the drafting record;
3. define every variable, index, operator, norm, and weight;
4. explain what input the formula processes and what output it produces;
5. connect the formula to a method step and technical effect.

Do not leave a core operation described only as "calculated according to a formula." Do not add unrelated evaluation metrics merely to increase the number of formulas.

### Abstract

Summarize the field, problem, essential solution, and main technical effect. Keep terminology aligned with claim 1 and avoid promotional language.

## 6. Algorithm-Related Inventions

Tie algorithmic operations to a technical context and technical data, such as sensor signals, industrial process variables, images, battery measurements, or remote-sensing data.

Describe:

- how data is obtained;
- how data is transformed;
- how model components cooperate;
- what technical quantity, state, location, class, or control instruction is produced;
- how that output improves a technical process.

Do not rely solely on accuracy claims. Explain the mechanism that reduces redundancy, preserves multiscale information, handles domain shift, reconstructs disturbed features, or otherwise produces the effect.

## 7. Final Audit

Confirm:

- claim 1 can be traced line by line to the source;
- dependent claims have correct antecedent basis;
- all claimed terms appear in the specification;
- the same object is not given multiple names;
- no equation has undefined variables;
- every source-supported core formula is present in the specification rather than only in claims or notes;
- the flowchart exists as both SVG and PNG and the PNG is embedded in the specification;
- the designated abstract figure is the same main figure reused in the specification and is embedded in the abstract deliverables;
- claims, specification, and abstract are delivered as separate DOCX files;
- no performance number is copied without its test context;
- publication and filing dates are flagged for professional novelty review;
- the draft is labeled for inventor and patent-professional review.

## 8. Quality Rubric

Score each dimension from 1 to 5 and give one sentence of evidence for the score.

| Dimension | 1 | 3 | 5 |
|---|---|---|---|
| evidence support | major claimed features are unsupported | most features are traceable, with gaps marked | every claimed feature is traceable and unsupported matter is excluded |
| claim architecture | scope is incoherent or dependencies fail | usable independent claim with limited fallback positions | clear essential chain and layered fallback positions across appropriate categories |
| terminology and consistency | conflicting terms and broken references | mostly consistent with minor issues | terms, symbols, dependencies, figures, and sections align throughout |
| enablement detail | result-only description | principal implementation is described | data, operations, relationships, parameters, and alternatives are sufficiently described |
| technical-effect reasoning | effects are promotional or detached | some effects are linked to features | each material effect is causally tied to disclosed technical means |

Delivery thresholds:

- Require at least 4 for evidence support.
- Require at least 4 for claim architecture.
- Require at least 4 for terminology and consistency.
- Require at least 3 for enablement detail.
- Require no unresolved structural errors from `scripts/audit_claims.py`.

If a threshold is missed, label the output `incomplete draft` and list the exact inventor input needed to improve it.
references/corpus-derived-patterns.md
# Corpus-Derived Paper-to-Patent Patterns

## Scope

These heuristics were derived from five paired research-paper and Chinese-patent examples covering robotic grasping, battery health estimation, industrial fault detection, surface defect detection, and remote-sensing biomass estimation.

They describe recurring drafting choices. They do not establish patentability or legal sufficiency.

## Recurring Transformations

| Academic contribution | Typical patent treatment |
|---|---|
| end-to-end model | method claim with ordered data and model operations |
| new backbone or feature extractor | dependent claim defining stages, branches, or topology |
| attention, fusion, or reconstruction module | dependent claims defining input, operation, and output |
| training strategy | method steps plus loss-function dependent claims |
| inference rule | post-processing or decision step |
| dataset extension | data acquisition, annotation, or preprocessing dependent claim |
| experimental advantage | beneficial effect tied to the responsible features |
| software implementation | device and computer-readable-medium claims |

## Claim-Layer Pattern

The examples commonly use:

1. one method claim covering the complete technical pipeline;
2. dependent claims unpacking each contribution in the same order as the pipeline;
3. dependent claims for formulas or parameterized operations;
4. device and storage-medium claims for software-controlled implementations.

Do not copy this shape blindly. Use it only where supported by the source and appropriate for the invention.

## Problem-Solution Patterns

### Multi-task Interference

When a paper addresses interference between tasks, claim the cooperating feature paths or reconstruction operations rather than only stating that interference is reduced.

### Domain Shift and Generalization

When a paper addresses multiple operating conditions, claim domain construction, meta-training/meta-testing or adaptation operations, feature fusion, and model updating. Treat generalization as an effect, not an unsupported step.

### Redundant Latent Features

When orthogonality or introspective training is used, separate the detection pipeline from training-specific dependent claims. Define positive/negative samples and loss terms before relying on their effects.

### Detection Accuracy and Speed

For multiscale detection, claim the feature hierarchy, candidate-region generation, and localization operations. Place exact backbone depth, anchor parameters, and benchmark values in narrower claims or embodiments.

### Limited Samples

For self-supervised reconstruction, claim how auxiliary/disturbed data is generated, how residual data is formed, how reconstruction pretraining works, and how the downstream estimator is fine-tuned.

## Drafting Lessons

- Papers often emphasize novelty by naming modules; patents must disclose the module's operations and relationships.
- The paper's contribution list is a strong candidate list for dependent claims, not necessarily for separate independent claims.
- Experimental tables support effect statements but usually should not define claim scope.
- Model names can remain in embodiments, but independent claims should generally use technically descriptive language.
- A paper and its patent may differ in terminology or implementation detail. Treat the paper as evidence, not permission to import unexplained features from another patent.
- Publication timing, prior disclosures, and inventorship require separate factual and professional review.
references/corpus-pair-audit.md
# Corpus Pair Audit

## Purpose

Use the five numbered pairs as examples of drafting transformations, not as unquestioned one-to-one ground truth. File numbering establishes intended pairing only. Technical content and dates determine the strength of each match.

## Pair Assessments

| Pair | Paper subject | Patent subject | Match strength | Important caution |
|---|---|---|---|---|
| 1 | MLEAFormer for grasp detection and classification | ECBAFormer robotic grasp detection method | partial | The task and broad architecture align, but model and module names differ. Verify every feature before transferring it. |
| 2 | MSDMLN for battery SOH under multiple target conditions | battery SOH estimator training using EMD, dense recurrent-convolution extraction, and meta-learning | strong | The patent application predates the paper publication. Use the technical overlap, but do not infer legal priority or inventorship. |
| 3 | ReSiOrCAE with variable rearrangement, dual-path convolution, orthogonality, and soft-introspective training | introspective orthogonal autoencoder process fault detection | partial | The patent strongly covers introspective and orthogonal training but does not necessarily capture every later paper contribution. |
| 4 | AGLNet for adaptive global localization surface-defect detection | AGLNet product surface-defect detection | strong | The patent mirrors the main pipeline closely. Exact legal status and chronology still require source records. |
| 5 | SSDFRN for limited-data mangrove biomass estimation | self-supervised disturbing-feature reconstruction biomass estimation | strong | The core data disturbance, reconstruction, multiview network, and fine-tuning chain align well. |

## Date Cautions

- A publication date is not necessarily the first public disclosure date.
- A patent PDF may show application, publication, or grant dates; distinguish them.
- A paper may have received, accepted, online-publication, and issue dates.
- Never state novelty, priority, ownership, or valid entitlement from these PDFs alone.

## Pair-Mapping Procedure

For each pair:

1. Normalize technical terms without collapsing distinct modules.
2. Map the independent claim feature by feature to paper pages.
3. Map dependent claims to paper methods, formulas, and implementation details.
4. Label patent-only material and exclude it when drafting solely from the paper.
5. Label paper-only material as a candidate contribution, not as proof of patentability.
6. Record chronology separately from technical similarity.

## Safe Uses

Use the corpus to learn:

- how contribution lists become claim branches;
- how model operations become ordered method steps;
- how experiments become effect evidence;
- how software methods expand to device and storage-medium claims.

Do not use it to:

- copy claim language into an unrelated invention;
- assume patent-only detail is supported by the paper;
- determine novelty or freedom to operate;
- infer inventors from paper authors.
references/disclosure/correction_handler.md
# 迭代模式:对话纠正

## 执行门禁

与 `merger.md` 相同:先 **`Read`** **`references/disclosure/iteration_context.md`**,再 **Read** 当前定稿与纠正意图;纠正结果须**另存为** **`{案件名}_{YYYYMMDDHHmmss}.md`** 及同名 `.docx`(§7.3 第 5 点),**禁止**默认覆盖旧稿。**禁止**在迭代意图下无合并/纠正落盘却回到 Step 3–4 全文专利点分析(除非用户明确要求重做专利点)。

## 何时启用

由 Agent 根据用户**意图**判断:用户针对**已有交底书**指出**错误、与事实或参数不符、表述问题、保护点调整**等(例如「这里不对」「和 3.5 不一致」「保护点应强调 XXX」)时,**应**执行本流程。**不要求**用户说出「迭代」等固定词;也**不必**先询问是否进入迭代模式。

## 步骤

1. **提取纠正点**:具体章节、原问题、用户期望,可摘要用户原话。
2. **分类**:
   - **事实与技术**:流程、参数、模块关系 → 改第三章及相关实施例、3.5。
   - **符号与公式体例**:上标维度(如 `^{cpu}`)、符号多义、LaTeX 分隔符混用、3.5 与 3.4.1 不同形 → 改 **3.4.1 符号表**、相关公式、**3.5 符号列**及第六章实施例;遵循 **`disclosure_builder.md` §7.7** 与 **`template_reference.md` §3.4.1** 正/反例。
   - **查新与区别**:现有技术或区别论述不准 → 改第一章,必要时再检索。
   - **保护点与表述**:第四章、第五章论点 → 与第三章对齐,避免矛盾。
3. **落地修改**:形成纠正后全文,**写入新文件** **`{案件名}_{YYYYMMDDHHmmss}.md`** 并经 `mermaid_render.py` 生成同名 `.docx`(§7.3 第 5 点);**禁止**无必要大段重写无关章节;**勿默认覆盖**用户上一版文件名。
4. **自检**:执行 `disclosure_self_check.md` 的 **8.2、8.3**。
5. **对话记录**:按 **`iteration_context.md`**「修订对话记录」在案件目录追加 **`交底书修订对话记录.md`**(优先 **`scripts/disclosure/iteration_dialog_log.py --kind correct`**)。

## 输出(强制,不可省略)

在交付修改后的正文(或说明已写入路径)之后,**必须在同一条回复中**追加独立小节,**标题固定为**:

## 纠正摘要(留档)

其下用 **2–5 句完整中文**,说明:**修改位置**、**依据**、**是否影响保护点或检索**。
若未输出本节,视为未完成本 prompt。

**定稿延续**:若本轮纠正结果作为**向用户交付的定稿**,在**同一条回复**中于上文之后,**还须**按 **`disclosure_builder.md` §7.6** 补充「权利要求偏向点」建议交互(可 1~2 句缩写版),**不得**写入 `.md`/`.docx` 正文;**禁止**编造与定稿不符的「偏向」选项(见 §7.6 第 3 点)。
references/disclosure/disclosure_builder.md
# 技术交底书生成模板(Step 7)

## Contents

- [7.1 章节结构](#71-章节结构)
- [7.2 文档头部模板](#72-文档头部模板)
- [注意事项](#注意事项)
- [7.3 输出文件命名(用户产出目录,必遵循)](#73-输出文件命名用户产出目录必遵循)
- [7.4 系统框图与流程图要点](#74-系统框图与流程图要点)
- [7.5 脱敏要求](#75-脱敏要求)
- [7.7 符号与公式体例(必遵)](#77-符号与公式体例必遵)
- [定稿交付:Markdown + Word(必做)](#定稿交付markdown-word必做)
- [7.6 交付回复:权利要求偏向点(建议交互,必做)](#76-交付回复权利要求偏向点建议交互必做)


详细章节范例与 **mermaid** 图示模版见同目录 **`template_reference.md`**。

## 7.1 章节结构

```
1. 注意事项
2. 一、介绍相关技术背景,描述与本发明技术最相近的现有技术,并说明该现有技术存在的缺点
   - 1.1 现有技术(按技术方向分类,含专利检索结果)
   - 1.2 现有技术存在的缺点
3. 二、针对上述缺点,说明本发明所要解决的技术问题
4. 三、本发明技术方案的详细阐述
   - 3.1 背景
   - 3.2 系统框图(**mermaid**,如 `flowchart` + `subgraph` 分层;交付前经工具转 PNG;**不要** ASCII 文字框图)
   - 3.3 模块功能说明(聚焦作用与关联关系,非输入输出)
   - 3.4 系统流程说明(**mermaid 流程图**,交付前经工具转 PNG;**不要** ASCII 文字/箭头流程图)
   - 3.4.1 符号与公式(出现公式或形式化变量时**须设**;**须**先符号与变量定义、再写公式;体例见 **§7.7** 与 **`template_reference.md` §3.4.1**)
   - 3.5 关键技术参数(参数表「符号」列与 3.4.1 **同形**)
5. 四、与现有技术相比,本发明具有哪些优点?
6. 五、本发明的技术关键点和欲保护点是什么?
7. 六、其它(实施例、技术效果、参数示例)
```

**第一章 1.1 撰写硬性要求**:在「按技术方向分类」的**每一条**现有技术(专利或文献)末尾或表格中,必须给出 **经核验的公开源 URL**(规范与示例见 `prior_art_search.md` 与 `template_reference.md` §1.1)。**国知局检索**(`EPUB_HITS_JSON`)中对该专利给出的 **`abstract` 非空时**:文中「技术方案 / 应用 / 局限」类表述**须以摘要理解为前提**(消化后重写,非整段粘贴),**禁止**与摘要明显矛盾或脱离摘要杜撰;详见 **`prior_art_search.md`**「`abstract` 必用」。

**禁止输出**:交底书正文中**不得**包含「自检清单」章节,例如检查项表格或带状态符号的清单列。自检见 `disclosure_self_check.md`,**内部执行**,不写入交付正文。

**禁止仓库/技能脚注**:交付用 Markdown **末尾不得**出现任何指向本技能或示例仓库的说明,例如「本文件为 `patent-disclosure-skill` 仓库内教学示例」「不构成任何法律或技术承诺」「虚构教学」「详见 `examples/`」等。正文**止于第六章及此前章节**;**不要**在文末追加斜体免责或仓库署名。

## 7.2 文档头部模板

```markdown
# 技术交底书

**案件名称**:[待填写]一种XXX方法及系统

**技术联系人**:
- 姓名:[待填写]
- 电话:[待填写]
- 邮箱:[待填写]

**专利类型**:发明

---

## 注意事项

(1)交底书应使代理人能看懂,尤其是背景技术和详细技术方案,一定要写得全面、清楚、完整;
(2)技术的公开程度,应以本领域普通技术人员不需付出创造性劳动即可进行实施为准。
(3)在与代理人沟通时,对于代理人咨询的技术问题,应给予回答并认真讲解,并且按要求及时正确地补充相应技术材料。
```

## 7.3 输出文件命名(用户产出目录,必遵循)

在用户目录(如 `outputs/{案件标识}/`)写入交底书 **Markdown / Word** 时,**主文件名须体现正文中的「案件名称」**,且**凡向用户落盘交付的定稿均须带时间戳后缀**(见下第 5 点),避免无区分度的固定名(如一律 `disclosure_draft.md`)。

1. **提取**:从 `**案件名称**:` 行取完整发明名称;去掉占位(如 `[待填写]`、`【待填写】`)、首尾空格。
2. **规范化**:删除或替换 Windows 非法字符 `\ / : * ? " < > |` 与换行;连续空格可压为单个空格或删去(中文标题通常无空格亦可)。
3. **长度**:文件名(不含扩展名)建议 **≤ 80 个字符**;超长则**截断**到约 80 字并保留语义完整(例如截在「方法及系统」等结尾词之前),勿用无意义随机串。
4. **定稿命令**:`mermaid_render.py` 的 `-o` / `--docx` 须使用**同一主文件名**,且该主名**必须符合第 5 点**(**案件名 + `_` + 14 位时间戳**)。示例(案件名已按上文规范化、过长已截断时):

   `python3 scripts/disclosure/mermaid_render.py -i "…草稿.md" -o "一种异构计算环境下基于资源画像与限频重排队的批任务调度方法及系统_20260408143025.md"`

   默认同目录生成同名 `.docx`。过程性中间稿(仅自用、非交付)文件名可自定,但**最终交付**不得省略时间戳。

5. **时间戳后缀(凡落盘交付必遵循)**:凡写入用户产出目录、作为**向用户交付**的技术交底书 **`.md` / `.docx`**,主文件名须为:
   **`{§7.3 规范化案件名}_{YYYYMMDDHHmmss}.md`** 与同名 **`.docx`**。
   - **首次定稿**与**迭代合并/纠正**适用**同一规则**;每次交付取**当次落盘时**的**本地时间** 14 位数字(年月日时分秒各 2 位,如 `20260408143025`)。
   - **不要覆盖**已有交付文件;新一次交付即**新时间戳**、新文件名。用户**明确要求**覆盖某路径时从其意。
   - **`mermaid_render.py`** 的 `-o` 与可选 `--docx` 须与上述主名一致。
   - **例外**:用户书面指定文件名时从其意(仍建议保留时间戳以免混淆)。

Agent **落盘时**即采用上述命名,并在回复中写明路径,便于用户对照标题与磁盘文件。

## 7.4 系统框图与流程图要点

- **系统框图与流程图**均仅用 **fenced mermaid**(本地 `mmdc` 渲染,不依赖外网 PlantUML);Word 中以 PNG 为准,**无需**再附 ASCII 文字框图
- mermaid 内节点标签用简短中文/数字,避免 ①②③。可另写简短「流程说明」段落概括各步,**不得**用 ASCII 框线箭头代替图示

## 7.5 脱敏要求

| 类型 | 脱敏方式 | 示例 |
|------|----------|------|
| 业务/行业 | 抽象为通用描述 | 「XX检测」→「多标签分类场景」 |
| 具体分类 | 用 ABC 等替代 | 「类别1、类别2」→「分类A、分类B、分类C」 |
| 具体数值 | 用范围或示例说明 | 「每日 N 人」→「每日一定规模」 |
| 公司/产品 | 不出现具体名称 | 删除或替换为「某系统」 |

## 7.7 符号与公式体例(必遵)

撰写 **3.4.1**、**3.5** 及全文含 LaTeX 的段落时须遵循;正/反例与符号表示例见 **`template_reference.md` §3.4.1**。

### 符号表先行

- 撰写 **3.4.1** 或全文含 LaTeX 时,**先写符号与变量定义**(可按(1)任务/对象下标、(2)节点/环境下标、(3)标量与向量分组),每项至少含:**符号、含义、下标含义(如 \(i\)=任务、\(j\)=节点)、量纲或取值范围**。
- 其后公式、**3.5 参数表**、**第六章实施例**中出现的符号须与符号表 **同形、同义**。
- **禁止同一字母多义**:例如任务侧权重用 \(b\),节点侧饱和度应改用 \(g\)、\(h\) 等其它字母,勿任务/节点共用 \(b\) 表示不同物理量。

### 下标与上标

- **资源维度/类型标签**(cpu、mem、io、peak 等)一律写入 **下标**,维度名用 `\mathrm{cpu}`、`\mathrm{mem}` 等正体,例如 `b_{i,\mathrm{cpu}}`、`a_{j,\mathrm{mem}}`。
- **禁止**用 `^{cpu}`、`^{mem}`、`^{io}` 等 **上标** 表示维度或字段名(易被读作幂次,亦不符合专利正文常见写法)。
- **上标仅用于**:幂次、转置、序号、撇号类标记;**下标用于**对象编号与维度标签。

### LaTeX 分隔与写法(全文统一)

- **行内公式**:全文统一 **`\(...\)`** 或 **`$...$`** 二选一,**不得混用**。
- **块级公式**:全文统一 **`\[...\]`** 或 **`$$...$$`** 二选一,**不得混用**。
- 比较符优先写 `\leq`、`\geq`(定稿工具可兼容 `\le`/`\ge`,正文仍推荐 `\leq`/`\geq`)。
- 块级公式尽量 **单行写完**;需编号时用 `\tag{1}` 或正文写「式 (1)」,**全文择一**并保持体例一致。
- 逻辑连接词(「且」「或」)优先写在公式 **外** 的中文叙述中;若必须写入公式内,用 `\land`/`\lor`,**避免** `\text{且}` 等复杂文本命令(定稿渲染易失败)。

### 跨节一致

- **3.4.1 符号表**、正文首次定义式、**3.5「符号」列**、**第六章实施例** 四处须 **逐字同形**。
- 修改任一处符号时,须同步核对上述各处及 3.3/3.4 文字叙述中的同名变量。

---

## 定稿交付:Markdown + Word(必做)

**版本与覆盖**:凡交付均以 **§7.3 第 5 点**「**案件名 + 时间戳**」落盘,同目录下多文件即版本历史。**可选**:另存 `versions/` 手工快照非强制。

须**同时**交付:

1. **Markdown**:定稿 `.md` **保留** `` ```mermaid`` 围栏源码,并含 ``<!-- ![图示](…png) -->`` 注释引用(由 `mermaid_render.py` 生成)。
2. **Word**:对上一文件执行 `md_to_docx.py`,或使用一条命令:

`python3 scripts/disclosure/mermaid_render.py -i <含图示的草稿.md> -o "<案件名_YYYYMMDDHHmmss>.md"`(默认在同目录生成**同名** `.docx`;可用 `--docx` 指定路径,`--no-docx` 跳过 Word。Word 失败时见终端提示的手动命令。)

(mermaid 依赖 Node/mmdc、`pip install -r requirements.txt` 见 `references/disclosure/tooling.md`。)

## 7.6 交付回复:权利要求偏向点(建议交互,必做)

每次向用户**交付**本技能产出的定稿(已写明 **`{案件名}_{YYYYMMDDHHmmss}.md` / `.docx` 路径**)时,在**同一条对话回复**中**追加**一段**仅供用户选用**的交互引导(**不得**写入交底书正文、不得出现在 `.md`/`.docx` 内)。

**须交代清楚:**

1. 用户若希望对**第五章「技术关键点和欲保护点」**做更贴近**权利要求书撰写习惯**的强化,可**用一句话说明侧重点**。
2. 承接方式:Agent **`Read`** **`iteration_context.md`**,再按 **`merger.md`** 以**当前交付稿为基准**合并,**另存**新时间戳 **`.md` / `.docx`**(§7.3 第 5 点),并维护 **`交底书修订对话记录.md`**。
3. **禁止捏造偏向**:对话里提出的「可对举的两类侧重点」**必须**能从**当前定稿与上游已用材料**中推出——包括 Step 2 扫描文档、Step 3–4 已整理专利点、**第三至五章已写明的技术方案与保护点表述**;**不得**为了凑交互而编造本案未涉及的场景、模块或行业词。若全文仅有一条清晰保护主线,**只须忠实概括该主线**并询问是否改为更「方法/系统/流程步骤」或更「装置/模块」等**书式侧重**(仍须对应文中已有结构,不新增技术事实)。
4. 在满足上条前提下,可给出**两组可对举的偏向**(用语须**摘编或概括**自正文已有概念,而非套用泛例);句式可参考:

> 若您希望权利要求/保护点表述更偏「……」或更偏「……」(**二者均须与本稿已阐述的技术路线或保护点一致,仅为书式或强调重心之择**),请说明侧重点;我可按 **`iteration_context.md`** 与 **`merger.md`** 另存一版,对**第五章做权利要求书式强化**(无新材料时其它章节以衔接一致为前提,尽量保持既有结论)。

**合并 / 纠正迭代**若再次交付定稿,**仍须**附带本节同类引导(可缩短为 1~2 句,但须保留「第五章」「新时间戳」「iteration_context + merger」三要素之一或等效说明,且**仍遵守「不捏造」**)。
references/disclosure/disclosure_preview.md
# 交底书生成前摘要预览(Step 6)

## 目的

在输出完整长文前降低返工:先展示结构化摘要,请用户 **确认 / 调整方向**。

若用户明确要求跳过预览,可直接进入 `disclosure_builder.md` 生成全文。

## 摘要应包含

- 选定专利点名称(工作标题)
- 解决的技术问题:对应现有技术缺点,1–3 条
- 核心创新模块或步骤(3–6 条)
- 与检索到的**最相近**现有技术的区别(1 段)

## 可选确认语

```
以上方向是否确认?确认后将按模版输出完整技术交底书,含 mermaid 系统框图与流程图(定稿时将转为 PNG 并同时交付 .md 与 .docx)。
```

## 禁止写入

摘要 Markdown **末尾不得**追加技能仓库名、`examples/` 路径、「教学示例」「虚构」「不构成法律承诺」等脚注(与 `disclosure_builder.md` 对定稿的要求一致)。
references/disclosure/disclosure_self_check.md
# 交底书生成后自检(Step 8,内部执行)

自检结果用于**修订正文**,默认**不单独输出自检报告**;用户索要时可单独提供。**不得**将自检清单作为交底书一章写入正文。

## 8.1 逻辑与闭环

- [ ] 技术方案是否形成完整闭环(发现→处理→扩展/输出)?
- [ ] 模块之间关联关系是否清晰?上下游是否衔接?
- [ ] 分支样本(如边界样本、低置信度样本等)是否有明确处理路径?
- [ ] 依赖项(如类别中心、动态阈值等)是否有来源说明?
- [ ] 第五章「技术关键点」与第三章「系统流程」是否呼应?第五章以论点概述为主,技术细节在第三章。

## 8.2 公式与参数一致性

**Step 8 必做(含公式时)**:除符号体例与跨节一致外,须**主动复核公式是否正确、公式逻辑是否与第三章叙述一致**(等同用户会提出的「检查公式和公式逻辑是否有误,有误请调整」);发现问题**直接改稿**,勿仅提示用户自行修改。

### 符号与体例

- [ ] **符号表(3.4.1)**:全文含公式时是否已设 **3.4.1** 并 **先定义符号**(含义、下标、量纲);式 (1) 及后文每个符号是否均已在表中定义?
- [ ] **维度下标**:是否存在 `^{cpu}`、`^{mem}`、`^{io}` 等 **上标表示维度** 的写法?若有须改为 `b_{i,\mathrm{cpu}}`、`a_{j,\mathrm{mem}}` 等 **下标 + `\mathrm{}`** 形式(见 **`disclosure_builder.md` §7.7**)
- [ ] **字母多义**:同一字母是否兼指任务侧与节点侧等不同对象?若有须拆分符号(如任务用 \(b\)、节点用 \(g\))
- [ ] **公式表述一致**:同一物理量在不同公式、段落中是否 **同形同义**(如权重公式、调整系数、匹配分 \(M_{ij}\) 等)
- [ ] **LaTeX 体例**:行内/块级分隔符是否全文统一(`\(...\)`/`\[...\]` 或 `$`/`$$` 二选一);块级公式是否尽量单行;是否避免 `\text{且}` 等易渲染失败写法
- [ ] **3.5 与符号表**:3.5 参数表「符号」列是否与 3.4.1 **逐字同形**
- [ ] **阈值范围**:与正文一致(如 0.5–1.5、0.8–1.2 等)
- [ ] **参数命名**:全文统一,避免同义不同名混用
- [ ] **实施例数值**:与 3.5 关键技术参数对应,不冲突

### 公式正确性与逻辑(必核)

- [ ] **式面正确性**:各式是否无明显笔误(运算符、括号配对、上下标错位、同一式内符号与符号表不符、缺项/多写项、指数或分母写错等)?
- [ ] **约束与不等式方向**:公式中的 `\leq`/`\geq`/正负号是否与文字含义一致(如「饱和度」「有余量」「限频间隔」等);多条件「且/或」是否与 3.4 流程分支一致?
- [ ] **公式与流程互推**:3.4.1 主公式(含式 (1))及衍生式,能否与 **3.4 流程说明**(各步骤 S1—Sn)、**3.3 模块职责** 逐步对应?叙述中的打分、重排、限频、窗口聚合等是否在公式中有体现,**无**「文字一套、公式另一套」?
- [ ] **边界与特殊情形**:空队列、单节点、分母为零、指标缺失、达到上下界等情形,公式或正文是否给出合理处理或说明(至少不与主公式矛盾)?
- [ ] **量纲与取值**:符号表声明的量纲/取值范围是否与公式用法一致(如权重为正、比例在 0—1、计数为非负整数等)?
- [ ] **修订联动**:若修正公式或逻辑,是否已同步改 **3.4 文字**、**3.5 参数**、**第六章实施例** 及 **3.4.1 符号表**(见 §7.7 跨节一致)?

## 8.3 格式与引用

- [ ] **迭代路径**:若本次走 `merger.md` / `correction_handler.md`,对话中是否已含 **`## 合并摘要(留档)`** 或 **`## 纠正摘要(留档)`**(按该 prompt 字数要求)
- [ ] **修订对话记录**:案件目录是否已追加 **`交底书修订对话记录.md`** 一条(含记录时间、用户说明摘要、本轮交付文件名、摘要摘录),见 **`iteration_context.md`**
- [ ] **交付文件名**:凡落盘交付的交底书是否均为 **`{案件名}_{YYYYMMDDHHmmss}.md`** 及同名 `.docx`(§7.3 第 5 点,**含首次定稿与迭代**),未无故覆盖旧稿
- [ ] **系统框图与流程图**:均为 fenced mermaid,定稿已用 `mermaid_render.py` 转 PNG,**无** ASCII 文字流程图/框图;`.md` 已交付,`.docx` 已生成或已按提示手动 `md_to_docx.py` 补全
- [ ] 章节引用:如「详见 3.4.1」须指向真实存在的章节
- [ ] **1.1 现有技术**:每个方向下列举的专利/文献是否均附有**可访问且与著录项一致**的公开 URL(非编造);是否与 `prior_art_search.md` 要求及 1.1「检索说明」自洽;**检索说明**是否**未**出现 `cnipa_epub_search.py`、WebSearch/降级等内部流程用语
- [ ] **查新摘要(国知局)**:凡 **`EPUB_HITS_JSON`** 中带非空 **`abstract`** 的条目,1.1 对该条的方案概括与局限**是否体现对摘要的理解**(非仅标题、且不与摘要矛盾);无 `abstract` 的条目是否在查新路径上已按 `prior_art_search.md` 用其它可核验来源补全理解
- [ ] **文末清洁**:正文**无**技能仓库名、`examples/`、`disclosure_draft` 路径、「教学示例」「虚构」「不构成法律承诺」等**元信息脚注**(若存在则删除)
- [ ] **权利要求偏向点(对话)**:凡**定稿交付**的对话回复,是否已按 **`disclosure_builder.md` §7.6** 补充「可选下一步」类建议交互(**仅对话**,不入正文);其中对举的侧重点是否**源于本稿与已定观点**,**无**凭空捏造

## 处理原则

发现问题则**直接修订正文**后再交付。
**公式类**:Step 8 须完成 §8.2「公式正确性与逻辑」核对;有误则改公式并联动修订相关章节,**不要**只向用户报告问题而不改稿(除非缺少必要技术事实、无法从正文与材料推断正确写法)。
references/disclosure/intake.md
# 边界与输入录入(Step 1)

## 用途

在开始专利挖掘与交底书撰写前,用**少量问题**收敛案件边界。信息不全时可跳过,由 Agent 后续推断并**注明假设**。

## 可选开场

```
为便于挖掘专利点与组织交底书,先确认几项边界;可跳过,将按已有材料推断。
```

## 问题序列

### Q1:技术主题或产品模块

```
请用一句话描述本方案所属技术主题或产品模块,例如「多模态检索排序」「工业质检缺陷分级」。
```

### Q2:专利类型倾向

```
是否已有倾向的权利要求类型?方法 / 系统 / 装置 / 暂不确定
```

### Q3:文头联系人

```
交底书文头的技术联系人是否需要占位?需要则提供姓名/电话/邮箱;不需要则全部写「待填写」。
```

## 汇总

将已确认项用 3–6 行 bullet 复述给用户,再进入 `project_scan.md` 对应的扫描阶段。
references/disclosure/iteration_context.md
# 迭代上下文(进入 merger / correction 前必读)

## 本文作用

约定**迭代时先干什么、产出什么**,避免 Agent 只读了合并/纠正模板却转去跑 **Step 3–4 专利点分析**或空泛「更新分析」而不落盘新稿。

---

## 何时读本文

用户明显在**已有交底书或上一轮交付稿**上继续工作时(补材料、改章节、纠错、调保护点表述等),在 **`Read` `merger.md` 或 `correction_handler.md` 之前**先读本文,再读对应迭代模板。

| 意图 | 下一步模板 |
|------|------------|
| 补充文档、扩展方案、合并新材料 | `merger.md` |
| 指出错误、与事实/参数不符、风格或保护点调整 | `correction_handler.md` |
| 用户已按 `disclosure_builder.md` §7.6 声明侧重点,仅需 **第五章权利要求书式强化**(取向须与本稿已有材料及第五、三章已写观点一致,**禁止**为交互而编造新场景) | `merger.md`(以最近定稿为基准,合并范围以第五章为主,必要时微调第四章与第五章衔接句) |

---

## 输入与输出

**输入**

- 对话中的本轮说明;用户 **@** 的文件或粘贴片段。
- **`Read`** 当前作为基准的交底书 `.md`(路径由用户给出或对话中已出现)。

**输出**

- 合并或纠正后的全文写入**新文件**,不得默认覆盖旧稿:
  **`{规范化案件名}_{YYYYMMDDHHmmss}.md`** + **`mermaid_render.py` 生成的同名 `.docx`**。
- 与 **Step 7 首次定稿**为**同一命名规则**(凡落盘交付均带时间戳),详见 **`disclosure_builder.md` §7.3 第 5 点**。
- 旧版 `.md`/`.docx` 保留在同目录,便于对照(用户明确要求覆盖时再覆盖)。

版本历史依赖**同目录下多个带时间戳的文件**,不需要 `iterations/` 子目录或快照脚本。

### 修订对话记录(单独 Markdown,必做)

每完成一轮 **合并**或**纠正**并在磁盘上写出新 `.md`/`.docx` 后,须在**案件产出目录**(与本轮交付文件同一目录,如 `outputs/{案件标识}/`)维护**一个固定文件**:

- **文件名**:`交底书修订对话记录.md`(默认;若环境对中文路径敏感,可用 `--log-name disclosure_revision_log.md` 调用脚本)。

**每条记录须含**:

1. **记录时间**:**本地时间**与 **UTC**(脚本自动生成;手工追加时两者都写)。
2. **类型**:合并迭代 / 纠正迭代。
3. **用户说明摘要**:本轮用户意图、要点(可含 @ 文件名称)。
4. **本轮交付文件**:新时间戳 `.md`、`.docx` 文件名。
5. **合并/纠正摘要摘录**:与当轮对话中「合并摘要(留档)」「纠正摘要(留档)」一致或为其缩写。

**推荐**:在写出交付文件并生成 Word 之后,执行 **`Bash`**:

```text
python3 scripts/disclosure/iteration_dialog_log.py --case-dir "{案件目录}" --kind merge --user "{用户说明摘要}" --summary "{摘要摘录}" --artifacts "{案件名_时间戳.md},{案件名_时间戳.docx}"
```

`--kind` 纠正时用 `correct`。若无法执行脚本,须 **`Read`** 已有 `交底书修订对话记录.md`(若无则 **`Write`** 创建),再 **`StrReplace`** 或等价方式在文末**追加**与上表结构相同的一条(时间须真实)。

**禁止**:完成迭代交付却**完全不**更新该对话记录文件。

---

## 建议执行顺序(短清单)

1. 读本文 → 按上表选 `merger.md` 或 `correction_handler.md` 并 **`Read`**。
2. **`Read`** 基准稿 + 本轮补充材料。
3. 在稿内完成合并或纠正逻辑(自检 **8.2、8.3** 见 `disclosure_self_check.md`)。
4. **`Write`** 新时间戳 `.md` → 运行 **`mermaid_render.py -o`** 写出定稿图与 **`.docx`**。
5. 追加 **`交底书修订对话记录.md`**(**`iteration_dialog_log.py`** 或手工),见上文「修订对话记录」。
6. 在回复中写明新文件路径,并输出该模板要求的 **「合并摘要(留档)」**或 **「纠正摘要(留档)」**。

---

## 禁止

- 已判定为迭代意图时,**不**经合并/纠正流程、不把结果写入**新时间戳文件**,却去跑全文专利点挖掘或仅输出分析段落。
- 例外:用户**明确要求**「重新挖掘专利点 / 从头再走查新」时,可走主流程 Step 3 起。
references/disclosure/merger.md
# 迭代模式:增量合并(修订与补充)

## 执行门禁(优先执行,不可跳过)

1. **`Read`** **`references/disclosure/iteration_context.md`**(迭代时读用户意图 + 已有稿路径,**不要**跳过合并去跑专利点分析)。
2. **`Read`** 用户给出的**当前定稿** `.md`(及本轮 @ 的补充材料)。
3. **落盘**:合并结果须写入**新文件**,文件名为 **`{案件名}_{YYYYMMDDHHmmss}.md`**,并经 `mermaid_render.py`(或等价流程)生成**同名** `.docx`,规则见 **`disclosure_builder.md` §7.3 第 5 点**。**禁止覆盖**上一轮交付文件(除非用户明确要求覆盖)。

**禁止**:在已判定为「在已有稿上迭代」时,去跑 Step 3–4 专利点全文分析、或仅泛泛「更新专利分析」而**未**把合并结果写入用户案件目录下的**新带时间戳文件**。**除非**用户明确要求重新挖掘/重写专利点。

## 何时启用

由 Agent 根据用户**意图**判断:**在已有交底书或上一轮输出上补充新材料**(新文档、新代码说明、粘贴片段、扩展章节等),且以**合并进现有结构**为主时,**应**执行本流程;用户按 **`disclosure_builder.md` §7.6** 仅要求**第五章权利要求书式强化**、已说明侧重点者,同样走本流程(合并范围以第五章为主)。**不要求**用户说出「迭代」等固定词;也**不必**先询问是否进入迭代模式。

## 流程

1. **识别增量**:新内容主要影响哪些章节:背景、1.1 现有技术、3.4 流程、实施例等。
2. **非破坏性合并**:以**追加或局部重写**为主,不推翻未涉及且用户未要求修改的章节。
3. **查新联动**:若增量改变了技术实质,判断是否需要**补充检索**并更新 1.1 / 区别论述。
4. **一致性**:合并后执行 `disclosure_self_check.md` 中的 **8.2、8.3** 快速检查;若涉及 **3.4.1 公式/3.5 参数**,须同步核对 **`disclosure_builder.md` §7.7**(符号表、维度下标、3.5 符号列同形)。
5. **落盘**:将合并后的全文写入 **`{案件名}_{YYYYMMDDHHmmss}.md`**,再经 `mermaid_render.py` 生成同名 `.docx`(§7.3 第 5 点)。
6. **对话记录**:按 **`iteration_context.md`**「修订对话记录」在案件目录追加 **`交底书修订对话记录.md`**(优先 **`scripts/disclosure/iteration_dialog_log.py --kind merge`**)。

## 输出(强制,不可省略)

在交付修改后的正文(或说明已写入路径)之后,**必须在同一条回复中**追加独立小节,**标题固定为**:

## 合并摘要(留档)

其下用 **3–6 句完整中文**,依次说明:**改了哪些章节**、**原因**、**是否影响保护点或检索结论**、**是否已做 8.2/8.3 核对**。
若未输出本节,视为未完成本 prompt。

**定稿延续**:若本轮合并结果作为**向用户交付的定稿**,在**同一条回复**中于上文之后,**还须**按 **`disclosure_builder.md` §7.6** 补充「权利要求偏向点」建议交互(可缩写),**不得**写入交底书正文;交互中的对举选项**须**来自本稿已有论述,**禁止捏造**(见 §7.6 第 3 点)。

## 与「纠正」的区别

- **merger**:侧重新材料、新功能的**扩展**。
- **correction_handler**:侧重用户指出**错误、风格或与事实不符**的修正。
references/disclosure/patent_points_analyzer.md
# 专利点挖掘与融合(Step 3–4)

## Step 3:候选专利点

- 列出 **3–5 个**候选专利点。
- 每个专利点需说明:
  - 技术背景
  - 创新点
  - 与现有技术区别(可先基于材料推断,查新后在第一章收紧)
  - 可实施性
- 可基于已有事实**适度推演**,但须有技术合理性支撑。

## Step 4:融合与选定

- 检查多个专利点是否可合并创新要素。
- 将相关技术点融合为**方法+系统**类专利(如适用)。
- 突出「组合创新」:若干要素有机结合形成的独创性方案。

### 输出策略

**默认**:最终优先产出**最有价值的一篇**交底书。

若用户明确要求多篇:先列多篇大纲(标题 + 核心区别),再与用户约定撰写顺序。

### 选定依据

- 创新性、可授权性
- 与查新结果的差异化(查新完成后复核)
- 技术方案完整、可实施
- 保护范围合理
references/disclosure/patent-disclosure-skill-MIT-LICENSE.txt
MIT License

Copyright (c) 2026 handsomestWei

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
references/disclosure/prior_art_search.md
# 联网检索查新(Step 5)

## Contents

- [必做时机](#必做时机)
- [检索渠道(优先国知局公布公告站,再降级 WebSearch)](#检索渠道优先国知局公布公告站再降级-websearch)
- [分析要求](#分析要求)
- [记录习惯](#记录习惯)


## 必做时机

生成交底书全文**之前或生成过程中**必须执行;检索结论写入第一章 **1.1 现有技术** 及与本案的**区别论述**。

## 检索渠道(**优先国知局公布公告站,再降级 WebSearch**)

### A. 中国专利公布公告(**优先**,官方站点)

1. **站点**:[国家知识产权局 中国专利公布公告](http://epub.cnipa.gov.cn/)(**仅** `epub.cnipa.gov.cn`)。
2. **工具**(本技能 `scripts/disclosure/`):**`cnipa_epub_search.py`** —— **一步**完成公布站检索与结果解析(Playwright 过站点 WAF);结果页 HTML **仅在内存中处理,不落盘**。成功时终端含 **`EPUB_NOTE:`**(ASCII,如 `html_bytes=… disk=0`)与 **`EPUB_HITS_JSON:`** 一行(JSON 数组:标题、公开号、链接、**`abstract`** 等)。
3. **国知局检索词(生成阶段必做,须在拼 Bash 之前完成)**

   - **拆分责任在 Agent**:在**生成/构造命令阶段**,从本案技术方案、专利点或用户主题中归纳 **2~8 个与方案相关度高的检索单位**,**仅用 ASCII 空格分隔**,再写入 `cnipa_epub_search.py` 的参数。每一单位宜为 **有检索意义的语义块**,例如:**专业术语**、**名词短语**、**名动组合(如「批量调度」「异构调度」)**、**业内固定搭配**;**不要**拆成过碎的单字、泛义双字(如单独 `检索`、`增强`、`系统`、`方法` 等泛词),也**不要**把无关联词硬凑成一串。
   - **禁止**把**无空格的一整句长中文**当作**唯一**参数(例如不要:`".../cnipa_epub_search.py" "知识库检索增强大语言模型"`)。长串在公布站单框内易被当作整句 AND,**极易 0 条**。
   - **Agent 执行时**:**每一轮 `Bash` 只传一个**检索单位(一个词块一句参数);**2~8 个单位须对应 2~8 次**独立调用,**禁止**在一次工具调用里把多个词块同时作为多个 argv 传给 `cnipa_epub_search.py`(脚本虽支持多词单次进程内合并,**仅供本地/人工**;Agent 为控时、降单次 Playwright 链路与 IDE/终端超时风险,**必须**拆进程)。
   - 示意(须按本案替换;**三次调用、每次一词**):

     ```bash
     python3 …/cnipa_epub_search.py 知识库
     python3 …/cnipa_epub_search.py 检索增强
     python3 …/cnipa_epub_search.py 大语言模型
     ```

   - **脚本不做**自动分词或自动拆长中文;若确需**整句一次** AND 检索,改用 **`cnipa_epub_crawler.py`** 单传一句。

4. **执行方式**(Step 5 在读完本文件后**先尝试**):

   ```bash
   pip install -r scripts/disclosure/requirements-cnipa.txt
   python -m playwright install chromium
   # Agent:对上一节每个检索单位各执行一次(示例仅展示首轮)
   python3 scripts/disclosure/cnipa_epub_search.py 词甲
   ```

   - **合并责任在 Agent**:每次调用解析 **stdout** 上**唯一一行** **`EPUB_HITS_JSON:`** 后的 JSON 数组;在推理中按 **`pub_number`** 为主键去重合并(无则 **`link`**,再否则可用标题前缀),得到**一份**总表后再写入查新笔记与 1.1。
   - **`cnipa_epub_search.py`** 若人工单次传入多词,会按空白拆段、进程内**一段一查**并去重(**stderr** 可出现 **`EPUB_MERGE:`**);与 Agent **分多次调用**策略无关。
   - 成功时 **stdout 仅一行** **`EPUB_HITS_JSON:`** + JSON 数组(UTF-8,含中文 `abstract`);**`EPUB_MERGE:`** / **`EPUB_NOTE:`** / **`EPUB_HINT:`** 等在 **stderr** 且为 **ASCII**(减轻 PowerShell 把中文 stderr 当成错误流)。解析命中时请以 **stdout 该行 JSON 为准**,勿因 stderr 或终端编码误判「未命中」而不必要地降级 WebSearch。Windows 乱码与 PowerShell 注意见 **`references/disclosure/tooling.md`**(`chcp 65001` / `PYTHONUTF8=1`、勿滥用 `2>&1`)。
   - 将 JSON 中**可核验**的公开号、标题、**国知局站点内详情链接**写入查新笔记与 1.1(见下 **`abstract` 必用**)。
   - **降级条件**(满足任一则进入 **B**):命令非 0 退出、超时、无 Playwright、**`EPUB_HITS_JSON` 为空数组**、或条目经人工核对明显与主题无关。

5. **`abstract` 字段(国知局条目,规定必用)**

   若 **`EPUB_HITS_JSON`** 中某项含非空的 **`abstract`**(解析自公布站结果页摘要),对**该条专利**须同时遵守:

   - **必用**:查新笔记、交底书 **1.1** 中对该专利的**技术方案概括、应用场景与局限性分析**,**必须先基于对该 `abstract` 的完整阅读与理解**后再撰写;**禁止**仅凭标题、公开号或 URL **臆造**方案要点或与摘要矛盾的表述。
   - **充分理解**:在写入 1.1 或查新笔记前,Agent 须在**推理过程内**明确:摘要所涉**技术领域、解决什么问题、核心手段/模块、主要效果或流程**;若摘要与标题存在差异,**以摘要为准**概括该技术。
   - **正文呈现**:交底书 1.1 中**不得**大段逐字粘贴官方摘要(避免抄袭与超字数);应**消化后**用**自己的话**压缩为「方案概括 + 应用 + 缺点/局限」;查新笔记可保留稍长的摘录供自用核对,但须标注来源于公布站摘要。
   - **缺失时**:若某条 JSON **无** `abstract` 或为空(旧版页面 / 表格布局未解析到等),须在查新笔记中注明「该条无摘要字段」,并改用**详情页**或 **Google Patents** 等可核验来源补全理解后再写 1.1,**不得**留空理由含糊带过。

6. **链接与著录**:国知局详情 URL 以脚本输出为准;**禁止编造**;若仅能从公布站得到公开号,可再配 **Google Patents** 稳定页 `https://patents.google.com/patent/CN…/en` 作为**补充**公开源(仍须打开校验)。

### B. Google 学术与 Google Patents(**降级 / 补充**)

在 **A 不可用或结果不足**时启用,与历史约定一致:

1. **中文文献与学术**:[Google 学术搜索](https://scholar.google.com)(`scholar.google.com`)。
   - 用**中文关键词**、技术方案核心术语、应用场景;可组合 2–3 组查询。
   - 强化「中国」语境时可加:`中国`、`site:.cn`、`专利`、`CN`(与专利号区分使用)等,以实际命中为准。
   - 通过 **WebSearch** 或浏览器可用能力检索 Scholar;结果中优先选用**可打开、与标题/作者匹配**的条目链接。
2. **中国专利公开文献(补充)**:[Google Patents](https://patents.google.com/) 检索中文标题、申请人或公开号(`CN…A` / `CN…B` 等),每条使用**稳定著录页 URL**。
3. **其它来源**:英文文献、非中国专利等可继续用 Google Patents、出版社页面、DOI、arXiv 等 + WebSearch。
4. **关键词构造**:技术方案核心术语、应用场景与方法名称,可组合 2–3 组查询。

## 分析要求

对检索到的、与方案**高度相关**的现有专利或公开文献逐项概括:

- 专利号 / 文献标识
- 技术方案要点(**若为国知局 JSON 且含 `abstract`,要点须与摘要理解一致**,见上文「`abstract` 必用」)
- 应用场景
- **局限性**
- **公开源 URL(必填)**:每一条必须附带**至少一个可公开访问、与著录项一致**的链接,写入查新笔记与交底书 1.1,便于代理人复核。**禁止编造或猜测 URL**;写入前应在浏览器中打开确认页面可访问且对应同一文献/专利。

### 链接来源与格式(须准确)

| 类型 | 推荐 URL 形式 | 说明 |
|------|----------------|------|
| 美国等专利(公开出版物号) | `https://patents.google.com/patent/US20240118920A1/en` | 将 `US20240118920A1` 替换为实际公开号;以 Google Patents 页面能打开且标题/摘要匹配为准。 |
| 中国专利 | **`https://patents.google.com/patent/CNXXXXXXXXXA/en`**(或对应 B 型等) | **优先**国知局公布站或 Google Patents 稳定著录页;勿依赖易过期的检索会话 URL。 |
| 学术论文(含 Scholar) | Scholar 条目页、出版社官方页或 **`https://doi.org/10.xxxx/...`** | Scholar 链接若重定向或镜像,以最终可长期解析的 DOI/出版社页为准。 |
| arXiv 预印本 | `https://arxiv.org/abs/2008.09213` | `abs` 页为规范条目页;勿用未经验证的镜像域名冒充官方。 |
| 期刊 / 会议 | 出版社 DOI:`https://doi.org/10.xxxx/...` 或官方摘要页 | 以 DOI 解析后页面与文献一致为准。 |

文末给出:**检索总结**与**本发明与现有技术的本质区别**,与 1.1 结尾及 1.2 缺点呼应。

## 记录习惯

便于写进交底书:保留专利号、标题、**消化摘要后的**一两句方案概括(有 **`abstract`** 时概括须可追溯至该摘要);**每条另起一行或表格列给出「来源 URL」**。避免大段抄袭权利要求或整段粘贴官方摘要。

### 1.1「检索说明」写法(交付正文,必遵)

写入交底书 **1.1** 开头的「检索说明」时,面向**代理人/审查员**表述,**不要**暴露 Agent 查新流程或本技能工具实现。

- **须写**:实际使用的**公开数据库或渠道名称**(如「国家知识产权局专利公布公告系统」)、本案**主要检索词**(与 Step 5 用词一致或概括);若部分条目经 **Google Patents** 等公开页复核著录项,可一句带过。
- **禁止写入 1.1 正文**:脚本/文件名(如 **`cnipa_epub_search.py`**、**`cnipa_epub_crawler.py`**)、「查新优先使用…检索工具」「是否触发 Google 学术降级」、Playwright、WebSearch、Agent、技能仓库名等**内部或流程元信息**。
- **示例(须按本案替换检索词与渠道)**:

  > 检索说明:在**国家知识产权局专利公布公告系统**及 **Google Patents** 中,以「批任务调度」「异构集群调度」「任务队列重排」「负载感知调度」等为检索词进行检索;部分条目的公开文本与著录项以 Google Patents 页面复核。

查新笔记(Agent 内部或对话留档)仍可记录是否调用脚本、是否降级 WebSearch;**上述内容不得原样抄进交底书 1.1**。
references/disclosure/project_scan.md
# 项目文档扫描(Step 2)

## 目标

按优先级扫描并提取**可专利化**内容。**根据当前项目结构调整扫描路径**。

## 优先级表

| 优先级 | 文档类型 | 关注内容 |
|--------|----------|----------|
| 1 | 专利相关文档 | 专利点分析、已有交底书、专利申报建议、创新点汇总 |
| 2 | 详细设计/方案文档 | 详细设计、方案讨论、流程图、完整流程、技术对比分析 |
| 3 | 核心实现代码 | 算法与策略实现、业务逻辑与流程编排、数据处理与转换、规则引擎与决策逻辑、接口与集成设计、状态机与调度机制、性能优化与缓存策略、安全与权限控制等(依项目领域灵活识别) |
| 4 | 系统设计文档 | 系统设计、架构说明、模块划分、数据流与控制流 |

## 扫描目标目录模版

执行时按项目实际目录填写:

```
[项目根目录]/
├── [专利或文档目录]/     ← 专利点分析、交底书、申报建议
├── [设计文档目录]/       ← 详细设计、方案讨论、流程图、技术对比
├── [代码目录]/           ← 算法实现、业务逻辑、规则引擎、接口与集成、调度机制等
└── [根目录]/             ← 系统设计、架构说明、模块与数据流
```

## 执行提示

- 大仓库先用搜索 / 语义检索定位关键文件,再精读。
- 记录**引用路径或文件名**,便于在交底书中写「参见某设计」时脱敏表述。
- 凡出现 **`.docx` / `.pptx`**,**必须**按下一节 **「Office 文档」** 先转 Markdown 再读,不可跳过或只扫纯文本而漏掉 Office。

## Office 文档(.docx / .pptx):必先转换再读

**格式**:脚本仅支持 OOXML(**`.docx` / `.pptx`**)。旧版 **`.doc` / `.ppt`** 须先在 Office / WPS 中**另存为**新格式后再走下列流程。

Agent **不得**因「只能舒适读取文本」而**遗漏**项目内的 Word / PPT:**必须先转为 Markdown 再纳入扫描**,不能只扫 `.md` 与源码。

1. **发现**:在扫描目录内 **`Glob` 或列举** `*.docx`、`*.pptx`(含子目录,如 `docs/sample_*.docx`)。
2. **转换(本技能脚本)**:在技能根目录或给出脚本绝对路径执行(路径按实际替换):

   ```bash
   python3 scripts/disclosure/docx_to_md.py -i "<路径>/<名>.docx" -o "<同目录或 docs>/<名>.md"
   python3 scripts/disclosure/pptx_to_md.py -i "<路径>/<名>.pptx" -o "<同目录或 docs>/<名>.md"
   ```

   需已 `pip install -r requirements.txt`。输出旁会生成 **`{md 主名}_media/`**,内为嵌入图,**以生成的 `.md` 正文与图片引用为扫描依据**。
3. **再读**:**`Read`** 上述新生成的 `.md`(及必要时扫一眼 `_media` 文件名用于脱敏引用),与原有 `.md`、代码**同等对待**,摘要进专利点材料表。
4. **解析重点**:表格、编号列表、**PPT 每页标题与正文**、**Word 修订区以外的正文**、**备注**(`pptx_to_md` 会写入「备注」小节)——均属可专利化叙述来源。

## 图片与裸图目录(跳过单独识图)

- **`sample_assets/`** 等目录下的 **独立 `.png` / `.jpg` / `.webp` 等**:**不作为** Step 2 必须逐个打开、OCR 或描述的对象(与 Word/PPT 内嵌图**通常重复**时更不必重复读图)。
- **例外**:用户**点名**某图片路径,或某图**未**出现在任何已转换 Office 的 `_media` 中且对专利点明显关键时,再按需处理。
- Word/PPT 转换后,嵌入图已在 **`![](相对路径)`** 中体现,**以 Markdown 文本扫描为主**即可。
references/disclosure/template_reference.md
# 专利交底书模版参考(脱敏版)

## Contents

- [文档头部](#文档头部)
- [注意事项](#注意事项)
- [一、技术背景与现有技术](#一技术背景与现有技术)
- [二、本发明所要解决的技术问题](#二本发明所要解决的技术问题)
- [三、技术方案详细阐述](#三技术方案详细阐述)
- [四、与现有技术相比的优点](#四与现有技术相比的优点)
- [五、技术关键点和欲保护点](#五技术关键点和欲保护点)
- [六、其它](#六其它)
- [脱敏检查表](#脱敏检查表)
- [交付正文禁忌(勿写入交底书)](#交付正文禁忌勿写入交底书)
- [公式与参数一致性检查](#公式与参数一致性检查)


本文档为技术交底书格式与章节要点参考,内容已脱敏,适用于多领域专利撰写。由 `disclosure_builder.md` 引用。

---

## 文档头部

```markdown
# 技术交底书

**案件名称**:[待填写]一种XXX方法及系统

**技术联系人**:
- 姓名:[待填写]
- 电话:[待填写]
- 邮箱:[待填写]

**专利类型**:发明

---

## 注意事项

(1)交底书应使代理人能看懂,尤其是背景技术和详细技术方案,一定要写得全面、清楚、完整;
(2)技术的公开程度,应以本领域普通技术人员不需付出创造性劳动即可进行实施为准。
(3)在与代理人沟通时,对于代理人咨询的技术问题,应给予回答并认真讲解,并且按要求及时正确地补充相应技术材料。
```

在用户产出目录保存时,**`.md` / `.docx` 主文件名**应为 **`{案件名称规范化}_{YYYYMMDDHHmmss}`**(占位去掉、非法字符、过长截断及时间戳规则见 `disclosure_builder.md` **§7.3**,**凡交付均须时间戳**),避免与标题无关的固定名。

---

## 一、技术背景与现有技术

### 1.1 现有技术

- 检索渠道、链接格式与禁止事项以 Step 5 **`prior_art_search.md`** 为准(不在此重复)。
- **检索说明**(建议置于 1.1 开头):写**公开数据库名称**与**检索词**;**勿**写 `cnipa_epub_search.py` 等脚本名或「查新降级」等流程用语(见 **`prior_art_search.md`「1.1 检索说明写法」**)
- 按**技术方向**分类列举(如:单标签方法、多标签方法、聚类策略等)
- 每条现有技术需包含:专利号 / 文献标识、申请方(或来源机构)、技术方案、应用场景、**局限性**、**公开源 URL(必填)**
  - **国知局 `abstract`**:若 Step 5 JSON 含 **`abstract`**,该条「技术方案」等叙述**必须先充分理解摘要后**再概括(见 **`prior_art_search.md`**);交底书正文勿大段粘贴官方摘要全文。
  - **URL 要求**:与 `prior_art_search.md` 一致——每条**至少一个**可公开访问链接,**写入前验证**有效且与著录项一致;**禁止虚构链接**。
  - **正文呈现建议**:在每条方向下可用「**来源链接**:…」单独一行,或表格增列「链接」。
- 结尾总结:检索总结、**本发明与现有技术的本质区别**

### 1.2 现有技术存在的缺点

- 分点列举,与 1.1 的局限性呼应
- 突出**核心缺陷**:现有技术无法解决的问题

---

## 二、本发明所要解决的技术问题

- 对应一中的缺点,逐条说明本发明的解决思路
- 简明扼要,为第三章详细方案做铺垫

---

## 三、技术方案详细阐述

### 3.1 背景

- 应用场景的通用描述(脱敏:用分类A/B/C、场景X等)
- 本发明针对的问题与核心创新点概述
- 若有人工环节,说明前提条件(如:样本需具有可区分显著特征)

### 3.2 系统框图

- 使用 **fenced mermaid**(推荐 `flowchart TB` / `LR` + `subgraph` 分层);模块名抽象通用,避免业务术语
- 定稿交付前经 **`scripts/disclosure/mermaid_render.py`** 转为 PNG 并**默认**生成 Word;**不需要**再附 ASCII 文字框图(Word 中以图为准)
- 布局宜层次清晰;复杂时可拆多张 mermaid 图

**mermaid 系统框图模版**(替换标题与模块名、连线;与 3.4 相同为 `` ```mermaid`` 围栏):

```mermaid
flowchart TB
  %% [系统名称] — 可在正文用 Markdown 小标题标注
  subgraph top[" "]
    direction LR
    A["[模块A]"]
    B["[模块B]"]
    C["[模块C]"]
  end
  subgraph mid[" "]
    direction LR
    A1["[子模块A1]"]
    B1["[子模块B1]"]
    C1["[子模块C1]"]
  end
  D["[模块D]"]
  E["[模块E]"]
  A --> B --> C
  A --> A1
  B --> B1
  C --> C1
  A1 --> D
  B1 --> D
  C1 --> D
  D --> E
```

### 3.3 模块功能说明

**重点**:各模块的**作用**和**模块间关联关系**,专利不强调输入输出。

- 作用:该模块在整体方案中的角色
- 关联关系:上下游依赖、数据流/控制流、闭环关系

### 3.4 系统流程说明

#### 流程图

- 使用 **fenced mermaid** 代码块;**不要** ASCII 文字/箭头流程图。
- 定稿交付前用仓库 **`scripts/disclosure/mermaid_render.py`**(本地 `mmdc`)转为 PNG 并**默认**生成 Word;失败时按终端提示用 **`md_to_docx.py`** 手动转换。

#### 流程说明

- 用文字简要说明各步骤或与图中节点的对应关系(**不替代**流程图图示)
- 流程涉及算法、评分、约束或形式化变量时,在 **3.4.1** 集中给出符号定义与主公式;须遵守 **`disclosure_builder.md` §7.7**

### 3.4.1 符号与公式

**撰写顺序**:先 **符号与变量定义** → 再 **核心公式**(含式 (1))→ 再文字解释与流程衔接。

#### 符号表示例(Markdown 正文可直接采用)

```markdown
#### (1)任务侧符号

| 符号 | 含义 | 下标/量纲 |
|------|------|-----------|
| \(i\) | 任务索引 | \(i=1,\ldots,N\) |
| \(b_{i,\mathrm{cpu}}\) | 任务 \(i\) 的 CPU 需求权重 | 无量纲,\(b_{i,\mathrm{cpu}}>0\) |
| \(b_{i,\mathrm{mem}}\) | 任务 \(i\) 的内存需求权重 | 同上 |

#### (2)节点侧符号

| 符号 | 含义 | 下标/量纲 |
|------|------|-----------|
| \(j\) | 计算节点索引 | \(j=1,\ldots,M\) |
| \(a_{j,\mathrm{cpu}}\) | 节点 \(j\) 的 CPU 资源饱和度 | 无量纲,\(a_{j,\mathrm{cpu}}\le 0\) 表示有余量 |
```

#### 公式正/反例(体例必遵)

| 场景 | ✅ 推荐 | ❌ 避免 |
|------|---------|---------|
| CPU 维度权重 | \(b_{i,\mathrm{cpu}}\) | \(b_i^{cpu}\)(上标易被读作幂次) |
| 节点饱和度 | \(a_{j,\mathrm{cpu}} \le 0\) | \(a_j^{cpu} \le 0\) |
| 多维度并列 | \(b_{i,\mathrm{cpu}},\, b_{i,\mathrm{mem}}\) | \(b_i^{cpu}, b_i^{mem}\) |
| 块级主公式 | `\[ M_{ij} = \alpha b_{i,\mathrm{cpu}} + \beta a_{j,\mathrm{cpu}} \tag{1} \]` 单行 | 块内多行 `\\` 换行堆叠(渲染易失败) |
| 逻辑连接 | 公式外写「且 \(a_{j,\mathrm{mem}}\le 0\)」 | 公式内 `\text{且}` |

**行内/块级分隔符**:全文统一 `\(...\)` / `\[...\]` **或** `$...$` / `$$...$$` 二选一;与 **`disclosure_builder.md` §7.7** 一致。

### 3.5 关键技术参数

- 置信度/阈值类:含义、取值范围
- 算法参数:公式、约束条件
- 参数表须设 **「符号」列**,与 **3.4.1 符号表逐字同形**(勿在 3.5 改用 `^{cpu}` 而上文用下标)
- 确保与正文公式、实施例数值一致

---

## 四、与现有技术相比的优点

- 先概括性观点,再分点详述
- 与第二章解决的问题、第五章保护点呼应
- 技术细节以第三章为准,本章以论点为主

---

## 五、技术关键点和欲保护点

- 列出核心创新点,每点简明定义
- 详细技术方案引用第三章(如「具体实现见 3.4.1」)
- 避免与第三章重复大段技术细节

---

## 六、其它

### 实施例

- 应用场景(脱敏)
- 已知类别、无标签数据规模(脱敏)
- 系统流程简述
- **技术效果**:量化或定性说明
- **参数设置示例**:注明「不作为权利要求限制」

---

## 脱敏检查表

| 检查项 | 脱敏方式 |
|--------|----------|
| 业务/行业名称 | 抽象为通用描述 |
| 具体分类标签 | 分类A、分类B、分类C 等 |
| 具体数值 | 用「一定规模」「预设值」等 |
| 公司/产品名 | 删除或「某系统」 |

---

## 交付正文禁忌(勿写入交底书)

- **禁止**在全文任意位置(尤其**文末**)加入技能仓库、示例仓库、`patent-disclosure-skill`、`examples/` 路径、「教学/虚构示例」「不构成法律或技术承诺」等**元脚注**;交付物视为正式技术交底书文稿,**止于业务章节**。

## 公式与参数一致性检查

- 全文公式表述统一(如:置信度权重、密度调整系数)
- **符号体例**:资源维度用下标 `_{\mathrm{cpu}}` 等,**无** `^{cpu}`/`^{mem}` 类上标维度写法
- **符号表完整**:3.4.1 已定义符号;式 (1) 及后文每个符号均在表中出现;**无**同一字母多义
- **公式正确性与逻辑**:各式无笔误;不等式方向与文字一致;公式与 3.4 流程/3.3 模块可互推;边界情形不矛盾
- **跨节同形**:3.4.1、3.5「符号」列、第六章实施例与正文公式 **逐字一致**
- 阈值范围一致(如 0.5–1.5、0.8–1.2)
- 参数命名统一(避免同义不同名混用)
- LaTeX 分隔符全文统一(`\(...\)`/`\[...\]` 或 `$`/`$$` 二选一)
- 实施例数值与 3.5 节对应
references/disclosure/tooling.md
# tools / 可选脚本

## Contents

- [国知局公布公告检索(epub.cnipa.gov.cn,Step 5 查新优先)](#国知局公布公告检索epubcnipagovcnstep-5-查新优先)
- [Office 文档(Word / PPT)转成可扫描文本](#office-文档word-ppt转成可扫描文本)
- [mermaid_render.py — mermaid:图示 → PNG + 定稿 Markdown + 默认生成 Word](#mermaid_renderpy-mermaid图示-png-定稿-markdown-默认生成-word)
- [math_render.py — LaTeX 公式 → PNG](#math_renderpy-latex-公式-png)
- [md_to_docx.py — Markdown → Word](#md_to_docxpy-markdown-word)
- [iteration_dialog_log.py — 修订对话记录(迭代用)](#iteration_dialog_logpy-修订对话记录迭代用)
- [docx_to_md.py — Word → Markdown + 抽取图片](#docx_to_mdpy-word-markdown-抽取图片)
- [pptx_to_md.py — PowerPoint → Markdown + 抽取图片](#pptx_to_mdpy-powerpoint-markdown-抽取图片)
- [扩展其它脚本时](#扩展其它脚本时)


本目录存放**可重复执行的辅助脚本**。技能主流程以 `SKILL.md` 与 `references/disclosure/` 为准;本目录侧重格式转换等可执行工具。

## 国知局公布公告检索(epub.cnipa.gov.cn,Step 5 查新优先)

| 脚本 | 作用 |
|------|------|
| **`cnipa_epub_search.py`** | **(Step 5 优先)** 一步:拉取 + 解析,**不写结果页 HTML 落盘**;**Agent 须按 `prior_art_search.md` 分多次调用、每轮一词并自行合并 JSON**;脚本在**单次命令多词**时也会进程内循环检索并合并(人工/本地便利);**stdout 仅一行** `EPUB_HITS_JSON:`;stderr 上 `EPUB_*` 为 **ASCII**;UTF-8 / PowerShell 见 **references/disclosure/tooling.md**。 |
| **`cnipa_epub_crawler.py`** | 仅 Playwright 拉取并**默认保存**结果页 HTML;stdout 亦含 **`EPUB_HITS_JSON:`**。 |
| **`cnipa_epub_parse.py`** | 仅解析已保存的 HTML:`python scripts/disclosure/cnipa_epub_parse.py path/to/_last_result_xxx.html`;字段含标题、公开号、链接、**`abstract`**(若有)。 |

依赖:`pip install -r scripts/disclosure/requirements-cnipa.txt` 与 `python -m playwright install chromium`。环境变量见各脚本文件头。默认结果 HTML 落在 **`scripts/disclosure/_last_result_*.html`**(已 `.gitignore`)。

抓取失败或解析无命中时,Agent 按 **`references/disclosure/prior_art_search.md`** 降级 **WebSearch**(如 Google 学术 / Google Patents)。

---

## Office 文档(Word / PPT)转成可扫描文本

用本技能 **`docx_to_md.py`**、**`pptx_to_md.py`**(纯 Python + 仓库根目录 `requirements.txt`),见下文各节;与 `SKILL.md`「工具与数据来源」一致。

## mermaid_render.py — mermaid:图示 → PNG + 定稿 Markdown + **默认生成 Word**

将 fenced **mermaid**(`` ```mermaid`` ``)逐块交给 **`mmdc`** 渲染为 PNG;输出 `.md` 中**保留** mermaid 围栏源码,并追加 ``<!-- ![图示 n](mermaid_figures/…) -->`` 供 **`md_to_docx.py`** 嵌入 Word(Word **仅**嵌 PNG,不写 mermaid 代码块)。**3.2 系统框图**与 **3.4 流程图**均用 mermaid(`flowchart` / `subgraph` 等),交底书正文**不再**要求单独的文字框图或 PlantUML。

**生图失败降级**:某一围栏 `mmdc` 失败时**不中断**——该处**保留**原 `` ```mermaid`` … `` ``` `` 源码;其余块照常出图。仍写出定稿 `.md`,并**照常尝试**生成 Word(未出图块在 Word 中为 **Consolas 代码块**,与 `md_to_docx` 行为一致)。

### 依赖:mermaid(须 Node.js + `mmdc`)

| 方式 | 安装 | 说明 |
|------|------|------|
| **本地 npm(推荐)** | **Node.js** + 本目录 `npm install`(见 `package.json`) | 优先使用 `scripts/disclosure/node_modules/.bin/mmdc`,避免每次 npx 拉包 |
| **npx** | 未执行 `npm install` 时由脚本调用 `npx -y @mermaid-js/mermaid-cli mmdc` | 首次可能较慢 |
| **全局 npm** | `npm install -g @mermaid-js/mermaid-cli` | 提供 **PATH** 上的 `mmdc` |

mermaid 脚本按顺序查找:`scripts/disclosure/node_modules/.bin/mmdc` → **PATH** 上的 `mmdc` → `npx`。

生成 Word 仍需:`pip install -r requirements.txt`(与上表无关)。

**npm 推荐(本地 CLI)**:

```bash
cd tools
npm install
```

`package.json` 已包含 **`puppeteer`**(`@mermaid-js/mermaid-cli` 的 peer)。**Puppeteer 23+** 可能不会在 `npm install` 时自动下载浏览器;若自检或 `mmdc` 报错 **Could not find Chrome**,在 **`scripts/disclosure/`** 再执行:

```bash
npx puppeteer browsers install chrome-headless-shell
```

(或按报错提示选用 `chrome` 等;详见 [Puppeteer 文档](https://pptr.dev/)。)

### mermaid CLI 与手动试转

**`mermaid_render.py` 与 11.x 一致**:在 **`mmdc -i <.mmd> -o <.png> -b white`** 基础上默认追加 **`-s 2 -w 1400 -H 1050`**(更高像素密度与视口,系统框图在 Word 中更清晰)。需要再锐化可 **`--mmdc-scale 3`**(PNG 更大);恢复接近旧版可 **`--mmdc-scale 1 --mmdc-width 800 --mmdc-height 600`**。
若某处写的是 `npx -y @mermaid-js/mermaid-cli -i …`,**少了子命令 `mmdc`**,参数会错位;正确示例:

```bash
npx -y @mermaid-js/mermaid-cli mmdc -i sample.mmd -o sample.png -b white
```

可自建极简 `sample.mmd`(如一行 `flowchart LR; A-->B`)试转;能出 PNG 则说明 **mmdc + Chrome** 正常,否则按上文安装 **`puppeteer` 浏览器**。

### 用法

```bash
# 写出定稿 .md,并在同目录生成同名 .docx(默认);-o 须为「案件名_YYYYMMDDHHmmss.md」(见 references/disclosure/disclosure_builder.md §7.3 第 5 点)
python3 scripts/disclosure/mermaid_render.py -i draft.md -o "一种XXX方法及系统_20260408143025.md"

# 指定 .docx 路径(.md 主名仍须含时间戳)
python3 scripts/disclosure/mermaid_render.py -i draft.md -o out/一种XXX方法及系统_20260408143025.md --docx out/一种XXX方法及系统_20260408143025.docx

# 仅 Markdown,不要 Word
python3 scripts/disclosure/mermaid_render.py -i draft.md -o "一种XXX方法及系统_20260408143025.md" --no-docx

# 更高清晰度(可选)
python3 scripts/disclosure/mermaid_render.py -i draft.md -o "…定稿.md" --mmdc-scale 3 --mmdc-width 1600 --mmdc-height 1200

# 指定 mermaid 图片子目录(相对输出 .md)
python3 scripts/disclosure/mermaid_render.py -i draft.md -o out/一种XXX方法及系统_20260408143025.md --assets-dir figures/mermaid
```

**Word 生成失败**(缺依赖、版式报错等)时:脚本仍以退出码 **0** 结束(Markdown 已成功);stderr 会打印 **`md_to_docx.py` 的手动命令**,请复制执行。

Windows 上若仅装 Node 未执行 `npm install`,脚本会通过 `npx -y @mermaid-js/mermaid-cli mmdc` 调用(首次可能较慢)。

### 与交底书约定

- 技能要求定稿**同时**交付 **Markdown + Word**,且 **`-o` 主文件名须含 `_{YYYYMMDDHHmmss}`**(`references/disclosure/disclosure_builder.md` §7.3 第 5 点,含首次定稿);**3.2 系统框图**与 **3.4 流程图**均用 fenced mermaid,**不要** ASCII 文字流程图或框图。
- 交付代理人前:运行 `mermaid_render.py` 一步即可(默认再调 `md_to_docx.py`);若 Word 失败,按 stderr 提示手动执行 `md_to_docx.py`。

---

## math_render.py — LaTeX 公式 → PNG

将 Markdown 中的 **LaTeX 公式**(``$...$`` / ``\\(...\\)`` 行内;``$$...$$`` / ``\\[...\\]`` 块级)用 **matplotlib mathtext** 渲染为 PNG;**保留 LaTeX 原文**,图片引用写入 HTML 注释 ``<!-- ![...](math_figures/...) -->``(Markdown 预览不显示图),供 **`md_to_docx.py`** 嵌入 Word。

**Mermaid 框图**:``mermaid_render.py`` **保留** `` ```mermaid`` 源码,并追加 ``<!-- ![图示 n](mermaid_figures/...) -->``(预览隐藏图引用,Word 仍大图嵌入)。

**mathtext 兼容**:渲染前自动将常见 LaTeX 简写映射为 mathtext 符号(如 ``\ge``→``\geq``、``\le``→``\leq``、``\land``→``\wedge``);块级式内**换行压成一行**、``\tag{1}`` 转为式末 ``(1)``;仍无法解析的公式保留原文。

**失败降级**:某一公式渲染失败时**不中断**——该处**保留原文**(``$...$`` 或 ``$$...$$``);``md_to_docx`` 对未转换的 ``$$`` 块以 **Consolas 代码块**写入 Word。

**Word 版式**:**全部公式图**(行内与块级式 (1) 等)在 Word 中统一按约 **0.17 英寸**高度嵌入;**mermaid 框图/流程图**仍按 **5.5×8.2 英寸**上限等比嵌入。块级 PNG 默认与行内同字号(10.5pt)渲染,避免块级式显得过粗过大。

### 依赖

```bash
pip install -r requirements.txt   # 含 matplotlib
```

### 用法

```bash
python3 scripts/disclosure/math_render.py -i draft.md -o draft_with_math.md
python3 scripts/disclosure/math_render.py -i draft.md -o out.md --assets-dir math_figures
```

定稿流水线:**``mermaid_render.py`` 默认先跑公式再跑 mermaid**(可用 ``--no-math`` 跳过)。单独转 Word 时 **`md_to_docx.py` 也会自动尝试公式渲染**(``--no-math-render`` 可关闭)。

---

## md_to_docx.py — Markdown → Word

将交底书 Markdown 转为 `.docx`,**`#`–`######` 映射为 Word 内置「标题 1」–「标题 9」**,正文为宋体 10.5pt,代码块为 Consolas,便于交给代理人或所内用 Word 修订。

**图示**:定稿应用 **`mermaid_render.py`** 将 mermaid 转为 PNG;若个别块生图失败被降级保留围栏,本脚本会将**仍存在的** `` ```mermaid`` 块按**代码块**写入 Word。本脚本不调用 `mmdc`。

### 依赖

```bash
pip install -r requirements.txt
```

依赖为 `python-docx`(见仓库根目录 `requirements.txt`)。

### 用法

```bash
python3 scripts/disclosure/md_to_docx.py --input path/to/交底书.md --output path/to/交底书.docx
```

图片 `![](相对路径.png)`:默认相对 **Markdown 文件所在目录**;也可指定根目录:

```bash
python3 scripts/disclosure/md_to_docx.py -i ./outputs/case/disclosure.md -o ./outputs/case/disclosure.docx --base-dir ./outputs/case
```

**插图**:对 PNG/GIF/JPEG 会读取像素尺寸,在默认 **最大宽 5.5" × 最大高 8.2"** 内**等比缩放**并同时指定 `width`/`height`,避免竖长流程图仅按宽度放大后**高度超出版心**、打印或阅读时像被裁切。可按纸张边距调整,例如:

```bash
python3 scripts/disclosure/md_to_docx.py -i a.md -o a.docx --image-max-width-inches 6 --image-max-height-inches 9
```

在技能根目录中使用 `scripts/disclosure/` 路径;在其它工作目录执行时使用该脚本目录的绝对路径。

### 支持的 Markdown 子集

| 元素 | 行为 |
|------|------|
| `#`–`######` | Word 标题 1–9 |
| 段落 | 宋体正文,支持 `**粗体**`、`` `行内代码` ``;**相邻非空行(中间无空行)各自成段**,「(1)…(2)…」会分行显示 |
| `-` / `*` 列表 | 项目符号列表 |
| `1.` 列表 | 编号列表 |
| ` ``` ` 围栏 | 等宽代码块 |
| `\| 表格 \|` | 简单表格(Table Grid);单元格内 ``\\(...\\)``、``$...$``、``<!-- -->`` 及 ``\\|`` 中的 ``|`` **不会**被当作列分隔符 |
| `> ` | 左缩进引用 |
| `---` 等 | 浅色分隔线 |
| `![](path)` | 嵌入图片(路径需存在;默认宽/高上限内等比缩放;公式图与正文混排) |
| `$` / `\\(...\\)` / `$$` / `\\[...\\]` LaTeX | 默认先 **`math_render`** 转 PNG(注释隐藏引用);失败则 **原文**写入 Word |

**未完整支持**:复杂嵌套列表、HTML 块、**未预渲染的** mermaid 围栏(仍为代码块)、脚注、任务列表等。定稿前请运行 **`mermaid_render.py`**;若仅用外部工具导出 PNG,可直接写 `![](...)`。

### 版式说明(md_to_docx)

- 不同语言 Word 中「标题 1」显示名可能为「Heading 1」或「标题 1」,样式仍为大纲级别标题,可用导航窗格与目录域。
- 若需所内固定模版(页眉、首页不同),可在本脚本生成后套用单位 `.dotx`,或后续扩展 `python-docx` 打开模版再写入。

---

## iteration_dialog_log.py — 修订对话记录(迭代用)

每轮 **`merger.md` / `correction_handler.md`** 交付后,在**案件目录**追加一条 **`交底书修订对话记录.md`**:含**本地时间与 UTC**、用户说明摘要、本轮交付文件名、合并/纠正摘要摘录。规则见 **`references/disclosure/iteration_context.md`**。

**依赖**:仅标准库。

```bash
python3 scripts/disclosure/iteration_dialog_log.py --case-dir outputs/某案件 --kind merge \
  --user "补充了调度装置资料,合并进第三章" \
  --summary "已扩写 3.4,并更新实施例;未改保护点表述。" \
  --artifacts "一种XXX方法及系统_20260408143025.md,一种XXX方法及系统_20260408143025.docx"
```

- `--kind`:`merge` 或 `correct`。
- `--log-name`:可选,默认 `交底书修订对话记录.md`;英文环境可改用 `disclosure_revision_log.md`。
- 无法执行脚本时,由 Agent 按同结构手工追加。

---

## docx_to_md.py — Word → Markdown + 抽取图片

将 **.docx**(Word / WPS 等另存为 docx)转为 **Markdown**,并把文档内嵌图片落到磁盘,便于 **`Read` 与 Step 2 扫描**(与直接读二进制 .docx 相比更稳)。**Step 2** 对扫描树内**每一个** `.docx` 都应先转换再读产出 `.md`,见 `references/disclosure/project_scan.md`。

### 依赖

与 `md_to_docx` 共用根目录 `requirements.txt`(`python-docx` + **`mammoth`**)。

```bash
pip install -r requirements.txt
```

### 用法

```bash
python3 scripts/disclosure/docx_to_md.py --input path/to/设计说明.docx --output outputs/case/design.md
```

- 默认图片目录:`outputs/case/design_media/`,Markdown 内为相对路径 `![](design_media/img_0001.png)`。
- 自定义图片目录:

```bash
python3 scripts/disclosure/docx_to_md.py -i ./raw/spec.docx -o ./knowledge/spec.md --media-dir ./knowledge/spec_assets
```

转换警告(如部分样式、WMF 图)会输出到 **stderr**,仍可能生成可用 `.md`。

### 局限(mammoth)

- 仅 **`.docx`**(OOXML);老版 **`.doc`** 不支持。
- **Markdown 输出在 mammoth 侧标记为 deprecated**,复杂排版可能弱于「先导出 HTML 再转 MD」;专利扫描一般足够。若版式崩坏,建议所内 **另存为 PDF 或纯文本** 再扫。
- **WMF/EMF** 等 Windows 图元可能需单独处理(见 [mammoth WMF 配方](https://github.com/mwilliamson/python-mammoth))。

在技能根目录中使用 `scripts/disclosure/` 路径;在其它工作目录执行时使用该脚本目录的绝对路径。Windows 无 `python3` 时用 `python`。

---

## pptx_to_md.py — PowerPoint → Markdown + 抽取图片

将 **.pptx** / **.ppsx** 按**幻灯片页**导出为 Markdown,并抽取幻灯片中的**嵌入位图**(`PICTURE` 形状),便于 **`Read` 与 Step 2 扫描**。**Step 2** 对扫描树内**每一个** `.pptx` 均应先转换再读 `.md`,见 `references/disclosure/project_scan.md`。

### 依赖

根目录 `requirements.txt` 中的 **`python-pptx`**。

```bash
pip install -r requirements.txt
```

### 用法

```bash
python3 scripts/disclosure/pptx_to_md.py --input path/to/评审材料.pptx --output outputs/case/review.md
```

- 默认图片目录:`outputs/case/review_media/`,文件名形如 `slide03_img0001.png`。
- 自定义图片目录:

```bash
python3 scripts/disclosure/pptx_to_md.py -i ./raw/deck.pptx -o ./knowledge/deck.md --media-dir ./knowledge/deck_media
```

每页输出二级标题 `## 第 N 页`,其后为该页形状中的**文本与表格**(简化为管道表)及图片引用;若存在**演讲者备注**,以「**备注**」小节附于该页末尾。

### 局限(python-pptx)

- 仅 **`.pptx` / `.ppsx`**(OOXML);**`.ppt`** 不支持,请先另存。
- **图表、SmartArt、嵌入 OLE** 等若未以普通图片形状存在,**不会**自动栅格化为 PNG;可先在 PowerPoint 中另存为图片或导出 PDF 作补充材料。
- 文本按形状遍历顺序输出,与视觉阅读顺序可能略有差异。

在技能根目录中使用 `scripts/disclosure/` 路径;在其它工作目录执行时使用该脚本目录的绝对路径。Windows 无 `python3` 时用 `python`。

---

## 扩展其它脚本时

- Word / PPT 转换依赖写在 `requirements.txt`。
- 在 `SKILL.md`「工具与数据来源」表中增加一行调用说明。
- 勿将密钥写入仓库;配置使用环境变量或用户主目录。
references/draft-schema.md
# Structured Draft Schema

## Contents

- [Purpose](#purpose)
- [Rules](#rules)


## Purpose

Populate a UTF-8 JSON file with this structure before rendering a DOCX. Empty optional arrays are allowed. Preserve `[TO CONFIRM: ...]` markers where facts remain unresolved.

```json
{
  "schema_version": "2.0",
  "title": "一种……方法、设备及介质",
  "metadata": {
    "source": "paper.pdf",
    "target": "中国发明专利",
    "draft_status": "供发明人及专利代理师复核"
  },
  "source_analysis": {
    "contains_core_formulas": true,
    "formula_count_in_source": 18,
    "contains_methodology_figures": true
  },
  "source_map": [
    {
      "id": "P001",
      "type": "paper-text",
      "locator": "第3页,2.2节,第1段",
      "summary": "公开核心特征提取流程",
      "confidence": "high"
    },
    {
      "id": "E001",
      "type": "equation",
      "locator": "第4页,公式(1)",
      "summary": "类别原型计算",
      "confidence": "high"
    }
  ],
  "terminology_ledger": [
    {
      "concept": "类别原型",
      "canonical_zh": "类别原型",
      "source_terms": ["class prototype", "prototype"],
      "forbidden_aliases": ["类别中心"]
    }
  ],
  "formula_inventory": [
    {
      "source_id": "E001",
      "source_number": "(1)",
      "technical_role": "根据支持集特征计算类别原型",
      "disposition": "specification-equation-1"
    }
  ],
  "figure_inventory": [
    {
      "source_id": "F001",
      "source_number": "Fig. 2",
      "type": "methodology",
      "disposition": "redraw-as-figure-2"
    }
  ],
  "abstract_figure_number": 1,
  "assumptions": [
    "目标法域为中国"
  ],
  "invention_concept": {
    "technical_problem": "……",
    "technical_means": "……",
    "technical_effect": "……"
  },
  "evidence_ledger": [
    {
      "id": "F1",
      "feature": "……",
      "source_ids": ["P001", "E001"],
      "source_location": "第3页,2.2节",
      "technical_role": "……",
      "effect": "……",
      "support_status": "explicit"
    }
  ],
  "claims": [
    {
      "number": 1,
      "text": "一种……方法,其特征在于,包括:……"
    },
    {
      "number": 2,
      "text": "根据权利要求1所述的方法,其特征在于,……"
    }
  ],
  "claim_feature_map": [
    {
      "claim_number": 1,
      "feature": "根据支持集特征计算类别原型",
      "evidence_ids": ["F1"],
      "specification_locations": ["具体实施方式,实施例1"]
    }
  ],
  "figures": [
    {
      "number": 1,
      "title": "方法流程图",
      "type": "flowchart",
      "orientation": "vertical",
      "claim_number": 1,
      "complete_claim_flow": true,
      "source_ids": ["P001"],
      "nodes": [
        {
          "id": "S1",
          "label": "S1:获取并预处理待检测数据",
          "claim_step": "S1"
        },
        {
          "id": "S2",
          "label": "S2:提取多尺度特征",
          "claim_step": "S2"
        }
      ],
      "edges": [
        {
          "from": "S1",
          "to": "S2",
          "label": ""
        }
      ]
    },
    {
      "number": 2,
      "title": "核心方法结构示意图",
      "type": "methodology",
      "orientation": "horizontal",
      "source_ids": ["F001", "P001"],
      "nodes": [
        {
          "id": "input",
          "label": "输入特征"
        },
        {
          "id": "module",
          "label": "核心处理模块"
        },
        {
          "id": "output",
          "label": "输出特征"
        }
      ],
      "edges": [
        {
          "from": "input",
          "to": "module",
          "label": ""
        },
        {
          "from": "module",
          "to": "output",
          "label": ""
        }
      ]
    }
  ],
  "specification": {
    "technical_field": [
      "本发明涉及……"
    ],
    "background": [
      "……"
    ],
    "invention_content": {
      "problem": [
        "……"
      ],
      "solution": [
        "……"
      ],
      "beneficial_effects": [
        "……"
      ]
    },
    "figure_descriptions": [
      "图1为本发明方法流程图。"
    ],
    "equations": [
      {
        "number": 1,
        "source_location": "论文第4页,公式(1)",
        "source_ids": ["E001"],
        "expression": "O_u = (1/|S_u|) Σ_(x_i,y_i∈S_u) h_γ(x_i)",
        "latex": "O_u = \\frac{1}{|S_u|}\\sum_{(x_i,y_i)\\in S_u} h_\\gamma(x_i)",
        "symbols": [
          {"symbol": "O_u", "meaning": "类别u的类别原型"},
          {"symbol": "S_u", "meaning": "类别u的支持集"},
          {"symbol": "h_\\gamma", "meaning": "监督特征提取器"}
        ],
        "technical_role": "对同一类别样本的监督特征求均值以获得类别原型",
        "description": "其中,O_u表示类别u的类别原型,S_u表示类别u的支持集,h_γ表示监督特征提取器。该公式通过对同一类别样本的监督特征求均值获得类别原型。"
      }
    ],
    "embodiments": [
      {
        "heading": "实施例1",
        "paragraphs": [
          "……"
        ]
      }
    ]
  },
  "abstract": "本发明涉及……",
  "audit": {
    "support_findings": [
      "……"
    ],
    "consistency_findings": [
      "……"
    ]
  },
  "quality_assessment": {
    "status": "review-draft",
    "scores": {
      "evidence_support": {"score": 4, "evidence": "每项权利要求特征均映射到证据台账。"},
      "claim_architecture": {"score": 4, "evidence": "独立权利要求形成完整技术链,并设置从属回退层。"},
      "terminology_consistency": {"score": 4, "evidence": "权利要求、说明书和附图使用统一术语。"},
      "enablement_detail": {"score": 3, "evidence": "已说明主要数据流、公式和实施步骤。"},
      "technical_effect_reasoning": {"score": 3, "evidence": "主要效果已关联到对应技术手段。"},
      "formula_coverage": {"score": 4, "evidence": "核心公式均已收录并定义符号。"},
      "figure_alignment": {"score": 4, "evidence": "主流程图与权利要求1步骤一致。"}
    }
  },
  "inventor_questions": [
    "[TO CONFIRM: ……]"
  ]
}
```

## Rules

- Use integer claim numbers in ascending order.
- Use stable source IDs: `P` for paper text, `E` for equations, `F` for
  source figures, and `C` for code or supplementary evidence.
- Give every `explicit` or `inherent` evidence-ledger item one or more
  `source_ids`.
- Add at least one `claim_feature_map` entry for every formal claim. Map each
  material limitation to evidence-ledger IDs, not merely to a general page.
- Store claim text without repeating the number at its beginning.
- Use arrays for paragraphs to preserve paragraph boundaries.
- Use only `explicit`, `inherent`, `needs-confirmation`, or `unsupported` as evidence status.
- Exclude unsupported features from formal claims.
- Keep internal audit material in the appendix, not in the formal application sections.
- Number figures consecutively from 1.
- Set `abstract_figure_number` to the main figure used as the abstract figure.
- Reuse that exact figure in the specification; do not create a conflicting duplicate.
- The abstract figure should normally be an overall method or system flow that represents the principal independent claim.
- Use `flowchart` as the figure type and `vertical` or `horizontal` as the orientation.
- Use `methodology` for an intermediate architecture, module, feature-flow, or loss-relationship figure.
- A methodology figure does not require `claim_number`, `claim_step`, or `complete_claim_flow`.
- Prefer a paper figure when suitable; otherwise redraw it as a concise black-and-white patent figure.
- Give every node a unique ASCII identifier.
- Keep `claim_step` equal to the corresponding identifier in the method claim, such as `S1`.
- Set `claim_number` to the method claim represented by the figure.
- Set `complete_claim_flow` to `true` for an overall flowchart that must cover every numbered step in that claim.
- Reference every figure in `specification.figure_descriptions`.
- Use concise node labels; put implementation detail in the specification.
- If the paper contains formulas that define core technical operations, populate `specification.equations`.
- Set `source_analysis.contains_core_formulas` after reviewing the paper.
- Number equations consecutively from 1.
- Add a valid `latex` field to every equation; the renderer converts it to editable Office Math.
- Add `source_ids`, structured `symbols`, and `technical_role` to every
  equation.
- Treat `expression` as a readable audit copy, not as the DOCX rendering source.
- Record the paper page and original formula number in `source_location`.
- Define every symbol in `description` and state the technical operation performed by the formula.
- Include formulas in the standalone specification DOCX; do not place them only in an internal appendix.
- Record a disposition for every core source formula and methodology figure.
- Populate `quality_assessment.scores` with a 1-5 score and evidence sentence
  for each dimension required by `static/core/output-contract.md`.
references/patent-figure-guide.md
# Patent Figure Guide

## Method Flowcharts

Build the main method flowchart from the ordered operations in the independent method claim.

Use:

- black strokes and white fills;
- rectangular process nodes;
- solid directional arrows;
- figure numbers such as `图1`;
- step identifiers such as `S1`, `S2`, and `S3`;
- concise Chinese operation labels.

Write the final node as the concrete result of the method. Examples include:

- `输出缺陷检测结果`;
- `获得故障检测结果`;
- `输出目标探测结果`;
- `获得电池健康状态估计结果`;
- `输出目标类别和目标位置`.

Keep the result name identical to the independent claim. Do not use invented umbrella terms such as `技术结果`.

## Abstract Figure

Use the main overall method flowchart as the abstract figure unless another single figure better represents the principal technical solution.

The same figure may and normally should also appear in the specification as `图1`. Reuse the same drawing file, figure number, node labels, and arrows. Do not generate separate "abstract" and "specification" versions that differ in wording or flow.

The abstract figure must:

- represent the principal independent claim;
- show the main input, core operations, and specific output;
- remain readable without equations or experimental details;
- be generated as both SVG and PNG;
- be embedded in the abstract-figure DOCX, abstract DOCX, and specification DOCX.

Avoid:

- color as the only carrier of meaning;
- gradients, shadows, decorative icons, or photographic backgrounds;
- unsupported branches or modules;
- effect-only nodes such as "提高准确率";
- vague final nodes such as `输出技术结果`, `获得处理结果`, or `输出最终结果`;
- dense equations or experimental results inside nodes;
- inconsistent terminology between the figure and claims.

## Consistency Rules

Confirm:

1. every `claim_step` appears in a method claim;
2. an overall figure marked `complete_claim_flow` covers every numbered step of its referenced claim;
3. node order follows the claimed data flow;
4. every edge connects existing nodes;
5. every node is reachable in the intended flow;
6. the figure description uses the same figure number and title;
7. the embodiment explains each node's operation;
8. optional details remain outside the main flow unless they form a disclosed branch.
9. `abstract_figure_number` points to an existing complete main figure;
10. the abstract and specification reuse the same image file.

## Figure Set

For algorithm-related inventions, consider:

- overall method flowchart;
- system or model architecture;
- core module structure;
- training flow;
- inference flow;
- data preprocessing flow.

Generate only figures supported by the source. The bundled script currently renders deterministic method flowcharts. Describe unsupported figure types as `[TO CONFIRM: figure required]` rather than fabricating them.
requirements.txt
latex2mathml>=3.77.0
mammoth>=1.6.0
matplotlib>=3.8.0
Pillow>=10.0.0
pypdf>=4.0.0
python-docx>=1.1.0
python-pptx>=0.6.21
scripts/audit_claims.py
#!/usr/bin/env python3
"""Run deterministic structural checks on Chinese patent claims."""

import argparse
import json
import re
from dataclasses import dataclass
from pathlib import Path


CLAIM_START = re.compile(r"(?m)^\s*(\d+)\s*[.、.]\s*")
REFERENCE = re.compile(
    r"权利要求\s*(\d+)(?:\s*[-—~~至]\s*(\d+))?"
    r"|权利要求\s*(\d+)\s*(?:或|、)\s*(\d+)"
)
TERM_INTRO = re.compile(r"(?:所述|该)([\u4e00-\u9fffA-Za-z][\u4e00-\u9fffA-Za-z0-9_-]{1,20})")
PLACEHOLDER = re.compile(r"\[(?:TO CONFIRM|待确认)[^\]]*\]", re.IGNORECASE)


@dataclass
class Finding:
    level: str
    claim: int | None
    code: str
    message: str


def split_claims(text: str) -> list[tuple[int, str]]:
    matches = list(CLAIM_START.finditer(text))
    claims = []
    for index, match in enumerate(matches):
        end = matches[index + 1].start() if index + 1 < len(matches) else len(text)
        claims.append((int(match.group(1)), text[match.end() : end].strip()))
    return claims


def references(body: str) -> list[int]:
    result = []
    for match in REFERENCE.finditer(body):
        if match.group(1):
            start = int(match.group(1))
            finish = int(match.group(2) or start)
            result.extend(range(start, finish + 1))
        else:
            result.extend((int(match.group(3)), int(match.group(4))))
    return sorted(set(result))


def normalize(text: str) -> str:
    return re.sub(r"\s+", "", text)


def audit(text: str) -> list[Finding]:
    claims = split_claims(text)
    findings = []
    if not claims:
        return [Finding("ERROR", None, "NO_CLAIMS", "未识别到以“1.”形式起始的权利要求。")]

    numbers = [number for number, _ in claims]
    expected = list(range(1, len(claims) + 1))
    if numbers != expected:
        findings.append(
            Finding("ERROR", None, "NUMBER_SEQUENCE", f"编号应连续为{expected},实际为{numbers}。")
        )

    previous_text = ""
    claim_map = {}
    for number, body in claims:
        compact = normalize(body)
        claim_map[number] = compact
        refs = references(body)

        if not body:
            findings.append(Finding("ERROR", number, "EMPTY", "权利要求正文为空。"))
            continue
        if PLACEHOLDER.search(body):
            findings.append(
                Finding("ERROR", number, "PLACEHOLDER", "正式权利要求中仍含待确认标记。")
            )
        if number == 1 and refs:
            findings.append(
                Finding("ERROR", number, "INDEPENDENT_REFERENCE", "权利要求1不应引用其他权利要求。")
            )
        if number > 1 and not refs:
            findings.append(
                Finding("WARNING", number, "NO_REFERENCE", "未检测到从属引用;确认其是否为独立权利要求。")
            )
        for ref in refs:
            if ref >= number:
                findings.append(
                    Finding("ERROR", number, "FORWARD_REFERENCE", f"引用了非在先权利要求{ref}。")
                )
            if ref not in claim_map:
                findings.append(
                    Finding("ERROR", number, "MISSING_REFERENCE", f"引用的权利要求{ref}不存在。")
                )

        if "其特征在于" not in compact:
            findings.append(
                Finding("WARNING", number, "TRANSITION", "未检测到“其特征在于”过渡语。")
            )
        if len(compact) < 25:
            findings.append(
                Finding("WARNING", number, "TOO_SHORT", "权利要求较短,确认是否完整限定技术方案。")
            )
        if re.search(r"(效果更好|性能优异|显著提高|大大提高|最佳|最优)", compact):
            findings.append(
                Finding("WARNING", number, "RESULT_LANGUAGE", "含结果或宣传性措辞,确认是否改为技术限定。")
            )

        searchable_basis = previous_text + "".join(
            claim_map.get(ref, "") for ref in refs
        )
        for term in sorted(set(TERM_INTRO.findall(body))):
            if term in {"方法", "装置", "设备", "系统", "步骤", "程序"}:
                continue
            if term not in searchable_basis and compact.find(term) <= 4:
                findings.append(
                    Finding(
                        "WARNING",
                        number,
                        "ANTECEDENT_BASIS",
                        f"术语“{term}”可能缺少清晰的前置基础。",
                    )
                )
        previous_text += compact

    return findings


def main() -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("claims", type=Path, help="UTF-8 claims text file")
    parser.add_argument("--json", action="store_true", help="Output findings as JSON")
    args = parser.parse_args()

    text = args.claims.read_text(encoding="utf-8")
    findings = audit(text)
    if args.json:
        print(json.dumps([finding.__dict__ for finding in findings], ensure_ascii=False, indent=2))
    elif not findings:
        print("PASS: 未发现结构性问题。")
    else:
        for finding in findings:
            location = f"权利要求{finding.claim}" if finding.claim else "整体"
            print(f"{finding.level}\t{location}\t{finding.code}\t{finding.message}")
        errors = sum(finding.level == "ERROR" for finding in findings)
        warnings = sum(finding.level == "WARNING" for finding in findings)
        print(f"\n汇总: {errors} 个错误, {warnings} 个警告")

    return 1 if any(finding.level == "ERROR" for finding in findings) else 0


if __name__ == "__main__":
    raise SystemExit(main())
scripts/build_patent_package.py
#!/usr/bin/env python3
"""Build the standard split Chinese patent application package."""

import argparse
import importlib.util
import json
import shutil
import subprocess
import sys
from pathlib import Path


def run(command: list[str]) -> None:
    subprocess.run(command, check=True)


def validate(data: dict) -> None:
    required = ("title", "claims", "specification", "abstract", "figures")
    missing = [key for key in required if not data.get(key)]
    if missing:
        raise ValueError(f"Missing required complete-package content: {', '.join(missing)}")
    spec = data["specification"]
    if not spec.get("figure_descriptions"):
        raise ValueError("Specification must contain figure descriptions")
    if "equations" not in spec:
        raise ValueError(
            "Specification must contain an equations array; use an empty array only when "
            "the source contains no core technical formulas"
        )
    source_analysis = data.get("source_analysis", {})
    if source_analysis.get("contains_core_formulas") and not spec.get("equations"):
        raise ValueError(
            "The source is marked as containing core formulas, but specification.equations is empty"
        )
    for equation in spec.get("equations", []):
        if not equation.get("latex"):
            raise ValueError(
                f"Equation {equation.get('number')} must include latex source for native Office Math"
            )
    equation_numbers = [item.get("number") for item in spec.get("equations", [])]
    if equation_numbers and equation_numbers != list(range(1, len(equation_numbers) + 1)):
        raise ValueError(
            f"Equation numbers must be consecutive integers starting at 1: {equation_numbers}"
        )
    abstract_figure_number = data.get("abstract_figure_number")
    if not isinstance(abstract_figure_number, int):
        raise ValueError("abstract_figure_number must be an integer")
    figure = next(
        (
            item
            for item in data["figures"]
            if item.get("number") == abstract_figure_number
        ),
        None,
    )
    if figure is None:
        raise ValueError(
            f"abstract_figure_number {abstract_figure_number} does not reference an existing figure"
        )
    if not figure.get("complete_claim_flow"):
        raise ValueError("The abstract figure must be a complete principal claim flow")
    if source_analysis.get("contains_methodology_figures"):
        methodology = [item for item in data["figures"] if item.get("type") == "methodology"]
        if not methodology:
            raise ValueError(
                "The source is marked as containing methodology figures, but no methodology "
                "figure is included"
            )


def main() -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("draft", type=Path, help="UTF-8 patent draft JSON")
    parser.add_argument("--output-dir", type=Path, required=True)
    parser.add_argument("--prefix", default="patent")
    args = parser.parse_args()

    data = json.loads(args.draft.read_text(encoding="utf-8"))
    root = Path(__file__).resolve().parent
    validator_script = root / "validate_patent_draft.py"
    validator_spec = importlib.util.spec_from_file_location(
        "patent_draft_validator", validator_script
    )
    validator = importlib.util.module_from_spec(validator_spec)
    sys.modules[validator_spec.name] = validator
    validator_spec.loader.exec_module(validator)
    validation_findings = validator.validate(data)
    args.output_dir.mkdir(parents=True, exist_ok=True)
    validation_report = args.output_dir / f"{args.prefix}-草稿验证报告.txt"
    validation_report.write_text(
        validator.format_report(validation_findings), encoding="utf-8"
    )
    if any(item.level == "ERROR" for item in validation_findings):
        print(validation_report)
        raise SystemExit(1)

    validate(data)
    docx_script = root / "render_patent_docx.py"
    figure_script = root / "render_flowchart_svg.py"

    figure_dir = args.output_dir / f"{args.prefix}-figures"
    run(
        [
            sys.executable,
            str(figure_script),
            str(args.draft),
            "--output-dir",
            str(figure_dir),
            "--png",
        ]
    )

    outputs = {
        "claims": args.output_dir / f"{args.prefix}-权利要求书.docx",
        "specification": args.output_dir / f"{args.prefix}-说明书.docx",
        "abstract": args.output_dir / f"{args.prefix}-说明书摘要.docx",
        "abstract-figure": args.output_dir / f"{args.prefix}-摘要附图.docx",
        "all": args.output_dir / f"{args.prefix}-完整审阅稿.docx",
    }
    for part, output in outputs.items():
        command = [
            sys.executable,
            str(docx_script),
            str(args.draft),
            "--output",
            str(output),
            "--part",
            part,
        ]
        if part in {"specification", "abstract", "abstract-figure", "all"}:
            command.extend(["--figure-dir", str(figure_dir)])
        run(command)

    claims_text = args.output_dir / f"{args.prefix}-权利要求书.txt"
    claims_text.write_text(
        "\n".join(f"{claim['number']}. {claim['text']}" for claim in data["claims"]) + "\n",
        encoding="utf-8",
    )
    audit = args.output_dir / f"{args.prefix}-权利要求检查.txt"
    audit_script = root / "audit_claims.py"
    spec = importlib.util.spec_from_file_location("patent_claim_audit", audit_script)
    audit_module = importlib.util.module_from_spec(spec)
    sys.modules[spec.name] = audit_module
    spec.loader.exec_module(audit_module)
    findings = audit_module.audit(claims_text.read_text(encoding="utf-8"))
    if findings:
        lines = []
        for finding in findings:
            location = f"权利要求{finding.claim}" if finding.claim else "整体"
            lines.append(
                f"{finding.level}\t{location}\t{finding.code}\t{finding.message}"
            )
    else:
        lines = ["PASS: 未发现权利要求结构性问题。"]
    audit.write_text("\n".join(lines) + "\n", encoding="utf-8")
    if any(finding.level == "ERROR" for finding in findings):
        raise SystemExit(1)

    json_copy = args.output_dir / f"{args.prefix}-结构化草稿.json"
    if args.draft.resolve() != json_copy.resolve():
        shutil.copy2(args.draft, json_copy)

    for output in (*outputs.values(), json_copy, audit, validation_report):
        print(output)
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
scripts/disclosure/cnipa_epub_crawler.py
# -*- coding: utf-8 -*-
"""
中国专利公布公告网站点:http://epub.cnipa.gov.cn/ —— **首页「公布公告查询」** 检索(#indexForm / #searchStr)。

须安装 **Playwright + Chromium**。若只需内存中解析、不落盘 HTML,优先用同目录 **`cnipa_epub_search.py`**;
本文件侧重 **写出结果页 HTML** 与可插拔的 ``fetch_epub_result_html`` API。

-------------------------------------------------------------------------------
一、整体流程(单次检索)
-------------------------------------------------------------------------------
1. 启动 Chromium(默认无头;可用环境变量改为有界面)。
2. 新建浏览器上下文:设定 **桌面 Chrome UA**、**zh-CN**、固定 **视口**(见 ``_new_context``),使请求形态接近普通用户浏览器。
3. ``page.goto`` 站点首页,**wait_until="load"**。
4. **等待首页可检索**:首页在访客到达后会先经 **前端脚本/WAF 一类逻辑**,未通过前 **不会出现** 检索输入框 ``#searchStr``。本实现通过 **周期性轮询 DOM**(每 3 秒一次,总时长见 ``EPUB_WAF_MAX_WAIT_SEC``,默认 180s)直到 ``#searchStr`` 出现;**不是**用 requests 直接 POST 能等价替代的步骤。
5. ``page.fill`` 将关键词写入 ``#searchStr``,对 ``#indexForm`` 执行 **submit**(而非单独点按钮),并 ``expect_navigation`` 等待结果页 **load**。
6. 结果页 **安定等待**:依次尝试 **load / networkidle**(超时则忽略),再 **固定短时 sleep**,减轻列表/统计脚本未跑完就取 HTML 导致的空壳或半截 DOM。
7. ``page.content()`` 取全页 HTML;若处于导航中抛错则 **重试退避**(``_safe_page_content``),避免竞态。
8. 后续解析由 **`cnipa_epub_parse.py`** 完成(本文件 ``search_epub_keyword`` 内会调用)。

-------------------------------------------------------------------------------
二、策略摘要:在解决什么、用了哪些手段
-------------------------------------------------------------------------------
- **为何用 Playwright**:站点依赖 **浏览器内 JavaScript** 渲染与风控后再开放检索框;**纯 HTTP 抓取**往往拿不到含 ``#searchStr`` 的可用首页或拿不到真实结果 DOM。
- **所谓「绕过」**:指 **技术层面** 与无头自动化、静态抓取之间的 gap——通过 **真实 Chromium 内核 + 等待 JS 完成 + 常见浏览器指纹**(UA、语言、viewport)降低「一进来就_submit」的失败率;**不**表示规避法律法规或站点服务条款,用途应限合法检索与交底书查新辅助。
- **反自动化/特征**:启动参数 ``--disable-blink-features=AutomationControlled`` 用于减弱 Chromium 的 **webdriver 自动化开关** 暴露(效果因站点升级而变,非保证)。
- **不覆盖的场景**:图形/滑块验证码、短信验证、强制登录等——若站点突然启用,本脚本**无**专门破解逻辑;可尝试 ``PLAYWRIGHT_HEADED=1`` 人工辅助或改用 **WebSearch**(见 ``references/disclosure/prior_art_search.md``)。

-------------------------------------------------------------------------------
三、检索关键词建议
-------------------------------------------------------------------------------
- 公布站首页检索框对 **多个词** 通常按 **同时包含(AND)** 理解,**词多且专**时极易 **0 条**;**建议每次尽量使用单个词或极短短语** 做一次检索,需要宽召回时可用 **`cnipa_epub_search.py`**(按空白拆成多词、多次检索再合并),或分多次手动换关键词。
- 本脚本命令行默认仍接受一个参数字符串(可含空格);含空格时与浏览器内一次提交一致,语义上仍是 **整句 AND**,不等同于拆词多查。

-------------------------------------------------------------------------------
环境变量
-------------------------------------------------------------------------------
  EPUB_WAF_MAX_WAIT_SEC  轮询等待 #searchStr 的最长时间,默认 180
  PLAYWRIGHT_HEADED        设为 1 时使用有界面 Chromium
  EPUB_RESULT_HTML         结果页 HTML 完整路径;不设则 scripts/disclosure/_last_result_YYYYMMDDHHmmss.html
"""
from __future__ import annotations

import json
import os
import sys
from datetime import datetime
from pathlib import Path
from typing import Callable

from playwright.sync_api import Browser, BrowserContext, Error, Page, Playwright, sync_playwright

from cnipa_epub_parse import EpubSearchHit, hits_to_jsonable, parse_search_result_html


def _ensure_utf8_stdio() -> None:
    """减轻 Windows 终端下 JSON 中文乱码(与 cnipa_epub_search.py 一致)。"""
    for stream in (sys.stdout, sys.stderr):
        try:
            if hasattr(stream, "reconfigure"):
                stream.reconfigure(encoding="utf-8", errors="replace")
        except (OSError, ValueError, TypeError):
            pass


EPUB_BASE = "http://epub.cnipa.gov.cn/"
DEFAULT_USER_AGENT = (
    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
    "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
)


def _max_wait_sec() -> float:
    return float(os.environ.get("EPUB_WAF_MAX_WAIT_SEC", "180"))


def _headed() -> bool:
    return os.environ.get("PLAYWRIGHT_HEADED", "").strip() in ("1", "true", "yes")


def default_result_html_path() -> Path:
    ts = datetime.now().strftime("%Y%m%d%H%M%S")
    return Path(__file__).resolve().parent / f"_last_result_{ts}.html"


def wait_for_epub_home_ready(page: Page, *, max_wait_sec: float | None = None) -> None:
    limit = max_wait_sec if max_wait_sec is not None else _max_wait_sec()
    page.goto(EPUB_BASE, wait_until="load", timeout=120_000)
    elapsed = 0.0
    step = 3.0
    while elapsed < limit:
        page.wait_for_timeout(int(step * 1000))
        elapsed += step
        if page.query_selector("#searchStr"):
            return
    raise TimeoutError(
        f"{limit}s 内未出现检索框 #searchStr;可增大 EPUB_WAF_MAX_WAIT_SEC 或设置 PLAYWRIGHT_HEADED=1"
    )


def _wait_result_page_settled(page: Page) -> None:
    try:
        page.wait_for_load_state("load", timeout=30_000)
    except Exception:
        pass
    try:
        page.wait_for_load_state("networkidle", timeout=25_000)
    except Exception:
        pass
    page.wait_for_timeout(800)


def _safe_page_content(page: Page, *, max_attempts: int = 10) -> str:
    last_err: Exception | None = None
    for i in range(max_attempts):
        try:
            return page.content()
        except Error as e:
            msg = str(e).lower()
            last_err = e
            if "navigating" not in msg and "changing" not in msg:
                raise
            try:
                page.wait_for_load_state("load", timeout=20_000)
            except Exception:
                pass
            page.wait_for_timeout(400 + 200 * i)
    if last_err:
        raise last_err
    raise RuntimeError("_safe_page_content: 未返回内容")


def submit_index_search(page: Page, keyword: str) -> None:
    page.fill("#searchStr", keyword)
    with page.expect_navigation(timeout=120_000, wait_until="load"):
        form = page.query_selector("#indexForm")
        if form:
            form.evaluate("el => el.submit()")
        else:
            page.evaluate(
                """() => {
                const f = document.getElementById('indexForm');
                if (f) f.submit();
            }"""
            )
    _wait_result_page_settled(page)


def fetch_epub_result_html(
    keyword: str,
    *,
    playwright_factory: Callable[[], Playwright] | None = None,
) -> str:
    """
    只拉取检索结果页 HTML,不在此函数内做正文解析。
    解析请使用 ``cnipa_epub_parse.parse_search_result_html(html)``。
    """
    pw_gen = playwright_factory or sync_playwright
    with pw_gen() as p:
        browser = _launch_browser(p)
        context = _new_context(browser)
        try:
            page = context.new_page()
            wait_for_epub_home_ready(page)
            submit_index_search(page, keyword)
            return _safe_page_content(page)
        finally:
            context.close()
            browser.close()


def search_epub_keyword(
    keyword: str,
    *,
    playwright_factory: Callable[[], Playwright] | None = None,
) -> tuple[str, list[EpubSearchHit]]:
    html = fetch_epub_result_html(keyword, playwright_factory=playwright_factory)
    return html, parse_search_result_html(html)


def search_epub_keyword_with_page(
    page: Page,
    keyword: str,
) -> tuple[str, list[EpubSearchHit]]:
    wait_for_epub_home_ready(page)
    submit_index_search(page, keyword)
    html = _safe_page_content(page)
    return html, parse_search_result_html(html)


def _launch_browser(p: Playwright) -> Browser:
    return p.chromium.launch(
        headless=not _headed(),
        args=[
            "--disable-blink-features=AutomationControlled",
            "--no-sandbox",
        ],
    )


def _new_context(browser: Browser) -> BrowserContext:
    return browser.new_context(
        user_agent=DEFAULT_USER_AGENT,
        locale="zh-CN",
        viewport={"width": 1280, "height": 900},
    )


def _dump_home_debug() -> None:
    """调试:仅拉取首页并保存 WAF 通过后 HTML。"""
    out = Path(__file__).resolve().parent / "_last_home.html"
    with sync_playwright() as p:
        browser = _launch_browser(p)
        context = _new_context(browser)
        page = context.new_page()
        try:
            wait_for_epub_home_ready(page)
            out.write_text(page.content(), encoding="utf-8")
            print("已保存:", out)
        finally:
            context.close()
            browser.close()


if __name__ == "__main__":
    _ensure_utf8_stdio()
    argv = [a for a in sys.argv[1:] if a.strip()]
    if argv and argv[0] in ("--dump-home", "-d"):
        _dump_home_debug()
        sys.exit(0)
    kw = (argv[0] if argv else "批处理").strip()
    try:
        out_html, hits = search_epub_keyword(kw)
    except Exception as e:
        print("CNIPA_EPUB_ERROR:", e, file=sys.stderr)
        sys.exit(1)
    out_path = Path(
        os.environ.get("EPUB_RESULT_HTML", "").strip() or default_result_html_path()
    )
    out_path = out_path.expanduser().resolve()
    out_path.write_text(out_html, encoding="utf-8")
    print(
        "结果页长度",
        len(out_html),
        "解析条目数",
        len(hits),
        file=sys.stderr,
        flush=True,
    )
    print("结果页 HTML 已保存:", out_path, file=sys.stderr, flush=True)
    print(
        "EPUB_HITS_JSON:",
        json.dumps(hits_to_jsonable(hits), ensure_ascii=False),
        flush=True,
    )
scripts/disclosure/cnipa_epub_parse.py
# -*- coding: utf-8 -*-
"""
解析 http://epub.cnipa.gov.cn/ 检索结果页 HTML,提取公布公告列表中的标题、公开号、详情链接、摘要(若有)。

与 `cnipa_epub_crawler.py` / **`cnipa_epub_search.py`**(一步检索+解析)配合:爬虫落盘 HTML 后可用本模块单独再解析;也可被其它脚本 import。
"""
from __future__ import annotations

import json
import re
import sys
from dataclasses import asdict, dataclass
from pathlib import Path

EPUB_BASE = "http://epub.cnipa.gov.cn/"


@dataclass
class EpubSearchHit:
    """单条检索命中(字段随页面结构尽力解析,可能为空)。"""

    raw_html: str
    title: str | None = None
    pub_number: str | None = None
    link: str | None = None
    abstract: str | None = None


def _html_fragment_to_plain(html_snippet: str) -> str:
    """从一小段 HTML 抽取可读纯文本(用于摘要等)。"""
    t = re.sub(r"<script[^>]*>.*?</script>", "", html_snippet, flags=re.I | re.DOTALL)
    t = re.sub(r"<style[^>]*>.*?</style>", "", t, flags=re.I | re.DOTALL)
    t = re.sub(r"<[^>]+>", " ", t)
    t = re.sub(r"\s+", " ", t).strip()
    t = re.sub(r"\s*全部\s*$", "", t).strip()
    return t


def _extract_abstract_from_item_html(item_html: str) -> str | None:
    """从单条 ``div.item`` 内 ``dt`` 摘要对应的 ``dd`` 中抽取全文(含折叠 span)。"""
    m = re.search(
        r'<dt[^>]*>\s*摘要\s*[::]\s*</dt>\s*<dd[^>]*>(.*?)</dd>',
        item_html,
        flags=re.IGNORECASE | re.DOTALL,
    )
    if not m:
        return None
    plain = _html_fragment_to_plain(m.group(1))
    return plain if len(plain) >= 4 else None


def _abs_url(href: str) -> str:
    if href.startswith("http://") or href.startswith("https://"):
        return href
    if href.startswith("/"):
        return EPUB_BASE.rstrip("/") + href
    return EPUB_BASE.rstrip("/") + "/" + href.lstrip("/")


def parse_search_result_html(html: str, base_url: str = EPUB_BASE) -> list[EpubSearchHit]:
    """
    解析「公布公告」检索结果列表页 HTML。
    兼容常见表格行 / 带链接的条目(站点改版时需调整正则或选择器)。
    """
    _ = base_url  # 预留与绝对链接拼接策略扩展
    hits: list[EpubSearchHit] = []
    for m in re.finditer(
        r"<tr[^>]*>(.*?)</tr>",
        html,
        flags=re.IGNORECASE | re.DOTALL,
    ):
        row = m.group(1)
        low = row.lower()
        if "indexquery" in low or "searchstr" in low:
            continue
        title_m = re.search(
            r'title="([^"]+)"',
            row,
            re.IGNORECASE,
        ) or re.search(r">([^<]{6,200})<", row)
        title = title_m.group(1).strip() if title_m else None
        link_m = re.search(r'href="([^"]+)"', row)
        href = link_m.group(1).strip() if link_m else None
        link = _abs_url(href) if href else None
        pub_m = re.search(
            r"(CN\s*\d{9,}[A-Z]\s*|ZL\s*\d{9,}\.\d+)",
            row,
            re.IGNORECASE,
        )
        pub_number = pub_m.group(1).replace(" ", "") if pub_m else None
        text = re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", row)).strip()
        if len(text) < 8 and not pub_number:
            continue
        hits.append(
            EpubSearchHit(
                raw_html=row[:2000],
                title=title or (text[:200] if text else None),
                pub_number=pub_number,
                link=link,
            )
        )
    seen: set[str] = set()
    out: list[EpubSearchHit] = []
    for h in hits:
        key = h.pub_number or h.title or h.raw_html[:80]
        if key in seen:
            continue
        seen.add(key)
        out.append(h)
    if out:
        return out
    overview = _parse_overview_card_layout(html)
    if overview:
        return overview
    return _parse_search_result_fallback_links(html)


def _parse_overview_card_layout(html: str) -> list[EpubSearchHit]:
    """
    新版「公布模式」结果页:无表格行,每条为 ``div.item``,题名在 ``h1.title``,
    详情 URL 多在二维码 ``div.qrcode`` 的 ``title="http://epub.../patent/CN…"`` 上;
    摘要位于 ``dt`` 为「摘要」的 ``dd`` 内(含 ``span.alltxt`` 折叠段)。
    """
    low = html.lower()
    if "overview-default" not in low and 'class="item"' not in low:
        return []
    parts = re.split(r'(<div\s+class="item"\s*>)', html, flags=re.IGNORECASE)
    blocks: list[str] = []
    for j in range(1, len(parts) - 1, 2):
        blocks.append(parts[j] + parts[j + 1])
    if not blocks:
        return []

    base = EPUB_BASE.rstrip("/")
    hits: list[EpubSearchHit] = []
    for item_html in blocks:
        tm = re.search(
            r'<h1\s+class="title">\s*([^<]+?)\s*</h1>',
            item_html,
            flags=re.IGNORECASE | re.DOTALL,
        )
        title = re.sub(r"\s+", " ", tm.group(1)).strip() if tm else None
        lm = re.search(
            r'title="(https?://epub\.cnipa\.gov\.cn/patent/[^"]+)"',
            item_html,
            flags=re.IGNORECASE,
        )
        link = lm.group(1).strip() if lm else None
        pm = re.search(
            r"(?:申请公布号|授权公告号)[::]\s*</dt>\s*<dd>([^<]+?)</dd>",
            item_html,
            flags=re.IGNORECASE,
        )
        pub_number = None
        if pm:
            pub_number = pm.group(1).strip().replace(" ", "")
            if not re.match(r"^(?:CN|ZL)", pub_number, re.IGNORECASE):
                pub_number = None
        if not link and pub_number:
            link = f"{base}/patent/{pub_number}"
        if link:
            m_pub = re.search(
                r"/patent/((?:CN|ZL)[^/?#]+)",
                link,
                flags=re.IGNORECASE,
            )
            if m_pub and not pub_number:
                pub_number = m_pub.group(1).strip()
        abstract = _extract_abstract_from_item_html(item_html)
        if not title and not pub_number and not link:
            continue
        raw = "|".join(
            x for x in (title, pub_number, link, (abstract or "")[:400]) if x
        )[:2000]
        hits.append(
            EpubSearchHit(
                raw_html=raw,
                title=title,
                pub_number=pub_number,
                link=link,
                abstract=abstract,
            )
        )
    seen: set[str] = set()
    out: list[EpubSearchHit] = []
    for h in hits:
        key = h.pub_number or h.link or (h.title or "")[:120]
        if key in seen:
            continue
        seen.add(key)
        out.append(h)
    return out


def _parse_search_result_fallback_links(html: str) -> list[EpubSearchHit]:
    """从结果页中抽取指向公布详情的 <a href>。"""
    hits: list[EpubSearchHit] = []
    for m in re.finditer(
        r'<a\s+[^>]*href="([^"]+)"[^>]*>([^<]*)</a>',
        html,
        flags=re.IGNORECASE | re.DOTALL,
    ):
        href = (m.group(1) or "").strip()
        title = (m.group(2) or "").strip()
        if not href.startswith("/") and "epub.cnipa.gov.cn" not in href:
            continue
        hlow = href.lower()
        if not any(
            x in hlow
            for x in ("/dxb/", "/sw/", "/patent/", "detail", "show")
        ):
            continue
        if "indexForm" in href or "javascript:" in href.lower():
            continue
        low = href.lower()
        if "article" in low and "indexquery" in low:
            continue
        link = _abs_url(href)
        pub_m = re.search(r"(CN\s*\d{9,}[A-Z]?|ZL\s*\d{9,}\.\d+)", href + title, re.I)
        pub_number = pub_m.group(1).replace(" ", "") if pub_m else None
        raw = m.group(0)[:2000]
        if len(title) < 2 and not pub_number:
            continue
        hits.append(
            EpubSearchHit(
                raw_html=raw,
                title=title or None,
                pub_number=pub_number,
                link=link,
            )
        )
    seen: set[str] = set()
    out: list[EpubSearchHit] = []
    for h in hits:
        key = h.link or h.title or ""
        if key in seen:
            continue
        seen.add(key)
        out.append(h)
    return out


def hits_to_jsonable(hits: list[EpubSearchHit]) -> list[dict]:
    """供 JSON 序列化(不含 raw_html 过大字段时可裁剪)。"""
    rows = []
    for h in hits:
        d = asdict(h)
        d.pop("raw_html", None)
        rows.append(d)
    return rows


if __name__ == "__main__":
    if len(sys.argv) < 2:
        print("用法: python cnipa_epub_parse.py <结果页.html>", file=sys.stderr)
        sys.exit(2)
    p = Path(sys.argv[1]).expanduser().resolve()
    html = p.read_text(encoding="utf-8")
    hits = parse_search_result_html(html)
    print(json.dumps(hits_to_jsonable(hits), ensure_ascii=False, indent=2))
scripts/disclosure/cnipa_epub_search.py
# -*- 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 前完成**(见 ``references/disclosure/prior_art_search.md``「国知局检索词(生成阶段必做)」)。
若需**整句一次**向公布站提交(站内 AND),请改用 ``cnipa_epub_crawler.py`` 单传一句。

需已安装:pip install -r scripts/disclosure/requirements-cnipa.txt && python -m playwright install chromium

用法:

  python scripts/disclosure/cnipa_epub_search.py 词1
  python scripts/disclosure/cnipa_epub_search.py "短语 含 空格"
  python scripts/disclosure/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 scripts/disclosure/cnipa_epub_search.py <term> [more terms...]", file=sys.stderr)
    print(
        "whitespace splits to multiple terms; one Playwright run per term; merge by pub_number.",
        file=sys.stderr,
    )
    print('example: python scripts/disclosure/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 scripts/disclosure/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())
scripts/disclosure/docx_to_md.py
#!/usr/bin/env python3
"""
将 Word(.docx)转为 Markdown,并把内嵌图片抽取到磁盘,便于 Step 2 扫描与 Agent Read。

依赖 mammoth(见仓库根目录 requirements.txt)。

用法:
  python docx_to_md.py --input design.docx --output outputs/case/design.md
  python docx_to_md.py -i a.docx -o b/out.md --media-dir b/my_images

默认图片目录:与输出 .md 同级的「{md 文件名}_media/」,Markdown 中为相对路径引用。
"""

from __future__ import annotations

import argparse
import sys
from pathlib import Path


def _require_mammoth():
    try:
        import mammoth
    except ImportError:
        print(
            "缺少依赖 mammoth。请在技能根目录执行: pip install -r requirements.txt",
            file=sys.stderr,
        )
        sys.exit(1)
    return mammoth


def _extension_for_content_type(content_type: str) -> str:
    subtype = (content_type or "").split("/")[-1].lower().strip()
    if not subtype or subtype == "octet-stream":
        return "bin"
    if subtype == "jpeg":
        return "jpg"
    return subtype[:12]


def _run(
    input_docx: Path,
    output_md: Path,
    media_dir: Path | None,
) -> int:
    mammoth = _require_mammoth()

    if not input_docx.is_file():
        print(f"输入文件不存在: {input_docx}", file=sys.stderr)
        return 2
    if input_docx.suffix.lower() != ".docx":
        print("警告: 期望 .docx(Office Open XML);旧版 .doc 不支持。", file=sys.stderr)

    output_md = output_md.resolve()
    output_md.parent.mkdir(parents=True, exist_ok=True)

    if media_dir is None:
        media_dir = output_md.parent / f"{output_md.stem}_media"
    else:
        media_dir = media_dir.resolve()
    media_dir.mkdir(parents=True, exist_ok=True)

    counter = [0]

    def save_image(image):
        counter[0] += 1
        ext = _extension_for_content_type(getattr(image, "content_type", "") or "")
        filename = f"img_{counter[0]:04d}.{ext}"
        out_path = media_dir / filename
        try:
            with image.open() as f:
                out_path.write_bytes(f.read())
        except Exception as e:
            print(f"警告: 抽取图片失败 ({filename}): {e}", file=sys.stderr)
            return {"src": "", "alt": ""}

        try:
            rel = out_path.relative_to(output_md.parent).as_posix()
        except ValueError:
            rel = out_path.as_posix()
        alt = getattr(image, "alt_text", None) or ""
        return {"src": rel, "alt": alt}

    image_converter = mammoth.images.img_element(save_image)

    with input_docx.open("rb") as docx_file:
        result = mammoth.convert_to_markdown(docx_file, convert_image=image_converter)

    for msg in result.messages:
        text = getattr(msg, "message", str(msg))
        typ = getattr(msg, "type", "message")
        print(f"mammoth [{typ}]: {text}", file=sys.stderr)

    text = (result.value or "").strip()
    header = (
        f"<!-- 由 docx_to_md.py 自 {input_docx.name} 转换,勿手改本行元信息 -->\n\n"
    )
    output_md.write_text(header + text + ("\n" if text else ""), encoding="utf-8")

    print(f"已写入: {output_md}")
    print(f"图片目录: {media_dir}")
    return 0


def main() -> int:
    p = argparse.ArgumentParser(description="Word (.docx) → Markdown + 抽取图片")
    p.add_argument("-i", "--input", required=True, type=Path, help="输入 .docx 路径")
    p.add_argument("-o", "--output", required=True, type=Path, help="输出 .md 路径")
    p.add_argument(
        "--media-dir",
        type=Path,
        default=None,
        help="图片输出目录(默认:与 .md 同级的 {md 主名}_media)",
    )
    args = p.parse_args()
    return _run(args.input, args.output, args.media_dir)


if __name__ == "__main__":
    raise SystemExit(main())
scripts/disclosure/iteration_dialog_log.py
#!/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 按 `references/disclosure/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())
scripts/disclosure/math_render.py
#!/usr/bin/env python3
r"""
将 Markdown 中的 LaTeX 公式渲染为 PNG(matplotlib mathtext),**保留 `$...$` / `\(...\)` / `$$...$$` / `\[...\]` 原文**,
图片引用写入 HTML 注释 ``<!-- ![...](path) -->``(预览不显示图,Word 仍嵌入)。

支持(失败时**保留原文**,不中断):

- **块级**:``$$ ... $$``(可跨行)、单行 ``$$...$$``、``\\[ ... \\]``
- **行内**:``$...$``、``\(...\)``(渲染失败则保留原文)

用法:

  python scripts/disclosure/math_render.py -i draft.md -o draft_with_math.md
  python scripts/disclosure/math_render.py -i draft.md -o out.md --assets-dir math_figures

依赖:``pip install matplotlib``(见仓库根 ``requirements.txt``)。
"""
from __future__ import annotations

import argparse
import re
import sys
from pathlib import Path

_DEFAULT_ASSETS = "math_figures"
_INLINE_RE = re.compile(
    r"(?<!\$)\$(?!\$)((?:\\.|[^$\n])+?)\$(?!\$)(?!\s*<!--)"
)
_INLINE_PAREN_RE = re.compile(r"\\\(((?:\\.|[^)])+?)\\\)(?!\s*<!--)")
_HIDDEN_IMG_COMMENT_RE = re.compile(
    r"<!--\s*!\[[^\]]*\]\([^)]+\)\s*-->"
)

# matplotlib mathtext 不识别部分 LaTeX 简写;按「长命令优先」映射为 mathtext 符号
_LATEX_CMD_ALIASES: tuple[tuple[str, str], ...] = (
    ("geqslant", "geq"),
    ("leqslant", "leq"),
    ("geqq", "geq"),
    ("leqq", "leq"),
    ("ge", "geq"),
    ("le", "leq"),
    ("ne", "neq"),
    ("land", "wedge"),
    ("lor", "vee"),
    ("gets", "leftarrow"),
    ("to", "rightarrow"),
    ("iff", "Longleftrightarrow"),
    ("implies", "Rightarrow"),
)


def normalize_latex_for_mathtext(body: str) -> str:
    """将常见 LaTeX 命令转为 matplotlib mathtext 可解析形式。"""
    out = body
    for short, repl in _LATEX_CMD_ALIASES:
        if short == repl:
            continue
        out = re.sub(rf"\\{short}(?![A-Za-z])", rf"\\{repl}", out)
    # 交底书 Word 正文为常规宋体,公式图不做数学粗体
    for cmd in ("mathbf", "bm", "boldsymbol", "textbf"):
        prev = None
        while prev != out:
            prev = out
            out = re.sub(rf"\\{cmd}\{{([^{{}}]+)\}}", r"\1", out)
    # mathtext 不支持 amsmath 编号/标签;块级公式内换行也会解析失败
    out = re.sub(r"\\label\s*\{[^{}]*\}", "", out)
    out = re.sub(r"\\tag\s*\{([^{}]*)\}", r"\\quad (\1)", out)
    out = re.sub(r"\\notag\b", "", out)
    out = re.sub(r"\s+", " ", out).strip()
    return out


def render_latex_to_png(
    latex: str,
    png_path: Path,
    *,
    dpi: int = 200,
    fontsize: float = 14.0,
) -> None:
    """用 matplotlib mathtext 将 LaTeX 片段写入 PNG(无坐标轴/网格)。"""
    import matplotlib

    matplotlib.use("Agg")
    from matplotlib import mathtext
    from matplotlib.font_manager import FontProperties

    body = latex.strip()
    if body.startswith("$") and body.endswith("$") and not body.startswith("$$"):
        body = body[1:-1].strip()
    if body.startswith("\\(") and body.endswith("\\)"):
        body = body[2:-2].strip()

    body = normalize_latex_for_mathtext(body)

    png_path.parent.mkdir(parents=True, exist_ok=True)
    mathtext.math_to_image(
        f"${body}$",
        str(png_path),
        prop=FontProperties(size=fontsize, weight="normal"),
        dpi=dpi,
        format="png",
    )


def _next_eq_name(counter: dict[str, int], kind: str) -> str:
    counter[kind] = counter.get(kind, 0) + 1
    n = counter[kind]
    if kind == "inline":
        return f"inline_{n:03d}.png"
    return f"eq_{n:03d}.png"


def _try_render(
    latex: str,
    png_path: Path,
    *,
    dpi: int,
    fontsize: float,
) -> bool:
    try:
        render_latex_to_png(latex, png_path, dpi=dpi, fontsize=fontsize)
        return png_path.is_file() and png_path.stat().st_size > 0
    except Exception as e:
        snippet = latex.strip().replace("\n", " ")[:120]
        print(f"[math_render] 渲染失败(将保留原文):{snippet}", file=sys.stderr)
        print(f"  {e}", file=sys.stderr)
        return False


def _replace_inline_math(
    text: str,
    assets_dir: Path,
    assets_rel: str,
    counter: dict[str, int],
    *,
    dpi: int,
    fontsize: float,
) -> tuple[str, int, int]:
    ok = 0
    failed = 0

    def render_one(inner: str, wrapper: str) -> str:
        nonlocal ok, failed
        fname = _next_eq_name(counter, "inline")
        png_path = assets_dir / fname
        if _try_render(inner, png_path, dpi=dpi, fontsize=fontsize):
            ok += 1
            rel = f"{assets_rel.strip('/')}/{fname}".replace("\\", "/")
            return f"{wrapper}<!-- ![公式·行内]({rel}) -->"
        failed += 1
        return wrapper

    def repl_dollar(m: re.Match[str]) -> str:
        inner = m.group(1)
        return render_one(inner, f"${inner}$")

    def repl_paren(m: re.Match[str]) -> str:
        inner = m.group(1)
        return render_one(inner, f"\\({inner}\\)")

    text = _INLINE_RE.sub(repl_dollar, text)
    text = _INLINE_PAREN_RE.sub(repl_paren, text)
    return text, ok, failed


def render_markdown_math(
    md_text: str,
    *,
    out_md_path: Path,
    assets_rel: str = _DEFAULT_ASSETS,
    dpi: int = 200,
    block_fontsize: float = 10.5,
    inline_fontsize: float = 10.5,
) -> tuple[str, int, int]:
    """
    返回 (新 markdown, 成功渲染数, 失败保留原文数)。
    PNG 目录:``out_md_path.parent / assets_rel``。
    """
    assets_dir = out_md_path.parent / assets_rel.strip("/\\")
    assets_dir.mkdir(parents=True, exist_ok=True)
    counter: dict[str, int] = {}
    ok = 0
    failed = 0

    lines = md_text.splitlines(keepends=True)
    out: list[str] = []
    i = 0

    while i < len(lines):
        stripped = lines[i].strip()

        # 块级 $$ ... $$
        if stripped == "$$":
            i += 1
            body_lines: list[str] = []
            while i < len(lines) and lines[i].strip() != "$$":
                body_lines.append(lines[i])
                i += 1
            closing = i < len(lines)
            if closing:
                i += 1
            if i < len(lines) and _HIDDEN_IMG_COMMENT_RE.match(lines[i].strip()):
                out.append("$$\n")
                out.extend(body_lines)
                out.append("$$\n")
                out.append(lines[i])
                i += 1
                continue
            latex = "".join(body_lines).strip()
            if not latex:
                out.append("$$\n")
                if body_lines:
                    out.extend(body_lines)
                if closing:
                    out.append("$$\n")
                continue
            fname = _next_eq_name(counter, "block")
            png_path = assets_dir / fname
            if _try_render(latex, png_path, dpi=dpi, fontsize=block_fontsize):
                ok += 1
                rel = f"{assets_rel.strip('/')}/{fname}".replace("\\", "/")
                out.append("$$\n")
                out.extend(body_lines)
                out.append("$$\n")
                out.append(f"<!-- ![公式]({rel}) -->\n")
            else:
                failed += 1
                out.append("$$\n")
                out.extend(body_lines)
                if not body_lines or not body_lines[-1].endswith("\n"):
                    pass
                out.append("$$\n")
            continue

        # 单行 $$...$$
        if (
            stripped.startswith("$$")
            and stripped.endswith("$$")
            and len(stripped) > 4
        ):
            latex = stripped[2:-2].strip()
            fname = _next_eq_name(counter, "block")
            png_path = assets_dir / fname
            if _try_render(latex, png_path, dpi=dpi, fontsize=block_fontsize):
                ok += 1
                rel = f"{assets_rel.strip('/')}/{fname}".replace("\\", "/")
                out.append(f"$${latex}$$\n")
                out.append(f"<!-- ![公式]({rel}) -->\n")
            else:
                failed += 1
                out.append(lines[i])
            i += 1
            continue

        # 块级 \[ ... \]
        if stripped.startswith("\\["):
            if stripped.endswith("\\]") and len(stripped) > 4:
                latex = stripped[2:-2].strip()
                fname = _next_eq_name(counter, "block")
                png_path = assets_dir / fname
                if _try_render(latex, png_path, dpi=dpi, fontsize=block_fontsize):
                    ok += 1
                    rel = f"{assets_rel.strip('/')}/{fname}".replace("\\", "/")
                    out.append(f"\\[{latex}\\]\n")
                    out.append(f"<!-- ![公式]({rel}) -->\n")
                else:
                    failed += 1
                    out.append(lines[i])
                i += 1
                continue
            i += 1
            body_lines = []
            while i < len(lines) and "\\]" not in lines[i]:
                body_lines.append(lines[i])
                i += 1
            tail = lines[i] if i < len(lines) else ""
            if i < len(lines):
                i += 1
            if i < len(lines) and _HIDDEN_IMG_COMMENT_RE.match(lines[i].strip()):
                out.append("\\[\n")
                out.extend(body_lines)
                out.append(tail if tail.endswith("\n") else tail + "\n")
                out.append(lines[i])
                i += 1
                continue
            latex = "".join(body_lines) + tail
            latex = latex.replace("\\[", "", 1).replace("\\]", "").strip()
            fname = _next_eq_name(counter, "block")
            png_path = assets_dir / fname
            if latex and _try_render(latex, png_path, dpi=dpi, fontsize=block_fontsize):
                ok += 1
                rel = f"{assets_rel.strip('/')}/{fname}".replace("\\", "/")
                out.append("\\[\n")
                out.extend(body_lines)
                out.append(tail if tail.endswith("\n") else tail + "\n")
                out.append(f"<!-- ![公式]({rel}) -->\n")
            else:
                failed += 1
                out.append("\\[\n")
                out.extend(body_lines)
                out.append(tail if tail.endswith("\n") else tail + "\n")
            continue

        # 围栏代码 / mermaid:不处理行内 $
        if stripped.startswith("```"):
            out.append(lines[i])
            i += 1
            while i < len(lines) and not lines[i].strip().startswith("```"):
                out.append(lines[i])
                i += 1
            if i < len(lines):
                out.append(lines[i])
                i += 1
            continue

        # 标题、图片行、空行:原样(图片行不跑行内替换)
        if (
            not stripped
            or stripped.startswith("#")
            or (stripped.startswith("![") and "](" in stripped)
        ):
            out.append(lines[i])
            i += 1
            continue

        new_line, i_ok, i_fail = _replace_inline_math(
            lines[i],
            assets_dir,
            assets_rel,
            counter,
            dpi=dpi,
            fontsize=inline_fontsize,
        )
        ok += i_ok
        failed += i_fail
        out.append(new_line if new_line.endswith("\n") else new_line + "\n")
        i += 1

    return "".join(out), ok, failed


def main(argv: list[str] | None = None) -> int:
    p = argparse.ArgumentParser(description="Markdown LaTeX 公式 → PNG")
    p.add_argument("-i", "--input", required=True, type=Path)
    p.add_argument("-o", "--output", required=True, type=Path)
    p.add_argument(
        "--assets-dir",
        default=_DEFAULT_ASSETS,
        help=f"PNG 相对输出 .md 的子目录(默认 {_DEFAULT_ASSETS})",
    )
    p.add_argument("--dpi", type=int, default=200)
    p.add_argument("--block-fontsize", type=float, default=10.5)
    p.add_argument("--inline-fontsize", type=float, default=10.5)
    args = p.parse_args(argv)

    in_path = args.input.resolve()
    if not in_path.is_file():
        print(f"错误:找不到输入 {in_path}", file=sys.stderr)
        return 1

    try:
        import matplotlib  # noqa: F401
    except ImportError:
        print("请先安装: pip install matplotlib", file=sys.stderr)
        return 1

    out_path = args.output.resolve()
    out_path.parent.mkdir(parents=True, exist_ok=True)
    md = in_path.read_text(encoding="utf-8")

    new_md, ok, failed = render_markdown_math(
        md,
        out_md_path=out_path,
        assets_rel=args.assets_dir.strip("/\\") or _DEFAULT_ASSETS,
        dpi=args.dpi,
        block_fontsize=args.block_fontsize,
        inline_fontsize=args.inline_fontsize,
    )
    out_path.write_text(new_md, encoding="utf-8")
    msg = f"已写入 {out_path}(公式:{ok} 处已转为 PNG"
    if failed:
        msg += f",{failed} 处失败已保留原文"
    print(msg + ")", file=sys.stderr)
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
scripts/disclosure/md_to_docx.py
#!/usr/bin/env python3
"""
将 Markdown 转为 Word(.docx),按标题层级映射为 Word 内置「标题 1–9」样式,
便于交底书交付代理人或所内流程。

支持:ATX 标题 (#–######)、段落、**粗体**、行内 `代码`、无序/有序列表、
围栏代码块、简单 GFM 表格、引用块(>)、水平线(---)、行内图片 ``![](path.png)``
(在最大宽、最大高约束下**等比缩放**,竖图自动缩小宽度以整图落入版面)。

**连续多行正文**(中间无空行、且非列表/标题等)时,**每一行**输出为 Word 中**独立一段**,
以便「(1)…(2)…」等分条换行;若须在同一段内接排,请写**同一行**内或用 Markdown 空行分隔逻辑段。

定稿宜先用同目录 **`mermaid_render.py`** 将 **mermaid** 转为 PNG;**LaTeX 公式**(``$...$`` / ``$$...$$``)由 **`math_render.py`**(或 ``md_to_docx`` 自动调用)转为 PNG;失败时保留原文写入 Word。

用法:
  python md_to_docx.py --input disclosure.md --output disclosure.docx
  python md_to_docx.py -i a.md -o b.docx --base-dir .   # 解析图片相对路径

依赖:python-docx
"""

from __future__ import annotations

import argparse
import re
import sys
from pathlib import Path

from docx import Document
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.oxml.ns import qn
from docx.shared import Inches, Pt, RGBColor

# 插图最大尺寸(英寸):在常见 A4、默认边距下保证整图可见、按比例缩放(不过宽也不过高)。
_DEFAULT_IMAGE_MAX_W_IN = 5.5
_DEFAULT_IMAGE_MAX_H_IN = 8.2
# 公式图在 Word 中统一按固定高度嵌入(英寸),避免块级式随 PNG 像素被放大、行内式过小
_FORMULA_DISPLAY_MAX_H_IN = 0.17
# 兼容旧名
_FORMULA_INLINE_MAX_H_IN = _FORMULA_DISPLAY_MAX_H_IN
_FORMULA_BLOCK_MAX_W_IN = 4.0  # 仅作块级超宽时的宽度上限(通常由固定高度约束)
_FORMULA_BLOCK_MAX_H_IN = _FORMULA_DISPLAY_MAX_H_IN

_MD_IMAGE_RE = re.compile(r"!\[([^\]]*)\]\(([^)]+)\)")
_HIDDEN_MD_IMAGE_COMMENT_RE = re.compile(
    r"<!--\s*!\[([^\]]*)\]\(([^)]+)\)\s*-->"
)
_INLINE_MATH_WITH_HIDDEN_IMG_RE = re.compile(
    r"(?<!\$)\$(?!\$)((?:\\.|[^$\n])+?)\$(?!\$)\s*"
    r"<!--\s*!\[([^\]]*)\]\(([^)]+)\)\s*-->"
)
_INLINE_MATH_PAREN_WITH_HIDDEN_IMG_RE = re.compile(
    r"\\\(((?:\\.|[^)])+?)\\\)\s*"
    r"<!--\s*!\[([^\]]*)\]\(([^)]+)\)\s*-->"
)


def _parse_hidden_image_comment(line: str) -> tuple[str, str] | None:
    m = _HIDDEN_MD_IMAGE_COMMENT_RE.match(line.strip())
    if not m:
        return None
    return m.group(1), m.group(2).strip()


def _try_embed_hidden_comment_line(
    doc: Document,
    line: str,
    base_dir: Path | None,
    *,
    image_max_w_in: float,
    image_max_h_in: float,
) -> bool:
    hidden = _parse_hidden_image_comment(line)
    if not hidden or not base_dir:
        return False
    alt, src = hidden
    if not _resolve_image_path(src, base_dir):
        return False
    _embed_from_image_ref(
        alt,
        src,
        base_dir,
        doc=doc,
        image_max_w_in=image_max_w_in,
        image_max_h_in=image_max_h_in,
    )
    return True


def _image_pixel_size(path: Path) -> tuple[int, int] | None:
    """读取常见位图宽高(像素),失败返回 None。不依赖 Pillow。"""
    try:
        raw = path.read_bytes()
    except OSError:
        return None
    if len(raw) >= 24 and raw.startswith(b"\x89PNG\r\n\x1a\n") and raw[12:16] == b"IHDR":
        w = int.from_bytes(raw[16:20], "big")
        h = int.from_bytes(raw[20:24], "big")
        if w > 0 and h > 0:
            return w, h
    if len(raw) >= 10 and raw[:3] == b"GIF" and raw[3:6] in (b"87a", b"89a"):
        w = int.from_bytes(raw[6:8], "little")
        h = int.from_bytes(raw[8:10], "little")
        if w > 0 and h > 0:
            return w, h
    if len(raw) >= 4 and raw.startswith(b"\xff\xd8"):
        i = 2
        n = len(raw)
        while i < n:
            if raw[i] != 0xFF:
                i += 1
                continue
            i += 1
            while i < n and raw[i] == 0xFF:
                i += 1
            if i >= n:
                break
            marker = raw[i]
            i += 1
            if marker in (0xD8, 0xD9):
                continue
            if marker == 0xDA:
                break
            if 0xD0 <= marker <= 0xD7:
                continue
            if i + 2 > n:
                break
            seg_len = int.from_bytes(raw[i : i + 2], "big")
            if seg_len < 2:
                break
            i += 2
            if marker in (0xC0, 0xC1, 0xC2) and i + 5 <= n:
                h = int.from_bytes(raw[i + 1 : i + 3], "big")
                w = int.from_bytes(raw[i + 3 : i + 5], "big")
                if w > 0 and h > 0:
                    return w, h
            i += seg_len - 2
    return None


def _fit_image_display_inches(
    px_w: int,
    px_h: int,
    *,
    max_w_in: float,
    max_h_in: float,
) -> tuple[Inches, Inches]:
    """在不超过 max_w / max_h 的前提下等比缩放,使整图落入版面。"""
    if px_w <= 0 or px_h <= 0:
        return Inches(max_w_in), Inches(max_h_in * 0.5)
    aw = max_w_in
    ah = aw * px_h / px_w
    if ah > max_h_in:
        ah = max_h_in
        aw = ah * px_w / px_h
    return Inches(aw), Inches(ah)


def _formula_image_kind(alt: str, src: str) -> str | None:
    """返回 ``block`` / ``inline`` 表示公式图,否则 None(含注释内引用)。"""
    a = alt or ""
    s = src.replace("\\", "/")
    if "math_figures" not in s and "公式" not in a:
        return None
    if "行内" in a:
        return "inline"
    return "block"


def _is_diagram_image(alt: str, src: str) -> bool:
    """mermaid 系统框图 / 流程图等(非公式,用全幅插图尺寸)。"""
    a = alt or ""
    s = src.replace("\\", "/")
    if "mermaid_figures" in s:
        return True
    if a.startswith("图示") or a.startswith("图 "):
        return True
    return False


def _span_overlaps(spans: list[tuple[int, int]], start: int, end: int) -> bool:
    return any(not (end <= s or start >= e) for s, e in spans)


def _embed_from_image_ref(
    alt: str,
    src: str,
    base_dir: Path | None,
    *,
    doc: Document | None = None,
    paragraph=None,
    image_max_w_in: float = _DEFAULT_IMAGE_MAX_W_IN,
    image_max_h_in: float = _DEFAULT_IMAGE_MAX_H_IN,
) -> None:
    """按公式 / 框图 / 普通图规则嵌入 PNG(仅公式用小尺寸)。"""
    ipath = _resolve_image_path(src, base_dir) if base_dir else None
    missing = f"[图片缺失: {alt or src}]"
    if not ipath:
        if paragraph is not None:
            paragraph.add_run(missing)
        elif doc is not None:
            doc.add_paragraph().add_run(missing)
        return

    kind = _formula_image_kind(alt, src)
    if kind == "inline":
        p = paragraph
        if p is None and doc is not None:
            p = doc.add_paragraph()
            p.paragraph_format.space_after = Pt(6)
            p.paragraph_format.line_spacing = 1.15
        if p is not None:
            _embed_picture_inline(p, ipath, max_h_in=_FORMULA_DISPLAY_MAX_H_IN)
        return

    if doc is None:
        if paragraph is not None:
            paragraph.add_run(missing)
        return

    if kind == "block":
        p = doc.add_paragraph()
        p.paragraph_format.space_after = Pt(6)
        p.paragraph_format.space_before = Pt(3)
        _embed_picture_inline(
            p,
            ipath,
            max_h_in=_FORMULA_DISPLAY_MAX_H_IN,
            max_w_in=_FORMULA_BLOCK_MAX_W_IN,
        )
    else:
        _embed_picture(
            doc,
            ipath,
            alt=alt,
            src=src,
            max_w_in=image_max_w_in,
            max_h_in=image_max_h_in,
            center=False,
        )


def _maybe_render_math_md(md_text: str, base_dir: Path) -> str:
    """若含 LaTeX 公式则尝试调用 ``math_render``(已注释的 PNG 引用会跳过)。"""
    if not re.search(r"\$\$|\\\[|\\\(|(?<!\$)\$(?!\$)", md_text):
        return md_text
    try:
        from math_render import render_markdown_math
    except ImportError:
        print(
            "[md_to_docx] 未安装 matplotlib,公式将按原文写入 Word",
            file=sys.stderr,
        )
        return md_text
    stub = base_dir / "_md_to_docx_math_stub.md"
    new_md, ok, failed = render_markdown_math(
        md_text,
        out_md_path=stub,
        assets_rel="math_figures",
    )
    if ok or failed:
        print(
            f"[md_to_docx] 公式渲染:{ok} 成功,{failed} 保留原文",
            file=sys.stderr,
        )
    return new_md


def _add_math_fallback_block(doc: Document, lines: list[str]) -> None:
    """未渲染成功的 ``$$ ... $$`` 以等宽原文写入 Word。"""
    body = [ln.rstrip("\n") for ln in lines]
    _add_code_block(doc, ["$$", *body, "$$"])


def _embed_picture(
    doc: Document,
    path: Path,
    *,
    alt: str,
    src: str,
    max_w_in: float,
    max_h_in: float,
    center: bool,
) -> None:
    p = doc.add_paragraph()
    if center:
        p.alignment = WD_ALIGN_PARAGRAPH.CENTER
    p.paragraph_format.space_after = Pt(6)
    p.paragraph_format.space_before = Pt(3)
    try:
        dims = _image_pixel_size(path)
        if dims:
            w_in, h_in = _fit_image_display_inches(
                *dims, max_w_in=max_w_in, max_h_in=max_h_in
            )
            run = p.add_run()
            run.font.bold = False
            run.add_picture(str(path.resolve()), width=w_in, height=h_in)
        else:
            run = p.add_run()
            run.font.bold = False
            run.add_picture(str(path.resolve()), width=Inches(max_w_in))
    except Exception:
        p.add_run(f"[图片无法嵌入: {alt or src} — {path}]")


def _embed_picture_inline(
    paragraph,
    path: Path,
    *,
    max_h_in: float,
    max_w_in: float | None = None,
) -> None:
    try:
        dims = _image_pixel_size(path)
        run = paragraph.add_run()
        run.font.bold = False
        if dims:
            px_w, px_h = dims
            h_in = max_h_in
            w_in = h_in * px_w / px_h if px_h else max_h_in
            if max_w_in is not None and w_in > max_w_in:
                w_in = max_w_in
                h_in = w_in * px_h / px_w if px_w else max_h_in
            run.add_picture(str(path.resolve()), width=Inches(w_in), height=Inches(h_in))
        else:
            run.add_picture(str(path.resolve()), height=Inches(max_h_in))
    except Exception:
        paragraph.add_run(f"[行内公式图缺失: {path}]")


def _add_rich_content_to_paragraph(
    paragraph,
    text: str,
    base_dir: Path | None,
    *,
    formula_inline_max_h_in: float = _FORMULA_DISPLAY_MAX_H_IN,
    image_max_w_in: float = _DEFAULT_IMAGE_MAX_W_IN,
    image_max_h_in: float = _DEFAULT_IMAGE_MAX_H_IN,
    mono: bool = False,
) -> None:
    """同一段内混排文字(**粗体**/`代码`)与公式/插图(含 HTML 注释隐藏引用)。"""
    taken: list[tuple[int, int]] = []
    tokens: list[tuple[int, int, str, tuple]] = []

    for m in _INLINE_MATH_WITH_HIDDEN_IMG_RE.finditer(text):
        tokens.append((m.start(), m.end(), "math_img", (m.group(2), m.group(3).strip())))
        taken.append((m.start(), m.end()))

    for m in _INLINE_MATH_PAREN_WITH_HIDDEN_IMG_RE.finditer(text):
        if _span_overlaps(taken, m.start(), m.end()):
            continue
        tokens.append((m.start(), m.end(), "math_img", (m.group(2), m.group(3).strip())))
        taken.append((m.start(), m.end()))

    for m in _HIDDEN_MD_IMAGE_COMMENT_RE.finditer(text):
        if _span_overlaps(taken, m.start(), m.end()):
            continue
        tokens.append((m.start(), m.end(), "hidden_img", (m.group(1), m.group(2).strip())))
        taken.append((m.start(), m.end()))

    for m in _MD_IMAGE_RE.finditer(text):
        if _span_overlaps(taken, m.start(), m.end()):
            continue
        tokens.append((m.start(), m.end(), "visible_img", (m.group(1), m.group(2).strip())))
        taken.append((m.start(), m.end()))

    inline_pat = re.compile(r"(\*\*[^*]+?\*\*|`[^`]+?`)")
    for m in inline_pat.finditer(text):
        if _span_overlaps(taken, m.start(), m.end()):
            continue
        tokens.append((m.start(), m.end(), "inline", (m.group(1),)))
        taken.append((m.start(), m.end()))

    tokens.sort(key=lambda t: t[0])
    pos = 0
    for start, end, kind, payload in tokens:
        if start > pos:
            _add_inline_to_paragraph(paragraph, text[pos:start], mono=mono)
        if kind == "inline":
            token = payload[0]
            if token.startswith("**"):
                run = paragraph.add_run(token[2:-2])
                _set_run_font(run, "宋体", 10.5, bold=True)
            else:
                run = paragraph.add_run(token[1:-1])
                _set_run_font(run, "Consolas", 9)
                run.font.color.rgb = RGBColor(0x33, 0x33, 0x33)
        else:
            alt, src = payload[0], payload[1]
            _embed_from_image_ref(
                alt,
                src,
                base_dir,
                paragraph=paragraph,
                image_max_w_in=image_max_w_in,
                image_max_h_in=image_max_h_in,
            )
        pos = end
    if pos < len(text):
        _add_inline_to_paragraph(paragraph, text[pos:], mono=mono)


def _set_run_font(run, name: str = "宋体", size_pt: float | None = None, bold: bool | None = None):
    run.font.name = name
    run._element.rPr.rFonts.set(qn("w:eastAsia"), name)
    if size_pt is not None:
        run.font.size = Pt(size_pt)
    if bold is not None:
        run.font.bold = bold


def _add_inline_to_paragraph(paragraph, text: str, *, mono: bool = False):
    """解析 **粗体**、`行内代码` 与普通文本,写入同一段落。"""
    if not text:
        return
    # 拆分为:粗体、行内代码、普通
    pattern = re.compile(r"(\*\*[^*]+?\*\*|`[^`]+?`)")
    pos = 0
    for m in pattern.finditer(text):
        if m.start() > pos:
            run = paragraph.add_run(text[pos : m.start()])
            _set_run_font(run, "Consolas" if mono else "宋体", 10.5 if not mono else 9)
        token = m.group(1)
        if token.startswith("**"):
            run = paragraph.add_run(token[2:-2])
            _set_run_font(run, "宋体", 10.5, bold=True)
        else:  # `code`
            run = paragraph.add_run(token[1:-1])
            _set_run_font(run, "Consolas", 9)
            run.font.color.rgb = RGBColor(0x33, 0x33, 0x33)
        pos = m.end()
    if pos < len(text):
        run = paragraph.add_run(text[pos:])
        _set_run_font(run, "Consolas" if mono else "宋体", 10.5 if not mono else 9)


def _add_heading(doc: Document, level: int, text: str):
    """level 1–9 对应 Word 标题 1–标题 9;去除行内标记时保留可读文本。"""
    plain = re.sub(r"\*\*([^*]+)\*\*", r"\1", text)
    plain = re.sub(r"`([^`]+)`", r"\1", plain)
    h = doc.add_heading(plain, level=min(max(level, 1), 9))
    for run in h.runs:
        _set_run_font(run, "黑体" if level <= 2 else "宋体")


def _add_body_paragraph(
    doc: Document,
    text: str,
    base_dir: Path | None = None,
    *,
    image_max_h_in: float = _DEFAULT_IMAGE_MAX_H_IN,
):
    p = doc.add_paragraph()
    p.paragraph_format.space_after = Pt(6)
    p.paragraph_format.line_spacing = 1.15
    if (
        _MD_IMAGE_RE.search(text)
        or _HIDDEN_MD_IMAGE_COMMENT_RE.search(text)
        or _INLINE_MATH_WITH_HIDDEN_IMG_RE.search(text)
        or _INLINE_MATH_PAREN_WITH_HIDDEN_IMG_RE.search(text)
    ):
        _add_rich_content_to_paragraph(
            p,
            text,
            base_dir,
            image_max_w_in=_DEFAULT_IMAGE_MAX_W_IN,
            image_max_h_in=image_max_h_in,
        )
    else:
        _add_inline_to_paragraph(p, text)
    for run in p.runs:
        if run.font.name in (None, ""):
            _set_run_font(run, "宋体", 10.5)


def _add_code_block(doc: Document, lines: list[str]):
    p = doc.add_paragraph()
    p.paragraph_format.left_indent = Inches(0.2)
    p.paragraph_format.space_after = Pt(6)
    p.paragraph_format.keep_together = True
    body = "\n".join(lines)
    run = p.add_run(body)
    _set_run_font(run, "Consolas", 9)
    run.font.color.rgb = RGBColor(0x20, 0x20, 0x20)


def _add_list_item(
    doc: Document,
    text: str,
    ordered: bool,
    base_dir: Path | None,
    *,
    image_max_h_in: float = _DEFAULT_IMAGE_MAX_H_IN,
):
    style = "List Number" if ordered else "List Bullet"
    try:
        p = doc.add_paragraph(style=style)
    except (KeyError, ValueError):
        p = doc.add_paragraph()
        p.paragraph_format.left_indent = Inches(0.35)
    p.paragraph_format.space_after = Pt(3)
    if (
        _MD_IMAGE_RE.search(text)
        or _HIDDEN_MD_IMAGE_COMMENT_RE.search(text)
        or _INLINE_MATH_WITH_HIDDEN_IMG_RE.search(text)
        or _INLINE_MATH_PAREN_WITH_HIDDEN_IMG_RE.search(text)
    ):
        _add_rich_content_to_paragraph(
            p,
            text,
            base_dir,
            image_max_w_in=_DEFAULT_IMAGE_MAX_W_IN,
            image_max_h_in=image_max_h_in,
        )
    else:
        _add_inline_to_paragraph(p, text)
    for run in p.runs:
        _set_run_font(run, "宋体", 10.5)


def _is_table_row(line: str) -> bool:
    s = line.strip()
    return s.startswith("|") and s.endswith("|") and "|" in s[1:-1]


def _split_table_cells(line: str) -> list[str]:
    """按列分隔符 ``|`` 拆分表格行,忽略 ``\\(...\\)``、``$...$``、``<!-- -->`` 与 ``\\|`` 内的竖线。"""
    s = line.strip()
    if s.startswith("|"):
        s = s[1:]
    if s.endswith("|"):
        s = s[:-1]

    cells: list[str] = []
    buf: list[str] = []
    i = 0
    n = len(s)

    while i < n:
        if s.startswith("<!--", i):
            end = s.find("-->", i)
            if end == -1:
                buf.append(s[i:])
                break
            buf.append(s[i : end + 3])
            i = end + 3
            continue

        if s.startswith("\\(", i):
            end = s.find("\\)", i + 2)
            if end == -1:
                buf.append(s[i:])
                break
            buf.append(s[i : end + 2])
            i = end + 2
            continue

        if s[i] == "$":
            if i + 1 < n and s[i + 1] == "$":
                end = s.find("$$", i + 2)
                if end == -1:
                    buf.append(s[i:])
                    break
                buf.append(s[i : end + 2])
                i = end + 2
                continue
            j = i + 1
            while j < n:
                if s[j] == "$" and (j == 0 or s[j - 1] != "\\"):
                    buf.append(s[i : j + 1])
                    i = j + 1
                    break
                j += 1
            else:
                buf.append(s[i:])
                break
            continue

        if s[i] == "\\" and i + 1 < n and s[i + 1] == "|":
            buf.append("\\|")
            i += 2
            continue

        if s[i] == "|":
            cells.append("".join(buf).strip())
            buf = []
            i += 1
            continue

        buf.append(s[i])
        i += 1

    cells.append("".join(buf).strip())
    return cells


def _parse_table_row(line: str) -> list[str]:
    return _split_table_cells(line)


def _is_table_sep(row: list[str]) -> bool:
    if not row:
        return False
    return all(re.match(r"^:?-{3,}:?$", c.strip()) for c in row if c.strip())


def _add_table(doc: Document, rows: list[list[str]], base_dir: Path | None = None):
    if not rows:
        return
    ncols = max(len(r) for r in rows)
    table = doc.add_table(rows=len(rows), cols=ncols)
    table.style = "Table Grid"
    for i, row in enumerate(rows):
        for j in range(ncols):
            cell_text = row[j] if j < len(row) else ""
            cell = table.rows[i].cells[j]
            cell.text = ""
            p = cell.paragraphs[0]
            if _line_has_embeddable_images(cell_text):
                _add_rich_content_to_paragraph(p, cell_text, base_dir)
            else:
                _add_inline_to_paragraph(p, cell_text)
            for run in p.runs:
                _set_run_font(run, "宋体", 10)


def _add_horizontal_rule(doc: Document):
    p = doc.add_paragraph()
    p.paragraph_format.space_after = Pt(8)
    p.paragraph_format.space_before = Pt(8)
    run = p.add_run("─" * 32)
    _set_run_font(run, "宋体", 8)
    run.font.color.rgb = RGBColor(0xAA, 0xAA, 0xAA)


def _resolve_image_path(src: str, base_dir: Path | None) -> Path | None:
    if not base_dir:
        return None
    path = (base_dir / src).resolve() if not Path(src).is_absolute() else Path(src)
    return path if path.is_file() else None


def _try_add_image(
    doc: Document,
    line: str,
    base_dir: Path | None,
    *,
    max_w_in: float = _DEFAULT_IMAGE_MAX_W_IN,
    max_h_in: float = _DEFAULT_IMAGE_MAX_H_IN,
) -> bool:
    m = _MD_IMAGE_RE.match(line.strip())
    if not m or not base_dir:
        return False
    alt, src = m.group(1), m.group(2).strip()
    _embed_from_image_ref(
        alt,
        src,
        base_dir,
        doc=doc,
        image_max_w_in=max_w_in,
        image_max_h_in=max_h_in,
    )
    return True


def _line_has_embeddable_images(line: str) -> bool:
    return bool(
        _MD_IMAGE_RE.search(line)
        or _HIDDEN_MD_IMAGE_COMMENT_RE.search(line)
        or _INLINE_MATH_WITH_HIDDEN_IMG_RE.search(line)
        or _INLINE_MATH_PAREN_WITH_HIDDEN_IMG_RE.search(line)
    )


def _add_paragraph_with_inline_images(
    doc: Document,
    line: str,
    base_dir: Path | None,
    *,
    max_w_in: float = _DEFAULT_IMAGE_MAX_W_IN,
    max_h_in: float = _DEFAULT_IMAGE_MAX_H_IN,
) -> None:
    """段落内混排文字与公式/插图(含 HTML 注释隐藏引用)。"""
    p = doc.add_paragraph()
    p.paragraph_format.space_after = Pt(6)
    p.paragraph_format.line_spacing = 1.15
    _add_rich_content_to_paragraph(
        p,
        line,
        base_dir,
        image_max_w_in=max_w_in,
        image_max_h_in=max_h_in,
    )
    for run in p.runs:
        if run.font.name in (None, ""):
            _set_run_font(run, "宋体", 10.5)


def convert_md_to_docx(
    md_text: str,
    base_dir: Path | None,
    *,
    image_max_w_in: float = _DEFAULT_IMAGE_MAX_W_IN,
    image_max_h_in: float = _DEFAULT_IMAGE_MAX_H_IN,
) -> Document:
    doc = Document()
    # 默认正文样式
    try:
        style = doc.styles["Normal"]
        style.font.name = "宋体"
        if style._element.rPr is not None:
            style._element.rPr.rFonts.set(qn("w:eastAsia"), "宋体")
        style.font.size = Pt(10.5)
    except (AttributeError, KeyError):
        pass

    lines = md_text.splitlines()
    i = 0
    para_buf: list[str] = []

    def flush_paragraph():
        nonlocal para_buf
        if not para_buf:
            return
        # 每行独立成段,避免「(1)…\n(2)…」被空格拼成一段(Word 内不换行)
        for p in para_buf:
            t = p.strip()
            if t:
                _add_body_paragraph(
                    doc,
                    t,
                    base_dir,
                    image_max_h_in=image_max_h_in,
                )
        para_buf = []

    while i < len(lines):
        raw = lines[i]
        line = raw.rstrip("\n")

        if line.strip() == "":
            flush_paragraph()
            i += 1
            continue

        # 围栏代码块
        if line.strip().startswith("```"):
            flush_paragraph()
            fence_lang = line.strip()[3:].strip()
            i += 1
            code_lines: list[str] = []
            while i < len(lines) and not lines[i].strip().startswith("```"):
                code_lines.append(lines[i])
                i += 1
            if i < len(lines):
                i += 1
            # 定稿 MD 保留 mermaid 源码 + 图示注释:Word 只嵌 PNG,不写源码块
            if fence_lang.lower() == "mermaid":
                j = i
                while j < len(lines) and lines[j].strip() == "":
                    j += 1
                if j < len(lines):
                    cm = _HIDDEN_MD_IMAGE_COMMENT_RE.match(lines[j].strip())
                    if cm and _is_diagram_image(cm.group(1), cm.group(2).strip()):
                        continue
            _add_code_block(doc, code_lines)
            continue

        # 块级公式:\[ ... \] + 可选 HTML 注释
        if line.strip() == "\\[":
            flush_paragraph()
            i += 1
            math_lines: list[str] = []
            while i < len(lines) and lines[i].strip() != "\\]":
                math_lines.append(lines[i])
                i += 1
            if i < len(lines):
                i += 1
            hidden: tuple[str, str] | None = None
            if i < len(lines):
                cm = _HIDDEN_MD_IMAGE_COMMENT_RE.match(lines[i].strip())
                if cm:
                    hidden = (cm.group(1), cm.group(2).strip())
                    i += 1
            if hidden and _formula_image_kind(*hidden):
                ipath = _resolve_image_path(hidden[1], base_dir)
                if ipath:
                    _embed_from_image_ref(
                        hidden[0],
                        hidden[1],
                        base_dir,
                        doc=doc,
                        image_max_w_in=image_max_w_in,
                        image_max_h_in=image_max_h_in,
                    )
                    continue
            _add_math_fallback_block(doc, ["\\[", *math_lines, "\\]"])
            continue

        # 块级公式:$$ ... $$ + 可选 HTML 注释(Word 嵌 PNG;预览见 LaTeX 原文)
        if line.strip() == "$$":
            flush_paragraph()
            i += 1
            math_lines: list[str] = []
            while i < len(lines) and lines[i].strip() != "$$":
                math_lines.append(lines[i])
                i += 1
            if i < len(lines):
                i += 1
            hidden: tuple[str, str] | None = None
            if i < len(lines):
                cm = _HIDDEN_MD_IMAGE_COMMENT_RE.match(lines[i].strip())
                if cm:
                    hidden = (cm.group(1), cm.group(2).strip())
                    i += 1
            if hidden and _formula_image_kind(*hidden):
                ipath = _resolve_image_path(hidden[1], base_dir)
                if ipath:
                    _embed_from_image_ref(
                        hidden[0],
                        hidden[1],
                        base_dir,
                        doc=doc,
                        image_max_w_in=image_max_w_in,
                        image_max_h_in=image_max_h_in,
                    )
                    continue
            _add_math_fallback_block(doc, math_lines)
            continue

        # 独立 HTML 注释行(公式图 / mermaid 框图引用)
        if _HIDDEN_MD_IMAGE_COMMENT_RE.fullmatch(line.strip()):
            flush_paragraph()
            _try_embed_hidden_comment_line(
                doc,
                line,
                base_dir,
                image_max_w_in=image_max_w_in,
                image_max_h_in=image_max_h_in,
            )
            i += 1
            continue

        # 图片行或含行内公式/注释的段落
        if _line_has_embeddable_images(line):
            flush_paragraph()
            stripped = line.strip()
            if _MD_IMAGE_RE.fullmatch(stripped) or (
                stripped.startswith("![") and stripped.count("![") == 1
            ):
                _try_add_image(
                    doc,
                    line,
                    base_dir,
                    max_w_in=image_max_w_in,
                    max_h_in=image_max_h_in,
                )
            else:
                _add_paragraph_with_inline_images(
                    doc,
                    line,
                    base_dir,
                    max_w_in=image_max_w_in,
                    max_h_in=image_max_h_in,
                )
            i += 1
            continue

        # 水平线
        if re.match(r"^[\s\-*_]{3,}\s*$", line) and set(line.strip()) <= {"-", "*", "_", " "}:
            flush_paragraph()
            _add_horizontal_rule(doc)
            i += 1
            continue

        # 标题
        m = re.match(r"^(#{1,6})\s+(.+)$", line)
        if m:
            flush_paragraph()
            level = len(m.group(1))
            title = m.group(2).strip()
            title = re.sub(r"\s+#+\s*$", "", title)
            _add_heading(doc, level, title)
            i += 1
            continue

        # 引用
        if line.lstrip().startswith("> "):
            flush_paragraph()
            quote = line.lstrip()[2:].strip()
            p = doc.add_paragraph()
            p.paragraph_format.left_indent = Inches(0.25)
            p.paragraph_format.space_after = Pt(4)
            _add_inline_to_paragraph(p, quote)
            for run in p.runs:
                _set_run_font(run, "宋体", 10.5)
            i += 1
            continue

        # 表格块
        if _is_table_row(line):
            flush_paragraph()
            table_rows: list[list[str]] = []
            while i < len(lines) and _is_table_row(lines[i]):
                row = _parse_table_row(lines[i])
                if not _is_table_sep(row):
                    table_rows.append(row)
                i += 1
            _add_table(doc, table_rows, base_dir)
            continue

        # 无序列表
        um = re.match(r"^(\s*)[-*+]\s+(.+)$", line)
        if um:
            flush_paragraph()
            _add_list_item(
                doc,
                um.group(2).strip(),
                ordered=False,
                base_dir=base_dir,
                image_max_h_in=image_max_h_in,
            )
            i += 1
            continue

        # 有序列表
        om = re.match(r"^(\s*)\d+\.\s+(.+)$", line)
        if om:
            flush_paragraph()
            _add_list_item(
                doc,
                om.group(2).strip(),
                ordered=True,
                base_dir=base_dir,
                image_max_h_in=image_max_h_in,
            )
            i += 1
            continue

        para_buf.append(line)
        i += 1

    flush_paragraph()
    return doc


def main(argv: list[str] | None = None) -> int:
    p = argparse.ArgumentParser(description="Markdown → Word(标题样式映射)")
    p.add_argument("-i", "--input", required=True, help="输入 .md 路径")
    p.add_argument("-o", "--output", required=True, help="输出 .docx 路径")
    p.add_argument(
        "--base-dir",
        default=None,
        help="解析 ![](/相对路径) 图片时的根目录(默认使用 .md 所在目录)",
    )
    p.add_argument(
        "--image-max-width-inches",
        type=float,
        default=_DEFAULT_IMAGE_MAX_W_IN,
        metavar="IN",
        help=f"插图最大宽度(英寸,默认 {_DEFAULT_IMAGE_MAX_W_IN}),与高度共同约束等比缩放",
    )
    p.add_argument(
        "--image-max-height-inches",
        type=float,
        default=_DEFAULT_IMAGE_MAX_H_IN,
        metavar="IN",
        help=f"插图最大高度(英寸,默认 {_DEFAULT_IMAGE_MAX_H_IN}),避免竖图仅按宽度缩放后超出单页可视区域",
    )
    p.add_argument(
        "--no-math-render",
        action="store_true",
        help="不自动调用 math_render(默认会先渲染 $ / $$ 公式为 PNG)",
    )
    args = p.parse_args(argv)

    in_path = Path(args.input).resolve()
    if not in_path.is_file():
        print(f"错误:找不到输入文件 {in_path}", file=sys.stderr)
        return 1

    base = Path(args.base_dir).resolve() if args.base_dir else in_path.parent
    try:
        md_text = in_path.read_text(encoding="utf-8")
    except UnicodeDecodeError:
        md_text = in_path.read_text(encoding="utf-8", errors="replace")
        print("警告:输入文件含非 UTF-8 字节,已使用替换字符解码后继续转换。", file=sys.stderr)

    if not args.no_math_render:
        md_text = _maybe_render_math_md(md_text, base)

    doc = convert_md_to_docx(
        md_text,
        base_dir=base,
        image_max_w_in=args.image_max_width_inches,
        image_max_h_in=args.image_max_height_inches,
    )
    out_path = Path(args.output).resolve()
    out_path.parent.mkdir(parents=True, exist_ok=True)
    doc.save(str(out_path))
    print(f"已写入: {out_path}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
scripts/disclosure/mermaid_render.py
#!/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. ``scripts/disclosure/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 scripts/disclosure/mermaid_render.py -i draft.md -o disclosure.md
  # 默认在同目录生成 disclosure.docx;失败时 stderr 会给出可复制的 md_to_docx 命令
  python scripts/disclosure/mermaid_render.py -i draft.md -o out/disclosure.md --docx out/custom.docx
  python scripts/disclosure/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:
    """``scripts/disclosure/npm install`` 后可用 ``node_modules/.bin/mmdc``,避免每次 npx 拉包。"""
    here = Path(__file__).resolve().parent
    if sys.platform == "win32":
        cand = here / "node_modules" / ".bin" / "mmdc.cmd"
    else:
        cand = here / "node_modules" / ".bin" / "mmdc"
    if cand.is_file():
        return [str(cand)], False
    return None


def _find_mmdc_invocation() -> tuple[list[str], bool]:
    """
    返回 (argv 前缀, use_shell)。
    Windows 上 npx 常为 .ps1,无独立 .exe,需 shell=True 调用 ``npx ...``。
    PATH 中的 ``mmdc`` 一般为 npm 全局安装的官方 CLI。
    """
    local = _local_mmdc()
    if local:
        return local
    mmdc = shutil.which("mmdc")
    if mmdc and Path(mmdc).suffix.lower() not in (".ps1",):
        return [mmdc], False
    if sys.platform == "win32":
        return ["npx", "-y", "@mermaid-js/mermaid-cli", "mmdc"], True
    return ["npx", "-y", "@mermaid-js/mermaid-cli", "mmdc"], False


def _mmdc_extra_args(
    *,
    scale: float,
    width: int,
    height: int,
) -> list[str]:
    """传给 mmdc 的分辨率相关参数(-s 为 Puppeteer deviceScaleFactor,显著影响 PNG 清晰度)。"""
    return [
        "-s",
        str(scale),
        "-w",
        str(width),
        "-H",
        str(height),
    ]


def _render_one_mermaid(
    mermaid_source: str,
    png_path: Path,
    mmdc_base: list[str],
    *,
    use_shell: bool,
    scale: float,
    width: int,
    height: int,
) -> None:
    png_path.parent.mkdir(parents=True, exist_ok=True)
    with tempfile.NamedTemporaryFile(
        mode="w",
        suffix=".mmd",
        delete=False,
        encoding="utf-8",
    ) as tmp:
        tmp.write(mermaid_source.strip() + "\n")
        tmp_path = Path(tmp.name)
    try:
        extra = _mmdc_extra_args(scale=scale, width=width, height=height)
        if use_shell:
            parts = [
                *mmdc_base,
                "-i",
                str(tmp_path),
                "-o",
                str(png_path),
                "-b",
                "white",
                *extra,
            ]
            cmd = " ".join(shlex.quote(p) for p in parts)
            r = subprocess.run(
                cmd,
                shell=True,
                capture_output=True,
                text=True,
                timeout=180,
            )
        else:
            cmd = [
                *mmdc_base,
                "-i",
                str(tmp_path),
                "-o",
                str(png_path),
                "-b",
                "white",
                *extra,
            ]
            r = subprocess.run(
                cmd,
                capture_output=True,
                text=True,
                timeout=180,
            )
        if r.returncode != 0:
            err = (r.stderr or r.stdout or "").strip()
            raise RuntimeError(f"mmdc 失败 (exit {r.returncode}): {err[:2000]}")
    finally:
        try:
            tmp_path.unlink(missing_ok=True)
        except OSError:
            pass


_MMD_START = re.compile(r"^```mermaid\s*$", re.IGNORECASE)
_MMD_END = re.compile(r"^```\s*$")
_MERMAID_HIDDEN_COMMENT_RE = re.compile(
    r"<!--\s*!\[([^\]]*)\]\(([^)]+)\)\s*-->"
)


def _is_mermaid_figure_comment(alt: str, src: str) -> bool:
    s = src.strip().replace("\\", "/")
    if "mermaid_figures" in s:
        return True
    a = alt.strip()
    return a.startswith("图示") or a.startswith("图 ")


def render_markdown_mermaid(
    md_text: str,
    *,
    out_md_path: Path,
    assets_rel: str,
    mmdc_scale: float = 2.0,
    mmdc_width: int = 1400,
    mmdc_height: int = 1050,
) -> tuple[str, int, int]:
    """
    返回 (新 markdown 全文, 成功转为 PNG 的块数, 生图失败而保留围栏的块数)。
    资源目录为 out_md_path.parent / assets_rel。
    失败的块原样写回 `` ```mermaid`` … `` ``` ``,不抛错。
    成功的块写回围栏源码 + 紧随其后的 ``<!-- ![图示](…) -->``(与 ``math_render`` 保留 LaTeX 原文同理)。
    若围栏后已有 mermaid 图示注释,则视为已处理,原样跳过(可重复跑脚本)。
    """
    lines = md_text.splitlines(keepends=True)
    out: list[str] = []
    i = 0
    ok = 0
    failed = 0
    block_idx = 0
    assets_dir = out_md_path.parent / assets_rel
    mmdc_base, use_shell = _find_mmdc_invocation()

    while i < len(lines):
        line = lines[i]
        if _MMD_START.match(line):
            fence_open = line
            i += 1
            body: list[str] = []
            while i < len(lines) and not _MMD_END.match(lines[i]):
                body.append(lines[i])
                i += 1
            closing = lines[i] if i < len(lines) else "```\n"
            if i < len(lines):
                i += 1

            # 已定稿:围栏 + 图示注释,不重复渲染
            j = i
            while j < len(lines) and lines[j].strip() == "":
                j += 1
            if j < len(lines):
                cm = _MERMAID_HIDDEN_COMMENT_RE.match(lines[j].strip())
                if cm and _is_mermaid_figure_comment(cm.group(1), cm.group(2)):
                    out.append(fence_open)
                    out.extend(body)
                    if not closing.endswith("\n"):
                        closing = closing + "\n"
                    out.append(closing)
                    while i < j:
                        out.append(lines[i])
                        i += 1
                    out.append(lines[i])
                    i += 1
                    ok += 1
                    continue

            block_idx += 1
            fname = f"fig_{ok + 1:03d}.png"
            png_path = assets_dir / fname
            try:
                _render_one_mermaid(
                    "".join(body),
                    png_path,
                    mmdc_base,
                    use_shell=use_shell,
                    scale=mmdc_scale,
                    width=mmdc_width,
                    height=mmdc_height,
                )
            except Exception as e:
                failed += 1
                print(
                    f"[mermaid_render] 第 {block_idx} 个 mermaid 围栏生图失败(已保留源码):{e}",
                    file=sys.stderr,
                )
                out.append(fence_open)
                out.extend(body)
                if not closing.endswith("\n"):
                    closing = closing + "\n"
                out.append(closing)
                continue
            ok += 1
            rel = f"{assets_rel.strip('/')}/{fname}".replace("\\", "/")
            out.append(fence_open)
            out.extend(body)
            if not closing.endswith("\n"):
                closing = closing + "\n"
            out.append(closing)
            out.append(f"<!-- ![图示 {ok}]({rel}) -->\n")
            continue
        out.append(line)
        i += 1

    return "".join(out), ok, failed


def _print_manual_docx_hint(out_md: Path, docx_out: Path, base_dir: Path, md_script: Path) -> None:
    print(
        "提示:可手动将上述 Markdown 转为 Word(需已 pip install -r requirements.txt):",
        file=sys.stderr,
    )
    if md_script.is_file():
        parts = [
            sys.executable,
            str(md_script),
            "-i",
            str(out_md),
            "-o",
            str(docx_out),
            "--base-dir",
            str(base_dir),
        ]
        print("  " + " ".join(shlex.quote(p) for p in parts), file=sys.stderr)
    else:
        print(
            "  python scripts/disclosure/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())
scripts/disclosure/package-lock.json
{
  "name": "nature-paper-to-patent-disclosure-tools",
  "lockfileVersion": 3,
  "requires": true,
  "packages": {
    "": {
      "name": "nature-paper-to-patent-disclosure-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"
      }
    }
  }
}
scripts/disclosure/package.json
{
  "name": "nature-paper-to-patent-disclosure-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"
  }
}
scripts/disclosure/pptx_to_md.py
#!/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"<!-- 由 pptx_to_md.py 自 {input_pptx.name} 转换,勿手改本行元信息 -->\n"
    ]
    img_counter = [0]

    for sn, slide in enumerate(prs.slides, start=1):
        lines.append(f"\n## 第 {sn} 页\n")

        for shape in _walk_shapes(slide.shapes, MSO_SHAPE_TYPE):
            if shape.shape_type == MSO_SHAPE_TYPE.PICTURE:
                try:
                    img = shape.image
                    ext = (img.ext or "png").lower()
                    if ext == "jpeg":
                        ext = "jpg"
                    img_counter[0] += 1
                    fname = f"slide{sn:02d}_img{img_counter[0]:04d}.{ext}"
                    out_img = media_dir / fname
                    out_img.write_bytes(img.blob)
                    rel = _rel_media_path(output_md, out_img)
                    lines.append(f"\n![]({rel})\n")
                except Exception as e:
                    print(f"警告: 第 {sn} 页抽取图片失败: {e}", file=sys.stderr)
                continue

            block = _shape_text(shape)
            if block:
                lines.append(block)
                lines.append("\n\n")

        try:
            nf = slide.notes_slide.notes_text_frame
            note_txt = (nf.text or "").strip() if nf is not None else ""
            if note_txt:
                lines.append("\n**备注**:\n\n")
                lines.append(note_txt)
                lines.append("\n\n")
        except (AttributeError, ValueError):
            pass

    body = "".join(lines).rstrip() + "\n"
    output_md.write_text(body, encoding="utf-8")

    print(f"已写入: {output_md}")
    print(f"图片目录: {media_dir}")
    return 0


def main() -> int:
    p = argparse.ArgumentParser(description="PowerPoint (.pptx) → Markdown + 抽取图片")
    p.add_argument("-i", "--input", required=True, type=Path, help="输入 .pptx / .ppsx 路径")
    p.add_argument("-o", "--output", required=True, type=Path, help="输出 .md 路径")
    p.add_argument(
        "--media-dir",
        type=Path,
        default=None,
        help="图片输出目录(默认:与 .md 同级的 {md 主名}_media)",
    )
    args = p.parse_args()
    return _run(args.input, args.output, args.media_dir)


if __name__ == "__main__":
    raise SystemExit(main())
scripts/disclosure/requirements-cnipa.txt
# 国知局公布公告站检索(scripts/disclosure/cnipa_epub_search.py / cnipa_epub_crawler.py)依赖;与仓库根目录 requirements.txt 独立
playwright>=1.40.0
scripts/extract_pdf_text.py
#!/usr/bin/env python3
"""Extract searchable text from one PDF or a directory of PDFs."""

import argparse
from pathlib import Path

from pypdf import PdfReader


def extract(source: Path, destination: Path) -> tuple[int, int]:
    reader = PdfReader(source)
    pages = []
    for number, page in enumerate(reader.pages, start=1):
        text = page.extract_text() or ""
        pages.append(f"\n\n===== PAGE {number} =====\n\n{text}")

    content = "".join(pages)
    destination.parent.mkdir(parents=True, exist_ok=True)
    destination.write_text(content, encoding="utf-8")
    return len(reader.pages), len(content.strip())


def collect_pdfs(source: Path) -> list[Path]:
    if source.is_file():
        if source.suffix.lower() != ".pdf":
            raise ValueError(f"Input is not a PDF: {source}")
        return [source]
    if source.is_dir():
        return sorted(path for path in source.rglob("*.pdf") if path.is_file())
    raise FileNotFoundError(f"Input does not exist: {source}")


def output_path(pdf: Path, source: Path, output: Path) -> Path:
    if source.is_file():
        if output.suffix.lower() == ".txt":
            return output
        return output / f"{pdf.stem}.txt"
    relative = pdf.relative_to(source).with_suffix(".txt")
    return output / relative


def main() -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("source", type=Path, help="PDF file or directory")
    parser.add_argument(
        "--output",
        type=Path,
        required=True,
        help="Output text file or directory",
    )
    args = parser.parse_args()

    try:
        pdfs = collect_pdfs(args.source)
    except (FileNotFoundError, ValueError) as error:
        parser.error(str(error))

    if not pdfs:
        parser.error(f"No PDF files found under: {args.source}")

    low_text = []
    for pdf in pdfs:
        destination = output_path(pdf, args.source, args.output)
        pages, characters = extract(pdf, destination)
        print(f"{pdf}\t{pages} pages\t{characters} characters\t{destination}")
        if characters < max(200, pages * 50):
            low_text.append(pdf)

    if low_text:
        print("\nOCR may be required for:")
        for pdf in low_text:
            print(f"- {pdf}")
        return 2
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
scripts/init_patent_project.py
#!/usr/bin/env python3
"""Create an agent-neutral paper-to-patent project workspace."""

import argparse
import json
import shutil
from pathlib import Path


DIRECTORIES = (
    "paper",
    "supplementary/source-code",
    "source-figures",
    "existing-patent",
    "work",
    "outputs",
)
SKILL_FILES = ("SKILL.md", "manifest.yaml", "requirements.txt")
SKILL_DIRECTORIES = ("static", "references", "scripts")


def main() -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("project_dir", type=Path)
    parser.add_argument("--paper", type=Path, help="Optional paper to copy into paper/")
    parser.add_argument("--force", action="store_true", help="Allow an existing project directory")
    parser.add_argument(
        "--no-embed-skill",
        action="store_true",
        help="Create only case directories without copying the skill bundle",
    )
    args = parser.parse_args()

    project = args.project_dir.resolve()
    if project.exists() and any(project.iterdir()) and not args.force:
        parser.error("project directory is not empty; use --force to add missing structure")
    project.mkdir(parents=True, exist_ok=True)
    for directory in DIRECTORIES:
        (project / directory).mkdir(parents=True, exist_ok=True)

    if not args.no_embed_skill:
        skill_root = Path(__file__).resolve().parents[1]
        if project != skill_root:
            for filename in SKILL_FILES:
                shutil.copy2(skill_root / filename, project / filename)
            for directory in SKILL_DIRECTORIES:
                shutil.copytree(
                    skill_root / directory,
                    project / directory,
                    dirs_exist_ok=True,
                    ignore=shutil.ignore_patterns("__pycache__", "*.pyc"),
                )

    if args.paper:
        paper = args.paper.resolve()
        if not paper.is_file():
            parser.error(f"paper does not exist: {paper}")
        shutil.copy2(paper, project / "paper" / paper.name)

    intake = {
        "target_jurisdiction": "中国发明专利",
        "requested_deliverable": "full-draft",
        "publication_status": "[TO CONFIRM: 论文是否已经公开]",
        "publication_dates": [],
        "inventorship": "[TO CONFIRM: 按实际技术贡献确认发明人]",
        "ownership": "[TO CONFIRM: 确认申请人和权属]",
        "source_files": [],
    }
    intake_path = project / "work" / "00-intake.json"
    if not intake_path.exists():
        intake_path.write_text(
            json.dumps(intake, ensure_ascii=False, indent=2) + "\n",
            encoding="utf-8",
        )

    notes = project / "supplementary" / "inventor-notes.md"
    if not notes.exists():
        notes.write_text(
            "# 发明人补充说明\n\n"
            "- 实际技术贡献:\n"
            "- 与论文不同的工程实现:\n"
            "- 可替代方案和参数范围:\n"
            "- 首次公开时间与方式:\n"
            "- 希望重点保护的内容:\n",
            encoding="utf-8",
        )

    print(project)
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
scripts/math_to_omml.py
#!/usr/bin/env python3
"""Convert a LaTeX equation into editable Word Office Math (OMML)."""

from copy import deepcopy
from xml.etree import ElementTree

from docx.oxml import OxmlElement
from docx.oxml.ns import qn


MATHML_NS = "{http://www.w3.org/1998/Math/MathML}"


def _element(name: str):
    return OxmlElement(f"m:{name}")


def _text_run(text: str):
    run = _element("r")
    properties = _element("rPr")
    style = _element("sty")
    style.set(qn("m:val"), "p")
    properties.append(style)
    run.append(properties)
    value = _element("t")
    value.text = text
    run.append(value)
    return run


def _append_children(target, source) -> None:
    if source.text and source.text.strip():
        target.append(_text_run(source.text.strip()))
    for child in source:
        _append_mathml(target, child)
        if child.tail and child.tail.strip():
            target.append(_text_run(child.tail.strip()))


def _script(target, node, kind: str) -> None:
    result = _element(kind)
    expression = _element("e")
    sub = _element("sub")
    sup = _element("sup")
    children = list(node)
    if children:
        _append_mathml(expression, children[0])
    if len(children) > 1:
        _append_mathml(sub, children[1])
    if len(children) > 2:
        _append_mathml(sup, children[2])
    result.append(expression)
    if kind in {"sSub", "sSubSup"}:
        result.append(sub)
    if kind in {"sSup", "sSubSup"}:
        result.append(sup if kind == "sSubSup" else sub)
    target.append(result)


def _append_mathml(target, node) -> None:
    tag = node.tag.removeprefix(MATHML_NS)
    children = list(node)

    if tag in {"math", "mrow", "mstyle", "semantics", "annotation"}:
        _append_children(target, node)
    elif tag in {"mi", "mn", "mo", "mtext"}:
        target.append(_text_run("".join(node.itertext())))
    elif tag == "mfrac":
        fraction = _element("f")
        numerator = _element("num")
        denominator = _element("den")
        if children:
            _append_mathml(numerator, children[0])
        if len(children) > 1:
            _append_mathml(denominator, children[1])
        fraction.extend((numerator, denominator))
        target.append(fraction)
    elif tag == "msub":
        _script(target, node, "sSub")
    elif tag == "msup":
        _script(target, node, "sSup")
    elif tag in {"msubsup", "munderover"}:
        _script(target, node, "sSubSup")
    elif tag == "munder":
        _script(target, node, "sSub")
    elif tag == "mover":
        _script(target, node, "sSup")
    elif tag == "msqrt":
        radical = _element("rad")
        properties = _element("radPr")
        hide_degree = _element("degHide")
        hide_degree.set(qn("m:val"), "1")
        properties.append(hide_degree)
        degree = _element("deg")
        expression = _element("e")
        _append_children(expression, node)
        radical.extend((properties, degree, expression))
        target.append(radical)
    elif tag == "mroot":
        radical = _element("rad")
        degree = _element("deg")
        expression = _element("e")
        if children:
            _append_mathml(expression, children[0])
        if len(children) > 1:
            _append_mathml(degree, children[1])
        radical.extend((degree, expression))
        target.append(radical)
    elif tag == "mfenced":
        delimiter = _element("d")
        properties = _element("dPr")
        begin = _element("begChr")
        begin.set(qn("m:val"), node.attrib.get("open", "("))
        end = _element("endChr")
        end.set(qn("m:val"), node.attrib.get("close", ")"))
        properties.extend((begin, end))
        expression = _element("e")
        _append_children(expression, node)
        delimiter.extend((properties, expression))
        target.append(delimiter)
    elif tag == "mtable":
        matrix = _element("m")
        for row_node in children:
            row = _element("mr")
            for cell_node in list(row_node):
                cell = _element("e")
                _append_children(cell, cell_node)
                row.append(cell)
            matrix.append(row)
        target.append(matrix)
    elif tag in {"mtr", "mtd"}:
        _append_children(target, node)
    elif tag == "mspace":
        target.append(_text_run(" "))
    else:
        _append_children(target, node)


def latex_to_omml(latex: str):
    try:
        from latex2mathml.converter import convert
    except ImportError as error:
        raise RuntimeError(
            "Native equations require latex2mathml: py -3 -m pip install latex2mathml"
        ) from error

    mathml = ElementTree.fromstring(convert(latex))
    paragraph = _element("oMathPara")
    math = _element("oMath")
    _append_mathml(math, mathml)
    paragraph.append(math)
    return paragraph


def clone_omml(element):
    return deepcopy(element)
scripts/render_flowchart_svg.py
#!/usr/bin/env python3
"""Render patent-style black-and-white flowchart SVGs from draft JSON."""

import argparse
import html
import json
import re
import textwrap
from pathlib import Path


STEP_PATTERN = re.compile(r"\bS\s*(\d+)\b", re.IGNORECASE)
ASCII_ID = re.compile(r"^[A-Za-z][A-Za-z0-9_-]*$")
VAGUE_FINAL_RESULT = re.compile(r"(技术结果|处理结果|最终结果)")


def normalize_step(value: str) -> str:
    match = STEP_PATTERN.fullmatch(str(value).strip())
    return f"S{match.group(1)}" if match else str(value).strip()


def claim_step_map(claims: list[dict]) -> dict[int, set[str]]:
    result = {}
    for claim in claims:
        number = claim.get("number")
        result[number] = {f"S{value}" for value in STEP_PATTERN.findall(claim.get("text", ""))}
    return result


def validate_figure(
    figure: dict,
    steps_by_claim: dict[int, set[str]],
    descriptions: list[str],
) -> list[str]:
    errors = []
    figure_type = figure.get("type")
    if figure_type not in {"flowchart", "methodology"}:
        errors.append("type must be 'flowchart' or 'methodology'")
    if figure.get("orientation", "vertical") not in {"vertical", "horizontal"}:
        errors.append("orientation must be 'vertical' or 'horizontal'")
    claim_number = figure.get("claim_number", 1)
    available_steps = set()
    if figure_type == "flowchart":
        if claim_number not in steps_by_claim:
            errors.append(f"claim_number {claim_number!r} does not exist")
        available_steps = steps_by_claim.get(claim_number, set())

    nodes = figure.get("nodes", [])
    if not nodes:
        errors.append("at least one node is required")
    ids = [str(node.get("id", "")) for node in nodes]
    if len(ids) != len(set(ids)):
        errors.append("node ids must be unique")
    for node in nodes:
        node_id = str(node.get("id", ""))
        if not ASCII_ID.fullmatch(node_id):
            errors.append(f"invalid node id: {node_id!r}")
        if not str(node.get("label", "")).strip():
            errors.append(f"node {node_id!r} has an empty label")
        step = normalize_step(node.get("claim_step", ""))
        if figure_type == "flowchart" and step and step not in available_steps:
            errors.append(
                f"node {node_id!r} references step {step!r}, not found in claim {claim_number}"
            )

    node_steps = {
        normalize_step(node.get("claim_step", ""))
        for node in nodes
        if str(node.get("claim_step", "")).strip()
    }
    if figure_type == "flowchart" and figure.get("complete_claim_flow") and node_steps != available_steps:
        missing = sorted(available_steps - node_steps)
        extra = sorted(node_steps - available_steps)
        if missing:
            errors.append(f"complete claim flow is missing steps: {missing}")
        if extra:
            errors.append(f"complete claim flow contains extra steps: {extra}")

    figure_token = f"图{figure.get('number')}"
    if not any(figure_token in str(description) for description in descriptions):
        errors.append(f"figure description does not reference {figure_token}")

    id_set = set(ids)
    incoming = {node_id: 0 for node_id in ids}
    outgoing = {node_id: 0 for node_id in ids}
    for edge in figure.get("edges", []):
        source = str(edge.get("from", ""))
        target = str(edge.get("to", ""))
        if source not in id_set:
            errors.append(f"edge source {source!r} does not exist")
        if target not in id_set:
            errors.append(f"edge target {target!r} does not exist")
        if source == target and source:
            errors.append(f"self-loop is not allowed for node {source!r}")
        if source in outgoing:
            outgoing[source] += 1
        if target in incoming:
            incoming[target] += 1

    if len(nodes) > 1 and not figure.get("edges"):
        errors.append("multiple nodes require edges")
    if len(nodes) > 1:
        if not any(value == 0 for value in incoming.values()):
            errors.append("flowchart has no start node")
        if not any(value == 0 for value in outgoing.values()):
            errors.append("flowchart has no end node")
        starts = [node_id for node_id, count in incoming.items() if count == 0]
        adjacency = {node_id: set() for node_id in ids}
        for edge in figure.get("edges", []):
            source = str(edge.get("from", ""))
            target = str(edge.get("to", ""))
            if source in adjacency and target in id_set:
                adjacency[source].add(target)
        reachable = set(starts)
        pending = list(starts)
        while pending:
            current = pending.pop()
            for target in adjacency[current]:
                if target not in reachable:
                    reachable.add(target)
                    pending.append(target)
        disconnected = sorted(id_set - reachable)
        if disconnected:
            errors.append(f"unreachable nodes from any start node: {disconnected}")
    for node in nodes:
        node_id = str(node.get("id", ""))
        if outgoing.get(node_id) == 0 and VAGUE_FINAL_RESULT.search(str(node.get("label", ""))):
            errors.append(
                f"end node {node_id!r} uses a vague result name; state the specific detection, "
                "estimation, classification, positioning, or control result"
            )
    return errors


def wrap_label(label: str, width: int = 18) -> list[str]:
    chunks = []
    for paragraph in str(label).splitlines() or [""]:
        chunks.extend(textwrap.wrap(paragraph, width=width) or [""])
    return chunks


def layout(figure: dict) -> tuple[dict[str, tuple[int, int, int, int]], int, int]:
    orientation = figure.get("orientation", "vertical")
    nodes = figure["nodes"]
    box_width = 360
    gap = 90
    margin = 70
    positions = {}
    max_height = 0
    heights = []
    for node in nodes:
        line_count = len(wrap_label(node["label"]))
        height = max(72, 34 + line_count * 24)
        heights.append(height)
        max_height = max(max_height, height)

    if orientation == "vertical":
        y = margin + 45
        for node, height in zip(nodes, heights):
            positions[node["id"]] = (margin, y, box_width, height)
            y += height + gap
        width = box_width + margin * 2
        height = y - gap + margin
    else:
        x = margin
        for node, height in zip(nodes, heights):
            positions[node["id"]] = (x, margin + 45, box_width, height)
            x += box_width + gap
        width = x - gap + margin
        height = max_height + margin * 2 + 45
    return positions, width, height


def anchor(box: tuple[int, int, int, int], side: str) -> tuple[float, float]:
    x, y, width, height = box
    points = {
        "top": (x + width / 2, y),
        "bottom": (x + width / 2, y + height),
        "left": (x, y + height / 2),
        "right": (x + width, y + height / 2),
    }
    return points[side]


def render(figure: dict) -> str:
    positions, width, height = layout(figure)
    orientation = figure.get("orientation", "vertical")
    title = f"图{figure['number']} {figure.get('title', '方法流程图')}"
    parts = [
        '<?xml version="1.0" encoding="UTF-8"?>',
        f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" '
        f'viewBox="0 0 {width} {height}" role="img" aria-label="{html.escape(title)}">',
        "<defs>",
        '<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" '
        'markerHeight="8" orient="auto-start-reverse">',
        '<path d="M 0 0 L 10 5 L 0 10 z" fill="#000"/>',
        "</marker>",
        "</defs>",
        '<rect width="100%" height="100%" fill="#fff"/>',
        f'<text x="{width / 2}" y="28" text-anchor="middle" '
        'font-family="SimSun, Songti SC, serif" font-size="20">'
        f"{html.escape(title)}</text>",
    ]

    for edge in figure.get("edges", []):
        source_box = positions[edge["from"]]
        target_box = positions[edge["to"]]
        source_side, target_side = (
            ("bottom", "top") if orientation == "vertical" else ("right", "left")
        )
        x1, y1 = anchor(source_box, source_side)
        x2, y2 = anchor(target_box, target_side)
        parts.append(
            f'<line x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}" '
            'stroke="#000" stroke-width="2" marker-end="url(#arrow)"/>'
        )
        label = str(edge.get("label", "")).strip()
        if label:
            parts.append(
                f'<text x="{(x1 + x2) / 2 + 8}" y="{(y1 + y2) / 2 - 8}" '
                'font-family="SimSun, Songti SC, serif" font-size="16">'
                f"{html.escape(label)}</text>"
            )

    for node in figure["nodes"]:
        x, y, box_width, box_height = positions[node["id"]]
        parts.append(
            f'<rect x="{x}" y="{y}" width="{box_width}" height="{box_height}" '
            'rx="0" fill="#fff" stroke="#000" stroke-width="2"/>'
        )
        lines = wrap_label(node["label"])
        start_y = y + box_height / 2 - (len(lines) - 1) * 12
        for index, line in enumerate(lines):
            parts.append(
                f'<text x="{x + box_width / 2}" y="{start_y + index * 24}" '
                'text-anchor="middle" dominant-baseline="middle" '
                'font-family="SimSun, Songti SC, serif" font-size="18">'
                f"{html.escape(line)}</text>"
            )

    parts.append("</svg>")
    return "\n".join(parts)


def load_font(size: int):
    from PIL import ImageFont

    candidates = (
        Path(r"C:\Windows\Fonts\simsun.ttc"),
        Path(r"C:\Windows\Fonts\msyh.ttc"),
        Path(r"C:\Windows\Fonts\simhei.ttf"),
    )
    for candidate in candidates:
        if candidate.exists():
            return ImageFont.truetype(str(candidate), size=size)
    return ImageFont.load_default()


def render_png(figure: dict, output: Path) -> None:
    from PIL import Image, ImageDraw

    positions, width, height = layout(figure)
    scale = 2
    image = Image.new("RGB", (width * scale, height * scale), "white")
    draw = ImageDraw.Draw(image)
    title_font = load_font(20 * scale)
    body_font = load_font(18 * scale)
    edge_font = load_font(16 * scale)

    def point(value: float) -> int:
        return int(round(value * scale))

    title = f"图{figure['number']} {figure.get('title', '方法流程图')}"
    title_box = draw.textbbox((0, 0), title, font=title_font)
    title_x = (width * scale - (title_box[2] - title_box[0])) / 2
    draw.text((title_x, point(8)), title, fill="black", font=title_font)

    orientation = figure.get("orientation", "vertical")
    for edge in figure.get("edges", []):
        source_box = positions[edge["from"]]
        target_box = positions[edge["to"]]
        source_side, target_side = (
            ("bottom", "top") if orientation == "vertical" else ("right", "left")
        )
        x1, y1 = anchor(source_box, source_side)
        x2, y2 = anchor(target_box, target_side)
        draw.line((point(x1), point(y1), point(x2), point(y2)), fill="black", width=4)
        if orientation == "vertical":
            arrow = [
                (point(x2), point(y2)),
                (point(x2 - 7), point(y2 - 12)),
                (point(x2 + 7), point(y2 - 12)),
            ]
        else:
            arrow = [
                (point(x2), point(y2)),
                (point(x2 - 12), point(y2 - 7)),
                (point(x2 - 12), point(y2 + 7)),
            ]
        draw.polygon(arrow, fill="black")
        label = str(edge.get("label", "")).strip()
        if label:
            draw.text(
                (point((x1 + x2) / 2 + 8), point((y1 + y2) / 2 - 20)),
                label,
                fill="black",
                font=edge_font,
            )

    for node in figure["nodes"]:
        x, y, box_width, box_height = positions[node["id"]]
        draw.rectangle(
            (point(x), point(y), point(x + box_width), point(y + box_height)),
            fill="white",
            outline="black",
            width=4,
        )
        lines = wrap_label(node["label"])
        line_height = 24 * scale
        total_height = line_height * len(lines)
        text_y = point(y + box_height / 2) - total_height / 2
        for line in lines:
            text_box = draw.textbbox((0, 0), line, font=body_font)
            text_width = text_box[2] - text_box[0]
            text_x = point(x + box_width / 2) - text_width / 2
            draw.text((text_x, text_y), line, fill="black", font=body_font)
            text_y += line_height

    output.parent.mkdir(parents=True, exist_ok=True)
    image.save(output, format="PNG", dpi=(300, 300))


def main() -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("draft", type=Path, help="UTF-8 patent draft JSON")
    parser.add_argument("--output-dir", type=Path, required=True, help="SVG output directory")
    parser.add_argument("--png", action="store_true", help="Also render PNG files")
    args = parser.parse_args()

    data = json.loads(args.draft.read_text(encoding="utf-8"))
    figures = data.get("figures", [])
    if not figures:
        parser.error("draft contains no figures")
    numbers = [figure.get("number") for figure in figures]
    if numbers != list(range(1, len(figures) + 1)):
        parser.error(f"figure numbers must be consecutive starting at 1: {numbers}")

    steps = claim_step_map(data.get("claims", []))
    descriptions = data.get("specification", {}).get("figure_descriptions", [])
    all_errors = []
    for figure in figures:
        errors = validate_figure(figure, steps, descriptions)
        all_errors.extend(f"Figure {figure.get('number')}: {error}" for error in errors)
    if all_errors:
        for error in all_errors:
            print(f"ERROR\t{error}")
        return 1

    args.output_dir.mkdir(parents=True, exist_ok=True)
    for figure in figures:
        output = args.output_dir / f"figure-{figure['number']}.svg"
        output.write_text(render(figure), encoding="utf-8")
        print(output)
        if args.png:
            try:
                import PIL
            except ImportError as error:
                raise SystemExit(
                    "PNG output requires Pillow: python -m pip install pillow"
                ) from error
            png_output = args.output_dir / f"figure-{figure['number']}.png"
            render_png(figure, png_output)
            print(png_output)
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
scripts/render_patent_docx.py
#!/usr/bin/env python3
"""Render a structured Chinese patent draft JSON file as DOCX."""

import argparse
import json
from pathlib import Path

from docx import Document
from docx.enum.section import WD_SECTION
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.oxml import OxmlElement
from docx.oxml.ns import qn
from docx.shared import Cm, Pt

from math_to_omml import latex_to_omml


def set_run_font(run, name: str, size: float, bold: bool = False) -> None:
    run.font.name = name
    run.font.size = Pt(size)
    run.bold = bold
    run._element.rPr.rFonts.set(qn("w:eastAsia"), name)


def add_heading(document: Document, text: str, level: int = 1) -> None:
    paragraph = document.add_paragraph()
    paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER if level == 1 else WD_ALIGN_PARAGRAPH.LEFT
    run = paragraph.add_run(text)
    set_run_font(run, "宋体", 16 if level == 1 else 14, bold=True)


def add_body(document: Document, text: str, indent: bool = True) -> None:
    paragraph = document.add_paragraph()
    paragraph.paragraph_format.line_spacing = 1.5
    if indent:
        paragraph.paragraph_format.first_line_indent = Cm(0.74)
    run = paragraph.add_run(str(text))
    set_run_font(run, "宋体", 12)


def add_equation(document: Document, equation: dict) -> None:
    paragraph = document.add_paragraph()
    paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
    paragraph.paragraph_format.space_before = Pt(6)
    paragraph.paragraph_format.space_after = Pt(6)
    latex = equation.get("latex")
    if not latex:
        raise ValueError(
            f"Equation {equation.get('number')} has no latex source for native Office Math"
        )
    paragraph._p.append(latex_to_omml(latex))
    number_run = paragraph.add_run(f"    ({equation.get('number', '')})")
    set_run_font(number_run, "Cambria Math", 12)
    description = equation.get("description")
    if description:
        add_body(document, description)


def add_page_number(section) -> None:
    paragraph = section.footer.paragraphs[0]
    paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
    run = paragraph.add_run()
    begin = OxmlElement("w:fldChar")
    begin.set(qn("w:fldCharType"), "begin")
    instruction = OxmlElement("w:instrText")
    instruction.set(qn("xml:space"), "preserve")
    instruction.text = " PAGE "
    end = OxmlElement("w:fldChar")
    end.set(qn("w:fldCharType"), "end")
    run._r.extend((begin, instruction, end))


def configure(document: Document) -> None:
    section = document.sections[0]
    section.top_margin = Cm(2.54)
    section.bottom_margin = Cm(2.54)
    section.left_margin = Cm(3.17)
    section.right_margin = Cm(3.17)
    add_page_number(section)


def add_claims(document: Document, claims: list[dict]) -> None:
    add_heading(document, "权利要求书")
    for claim in claims:
        add_body(document, f"{claim['number']}. {claim['text']}", indent=False)


def add_specification(document: Document, data: dict, figure_dir: Path | None = None) -> None:
    add_heading(document, "说明书")
    add_heading(document, data.get("title", "[TO CONFIRM: title]"), level=2)
    spec = data.get("specification", {})
    sections = [
        ("技术领域", spec.get("technical_field", [])),
        ("背景技术", spec.get("background", [])),
    ]
    invention = spec.get("invention_content", {})
    sections.extend(
        [
            ("发明内容", invention.get("problem", []) + invention.get("solution", [])),
            ("有益效果", invention.get("beneficial_effects", [])),
            ("附图说明", spec.get("figure_descriptions", [])),
        ]
    )
    for heading, paragraphs in sections:
        add_heading(document, heading, level=2)
        for paragraph in paragraphs:
            add_body(document, paragraph)

    equations = spec.get("equations", [])
    if equations:
        add_heading(document, "公式及符号说明", level=2)
        for equation in equations:
            add_equation(document, equation)

    if figure_dir:
        figures = data.get("figures", [])
        if figures:
            add_heading(document, "说明书附图", level=2)
        for figure in figures:
            image = figure_dir / f"figure-{figure['number']}.png"
            if not image.exists():
                continue
            paragraph = document.add_paragraph()
            paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
            run = paragraph.add_run()
            run.add_picture(str(image), width=Cm(14))
            caption = document.add_paragraph()
            caption.alignment = WD_ALIGN_PARAGRAPH.CENTER
            caption_run = caption.add_run(f"图{figure['number']} {figure.get('title', '')}")
            set_run_font(caption_run, "宋体", 11)

    add_heading(document, "具体实施方式", level=2)
    for embodiment in spec.get("embodiments", []):
        add_heading(document, embodiment.get("heading", "实施例"), level=2)
        for paragraph in embodiment.get("paragraphs", []):
            add_body(document, paragraph)


def add_figure(
    document: Document,
    figure: dict,
    figure_dir: Path | None,
    heading: str | None = None,
) -> bool:
    if not figure_dir:
        return False
    image = figure_dir / f"figure-{figure['number']}.png"
    if not image.exists():
        return False
    if heading:
        add_heading(document, heading, level=2)
    paragraph = document.add_paragraph()
    paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
    run = paragraph.add_run()
    run.add_picture(str(image), width=Cm(14))
    caption = document.add_paragraph()
    caption.alignment = WD_ALIGN_PARAGRAPH.CENTER
    caption_run = caption.add_run(f"图{figure['number']} {figure.get('title', '')}")
    set_run_font(caption_run, "宋体", 11)
    return True


def abstract_figure(data: dict) -> dict | None:
    number = data.get("abstract_figure_number")
    return next(
        (figure for figure in data.get("figures", []) if figure.get("number") == number),
        None,
    )


def add_abstract(
    document: Document,
    data: dict,
    figure_dir: Path | None = None,
    include_figure: bool = True,
) -> None:
    add_heading(document, "说明书摘要")
    add_heading(document, data.get("title", "[TO CONFIRM: title]"), level=2)
    add_body(document, data.get("abstract", ""), indent=False)
    figure = abstract_figure(data)
    if include_figure and figure:
        add_figure(document, figure, figure_dir, heading="摘要附图")


def add_abstract_figure(
    document: Document,
    data: dict,
    figure_dir: Path | None,
) -> None:
    add_heading(document, "摘要附图")
    figure = abstract_figure(data)
    if figure:
        add_figure(document, figure, figure_dir)


def add_review_appendix(document: Document, data: dict) -> None:
    document.add_section(WD_SECTION.NEW_PAGE)
    add_heading(document, "起草审查附录")
    metadata = data.get("metadata", {})
    for key, value in metadata.items():
        add_body(document, f"{key}: {value}", indent=False)

    add_heading(document, "前提假设", level=2)
    for item in data.get("assumptions", []):
        add_body(document, f"- {item}", indent=False)

    concept = data.get("invention_concept", {})
    add_heading(document, "发明构思", level=2)
    for key in ("technical_problem", "technical_means", "technical_effect"):
        if concept.get(key):
            add_body(document, f"{key}: {concept[key]}", indent=False)

    ledger = data.get("evidence_ledger", [])
    if ledger:
        add_heading(document, "证据台账", level=2)
        table = document.add_table(rows=1, cols=6)
        table.style = "Table Grid"
        headers = ("ID", "技术特征", "来源位置", "技术作用", "效果", "支持状态")
        for cell, header in zip(table.rows[0].cells, headers):
            cell.text = header
        keys = ("id", "feature", "source_location", "technical_role", "effect", "support_status")
        for item in ledger:
            cells = table.add_row().cells
            for cell, key in zip(cells, keys):
                cell.text = str(item.get(key, ""))

    audit = data.get("audit", {})
    for heading, key in (
        ("支持性检查", "support_findings"),
        ("一致性检查", "consistency_findings"),
        ("发明人待确认问题", None),
    ):
        add_heading(document, heading, level=2)
        items = data.get("inventor_questions", []) if key is None else audit.get(key, [])
        for item in items:
            add_body(document, f"- {item}", indent=False)


def validate(data: dict) -> None:
    required = ("title", "claims", "specification", "abstract")
    missing = [key for key in required if key not in data]
    if missing:
        raise ValueError(f"Missing required keys: {', '.join(missing)}")
    numbers = [claim.get("number") for claim in data["claims"]]
    if numbers != list(range(1, len(numbers) + 1)):
        raise ValueError(f"Claim numbers must be consecutive integers starting at 1: {numbers}")


def main() -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("draft", type=Path, help="UTF-8 JSON draft")
    parser.add_argument("--output", type=Path, required=True, help="Output DOCX path")
    parser.add_argument(
        "--part",
        choices=("all", "claims", "specification", "abstract", "abstract-figure"),
        default="all",
        help="Document part to render",
    )
    parser.add_argument(
        "--figure-dir",
        type=Path,
        help="Directory containing figure-N.png images for the specification",
    )
    args = parser.parse_args()

    data = json.loads(args.draft.read_text(encoding="utf-8"))
    validate(data)
    document = Document()
    configure(document)
    if args.part == "claims":
        add_claims(document, data["claims"])
    elif args.part == "specification":
        add_specification(document, data, args.figure_dir)
    elif args.part == "abstract":
        add_abstract(document, data, args.figure_dir)
    elif args.part == "abstract-figure":
        add_abstract_figure(document, data, args.figure_dir)
    else:
        add_claims(document, data["claims"])
        document.add_page_break()
        add_specification(document, data, args.figure_dir)
        document.add_page_break()
        add_abstract(document, data, args.figure_dir)
        add_review_appendix(document, data)
    args.output.parent.mkdir(parents=True, exist_ok=True)
    document.save(args.output)
    print(args.output)
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
scripts/validate_patent_draft.py
#!/usr/bin/env python3
"""Validate traceability, completeness, and quality gates in a patent draft."""

import argparse
import json
import re
from dataclasses import dataclass
from pathlib import Path


SOURCE_ID = re.compile(r"^[PEFC]\d{3,}$")
PLACEHOLDER = re.compile(r"\[(?:TO CONFIRM|待确认)[^\]]*\]", re.IGNORECASE)
VAGUE_RESULT = re.compile(r"(技术结果|处理结果|最终结果)")
QUALITY_THRESHOLDS = {
    "evidence_support": 4,
    "claim_architecture": 4,
    "terminology_consistency": 4,
    "enablement_detail": 3,
    "technical_effect_reasoning": 3,
}


@dataclass
class Finding:
    level: str
    code: str
    message: str


def add(findings: list[Finding], level: str, code: str, message: str) -> None:
    findings.append(Finding(level, code, message))


def validate(data: dict) -> list[Finding]:
    findings: list[Finding] = []
    required = (
        "title",
        "metadata",
        "source_analysis",
        "source_map",
        "terminology_ledger",
        "formula_inventory",
        "figure_inventory",
        "evidence_ledger",
        "claims",
        "claim_feature_map",
        "figures",
        "specification",
        "abstract",
        "quality_assessment",
    )
    for key in required:
        if key not in data:
            add(findings, "ERROR", "MISSING_KEY", f"缺少顶层字段:{key}。")

    claims = data.get("claims", [])
    numbers = [claim.get("number") for claim in claims]
    if not claims:
        add(findings, "ERROR", "NO_CLAIMS", "完整草稿必须包含权利要求。")
    elif numbers != list(range(1, len(numbers) + 1)):
        add(findings, "ERROR", "CLAIM_SEQUENCE", f"权利要求编号不连续:{numbers}。")
    for claim in claims:
        text = str(claim.get("text", ""))
        if not text.strip():
            add(findings, "ERROR", "EMPTY_CLAIM", f"权利要求{claim.get('number')}为空。")
        if PLACEHOLDER.search(text):
            add(
                findings,
                "ERROR",
                "CLAIM_PLACEHOLDER",
                f"权利要求{claim.get('number')}仍含待确认标记。",
            )

    source_records = data.get("source_map", [])
    source_ids = set()
    for record in source_records:
        source_id = str(record.get("id", ""))
        if not SOURCE_ID.fullmatch(source_id):
            add(findings, "ERROR", "SOURCE_ID", f"无效来源ID:{source_id!r}。")
        if source_id in source_ids:
            add(findings, "ERROR", "DUPLICATE_SOURCE_ID", f"来源ID重复:{source_id}。")
        source_ids.add(source_id)
        if not record.get("locator"):
            add(findings, "WARNING", "SOURCE_LOCATOR", f"{source_id}缺少页码、章节或行号。")

    canonical_terms = set()
    forbidden_aliases = set()
    for item in data.get("terminology_ledger", []):
        canonical = str(item.get("canonical_zh", "")).strip()
        if not canonical:
            add(findings, "ERROR", "CANONICAL_TERM", "术语表存在空的canonical_zh。")
        elif canonical in canonical_terms:
            add(findings, "ERROR", "DUPLICATE_TERM", f"规范术语重复:{canonical}。")
        canonical_terms.add(canonical)
        forbidden_aliases.update(
            str(alias).strip() for alias in item.get("forbidden_aliases", []) if str(alias).strip()
        )

    ledger_ids = set()
    for item in data.get("evidence_ledger", []):
        ledger_id = str(item.get("id", ""))
        if not ledger_id:
            add(findings, "ERROR", "LEDGER_ID", "证据台账条目缺少ID。")
        elif ledger_id in ledger_ids:
            add(findings, "ERROR", "DUPLICATE_LEDGER_ID", f"证据台账ID重复:{ledger_id}。")
        ledger_ids.add(ledger_id)
        status = item.get("support_status")
        if status not in {"explicit", "inherent", "needs-confirmation", "unsupported"}:
            add(findings, "ERROR", "SUPPORT_STATUS", f"{ledger_id}的支持状态无效:{status}。")
        referenced = item.get("source_ids", [])
        if status in {"explicit", "inherent"} and not referenced:
            add(findings, "ERROR", "MISSING_SOURCE_LINK", f"{ledger_id}没有来源ID。")
        for source_id in referenced:
            if source_ids and source_id not in source_ids:
                add(findings, "ERROR", "UNKNOWN_SOURCE_ID", f"{ledger_id}引用未知来源ID:{source_id}。")

    mapped_claims = set()
    for mapping in data.get("claim_feature_map", []):
        claim_number = mapping.get("claim_number")
        mapped_claims.add(claim_number)
        if claim_number not in numbers:
            add(findings, "ERROR", "UNKNOWN_CLAIM", f"特征映射引用不存在的权利要求:{claim_number}。")
        if not str(mapping.get("feature", "")).strip():
            add(findings, "ERROR", "EMPTY_FEATURE", "权利要求特征映射存在空特征。")
        evidence_ids = mapping.get("evidence_ids", [])
        if not evidence_ids:
            add(
                findings,
                "ERROR",
                "UNMAPPED_FEATURE",
                f"权利要求{claim_number}的特征“{mapping.get('feature', '')}”没有证据ID。",
            )
        for evidence_id in evidence_ids:
            if evidence_id not in ledger_ids:
                add(
                    findings,
                    "ERROR",
                    "UNKNOWN_EVIDENCE_ID",
                    f"权利要求{claim_number}引用未知证据ID:{evidence_id}。",
                )
    for number in numbers:
        if number not in mapped_claims:
            add(findings, "ERROR", "CLAIM_NOT_MAPPED", f"权利要求{number}没有特征证据映射。")
    formal_text = "\n".join(str(claim.get("text", "")) for claim in claims)
    formal_text += "\n" + json.dumps(data.get("specification", {}), ensure_ascii=False)
    for alias in sorted(forbidden_aliases):
        if alias in formal_text:
            add(findings, "ERROR", "FORBIDDEN_ALIAS", f"正式文本使用了禁用别名:{alias}。")

    source_analysis = data.get("source_analysis", {})
    spec = data.get("specification", {})
    equations = spec.get("equations", [])
    formula_inventory = data.get("formula_inventory", [])
    for item in formula_inventory:
        source_id = item.get("source_id")
        if source_ids and source_id not in source_ids:
            add(findings, "ERROR", "FORMULA_INVENTORY_SOURCE", f"公式清单引用未知来源ID:{source_id}。")
        if not item.get("disposition"):
            add(findings, "ERROR", "FORMULA_DISPOSITION", f"来源公式{source_id}缺少处理去向。")
    expected_formula_count = source_analysis.get("formula_count_in_source")
    if isinstance(expected_formula_count, int) and expected_formula_count != len(formula_inventory):
        add(
            findings,
            "WARNING",
            "FORMULA_INVENTORY_COUNT",
            f"来源标记{expected_formula_count}个公式,公式清单记录{len(formula_inventory)}个。",
        )
    if "equations" not in spec:
        add(findings, "ERROR", "EQUATIONS_ARRAY", "说明书必须包含equations数组。")
    if source_analysis.get("contains_core_formulas") and not equations:
        add(findings, "ERROR", "MISSING_CORE_EQUATIONS", "来源包含核心公式,但说明书未收录公式。")
    equation_numbers = [equation.get("number") for equation in equations]
    if equation_numbers and equation_numbers != list(range(1, len(equation_numbers) + 1)):
        add(findings, "ERROR", "EQUATION_SEQUENCE", f"公式编号不连续:{equation_numbers}。")
    for equation in equations:
        number = equation.get("number")
        if not equation.get("latex"):
            add(findings, "ERROR", "EQUATION_LATEX", f"公式{number}缺少可转换的LaTeX源。")
        if not equation.get("source_ids"):
            add(findings, "ERROR", "EQUATION_SOURCE", f"公式{number}缺少来源ID。")
        for source_id in equation.get("source_ids", []):
            if source_ids and source_id not in source_ids:
                add(findings, "ERROR", "EQUATION_SOURCE", f"公式{number}引用未知来源ID:{source_id}。")
        if not equation.get("symbols"):
            add(findings, "ERROR", "EQUATION_SYMBOLS", f"公式{number}缺少结构化符号定义。")
        if not equation.get("technical_role"):
            add(findings, "ERROR", "EQUATION_ROLE", f"公式{number}缺少技术作用说明。")

    figures = data.get("figures", [])
    for item in data.get("figure_inventory", []):
        source_id = item.get("source_id")
        if source_ids and source_id not in source_ids:
            add(findings, "ERROR", "FIGURE_INVENTORY_SOURCE", f"附图清单引用未知来源ID:{source_id}。")
        if not item.get("disposition"):
            add(findings, "ERROR", "FIGURE_DISPOSITION", f"来源附图{source_id}缺少处理去向。")
    figure_numbers = [figure.get("number") for figure in figures]
    if not figures:
        add(findings, "ERROR", "NO_FIGURES", "完整草稿必须包含至少一幅专利附图。")
    elif figure_numbers != list(range(1, len(figure_numbers) + 1)):
        add(findings, "ERROR", "FIGURE_SEQUENCE", f"附图编号不连续:{figure_numbers}。")
    abstract_figure = data.get("abstract_figure_number")
    if abstract_figure not in figure_numbers:
        add(findings, "ERROR", "ABSTRACT_FIGURE", "摘要附图编号未指向现有附图。")
    for figure in figures:
        if not figure.get("source_ids"):
            add(findings, "WARNING", "FIGURE_SOURCE", f"图{figure.get('number')}缺少来源ID或重绘依据。")
        for source_id in figure.get("source_ids", []):
            if source_ids and source_id not in source_ids:
                add(
                    findings,
                    "ERROR",
                    "FIGURE_SOURCE",
                    f"图{figure.get('number')}引用未知来源ID:{source_id}。",
                )
        end_nodes = set(str(node.get("id")) for node in figure.get("nodes", []))
        for edge in figure.get("edges", []):
            end_nodes.discard(str(edge.get("from")))
        for node in figure.get("nodes", []):
            if str(node.get("id")) in end_nodes and VAGUE_RESULT.search(str(node.get("label", ""))):
                add(
                    findings,
                    "ERROR",
                    "VAGUE_FINAL_RESULT",
                    f"图{figure.get('number')}末端节点使用了模糊结果名称。",
                )

    for field in ("technical_field", "background", "embodiments", "figure_descriptions"):
        if not spec.get(field):
            add(findings, "ERROR", "SPEC_SECTION", f"说明书缺少或清空了字段:{field}。")
    invention = spec.get("invention_content", {})
    for field in ("problem", "solution", "beneficial_effects"):
        if not invention.get(field):
            add(findings, "ERROR", "INVENTION_CONTENT", f"发明内容缺少:{field}。")

    abstract = re.sub(r"\s+", "", str(data.get("abstract", "")))
    if not abstract:
        add(findings, "ERROR", "EMPTY_ABSTRACT", "说明书摘要为空。")
    elif len(abstract) > 300:
        add(findings, "WARNING", "ABSTRACT_LENGTH", f"摘要约{len(abstract)}字,建议人工核对篇幅。")

    quality = data.get("quality_assessment", {})
    if quality.get("status") not in {"review-draft", "incomplete-draft"}:
        add(
            findings,
            "WARNING",
            "DRAFT_STATUS",
            "quality_assessment.status建议使用review-draft或incomplete-draft。",
        )
    scores = quality.get("scores", {})
    for dimension, threshold in QUALITY_THRESHOLDS.items():
        item = scores.get(dimension)
        if not isinstance(item, dict) or not isinstance(item.get("score"), int):
            add(findings, "ERROR", "QUALITY_SCORE", f"缺少质量评分:{dimension}。")
            continue
        score = item["score"]
        if score < 1 or score > 5:
            add(findings, "ERROR", "QUALITY_RANGE", f"{dimension}评分超出1-5:{score}。")
        elif score < threshold:
            add(
                findings,
                "ERROR",
                "QUALITY_THRESHOLD",
                f"{dimension}评分{score},低于交付阈值{threshold}。",
            )
        if not str(item.get("evidence", "")).strip():
            add(findings, "WARNING", "QUALITY_EVIDENCE", f"{dimension}评分缺少依据。")

    if source_analysis.get("contains_core_formulas"):
        formula_item = scores.get("formula_coverage", {})
        if formula_item.get("score", 0) < 4:
            add(findings, "ERROR", "FORMULA_SCORE", "存在核心公式时,formula_coverage必须至少为4。")
    if figures:
        figure_item = scores.get("figure_alignment", {})
        if figure_item.get("score", 0) < 4:
            add(findings, "ERROR", "FIGURE_SCORE", "存在附图时,figure_alignment必须至少为4。")

    return findings


def format_report(findings: list[Finding]) -> str:
    if not findings:
        return "PASS: 草稿通过结构、溯源和质量门槛检查。\n"
    lines = [f"{item.level}\t{item.code}\t{item.message}" for item in findings]
    errors = sum(item.level == "ERROR" for item in findings)
    warnings = sum(item.level == "WARNING" for item in findings)
    lines.extend(("", f"汇总: {errors} 个错误, {warnings} 个警告"))
    return "\n".join(lines) + "\n"


def main() -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("draft", type=Path, help="UTF-8 structured patent draft JSON")
    parser.add_argument("--report", type=Path, help="Write the validation report to a file")
    parser.add_argument("--json", action="store_true", help="Print findings as JSON")
    args = parser.parse_args()

    data = json.loads(args.draft.read_text(encoding="utf-8"))
    findings = validate(data)
    report = format_report(findings)
    if args.report:
        args.report.parent.mkdir(parents=True, exist_ok=True)
        args.report.write_text(report, encoding="utf-8")
    if args.json:
        print(json.dumps([item.__dict__ for item in findings], ensure_ascii=False, indent=2))
    else:
        print(report, end="")
    return 1 if any(item.level == "ERROR" for item in findings) else 0


if __name__ == "__main__":
    raise SystemExit(main())
SKILL.md
---
name: nature-paper-to-patent
description: Convert scientific papers, theses, technical reports, source code, figures, inventor notes, or research manuscripts into evidence-grounded Chinese invention patent drafts and attorney-facing technical disclosure materials. Use when an AI agent must mine patent points, draft or revise a Chinese technical disclosure (技术交底书), run prior-art comparison, convert Office project materials, map every claimed feature to source evidence, preserve core formulas as editable Office Math, generate claim-aligned flowcharts and methodology figures, compare a paper with an existing patent, audit support and consistency, or deliver Chinese DOCX patent/disclosure files.
---

# Paper to Chinese Patent

Use this file as the router for the patent-drafting workflow. Do not draft the
application directly from the paper abstract or contribution list.

## 1. Load the workflow

Read `manifest.yaml`, then read every file under `always_load`.

Detect these axes from the user's files and request:

- `source_format`: selectable PDF, scanned PDF, pasted text, or mixed project;
- `task_mode`: full draft, claim set, disclosure analysis, technical
  disclosure, disclosure iteration, or paper-patent audit;
- `invention_type`: algorithm/software, apparatus/system, process/material, or mixed.

State the detected values in one short line. Load only the matching fragments
declared in the manifest. Load detailed references only when their condition
applies.

## 2. Preserve source grounding

Create stable source IDs before drafting:

- `P001...` for paper text blocks;
- `E001...` for equations;
- `F001...` for source figures;
- `C001...` for source-code or supplementary evidence.

Every material feature in a formal claim must map to one or more source IDs.
Use only `explicit`, `inherent`, `needs-confirmation`, or `unsupported` as
support states. Exclude `unsupported` features from formal claims.

Never infer inventorship, ownership, unpublished implementation details,
publication dates, prior-art conclusions, or legal sufficiency. Use
`[TO CONFIRM: specific question]` outside formal claims when facts are missing.

## 3. Draft through stage gates

For `full-draft`, `claim-set`, `disclosure-analysis`, and `paper-patent-audit`,
complete the stages in `static/core/workflow.md` in order. Persist the
intermediate artifacts specified there. Do not move to formal claims until the
source map, terminology ledger, inventories, evidence ledger, and invention
concept pass their gates.

For `technical-disclosure`, follow the ordered prompt references in
`static/fragments/task/technical-disclosure.md`. For `disclosure-iteration`,
follow `static/fragments/task/disclosure-iteration.md` and preserve the prior
draft instead of restarting the formal application workflow.

For a full application, draft claims first, then align the specification,
figures, embodiments, and abstract to the claim terminology and step order.

## 4. Produce Chinese formal documents

Agent-facing analysis may use the user's preferred language. Produce formal
Chinese patent deliverables in Chinese when the task is a formal application
package:

- 权利要求书;
- 说明书;
- 说明书摘要;
- 摘要附图;
- figure labels and descriptions.

For `technical-disclosure` and `disclosure-iteration`, produce the Chinese
technical disclosure (`技术交底书`) as timestamped Markdown plus matching DOCX,
with Mermaid system/process diagrams rendered through `scripts/disclosure/`.

For algorithmic inventions, retain source-supported core formulas, define every
symbol, explain each formula's technical operation, and render formulas as
native editable Office Math in DOCX. Do not use plain LaTeX strings as the
visible formula.

Generate the main flowchart from the ordered steps of the principal method
claim. Its final node must name the concrete domain output, such as a defect
detection result, target pose, state estimate, or control instruction. Reuse
the same main figure as the abstract figure and a specification figure.

## 5. Validate before delivery

For formal application packages, populate the structured draft described in
`references/draft-schema.md`, then run:

```bash
python scripts/validate_patent_draft.py draft.json
python scripts/build_patent_package.py draft.json --output-dir outputs --prefix patent
```

Resolve all validation `ERROR` findings. Review every `WARNING` against the
source. Label the result `incomplete draft` when a required quality threshold
in `static/core/output-contract.md` is not met.

For technical disclosures, run the internal checks in
`references/disclosure/disclosure_self_check.md`, render Mermaid/Word outputs
with `scripts/disclosure/mermaid_render.py`, and resolve formula, parameter,
prior-art URL, and chapter-consistency issues before delivery.

The generated package is a drafting aid for inventor and patent-professional
review, not a patentability opinion, infringement opinion, or filing guarantee.
static/core/output-contract.md
# Output Contract

## Full package

A full-draft job must produce:

- `权利要求书.docx`;
- `说明书.docx`;
- `说明书摘要.docx`;
- `摘要附图.docx`;
- `完整审阅稿.docx`;
- `结构化草稿.json`;
- `权利要求检查.txt`;
- `草稿验证报告.txt`;
- SVG and PNG files for every generated patent figure.

The structured draft is the source of truth. DOCX files are rendered outputs.

## Required traceability

- Every material claim feature maps to at least one source ID.
- Every source-supported core equation has a recorded disposition.
- Every formal term uses the terminology ledger's canonical Chinese form.
- Every numbered claim step maps to one main-flowchart node and one embodiment
  explanation.
- Every methodology figure is source-supported or explicitly identified as a
  redrawing of supported operations.

## Formal-document rules

- Use Chinese for claims, specification, abstract, and figure labels.
- Do not place source IDs, support labels, drafting notes, or quality scores in
  formal claims.
- Render formal equations as editable Office Math.
- Use a concrete final method output; do not use “技术结果”“处理结果” or
  “最终结果”.
- Keep the abstract concise and free of promotional or unsupported promises.

## Quality thresholds

Score each dimension from 1 to 5 and record one sentence of evidence:

- evidence support: at least 4;
- claim architecture: at least 4;
- terminology and dependency consistency: at least 4;
- enablement detail: at least 3;
- technical-effect reasoning: at least 3;
- formula coverage: at least 4 when core formulas exist;
- figure alignment: at least 4 when figures are required.

Any validation `ERROR`, an unmapped material claim feature, or a missing core
formula forces the status `incomplete draft`.

## Delivery note

State that the package requires inventor confirmation and qualified Chinese
patent-professional review. Do not describe it as filing-ready merely because
the automated checks pass.
static/core/principles.md
# Core Principles

1. Treat the paper as technical evidence, not as claim language.
2. Build a full source map before selecting the invention concept.
3. Keep claims no broader than the disclosure can support.
4. Separate essential features from examples, parameters, datasets, and
   preferred implementations.
5. Tie each technical effect to identified cooperating technical means.
6. Keep terminology, symbols, claim steps, figures, and embodiments aligned.
7. Preserve uncertainty. Mark gaps rather than filling them with plausible
   inventions.
8. Distinguish drafting quality from patentability. Do not claim novelty,
   inventiveness, freedom to operate, or filing readiness without professional
   review.
9. Keep formal application text clean. Put source IDs, support states, scores,
   and inventor questions in the structured draft and review appendix.
10. Prefer reproducible artifacts: structured JSON, deterministic figures,
    editable equations, validation reports, and separate DOCX deliverables.
static/core/workflow.md
# Patent Generation Workflow

Run all applicable stages. A later stage must not silently repair missing
evidence from an earlier stage.

## Stage 0 - Intake and risk flags

Record the source files, requested deliverables, target jurisdiction,
publication status, known disclosure dates, and missing inventor facts.

Output: `work/00-intake.json`.

Gate: every input file is identified; unknown publication, inventorship, and
ownership facts are explicitly marked.

## Stage 1 - Source map

Extract the entire substantive disclosure, not only the abstract. Assign stable
IDs to text blocks, equations, figures, and supplementary/code evidence. Keep
page, section, caption, file, or line locators.

Output: `work/01-source-map.json`.

Gate: the method, implementation, experiments, limitations, formulas, and
methodology figures have all been inspected or marked unavailable.

## Stage 2 - Technical inventories

Create:

- a terminology ledger with one canonical Chinese term per object;
- an input-operation-output map;
- a formula inventory with symbols and technical role;
- a figure inventory distinguishing methodology figures from result charts;
- an implementation-gap list.

Output: `work/02-technical-inventory.json`.

Gate: every core operation has an identified input and output; every core
formula and source methodology figure has a disposition.

## Stage 3 - Evidence ledger

Convert candidate features into an evidence ledger. Assign support state,
source IDs, technical role, effect, and proposed destination.

Output: `work/03-evidence-ledger.json`.

Gate: no candidate essential feature is `unsupported`; every
`needs-confirmation` feature becomes an inventor question or is removed.

## Stage 4 - Invention concept and claim strategy

Write the one-sentence concept:

`technical problem -> cooperating technical means -> specific technical output/effect`

Select the principal protected object, essential feature chain, fallback
positions, and technically appropriate claim categories. Avoid automatically
adding device, medium, system, or use claims.

Output: `work/04-claim-strategy.json`.

Gate: each essential feature has evidence; the principal claim forms a closed
input-operation-output chain.

## Stage 5 - Claims

Draft the principal independent claim first. Draft dependent claims in the same
technical order, then add other supported categories. Create a claim-feature
map from every material limitation to evidence IDs.

Output:

- `work/05-claims.txt`;
- `work/05-claim-map.json`.

Gate: `scripts/audit_claims.py` has no `ERROR`; every formal claim has mapped
source support; no `[TO CONFIRM]` marker remains in formal claims.

## Stage 6 - Specification and figures

Draft the specification around the claims while adding enough implementation
detail from the source. Include core formulas, symbol definitions, alternatives,
figure descriptions, and embodiments. Generate the main claim-aligned
flowchart and supported methodology figures.

Output: `work/06-draft.json`.

Gate: every claimed term appears in the specification; every claim step is
explained; every figure is referenced; every core formula has editable-math
source and symbol definitions.

## Stage 7 - Abstract and package

Draft the abstract last. Keep terminology aligned with the principal claim.
Use the same main figure as the abstract figure and specification figure.

Output: separate Chinese DOCX files, SVG/PNG figures, structured JSON, and
audit reports under `outputs/`.

Gate: `scripts/validate_patent_draft.py` and
`scripts/build_patent_package.py` complete without errors.

## Stage 8 - Final review

Score evidence support, claim architecture, consistency, enablement,
technical-effect reasoning, formula coverage, and figure alignment. List
unresolved inventor questions and publication risks.

Gate: meet the thresholds in `static/core/output-contract.md`; otherwise label
the package `incomplete draft`.
static/fragments/invention/algorithm-software.md
# Algorithm and Software Inventions

Anchor claims in technical data acquisition, transformations, model or
algorithm operations, and a specific technical-domain output or control
action. Preserve essential formulas and data dependencies. Keep dataset names,
benchmark values, model brands, and exact layer counts in embodiments or
narrower claims unless essential.
static/fragments/invention/apparatus-system.md
# Apparatus and System Inventions

Identify physical components, connections, spatial relationships, signal or
material paths, operating states, and cooperation. Use reference numerals only
after the structure is stable. Do not replace structural disclosure with
functional module names.
static/fragments/invention/mixed.md
# Mixed Inventions

Select one principal protected object and use other categories as supported
fallbacks. Keep the same technical relationships across method, apparatus,
system, product, and medium claims. Do not duplicate claim categories
mechanically.
static/fragments/invention/process-material.md
# Process and Material Inventions

Identify raw materials, sequence, conditions, ranges, intermediate states,
product characteristics, and measurement methods. Preserve disclosed range
boundaries and units. Do not generalize a single example into an unsupported
genus or range.
static/fragments/source/mixed-project.md
# Mixed Project

Inventory every paper, supplement, code directory, inventor note, source
figure, and existing patent. Record provenance separately. Source code may
support implementation detail but does not automatically establish
inventorship or publication status. When paper and patent filenames share a
number, verify technical correspondence from their contents.
static/fragments/source/pasted-text.md
# Pasted Text

Treat the input as incomplete unless the user confirms it contains the full
technical disclosure. Create paragraph-level source IDs. Ask for figures,
formulas, implementation details, and experimental context only when their
absence blocks the requested deliverable; otherwise continue in draft mode.
static/fragments/source/pdf-text.md
# Selectable PDF

Run `scripts/extract_pdf_text.py` and preserve page markers. Check extraction
coverage across the first, middle, and final pages. Inspect equations and
figures against the original PDF because text extraction may lose notation,
subscripts, superscripts, or reading order.
static/fragments/source/scanned-pdf.md
# Scanned PDF

Do not treat empty extraction as an empty disclosure. Perform OCR with page
coordinates when available. Mark low-confidence symbols, equations, tables,
and captions. Require manual confirmation for any uncertain content that would
materially limit a claim.
static/fragments/task/claim-set.md
# Claim Set

Run Stages 0-5 and the claim-related portions of Stage 8. Return the invention
concept, evidence ledger, claims, claim-feature map, audit report, and inventor
questions. Do not imply that omitted specification support has been checked.
static/fragments/task/disclosure-analysis.md
# Disclosure Analysis

Run Stages 0-4. Identify protectable technical chains, source-supported
fallback positions, implementation gaps, formulas, figures, and inventor
questions. Do not generate polished formal claims unless requested.
static/fragments/task/disclosure-iteration.md
# Disclosure Iteration

Use this mode when the user continues from an existing Chinese technical
disclosure or a previous disclosure draft: adding materials, revising a
chapter, correcting facts, changing formula/parameter wording, or adjusting
the desired protection focus.

First read `references/disclosure/iteration_context.md`. Then choose:

- `references/disclosure/merger.md` for new materials, expansion, or
  claim-style strengthening of Chapter 5 based on the current draft;
- `references/disclosure/correction_handler.md` for factual, technical,
  formula, style, or protection-point corrections.

Do not restart patent-point mining merely because the artifact is a patent
disclosure. Preserve the previous version, write a new timestamped `.md` and
matching `.docx`, and append the revision log described in
`iteration_context.md` using `scripts/disclosure/iteration_dialog_log.py` or
the same structure manually.

After delivery, include the required merge or correction summary in the chat.
static/fragments/task/full-draft.md
# Full Draft

Run Stages 0-8. Produce the complete output package. Draft claims before the
specification and abstract. Keep internal evidence and audit material in the
structured draft and combined review appendix, not in formal application text.
static/fragments/task/paper-patent-audit.md
# Paper-Patent Audit

Build independent source maps for the paper and patent. Compare features using
`same`, `narrower-in-patent`, `broader-in-patent`, `renamed`, `patent-only`, or
`paper-only`. Cite both locations. Do not infer that patent-only matter is
supported by the paper.
static/fragments/task/technical-disclosure.md
# Technical Disclosure

Use this mode for Chinese patent mining and technical disclosure drafts
(`技术交底书`), especially when the user asks for patent points, disclosure
materials for a patent attorney, prior-art comparison, or a Markdown/DOCX
disclosure rather than a full formal application package.

Load and follow these references in order:

1. `references/disclosure/intake.md` for minimal boundary questions;
2. `references/disclosure/project_scan.md` before scanning projects, code,
   `.docx`, or `.pptx` materials;
3. `references/disclosure/patent_points_analyzer.md` to form candidate
   patent points and select the best disclosure direction;
4. `references/disclosure/prior_art_search.md` before writing prior-art
   discussion;
5. `references/disclosure/disclosure_preview.md` unless the user explicitly
   skips preview;
6. `references/disclosure/disclosure_builder.md` and
   `references/disclosure/template_reference.md` for the final disclosure;
7. `references/disclosure/disclosure_self_check.md` for internal QA before
   delivery.

Use scripts under `scripts/disclosure/` for Office conversion, CNIPA
published-patent search, Mermaid rendering, Markdown-to-DOCX conversion,
formula rendering, and revision logs. Read `references/disclosure/tooling.md`
when command details or dependency setup are needed.

Deliver final technical disclosures as both `.md` and `.docx` using the
case-name plus timestamp naming rule from `disclosure_builder.md`. Do not put
self-check tables, source IDs, skill metadata, or repository notes into the
delivered disclosure body.
tests/test_validation.py
import importlib.util
import json
import sys
import tempfile
import unittest
from pathlib import Path


ROOT = Path(__file__).resolve().parents[1]
SPEC = importlib.util.spec_from_file_location(
    "validate_patent_draft", ROOT / "scripts" / "validate_patent_draft.py"
)
VALIDATOR = importlib.util.module_from_spec(SPEC)
sys.modules[SPEC.name] = VALIDATOR
SPEC.loader.exec_module(VALIDATOR)


def valid_draft():
    return {
        "title": "一种工业图像缺陷检测方法",
        "metadata": {"draft_status": "供复核"},
        "source_analysis": {
            "contains_core_formulas": True,
            "contains_methodology_figures": False,
        },
        "source_map": [
            {"id": "P001", "type": "paper-text", "locator": "第3页", "summary": "流程"},
            {"id": "E001", "type": "equation", "locator": "第4页公式1", "summary": "融合"},
        ],
        "terminology_ledger": [
            {
                "concept": "融合特征",
                "canonical_zh": "融合特征",
                "source_terms": ["fused feature"],
                "forbidden_aliases": [],
            }
        ],
        "formula_inventory": [
            {
                "source_id": "E001",
                "source_number": "(1)",
                "technical_role": "融合不同尺度特征",
                "disposition": "specification-equation-1",
            }
        ],
        "figure_inventory": [
            {
                "source_id": "P001",
                "source_number": "方法章节",
                "type": "flowchart",
                "disposition": "redraw-as-figure-1",
            }
        ],
        "evidence_ledger": [
            {
                "id": "EV1",
                "feature": "多尺度特征融合",
                "source_ids": ["P001", "E001"],
                "support_status": "explicit",
            }
        ],
        "claims": [
            {
                "number": 1,
                "text": "一种工业图像缺陷检测方法,其特征在于,包括:S1,获取工业图像;S2,对工业图像进行多尺度特征融合;S3,根据融合特征输出缺陷检测结果。",
            }
        ],
        "claim_feature_map": [
            {"claim_number": 1, "feature": "多尺度特征融合", "evidence_ids": ["EV1"]}
        ],
        "abstract_figure_number": 1,
        "figures": [
            {
                "number": 1,
                "title": "方法流程图",
                "type": "flowchart",
                "orientation": "vertical",
                "claim_number": 1,
                "complete_claim_flow": True,
                "source_ids": ["P001"],
                "nodes": [
                    {"id": "S1", "label": "S1:获取工业图像", "claim_step": "S1"},
                    {
                        "id": "S2",
                        "label": "S2:进行多尺度特征融合",
                        "claim_step": "S2",
                    },
                    {
                        "id": "S3",
                        "label": "S3:输出缺陷检测结果",
                        "claim_step": "S3",
                    },
                ],
                "edges": [{"from": "S1", "to": "S2"}, {"from": "S2", "to": "S3"}],
            }
        ],
        "specification": {
            "technical_field": ["本发明涉及工业视觉检测领域。"],
            "background": ["现有方法对小缺陷表征不足。"],
            "invention_content": {
                "problem": ["提高小缺陷检测能力。"],
                "solution": ["采用多尺度特征融合。"],
                "beneficial_effects": ["保留不同尺度的缺陷信息。"],
            },
            "figure_descriptions": ["图1为方法流程图。"],
            "equations": [
                {
                    "number": 1,
                    "source_ids": ["E001"],
                    "latex": "F=F_1+F_2",
                    "expression": "F=F1+F2",
                    "symbols": [
                        {"symbol": "F", "meaning": "融合特征"},
                        {"symbol": "F_1", "meaning": "第一尺度特征"},
                    ],
                    "technical_role": "融合不同尺度特征",
                    "description": "其中,F表示融合特征,F1和F2表示不同尺度特征。",
                }
            ],
            "embodiments": [{"heading": "实施例1", "paragraphs": ["执行上述步骤。"]}],
        },
        "abstract": "本发明公开一种工业图像缺陷检测方法,通过多尺度特征融合输出缺陷检测结果。",
        "quality_assessment": {
            "status": "review-draft",
            "scores": {
                "evidence_support": {"score": 4, "evidence": "特征均有来源。"},
                "claim_architecture": {"score": 4, "evidence": "技术链闭合。"},
                "terminology_consistency": {"score": 4, "evidence": "术语一致。"},
                "enablement_detail": {"score": 3, "evidence": "已说明主要步骤。"},
                "technical_effect_reasoning": {"score": 3, "evidence": "效果关联手段。"},
                "formula_coverage": {"score": 4, "evidence": "核心公式已收录。"},
                "figure_alignment": {"score": 4, "evidence": "附图与步骤一致。"},
            }
        },
    }


class DraftValidationTests(unittest.TestCase):
    def test_valid_draft_passes(self):
        self.assertEqual([], VALIDATOR.validate(valid_draft()))

    def test_unmapped_claim_fails(self):
        draft = valid_draft()
        draft["claim_feature_map"] = []
        codes = {item.code for item in VALIDATOR.validate(draft)}
        self.assertIn("CLAIM_NOT_MAPPED", codes)

    def test_missing_core_equation_fails(self):
        draft = valid_draft()
        draft["specification"]["equations"] = []
        codes = {item.code for item in VALIDATOR.validate(draft)}
        self.assertIn("MISSING_CORE_EQUATIONS", codes)


if __name__ == "__main__":
    unittest.main()
nature-paper-to-patent · Trending Agent Skills | Mengbi